diff --git a/contracts/README.md b/contracts/README.md index 39e447a7..444df70d 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -90,12 +90,18 @@ product page. `MessagingClient.cloudCatalogs.listProducts` follows its WABA and catalog path, `sessions:read` credential boundary, projected product fields, and opaque cursor response. It does not establish live catalog access. +API PRs #333 and #385, merged at the pinned commit, add the Official API +message-media retrieval status and the stored-copy download route. +`MessagingClient.chats.downloadMessageMedia` and `downloadMessageMediaStream` +cover the route; `HistoryMessage.mediaRetrieval` types the status. This does +not establish deployed HMS beta access or SDK package publication. + The merged Campaigns failed-state follow-up updates the `campaign.failed` reason example and the public archive/delete `409` descriptions. It changes no operation fingerprint or SDK method mapping. The API merge also incorporates two unrelated routes from API PRs #333 and -#332. Hosted-history media download is assigned to SDK PR #316; Messaging +#332. Hosted-history media download is covered by `MessagingClient.chats`; Messaging campaign update is assigned to SDK PR #307. Both are intentional exclusions in this Calls ledger. Existing hosted-history message reads now type the optional `mediaRetrieval` status and remain covered. @@ -205,9 +211,9 @@ signed events with these names still decode as unknown events. | Status | Operations | | ------------------- | ---------: | -| Covered | 405 | +| Covered | 406 | | Missing | 0 | -| Excluded | 131 | +| Excluded | 130 | | Partial | 0 | | Changed fingerprint | 0 | | Total | 536 | diff --git a/contracts/coverage.json b/contracts/coverage.json index 131adc0e..bd1cbdc7 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -587,8 +587,8 @@ "operationId": "getChatMessageMedia", "fingerprint": "fff403df5eb56d8cfd73b930641f5ea070b39eb9a4e2d8bb05aa7f9992ca7065", "typescript": { - "status": "excluded", - "reason": "Official API hosted-history media download is owned by SDK PR #316." + "status": "covered", + "method": "MessagingClient.chats.downloadMessageMedia" } }, { diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 93752eae..b27869cd 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -1178,14 +1178,22 @@ if (page.data.nextCursor) { console.log(page.metadata.headers["polymorfa-data-region"]); ``` -These four reads require an organization key or project token, a visible +These conversation and message reads require an organization key or project token, a visible Number with hosted message storage enabled, team enrollment in `messaging.history`, and `chats:read` or `messages:read` as appropriate. The feature is an unreleased enrolled beta; an SDK method does not grant access. Client tokens are refused before transport. A disabled HMS Number yields `404 hms_not_enabled`; absent beta access yields `403 permission_denied`, and -an unavailable regional read yields `503 service_unavailable`. Media entries -carry an API download path, not a signed URL; downloading requires `media:read`. +an unavailable regional read yields `503 service_unavailable`. + +For Official API media, `message.mediaRetrieval.state` reports whether a copy +is `pending`, `stored`, or in a final state without a copy. A `stored` message +has a media entry whose `url` is a message-scoped API path, not a signed URL. +Download that copy with `messaging.chats.downloadMessageMedia(session, conversation, message.id)` +or stream it with `downloadMessageMediaStream(session, conversation, message.id)`. These reads +require both `messages:read` and `media:read`; a missing copy returns 404. +`downloadMessageMedia` buffers the whole file, so prefer the stream for large +media. Linked-device media paths still use `messaging.media.download(mediaId)`. Client tokens can call all five Messages operations only when the corresponding live rule is enabled: `send_message` for send and star, `send_reaction` for diff --git a/packages/typescript/src/messaging/chats.ts b/packages/typescript/src/messaging/chats.ts index 00c64db1..e8293693 100644 --- a/packages/typescript/src/messaging/chats.ts +++ b/packages/typescript/src/messaging/chats.ts @@ -11,6 +11,7 @@ import type { ListHistoryChatsParams, ListHistoryMessagesParams, } from "./history.js"; +import type { MediaDownloadStream } from "./media.js"; import type { DisappearingTimerRequest, EditMessageRequest, @@ -115,6 +116,53 @@ export class ChatsResource { }); } + /** Downloads a stored Official API message copy. Requires `messages:read`, `media:read`, and enrolled HMS history access. */ + downloadMessageMedia( + session: string, + conversation: string, + messageId: string, + options: RequestOptions = {}, + ): Promise> { + this.assertHistoryCredential(); + return this.transport.requestBinary({ + method: "GET", + path: `${chatMessagePath(session, conversation, messageId)}/media`, + ...options, + }); + } + + /** Streams a stored Official API message copy without buffering the file. */ + async downloadMessageMediaStream( + session: string, + conversation: string, + messageId: string, + options: RequestOptions = {}, + ): Promise { + this.assertHistoryCredential(); + const response = await this.transport.requestStream({ + method: "GET", + path: `${chatMessagePath(session, conversation, messageId)}/media`, + ...options, + }); + return Object.freeze({ + body: response.body, + ...(response.contentType === undefined + ? {} + : { contentType: response.contentType }), + ...(response.contentLength === undefined + ? {} + : { contentLength: response.contentLength }), + ...(response.filename === undefined + ? {} + : { filename: response.filename }), + ...(response.metadata.requestId === undefined + ? {} + : { requestId: response.metadata.requestId }), + redirected: response.redirected, + metadata: response.metadata, + }); + } + editMessage( session: string, chatId: string, diff --git a/packages/typescript/src/messaging/history.ts b/packages/typescript/src/messaging/history.ts index 2f0f5e68..133fd94e 100644 --- a/packages/typescript/src/messaging/history.ts +++ b/packages/typescript/src/messaging/history.ts @@ -22,11 +22,11 @@ export interface HistoryMedia { readonly id: string; readonly mimeType: string; readonly fileLength: number; - /** API path for stored bytes; this is not a signed URL. */ + /** API download path. Cloud stored copies use `MessagingClient.chats.downloadMessageMedia`. */ readonly url: string; } -/** Official API media storage status on a hosted history message. */ +/** Official API media copy status. Other final states have no downloadable copy. */ export interface HistoryMediaRetrieval { readonly state: | "pending" diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index e92b113b..686e2225 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -175,10 +175,10 @@ describe("coverage checker", () => { expect(result.report).toMatchObject({ sourceCommit: "29abb7b66a8e225af4df5cf354e9836ccb2ce04c", total: 536, - covered: 405, + covered: 406, partial: 0, missing: 0, - excluded: 131, + excluded: 130, changed: 0, }); const resolutions = (result.report?.resolutions ?? []) as Array<{ diff --git a/packages/typescript/test/history.test.ts b/packages/typescript/test/history.test.ts index d8745ce6..51c1657d 100644 --- a/packages/typescript/test/history.test.ts +++ b/packages/typescript/test/history.test.ts @@ -170,6 +170,50 @@ describe("hosted message history", () => { ); }); + it("downloads a stored Official API copy by encoded message path", async () => { + const fetch = vi.fn( + async () => + new Response(Uint8Array.from([1, 2, 3]), { + headers: { "content-type": "image/jpeg", "content-length": "3" }, + }), + ); + const result = await client(fetch).chats.downloadMessageMedia( + "support/1", + "+14155550123", + "739182640518204", + ); + expect(new Uint8Array(result.data)).toEqual(Uint8Array.from([1, 2, 3])); + const url = new URL(String(fetch.mock.calls[0]![0])); + expect(url.pathname).toBe( + "/messaging/support%2F1/chats/%2B14155550123/messages/739182640518204/media", + ); + + const stream = await client(fetch).chats.downloadMessageMediaStream( + "support", + "+14155550123", + "739182640518204", + ); + expect(stream).toMatchObject({ + contentType: "image/jpeg", + contentLength: 3, + }); + expect( + new Uint8Array(await new Response(stream.body).arrayBuffer()), + ).toEqual(Uint8Array.from([1, 2, 3])); + }); + + it("rejects client tokens before downloading stored media", async () => { + const fetch = vi.fn(); + const chats = client(fetch, "clientToken").chats; + expect(() => + chats.downloadMessageMedia("support", "+14155550123", "1"), + ).toThrow(PolymorfaConfigurationError); + await expect( + chats.downloadMessageMediaStream("support", "+14155550123", "1"), + ).rejects.toBeInstanceOf(PolymorfaConfigurationError); + expect(fetch).not.toHaveBeenCalled(); + }); + it("rejects client tokens locally on all history reads", () => { const fetch = vi.fn(); const chats = client(fetch, "clientToken").chats;