Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions docs/command-research/history-media.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions src/commands/chat/message/media.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
84 changes: 84 additions & 0 deletions src/services/history.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {stat} from 'node:fs/promises'
import {resolve} from 'node:path'

import {
PolymorfaConfigurationError,
PolymorfaConflictError,
type HistoryChat,
type HistoryMessage,
type HistoryPage,
Expand All @@ -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<T> {
readonly data: T
Expand Down Expand Up @@ -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<HistoryChat>): string {
const lines = page.data.map((chat) =>
[
Expand Down
37 changes: 37 additions & 0 deletions test/commands/history.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<T>(run: () => Promise<T>): Promise<T> {
if (server === undefined) throw new Error('Test server was not started.')
return withEnvironment(
Expand Down