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
44 changes: 18 additions & 26 deletions .opencode/todo.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,22 @@
# Mission Tasks for Issue #609 - Newsletters & Articles: make it production-grade
# Mission: Notifications: make it production-grade (#613)

## Phase 1: Support Rich Text and Images in Articles
- [ ] Investigate how LinkedIn rich text editor works (contenteditable vs specific DOM structure)
- [ ] Add support for cover image URL in `PrepareCreateArticleInput` and `PrepareCreateNewsletterInput`
- [ ] Add support for HTML/Markdown body parsing and inserting rich text
## M1: Analysis & Infrastructure
### T1.1: Identify Notification Types & Extracted Data | agent:Planner
- [x] S1.1.1: Document parsing logic for the 9 specified notification types | size:M
- [x] S1.1.2: Define updated `LinkedInNotification` interface with `extracted_data` | size:S

## Phase 2: Newsletter Metadata Editing
- [ ] Create `PrepareUpdateNewsletterInput` interface
- [ ] Implement `prepareUpdate` in `LinkedInNewslettersService`
- [ ] Implement `UpdateNewsletterActionExecutor`
- [ ] Register tool in MCP `linkedin.newsletter.prepare_update`
- [ ] Add CLI command for updating newsletter
## M2: Implementation
### T2.1: Implement Data Extraction (Rich Data) | agent:Worker | depends:T1.1
- [x] S2.1.1: Update `extractNotificationSnapshots` to extract structured data for all required types | size:L
- [x] S2.1.2: Add parsing utilities for metrics (view counts, names, etc) | size:M

## Phase 3: Newsletter Editions List and Stats
- [ ] Update `list` method in `LinkedInNewslettersService` to fetch stats (subscribers, views)
- [ ] Add new interface `ListNewsletterEditionsInput` and `listEditions` method to list individual editions for a newsletter
- [ ] Register `linkedin.newsletter.list_editions` tool in MCP
- [ ] Add CLI command for listing editions
### T2.2: Implement Type Filtering & Pagination | agent:Worker | depends:T2.1
- [x] S2.2.1: Add `types` filtering to `ListNotificationsInput` and `listNotifications` method | size:S
- [x] S2.2.2: Add `types` parameter to MCP tool `notifications_list` | size:S
- [x] S2.2.3: Update pagination logic in `loadNotificationSnapshots` to support fetching longer feeds without fixed scroll limits | size:M

## Phase 4: Share Newsletter
- [ ] Investigate how sharing works for newsletters (Share button -> modal -> post)
- [ ] Implement `prepareShare` in `LinkedInNewslettersService`
- [ ] Implement `ShareNewsletterActionExecutor`
- [ ] Register MCP tool and CLI command

## Phase 5: Testing and Polish
- [ ] Add unit tests for new methods in `linkedinPublishing.test.ts`
- [ ] Run e2e tests
- [ ] Address edge cases: draft vs published, editing after publish, newsletter with zero editions
## M3: Verification & Integration
### T3.1: Testing & Quality Gates | agent:Reviewer | depends:M2
- [x] S3.1.1: Run unit tests and e2e tests for notifications | size:M
- [x] S3.1.2: Run lint and typecheck | size:S
- [x] S3.1.3: Wait for CI and create PR | size:S
11 changes: 11 additions & 0 deletions .opencode/work-log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Work Log

## Active Sessions

## Completed Units (Ready for Integration)
| File | Session | Unit Test | Timestamp |
|------|---------|-----------|-----------|
| packages/core/src/linkedinNotifications.ts | Worker | pass | 2026-03-23T15:42:58.981Z |
| packages/mcp/src/bin/linkedin-mcp.ts | Worker | pass | 2026-03-23T15:42:58.981Z |

## Pending Integration
7 changes: 6 additions & 1 deletion packages/cli/src/bin/linkedin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8515,6 +8515,7 @@ async function runNotificationsList(
input: {
profileName: string;
limit: number;
types?: string[];
},
cdpUrl?: string,
): Promise<void> {
Expand All @@ -8524,11 +8525,13 @@ async function runNotificationsList(
runtime.logger.log("info", "cli.notifications.list.start", {
profileName: input.profileName,
limit: input.limit,
types: input.types,
});

const notifications = await runtime.notifications.listNotifications({
profileName: input.profileName,
limit: input.limit,
...(input.types ? { types: input.types } : {}),
});

runtime.logger.log("info", "cli.notifications.list.done", {
Expand Down Expand Up @@ -12799,11 +12802,13 @@ export function createCliProgram(): Command {
.description("List your LinkedIn notifications")
.option("-p, --profile <profile>", "Profile name", "default")
.option("-l, --limit <limit>", "Max notifications to return", "20")
.action(async (options: { profile: string; limit: string }) => {
.option("-t, --types <types...>", "Filter by notification types")
.action(async (options: { profile: string; limit: string; types?: string[] }) => {
await runNotificationsList(
{
profileName: options.profile,
limit: coercePositiveInt(options.limit, "limit"),
...(options.types ? { types: options.types } : {}),
},
readCdpUrl(),
);
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/__tests__/linkedinNotifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LINKEDIN_NOTIFICATION_PREFERENCE_CHANNELS,
_hashNotificationFingerprint as hashNotificationFingerprint,
_legacyHashNotificationFingerprint as legacyHashNotificationFingerprint,
_extractNotificationStructuredData as extractNotificationStructuredData,
_normalizeNotificationLink as normalizeNotificationLink,
_stripVolatileContent as stripVolatileContent,
LinkedInNotificationsService,
Expand Down Expand Up @@ -1083,3 +1084,122 @@ describe("legacyHashNotificationFingerprint", () => {
expect(id).toMatch(/^notif_[0-9a-f]{16}$/);
});
});

describe("extractNotificationStructuredData", () => {
it("extracts post analytics from 'Your post has ... views'", () => {
expect(extractNotificationStructuredData("Your post has 1,234 views")).toEqual({
views: 1234,
});
});

it("extracts post analytics from 'people viewed your post'", () => {
expect(extractNotificationStructuredData("500 people viewed your post")).toEqual({
views: 500,
});
});

it("extracts profile views from 'people viewed your profile'", () => {
expect(extractNotificationStructuredData("42 people viewed your profile")).toEqual({
profile_views: 42,
});
});

it("extracts profile views from 'Your profile was viewed by ... people'", () => {
expect(
extractNotificationStructuredData("Your profile was viewed by 100 people"),
).toEqual({
profile_views: 100,
});
});

it("extracts search appearances", () => {
expect(extractNotificationStructuredData("You appeared in 15 searches")).toEqual({
search_appearances: 15,
});
});

it("extracts mention sender", () => {
expect(extractNotificationStructuredData("John Smith mentioned you")).toEqual({
mentioned_by: "John Smith",
});
});

it("extracts connection sender from connection request", () => {
expect(
extractNotificationStructuredData("Jane Doe sent you a connection request"),
).toEqual({
sender: "Jane Doe",
});
});

it("extracts connection sender from accepted connection", () => {
expect(extractNotificationStructuredData("Bob accepted your connection")).toEqual({
sender: "Bob",
});
});

it("extracts newsletter subscriber count", () => {
expect(extractNotificationStructuredData("1,500 people subscribed to")).toEqual({
subscriber_count: 1500,
});
});

it("extracts newsletter subscriber name", () => {
expect(extractNotificationStructuredData("Alice subscribed to")).toEqual({
subscriber: "Alice",
});
});

it("extracts job alert count and title", () => {
expect(
extractNotificationStructuredData('5 new jobs for "Software Engineer"'),
).toEqual({
job_count: 5,
job_title: "Software Engineer",
});
});

it("extracts single job alert title", () => {
expect(extractNotificationStructuredData('new job for "Designer"')).toEqual({
job_title: "Designer",
});
});

it("extracts company name from posted notification", () => {
expect(extractNotificationStructuredData("Google posted:")).toEqual({
company_name: "Google",
});
});

it("extracts company name from shared post notification", () => {
expect(extractNotificationStructuredData("Microsoft shared a post:")).toEqual({
company_name: "Microsoft",
});
});

it("extracts trending topic", () => {
expect(
extractNotificationStructuredData("Trending: AI advances in 2025"),
).toEqual({
topic: "AI advances in 2025",
});
});

it("returns undefined when message does not match known parsers", () => {
expect(
extractNotificationStructuredData("Someone liked your comment"),
).toBeUndefined();
});

it("returns undefined for empty string", () => {
expect(extractNotificationStructuredData("")).toBeUndefined();
});

it("normalizes excessive whitespace before parsing", () => {
expect(
extractNotificationStructuredData(" John Smith mentioned\n\t you "),
).toEqual({
mentioned_by: "John Smith",
});
});
});
Loading