Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion packages/cli/src/bin/linkedin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8741,6 +8741,41 @@ async function runNotificationsPreferencesPrepareUpdate(
}
}

async function runGroupsList(
input: {
profileName: string;
limit: number;
},
cdpUrl?: string,
): Promise<void> {
const runtime = createRuntime(cdpUrl);

try {
runtime.logger.log("info", "cli.groups.list.start", {
profileName: input.profileName,
limit: input.limit,
});

const result = await runtime.groups.listGroups({
profileName: input.profileName,
limit: input.limit,
});

runtime.logger.log("info", "cli.groups.list.done", {
profileName: input.profileName,
count: result.count,
});

printJson({
run_id: runtime.runId,
profile_name: input.profileName,
...result,
});
} finally {
runtime.close();
}
}

async function runGroupsSearch(
input: {
profileName: string;
Expand Down Expand Up @@ -12341,6 +12376,23 @@ export function createCliProgram(): Command {
"Search, view, join, leave, and post in LinkedIn groups",
);

groupsCommand
.command("list")
.description("List LinkedIn groups you are a member of")
.option("-l, --limit <limit>", "Maximum number of groups to return", "10")
.option("-p, --profile <profile>", "Profile name", "default")
.action(
async (options: { profile: string; limit: string }) => {
await runGroupsList(
{
profileName: options.profile,
limit: parseInt(options.limit, 10)
},
readCdpUrl()
);
}
);

groupsCommand
.command("search")
.description("Search LinkedIn groups by keyword")
Expand Down Expand Up @@ -12387,11 +12439,13 @@ export function createCliProgram(): Command {
.option("-i, --industry <industry>", "Group industry")
.option("-l, --location <location>", "Group location")
.option("--unlisted", "Make the group unlisted")
.option("--logo-path <path>", "Path to logo image")
.option("--cover-image-path <path>", "Path to cover image")
.option("-p, --profile <profile>", "Profile name", "default")
.option("-o, --operator-note <note>", "Optional operator note")
.action(
async (
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string },
options: { profile: string; name: string; description: string; rules?: string; industry?: string; location?: string; unlisted?: boolean; operatorNote?: string; logoPath?: string; coverImagePath?: string },
) => {
await runGroupsPrepareCreate(
{
Expand All @@ -12402,6 +12456,8 @@ export function createCliProgram(): Command {
...(options.industry ? { industry: options.industry } : {}),
...(options.location ? { location: options.location } : {}),
...(options.unlisted ? { isUnlisted: options.unlisted } : {}),
...(options.logoPath ? { logoPath: options.logoPath } : {}),
...(options.coverImagePath ? { coverImagePath: options.coverImagePath } : {}),
...(options.operatorNote
? { operatorNote: options.operatorNote }
: {}),
Expand Down
60 changes: 57 additions & 3 deletions packages/core/src/__tests__/e2e/groups.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,30 @@ import {
describe("Groups E2E", () => {
const e2e = setupE2ESuite();

it("searchGroups returns results with populated fields", async (context) => {
it("listGroups returns current groups", async (context) => {
skipIfE2EUnavailable(e2e, context);
const runtime = e2e.runtime();
const result = await runtime.groups.listGroups({
limit: 5,
});

expect(result.count).toBeGreaterThanOrEqual(0);
if (result.count > 0) {
expect(result.results.length).toBeGreaterThan(0);
const first = result.results[0]!;
expect(first.name.length).toBeGreaterThan(0);
expect(first.group_url).toContain("/groups/");
expect(first.group_id.length).toBeGreaterThan(0);
expect(typeof first.member_count).toBe("string");
expect(typeof first.visibility).toBe("string");
expect(typeof first.description).toBe("string");
expect(["member", "joinable", "pending", "unknown"]).toContain(
first.membership_state,
);
}
});

it("searchGroups returns results with populated fields", async (context) => {
skipIfE2EUnavailable(e2e, context);
const runtime = e2e.runtime();
const result = await runtime.groups.searchGroups({
Expand Down Expand Up @@ -124,7 +147,25 @@ describe("Groups E2E", () => {
.toThrow(/text is required/i);
});

it("MCP groups.search returns results", async (context) => {
it("MCP groups.list returns results", async (context) => {
skipIfE2EUnavailable(e2e, context);
const result = await callMcpTool(MCP_TOOL_NAMES.groupsList, {
limit: 5,
});

expect(result.isError).toBe(false);
expect(result.payload).toHaveProperty("count");
expect(Number(result.payload.count)).toBeGreaterThanOrEqual(0);
const results = result.payload.results;
expect(Array.isArray(results)).toBe(true);
if (Array.isArray(results) && results.length > 0) {
const firstResult = (results as Record<string, unknown>[])[0]!;
expect(typeof firstResult.name).toBe("string");
expect(typeof firstResult.group_url).toBe("string");
}
});

it("MCP groups.search returns results", async (context) => {
skipIfE2EUnavailable(e2e, context);
const result = await callMcpTool(MCP_TOOL_NAMES.groupsSearch, {
query: "software engineering",
Expand Down Expand Up @@ -191,7 +232,20 @@ describe("Groups E2E", () => {
expect(typeof result.payload.confirmToken).toBe("string");
});

it("CLI groups search returns results", async (context) => {
it("CLI groups list returns results", async (context) => {
skipIfE2EUnavailable(e2e, context);
const result = await runCliCommand(
["groups", "list", "--limit", "5"],
{ timeoutMs: 30_000 },
);

expect(result.exitCode).toBe(0);
const output = getLastJsonObject(result.stdout);
expect(Number(output.count)).toBeGreaterThanOrEqual(0);
expect(Array.isArray(output.results)).toBe(true);
});

it("CLI groups search returns results", async (context) => {
skipIfE2EUnavailable(e2e, context);
const result = await runCliCommand(
["groups", "search", "--query", "software engineering", "--limit", "5"],
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/__tests__/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
LINKEDIN_GROUPS_PREPARE_POST_TOOL,
LINKEDIN_GROUPS_LIST_TOOL,
LINKEDIN_GROUPS_SEARCH_TOOL,
LINKEDIN_GROUPS_VIEW_TOOL,
LINKEDIN_JOBS_ALERTS_CREATE_TOOL,
Expand Down Expand Up @@ -1026,6 +1027,7 @@ export const MCP_TOOL_NAMES = {
groupsPrepareJoin: LINKEDIN_GROUPS_PREPARE_JOIN_TOOL,
groupsPrepareLeave: LINKEDIN_GROUPS_PREPARE_LEAVE_TOOL,
groupsPreparePost: LINKEDIN_GROUPS_PREPARE_POST_TOOL,
groupsList: LINKEDIN_GROUPS_LIST_TOOL,
groupsSearch: LINKEDIN_GROUPS_SEARCH_TOOL,
groupsView: LINKEDIN_GROUPS_VIEW_TOOL,
submitFeedback: SUBMIT_FEEDBACK_TOOL,
Expand Down
124 changes: 123 additions & 1 deletion packages/core/src/linkedinGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,21 @@ export interface CreateGroupInput {
industry?: string;
location?: string;
isUnlisted?: boolean;
logoPath?: string;
coverImagePath?: string;
operatorNote?: string;
}

export interface ListGroupsInput {
profileName?: string;
limit?: number;
}

export interface ListGroupsOutput {
results: LinkedInGroupsSearchResult[];
count: number;
}

export interface SearchGroupsInput {
profileName?: string;
query: string;
Expand Down Expand Up @@ -919,7 +931,7 @@ export class CreateGroupActionExecutor

const group = await runtime.profileManager.runWithPersistentContext(
data.profileName ?? "default",
{ headless: false },
{ headless: true },
async (context) => {
const page = await context.newPage();
try {
Expand All @@ -943,6 +955,47 @@ export class CreateGroupActionExecutor
await page.locator("label[for='unlisted-group']").click();
}

if (data.industry) {
const industryLocator = page.locator("input[placeholder*='Industry']").or(page.getByLabel(/Industry/i)).first();
await industryLocator.fill(data.industry);
await page.waitForTimeout(1000);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
}

if (data.location) {
const locationLocator = page.locator("input[placeholder*='Location']").or(page.getByLabel(/Location/i)).first();
await locationLocator.fill(data.location);
await page.waitForTimeout(1000);
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
}

if (data.logoPath) {
const fileChooserPromise = page.waitForEvent('filechooser');
const logoButton = page.locator("button[aria-label*='logo']").or(page.getByRole("button", { name: /logo/i })).first();
if (await logoButton.isVisible().catch(() => false)) {
await logoButton.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(data.logoPath);
await page.waitForTimeout(1000);
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
}
}

if (data.coverImagePath) {
const fileChooserPromise = page.waitForEvent('filechooser');
const coverButton = page.locator("button[aria-label*='cover image']").or(page.getByRole("button", { name: /cover image/i })).first();
if (await coverButton.isVisible().catch(() => false)) {
await coverButton.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(data.coverImagePath);
await page.waitForTimeout(1000);
await page.getByRole("button", { name: /Apply|Save/i }).first().click().catch(() => {});
}
}


await page.locator("button[type='submit']").click();

// Wait for redirect to new group page
Expand Down Expand Up @@ -1071,6 +1124,8 @@ export class LinkedInGroupsService {
industry: input.industry,
location: input.location,
isUnlisted: input.isUnlisted,
logoPath: input.logoPath,
coverImagePath: input.coverImagePath,
},
preview: {
summary: `Create LinkedIn group "${input.name}"`,
Expand All @@ -1087,6 +1142,73 @@ export class LinkedInGroupsService {
});
}


async listGroups(input: ListGroupsInput): Promise<ListGroupsOutput> {
const profileName = input.profileName ?? "default";
const limit = readSearchLimit(input.limit);

await this.runtime.auth.ensureAuthenticated({
profileName
});

try {
const snapshots = await this.runtime.profileManager.runWithPersistentContext(
profileName,
{ headless: true },
async (context) => {
const page = await getOrCreatePage(context);
await page.goto("https://www.linkedin.com/groups/", {
waitUntil: "domcontentloaded"
});
await waitForNetworkIdleBestEffort(page);

const selectors = [
".groups-list",
"ul.groups-list",
".scaffold-layout__main",
"main"
];
for (const selector of selectors) {
try {
await page.locator(selector).first().waitFor({
state: "visible",
timeout: 5_000
});
break;
} catch {
// Try next
}
}
return scrollSearchResultsIfNeeded(page, extractGroupSearchResults, limit);
}
);

const results = snapshots
.map((snapshot) => ({
group_id: normalizeText(snapshot.group_id),
name: normalizeText(snapshot.name),
group_url: normalizeText(snapshot.group_url),
visibility: normalizeText(snapshot.visibility),
member_count: normalizeText(snapshot.member_count),
description: normalizeText(snapshot.description),
membership_state: snapshot.membership_state
}))
.filter((result) => result.name.length > 0 || result.group_url.length > 0)
.slice(0, limit);

return {
results,
count: results.length
};
} catch (error) {
throw asLinkedInBuddyError(
error,
"UNKNOWN",
"Failed to list LinkedIn groups."
);
}
}

async searchGroups(input: SearchGroupsInput): Promise<SearchGroupsOutput> {
const profileName = input.profileName ?? "default";
const query = normalizeText(input.query);
Expand Down
Loading