diff --git a/src/commands/__tests__/instagram-content.test.ts b/src/commands/__tests__/instagram-content.test.ts new file mode 100644 index 0000000..d108440 --- /dev/null +++ b/src/commands/__tests__/instagram-content.test.ts @@ -0,0 +1,156 @@ +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('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('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(); + }); + + 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('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', media: '17999', reply: 'thanks!' }); + restore(); + + const call = vi.mocked(gatewayRequest).mock.calls[0][0]; + expect(call.path).toBe('/{ig_id}/mentions'); + expect(out()).toContain('Replied. id=17846'); + }); + + 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', comment: '17888', reply: 'hi' }); + restore(); + + expect((vi.mocked(gatewayRequest).mock.calls[0][0].body as Record).comment_id).toBe('17888'); + }); + + 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 without reply text, since this command only posts', async () => { + await expect(runInstagramMentions({ channel: '@acme', media: '17999' })).rejects.toBeInstanceOf(ValidationError); + expect(gatewayRequest).not.toHaveBeenCalled(); + }); +}); + +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..086ed9c --- /dev/null +++ b/src/commands/__tests__/instagram-inbox.test.ts @@ -0,0 +1,111 @@ +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('expands messages on the conversation node and unwraps messages.data', async () => { + vi.mocked(gatewayRequest).mockResolvedValue({ + messages: { data: [{ created_time: 'T', from: { username: 'fan' }, message: 'hello there' }] }, + }); + const [out, restore] = captureStdout(); + + await runInstagramThreads({ channel: '@acme', thread: 'aWdfXTHREAD' }); + restore(); + + const p = decodeURIComponent(vi.mocked(gatewayRequest).mock.calls[0][0].path as string); + expect(p).toContain('/aWdfXTHREAD?'); + expect(p).toContain('messages.limit(25){'); + 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('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(); + + 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..6173d76 --- /dev/null +++ b/src/commands/instagram-content.ts @@ -0,0 +1,249 @@ +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 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 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. */ +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 params = new URLSearchParams({ + fields: source === 'tagged' ? TAGGED_FIELDS : MEDIA_FIELDS, + limit: pageSize(opts.limit), + ...(opts.after ? { after: opts.after } : {}), + }); + 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({ 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; + comment?: string; + reply?: string; +} + +/** + * 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 { + if (!opts.media) { + throw new ValidationError( + '--media is required. It comes from the mentions webhook, or from `instagram media --source tagged`.', + 'MENTION_NO_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 res = await gatewayRequest({ + channel, + method: 'POST', + path: '/{ig_id}/mentions', + body: { + media_id: opts.media, + ...(opts.comment ? { comment_id: opts.comment } : {}), + message: opts.reply, + }, + }); + + process.stdout.write( + (cmd && isJsonMode(cmd) ? JSON.stringify(res) : `Replied. id=${res?.id ?? '(unknown)'}`) + '\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 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') + .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 tagged + $ hookmyapp instagram media --channel @acme --media + $ hookmyapp instagram media --channel @acme --json +`, + ); + + const mentions = instagram + .command('mentions') + .description('Reply to a post or comment that @mentioned you') + .option('--channel ', 'Channel: @handle or ch_id (defaults to HOOKMYAPP_CHANNEL_ID)') + .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); + }); + + addExamples( + mentions, + ` +EXAMPLES: + $ hookmyapp instagram mentions --channel @acme --media --reply "thanks!" + $ hookmyapp instagram mentions --channel @acme --media --comment --reply "thanks!" + +Instagram has no mention listing. See what tagged you with: + $ hookmyapp instagram media --channel @acme --source tagged +`, + ); + + 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..d21bef8 --- /dev/null +++ b/src/commands/instagram-inbox.ts @@ -0,0 +1,146 @@ +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'; +// 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)) { + 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'); + // A conversation has no /messages edge — Meta expands them on the node and + // nests the rows under `messages.data`. + const params = new URLSearchParams({ + 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>; + if (json) { + process.stdout.write( + JSON.stringify({ messages: rows, nextCursor: res?.messages?.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'); + // 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; + } + + // `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; }