@viral_vector/ig-mcp 0.1.0

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.
@@ -0,0 +1,1466 @@
1
+ import { z } from 'zod';
2
+ import { BRIDGE_ERROR_CODES, CAPABILITIES } from '../bridge/protocol.js';
3
+ const MEDIA_SOURCE_VALUES = ['posts', 'reels', 'stories', 'highlights'];
4
+ const REFRESH_DATA_VALUES = ['profile', 'followers', 'following', 'media'];
5
+ const AUDIENCE_KIND_VALUES = ['followers', 'following'];
6
+ const LIBRARY_INCLUDE_VALUES = ['summary', 'accounts', 'hashtags'];
7
+ const ACCOUNT_INCLUDE_VALUES = [
8
+ 'profile_snapshot',
9
+ 'engagement_snapshot',
10
+ 'audience_summary',
11
+ 'media_summary',
12
+ 'recent_media',
13
+ 'exports',
14
+ 'actions'
15
+ ];
16
+ const HASHTAG_INCLUDE_VALUES = [
17
+ 'snapshot',
18
+ 'media_summary',
19
+ 'recent_media',
20
+ 'creators',
21
+ 'related',
22
+ 'exports',
23
+ 'actions'
24
+ ];
25
+ const MEDIA_INCLUDE_VALUES = ['download_status'];
26
+ const OPERATION_KIND_VALUES = [
27
+ 'account_track',
28
+ 'account_update_role',
29
+ 'account_untrack',
30
+ 'account_refresh',
31
+ 'hashtag_track',
32
+ 'hashtag_untrack',
33
+ 'hashtag_refresh',
34
+ 'media_comments_refresh',
35
+ 'media_download',
36
+ 'export_create'
37
+ ];
38
+ const OPERATION_STATUS_VALUES = ['queued', 'running', 'completed', 'failed', 'cancelled'];
39
+ const baseStatusSchema = z.object({
40
+ connected: z.boolean(),
41
+ clientId: z.string().nullable().optional(),
42
+ extensionVersion: z.string().nullable().optional(),
43
+ connectedAt: z.string().nullable().optional(),
44
+ lastSeenAt: z.string().nullable().optional()
45
+ });
46
+ const includeSchema = z.array(z.enum(['exports', 'actions'])).optional();
47
+ const readOnlyAnnotations = {
48
+ readOnlyHint: true,
49
+ destructiveHint: false,
50
+ idempotentHint: true,
51
+ openWorldHint: false
52
+ };
53
+ const liveReadOnlyAnnotations = {
54
+ readOnlyHint: true,
55
+ destructiveHint: false,
56
+ idempotentHint: true,
57
+ openWorldHint: true
58
+ };
59
+ const operationAnnotations = {
60
+ readOnlyHint: false,
61
+ destructiveHint: false,
62
+ idempotentHint: false,
63
+ openWorldHint: true
64
+ };
65
+ export const V2_TOOL_DEFINITIONS = [
66
+ {
67
+ name: 'vv_status',
68
+ title: 'Viral Vector Status',
69
+ description: 'Returns local bridge and Instagram session status for the ViralVector Instagram library.',
70
+ inputSchema: {},
71
+ annotations: readOnlyAnnotations,
72
+ handler: async (_input, registry) => {
73
+ const connection = registry.getStatus();
74
+ const session = await requestCapability(registry, CAPABILITIES.SOCIAL_SESSION_STATUS, {});
75
+ if (!session.ok) {
76
+ return errorToolResponse(session);
77
+ }
78
+ return jsonToolResponse({
79
+ bridge: summarizeConnection(connection),
80
+ session: session.data
81
+ });
82
+ }
83
+ },
84
+ {
85
+ name: 'vv_library_query',
86
+ title: 'Viral Vector Library Query',
87
+ description: 'Queries locally saved Instagram accounts and hashtags, with optional library summary.',
88
+ inputSchema: {
89
+ query: z.string().optional(),
90
+ include: z.array(z.enum(LIBRARY_INCLUDE_VALUES)).optional(),
91
+ limit: z.number().int().nonnegative().optional(),
92
+ offset: z.number().int().nonnegative().optional()
93
+ },
94
+ annotations: readOnlyAnnotations,
95
+ handler: async (input, registry) => {
96
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
97
+ if (!response.ok) {
98
+ return errorToolResponse(response);
99
+ }
100
+ const value = asObject(response.data);
101
+ const query = stringOrNull(input.query)?.toLowerCase() ?? '';
102
+ const include = asStringArray(input.include);
103
+ const includeAccounts = include.length === 0 || include.includes('accounts');
104
+ const includeHashtags = include.length === 0 || include.includes('hashtags');
105
+ const includeSummary = include.length === 0 || include.includes('summary');
106
+ const accounts = includeAccounts
107
+ ? paginate(asArray(value.accounts).filter((item) => !query || matchesSearch(item, query)).map(summarizeAccount), input)
108
+ : [];
109
+ const hashtags = includeHashtags
110
+ ? paginate(asArray(value.hashtags).filter((item) => !query || matchesSearch(item, query)).map(summarizeHashtag), input)
111
+ : [];
112
+ return jsonToolResponse(queryEnvelope(input, {
113
+ results: {
114
+ accounts,
115
+ hashtags
116
+ },
117
+ included: {
118
+ summary: includeSummary ? {
119
+ accounts: asArray(value.accounts).length,
120
+ hashtags: asArray(value.hashtags).length,
121
+ profileSnapshots: asArray(value.profileSnapshots).length,
122
+ hashtagSnapshots: asArray(value.hashtagSnapshots).length
123
+ } : undefined
124
+ }
125
+ }));
126
+ }
127
+ },
128
+ {
129
+ name: 'vv_account_query',
130
+ title: 'Viral Vector Account Query',
131
+ description: 'Queries saved Instagram accounts or returns account context with optional snapshots, summaries, recent media, exports, and actions.',
132
+ inputSchema: {
133
+ username: z.string().min(1).optional(),
134
+ query: z.string().optional(),
135
+ role: z.enum(['my_account', 'competitor', 'benchmark', 'creator', 'uncategorized']).optional(),
136
+ include: z.array(z.enum(ACCOUNT_INCLUDE_VALUES)).optional(),
137
+ mediaLimit: z.number().int().nonnegative().optional(),
138
+ limit: z.number().int().nonnegative().optional(),
139
+ offset: z.number().int().nonnegative().optional()
140
+ },
141
+ annotations: readOnlyAnnotations,
142
+ handler: async (input, registry) => {
143
+ if (typeof input.username === 'string' && input.username.trim()) {
144
+ const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
145
+ username: input.username,
146
+ postsLimit: input.mediaLimit
147
+ });
148
+ if (!detail.ok) {
149
+ return errorToolResponse(detail);
150
+ }
151
+ const exportsResult = includesV2(input, 'exports')
152
+ ? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_EXPORTS, {
153
+ targetType: 'account',
154
+ targetHandle: input.username
155
+ })
156
+ : null;
157
+ if (exportsResult && !exportsResult.ok) {
158
+ return errorToolResponse(exportsResult);
159
+ }
160
+ return jsonToolResponse(queryEnvelope(input, {
161
+ item: buildAccountQueryItem(asObject(detail.data), input, exportsResult?.data)
162
+ }));
163
+ }
164
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
165
+ if (!response.ok) {
166
+ return errorToolResponse(response);
167
+ }
168
+ const query = stringOrNull(input.query)?.toLowerCase() ?? '';
169
+ const role = typeof input.role === 'string' ? input.role : null;
170
+ const accounts = asArray(asObject(response.data).accounts)
171
+ .map(asObject)
172
+ .filter((item) => !role || String(item.targetRole ?? 'uncategorized') === role)
173
+ .filter((item) => !query || matchesSearch(item, query))
174
+ .map(summarizeAccount);
175
+ return jsonToolResponse(queryEnvelope(input, {
176
+ results: {
177
+ accounts: paginate(accounts, input)
178
+ }
179
+ }));
180
+ }
181
+ },
182
+ {
183
+ name: 'vv_account_audience_query',
184
+ title: 'Viral Vector Account Audience Query',
185
+ description: 'Queries saved followers or following records for an Instagram account.',
186
+ inputSchema: {
187
+ username: z.string().min(1),
188
+ kind: z.enum(AUDIENCE_KIND_VALUES),
189
+ query: z.string().optional(),
190
+ privacy: z.enum(['public', 'private', 'unknown']).optional(),
191
+ verification: z.enum(['verified', 'unverified', 'unknown']).optional(),
192
+ limit: z.number().int().nonnegative().optional(),
193
+ offset: z.number().int().nonnegative().optional()
194
+ },
195
+ annotations: readOnlyAnnotations,
196
+ handler: async (input, registry) => queryAudience(input, registry)
197
+ },
198
+ {
199
+ name: 'vv_hashtag_query',
200
+ title: 'Viral Vector Hashtag Query',
201
+ description: 'Queries saved Instagram hashtags or returns hashtag context with optional snapshot, media, creators, related hashtags, exports, and actions.',
202
+ inputSchema: {
203
+ handle: z.string().min(1).optional(),
204
+ query: z.string().optional(),
205
+ include: z.array(z.enum(HASHTAG_INCLUDE_VALUES)).optional(),
206
+ mediaLimit: z.number().int().nonnegative().optional(),
207
+ limit: z.number().int().nonnegative().optional(),
208
+ offset: z.number().int().nonnegative().optional()
209
+ },
210
+ annotations: readOnlyAnnotations,
211
+ handler: async (input, registry) => {
212
+ if (typeof input.handle === 'string' && input.handle.trim()) {
213
+ const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_HASHTAG_DETAIL, {
214
+ handle: input.handle,
215
+ postsLimit: input.mediaLimit
216
+ });
217
+ if (!detail.ok) {
218
+ return errorToolResponse(detail);
219
+ }
220
+ const exportsResult = includesV2(input, 'exports')
221
+ ? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_EXPORTS, {
222
+ targetType: 'hashtag',
223
+ targetHandle: input.handle
224
+ })
225
+ : null;
226
+ if (exportsResult && !exportsResult.ok) {
227
+ return errorToolResponse(exportsResult);
228
+ }
229
+ return jsonToolResponse(queryEnvelope(input, {
230
+ item: buildHashtagQueryItem(asObject(detail.data), input, exportsResult?.data)
231
+ }));
232
+ }
233
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
234
+ if (!response.ok) {
235
+ return errorToolResponse(response);
236
+ }
237
+ const query = stringOrNull(input.query)?.toLowerCase() ?? '';
238
+ const hashtags = asArray(asObject(response.data).hashtags)
239
+ .filter((item) => !query || matchesSearch(item, query))
240
+ .map(summarizeHashtag);
241
+ return jsonToolResponse(queryEnvelope(input, {
242
+ results: {
243
+ hashtags: paginate(hashtags, input)
244
+ }
245
+ }));
246
+ }
247
+ },
248
+ {
249
+ name: 'vv_media_query',
250
+ title: 'Viral Vector Media Query',
251
+ description: 'Queries saved account or hashtag media, or returns a single media item by media id or shortcode.',
252
+ inputSchema: {
253
+ account: z.string().min(1).optional(),
254
+ hashtag: z.string().min(1).optional(),
255
+ mediaId: z.string().min(1).optional(),
256
+ shortcode: z.string().min(1).optional(),
257
+ sources: z.array(z.enum(MEDIA_SOURCE_VALUES)).optional(),
258
+ include: z.array(z.enum(MEDIA_INCLUDE_VALUES)).optional(),
259
+ limit: z.number().int().nonnegative().optional(),
260
+ offset: z.number().int().nonnegative().optional()
261
+ },
262
+ annotations: readOnlyAnnotations,
263
+ handler: async (input, registry) => queryMedia(input, registry)
264
+ },
265
+ {
266
+ name: 'vv_comments_query',
267
+ title: 'Viral Vector Comments Query',
268
+ description: 'Queries saved comments for a media item by Instagram shortcode.',
269
+ inputSchema: {
270
+ shortcode: z.string().min(1),
271
+ limit: z.number().int().nonnegative().optional(),
272
+ offset: z.number().int().nonnegative().optional()
273
+ },
274
+ annotations: readOnlyAnnotations,
275
+ handler: async (input, registry) => {
276
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_POST_DETAIL, {
277
+ shortcode: input.shortcode,
278
+ commentsLimit: input.limit
279
+ });
280
+ if (!response.ok) {
281
+ return errorToolResponse(response);
282
+ }
283
+ const value = asObject(response.data);
284
+ return jsonToolResponse(queryEnvelope(input, {
285
+ item: summarizeMediaFromPost(value.post),
286
+ results: {
287
+ comments: paginate(asArray(value.comments), input)
288
+ }
289
+ }));
290
+ }
291
+ },
292
+ {
293
+ name: 'vv_operation_run',
294
+ title: 'Viral Vector Operation Run',
295
+ description: 'Runs or dry-runs local library mutations, refreshes, downloads, and exports. Untrack operations delete local tracked targets only; they do not delete Instagram content.',
296
+ inputSchema: {
297
+ kind: z.enum(OPERATION_KIND_VALUES),
298
+ input: z.record(z.string(), z.unknown()),
299
+ dryRun: z.boolean().optional(),
300
+ idempotencyKey: z.string().optional()
301
+ },
302
+ annotations: operationAnnotations,
303
+ handler: async (input, registry) => runOperation(input, registry)
304
+ },
305
+ {
306
+ name: 'vv_operation_query',
307
+ title: 'Viral Vector Operation Query',
308
+ description: 'Queries collection operation status and recent operation history.',
309
+ inputSchema: {
310
+ operationId: z.string().min(1).optional(),
311
+ kind: z.string().optional(),
312
+ status: z.enum(OPERATION_STATUS_VALUES).optional(),
313
+ account: z.string().min(1).optional(),
314
+ hashtag: z.string().min(1).optional(),
315
+ shortcode: z.string().min(1).optional(),
316
+ limit: z.number().int().nonnegative().optional(),
317
+ offset: z.number().int().nonnegative().optional()
318
+ },
319
+ annotations: readOnlyAnnotations,
320
+ handler: async (input, registry) => queryOperations(input, registry)
321
+ },
322
+ {
323
+ name: 'ig_search',
324
+ title: 'Instagram Live Search',
325
+ description: 'Temporarily searches Instagram live candidates for accounts or hashtags. Results are not saved or tracked; use vv_operation_run to persist a chosen candidate.',
326
+ inputSchema: {
327
+ query: z.string().min(1),
328
+ targets: z.array(z.enum(['accounts', 'hashtags'])).min(1).optional(),
329
+ limit: z.number().int().min(1).max(20).optional()
330
+ },
331
+ annotations: liveReadOnlyAnnotations,
332
+ handler: async (input, registry) => searchInstagram(input, registry)
333
+ },
334
+ {
335
+ name: 'ig_lookup',
336
+ title: 'Instagram Live Lookup',
337
+ description: 'Temporarily looks up one Instagram account or hashtag with optional recent media and related hashtag context. Results are not saved or tracked.',
338
+ inputSchema: {
339
+ type: z.enum(['account', 'hashtag']),
340
+ username: z.string().min(1).optional(),
341
+ hashtag: z.string().min(1).optional(),
342
+ include: z.array(z.enum(['recent_media', 'related'])).optional(),
343
+ mediaLimit: z.number().int().min(1).max(20).optional()
344
+ },
345
+ annotations: liveReadOnlyAnnotations,
346
+ handler: async (input, registry) => lookupInstagram(input, registry)
347
+ }
348
+ ];
349
+ export const MCP_TOOL_DEFINITIONS = V2_TOOL_DEFINITIONS;
350
+ export function registerMcpTools(server, registry) {
351
+ for (const definition of MCP_TOOL_DEFINITIONS) {
352
+ server.registerTool(definition.name, {
353
+ title: definition.title,
354
+ description: definition.description,
355
+ inputSchema: definition.inputSchema,
356
+ annotations: definition.annotations
357
+ }, async (input) => definition.handler(input, registry));
358
+ }
359
+ }
360
+ async function requestCapability(registry, capability, input) {
361
+ return registry.request(capability, input);
362
+ }
363
+ function queryEnvelope(input, payload) {
364
+ return {
365
+ query: sanitizeQueryInput(input),
366
+ ...payload,
367
+ page: buildPage(input)
368
+ };
369
+ }
370
+ function sanitizeQueryInput(input) {
371
+ return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
372
+ }
373
+ function buildPage(input) {
374
+ return {
375
+ limit: numberOrNull(input.limit),
376
+ offset: numberOrNull(input.offset) ?? 0
377
+ };
378
+ }
379
+ function paginate(items, input) {
380
+ const offset = Math.max(0, Math.trunc(numberOrNull(input.offset) ?? 0));
381
+ const limit = numberOrNull(input.limit);
382
+ return typeof limit === 'number'
383
+ ? items.slice(offset, offset + Math.max(0, Math.trunc(limit)))
384
+ : items.slice(offset);
385
+ }
386
+ function includesV2(input, value) {
387
+ return Array.isArray(input.include) && input.include.includes(value);
388
+ }
389
+ function includeAny(input, values) {
390
+ return values.some((value) => includesV2(input, value));
391
+ }
392
+ function buildAccountQueryItem(detail, input, exportsData) {
393
+ const item = {
394
+ account: summarizeAccount(detail.account)
395
+ };
396
+ const included = {};
397
+ if (includesV2(input, 'profile_snapshot')) {
398
+ included.profileSnapshot = detail.profileSnapshot ?? null;
399
+ }
400
+ if (includesV2(input, 'engagement_snapshot')) {
401
+ included.engagementSnapshot = detail.engagementSnapshot ?? null;
402
+ }
403
+ if (includesV2(input, 'audience_summary')) {
404
+ included.audience = buildAccountAudience(detail);
405
+ }
406
+ if (includesV2(input, 'media_summary')) {
407
+ included.mediaSummary = buildAccountMediaSummary(detail);
408
+ }
409
+ if (includesV2(input, 'recent_media')) {
410
+ included.recentMedia = paginate(buildAccountMediaViews(detail), {
411
+ limit: input.mediaLimit ?? input.limit,
412
+ offset: input.offset
413
+ });
414
+ }
415
+ if (exportsData !== undefined) {
416
+ included.exports = asArray(asObject(exportsData).exports).map(summarizeExport);
417
+ }
418
+ if (includesV2(input, 'actions')) {
419
+ included.actions = buildActions(asArray(detail.tasks));
420
+ }
421
+ if (Object.keys(included).length > 0) {
422
+ item.included = included;
423
+ }
424
+ return item;
425
+ }
426
+ function buildHashtagQueryItem(detail, input, exportsData) {
427
+ const item = {
428
+ hashtag: summarizeHashtag(detail.hashtag ?? { handle: input.handle })
429
+ };
430
+ const included = {};
431
+ if (includesV2(input, 'snapshot')) {
432
+ included.snapshot = detail.snapshot ?? null;
433
+ }
434
+ if (includesV2(input, 'media_summary')) {
435
+ included.mediaSummary = {
436
+ count: buildHashtagMediaViews(detail).length
437
+ };
438
+ }
439
+ if (includesV2(input, 'recent_media')) {
440
+ included.recentMedia = paginate(buildHashtagMediaViews(detail), {
441
+ limit: input.mediaLimit ?? input.limit,
442
+ offset: input.offset
443
+ });
444
+ }
445
+ if (includesV2(input, 'creators')) {
446
+ included.creators = detail.creators ?? [];
447
+ }
448
+ if (includesV2(input, 'related')) {
449
+ included.related = detail.relatedHashtags ?? [];
450
+ }
451
+ if (exportsData !== undefined) {
452
+ included.exports = asArray(asObject(exportsData).exports).map(summarizeExport);
453
+ }
454
+ if (includesV2(input, 'actions')) {
455
+ included.actions = buildActions(asArray(detail.tasks));
456
+ }
457
+ if (Object.keys(included).length > 0) {
458
+ item.included = included;
459
+ }
460
+ return item;
461
+ }
462
+ async function queryAudience(input, registry) {
463
+ const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
464
+ username: input.username,
465
+ postsLimit: 0
466
+ });
467
+ if (!detail.ok) {
468
+ return errorToolResponse(detail);
469
+ }
470
+ const task = findLatestTaskForAudience(asArray(asObject(detail.data).tasks), String(input.kind));
471
+ if (!task) {
472
+ return jsonToolResponse(queryEnvelope(input, {
473
+ results: {
474
+ audience: []
475
+ },
476
+ included: {
477
+ stats: null
478
+ }
479
+ }));
480
+ }
481
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_QUERY_AUDIENCE_MEMBERS, {
482
+ taskId: task.id,
483
+ query: input.query,
484
+ privacy: input.privacy,
485
+ verification: input.verification,
486
+ limit: input.limit,
487
+ offset: input.offset
488
+ });
489
+ if (!response.ok) {
490
+ return errorToolResponse(response);
491
+ }
492
+ return jsonToolResponse(queryEnvelope(input, {
493
+ results: {
494
+ audience: asObject(response.data).records ?? []
495
+ },
496
+ included: {
497
+ stats: asObject(response.data).stats ?? null
498
+ }
499
+ }));
500
+ }
501
+ async function queryMedia(input, registry) {
502
+ const hasAccount = typeof input.account === 'string' && input.account.trim();
503
+ const hasHashtag = typeof input.hashtag === 'string' && input.hashtag.trim();
504
+ if (hasAccount && hasHashtag) {
505
+ return validationError('vv_media_query accepts either account or hashtag, not both.');
506
+ }
507
+ if (!hasAccount && !hasHashtag) {
508
+ return validationError('vv_media_query requires account or hashtag.');
509
+ }
510
+ const response = hasAccount
511
+ ? await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
512
+ username: input.account,
513
+ postsLimit: input.limit ?? 100
514
+ })
515
+ : await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_HASHTAG_DETAIL, {
516
+ handle: input.hashtag,
517
+ postsLimit: input.limit ?? 100
518
+ });
519
+ if (!response.ok) {
520
+ return errorToolResponse(response);
521
+ }
522
+ const media = hasAccount
523
+ ? filterMediaViews(buildAccountMediaViews(asObject(response.data)), asStringArray(input.sources))
524
+ : buildHashtagMediaViews(asObject(response.data));
525
+ const selected = findMediaView(media, input.mediaId, input.shortcode);
526
+ if (typeof input.mediaId === 'string' || typeof input.shortcode === 'string') {
527
+ return jsonToolResponse(queryEnvelope(input, { item: selected }));
528
+ }
529
+ return jsonToolResponse(queryEnvelope(input, {
530
+ results: {
531
+ media: paginate(media, input)
532
+ }
533
+ }));
534
+ }
535
+ async function runOperation(input, registry) {
536
+ const kind = String(input.kind ?? '');
537
+ const operationInput = asObject(input.input);
538
+ const normalizedInput = normalizeOperationInput(kind, operationInput);
539
+ if ('error' in normalizedInput) {
540
+ return validationError(String(normalizedInput.error));
541
+ }
542
+ const effects = describeOperationEffects(kind, normalizedInput.value);
543
+ if (input.dryRun === true) {
544
+ return jsonToolResponse({
545
+ dryRun: true,
546
+ kind,
547
+ normalizedInput: normalizedInput.value,
548
+ effects,
549
+ warnings: []
550
+ });
551
+ }
552
+ const result = await executeOperation(kind, normalizedInput.value, registry);
553
+ if (!result.ok) {
554
+ return errorToolResponse(result);
555
+ }
556
+ return jsonToolResponse({
557
+ dryRun: false,
558
+ kind,
559
+ normalizedInput: normalizedInput.value,
560
+ effects,
561
+ ...buildOperationRunPayload(kind, normalizedInput.value, result.data)
562
+ });
563
+ }
564
+ async function queryOperations(input, registry) {
565
+ const target = buildOperationQueryTarget(input);
566
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_LIST_TASKS, {
567
+ operationId: input.operationId,
568
+ targetHandle: target?.handle,
569
+ targetType: target?.type,
570
+ limit: input.limit
571
+ });
572
+ if (!response.ok) {
573
+ return errorToolResponse(response);
574
+ }
575
+ const tasks = asArray(asObject(response.data).tasks)
576
+ .map(asObject)
577
+ .filter((task) => !input.status || normalizeActionStatus(task.status) === input.status)
578
+ .filter((task) => !input.kind || operationKindFromCapability(String(task.capability ?? '')) === input.kind);
579
+ const operations = paginate(buildActions(tasks), input);
580
+ if (typeof input.operationId === 'string' && input.operationId.trim()) {
581
+ return jsonToolResponse(queryEnvelope(input, {
582
+ operation: operations[0] ?? null
583
+ }));
584
+ }
585
+ return jsonToolResponse(queryEnvelope(input, {
586
+ operations
587
+ }));
588
+ }
589
+ function normalizeOperationInput(kind, input) {
590
+ switch (kind) {
591
+ case 'account_track':
592
+ return requiredObject({
593
+ username: normalizeRequiredString(input.username, 'username'),
594
+ role: input.role
595
+ });
596
+ case 'account_update_role':
597
+ return requiredObject({
598
+ username: normalizeRequiredString(input.username, 'username'),
599
+ role: normalizeRequiredString(input.role, 'role')
600
+ });
601
+ case 'account_untrack':
602
+ return requiredObject({
603
+ username: normalizeRequiredString(input.username, 'username')
604
+ });
605
+ case 'account_refresh':
606
+ return requiredObject({
607
+ username: normalizeRequiredString(input.username, 'username'),
608
+ data: Array.isArray(input.data) && input.data.length > 0 ? input.data : ['profile', 'media'],
609
+ limit: input.limit,
610
+ mediaSources: input.mediaSources
611
+ });
612
+ case 'hashtag_track':
613
+ case 'hashtag_untrack':
614
+ return requiredObject({
615
+ handle: normalizeRequiredString(input.handle, 'handle')
616
+ });
617
+ case 'hashtag_refresh':
618
+ return requiredObject({
619
+ handle: normalizeRequiredString(input.handle, 'handle'),
620
+ limit: input.limit
621
+ });
622
+ case 'media_comments_refresh':
623
+ return requiredObject({
624
+ shortcode: normalizeRequiredString(input.shortcode, 'shortcode'),
625
+ commentsLimit: input.commentsLimit ?? input.limit
626
+ });
627
+ case 'media_download':
628
+ return requiredObject({
629
+ mediaItemIds: Array.isArray(input.mediaItemIds) && input.mediaItemIds.length > 0 ? input.mediaItemIds : null,
630
+ action: input.action ?? 'queue'
631
+ });
632
+ case 'export_create':
633
+ return requiredObject({
634
+ target: asObject(input.target),
635
+ username: input.username,
636
+ handle: input.handle,
637
+ dataset: input.dataset ?? input.data,
638
+ limit: input.limit,
639
+ mediaSources: input.mediaSources,
640
+ commentsLimit: input.commentsLimit,
641
+ includeComments: input.includeComments,
642
+ includeMediaUrls: input.includeMediaUrls
643
+ });
644
+ default:
645
+ return { error: `Unsupported operation kind: ${kind}` };
646
+ }
647
+ }
648
+ function requiredObject(value) {
649
+ const invalid = Object.entries(value).find(([, field]) => field === null);
650
+ if (invalid) {
651
+ return { error: `vv_operation_run input requires ${invalid[0]}.` };
652
+ }
653
+ return { value };
654
+ }
655
+ async function searchInstagram(input, registry) {
656
+ const targets = asStringArray(input.targets);
657
+ const requestedTargets = targets.length > 0 ? targets : ['accounts', 'hashtags'];
658
+ const libraryPromise = requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {});
659
+ const accountsPromise = requestedTargets.includes('accounts')
660
+ ? requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_SEARCH_ACCOUNTS, { query: input.query, limit: input.limit })
661
+ : null;
662
+ const hashtagsPromise = requestedTargets.includes('hashtags')
663
+ ? requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_SEARCH_HASHTAGS, { query: input.query, limit: input.limit })
664
+ : null;
665
+ const [library, accountsResult, hashtagsResult] = await Promise.all([libraryPromise, accountsPromise, hashtagsPromise]);
666
+ if (!library.ok) {
667
+ return errorToolResponse(library);
668
+ }
669
+ for (const result of [accountsResult, hashtagsResult]) {
670
+ if (result && !result.ok) {
671
+ return errorToolResponse(result);
672
+ }
673
+ }
674
+ const libraryData = asObject(library.data);
675
+ const localAccounts = new Map(asArray(libraryData.accounts).map((item) => {
676
+ const account = asObject(item);
677
+ return [normalizeUsername(account.username), account];
678
+ }));
679
+ const localHashtags = new Map(asArray(libraryData.hashtags).map((item) => {
680
+ const hashtag = asObject(item);
681
+ return [normalizeHashtag(hashtag.handle), hashtag];
682
+ }));
683
+ const accounts = accountsResult?.ok
684
+ ? asArray(asObject(accountsResult.data).results).map((item) => {
685
+ const candidate = asObject(item);
686
+ const local = localAccounts.get(normalizeUsername(candidate.username));
687
+ return { ...candidate, inLibrary: Boolean(local), isTracked: Boolean(local), targetRole: local?.targetRole ?? null };
688
+ })
689
+ : [];
690
+ const hashtags = hashtagsResult?.ok
691
+ ? asArray(asObject(hashtagsResult.data).results).map((item) => {
692
+ const candidate = asObject(item);
693
+ const local = localHashtags.get(normalizeHashtag(candidate.handle));
694
+ return { ...candidate, inLibrary: Boolean(local), isTracked: Boolean(local) };
695
+ })
696
+ : [];
697
+ return jsonToolResponse({
698
+ query: stringOrNull(input.query) ?? '',
699
+ results: { accounts, hashtags }
700
+ });
701
+ }
702
+ async function lookupInstagram(input, registry) {
703
+ const type = input.type;
704
+ const include = asStringArray(input.include);
705
+ if (type === 'account' && include.includes('related')) {
706
+ return validationError('ig_lookup account include supports only recent_media.');
707
+ }
708
+ const capability = type === 'account'
709
+ ? CAPABILITIES.SOCIAL_INSTAGRAM_LOOKUP_ACCOUNT
710
+ : CAPABILITIES.SOCIAL_INSTAGRAM_LOOKUP_HASHTAG;
711
+ const identifier = type === 'account'
712
+ ? normalizeRequiredString(input.username, 'username')
713
+ : normalizeRequiredString(input.hashtag, 'hashtag');
714
+ if (!identifier) {
715
+ return validationError(`ig_lookup type ${String(type)} requires ${type === 'account' ? 'username' : 'hashtag'}.`);
716
+ }
717
+ const [live, library] = await Promise.all([
718
+ requestCapability(registry, capability, {
719
+ ...(type === 'account' ? { username: identifier } : { hashtag: identifier }),
720
+ include,
721
+ mediaLimit: input.mediaLimit
722
+ }),
723
+ requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_LIST_ACCOUNTS, {})
724
+ ]);
725
+ if (!live.ok) {
726
+ return errorToolResponse(live);
727
+ }
728
+ if (!library.ok) {
729
+ return errorToolResponse(library);
730
+ }
731
+ const data = asObject(live.data);
732
+ const libraryData = asObject(library.data);
733
+ if (type === 'account') {
734
+ const local = asArray(libraryData.accounts).map(asObject)
735
+ .find((account) => normalizeUsername(account.username) === normalizeUsername(identifier));
736
+ return jsonToolResponse({
737
+ item: {
738
+ type: 'account',
739
+ profile: data.profile ?? null,
740
+ inLibrary: Boolean(local),
741
+ isTracked: Boolean(local),
742
+ targetRole: local?.targetRole ?? null
743
+ },
744
+ included: asObject(data.included)
745
+ });
746
+ }
747
+ const local = asArray(libraryData.hashtags).map(asObject)
748
+ .find((hashtag) => normalizeHashtag(hashtag.handle) === normalizeHashtag(identifier));
749
+ return jsonToolResponse({
750
+ item: {
751
+ type: 'hashtag',
752
+ hashtag: data.hashtag ?? `#${normalizeHashtag(identifier)}`,
753
+ snapshot: data.snapshot ?? null,
754
+ inLibrary: Boolean(local),
755
+ isTracked: Boolean(local)
756
+ },
757
+ included: asObject(data.included)
758
+ });
759
+ }
760
+ function normalizeUsername(value) {
761
+ return typeof value === 'string' ? value.trim().replace(/^@/, '').toLowerCase() : '';
762
+ }
763
+ function normalizeHashtag(value) {
764
+ return typeof value === 'string' ? value.trim().replace(/^#+/, '').toLowerCase() : '';
765
+ }
766
+ function normalizeRequiredString(value, name) {
767
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
768
+ }
769
+ function describeOperationEffects(kind, input) {
770
+ switch (kind) {
771
+ case 'account_track':
772
+ return [`Track @${input.username} in the local Instagram library.`];
773
+ case 'account_update_role':
774
+ return [`Update local role metadata for @${input.username}.`];
775
+ case 'account_untrack':
776
+ return [`Delete @${input.username} from local tracked accounts. Instagram content is not deleted.`];
777
+ case 'account_refresh':
778
+ return asStringArray(input.data).map((dataKind) => `Queue ${dataKind} refresh for @${input.username}.`);
779
+ case 'hashtag_track':
780
+ return [`Track #${normalizeHashtagHandle(String(input.handle))} in the local Instagram library.`];
781
+ case 'hashtag_untrack':
782
+ return [`Delete #${normalizeHashtagHandle(String(input.handle))} from local tracked hashtags. Instagram content is not deleted.`];
783
+ case 'hashtag_refresh':
784
+ return [`Queue snapshot and media refresh for #${normalizeHashtagHandle(String(input.handle))}.`];
785
+ case 'media_comments_refresh':
786
+ return [`Queue comments refresh for media shortcode ${input.shortcode}.`];
787
+ case 'media_download':
788
+ return [`${String(input.action ?? 'queue')} local media downloads for ${asArray(input.mediaItemIds).length} media items.`];
789
+ case 'export_create':
790
+ return ['Create a local CSV export from saved Instagram library data.'];
791
+ default:
792
+ return [];
793
+ }
794
+ }
795
+ async function executeOperation(kind, input, registry) {
796
+ switch (kind) {
797
+ case 'account_track':
798
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPSERT_ACCOUNT_TARGET, {
799
+ username: input.username,
800
+ role: input.role
801
+ });
802
+ case 'account_update_role':
803
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPDATE_ACCOUNT_ROLE, {
804
+ username: input.username,
805
+ role: input.role
806
+ });
807
+ case 'account_untrack':
808
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_DELETE_ACCOUNT, {
809
+ username: input.username
810
+ });
811
+ case 'account_refresh': {
812
+ const dataKinds = asStringArray(input.data);
813
+ const results = [];
814
+ for (const dataKind of dataKinds) {
815
+ const response = await requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, mapAccountRefreshInput(String(input.username), dataKind, input));
816
+ if (!response.ok) {
817
+ return response;
818
+ }
819
+ results.push(response.data);
820
+ }
821
+ return {
822
+ ok: true,
823
+ requestId: 'operation-account-refresh',
824
+ capability: CAPABILITIES.SOCIAL_COLLECTION_START,
825
+ data: { results }
826
+ };
827
+ }
828
+ case 'hashtag_track':
829
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_UPSERT_HASHTAG_TARGET, {
830
+ handle: input.handle
831
+ });
832
+ case 'hashtag_untrack':
833
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_DELETE_HASHTAG, {
834
+ handle: input.handle
835
+ });
836
+ case 'hashtag_refresh':
837
+ return requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, {
838
+ capability: 'instagram.hashtag.snapshot.collect',
839
+ target: {
840
+ type: 'hashtag',
841
+ handle: normalizeHashtagHandle(String(input.handle))
842
+ },
843
+ options: {
844
+ limit: input.limit
845
+ }
846
+ });
847
+ case 'media_comments_refresh':
848
+ return requestCapability(registry, CAPABILITIES.SOCIAL_COLLECTION_START, {
849
+ capability: 'instagram.post.comments.collect',
850
+ target: {
851
+ type: 'post',
852
+ handle: input.shortcode
853
+ },
854
+ options: {
855
+ limit: input.commentsLimit
856
+ }
857
+ });
858
+ case 'media_download':
859
+ return requestCapability(registry, CAPABILITIES.SOCIAL_MEDIA_DOWNLOAD, {
860
+ action: input.action ?? 'queue',
861
+ mediaItemIds: input.mediaItemIds
862
+ });
863
+ case 'export_create':
864
+ return executeExportOperation(input, registry);
865
+ default:
866
+ return {
867
+ ok: false,
868
+ requestId: null,
869
+ capability: null,
870
+ error: {
871
+ code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
872
+ message: `Unsupported operation kind: ${kind}`
873
+ }
874
+ };
875
+ }
876
+ }
877
+ async function executeExportOperation(input, registry) {
878
+ const target = asObject(input.target);
879
+ const username = stringOrNull(input.username) ?? (target.type === 'account' ? stringOrNull(target.handle) : null);
880
+ const handle = stringOrNull(input.handle) ?? (target.type === 'hashtag' ? stringOrNull(target.handle) : null);
881
+ if (username) {
882
+ const mapped = await mapAccountExportInput({
883
+ username,
884
+ data: input.dataset ?? 'media',
885
+ limit: input.limit,
886
+ mediaSources: input.mediaSources,
887
+ commentsLimit: input.commentsLimit
888
+ }, registry);
889
+ if ('error' in mapped && mapped.error) {
890
+ return mapped.error;
891
+ }
892
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV, mapped.value);
893
+ }
894
+ if (handle) {
895
+ return requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV, {
896
+ sourceType: 'hashtag_posts',
897
+ handle: normalizeHashtagHandle(handle),
898
+ limit: input.limit,
899
+ includeComments: input.includeComments === true,
900
+ includeMediaUrls: input.includeMediaUrls === true
901
+ });
902
+ }
903
+ return {
904
+ ok: false,
905
+ requestId: null,
906
+ capability: CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV,
907
+ error: {
908
+ code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
909
+ message: 'export_create requires username, handle, or target.'
910
+ }
911
+ };
912
+ }
913
+ function buildOperationRunPayload(kind, input, data) {
914
+ if (kind === 'account_refresh') {
915
+ const results = asArray(asObject(data).results);
916
+ return {
917
+ operationId: stringOrNull(asObject(results[0]).operationId) ?? stringOrNull(asObject(results[0]).taskId),
918
+ status: 'running',
919
+ actions: results.map((result) => buildActionFromCollectionRun('refresh', { type: 'account', handle: String(input.username ?? '') }, result))
920
+ };
921
+ }
922
+ if (kind === 'hashtag_refresh' || kind === 'media_comments_refresh') {
923
+ const target = kind === 'hashtag_refresh'
924
+ ? { type: 'hashtag', handle: normalizeHashtagHandle(String(input.handle ?? '')) }
925
+ : { type: 'post', handle: String(input.shortcode ?? '') };
926
+ return {
927
+ operationId: stringOrNull(asObject(data).operationId) ?? stringOrNull(asObject(data).taskId),
928
+ status: 'running',
929
+ actions: [buildActionFromCollectionRun('refresh', target, data)]
930
+ };
931
+ }
932
+ if (kind === 'media_download') {
933
+ return {
934
+ status: 'running',
935
+ download: data
936
+ };
937
+ }
938
+ if (kind === 'export_create') {
939
+ return {
940
+ status: 'completed',
941
+ file: summarizeExportPayload(data)
942
+ };
943
+ }
944
+ return {
945
+ status: 'completed',
946
+ result: data
947
+ };
948
+ }
949
+ function buildOperationQueryTarget(input) {
950
+ if (typeof input.account === 'string' && input.account.trim()) {
951
+ return { type: 'account', handle: input.account };
952
+ }
953
+ if (typeof input.hashtag === 'string' && input.hashtag.trim()) {
954
+ return { type: 'hashtag', handle: normalizeHashtagHandle(input.hashtag) };
955
+ }
956
+ if (typeof input.shortcode === 'string' && input.shortcode.trim()) {
957
+ return { type: 'post', handle: input.shortcode };
958
+ }
959
+ return null;
960
+ }
961
+ function operationKindFromCapability(capability) {
962
+ if (capability === 'instagram.hashtag.snapshot.collect') {
963
+ return 'hashtag_refresh';
964
+ }
965
+ if (capability === 'instagram.post.comments.collect') {
966
+ return 'media_comments_refresh';
967
+ }
968
+ if (capability.includes('instagram.account') || capability.includes('instagram.audience') || capability.includes('instagram.posts')) {
969
+ return 'account_refresh';
970
+ }
971
+ return capability;
972
+ }
973
+ function validationError(message) {
974
+ return {
975
+ isError: true,
976
+ content: [{
977
+ type: 'text',
978
+ text: JSON.stringify({
979
+ error: {
980
+ code: BRIDGE_ERROR_CODES.VALIDATION_FAILED,
981
+ message
982
+ }
983
+ }, null, 2)
984
+ }]
985
+ };
986
+ }
987
+ function buildAccountProfile(detail) {
988
+ return {
989
+ account: summarizeAccount(detail.account),
990
+ profileSnapshot: detail.profileSnapshot ?? null,
991
+ engagementSnapshot: detail.engagementSnapshot ?? null
992
+ };
993
+ }
994
+ function buildAccountAudience(detail) {
995
+ const tasks = asArray(detail.tasks);
996
+ return {
997
+ followers: summarizeLatestAudienceTask(tasks, 'instagram.audience.followers.collect'),
998
+ following: summarizeLatestAudienceTask(tasks, 'instagram.audience.following.collect')
999
+ };
1000
+ }
1001
+ function buildAccountMediaSummary(detail) {
1002
+ const media = buildAccountMediaViews(detail);
1003
+ return {
1004
+ count: media.length,
1005
+ posts: media.filter((item) => item.source === 'post' || item.source === 'carousel').length,
1006
+ reels: media.filter((item) => item.source === 'reel').length,
1007
+ stories: media.filter((item) => item.source === 'story').length,
1008
+ highlights: media.filter((item) => item.source === 'highlight').length
1009
+ };
1010
+ }
1011
+ function summarizeLatestAudienceTask(tasks, capability) {
1012
+ const task = tasks
1013
+ .map(asObject)
1014
+ .filter((item) => item.capability === capability)
1015
+ .sort(compareUpdatedAt)[0];
1016
+ if (!task) {
1017
+ return null;
1018
+ }
1019
+ return {
1020
+ status: normalizeActionStatus(task.status),
1021
+ updatedAt: stringOrNull(task.updatedAt),
1022
+ totalSaved: numberOrNull(task.totalSaved),
1023
+ totalDiscovered: numberOrNull(task.totalDiscovered)
1024
+ };
1025
+ }
1026
+ function buildActions(tasks) {
1027
+ return tasks
1028
+ .map(asObject)
1029
+ .sort(compareUpdatedAt)
1030
+ .map((task) => ({
1031
+ id: stringOrNull(task.operationId) ?? String(task.id ?? ''),
1032
+ type: inferActionTypeFromCapability(String(task.capability ?? '')),
1033
+ status: normalizeActionStatus(task.status),
1034
+ target: asObject(task.target),
1035
+ capability: task.capability ?? null,
1036
+ updatedAt: stringOrNull(task.updatedAt),
1037
+ message: stringOrNull(task.failureReason) ?? stringOrNull(task.recommendedAction)
1038
+ }));
1039
+ }
1040
+ function buildAccountMediaViews(detail) {
1041
+ const mediaItems = asArray(detail.mediaItems).map((item) => fromMediaItem(asObject(item), asArray(detail.downloadTasks)));
1042
+ const seen = new Set(mediaItems.map((item) => item.shortcode ?? item.id));
1043
+ const legacyPosts = asArray(detail.posts)
1044
+ .map((item) => fromPost(asObject(item), true))
1045
+ .filter((item) => !seen.has(item.shortcode ?? item.id));
1046
+ return [...mediaItems, ...legacyPosts].sort(compareUpdatedAtDesc);
1047
+ }
1048
+ function buildHashtagMediaViews(detail) {
1049
+ return asArray(detail.posts).map((item) => fromPost(asObject(item), false)).sort(compareUpdatedAtDesc);
1050
+ }
1051
+ function findMediaView(media, mediaId, shortcode) {
1052
+ const mediaIdValue = typeof mediaId === 'string' ? mediaId : null;
1053
+ const shortcodeValue = typeof shortcode === 'string' ? shortcode : null;
1054
+ return media.find((item) => (mediaIdValue && item.id === mediaIdValue) ||
1055
+ (shortcodeValue && item.shortcode === shortcodeValue)) ?? null;
1056
+ }
1057
+ function filterMediaViews(media, sources) {
1058
+ if (!sources.length) {
1059
+ return media;
1060
+ }
1061
+ const normalized = new Set(sources.map((value) => normalizeMediaSourceFilter(value)));
1062
+ return media.filter((item) => normalized.has(item.source));
1063
+ }
1064
+ function normalizeMediaSourceFilter(value) {
1065
+ if (value === 'stories') {
1066
+ return 'story';
1067
+ }
1068
+ if (value === 'highlights') {
1069
+ return 'highlight';
1070
+ }
1071
+ if (value === 'reels') {
1072
+ return 'reel';
1073
+ }
1074
+ return 'post';
1075
+ }
1076
+ function fromMediaItem(item, downloadTasks) {
1077
+ const tasks = groupDownloadTasks(downloadTasks).get(String(item.id ?? '')) ?? [];
1078
+ const assets = asArray(item.assets).map((asset) => {
1079
+ const value = asObject(asset);
1080
+ return {
1081
+ type: String(value.type ?? 'image'),
1082
+ availability: String(value.availability ?? 'unknown')
1083
+ };
1084
+ });
1085
+ return {
1086
+ id: String(item.id ?? ''),
1087
+ source: normalizeMediaSource(String(item.sourceKind ?? 'post')),
1088
+ shortcode: stringOrNull(item.shortcode),
1089
+ url: stringOrNull(item.url),
1090
+ caption: String(item.caption ?? ''),
1091
+ authorUsername: stringOrNull(item.username),
1092
+ metrics: {
1093
+ likes: numberOrNull(asObject(item.metrics).likeCount),
1094
+ comments: numberOrNull(asObject(item.metrics).commentCount),
1095
+ views: numberOrNull(asObject(item.metrics).viewCount) ?? numberOrNull(asObject(item.metrics).playCount)
1096
+ },
1097
+ assets,
1098
+ commentsAvailable: typeof item.shortcode === 'string' && item.shortcode.length > 0,
1099
+ downloadStatus: summarizeDownloadStatus(tasks, assets.length),
1100
+ collectedAt: String(item.collectedAt ?? ''),
1101
+ updatedAt: String(item.updatedAt ?? '')
1102
+ };
1103
+ }
1104
+ function fromPost(item, includeDownloadMetadata) {
1105
+ const source = inferPostSource(item);
1106
+ const hasAnyAsset = Boolean(item.thumbnailUrl) || Boolean(item.videoUrl);
1107
+ return {
1108
+ id: String(item.id ?? ''),
1109
+ source,
1110
+ shortcode: stringOrNull(item.shortcode),
1111
+ url: stringOrNull(item.url),
1112
+ caption: String(item.caption ?? ''),
1113
+ authorUsername: stringOrNull(item.username),
1114
+ metrics: {
1115
+ likes: numberOrNull(item.likeCount),
1116
+ comments: numberOrNull(item.commentCount),
1117
+ views: numberOrNull(item.viewCount)
1118
+ },
1119
+ assets: [{
1120
+ type: String(item.mediaType ?? 'image'),
1121
+ availability: hasAnyAsset ? 'available' : 'metadata_only'
1122
+ }],
1123
+ commentsAvailable: typeof item.shortcode === 'string' && item.shortcode.length > 0,
1124
+ downloadStatus: includeDownloadMetadata ? (hasAnyAsset ? 'metadata_only' : 'none') : 'metadata_only',
1125
+ collectedAt: String(item.collectedAt ?? ''),
1126
+ updatedAt: String(item.updatedAt ?? '')
1127
+ };
1128
+ }
1129
+ function summarizeMediaFromPost(post) {
1130
+ return post ? fromPost(asObject(post), false) : null;
1131
+ }
1132
+ function inferPostSource(item) {
1133
+ const url = String(item.url ?? '');
1134
+ if (url.includes('/reel/')) {
1135
+ return 'reel';
1136
+ }
1137
+ return item.mediaType === 'carousel' ? 'carousel' : 'post';
1138
+ }
1139
+ function normalizeMediaSource(value) {
1140
+ if (value === 'reel') {
1141
+ return 'reel';
1142
+ }
1143
+ if (value === 'carousel') {
1144
+ return 'carousel';
1145
+ }
1146
+ if (value === 'story') {
1147
+ return 'story';
1148
+ }
1149
+ if (value === 'highlight_story') {
1150
+ return 'highlight';
1151
+ }
1152
+ return 'post';
1153
+ }
1154
+ function groupDownloadTasks(tasks) {
1155
+ const grouped = new Map();
1156
+ for (const task of tasks.map(asObject)) {
1157
+ const mediaItemId = String(task.mediaItemId ?? '');
1158
+ if (!mediaItemId) {
1159
+ continue;
1160
+ }
1161
+ const existing = grouped.get(mediaItemId) ?? [];
1162
+ existing.push(task);
1163
+ grouped.set(mediaItemId, existing);
1164
+ }
1165
+ return grouped;
1166
+ }
1167
+ function summarizeDownloadStatus(tasks, assetCount) {
1168
+ if (assetCount === 0) {
1169
+ return 'metadata_only';
1170
+ }
1171
+ if (tasks.length === 0) {
1172
+ return 'none';
1173
+ }
1174
+ const statuses = tasks.map((task) => String(task.status ?? ''));
1175
+ const completed = statuses.filter((status) => status === 'completed').length;
1176
+ if (completed === assetCount && assetCount > 0) {
1177
+ return 'completed';
1178
+ }
1179
+ if (statuses.some((status) => status === 'running')) {
1180
+ return 'running';
1181
+ }
1182
+ if (statuses.some((status) => status === 'queued')) {
1183
+ return 'queued';
1184
+ }
1185
+ if (completed > 0) {
1186
+ return 'partial';
1187
+ }
1188
+ if (statuses.some((status) => status === 'failed' || status === 'cancelled' || status === 'stale_url')) {
1189
+ return 'failed';
1190
+ }
1191
+ return 'none';
1192
+ }
1193
+ async function mapAccountExportInput(input, registry) {
1194
+ const username = String(input.username ?? '');
1195
+ const data = String(input.data ?? '');
1196
+ if (data === 'media') {
1197
+ return {
1198
+ value: {
1199
+ sourceType: 'account_media',
1200
+ username,
1201
+ limit: input.limit,
1202
+ mediaSources: mapMediaSources(asStringArray(input.mediaSources))
1203
+ }
1204
+ };
1205
+ }
1206
+ if (data === 'media_comments') {
1207
+ return {
1208
+ value: {
1209
+ sourceType: 'account_comments',
1210
+ username,
1211
+ mediaLimit: input.limit,
1212
+ mediaSources: mapMediaSources(asStringArray(input.mediaSources)),
1213
+ commentsLimit: input.commentsLimit
1214
+ }
1215
+ };
1216
+ }
1217
+ const detail = await requestCapability(registry, CAPABILITIES.SOCIAL_CONTEXT_GET_ACCOUNT_DETAIL, {
1218
+ username,
1219
+ postsLimit: 0
1220
+ });
1221
+ if (!detail.ok) {
1222
+ return { error: detail };
1223
+ }
1224
+ const task = findLatestTaskForAudience(asArray(asObject(detail.data).tasks), data);
1225
+ if (!task) {
1226
+ return {
1227
+ error: {
1228
+ ok: false,
1229
+ requestId: null,
1230
+ capability: CAPABILITIES.SOCIAL_CONTEXT_EXPORT_CSV,
1231
+ error: {
1232
+ code: BRIDGE_ERROR_CODES.UNSUPPORTED_CAPABILITY,
1233
+ message: `No saved ${data} data is available for @${username}.`
1234
+ }
1235
+ }
1236
+ };
1237
+ }
1238
+ return {
1239
+ value: {
1240
+ taskId: task.id,
1241
+ limit: input.limit
1242
+ }
1243
+ };
1244
+ }
1245
+ function mapMediaSources(values) {
1246
+ const normalized = values.filter((value) => (value === 'posts' || value === 'reels' || value === 'stories' || value === 'highlights'));
1247
+ return normalized.length > 0 ? normalized : undefined;
1248
+ }
1249
+ function mapAccountRefreshInput(username, dataKind, input) {
1250
+ if (dataKind === 'profile') {
1251
+ return {
1252
+ capability: 'instagram.account.profile.snapshot',
1253
+ target: { type: 'account', handle: username },
1254
+ options: {
1255
+ limit: input.limit
1256
+ }
1257
+ };
1258
+ }
1259
+ if (dataKind === 'followers') {
1260
+ return {
1261
+ capability: 'instagram.audience.followers.collect',
1262
+ target: { type: 'account', handle: username },
1263
+ options: {
1264
+ limit: input.limit
1265
+ }
1266
+ };
1267
+ }
1268
+ if (dataKind === 'following') {
1269
+ return {
1270
+ capability: 'instagram.audience.following.collect',
1271
+ target: { type: 'account', handle: username },
1272
+ options: {
1273
+ limit: input.limit
1274
+ }
1275
+ };
1276
+ }
1277
+ return {
1278
+ capability: 'instagram.posts.recent.collect',
1279
+ target: { type: 'account', handle: username },
1280
+ options: {
1281
+ limit: input.limit,
1282
+ mediaSources: mapMediaSources(asStringArray(input.mediaSources))
1283
+ }
1284
+ };
1285
+ }
1286
+ function findLatestTaskForAudience(tasks, kind) {
1287
+ const capability = kind === 'following'
1288
+ ? 'instagram.audience.following.collect'
1289
+ : 'instagram.audience.followers.collect';
1290
+ return tasks
1291
+ .map(asObject)
1292
+ .filter((task) => task.capability === capability)
1293
+ .sort(compareUpdatedAt)[0] ?? null;
1294
+ }
1295
+ function buildActionFromCollectionRun(actionType, target, data) {
1296
+ const value = asObject(data);
1297
+ return {
1298
+ ...buildActionEnvelope(actionType, 'running', target),
1299
+ actionId: stringOrNull(value.taskId),
1300
+ result: value
1301
+ };
1302
+ }
1303
+ function buildActionEnvelope(type, status, target) {
1304
+ return {
1305
+ type,
1306
+ status,
1307
+ target
1308
+ };
1309
+ }
1310
+ function summarizeAccount(account) {
1311
+ if (!account || typeof account !== 'object') {
1312
+ return null;
1313
+ }
1314
+ const value = asObject(account);
1315
+ return {
1316
+ id: stringOrNull(value.id),
1317
+ username: stringOrNull(value.username),
1318
+ displayName: stringOrNull(value.displayName),
1319
+ role: stringOrNull(value.targetRole) ?? 'uncategorized',
1320
+ profileUrl: stringOrNull(value.profileUrl),
1321
+ avatarUrl: stringOrNull(value.avatarUrl),
1322
+ updatedAt: stringOrNull(value.updatedAt),
1323
+ lastObservedAt: stringOrNull(value.lastObservedAt)
1324
+ };
1325
+ }
1326
+ function summarizeHashtag(hashtag) {
1327
+ if (!hashtag || typeof hashtag !== 'object') {
1328
+ return null;
1329
+ }
1330
+ const value = asObject(hashtag);
1331
+ return {
1332
+ id: stringOrNull(value.id),
1333
+ handle: stringOrNull(value.handle),
1334
+ displayName: stringOrNull(value.displayName),
1335
+ profileUrl: stringOrNull(value.profileUrl),
1336
+ updatedAt: stringOrNull(value.updatedAt),
1337
+ lastObservedAt: stringOrNull(value.lastObservedAt)
1338
+ };
1339
+ }
1340
+ function summarizeExport(contextExport) {
1341
+ const value = asObject(contextExport);
1342
+ return {
1343
+ id: stringOrNull(value.id),
1344
+ filename: stringOrNull(value.filename),
1345
+ dataType: stringOrNull(value.dataType),
1346
+ target: value.target ?? null,
1347
+ createdAt: stringOrNull(value.createdAt),
1348
+ recordCount: numberOrNull(value.recordCount),
1349
+ format: stringOrNull(value.format)
1350
+ };
1351
+ }
1352
+ function summarizeExportPayload(data) {
1353
+ const value = asObject(data);
1354
+ return {
1355
+ id: stringOrNull(value.exportId),
1356
+ name: stringOrNull(value.filename),
1357
+ format: stringOrNull(value.format),
1358
+ recordCount: numberOrNull(value.recordCount),
1359
+ content: stringOrNull(value.content),
1360
+ csv: stringOrNull(value.csv)
1361
+ };
1362
+ }
1363
+ function summarizeConnection(status) {
1364
+ return {
1365
+ connected: status.connected,
1366
+ clientId: status.clientId,
1367
+ extensionVersion: status.extensionVersion,
1368
+ connectedAt: status.connectedAt,
1369
+ lastSeenAt: status.lastSeenAt,
1370
+ pendingRequests: status.pendingRequests
1371
+ };
1372
+ }
1373
+ function inferActionTypeFromCapability(capability) {
1374
+ if (capability.includes('.export') || capability.includes('comments.collect') || capability.includes('snapshot.collect')) {
1375
+ return 'export';
1376
+ }
1377
+ if (capability.includes('.download')) {
1378
+ return 'download';
1379
+ }
1380
+ if (capability.includes('.upsert') || capability.includes('.delete') || capability.includes('.update')) {
1381
+ return 'manage';
1382
+ }
1383
+ return 'refresh';
1384
+ }
1385
+ function normalizeActionStatus(value) {
1386
+ if (value === 'queued' || value === 'running' || value === 'completed' || value === 'failed' || value === 'cancelled') {
1387
+ return value;
1388
+ }
1389
+ return 'failed';
1390
+ }
1391
+ function shouldInclude(input, value) {
1392
+ return Array.isArray(input.include) && input.include.includes(value);
1393
+ }
1394
+ function bridgeResultToToolResponse(result) {
1395
+ if (result.ok) {
1396
+ return jsonToolResponse(result.data);
1397
+ }
1398
+ return errorToolResponse(result);
1399
+ }
1400
+ function errorToolResponse(result) {
1401
+ if (result.ok) {
1402
+ return jsonToolResponse(result.data);
1403
+ }
1404
+ return {
1405
+ isError: true,
1406
+ content: [{
1407
+ type: 'text',
1408
+ text: JSON.stringify({
1409
+ error: {
1410
+ ...result.error,
1411
+ retryAfter: extractRetryAfter(result.error.message)
1412
+ },
1413
+ requestId: result.requestId,
1414
+ capability: result.capability
1415
+ }, null, 2)
1416
+ }]
1417
+ };
1418
+ }
1419
+ function extractRetryAfter(message) {
1420
+ const match = /Retry after (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)/.exec(message);
1421
+ return match?.[1];
1422
+ }
1423
+ function jsonToolResponse(value) {
1424
+ return {
1425
+ content: [{
1426
+ type: 'text',
1427
+ text: JSON.stringify(value, null, 2)
1428
+ }]
1429
+ };
1430
+ }
1431
+ function asObject(value) {
1432
+ return value && typeof value === 'object' && !Array.isArray(value)
1433
+ ? value
1434
+ : {};
1435
+ }
1436
+ function asArray(value) {
1437
+ return Array.isArray(value) ? value : [];
1438
+ }
1439
+ function asStringArray(value) {
1440
+ return Array.isArray(value)
1441
+ ? value.filter((item) => typeof item === 'string')
1442
+ : [];
1443
+ }
1444
+ function stringOrNull(value) {
1445
+ return typeof value === 'string' && value.trim() ? value : null;
1446
+ }
1447
+ function numberOrNull(value) {
1448
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
1449
+ }
1450
+ function matchesSearch(value, query) {
1451
+ const object = asObject(value);
1452
+ return [
1453
+ object.username,
1454
+ object.displayName,
1455
+ object.handle
1456
+ ].some((field) => typeof field === 'string' && field.toLowerCase().includes(query));
1457
+ }
1458
+ function compareUpdatedAt(left, right) {
1459
+ return String(right.updatedAt ?? '').localeCompare(String(left.updatedAt ?? ''));
1460
+ }
1461
+ function compareUpdatedAtDesc(left, right) {
1462
+ return String(right.updatedAt || right.collectedAt).localeCompare(String(left.updatedAt || left.collectedAt));
1463
+ }
1464
+ function normalizeHashtagHandle(value) {
1465
+ return value.trim().replace(/^#+/, '').trim().toLowerCase();
1466
+ }