@framers/agentos-ext-tool-multi-channel-post 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -2,12 +2,12 @@
2
2
  "$schema": "https://agentos.sh/schemas/extension-manifest-v1.json",
3
3
  "id": "com.framers.tools.multi-channel-post",
4
4
  "name": "Multi-Channel Post Tool",
5
- "version": "0.1.0",
5
+ "version": "0.1.1",
6
6
  "description": "Publish adapted content to multiple social platforms simultaneously. Handles platform-specific content adaptation, media format validation, and per-platform result tracking.",
7
7
  "author": {
8
8
  "name": "Frame.dev",
9
9
  "email": "support@frame.dev",
10
- "url": "https://github.com/framersai"
10
+ "url": "https://github.com/framerslab"
11
11
  },
12
12
  "license": "MIT",
13
13
  "keywords": ["social", "multi-channel", "cross-platform", "posting", "content-adaptation"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@framers/agentos-ext-tool-multi-channel-post",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Cross-platform social media posting tool — publish adapted content to multiple platforms in a single operation.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -37,30 +37,37 @@
37
37
  "license": "MIT",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "https://github.com/framersai/agentos-extensions.git",
40
+ "url": "git+https://github.com/framerslab/agentos-extensions.git",
41
41
  "directory": "registry/curated/tools/multi-channel-post"
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
46
  "peerDependencies": {
47
- "@framers/agentos": "^0.5.2"
47
+ "@framers/agentos": ">=0.10.40"
48
48
  },
49
49
  "devDependencies": {
50
- "@framers/agentos": "^0.6.6",
51
50
  "@types/node": "^20.12.12",
52
- "@vitest/coverage-v8": "^1.6.0",
51
+ "@vitest/coverage-v8": "^3.2.7",
53
52
  "rimraf": "^5.0.7",
54
53
  "typescript": "^5.4.5",
55
- "vitest": "^1.6.0"
54
+ "vitest": "^3.2.7",
55
+ "@framers/agentos": "^0.10.40"
56
56
  },
57
+ "homepage": "https://github.com/framerslab/agentos-extensions/tree/master/registry/curated/tools/multi-channel-post#readme",
58
+ "bugs": {
59
+ "url": "https://github.com/framerslab/agentos-extensions/issues"
60
+ },
61
+ "files": [
62
+ "dist",
63
+ "manifest.json"
64
+ ],
57
65
  "scripts": {
58
66
  "build": "tsc",
59
67
  "dev": "tsc --watch",
60
- "test": "vitest run",
68
+ "test": "vitest run --passWithNoTests",
61
69
  "test:watch": "vitest",
62
70
  "test:coverage": "vitest run --coverage",
63
- "lint": "eslint src --ext .ts",
64
71
  "typecheck": "tsc --noEmit",
65
72
  "clean": "rimraf dist"
66
73
  }
@@ -1,491 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * @fileoverview Multi-Channel Post Tool — publish adapted content to N platforms.
4
- *
5
- * This tool acts as an orchestrator that delegates to per-platform post tools
6
- * (twitterPost, instagramPost, linkedinPost, etc.) and aggregates results.
7
- * It adapts content for each platform's constraints before posting.
8
- */
9
-
10
- /* ------------------------------------------------------------------ */
11
- /* Types */
12
- /* ------------------------------------------------------------------ */
13
-
14
- export interface MultiChannelPostInput {
15
- /** Base content to publish. */
16
- content: string;
17
- /** Target platforms (e.g., ['twitter', 'linkedin', 'instagram']). */
18
- platforms: string[];
19
- /** Optional per-platform content overrides. */
20
- adaptations?: Record<string, string>;
21
- /** Optional media URLs to attach. */
22
- mediaUrls?: string[];
23
- /** Optional per-platform argument overrides passed to the platform tool. */
24
- platformConfigs?: Record<string, Record<string, unknown>>;
25
- /** Optional hashtags to include (adapted per platform). */
26
- hashtags?: string[];
27
- /** Optional: if true, don't actually post — just show adapted content. */
28
- dryRun?: boolean;
29
- }
30
-
31
- export interface PlatformPostResult {
32
- platform: string;
33
- success: boolean;
34
- postId?: string;
35
- url?: string;
36
- adaptedContent?: string;
37
- error?: string;
38
- }
39
-
40
- export interface MultiChannelPostOutput {
41
- totalPlatforms: number;
42
- successful: number;
43
- failed: number;
44
- results: PlatformPostResult[];
45
- }
46
-
47
- /* ------------------------------------------------------------------ */
48
- /* Tool executor callback type */
49
- /* ------------------------------------------------------------------ */
50
-
51
- export type ToolExecutorFn = (
52
- toolName: string,
53
- args: Record<string, unknown>,
54
- ) => Promise<{ success: boolean; data?: Record<string, unknown>; error?: string }>;
55
-
56
- /* ------------------------------------------------------------------ */
57
- /* MultiChannelPostTool */
58
- /* ------------------------------------------------------------------ */
59
-
60
- export class MultiChannelPostTool {
61
- readonly id = 'multiChannelPost';
62
- readonly name = 'multiChannelPost';
63
- readonly displayName = 'Post to Multiple Platforms';
64
- readonly description =
65
- 'Publish content to multiple social media platforms simultaneously with automatic ' +
66
- 'platform-specific content adaptation. Supports Twitter, Instagram, Reddit, YouTube, ' +
67
- 'TikTok, Pinterest, LinkedIn, Facebook, Threads, Bluesky, Mastodon.';
68
- readonly category = 'social';
69
- readonly version = '0.1.0';
70
- readonly hasSideEffects = true;
71
-
72
- readonly inputSchema = {
73
- type: 'object' as const,
74
- properties: {
75
- content: {
76
- type: 'string',
77
- description: 'Base content text to publish across platforms',
78
- },
79
- platforms: {
80
- type: 'array',
81
- items: { type: 'string' },
82
- description:
83
- 'Target platforms: twitter, instagram, reddit, youtube, tiktok, pinterest, linkedin, facebook, threads, bluesky, mastodon',
84
- },
85
- adaptations: {
86
- type: 'object',
87
- additionalProperties: { type: 'string' },
88
- description: 'Optional per-platform content overrides (platform name -> custom text)',
89
- },
90
- mediaUrls: {
91
- type: 'array',
92
- items: { type: 'string' },
93
- description: 'Media URLs or file paths to attach',
94
- },
95
- platformConfigs: {
96
- type: 'object',
97
- additionalProperties: {
98
- type: 'object',
99
- additionalProperties: true,
100
- },
101
- description:
102
- 'Optional per-platform tool argument overrides (e.g. subreddit, boardId, videoUrl, communityId).',
103
- },
104
- hashtags: {
105
- type: 'array',
106
- items: { type: 'string' },
107
- description: 'Hashtags to include (adapted per platform conventions)',
108
- },
109
- dryRun: {
110
- type: 'boolean',
111
- description: 'If true, return adapted content without posting',
112
- },
113
- },
114
- required: ['content', 'platforms'],
115
- };
116
-
117
- /* -------------------------------------------------------------- */
118
- /* Platform metadata */
119
- /* -------------------------------------------------------------- */
120
-
121
- /** Platform character limits for basic truncation. */
122
- private static readonly PLATFORM_LIMITS: Record<string, number> = {
123
- twitter: 280,
124
- threads: 500,
125
- bluesky: 300,
126
- mastodon: 500,
127
- farcaster: 320,
128
- instagram: 2200,
129
- tiktok: 2200,
130
- pinterest: 500,
131
- linkedin: 3000,
132
- facebook: 63206,
133
- reddit: 40000,
134
- youtube: 5000,
135
- devto: 100000,
136
- medium: 100000,
137
- };
138
-
139
- /** Platform hashtag conventions. */
140
- private static readonly HASHTAG_STYLE: Record<string, 'inline' | 'footer' | 'none'> = {
141
- twitter: 'inline',
142
- instagram: 'footer',
143
- linkedin: 'footer',
144
- facebook: 'inline',
145
- threads: 'inline',
146
- mastodon: 'inline',
147
- tiktok: 'inline',
148
- reddit: 'none',
149
- bluesky: 'none',
150
- pinterest: 'none',
151
- youtube: 'inline',
152
- };
153
-
154
- /** Platform name -> per-platform post tool name. */
155
- private static readonly TOOL_NAME_MAP: Record<string, string> = {
156
- twitter: 'twitterPost',
157
- instagram: 'instagramPost',
158
- reddit: 'redditSubmitPost',
159
- youtube: 'youtubeUpload',
160
- tiktok: 'tiktokUpload',
161
- pinterest: 'pinterestPin',
162
- linkedin: 'linkedinPost',
163
- facebook: 'facebookPost',
164
- threads: 'threadsPost',
165
- bluesky: 'blueskyPost',
166
- mastodon: 'mastodonPost',
167
- farcaster: 'farcasterCast',
168
- lemmy: 'lemmyPost',
169
- };
170
-
171
- /* -------------------------------------------------------------- */
172
- /* Tool executor — set by the orchestrator that loads this ext */
173
- /* -------------------------------------------------------------- */
174
-
175
- private toolExecutor?: ToolExecutorFn;
176
-
177
- setToolExecutor(executor: ToolExecutorFn): void {
178
- this.toolExecutor = executor;
179
- }
180
-
181
- /* -------------------------------------------------------------- */
182
- /* execute() */
183
- /* -------------------------------------------------------------- */
184
-
185
- async execute(
186
- args: MultiChannelPostInput,
187
- ): Promise<{ success: boolean; data?: MultiChannelPostOutput; error?: string }> {
188
- const results: PlatformPostResult[] = [];
189
-
190
- for (const platform of args.platforms) {
191
- const adaptedContent =
192
- args.adaptations?.[platform] ??
193
- this.adaptForPlatform(args.content, platform, args.hashtags);
194
-
195
- // Dry-run mode: collect adapted content without posting.
196
- if (args.dryRun) {
197
- results.push({ platform, success: true, adaptedContent });
198
- continue;
199
- }
200
-
201
- try {
202
- const toolName = this.getPostToolName(platform);
203
- const toolArgs = this.buildToolArgs(
204
- platform,
205
- adaptedContent,
206
- args.mediaUrls,
207
- args.platformConfigs?.[platform],
208
- );
209
-
210
- if (this.toolExecutor) {
211
- const result = await this.toolExecutor(toolName, toolArgs);
212
- results.push({
213
- platform,
214
- success: result.success,
215
- postId: (result.data?.id ?? result.data?.postId) as string | undefined,
216
- url: result.data?.url as string | undefined,
217
- adaptedContent,
218
- error: result.error,
219
- });
220
- } else {
221
- results.push({
222
- platform,
223
- success: false,
224
- adaptedContent,
225
- error: `No tool executor available. Platform tool "${toolName}" cannot be invoked.`,
226
- });
227
- }
228
- } catch (err: unknown) {
229
- const message = err instanceof Error ? err.message : String(err);
230
- results.push({
231
- platform,
232
- success: false,
233
- adaptedContent,
234
- error: message,
235
- });
236
- }
237
- }
238
-
239
- const successful = results.filter((r) => r.success).length;
240
-
241
- return {
242
- success: successful > 0,
243
- data: {
244
- totalPlatforms: args.platforms.length,
245
- successful,
246
- failed: args.platforms.length - successful,
247
- results,
248
- },
249
- };
250
- }
251
-
252
- /* -------------------------------------------------------------- */
253
- /* Content adaptation */
254
- /* -------------------------------------------------------------- */
255
-
256
- /** Adapt content for a specific platform's constraints and conventions. */
257
- private adaptForPlatform(
258
- content: string,
259
- platform: string,
260
- hashtags?: string[],
261
- ): string {
262
- const limit = MultiChannelPostTool.PLATFORM_LIMITS[platform] ?? 5000;
263
- const hashtagStyle = MultiChannelPostTool.HASHTAG_STYLE[platform] ?? 'none';
264
-
265
- let text = content;
266
-
267
- // Append hashtags according to platform convention.
268
- if (hashtags?.length && hashtagStyle !== 'none') {
269
- const tags = hashtags.map((t) => (t.startsWith('#') ? t : `#${t}`));
270
- if (hashtagStyle === 'footer') {
271
- text = text + '\n\n' + tags.join(' ');
272
- } else {
273
- text = text + ' ' + tags.join(' ');
274
- }
275
- }
276
-
277
- // Truncate if the text exceeds the platform limit.
278
- if (text.length > limit) {
279
- text = text.substring(0, limit - 3) + '...';
280
- }
281
-
282
- return text;
283
- }
284
-
285
- /* -------------------------------------------------------------- */
286
- /* Tool name / args mapping */
287
- /* -------------------------------------------------------------- */
288
-
289
- /** Map platform to its per-platform post tool name. */
290
- private getPostToolName(platform: string): string {
291
- return MultiChannelPostTool.TOOL_NAME_MAP[platform] ?? `${platform}Post`;
292
- }
293
-
294
- /** Build platform-specific tool arguments. */
295
- private buildToolArgs(
296
- platform: string,
297
- content: string,
298
- mediaUrls?: string[],
299
- platformConfig?: Record<string, unknown>,
300
- ): Record<string, unknown> {
301
- const config = platformConfig ?? {};
302
- const mediaUrl = this.getOptionalString(config['mediaUrl']) ?? mediaUrls?.[0];
303
- const mediaPath = this.getOptionalString(config['mediaPath']) ?? mediaUrl;
304
-
305
- switch (platform) {
306
- case 'twitter':
307
- return { text: content, mediaPath };
308
- case 'instagram':
309
- return {
310
- caption: content,
311
- imageUrls: this.requireStringArray(
312
- this.getOptionalStringArray(config['imageUrls']) ?? mediaUrls,
313
- 'instagram requires imageUrls (provide mediaUrls or platformConfigs.instagram.imageUrls)',
314
- ),
315
- };
316
- case 'reddit': {
317
- const type =
318
- this.getOptionalString(config['type']) ??
319
- (mediaUrl ? 'image' : 'text');
320
- const title =
321
- this.getOptionalString(config['title']) ??
322
- content.substring(0, 300);
323
- const bodyContent =
324
- this.getOptionalString(config['content']) ??
325
- (type === 'image' || type === 'link'
326
- ? this.requireString(
327
- mediaUrl,
328
- `reddit ${type} post requires a URL (provide mediaUrls or platformConfigs.reddit.content)`,
329
- )
330
- : content);
331
-
332
- return {
333
- subreddit: this.getOptionalString(config['subreddit']) ?? 'self',
334
- title,
335
- content: bodyContent,
336
- type,
337
- pollOptions: this.getOptionalStringArray(config['pollOptions']),
338
- pollDurationDays: this.getOptionalNumber(config['pollDurationDays']),
339
- flairId: this.getOptionalString(config['flairId']),
340
- nsfw: this.getOptionalBoolean(config['nsfw']),
341
- spoiler: this.getOptionalBoolean(config['spoiler']),
342
- };
343
- }
344
- case 'youtube':
345
- return {
346
- videoUrl: this.requireString(
347
- this.getOptionalString(config['videoUrl']) ?? mediaUrl,
348
- 'youtube requires videoUrl (provide mediaUrls or platformConfigs.youtube.videoUrl)',
349
- ),
350
- title:
351
- this.getOptionalString(config['title']) ??
352
- content.substring(0, 100),
353
- description:
354
- this.getOptionalString(config['description']) ?? content,
355
- tags: this.getOptionalStringArray(config['tags']),
356
- categoryId: this.getOptionalString(config['categoryId']),
357
- privacyStatus: this.getOptionalString(config['privacyStatus']),
358
- mimeType: this.getOptionalString(config['mimeType']),
359
- };
360
- case 'tiktok':
361
- return {
362
- videoUrl: this.requireString(
363
- this.getOptionalString(config['videoUrl']) ?? mediaUrl,
364
- 'tiktok requires videoUrl (provide mediaUrls or platformConfigs.tiktok.videoUrl)',
365
- ),
366
- caption: this.getOptionalString(config['caption']) ?? content,
367
- hashtags: this.getOptionalStringArray(config['hashtags']),
368
- privacyLevel: this.getOptionalString(config['privacyLevel']),
369
- disableComment: this.getOptionalBoolean(config['disableComment']),
370
- disableDuet: this.getOptionalBoolean(config['disableDuet']),
371
- disableStitch: this.getOptionalBoolean(config['disableStitch']),
372
- coverTimestampMs: this.getOptionalNumber(config['coverTimestampMs']),
373
- };
374
- case 'pinterest': {
375
- const mediaUrlsForCarousel =
376
- this.getOptionalStringArray(config['mediaUrls']);
377
- return {
378
- boardId: this.requireString(
379
- this.getOptionalString(config['boardId']),
380
- 'pinterest requires boardId (provide platformConfigs.pinterest.boardId)',
381
- ),
382
- title:
383
- this.getOptionalString(config['title']) ??
384
- content.substring(0, 100),
385
- description:
386
- this.getOptionalString(config['description']) ?? content,
387
- link: this.getOptionalString(config['link']),
388
- mediaType:
389
- this.getOptionalString(config['mediaType']) ??
390
- (mediaUrlsForCarousel && mediaUrlsForCarousel.length > 1
391
- ? 'carousel'
392
- : 'image'),
393
- mediaUrl: this.requireString(
394
- this.getOptionalString(config['mediaUrl']) ?? mediaUrl,
395
- 'pinterest requires mediaUrl (provide mediaUrls or platformConfigs.pinterest.mediaUrl)',
396
- ),
397
- mediaUrls: mediaUrlsForCarousel,
398
- coverImageUrl: this.getOptionalString(config['coverImageUrl']),
399
- altText: this.getOptionalString(config['altText']),
400
- hashtags: this.getOptionalStringArray(config['hashtags']),
401
- };
402
- }
403
- case 'linkedin':
404
- return { text: content, mediaPath };
405
- case 'facebook':
406
- return {
407
- text: content,
408
- mediaPath,
409
- link: this.getOptionalString(config['link']),
410
- mediaType: this.getOptionalString(config['mediaType']),
411
- pageId: this.getOptionalString(config['pageId']),
412
- };
413
- case 'threads':
414
- return {
415
- text: content,
416
- imageUrl: this.getOptionalString(config['imageUrl']) ?? mediaUrl,
417
- videoUrl: this.getOptionalString(config['videoUrl']),
418
- carouselItems: config['carouselItems'],
419
- };
420
- case 'bluesky':
421
- return {
422
- text: content,
423
- imagePaths: this.getOptionalStringArray(config['imagePaths']),
424
- langs: this.getOptionalStringArray(config['langs']),
425
- };
426
- case 'mastodon':
427
- return {
428
- text: content,
429
- mediaPath,
430
- spoilerText: this.getOptionalString(config['spoilerText']),
431
- visibility: this.getOptionalString(config['visibility']),
432
- sensitive: this.getOptionalBoolean(config['sensitive']),
433
- language: this.getOptionalString(config['language']),
434
- };
435
- case 'farcaster':
436
- return {
437
- text: content,
438
- embeds: this.getOptionalStringArray(config['embeds']) ??
439
- (mediaUrl ? [mediaUrl] : undefined),
440
- channelId: this.getOptionalString(config['channelId']),
441
- };
442
- case 'lemmy':
443
- return {
444
- communityId: this.requireNumber(
445
- this.getOptionalNumber(config['communityId']),
446
- 'lemmy requires communityId (provide platformConfigs.lemmy.communityId)',
447
- ),
448
- name:
449
- this.getOptionalString(config['name']) ??
450
- content.substring(0, 200),
451
- body: this.getOptionalString(config['body']) ?? content,
452
- url: this.getOptionalString(config['url']) ?? mediaUrl,
453
- };
454
- default:
455
- return { text: content, mediaPath };
456
- }
457
- }
458
-
459
- private getOptionalString(value: unknown): string | undefined {
460
- return typeof value === 'string' && value.trim() !== '' ? value : undefined;
461
- }
462
-
463
- private getOptionalStringArray(value: unknown): string[] | undefined {
464
- if (!Array.isArray(value)) return undefined;
465
- const values = value.filter((v): v is string => typeof v === 'string' && v.trim() !== '');
466
- return values.length ? values : undefined;
467
- }
468
-
469
- private getOptionalNumber(value: unknown): number | undefined {
470
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
471
- }
472
-
473
- private getOptionalBoolean(value: unknown): boolean | undefined {
474
- return typeof value === 'boolean' ? value : undefined;
475
- }
476
-
477
- private requireString(value: string | undefined, error: string): string {
478
- if (!value) throw new Error(error);
479
- return value;
480
- }
481
-
482
- private requireNumber(value: number | undefined, error: string): number {
483
- if (typeof value !== 'number') throw new Error(error);
484
- return value;
485
- }
486
-
487
- private requireStringArray(value: string[] | undefined, error: string): string[] {
488
- if (!value || value.length === 0) throw new Error(error);
489
- return value;
490
- }
491
- }
package/src/index.ts DELETED
@@ -1,67 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * Multi-Channel Post Extension Pack — publish adapted content to N social platforms.
4
- */
5
-
6
- import { MultiChannelPostTool } from './MultiChannelPostTool.js';
7
-
8
- /* ------------------------------------------------------------------ */
9
- /* Extension pack types */
10
- /* ------------------------------------------------------------------ */
11
-
12
- export interface ExtensionContext {
13
- options?: Record<string, unknown>;
14
- secrets?: Record<string, string>;
15
- getSecret?: (key: string) => string | undefined;
16
- logger?: { info: (msg: string) => void };
17
- }
18
-
19
- export interface ExtensionPack {
20
- name: string;
21
- version: string;
22
- descriptors: Array<{
23
- id: string;
24
- kind: string;
25
- priority?: number;
26
- payload: unknown;
27
- }>;
28
- onActivate?: () => Promise<void>;
29
- onDeactivate?: () => Promise<void>;
30
- }
31
-
32
- /* ------------------------------------------------------------------ */
33
- /* Factory */
34
- /* ------------------------------------------------------------------ */
35
-
36
- export function createExtensionPack(context: ExtensionContext): ExtensionPack {
37
- const tool = new MultiChannelPostTool();
38
-
39
- return {
40
- name: '@framers/agentos-ext-tool-multi-channel-post',
41
- version: '0.1.0',
42
- descriptors: [
43
- {
44
- id: tool.name,
45
- kind: 'tool' as const,
46
- priority: 50,
47
- payload: tool,
48
- },
49
- ],
50
- onActivate: async () => context.logger?.info('Multi-Channel Post Extension activated'),
51
- onDeactivate: async () => context.logger?.info('Multi-Channel Post Extension deactivated'),
52
- };
53
- }
54
-
55
- /* ------------------------------------------------------------------ */
56
- /* Re-exports */
57
- /* ------------------------------------------------------------------ */
58
-
59
- export { MultiChannelPostTool } from './MultiChannelPostTool.js';
60
- export type {
61
- MultiChannelPostInput,
62
- MultiChannelPostOutput,
63
- PlatformPostResult,
64
- ToolExecutorFn,
65
- } from './MultiChannelPostTool.js';
66
-
67
- export default createExtensionPack;
@@ -1,528 +0,0 @@
1
- // @ts-nocheck
2
- /**
3
- * @fileoverview Unit tests for MultiChannelPostTool.
4
- *
5
- * Tests cover: tool metadata, dry-run mode, multi-platform posting,
6
- * per-platform content overrides, partial failures, content adaptation
7
- * (character limits, hashtag placement), and missing executor handling.
8
- */
9
-
10
- import { describe, it, expect, vi, beforeEach } from 'vitest';
11
- import {
12
- MultiChannelPostTool,
13
- type ToolExecutorFn,
14
- type MultiChannelPostInput,
15
- } from '../src/MultiChannelPostTool.js';
16
-
17
- /* ------------------------------------------------------------------ */
18
- /* Helpers */
19
- /* ------------------------------------------------------------------ */
20
-
21
- function createMockExecutor(
22
- overrides?: Partial<Record<string, { success: boolean; data?: Record<string, unknown>; error?: string }>>,
23
- ): ToolExecutorFn {
24
- return vi.fn(async (toolName: string, _args: Record<string, unknown>) => {
25
- if (overrides?.[toolName]) return overrides[toolName]!;
26
- return {
27
- success: true,
28
- data: { id: `post-${toolName}-001`, url: `https://${toolName}.example.com/post/001` },
29
- };
30
- });
31
- }
32
-
33
- /* ------------------------------------------------------------------ */
34
- /* Tests */
35
- /* ------------------------------------------------------------------ */
36
-
37
- describe('MultiChannelPostTool', () => {
38
- let tool: MultiChannelPostTool;
39
-
40
- beforeEach(() => {
41
- tool = new MultiChannelPostTool();
42
- });
43
-
44
- /* ── Metadata ─────────────────────────────────────────────────── */
45
-
46
- describe('metadata', () => {
47
- it('should expose the correct id and name', () => {
48
- expect(tool.id).toBe('multiChannelPost');
49
- expect(tool.name).toBe('multiChannelPost');
50
- });
51
-
52
- it('should have a display name and description', () => {
53
- expect(tool.displayName).toBe('Post to Multiple Platforms');
54
- expect(tool.description).toContain('multiple social media platforms');
55
- });
56
-
57
- it('should declare the category as social', () => {
58
- expect(tool.category).toBe('social');
59
- });
60
-
61
- it('should flag hasSideEffects as true', () => {
62
- expect(tool.hasSideEffects).toBe(true);
63
- });
64
-
65
- it('should expose a valid inputSchema with required fields', () => {
66
- expect(tool.inputSchema.type).toBe('object');
67
- expect(tool.inputSchema.required).toEqual(['content', 'platforms']);
68
- expect(tool.inputSchema.properties).toHaveProperty('content');
69
- expect(tool.inputSchema.properties).toHaveProperty('platforms');
70
- expect(tool.inputSchema.properties).toHaveProperty('adaptations');
71
- expect(tool.inputSchema.properties).toHaveProperty('mediaUrls');
72
- expect(tool.inputSchema.properties).toHaveProperty('platformConfigs');
73
- expect(tool.inputSchema.properties).toHaveProperty('hashtags');
74
- expect(tool.inputSchema.properties).toHaveProperty('dryRun');
75
- });
76
- });
77
-
78
- /* ── Dry-run mode ─────────────────────────────────────────────── */
79
-
80
- describe('dry-run mode', () => {
81
- it('should return adapted content for each platform without posting', async () => {
82
- const executor = createMockExecutor();
83
- tool.setToolExecutor(executor);
84
-
85
- const result = await tool.execute({
86
- content: 'Hello world!',
87
- platforms: ['twitter', 'linkedin'],
88
- dryRun: true,
89
- });
90
-
91
- expect(result.success).toBe(true);
92
- expect(result.data!.totalPlatforms).toBe(2);
93
- expect(result.data!.successful).toBe(2);
94
- expect(result.data!.failed).toBe(0);
95
-
96
- // The executor should NOT have been called at all
97
- expect(executor).not.toHaveBeenCalled();
98
-
99
- // Each result should contain the adapted content
100
- for (const r of result.data!.results) {
101
- expect(r.success).toBe(true);
102
- expect(r.adaptedContent).toBeDefined();
103
- expect(r.postId).toBeUndefined();
104
- }
105
- });
106
-
107
- it('should use per-platform overrides in dry-run mode', async () => {
108
- const result = await tool.execute({
109
- content: 'Default text',
110
- platforms: ['twitter', 'linkedin'],
111
- adaptations: { twitter: 'Custom tweet text' },
112
- dryRun: true,
113
- });
114
-
115
- const twitterResult = result.data!.results.find((r) => r.platform === 'twitter')!;
116
- const linkedinResult = result.data!.results.find((r) => r.platform === 'linkedin')!;
117
-
118
- expect(twitterResult.adaptedContent).toBe('Custom tweet text');
119
- expect(linkedinResult.adaptedContent).toBe('Default text');
120
- });
121
- });
122
-
123
- /* ── Multi-platform posting ───────────────────────────────────── */
124
-
125
- describe('posting to multiple platforms', () => {
126
- it('should post to all specified platforms and aggregate results', async () => {
127
- const executor = createMockExecutor();
128
- tool.setToolExecutor(executor);
129
-
130
- const result = await tool.execute({
131
- content: 'Check out our new launch!',
132
- platforms: ['twitter', 'linkedin', 'facebook'],
133
- });
134
-
135
- expect(result.success).toBe(true);
136
- expect(result.data!.totalPlatforms).toBe(3);
137
- expect(result.data!.successful).toBe(3);
138
- expect(result.data!.failed).toBe(0);
139
- expect(result.data!.results).toHaveLength(3);
140
-
141
- // Verify correct tool names were called
142
- expect(executor).toHaveBeenCalledTimes(3);
143
- expect(executor).toHaveBeenCalledWith('twitterPost', expect.any(Object));
144
- expect(executor).toHaveBeenCalledWith('linkedinPost', expect.any(Object));
145
- expect(executor).toHaveBeenCalledWith('facebookPost', expect.any(Object));
146
- });
147
-
148
- it('should pass media URLs in tool args', async () => {
149
- const executor = createMockExecutor();
150
- tool.setToolExecutor(executor);
151
-
152
- await tool.execute({
153
- content: 'Photo post',
154
- platforms: ['twitter'],
155
- mediaUrls: ['https://example.com/photo.jpg'],
156
- });
157
-
158
- expect(executor).toHaveBeenCalledWith('twitterPost', {
159
- text: expect.any(String),
160
- mediaPath: 'https://example.com/photo.jpg',
161
- });
162
- });
163
-
164
- it('should extract postId and url from executor results', async () => {
165
- const executor = createMockExecutor({
166
- twitterPost: {
167
- success: true,
168
- data: { id: 'tw-999', url: 'https://twitter.com/status/999' },
169
- },
170
- });
171
- tool.setToolExecutor(executor);
172
-
173
- const result = await tool.execute({
174
- content: 'Hello',
175
- platforms: ['twitter'],
176
- });
177
-
178
- const tResult = result.data!.results[0]!;
179
- expect(tResult.postId).toBe('tw-999');
180
- expect(tResult.url).toBe('https://twitter.com/status/999');
181
- });
182
-
183
- it('should extract postId from data.postId when data.id is absent', async () => {
184
- const executor = createMockExecutor({
185
- linkedinPost: {
186
- success: true,
187
- data: { postId: 'li-555', url: 'https://linkedin.com/post/555' },
188
- },
189
- });
190
- tool.setToolExecutor(executor);
191
-
192
- const result = await tool.execute({
193
- content: 'LinkedIn update',
194
- platforms: ['linkedin'],
195
- });
196
-
197
- expect(result.data!.results[0]!.postId).toBe('li-555');
198
- });
199
- });
200
-
201
- /* ── Per-platform content overrides ───────────────────────────── */
202
-
203
- describe('per-platform content adaptations', () => {
204
- it('should use explicit per-platform overrides when provided', async () => {
205
- const executor = createMockExecutor();
206
- tool.setToolExecutor(executor);
207
-
208
- await tool.execute({
209
- content: 'Default content',
210
- platforms: ['twitter', 'instagram'],
211
- mediaUrls: ['https://cdn.example.com/adapt-image.jpg'],
212
- adaptations: {
213
- twitter: 'A short tweet',
214
- instagram: 'A longer Instagram caption with details',
215
- },
216
- });
217
-
218
- expect(executor).toHaveBeenCalledWith('twitterPost', expect.objectContaining({ text: 'A short tweet' }));
219
- expect(executor).toHaveBeenCalledWith('instagramPost', expect.objectContaining({ caption: 'A longer Instagram caption with details' }));
220
- });
221
- });
222
-
223
- /* ── Content adaptation (auto) ────────────────────────────────── */
224
-
225
- describe('automatic content adaptation', () => {
226
- it('should truncate content that exceeds platform character limit', async () => {
227
- const longContent = 'A'.repeat(300); // exceeds Twitter's 280 limit
228
- const result = await tool.execute({
229
- content: longContent,
230
- platforms: ['twitter'],
231
- dryRun: true,
232
- });
233
-
234
- const adapted = result.data!.results[0]!.adaptedContent!;
235
- expect(adapted.length).toBeLessThanOrEqual(280);
236
- expect(adapted.endsWith('...')).toBe(true);
237
- });
238
-
239
- it('should not truncate content within platform limits', async () => {
240
- const shortContent = 'Short post';
241
- const result = await tool.execute({
242
- content: shortContent,
243
- platforms: ['twitter'],
244
- dryRun: true,
245
- });
246
-
247
- expect(result.data!.results[0]!.adaptedContent).toBe('Short post');
248
- });
249
-
250
- it('should append hashtags inline for Twitter', async () => {
251
- const result = await tool.execute({
252
- content: 'Great day',
253
- platforms: ['twitter'],
254
- hashtags: ['awesome', '#blessed'],
255
- dryRun: true,
256
- });
257
-
258
- const adapted = result.data!.results[0]!.adaptedContent!;
259
- expect(adapted).toBe('Great day #awesome #blessed');
260
- });
261
-
262
- it('should append hashtags as footer for Instagram', async () => {
263
- const result = await tool.execute({
264
- content: 'Check this out',
265
- platforms: ['instagram'],
266
- hashtags: ['photography', 'nature'],
267
- dryRun: true,
268
- });
269
-
270
- const adapted = result.data!.results[0]!.adaptedContent!;
271
- expect(adapted).toBe('Check this out\n\n#photography #nature');
272
- });
273
-
274
- it('should not append hashtags for platforms with none style', async () => {
275
- const result = await tool.execute({
276
- content: 'Reddit post',
277
- platforms: ['reddit'],
278
- hashtags: ['test'],
279
- dryRun: true,
280
- });
281
-
282
- const adapted = result.data!.results[0]!.adaptedContent!;
283
- expect(adapted).toBe('Reddit post');
284
- });
285
- });
286
-
287
- /* ── Partial failures ─────────────────────────────────────────── */
288
-
289
- describe('partial failures', () => {
290
- it('should handle a mix of successes and failures', async () => {
291
- const executor = createMockExecutor({
292
- twitterPost: { success: true, data: { id: 'tw-1' } },
293
- linkedinPost: { success: false, error: 'LinkedIn API is down' },
294
- facebookPost: { success: true, data: { id: 'fb-1' } },
295
- });
296
- tool.setToolExecutor(executor);
297
-
298
- const result = await tool.execute({
299
- content: 'Hello',
300
- platforms: ['twitter', 'linkedin', 'facebook'],
301
- });
302
-
303
- expect(result.success).toBe(true); // at least one succeeded
304
- expect(result.data!.successful).toBe(2);
305
- expect(result.data!.failed).toBe(1);
306
-
307
- const linkedinResult = result.data!.results.find((r) => r.platform === 'linkedin')!;
308
- expect(linkedinResult.success).toBe(false);
309
- expect(linkedinResult.error).toBe('LinkedIn API is down');
310
- });
311
-
312
- it('should report success=false when ALL platforms fail', async () => {
313
- const executor = createMockExecutor({
314
- twitterPost: { success: false, error: 'Fail 1' },
315
- linkedinPost: { success: false, error: 'Fail 2' },
316
- });
317
- tool.setToolExecutor(executor);
318
-
319
- const result = await tool.execute({
320
- content: 'Hello',
321
- platforms: ['twitter', 'linkedin'],
322
- });
323
-
324
- expect(result.success).toBe(false);
325
- expect(result.data!.successful).toBe(0);
326
- expect(result.data!.failed).toBe(2);
327
- });
328
-
329
- it('should catch thrown errors from executor and report them gracefully', async () => {
330
- const executor = vi.fn(async () => {
331
- throw new Error('Network timeout');
332
- });
333
- tool.setToolExecutor(executor);
334
-
335
- const result = await tool.execute({
336
- content: 'Hello',
337
- platforms: ['twitter'],
338
- });
339
-
340
- expect(result.success).toBe(false);
341
- expect(result.data!.results[0]!.error).toBe('Network timeout');
342
- });
343
-
344
- it('should catch non-Error thrown values', async () => {
345
- const executor = vi.fn(async () => {
346
- throw 'string error'; // eslint-disable-line no-throw-literal
347
- });
348
- tool.setToolExecutor(executor);
349
-
350
- const result = await tool.execute({
351
- content: 'Hello',
352
- platforms: ['twitter'],
353
- });
354
-
355
- expect(result.data!.results[0]!.error).toBe('string error');
356
- });
357
- });
358
-
359
- /* ── Missing executor ─────────────────────────────────────────── */
360
-
361
- describe('missing tool executor', () => {
362
- it('should report per-platform errors when no executor is set', async () => {
363
- const result = await tool.execute({
364
- content: 'No executor available',
365
- platforms: ['twitter', 'linkedin'],
366
- });
367
-
368
- expect(result.success).toBe(false);
369
- expect(result.data!.failed).toBe(2);
370
- for (const r of result.data!.results) {
371
- expect(r.success).toBe(false);
372
- expect(r.error).toContain('No tool executor available');
373
- }
374
- });
375
- });
376
-
377
- /* ── Tool name mapping ────────────────────────────────────────── */
378
-
379
- describe('tool name mapping', () => {
380
- it('should map known platforms to their expected tool names', async () => {
381
- const executor = createMockExecutor();
382
- tool.setToolExecutor(executor);
383
-
384
- const platforms = ['twitter', 'instagram', 'reddit', 'youtube', 'bluesky', 'mastodon', 'threads'];
385
- const expectedToolNames = [
386
- 'twitterPost', 'instagramPost', 'redditSubmitPost', 'youtubeUpload',
387
- 'blueskyPost', 'mastodonPost', 'threadsPost',
388
- ];
389
-
390
- await tool.execute({
391
- content: 'Test',
392
- platforms,
393
- mediaUrls: ['https://cdn.example.com/tool-map-media.mp4'],
394
- });
395
-
396
- for (const expected of expectedToolNames) {
397
- expect(executor).toHaveBeenCalledWith(expected, expect.any(Object));
398
- }
399
- });
400
-
401
- it('should fallback to "<platform>Post" for unknown platforms', async () => {
402
- const executor = createMockExecutor();
403
- tool.setToolExecutor(executor);
404
-
405
- await tool.execute({
406
- content: 'Test',
407
- platforms: ['myplatform'],
408
- });
409
-
410
- expect(executor).toHaveBeenCalledWith('myplatformPost', expect.any(Object));
411
- });
412
- });
413
-
414
- /* ── Platform-specific arg building ───────────────────────────── */
415
-
416
- describe('platform-specific tool arguments', () => {
417
- it('should build twitter args with text field', async () => {
418
- const executor = createMockExecutor();
419
- tool.setToolExecutor(executor);
420
- await tool.execute({ content: 'Tweet!', platforms: ['twitter'] });
421
- expect(executor).toHaveBeenCalledWith('twitterPost', expect.objectContaining({ text: 'Tweet!' }));
422
- });
423
-
424
- it('should build instagram args with caption + imageUrls', async () => {
425
- const executor = createMockExecutor();
426
- tool.setToolExecutor(executor);
427
- await tool.execute({
428
- content: 'IG post',
429
- platforms: ['instagram'],
430
- mediaUrls: ['https://cdn.example.com/photo.jpg'],
431
- });
432
- expect(executor).toHaveBeenCalledWith(
433
- 'instagramPost',
434
- expect.objectContaining({
435
- caption: 'IG post',
436
- imageUrls: ['https://cdn.example.com/photo.jpg'],
437
- }),
438
- );
439
- });
440
-
441
- it('should build reddit args with required content/type fields', async () => {
442
- const executor = createMockExecutor();
443
- tool.setToolExecutor(executor);
444
- await tool.execute({ content: 'Reddit thread', platforms: ['reddit'] });
445
- expect(executor).toHaveBeenCalledWith(
446
- 'redditSubmitPost',
447
- expect.objectContaining({
448
- subreddit: 'self',
449
- title: expect.any(String),
450
- content: 'Reddit thread',
451
- type: 'text',
452
- }),
453
- );
454
- });
455
-
456
- it('should build youtube args with required videoUrl', async () => {
457
- const executor = createMockExecutor();
458
- tool.setToolExecutor(executor);
459
- await tool.execute({
460
- content: 'Video description',
461
- platforms: ['youtube'],
462
- mediaUrls: ['https://cdn.example.com/video.mp4'],
463
- });
464
- expect(executor).toHaveBeenCalledWith(
465
- 'youtubeUpload',
466
- expect.objectContaining({
467
- videoUrl: 'https://cdn.example.com/video.mp4',
468
- title: expect.any(String),
469
- description: 'Video description',
470
- }),
471
- );
472
- });
473
-
474
- it('should fail youtube post when no video URL is provided', async () => {
475
- const executor = createMockExecutor();
476
- tool.setToolExecutor(executor);
477
-
478
- const result = await tool.execute({
479
- content: 'Video description',
480
- platforms: ['youtube'],
481
- });
482
-
483
- expect(result.success).toBe(false);
484
- expect(result.data!.results[0]!.error).toContain('youtube requires videoUrl');
485
- expect(executor).not.toHaveBeenCalled();
486
- });
487
-
488
- it('should allow platformConfigs overrides for pinterest', async () => {
489
- const executor = createMockExecutor();
490
- tool.setToolExecutor(executor);
491
-
492
- await tool.execute({
493
- content: 'Pin this',
494
- platforms: ['pinterest'],
495
- mediaUrls: ['https://cdn.example.com/image.jpg'],
496
- platformConfigs: {
497
- pinterest: {
498
- boardId: 'board-123',
499
- mediaType: 'image',
500
- },
501
- },
502
- });
503
-
504
- expect(executor).toHaveBeenCalledWith(
505
- 'pinterestPin',
506
- expect.objectContaining({
507
- boardId: 'board-123',
508
- mediaType: 'image',
509
- mediaUrl: 'https://cdn.example.com/image.jpg',
510
- }),
511
- );
512
- });
513
-
514
- it('should fail lemmy post when communityId is missing', async () => {
515
- const executor = createMockExecutor();
516
- tool.setToolExecutor(executor);
517
-
518
- const result = await tool.execute({
519
- content: 'Lemmy post',
520
- platforms: ['lemmy'],
521
- });
522
-
523
- expect(result.success).toBe(false);
524
- expect(result.data!.results[0]!.error).toContain('lemmy requires communityId');
525
- expect(executor).not.toHaveBeenCalled();
526
- });
527
- });
528
- });
package/tsconfig.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ESNext",
5
- "lib": ["ES2022"],
6
- "outDir": "./dist",
7
- "rootDir": "./src",
8
- "strict": true,
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "noEmitOnError": false,
12
- "forceConsistentCasingInFileNames": true,
13
- "declaration": true,
14
- "declarationMap": true,
15
- "sourceMap": true,
16
- "resolveJsonModule": true,
17
- "moduleResolution": "Bundler",
18
- "types": ["node"]
19
- },
20
- "include": ["src/**/*"],
21
- "exclude": ["node_modules", "dist", "test"]
22
- }