diff --git a/README.md b/README.md index e11fb38..b188310 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,7 @@ polymorfa chat list --session NUMBER_ID --limit 20 --kind direct polymorfa chat view +15550001111 --session NUMBER_ID polymorfa chat message list +15550001111 --session NUMBER_ID --limit 20 --order desc polymorfa chat message view +15550001111 739182640518204 --session NUMBER_ID +polymorfa chat message media +15550001111 739182640518204 --session NUMBER_ID --output ./attachment.bin ``` Chat reads require `chats:read`; message reads require `messages:read`. Older @@ -618,6 +619,14 @@ the Number's regional history service cannot be reached. No search command is available. See the [message history guide](https://docs.polymorfa.com/guides/communicate/message-history) for retention and media download behavior. +For a stored Official API media copy, `chat message media` streams the bytes to +a temporary sibling file and moves it into place only after the download +finishes. It requires both `messages:read` and `media:read`; the message's +`mediaRetrieval.state` is `stored` when a copy is available. Use `--force` to +replace an existing file and `--max-bytes` to change the 32 MiB local limit. +The API returns 404 if no copy exists and 503 if the regional service is +unavailable. The command does not create a signed URL. + ## Media Messaging media is distinct from Platform media and requires a server API key: diff --git a/docs/command-research/history-media.md b/docs/command-research/history-media.md new file mode 100644 index 0000000..12c2b4f --- /dev/null +++ b/docs/command-research/history-media.md @@ -0,0 +1,18 @@ +# Hosted history media source boundary + +This command follows API PR #333 at +`5044826d122759aaa8be3e2c2d0f120a326fda81`, +`apps/api/docs/openapi.json` SHA-256 +`b7b79f399ce9f6944497a2970e184fe40c276dff9034726297ca18b2aab0bd41`. +The source operation is `getChatMessageMedia` at +`GET /messaging/{session}/chats/{conversation}/messages/{messageId}/media`. +API PR #385 adds a final history authority recheck without changing that +contract. SDK PR #316 at `69e680ee37fbb86f50a86294cb17f63c78cd729c` +adds `MessagingClient.chats.downloadMessageMediaStream`. + +The CLI package still pins an older published SDK. Do not update its exact +registry dependency until PR #316 is merged and the resulting SDK artifact is +published and its version and integrity are verified. The source command can +then use that exact artifact. API merge, beta enrollment, regional deployment, +and real provider-backed acceptance remain separate gates. The command does +not establish any of them. diff --git a/src/commands/chat/message/media.ts b/src/commands/chat/message/media.ts new file mode 100644 index 0000000..9923682 --- /dev/null +++ b/src/commands/chat/message/media.ts @@ -0,0 +1,65 @@ +import {Args, Flags} from '@oclif/core' +import {PolymorfaValidationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + DEFAULT_MAX_HISTORY_MEDIA_BYTES, + downloadHistoryMessageMedia, +} from '../../../services/history.js' + +export default class ChatMessageMedia extends BaseCommand { + static override args = { + conversation: Args.string({ + description: 'Conversation ID or E.164 phone number.', + required: true, + }), + message: Args.string({ + description: 'Opaque Polymorfa message ID.', + required: true, + }), + } + + static override description = + 'Download a stored Official API message media copy for an enrolled HMS Number (beta). Requires messages:read and media:read.' + + static override examples = [ + '<%= config.bin %> chat message media +15550001111 739182640518204 --session NUMBER_ID --output ./attachment.bin', + ] + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + output: Flags.string({ + char: 'o', + description: 'Destination file path.', + required: true, + }), + force: Flags.boolean({description: 'Overwrite an existing output file.'}), + 'max-bytes': Flags.integer({ + default: DEFAULT_MAX_HISTORY_MEDIA_BYTES, + description: 'Maximum bytes to download (default 32 MiB).', + min: 1, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(ChatMessageMedia) + if (!flags.output.trim()) { + throw new PolymorfaValidationError('Pass a nonempty --output path.') + } + const result = await downloadHistoryMessageMedia( + await this.historyMessagingClient(flags), + flags.session, + args.conversation, + args.message, + { + force: flags.force, + maxBytes: flags['max-bytes'], + output: flags.output, + }, + ) + this.writeValue(result, result.data.path, flags) + } +} diff --git a/src/services/history.ts b/src/services/history.ts index db6daf9..ae12ccf 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -1,4 +1,9 @@ +import {stat} from 'node:fs/promises' +import {resolve} from 'node:path' + import { + PolymorfaConfigurationError, + PolymorfaConflictError, type HistoryChat, type HistoryMessage, type HistoryPage, @@ -7,6 +12,9 @@ import { type MessagingClient, type ResponseMetadata, } from '@polymorfa/sdk' +import {writeStreamToFile} from '@polymorfa/sdk/node' + +export const DEFAULT_MAX_HISTORY_MEDIA_BYTES = 32 * 1024 * 1024 interface HistoryResult { readonly data: T @@ -64,6 +72,82 @@ export async function viewHistoryMessage( return result(response.data.data, response.metadata) } +/** Download a stored Official API message copy through the enrolled history API. */ +export async function downloadHistoryMessageMedia( + client: MessagingClient, + session: string, + conversation: string, + messageId: string, + options: { + readonly force: boolean + readonly maxBytes: number + readonly output: string + }, +): Promise< + HistoryResult<{ + readonly bytes: number + readonly path: string + readonly contentType?: string + }> +> { + const output = resolve(options.output) + if (!options.force) { + try { + await stat(output) + throw new PolymorfaConflictError( + `Output file ${output} already exists. Pass --force to overwrite it.`, + ) + } catch (error) { + if ((error as {code?: string}).code !== 'ENOENT') throw error + } + } + + const download = await client.chats.downloadMessageMediaStream( + session, + conversation, + messageId, + ) + if ( + download.contentLength !== undefined && + download.contentLength > options.maxBytes + ) { + await download.body.cancel().catch(() => {}) + throw new PolymorfaConfigurationError( + `Stored media exceeds the ${options.maxBytes} byte download limit. Increase --max-bytes to allow larger files.`, + 'max-bytes', + ) + } + + const written = await writeStreamToFile(download.body, output, { + maxBytes: options.maxBytes, + overwrite: options.force, + }).catch((error: unknown) => { + if (error instanceof PolymorfaConflictError) { + throw new PolymorfaConflictError( + `Output file ${output} already exists. Pass --force to overwrite it.`, + ) + } + if ((error as {code?: unknown} | undefined)?.code === 'media_too_large') { + throw new PolymorfaConfigurationError( + `Stored media exceeds the ${options.maxBytes} byte download limit. Increase --max-bytes to allow larger files.`, + 'max-bytes', + ) + } + throw error + }) + + return result( + { + bytes: written.bytes, + path: output, + ...(download.contentType === undefined + ? {} + : {contentType: download.contentType}), + }, + download.metadata, + ) +} + export function renderHistoryChats(page: HistoryPage): string { const lines = page.data.map((chat) => [ diff --git a/test/commands/history.test.ts b/test/commands/history.test.ts index becd16d..ac4ee25 100644 --- a/test/commands/history.test.ts +++ b/test/commands/history.test.ts @@ -1,3 +1,6 @@ +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' import {runCommand} from '@oclif/test' import {afterEach, describe, expect, test} from 'vitest' @@ -168,6 +171,40 @@ describe('stored chat history commands', () => { } }) + test('downloads stored message media to a new file only', async () => { + const directory = await mkdtemp(join(tmpdir(), 'history-media-test-')) + try { + server = await startHttpServer({ + rawBody: Uint8Array.from([1, 2, 3]), + headers: {'content-type': 'image/jpeg', 'content-length': '3'}, + }) + const output = join(directory, 'photo.jpg') + const result = await apiKey(() => + runCommand( + `chat message media +15550001111 739182640518204 --session number/one --output ${output} --json`, + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/messaging/number%2Fone/chats/%2B15550001111/messages/739182640518204/media', + }) + expect([...(await readFile(output))]).toEqual([1, 2, 3]) + + await writeFile(output, 'keep') + const refused = await apiKey(() => + runCommand( + `chat message media +15550001111 739182640518204 --session number/one --output ${output}`, + ), + ) + expect(refused.error).toBeDefined() + expect(await readFile(output, 'utf8')).toBe('keep') + expect(server.requests).toHaveLength(1) + } finally { + await rm(directory, {recursive: true, force: true}) + } + }) + async function apiKey(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment(