Skip to content

Commit b2109ef

Browse files
bchapuisclaude
andcommitted
Add image and link support to social share nodes, standardize naming
- LinkedIn Share Post: add image upload (register + upload to Digital Media API) and link attachment (ARTICLE media category) support - Reddit Share Post: add image upload (S3 lease + upload) support with kind=image - Rename Reddit submit-post-reddit → share-post-reddit and X create-post-x → share-post-x for consistent naming across all social integrations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d5e849a commit b2109ef

7 files changed

Lines changed: 335 additions & 67 deletions

File tree

apps/api/src/runtime/cloudflare-node-registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,8 @@ import { ListUserCommentsRedditNode } from "@dafthunk/runtime/nodes/reddit/list-
390390
import { ListUserPostsRedditNode } from "@dafthunk/runtime/nodes/reddit/list-user-posts-reddit-node";
391391
import { SearchRedditNode } from "@dafthunk/runtime/nodes/reddit/search-reddit-node";
392392
import { SearchSubredditsRedditNode } from "@dafthunk/runtime/nodes/reddit/search-subreddits-reddit-node";
393+
import { SharePostRedditNode } from "@dafthunk/runtime/nodes/reddit/share-post-reddit-node";
393394
import { SubmitCommentRedditNode } from "@dafthunk/runtime/nodes/reddit/submit-comment-reddit-node";
394-
import { SubmitPostRedditNode } from "@dafthunk/runtime/nodes/reddit/submit-post-reddit-node";
395395
import { VoteRedditNode } from "@dafthunk/runtime/nodes/reddit/vote-reddit-node";
396396
import { ReplicateModelNode } from "@dafthunk/runtime/nodes/replicate/replicate-model-node";
397397
import { ReceiveScheduledTriggerNode } from "@dafthunk/runtime/nodes/scheduled/receive-scheduled-trigger-node";
@@ -442,7 +442,6 @@ import { BotReceiveWhatsAppMessageNode } from "@dafthunk/runtime/nodes/whatsapp/
442442
import { BotSendImageWhatsAppNode } from "@dafthunk/runtime/nodes/whatsapp/bot-send-image-whatsapp-node";
443443
import { BotSendMessageWhatsAppNode } from "@dafthunk/runtime/nodes/whatsapp/bot-send-message-whatsapp-node";
444444
import { BotSendTemplateWhatsAppNode } from "@dafthunk/runtime/nodes/whatsapp/bot-send-template-whatsapp-node";
445-
import { CreatePostXNode } from "@dafthunk/runtime/nodes/x/create-post-x-node";
446445
import { DeletePostXNode } from "@dafthunk/runtime/nodes/x/delete-post-x-node";
447446
import { FollowUserXNode } from "@dafthunk/runtime/nodes/x/follow-user-x-node";
448447
import { GetPostXNode } from "@dafthunk/runtime/nodes/x/get-post-x-node";
@@ -454,6 +453,7 @@ import { ListUserMentionsXNode } from "@dafthunk/runtime/nodes/x/list-user-menti
454453
import { ListUserPostsXNode } from "@dafthunk/runtime/nodes/x/list-user-posts-x-node";
455454
import { RepostXNode } from "@dafthunk/runtime/nodes/x/repost-x-node";
456455
import { SearchPostsXNode } from "@dafthunk/runtime/nodes/x/search-posts-x-node";
456+
import { SharePostXNode } from "@dafthunk/runtime/nodes/x/share-post-x-node";
457457
import type { Bindings } from "../context";
458458

459459
export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
@@ -810,12 +810,12 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
810810
this.registerImplementation(SearchRedditNode);
811811
this.registerImplementation(SearchSubredditsRedditNode);
812812
this.registerImplementation(SubmitCommentRedditNode);
813-
this.registerImplementation(SubmitPostRedditNode);
813+
this.registerImplementation(SharePostRedditNode);
814814
this.registerImplementation(VoteRedditNode);
815815
}
816816

817817
if (hasX) {
818-
this.registerImplementation(CreatePostXNode);
818+
this.registerImplementation(SharePostXNode);
819819
this.registerImplementation(DeletePostXNode);
820820
this.registerImplementation(FollowUserXNode);
821821
this.registerImplementation(GetPostXNode);

apps/www/data/categories.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@
127127
"share-post-linkedin",
128128
"get-profile-linkedin",
129129
"list-posts-reddit",
130-
"submit-post-reddit",
130+
"share-post-reddit",
131131
"create-event-google-calendar",
132132
"list-events-google-calendar",
133133
"send-email-google-mail",

packages/runtime/src/nodes/linkedin/share-post-linkedin-node.ts

Lines changed: 164 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import type { ImageParameter } from "@dafthunk/runtime";
12
import { ExecutableNode, type NodeContext } from "@dafthunk/runtime";
23
import type { NodeExecution, NodeType } from "@dafthunk/types";
34

45
/**
56
* LinkedIn Share Post node implementation
6-
* Shares a post to LinkedIn
7+
* Shares a post to LinkedIn with optional image or link attachment
78
*/
89
export class SharePostLinkedInNode extends ExecutableNode {
910
public static readonly nodeType: NodeType = {
@@ -14,7 +15,7 @@ export class SharePostLinkedInNode extends ExecutableNode {
1415
tags: ["Social", "LinkedIn", "Post", "Share"],
1516
icon: "send",
1617
documentation:
17-
"This node shares a post to your LinkedIn profile. Supports text content and optional URLs. Requires a connected LinkedIn integration.",
18+
"This node shares a post to your LinkedIn profile. Supports text content with optional image or link attachments. Requires a connected LinkedIn integration.",
1819
usage: 10,
1920
subscription: true,
2021
asTool: true,
@@ -33,6 +34,30 @@ export class SharePostLinkedInNode extends ExecutableNode {
3334
description: "Post text content",
3435
required: true,
3536
},
37+
{
38+
name: "image",
39+
type: "image",
40+
description: "Optional image to attach to the post",
41+
required: false,
42+
},
43+
{
44+
name: "linkUrl",
45+
type: "string",
46+
description: "Optional link URL to attach to the post",
47+
required: false,
48+
},
49+
{
50+
name: "linkTitle",
51+
type: "string",
52+
description: "Title for the link attachment",
53+
required: false,
54+
},
55+
{
56+
name: "linkDescription",
57+
type: "string",
58+
description: "Description for the link attachment",
59+
required: false,
60+
},
3661
{
3762
name: "visibility",
3863
type: "string",
@@ -56,9 +81,95 @@ export class SharePostLinkedInNode extends ExecutableNode {
5681
],
5782
};
5883

84+
private async registerAndUploadImage(
85+
accessToken: string,
86+
userId: string,
87+
image: ImageParameter
88+
): Promise<string> {
89+
// Step 1: Register the upload to get an upload URL and asset URN
90+
const registerResponse = await fetch(
91+
"https://api.linkedin.com/v2/assets?action=registerUpload",
92+
{
93+
method: "POST",
94+
headers: {
95+
Authorization: `Bearer ${accessToken}`,
96+
"Content-Type": "application/json",
97+
"X-Restli-Protocol-Version": "2.0.0",
98+
},
99+
body: JSON.stringify({
100+
registerUploadRequest: {
101+
recipes: ["urn:li:digitalmediaRecipe:feedshare-image"],
102+
owner: `urn:li:person:${userId}`,
103+
serviceRelationships: [
104+
{
105+
relationshipType: "OWNER",
106+
identifier: "urn:li:userGeneratedContent",
107+
},
108+
],
109+
},
110+
}),
111+
}
112+
);
113+
114+
if (!registerResponse.ok) {
115+
const errorData = await registerResponse.text();
116+
throw new Error(`Failed to register image upload: ${errorData}`);
117+
}
118+
119+
const registerResult = (await registerResponse.json()) as {
120+
value: {
121+
asset: string;
122+
uploadMechanism: {
123+
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest": {
124+
uploadUrl: string;
125+
};
126+
};
127+
};
128+
};
129+
130+
const uploadUrl =
131+
registerResult.value.uploadMechanism[
132+
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
133+
].uploadUrl;
134+
const assetUrn = registerResult.value.asset;
135+
136+
// Step 2: Upload the image binary data
137+
const uploadResponse = await fetch(uploadUrl, {
138+
method: "PUT",
139+
headers: {
140+
Authorization: `Bearer ${accessToken}`,
141+
"Content-Type": image.mimeType,
142+
},
143+
body: image.data,
144+
});
145+
146+
if (!uploadResponse.ok) {
147+
const errorData = await uploadResponse.text();
148+
throw new Error(`Failed to upload image: ${errorData}`);
149+
}
150+
151+
return assetUrn;
152+
}
153+
59154
async execute(context: NodeContext): Promise<NodeExecution> {
60155
try {
61-
const { integrationId, text, visibility } = context.inputs;
156+
const {
157+
integrationId,
158+
text,
159+
image,
160+
linkUrl,
161+
linkTitle,
162+
linkDescription,
163+
visibility,
164+
} = context.inputs as {
165+
integrationId?: string;
166+
text?: string;
167+
image?: ImageParameter;
168+
linkUrl?: string;
169+
linkTitle?: string;
170+
linkDescription?: string;
171+
visibility?: string;
172+
};
62173
const { organizationId } = context;
63174

64175
// Validate required inputs
@@ -76,6 +187,13 @@ export class SharePostLinkedInNode extends ExecutableNode {
76187
return this.createErrorResult("Organization ID is required");
77188
}
78189

190+
// Cannot attach both image and link
191+
if (image?.data && linkUrl) {
192+
return this.createErrorResult(
193+
"Cannot attach both an image and a link. Please provide only one."
194+
);
195+
}
196+
79197
// Validate visibility
80198
const postVisibility =
81199
visibility && typeof visibility === "string"
@@ -89,7 +207,6 @@ export class SharePostLinkedInNode extends ExecutableNode {
89207

90208
// Get integration with auto-refreshed token
91209
const integration = await context.getIntegration(integrationId);
92-
93210
const accessToken = integration.token;
94211

95212
// Get user's LinkedIn ID from metadata
@@ -102,17 +219,54 @@ export class SharePostLinkedInNode extends ExecutableNode {
102219
);
103220
}
104221

222+
// Build share content based on attachment type
223+
let shareMediaCategory = "NONE";
224+
const media: Array<Record<string, unknown>> = [];
225+
226+
if (image?.data && image?.mimeType) {
227+
// Upload image and get asset URN
228+
const assetUrn = await this.registerAndUploadImage(
229+
accessToken,
230+
userId,
231+
image
232+
);
233+
shareMediaCategory = "IMAGE";
234+
media.push({
235+
status: "READY",
236+
media: assetUrn,
237+
});
238+
} else if (linkUrl && typeof linkUrl === "string") {
239+
shareMediaCategory = "ARTICLE";
240+
const linkMedia: Record<string, unknown> = {
241+
status: "READY",
242+
originalUrl: linkUrl,
243+
};
244+
if (linkTitle && typeof linkTitle === "string") {
245+
linkMedia.title = { text: linkTitle };
246+
}
247+
if (linkDescription && typeof linkDescription === "string") {
248+
linkMedia.description = { text: linkDescription };
249+
}
250+
media.push(linkMedia);
251+
}
252+
105253
// Prepare post data using LinkedIn UGC Post API
254+
const shareContent: Record<string, unknown> = {
255+
shareCommentary: {
256+
text: text as string,
257+
},
258+
shareMediaCategory,
259+
};
260+
261+
if (media.length > 0) {
262+
shareContent.media = media;
263+
}
264+
106265
const postData = {
107266
author: `urn:li:person:${userId}`,
108267
lifecycleState: "PUBLISHED",
109268
specificContent: {
110-
"com.linkedin.ugc.ShareContent": {
111-
shareCommentary: {
112-
text: text as string,
113-
},
114-
shareMediaCategory: "NONE",
115-
},
269+
"com.linkedin.ugc.ShareContent": shareContent,
116270
},
117271
visibility: {
118272
"com.linkedin.ugc.MemberNetworkVisibility": postVisibility,

packages/runtime/src/nodes/reddit/submit-post-reddit-node.integration.ts renamed to packages/runtime/src/nodes/reddit/share-post-reddit-node.integration.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ import {
66
REDDIT_TEST_CONFIG,
77
skipIfNoRedditToken,
88
} from "./reddit-test-helper";
9-
import { SubmitPostRedditNode } from "./submit-post-reddit-node";
9+
import { SharePostRedditNode } from "./share-post-reddit-node";
1010

11-
describe("SubmitPostRedditNode", () => {
11+
describe("SharePostRedditNode", () => {
1212
it.skipIf(skipIfNoRedditToken())(
13-
"should submit a text post to r/dafthunk_test",
13+
"should share a text post to r/dafthunk_test",
1414
async () => {
15-
const node = new SubmitPostRedditNode({
16-
nodeId: "submit-post-reddit",
15+
const node = new SharePostRedditNode({
16+
nodeId: "share-post-reddit",
1717
} as unknown as Node);
1818

1919
const timestamp = Date.now();
20-
const context = createRedditTestContext("submit-post-reddit", {
20+
const context = createRedditTestContext("share-post-reddit", {
2121
subreddit: REDDIT_TEST_CONFIG.subreddit,
2222
title: `Integration Test Post ${timestamp}`,
2323
kind: "self",

0 commit comments

Comments
 (0)