From 2730680ac3af67f2267c3baeb17dd1503c9ba84d Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 11:08:30 +0300 Subject: [PATCH 1/7] wip: AIT-470 Instagram tool surface From 000a441a4e74cad949697eb6b9c8a333c74cf1f0 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 11:30:19 +0300 Subject: [PATCH 2/7] feat: AIT-470 list your posts, mentions, threads, and profile from the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the new MCP tools so the CLI stops sending people to raw curl for things we are approved for: - instagram media — posts, stories, and tagged posts; --media reads one post and expands carousel children. Until now --media on insights and comments wanted an id the CLI never produced, and its own help sourced it "from the inbound webhook". - instagram mentions — where other accounts @mentioned you. - instagram threads — DM threads, one thread's messages, and the public profile behind an IGSID. Scoped with platform=instagram so a linked Page cannot answer with the wrong inbox. - instagram profile — the profile fields insights never served, plus the publishing quota behind --quota. Thread and participant ids are Meta's opaque keys rather than numeric Graph ids, so they get a URL-safe-alphabet guard instead of the numeric one. --- .../__tests__/instagram-content.test.ts | 155 +++++++++++ .../__tests__/instagram-inbox.test.ts | 80 ++++++ src/commands/instagram-content.ts | 242 ++++++++++++++++++ src/commands/instagram-inbox.ts | 131 ++++++++++ src/commands/instagram.ts | 8 +- 5 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 src/commands/__tests__/instagram-content.test.ts create mode 100644 src/commands/__tests__/instagram-inbox.test.ts create mode 100644 src/commands/instagram-content.ts create mode 100644 src/commands/instagram-inbox.ts diff --git a/src/commands/__tests__/instagram-content.test.ts b/src/commands/__tests__/instagram-content.test.ts new file mode 100644 index 0000000..a9e11a2 --- /dev/null +++ b/src/commands/__tests__/instagram-content.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +vi.mock('../../api/gateway.js', () => ({ gatewayRequest: vi.fn() })); +vi.mock('../_helpers.js', () => ({ resolveChannelRefOrDefault: vi.fn(async () => ({ id: 'ch_ig', type: 'instagram', metaResourceId: '17841400000000000', metaWabaId: null, workspaceId: 'ws_1' })) })); +vi.mock('../../output/format.js', () => ({ isJsonMode: vi.fn(() => false) })); +import { runInstagramMediaList, runInstagramMentions, runInstagramProfile } from '../instagram-content.js'; +import { gatewayRequest } from '../../api/gateway.js'; +import { resolveChannelRefOrDefault } from '../_helpers.js'; +import { isJsonMode } from '../../output/format.js'; +import { ValidationError } from '../../output/error.js'; + +function captureStdout(): [() => string, () => void] { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((s) => { writes.push(String(s)); return true; }); + return [() => writes.join(''), () => spy.mockRestore()]; +} + +describe('instagram media', () => { + beforeEach(() => { + vi.mocked(gatewayRequest).mockReset(); + vi.mocked(resolveChannelRefOrDefault).mockClear(); + vi.mocked(isJsonMode).mockReturnValue(false); + }); + + it('lists posts from the media edge and prints the id first, since that is what other commands need', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [{ id: '17999', media_type: 'IMAGE', timestamp: 'T', caption: 'hello' }] }); + const [out, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/media?'); + expect(out()).toContain('17999\tIMAGE\tT\thello'); + }); + + it('reads stories from the stories edge', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme', source: 'stories' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/stories?'); + }); + + it('reads tagged posts from the tags edge and asks for the tagging username', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme', source: 'tagged' }); + restore(); + + const path = vi.mocked(gatewayRequest).mock.calls[0][0].path as string; + expect(path).toContain('/{ig_id}/tags?'); + expect(decodeURIComponent(path)).toContain('username'); + }); + + it('expands carousel children when one post is read', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ id: '17999' }); + const [, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme', media: '17999' }); + restore(); + + expect(decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string)).toContain('children{'); + }); + + it('clamps a limit above Meta ceiling to 100 rather than letting Meta truncate silently', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme', limit: '5000' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('limit=100'); + }); + + it('rejects a non-numeric media id before any gateway call', async () => { + await expect(runInstagramMediaList({ channel: '@acme', media: '../17999' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + + it('rejects an unknown source before any gateway call', async () => { + await expect(runInstagramMediaList({ channel: '@acme', source: 'reels' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + + it('reports the next cursor so paging is discoverable without --json', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [], paging: { cursors: { after: 'CUR9' } } }); + const [out, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme' }); + restore(); + + expect(out()).toContain('--after CUR9'); + }); +}); + +describe('instagram mentions', () => { + beforeEach(() => { + vi.mocked(gatewayRequest).mockReset(); + vi.mocked(isJsonMode).mockReturnValue(false); + }); + + it('reads the mentions edge and prints who mentioned you', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [{ id: '17777', username: 'fan', text: 'love @acme' }] }); + const [out, restore] = captureStdout(); + + await runInstagramMentions({ channel: '@acme' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/mentions?'); + expect(out()).toContain('@fan'); + }); + + it('says so plainly when there are no mentions', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [out, restore] = captureStdout(); + + await runInstagramMentions({ channel: '@acme' }); + restore(); + + expect(out()).toContain('No mentions found.'); + }); +}); + +describe('instagram profile', () => { + beforeEach(() => { + vi.mocked(gatewayRequest).mockReset(); + vi.mocked(isJsonMode).mockReturnValue(false); + }); + + it('reads the profile node and skips the quota edge unless asked', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ username: 'acme', followers_count: 12 }); + const [out, restore] = captureStdout(); + + await runInstagramProfile({ channel: '@acme' }); + restore(); + + expect(gatewayRequest).toHaveBeenCalledTimes(1); + expect(out()).toContain('username\tacme'); + }); + + it('also reads the publishing quota when --quota is given', async () => { + vi.mocked(gatewayRequest) + .mockResolvedValueOnce({ username: 'acme' }) + .mockResolvedValueOnce({ data: [{ quota_usage: 3 }] }); + const [out, restore] = captureStdout(); + + await runInstagramProfile({ channel: '@acme', quota: true }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[1][0].path).toContain('content_publishing_limit'); + expect(out()).toContain('publishing_quota_used\t3'); + }); +}); diff --git a/src/commands/__tests__/instagram-inbox.test.ts b/src/commands/__tests__/instagram-inbox.test.ts new file mode 100644 index 0000000..11bb6e6 --- /dev/null +++ b/src/commands/__tests__/instagram-inbox.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +vi.mock('../../api/gateway.js', () => ({ gatewayRequest: vi.fn() })); +vi.mock('../_helpers.js', () => ({ resolveChannelRefOrDefault: vi.fn(async () => ({ id: 'ch_ig', type: 'instagram', metaResourceId: '17841400000000000', metaWabaId: null, workspaceId: 'ws_1' })) })); +vi.mock('../../output/format.js', () => ({ isJsonMode: vi.fn(() => false) })); +import { runInstagramThreads } from '../instagram-inbox.js'; +import { gatewayRequest } from '../../api/gateway.js'; +import { isJsonMode } from '../../output/format.js'; +import { ValidationError } from '../../output/error.js'; + +function captureStdout(): [() => string, () => void] { + const writes: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((s) => { writes.push(String(s)); return true; }); + return [() => writes.join(''), () => spy.mockRestore()]; +} + +describe('instagram threads', () => { + beforeEach(() => { + vi.mocked(gatewayRequest).mockReset(); + vi.mocked(isJsonMode).mockReturnValue(false); + }); + + it('scopes the conversation list to the instagram inbox, not the linked Page inbox', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('platform=instagram'); + }); + + it('lists threads with the people in them', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ + data: [{ id: 'aWdfX', updated_time: 'T', participants: { data: [{ username: 'fan' }] } }], + }); + const [out, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme' }); + restore(); + + expect(out()).toContain('aWdfX\tT\t@fan'); + }); + + it('reads one thread messages when --thread is given', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ + data: [{ created_time: 'T', from: { username: 'fan' }, message: 'hello there' }], + }); + const [out, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' }); + restore(); + + expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/aWdfXTHREAD/messages?'); + expect(out()).toContain('@fan\thello there'); + }); + + it('reads only the public profile when --participant is given', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ id: 'IGSID1', username: 'fan' }); + const [, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme', participant: 'IGSID1' }); + restore(); + + const path = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); + expect(path).toContain('/IGSID1?fields='); + expect(path).not.toContain('followers_count'); + }); + + it('rejects a thread id carrying a path separator before any gateway call', async () => { + await expect( + runInstagramThreads({ channel: '@acme', thread: 'abc/../def' }), + ).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + + it('rejects a limit that is not a whole number', async () => { + await expect(runInstagramThreads({ channel: '@acme', limit: 'lots' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/instagram-content.ts b/src/commands/instagram-content.ts new file mode 100644 index 0000000..bcb29a4 --- /dev/null +++ b/src/commands/instagram-content.ts @@ -0,0 +1,242 @@ +import type { Command } from 'commander'; +import { addExamples } from '../output/help.js'; +import { gatewayRequest } from '../api/gateway.js'; +import { resolveChannelRefOrDefault } from './_helpers.js'; +import { isJsonMode } from '../output/format.js'; +import { ValidationError } from '../output/error.js'; + +// Same shape guard the insights command applies: a Graph media id is numeric, so +// anything else could smuggle path segments into the route. +const IG_MEDIA_ID_RE = /^\d+$/; +const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,thumbnail_url,timestamp,like_count,comments_count'; +const MEDIA_CHILDREN = 'children{id,media_type,media_url,thumbnail_url}'; +const TAGGED_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,username'; +const MENTION_FIELDS = + 'id,text,timestamp,username,media{id,caption,media_type,media_url,permalink,timestamp,username}'; +const PROFILE_FIELDS = + 'id,username,name,biography,website,profile_picture_url,followers_count,follows_count,media_count'; + +const SOURCES = ['posts', 'stories', 'tagged'] as const; +type Source = (typeof SOURCES)[number]; + +/** Meta caps a page at 100 and silently truncates a larger ask, which would read + * as the end of the list. */ +function pageSize(limit?: string): string { + if (limit === undefined) return '25'; + const n = Number(limit); + if (!Number.isInteger(n) || n < 1) { + throw new ValidationError(`--limit must be a whole number of 1 or more (got: ${limit}).`, 'BAD_LIMIT'); + } + return String(Math.min(n, 100)); +} + +function assertMediaId(id: string, flag: string): void { + if (!IG_MEDIA_ID_RE.test(id)) { + throw new ValidationError(`${flag} must be a numeric Instagram media id (got: ${id}).`, 'BAD_MEDIA_ID'); + } +} + +/** One row per post — id first, because the id is what every other command wants. */ +function printMediaRows(rows: Array>): void { + for (const row of rows) { + const caption = typeof row.caption === 'string' ? row.caption.replace(/\s+/g, ' ').slice(0, 60) : ''; + process.stdout.write( + `${String(row.id ?? '')}\t${String(row.media_type ?? '')}\t${String(row.timestamp ?? '')}\t${caption}\n`, + ); + } + if (rows.length === 0) process.stdout.write('No posts found.\n'); +} + +export interface IgMediaOpts { + channel?: string; + source?: string; + media?: string; + limit?: string; + after?: string; +} + +export async function runInstagramMediaList(opts: IgMediaOpts, cmd?: Command): Promise { + const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); + + if (opts.media) { + assertMediaId(opts.media, '--media'); + const res = await gatewayRequest({ + channel, + method: 'GET', + path: `/${opts.media}?fields=${encodeURIComponent(`${MEDIA_FIELDS},${MEDIA_CHILDREN}`)}`, + }); + process.stdout.write( + (cmd && isJsonMode(cmd) ? JSON.stringify(res) : JSON.stringify(res, null, 2)) + '\n', + ); + return; + } + + const source = (opts.source ?? 'posts') as Source; + if (!SOURCES.includes(source)) { + throw new ValidationError(`--source must be one of ${SOURCES.join(', ')}.`, 'BAD_SOURCE'); + } + const edge = source === 'posts' ? 'media' : source === 'stories' ? 'stories' : 'tags'; + const params = new URLSearchParams({ + fields: source === 'tagged' ? TAGGED_FIELDS : MEDIA_FIELDS, + limit: pageSize(opts.limit), + ...(opts.after ? { after: opts.after } : {}), + }); + const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/${edge}?${params.toString()}` }); + const rows = (res?.data ?? []) as Array>; + + if (cmd && isJsonMode(cmd)) { + process.stdout.write( + JSON.stringify({ source, media: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + ); + return; + } + printMediaRows(rows); + const next = res?.paging?.cursors?.after; + if (next) process.stdout.write(`More: --after ${next}\n`); +} + +export interface IgMentionsOpts { + channel?: string; + media?: string; + limit?: string; + after?: string; +} + +export async function runInstagramMentions(opts: IgMentionsOpts, cmd?: Command): Promise { + const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); + + if (opts.media) { + assertMediaId(opts.media, '--media'); + const res = await gatewayRequest({ + channel, + method: 'GET', + path: `/${opts.media}?fields=${encodeURIComponent(TAGGED_FIELDS)}`, + }); + process.stdout.write((cmd && isJsonMode(cmd) ? JSON.stringify(res) : JSON.stringify(res, null, 2)) + '\n'); + return; + } + + const params = new URLSearchParams({ + fields: MENTION_FIELDS, + limit: pageSize(opts.limit), + ...(opts.after ? { after: opts.after } : {}), + }); + const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/mentions?${params.toString()}` }); + const rows = (res?.data ?? []) as Array>; + + if (cmd && isJsonMode(cmd)) { + process.stdout.write( + JSON.stringify({ mentions: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + ); + return; + } + for (const row of rows) { + const text = typeof row.text === 'string' ? row.text.replace(/\s+/g, ' ').slice(0, 70) : ''; + process.stdout.write(`${String(row.id ?? '')}\t@${String(row.username ?? '')}\t${text}\n`); + } + if (rows.length === 0) process.stdout.write('No mentions found.\n'); + const next = res?.paging?.cursors?.after; + if (next) process.stdout.write(`More: --after ${next}\n`); +} + +export interface IgProfileOpts { + channel?: string; + quota?: boolean; +} + +export async function runInstagramProfile(opts: IgProfileOpts, cmd?: Command): Promise { + const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); + const profile = await gatewayRequest({ + channel, + method: 'GET', + path: `/{ig_id}?fields=${encodeURIComponent(PROFILE_FIELDS)}`, + }); + + let publishingLimit: unknown = null; + if (opts.quota) { + const quota = await gatewayRequest({ + channel, + method: 'GET', + path: '/{ig_id}/content_publishing_limit?fields=config,quota_usage', + }); + publishingLimit = quota?.data?.[0] ?? null; + } + + if (cmd && isJsonMode(cmd)) { + process.stdout.write(JSON.stringify({ profile, publishingLimit }) + '\n'); + return; + } + for (const [key, value] of Object.entries(profile ?? {})) { + process.stdout.write(`${key}\t${String(value ?? '')}\n`); + } + if (opts.quota) { + const used = (publishingLimit as { quota_usage?: number } | null)?.quota_usage; + process.stdout.write(`publishing_quota_used\t${used ?? '(unknown)'}\n`); + } +} + +/** Registers `instagram media|mentions|profile`. */ +export function registerInstagramContent(instagram: Command): void { + const media = instagram + .command('media') + .description('List your posts, stories, and posts you are tagged in') + .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .option('--source ', `One of ${SOURCES.join(', ')} (default posts)`) + .option('--media ', 'Read one post instead of a list, including carousel items') + .option('--limit ', 'Page size, 1-100 (default 25)') + .option('--after ', 'Continue from a previous page') + .action(async function (this: Command, opts: IgMediaOpts) { + await runInstagramMediaList(opts, this); + }); + + addExamples( + media, + ` +EXAMPLES: + $ hookmyapp instagram media --channel @acme + $ hookmyapp instagram media --channel @acme --source stories + $ hookmyapp instagram media --channel @acme --media + $ hookmyapp instagram media --channel @acme --json +`, + ); + + const mentions = instagram + .command('mentions') + .description('List posts and comments where other accounts @mentioned you') + .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .option('--media ', 'Read one post you were mentioned in') + .option('--limit ', 'Page size, 1-100 (default 25)') + .option('--after ', 'Continue from a previous page') + .action(async function (this: Command, opts: IgMentionsOpts) { + await runInstagramMentions(opts, this); + }); + + addExamples( + mentions, + ` +EXAMPLES: + $ hookmyapp instagram mentions --channel @acme + $ hookmyapp instagram mentions --channel @acme --json + $ hookmyapp instagram comments reply --channel @acme --comment --text "thanks!" +`, + ); + + const profile = instagram + .command('profile') + .description('Read your account profile, and optionally your publishing quota') + .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .option('--quota', 'Also report how much of today’s publishing quota is used') + .action(async function (this: Command, opts: IgProfileOpts) { + await runInstagramProfile(opts, this); + }); + + addExamples( + profile, + ` +EXAMPLES: + $ hookmyapp instagram profile --channel @acme + $ hookmyapp instagram profile --channel @acme --quota --json +`, + ); +} diff --git a/src/commands/instagram-inbox.ts b/src/commands/instagram-inbox.ts new file mode 100644 index 0000000..402033a --- /dev/null +++ b/src/commands/instagram-inbox.ts @@ -0,0 +1,131 @@ +import type { Command } from 'commander'; +import { addExamples } from '../output/help.js'; +import { gatewayRequest } from '../api/gateway.js'; +import { resolveChannelRefOrDefault } from './_helpers.js'; +import { isJsonMode } from '../output/format.js'; +import { ValidationError } from '../output/error.js'; + +// Thread and participant ids are Meta's opaque keys, not numeric Graph ids, so +// the numeric guard used for media would reject every real one. Constrain to the +// unreserved URL alphabet instead — enough to stop a smuggled path segment. +const IG_OPAQUE_ID_RE = /^[A-Za-z0-9_-]+$/; +const CONVERSATION_FIELDS = 'id,updated_time,participants,unread_count'; +const MESSAGE_FIELDS = 'id,message,from,to,created_time,reply_to'; +const PARTICIPANT_FIELDS = 'id,name,username,profile_pic'; + +function assertOpaqueId(id: string, flag: string): void { + if (!IG_OPAQUE_ID_RE.test(id)) { + throw new ValidationError(`${flag} is not in the expected format (got: ${id}).`, 'BAD_THREAD_ID'); + } +} + +function pageSize(limit?: string): string { + if (limit === undefined) return '25'; + const n = Number(limit); + if (!Number.isInteger(n) || n < 1) { + throw new ValidationError(`--limit must be a whole number of 1 or more (got: ${limit}).`, 'BAD_LIMIT'); + } + return String(Math.min(n, 100)); +} + +export interface IgThreadsOpts { + channel?: string; + thread?: string; + participant?: string; + limit?: string; + after?: string; +} + +export async function runInstagramThreads(opts: IgThreadsOpts, cmd?: Command): Promise { + const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); + const json = Boolean(cmd && isJsonMode(cmd)); + + if (opts.participant) { + assertOpaqueId(opts.participant, '--participant'); + const person = await gatewayRequest({ + channel, + method: 'GET', + path: `/${opts.participant}?fields=${encodeURIComponent(PARTICIPANT_FIELDS)}`, + }); + process.stdout.write((json ? JSON.stringify(person) : JSON.stringify(person, null, 2)) + '\n'); + return; + } + + if (opts.thread) { + assertOpaqueId(opts.thread, '--thread'); + const params = new URLSearchParams({ + fields: MESSAGE_FIELDS, + limit: pageSize(opts.limit), + ...(opts.after ? { after: opts.after } : {}), + }); + const res = await gatewayRequest({ + channel, method: 'GET', path: `/${opts.thread}/messages?${params.toString()}`, + }); + const rows = (res?.data ?? []) as Array>; + if (json) { + process.stdout.write( + JSON.stringify({ messages: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + ); + return; + } + for (const row of rows) { + const who = (row.from as { username?: string } | undefined)?.username ?? ''; + const text = typeof row.message === 'string' ? row.message.replace(/\s+/g, ' ').slice(0, 70) : ''; + process.stdout.write(`${String(row.created_time ?? '')}\t@${who}\t${text}\n`); + } + if (rows.length === 0) process.stdout.write('No messages in this thread.\n'); + return; + } + + // `platform` matters on an account linked to a Page — without it the edge can + // answer with the Messenger inbox instead. + const params = new URLSearchParams({ + fields: CONVERSATION_FIELDS, + platform: 'instagram', + limit: pageSize(opts.limit), + ...(opts.after ? { after: opts.after } : {}), + }); + const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/conversations?${params.toString()}` }); + const rows = (res?.data ?? []) as Array>; + + if (json) { + process.stdout.write( + JSON.stringify({ conversations: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + ); + return; + } + for (const row of rows) { + const people = ((row.participants as { data?: Array<{ username?: string }> } | undefined)?.data ?? []) + .map((p) => `@${p.username ?? ''}`) + .join(','); + process.stdout.write(`${String(row.id ?? '')}\t${String(row.updated_time ?? '')}\t${people}\n`); + } + if (rows.length === 0) process.stdout.write('No conversations found.\n'); + const next = res?.paging?.cursors?.after; + if (next) process.stdout.write(`More: --after ${next}\n`); +} + +/** Registers `instagram threads`. */ +export function registerInstagramInbox(instagram: Command): void { + const threads = instagram + .command('threads') + .description('List DM threads, read one thread, or look up who you are talking to') + .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .option('--thread ', 'Read the messages in this thread') + .option('--participant ', 'Read that person’s public profile') + .option('--limit ', 'Page size, 1-100 (default 25)') + .option('--after ', 'Continue from a previous page') + .action(async function (this: Command, opts: IgThreadsOpts) { + await runInstagramThreads(opts, this); + }); + + addExamples( + threads, + ` +EXAMPLES: + $ hookmyapp instagram threads --channel @acme + $ hookmyapp instagram threads --channel @acme --thread + $ hookmyapp instagram threads --channel @acme --participant --json +`, + ); +} diff --git a/src/commands/instagram.ts b/src/commands/instagram.ts index 550f01b..289a124 100644 --- a/src/commands/instagram.ts +++ b/src/commands/instagram.ts @@ -8,6 +8,8 @@ import { ValidationError } from '../output/error.js'; import { registerInstagramComments } from './instagram-comments.js'; import { registerInstagramPublish } from './instagram-publish.js'; import { registerInstagramInsights } from './instagram-insights.js'; +import { registerInstagramContent } from './instagram-content.js'; +import { registerInstagramInbox } from './instagram-inbox.js'; export interface IgSendOpts { channel?: string; @@ -106,7 +108,7 @@ export function registerInstagramCommand(program: Command): Command { const instagram = program .command('instagram') .alias('ig') - .description('Instagram comments, direct messages, publishing, and insights'); + .description('Instagram posts, mentions, comments, direct messages, publishing, and insights'); addExamples( instagram, @@ -116,6 +118,8 @@ EXAMPLES: $ hookmyapp ig --help $ hookmyapp instagram messages send --channel @acme --to --text "hi" $ hookmyapp instagram comments reply --channel @acme --comment --text "thanks!" + $ hookmyapp instagram media --channel @acme + $ hookmyapp instagram mentions --channel @acme `, ); @@ -123,6 +127,8 @@ EXAMPLES: registerInstagramComments(instagram); registerInstagramPublish(instagram); registerInstagramInsights(instagram); + registerInstagramContent(instagram); + registerInstagramInbox(instagram); return instagram; } From fd213aedfa09be20dab835922c9e1801a57d07a3 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 11:54:38 +0300 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20AIT-470=20mentions=20cannot=20be=20l?= =?UTF-8?q?isted=20=E2=80=94=20read=20them=20by=20id=20from=20the=20webhoo?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta documents /{ig-user-id}/mentions as POST-only: it creates the reply. The list tool this branch added would have failed against Meta on every call. The real read is a field expansion on the IG-User node — mentioned_comment.comment_id() or mentioned_media.media_id() — keyed by an id that only the mentions webhook supplies. Replying keys off media_id in both shapes, so a comment mention needs both ids. Renamed to get_instagram_mention / `instagram mentions --media|--comment`, which reads the mention and posts the reply in one call, and rejects a reply missing media_id locally instead of letting Meta return an unhelpful error. The docs and skill now say plainly that no listing exists and point at the webhook. --- .../__tests__/instagram-content.test.ts | 45 +++++++-- src/commands/instagram-content.ts | 96 ++++++++++++------- 2 files changed, 96 insertions(+), 45 deletions(-) diff --git a/src/commands/__tests__/instagram-content.test.ts b/src/commands/__tests__/instagram-content.test.ts index a9e11a2..69f1a5e 100644 --- a/src/commands/__tests__/instagram-content.test.ts +++ b/src/commands/__tests__/instagram-content.test.ts @@ -101,25 +101,52 @@ describe('instagram mentions', () => { vi.mocked(isJsonMode).mockReturnValue(false); }); - it('reads the mentions edge and prints who mentioned you', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ data: [{ id: '17777', username: 'fan', text: 'love @acme' }] }); + it('expands mentioned_comment on the IG-User node, since /mentions cannot be listed', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ mentioned_comment: { id: '17888', text: 'love @acme' } }); const [out, restore] = captureStdout(); - await runInstagramMentions({ channel: '@acme' }); + await runInstagramMentions({ channel: '@acme', comment: '17888' }); restore(); - expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/mentions?'); - expect(out()).toContain('@fan'); + const path = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); + expect(path).toContain('mentioned_comment.comment_id(17888)'); + expect(out()).toContain('love @acme'); }); - it('says so plainly when there are no mentions', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + it('expands mentioned_media when only a media id is given', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ mentioned_media: { id: '17999' } }); + const [, restore] = captureStdout(); + + await runInstagramMentions({ channel: '@acme', media: '17999' }); + restore(); + + expect(decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string)) + .toContain('mentioned_media.media_id(17999)'); + }); + + it('posts the reply to the mentions edge and reports its id', async () => { + vi.mocked(gatewayRequest) + .mockResolvedValueOnce({ mentioned_media: { id: '17999' } }) + .mockResolvedValueOnce({ id: '17846' }); const [out, restore] = captureStdout(); - await runInstagramMentions({ channel: '@acme' }); + await runInstagramMentions({ channel: '@acme', media: '17999', reply: 'thanks!' }); restore(); - expect(out()).toContain('No mentions found.'); + expect(vi.mocked(gatewayRequest).mock.calls[1][0].method).toBe('POST'); + expect(out()).toContain('Replied. id=17846'); + }); + + it('says where the ids come from when neither is given', async () => { + await expect(runInstagramMentions({ channel: '@acme' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + + it('refuses to reply without the media id Meta keys the reply on', async () => { + await expect( + runInstagramMentions({ channel: '@acme', comment: '17888', reply: 'hi' }), + ).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/instagram-content.ts b/src/commands/instagram-content.ts index bcb29a4..2a9588e 100644 --- a/src/commands/instagram-content.ts +++ b/src/commands/instagram-content.ts @@ -12,8 +12,8 @@ const MEDIA_FIELDS = 'id,caption,media_type,media_product_type,media_url,permalink,thumbnail_url,timestamp,like_count,comments_count'; const MEDIA_CHILDREN = 'children{id,media_type,media_url,thumbnail_url}'; const TAGGED_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,username'; -const MENTION_FIELDS = - 'id,text,timestamp,username,media{id,caption,media_type,media_url,permalink,timestamp,username}'; +const MENTIONED_MEDIA_FIELDS = + 'id,caption,media_type,media_url,permalink,timestamp,username,comments_count'; const PROFILE_FIELDS = 'id,username,name,biography,website,profile_picture_url,followers_count,follows_count,media_count'; @@ -98,46 +98,67 @@ export async function runInstagramMediaList(opts: IgMediaOpts, cmd?: Command): P export interface IgMentionsOpts { channel?: string; + comment?: string; media?: string; - limit?: string; - after?: string; + reply?: string; } +/** + * Instagram has no endpoint that lists past mentions — `/{ig}/mentions` is + * POST-only (it creates the reply). A mention arrives on the `mentions` + * webhook carrying the comment id or media id, and that id is read back as a + * field expansion on the IG-User node. + */ export async function runInstagramMentions(opts: IgMentionsOpts, cmd?: Command): Promise { + const hasComment = Boolean(opts.comment); + const hasMedia = Boolean(opts.media); + if (!hasComment && !hasMedia) { + throw new ValidationError( + 'Pass --comment or --media from the mentions webhook. Instagram has no endpoint that lists past mentions.', + 'MENTION_NO_TARGET', + ); + } + if (hasComment) assertMediaId(opts.comment!, '--comment'); + if (hasMedia) assertMediaId(opts.media!, '--media'); + if (opts.reply && !hasMedia) { + throw new ValidationError( + 'Replying to a mention needs --media from the webhook, alongside --comment.', + 'MENTION_REPLY_NEEDS_MEDIA', + ); + } const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); - if (opts.media) { - assertMediaId(opts.media, '--media'); - const res = await gatewayRequest({ + const expansion = hasComment + ? `mentioned_comment.comment_id(${opts.comment}){id,text,timestamp,username,like_count}` + : `mentioned_media.media_id(${opts.media}){${MENTIONED_MEDIA_FIELDS}}`; + const node = await gatewayRequest({ + channel, + method: 'GET', + path: `/{ig_id}?fields=${encodeURIComponent(expansion)}`, + }); + const mention = (hasComment ? node?.mentioned_comment : node?.mentioned_media) ?? null; + + let replyId: string | null = null; + if (opts.reply) { + const posted = await gatewayRequest({ channel, - method: 'GET', - path: `/${opts.media}?fields=${encodeURIComponent(TAGGED_FIELDS)}`, + method: 'POST', + path: '/{ig_id}/mentions', + body: { + media_id: opts.media, + ...(hasComment ? { comment_id: opts.comment } : {}), + message: opts.reply, + }, }); - process.stdout.write((cmd && isJsonMode(cmd) ? JSON.stringify(res) : JSON.stringify(res, null, 2)) + '\n'); - return; + replyId = posted?.id ?? null; } - const params = new URLSearchParams({ - fields: MENTION_FIELDS, - limit: pageSize(opts.limit), - ...(opts.after ? { after: opts.after } : {}), - }); - const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/mentions?${params.toString()}` }); - const rows = (res?.data ?? []) as Array>; - if (cmd && isJsonMode(cmd)) { - process.stdout.write( - JSON.stringify({ mentions: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', - ); + process.stdout.write(JSON.stringify({ target: hasComment ? 'comment' : 'media', mention, replyId }) + '\n'); return; } - for (const row of rows) { - const text = typeof row.text === 'string' ? row.text.replace(/\s+/g, ' ').slice(0, 70) : ''; - process.stdout.write(`${String(row.id ?? '')}\t@${String(row.username ?? '')}\t${text}\n`); - } - if (rows.length === 0) process.stdout.write('No mentions found.\n'); - const next = res?.paging?.cursors?.after; - if (next) process.stdout.write(`More: --after ${next}\n`); + process.stdout.write(JSON.stringify(mention, null, 2) + '\n'); + if (replyId) process.stdout.write(`Replied. id=${replyId}\n`); } export interface IgProfileOpts { @@ -203,11 +224,11 @@ EXAMPLES: const mentions = instagram .command('mentions') - .description('List posts and comments where other accounts @mentioned you') + .description('Read a post or comment you were @mentioned in, and optionally reply') .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') - .option('--media ', 'Read one post you were mentioned in') - .option('--limit ', 'Page size, 1-100 (default 25)') - .option('--after ', 'Continue from a previous page') + .option('--comment ', 'Comment id from the mentions webhook') + .option('--media ', 'Media id from the mentions webhook (required to reply)') + .option('--reply ', 'Post this reply as a comment from your account') .action(async function (this: Command, opts: IgMentionsOpts) { await runInstagramMentions(opts, this); }); @@ -216,9 +237,12 @@ EXAMPLES: mentions, ` EXAMPLES: - $ hookmyapp instagram mentions --channel @acme - $ hookmyapp instagram mentions --channel @acme --json - $ hookmyapp instagram comments reply --channel @acme --comment --text "thanks!" + $ hookmyapp instagram mentions --channel @acme --media + $ hookmyapp instagram mentions --channel @acme --comment --media + $ hookmyapp instagram mentions --channel @acme --media --reply "thanks for the shout-out" + +Instagram has no endpoint that lists past mentions. The ids come from the +mentions webhook — subscribe to it to catch them as they happen. `, ); From 18064558804f3af96ce9b98048b4c6ea3284b582 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 12:02:15 +0300 Subject: [PATCH 4/7] fix: AIT-470 drop the two edges Instagram Login cannot reach Meta gates /{ig}/stories and /{ig}/tags on a Facebook User access token plus pages_read_engagement, and the Instagram-Login page states the setup "cannot access ads or tagging". Every channel we connect is Instagram Login, so the stories and tagged sources would have failed for every customer. Removed rather than left in to fail: an option that never works is worse than an absent one. list_instagram_media is now posts plus the single-post read. content_publishing_limit is documented against instagram_business_content_publish, so get_instagram_account gates on that scope when the quota is requested and on basic when it is not. The docs page keeps its stories and tags curl, now under a warning saying who can actually call them. --- .../__tests__/instagram-content.test.ts | 27 ------------------- src/commands/instagram-content.ts | 22 +++++---------- 2 files changed, 7 insertions(+), 42 deletions(-) diff --git a/src/commands/__tests__/instagram-content.test.ts b/src/commands/__tests__/instagram-content.test.ts index 69f1a5e..97c1194 100644 --- a/src/commands/__tests__/instagram-content.test.ts +++ b/src/commands/__tests__/instagram-content.test.ts @@ -32,28 +32,6 @@ describe('instagram media', () => { expect(out()).toContain('17999\tIMAGE\tT\thello'); }); - it('reads stories from the stories edge', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); - const [, restore] = captureStdout(); - - await runInstagramMediaList({ channel: '@acme', source: 'stories' }); - restore(); - - expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/{ig_id}/stories?'); - }); - - it('reads tagged posts from the tags edge and asks for the tagging username', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); - const [, restore] = captureStdout(); - - await runInstagramMediaList({ channel: '@acme', source: 'tagged' }); - restore(); - - const path = vi.mocked(gatewayRequest).mock.calls[0][0].path as string; - expect(path).toContain('/{ig_id}/tags?'); - expect(decodeURIComponent(path)).toContain('username'); - }); - it('expands carousel children when one post is read', async () => { vi.mocked(gatewayRequest).mockResolvedValue({ id: '17999' }); const [, restore] = captureStdout(); @@ -79,11 +57,6 @@ describe('instagram media', () => { expect(gatewayRequest).not.toHaveBeenCalled(); }); - it('rejects an unknown source before any gateway call', async () => { - await expect(runInstagramMediaList({ channel: '@acme', source: 'reels' })).rejects.toBeInstanceOf(ValidationError); - expect(gatewayRequest).not.toHaveBeenCalled(); - }); - it('reports the next cursor so paging is discoverable without --json', async () => { vi.mocked(gatewayRequest).mockResolvedValue({ data: [], paging: { cursors: { after: 'CUR9' } } }); const [out, restore] = captureStdout(); diff --git a/src/commands/instagram-content.ts b/src/commands/instagram-content.ts index 2a9588e..ff7e273 100644 --- a/src/commands/instagram-content.ts +++ b/src/commands/instagram-content.ts @@ -11,14 +11,14 @@ const IG_MEDIA_ID_RE = /^\d+$/; const MEDIA_FIELDS = 'id,caption,media_type,media_product_type,media_url,permalink,thumbnail_url,timestamp,like_count,comments_count'; const MEDIA_CHILDREN = 'children{id,media_type,media_url,thumbnail_url}'; -const TAGGED_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,username'; const MENTIONED_MEDIA_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,username,comments_count'; const PROFILE_FIELDS = 'id,username,name,biography,website,profile_picture_url,followers_count,follows_count,media_count'; -const SOURCES = ['posts', 'stories', 'tagged'] as const; -type Source = (typeof SOURCES)[number]; +// No --source for stories or tagged posts: both need a Facebook User access +// token and pages_read_engagement, and Meta states the Instagram-Login setup +// "cannot access ads or tagging". Every channel we connect is Instagram-Login. /** Meta caps a page at 100 and silently truncates a larger ask, which would read * as the end of the list. */ @@ -50,7 +50,6 @@ function printMediaRows(rows: Array>): void { export interface IgMediaOpts { channel?: string; - source?: string; media?: string; limit?: string; after?: string; @@ -72,22 +71,17 @@ export async function runInstagramMediaList(opts: IgMediaOpts, cmd?: Command): P return; } - const source = (opts.source ?? 'posts') as Source; - if (!SOURCES.includes(source)) { - throw new ValidationError(`--source must be one of ${SOURCES.join(', ')}.`, 'BAD_SOURCE'); - } - const edge = source === 'posts' ? 'media' : source === 'stories' ? 'stories' : 'tags'; const params = new URLSearchParams({ - fields: source === 'tagged' ? TAGGED_FIELDS : MEDIA_FIELDS, + fields: MEDIA_FIELDS, limit: pageSize(opts.limit), ...(opts.after ? { after: opts.after } : {}), }); - const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/${edge}?${params.toString()}` }); + const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/media?${params.toString()}` }); const rows = (res?.data ?? []) as Array>; if (cmd && isJsonMode(cmd)) { process.stdout.write( - JSON.stringify({ source, media: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + JSON.stringify({ media: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', ); return; } @@ -201,9 +195,8 @@ export async function runInstagramProfile(opts: IgProfileOpts, cmd?: Command): P export function registerInstagramContent(instagram: Command): void { const media = instagram .command('media') - .description('List your posts, stories, and posts you are tagged in') + .description('List your published posts, and get the media ids other commands need') .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') - .option('--source ', `One of ${SOURCES.join(', ')} (default posts)`) .option('--media ', 'Read one post instead of a list, including carousel items') .option('--limit ', 'Page size, 1-100 (default 25)') .option('--after ', 'Continue from a previous page') @@ -216,7 +209,6 @@ export function registerInstagramContent(instagram: Command): void { ` EXAMPLES: $ hookmyapp instagram media --channel @acme - $ hookmyapp instagram media --channel @acme --source stories $ hookmyapp instagram media --channel @acme --media $ hookmyapp instagram media --channel @acme --json `, From cc93cf86346af6b75b4aef579b3a823f8b20d9c3 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 12:52:33 +0300 Subject: [PATCH 5/7] fix: AIT-470 follow Meta Instagram-Login contracts for mentions and threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the Codex review, verified against Meta docs before applying. Accepted: - A conversation has NO /messages edge. Meta serves the messages as a field expansion on the conversation node and nests them under messages.data, so the old call returned an empty list and dropped the cursor silently. - mentioned_comment / mentioned_media are Facebook-Login expansions. The Instagram-Login Mentions guide documents exactly two calls on graph.instagram.com: GET //tags to see what tagged or @mentioned you, and POST //mentions to reply. The tool is now reply-only and the read is list_instagram_media with source "tagged". Rejected, both contradicted by Meta: - ice_breakers as a flat array. The IG-Login doc shows the call_to_actions wrapper, and the Messenger variant marks locale "default" as REQUIRED. - fields in the DELETE query string. Meta sends it as a JSON body. Also reverses my own earlier removal of /tags: I had read the IG-User /tags REFERENCE page, which lists Facebook-Login permissions. The Instagram-Login guide is the one that applies to us, and it documents /tags on graph.instagram.com under instagram_business_* scopes. /stories stays out — no Instagram-Login guide documents it. --- .../__tests__/instagram-content.test.ts | 61 +++++----- .../__tests__/instagram-inbox.test.ts | 8 +- src/commands/instagram-content.ts | 113 ++++++++---------- src/commands/instagram-inbox.ts | 14 +-- 4 files changed, 95 insertions(+), 101 deletions(-) diff --git a/src/commands/__tests__/instagram-content.test.ts b/src/commands/__tests__/instagram-content.test.ts index 97c1194..d108440 100644 --- a/src/commands/__tests__/instagram-content.test.ts +++ b/src/commands/__tests__/instagram-content.test.ts @@ -52,6 +52,23 @@ describe('instagram media', () => { expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('limit=100'); }); + it('reads tagged posts from the tags edge, the Instagram-Login mention read', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ data: [] }); + const [, restore] = captureStdout(); + + await runInstagramMediaList({ channel: '@acme', source: 'tagged' }); + restore(); + + const path = vi.mocked(gatewayRequest).mock.calls[0][0].path as string; + expect(path).toContain('/{ig_id}/tags?'); + expect(decodeURIComponent(path)).toContain('username'); + }); + + it('rejects an unknown source before any gateway call', async () => { + await expect(runInstagramMediaList({ channel: '@acme', source: 'stories' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + it('rejects a non-numeric media id before any gateway call', async () => { await expect(runInstagramMediaList({ channel: '@acme', media: '../17999' })).rejects.toBeInstanceOf(ValidationError); expect(gatewayRequest).not.toHaveBeenCalled(); @@ -74,51 +91,35 @@ describe('instagram mentions', () => { vi.mocked(isJsonMode).mockReturnValue(false); }); - it('expands mentioned_comment on the IG-User node, since /mentions cannot be listed', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ mentioned_comment: { id: '17888', text: 'love @acme' } }); + it('POSTs the reply to the mentions edge, the only mention write Instagram Login has', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ id: '17846' }); const [out, restore] = captureStdout(); - await runInstagramMentions({ channel: '@acme', comment: '17888' }); + await runInstagramMentions({ channel: '@acme', media: '17999', reply: 'thanks!' }); restore(); - const path = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); - expect(path).toContain('mentioned_comment.comment_id(17888)'); - expect(out()).toContain('love @acme'); + const call = vi.mocked(gatewayRequest).mock.calls[0][0]; + expect(call.path).toBe('/{ig_id}/mentions'); + expect(out()).toContain('Replied. id=17846'); }); - it('expands mentioned_media when only a media id is given', async () => { - vi.mocked(gatewayRequest).mockResolvedValue({ mentioned_media: { id: '17999' } }); + it('includes comment_id when the mention was in a comment', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ id: '17847' }); const [, restore] = captureStdout(); - await runInstagramMentions({ channel: '@acme', media: '17999' }); - restore(); - - expect(decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string)) - .toContain('mentioned_media.media_id(17999)'); - }); - - it('posts the reply to the mentions edge and reports its id', async () => { - vi.mocked(gatewayRequest) - .mockResolvedValueOnce({ mentioned_media: { id: '17999' } }) - .mockResolvedValueOnce({ id: '17846' }); - const [out, restore] = captureStdout(); - - await runInstagramMentions({ channel: '@acme', media: '17999', reply: 'thanks!' }); + await runInstagramMentions({ channel: '@acme', media: '17999', comment: '17888', reply: 'hi' }); restore(); - expect(vi.mocked(gatewayRequest).mock.calls[1][0].method).toBe('POST'); - expect(out()).toContain('Replied. id=17846'); + expect((vi.mocked(gatewayRequest).mock.calls[0][0].body as Record).comment_id).toBe('17888'); }); - it('says where the ids come from when neither is given', async () => { - await expect(runInstagramMentions({ channel: '@acme' })).rejects.toBeInstanceOf(ValidationError); + it('points at the tagged list when --media is missing', async () => { + await expect(runInstagramMentions({ channel: '@acme', reply: 'hi' })).rejects.toBeInstanceOf(ValidationError); expect(gatewayRequest).not.toHaveBeenCalled(); }); - it('refuses to reply without the media id Meta keys the reply on', async () => { - await expect( - runInstagramMentions({ channel: '@acme', comment: '17888', reply: 'hi' }), - ).rejects.toBeInstanceOf(ValidationError); + it('refuses without reply text, since this command only posts', async () => { + await expect(runInstagramMentions({ channel: '@acme', media: '17999' })).rejects.toBeInstanceOf(ValidationError); expect(gatewayRequest).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/__tests__/instagram-inbox.test.ts b/src/commands/__tests__/instagram-inbox.test.ts index 11bb6e6..aaf4e8e 100644 --- a/src/commands/__tests__/instagram-inbox.test.ts +++ b/src/commands/__tests__/instagram-inbox.test.ts @@ -41,16 +41,18 @@ describe('instagram threads', () => { expect(out()).toContain('aWdfX\tT\t@fan'); }); - it('reads one thread messages when --thread is given', async () => { + it('expands messages on the conversation node and unwraps messages.data', async () => { vi.mocked(gatewayRequest).mockResolvedValue({ - data: [{ created_time: 'T', from: { username: 'fan' }, message: 'hello there' }], + messages: { data: [{ created_time: 'T', from: { username: 'fan' }, message: 'hello there' }] }, }); const [out, restore] = captureStdout(); await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' }); restore(); - expect(vi.mocked(gatewayRequest).mock.calls[0][0].path).toContain('/aWdfXTHREAD/messages?'); + const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); + expect(p).toContain('/aWdfXTHREAD?'); + expect(p).toContain('messages{'); expect(out()).toContain('@fan\thello there'); }); diff --git a/src/commands/instagram-content.ts b/src/commands/instagram-content.ts index ff7e273..6173d76 100644 --- a/src/commands/instagram-content.ts +++ b/src/commands/instagram-content.ts @@ -16,9 +16,14 @@ const MENTIONED_MEDIA_FIELDS = const PROFILE_FIELDS = 'id,username,name,biography,website,profile_picture_url,followers_count,follows_count,media_count'; -// No --source for stories or tagged posts: both need a Facebook User access -// token and pages_read_engagement, and Meta states the Instagram-Login setup -// "cannot access ads or tagging". Every channel we connect is Instagram-Login. +const TAGGED_FIELDS = 'id,caption,media_type,media_url,permalink,timestamp,username'; + +// No --source for stories: its reference lists a Facebook User access token and +// pages_read_engagement. `tagged` IS here — the Instagram-Login Mentions guide +// documents GET //tags against graph.instagram.com, and it is how a +// mention on a post is read back. +const SOURCES = ['posts', 'tagged'] as const; +type Source = (typeof SOURCES)[number]; /** Meta caps a page at 100 and silently truncates a larger ask, which would read * as the end of the list. */ @@ -50,6 +55,7 @@ function printMediaRows(rows: Array>): void { export interface IgMediaOpts { channel?: string; + source?: string; media?: string; limit?: string; after?: string; @@ -71,17 +77,22 @@ export async function runInstagramMediaList(opts: IgMediaOpts, cmd?: Command): P return; } + const source = (opts.source ?? 'posts') as Source; + if (!SOURCES.includes(source)) { + throw new ValidationError(`--source must be one of ${SOURCES.join(', ')}.`, 'BAD_SOURCE'); + } const params = new URLSearchParams({ - fields: MEDIA_FIELDS, + fields: source === 'tagged' ? TAGGED_FIELDS : MEDIA_FIELDS, limit: pageSize(opts.limit), ...(opts.after ? { after: opts.after } : {}), }); - const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/media?${params.toString()}` }); + const edge = source === 'tagged' ? 'tags' : 'media'; + const res = await gatewayRequest({ channel, method: 'GET', path: `/{ig_id}/${edge}?${params.toString()}` }); const rows = (res?.data ?? []) as Array>; if (cmd && isJsonMode(cmd)) { process.stdout.write( - JSON.stringify({ media: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + JSON.stringify({ source, media: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', ); return; } @@ -92,67 +103,46 @@ export async function runInstagramMediaList(opts: IgMediaOpts, cmd?: Command): P export interface IgMentionsOpts { channel?: string; - comment?: string; media?: string; + comment?: string; reply?: string; } /** - * Instagram has no endpoint that lists past mentions — `/{ig}/mentions` is - * POST-only (it creates the reply). A mention arrives on the `mentions` - * webhook carrying the comment id or media id, and that id is read back as a - * field expansion on the IG-User node. + * Meta's Instagram-Login Mentions guide documents two endpoints on + * graph.instagram.com: GET //tags to see what you were tagged in (that + * is `instagram media --source tagged`), and POST //mentions to reply. + * There is no mention listing, and the mentioned_comment / mentioned_media + * expansions belong to the Facebook-Login flow. */ export async function runInstagramMentions(opts: IgMentionsOpts, cmd?: Command): Promise { - const hasComment = Boolean(opts.comment); - const hasMedia = Boolean(opts.media); - if (!hasComment && !hasMedia) { + if (!opts.media) { throw new ValidationError( - 'Pass --comment or --media from the mentions webhook. Instagram has no endpoint that lists past mentions.', - 'MENTION_NO_TARGET', + '--media is required. It comes from the mentions webhook, or from `instagram media --source tagged`.', + 'MENTION_NO_MEDIA', ); } - if (hasComment) assertMediaId(opts.comment!, '--comment'); - if (hasMedia) assertMediaId(opts.media!, '--media'); - if (opts.reply && !hasMedia) { - throw new ValidationError( - 'Replying to a mention needs --media from the webhook, alongside --comment.', - 'MENTION_REPLY_NEEDS_MEDIA', - ); + assertMediaId(opts.media, '--media'); + if (opts.comment) assertMediaId(opts.comment, '--comment'); + if (!opts.reply) { + throw new ValidationError('--reply is required — this command posts a reply.', 'MENTION_NO_TEXT'); } const channel = await resolveChannelRefOrDefault(opts.channel, 'instagram'); - const expansion = hasComment - ? `mentioned_comment.comment_id(${opts.comment}){id,text,timestamp,username,like_count}` - : `mentioned_media.media_id(${opts.media}){${MENTIONED_MEDIA_FIELDS}}`; - const node = await gatewayRequest({ + const res = await gatewayRequest({ channel, - method: 'GET', - path: `/{ig_id}?fields=${encodeURIComponent(expansion)}`, + method: 'POST', + path: '/{ig_id}/mentions', + body: { + media_id: opts.media, + ...(opts.comment ? { comment_id: opts.comment } : {}), + message: opts.reply, + }, }); - const mention = (hasComment ? node?.mentioned_comment : node?.mentioned_media) ?? null; - let replyId: string | null = null; - if (opts.reply) { - const posted = await gatewayRequest({ - channel, - method: 'POST', - path: '/{ig_id}/mentions', - body: { - media_id: opts.media, - ...(hasComment ? { comment_id: opts.comment } : {}), - message: opts.reply, - }, - }); - replyId = posted?.id ?? null; - } - - if (cmd && isJsonMode(cmd)) { - process.stdout.write(JSON.stringify({ target: hasComment ? 'comment' : 'media', mention, replyId }) + '\n'); - return; - } - process.stdout.write(JSON.stringify(mention, null, 2) + '\n'); - if (replyId) process.stdout.write(`Replied. id=${replyId}\n`); + process.stdout.write( + (cmd && isJsonMode(cmd) ? JSON.stringify(res) : `Replied. id=${res?.id ?? '(unknown)'}`) + '\n', + ); } export interface IgProfileOpts { @@ -195,8 +185,9 @@ export async function runInstagramProfile(opts: IgProfileOpts, cmd?: Command): P export function registerInstagramContent(instagram: Command): void { const media = instagram .command('media') - .description('List your published posts, and get the media ids other commands need') + .description('List your published posts, or the posts that tagged you') .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .option('--source ', `One of ${SOURCES.join(', ')} (default posts)`) .option('--media ', 'Read one post instead of a list, including carousel items') .option('--limit ', 'Page size, 1-100 (default 25)') .option('--after ', 'Continue from a previous page') @@ -209,6 +200,7 @@ export function registerInstagramContent(instagram: Command): void { ` EXAMPLES: $ hookmyapp instagram media --channel @acme + $ hookmyapp instagram media --channel @acme --source tagged $ hookmyapp instagram media --channel @acme --media $ hookmyapp instagram media --channel @acme --json `, @@ -216,11 +208,11 @@ EXAMPLES: const mentions = instagram .command('mentions') - .description('Read a post or comment you were @mentioned in, and optionally reply') + .description('Reply to a post or comment that @mentioned you') .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') - .option('--comment ', 'Comment id from the mentions webhook') - .option('--media ', 'Media id from the mentions webhook (required to reply)') - .option('--reply ', 'Post this reply as a comment from your account') + .option('--media ', 'Media id carrying the mention (required)') + .option('--comment ', 'Comment id, when the mention was in a comment') + .option('--reply ', 'The reply to post (required)') .action(async function (this: Command, opts: IgMentionsOpts) { await runInstagramMentions(opts, this); }); @@ -229,12 +221,11 @@ EXAMPLES: mentions, ` EXAMPLES: - $ hookmyapp instagram mentions --channel @acme --media - $ hookmyapp instagram mentions --channel @acme --comment --media - $ hookmyapp instagram mentions --channel @acme --media --reply "thanks for the shout-out" + $ hookmyapp instagram mentions --channel @acme --media --reply "thanks!" + $ hookmyapp instagram mentions --channel @acme --media --comment --reply "thanks!" -Instagram has no endpoint that lists past mentions. The ids come from the -mentions webhook — subscribe to it to catch them as they happen. +Instagram has no mention listing. See what tagged you with: + $ hookmyapp instagram media --channel @acme --source tagged `, ); diff --git a/src/commands/instagram-inbox.ts b/src/commands/instagram-inbox.ts index 402033a..9cc0a9c 100644 --- a/src/commands/instagram-inbox.ts +++ b/src/commands/instagram-inbox.ts @@ -10,7 +10,7 @@ import { ValidationError } from '../output/error.js'; // unreserved URL alphabet instead — enough to stop a smuggled path segment. const IG_OPAQUE_ID_RE = /^[A-Za-z0-9_-]+$/; const CONVERSATION_FIELDS = 'id,updated_time,participants,unread_count'; -const MESSAGE_FIELDS = 'id,message,from,to,created_time,reply_to'; +const MESSAGE_EXPANSION = 'messages{id,message,from,to,created_time,reply_to}'; const PARTICIPANT_FIELDS = 'id,name,username,profile_pic'; function assertOpaqueId(id: string, flag: string): void { @@ -53,18 +53,18 @@ export async function runInstagramThreads(opts: IgThreadsOpts, cmd?: Command): P if (opts.thread) { assertOpaqueId(opts.thread, '--thread'); + // A conversation has no /messages edge — Meta expands them on the node and + // nests the rows under `messages.data`. const params = new URLSearchParams({ - fields: MESSAGE_FIELDS, + fields: MESSAGE_EXPANSION, limit: pageSize(opts.limit), ...(opts.after ? { after: opts.after } : {}), }); - const res = await gatewayRequest({ - channel, method: 'GET', path: `/${opts.thread}/messages?${params.toString()}`, - }); - const rows = (res?.data ?? []) as Array>; + const res = await gatewayRequest({ channel, method: 'GET', path: `/${opts.thread}?${params.toString()}` }); + const rows = (res?.messages?.data ?? []) as Array>; if (json) { process.stdout.write( - JSON.stringify({ messages: rows, nextCursor: res?.paging?.cursors?.after ?? null }) + '\n', + JSON.stringify({ messages: rows, nextCursor: res?.messages?.paging?.cursors?.after ?? null }) + '\n', ); return; } From e7f7b190bb602256050b4a5088523a58cbba35df Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 13:11:09 +0300 Subject: [PATCH 6/7] AIT-470: show the paging cursor when reading an Instagram thread Reading a thread printed the messages and stopped. The conversation list right below it ends with a "More: --after " line, so the same command taught you how to page in one mode and not the other, and --after was undiscoverable for threads. The cursor is there, just nested: a conversation has no /messages edge, so Meta expands the messages onto the node and the cursor lands under messages.paging.cursors.after rather than at the top level. The JSON output already read it from there; the human-readable output did not. Found by a second Codex pass over the branch. --- src/commands/__tests__/instagram-inbox.test.ts | 12 ++++++++++++ src/commands/instagram-inbox.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/src/commands/__tests__/instagram-inbox.test.ts b/src/commands/__tests__/instagram-inbox.test.ts index aaf4e8e..f122cbd 100644 --- a/src/commands/__tests__/instagram-inbox.test.ts +++ b/src/commands/__tests__/instagram-inbox.test.ts @@ -56,6 +56,18 @@ describe('instagram threads', () => { expect(out()).toContain('@fan\thello there'); }); + it('shows how to page on from a thread, the way the conversation list does', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ + messages: { data: [{ created_time: 'T', message: 'hi' }], paging: { cursors: { after: 'CUR2' } } }, + }); + const [out, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' }); + restore(); + + expect(out()).toContain('More: --after CUR2'); + }); + it('reads only the public profile when --participant is given', async () => { vi.mocked(gatewayRequest).mockResolvedValue({ id: 'IGSID1', username: 'fan' }); const [, restore] = captureStdout(); diff --git a/src/commands/instagram-inbox.ts b/src/commands/instagram-inbox.ts index 9cc0a9c..8e21e07 100644 --- a/src/commands/instagram-inbox.ts +++ b/src/commands/instagram-inbox.ts @@ -74,6 +74,10 @@ export async function runInstagramThreads(opts: IgThreadsOpts, cmd?: Command): P process.stdout.write(`${String(row.created_time ?? '')}\t@${who}\t${text}\n`); } if (rows.length === 0) process.stdout.write('No messages in this thread.\n'); + // The thread cursor is nested under the expansion, unlike the conversation + // list's top-level one — without printing it, --after is undiscoverable. + const nextMessage = res?.messages?.paging?.cursors?.after; + if (nextMessage) process.stdout.write(`More: --after ${nextMessage}\n`); return; } From b6553201191085af2d3f618afaa3f9d8f6ae4530 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 24 Aug 2026 13:26:06 +0300 Subject: [PATCH 7/7] AIT-470: page Instagram thread messages inside the field expansion Same defect as the backend tool. --after was sent as a top-level param, so it paged the conversation node rather than the expanded messages edge, and the "More: --after" hint added in e7f7b19 pointed at a cursor that returned the same page. The modifiers now ride inside the expansion as messages.limit(N).after(CUR){...}, and the cursor is validated before it is interpolated there, since a stray ) or { would escape the expansion. --- .../__tests__/instagram-inbox.test.ts | 19 ++++++++++++++++++- src/commands/instagram-inbox.ts | 19 +++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/commands/__tests__/instagram-inbox.test.ts b/src/commands/__tests__/instagram-inbox.test.ts index f122cbd..086ed9c 100644 --- a/src/commands/__tests__/instagram-inbox.test.ts +++ b/src/commands/__tests__/instagram-inbox.test.ts @@ -52,7 +52,7 @@ describe('instagram threads', () => { const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); expect(p).toContain('/aWdfXTHREAD?'); - expect(p).toContain('messages{'); + expect(p).toContain('messages.limit(25){'); expect(out()).toContain('@fan\thello there'); }); @@ -68,6 +68,23 @@ describe('instagram threads', () => { expect(out()).toContain('More: --after CUR2'); }); + it('pages the messages, not the conversation node, when --after is given', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ messages: { data: [] } }); + + await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD', after: 'CUR2', limit: '5' }); + + const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); + expect(p).toContain('messages.limit(5).after(CUR2){'); + expect(p).not.toMatch(/[?&](limit|after)=/); + }); + + it('rejects a cursor that could break out of the field expansion', async () => { + await expect( + runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD', after: 'CUR){x' }), + ).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); + it('reads only the public profile when --participant is given', async () => { vi.mocked(gatewayRequest).mockResolvedValue({ id: 'IGSID1', username: 'fan' }); const [, restore] = captureStdout(); diff --git a/src/commands/instagram-inbox.ts b/src/commands/instagram-inbox.ts index 8e21e07..d21bef8 100644 --- a/src/commands/instagram-inbox.ts +++ b/src/commands/instagram-inbox.ts @@ -10,8 +10,21 @@ import { ValidationError } from '../output/error.js'; // unreserved URL alphabet instead — enough to stop a smuggled path segment. const IG_OPAQUE_ID_RE = /^[A-Za-z0-9_-]+$/; const CONVERSATION_FIELDS = 'id,updated_time,participants,unread_count'; -const MESSAGE_EXPANSION = 'messages{id,message,from,to,created_time,reply_to}'; +const MESSAGE_FIELDS = 'id,message,from,to,created_time,reply_to'; const PARTICIPANT_FIELDS = 'id,name,username,profile_pic'; +// Cursors carry base64 padding, so they need a wider alphabet than an id. +const IG_CURSOR_RE = /^[A-Za-z0-9_=-]+$/; + +/** Paging a field expansion is done with modifiers INSIDE the expansion — + * `messages.limit(25).after(CUR){...}`. Top-level limit/after page the + * conversation node instead, so the messages cursor would never be redeemed. */ +function messageExpansion(limit: string, after?: string): string { + if (after !== undefined && !IG_CURSOR_RE.test(after)) { + throw new ValidationError(`--after is not in the expected format (got: ${after}).`, 'BAD_CURSOR'); + } + const mods = `.limit(${limit})${after !== undefined ? `.after(${after})` : ''}`; + return `messages${mods}{${MESSAGE_FIELDS}}`; +} function assertOpaqueId(id: string, flag: string): void { if (!IG_OPAQUE_ID_RE.test(id)) { @@ -56,9 +69,7 @@ export async function runInstagramThreads(opts: IgThreadsOpts, cmd?: Command): P // A conversation has no /messages edge — Meta expands them on the node and // nests the rows under `messages.data`. const params = new URLSearchParams({ - fields: MESSAGE_EXPANSION, - limit: pageSize(opts.limit), - ...(opts.after ? { after: opts.after } : {}), + fields: messageExpansion(pageSize(opts.limit), opts.after), }); const res = await gatewayRequest({ channel, method: 'GET', path: `/${opts.thread}?${params.toString()}` }); const rows = (res?.messages?.data ?? []) as Array>;