diff --git a/apps/cli/remote-session/config.ts b/apps/cli/remote-session/config.ts index 1afa6f7bad..51966a3d39 100644 --- a/apps/cli/remote-session/config.ts +++ b/apps/cli/remote-session/config.ts @@ -1,9 +1,25 @@ import fs from 'fs'; +import { hostname } from 'os'; import { readAuthToken } from '@studio/common/lib/shared-config'; import { getRemoteSessionConfigPath } from '@studio/common/lib/well-known-paths'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; +/** + * Sanitize the host's name down to the wpcom-friendly charset + * (`[a-z0-9_]`, max 32 chars) so it's safe to use as a `machine_id` cache key + * suffix without escaping. Falls back to `unknown_host` if `os.hostname()` + * yields nothing usable. + */ +function deriveMachineId(): string { + const sanitized = hostname() + .toLowerCase() + .replace( /[^a-z0-9_]/g, '_' ) + .replace( /^_+|_+$/g, '' ) + .slice( 0, 32 ); + return sanitized || 'unknown_host'; +} + export const remoteSessionConfigSchema = z.object( { base_url: z.string().url().default( 'https://public-api.wordpress.com/wpcom/v2/telegram-bot' ), // `token` is the only required field; if omitted everywhere, falls back to the @@ -15,6 +31,14 @@ export const remoteSessionConfigSchema = z.object( { // polled message back into the response. bot: z.string().min( 1 ).optional(), chat_id: z.number().int().optional(), + // Stable identifier for this CLI host, sent as `machine_id` on outbound + // replies to studio-mobile bots. Defaults to a sanitized `os.hostname()`; + // override via `STUDIO_REMOTE_MACHINE_ID` if multiple installs on the same + // host need to be distinguished (or to pin a friendly name for tests). + machine_id: z + .string() + .regex( /^[a-z0-9_]{1,32}$/, 'machine_id must match [a-z0-9_]{1,32}' ) + .default( deriveMachineId ), poll_interval_seconds: z.number().positive().default( 2 ), long_poll_timeout_seconds: z.number().positive().default( 25 ), max_message_chars: z.number().int().positive().max( 4096 ).default( 3800 ), @@ -28,6 +52,7 @@ export interface RemoteSessionOverrides { bot?: string; chat_id?: number; base_url?: string; + machine_id?: string; } export class RemoteSessionConfigError extends Error { @@ -57,6 +82,9 @@ function readEnvOverrides(): RemoteSessionOverrides { overrides.chat_id = parsed; } } + if ( process.env.STUDIO_REMOTE_MACHINE_ID ) { + overrides.machine_id = process.env.STUDIO_REMOTE_MACHINE_ID; + } return overrides; } diff --git a/apps/cli/remote-session/index.ts b/apps/cli/remote-session/index.ts index 31015e57ec..89e5258e3f 100644 --- a/apps/cli/remote-session/index.ts +++ b/apps/cli/remote-session/index.ts @@ -9,7 +9,7 @@ import { import { installDaemonChildHooks, isDaemonChild } from 'cli/remote-session/daemon'; import { RemoteSessionLogger } from 'cli/remote-session/logger'; import { runPollLoop } from 'cli/remote-session/poll-loop'; -import { respondMessage } from 'cli/remote-session/telegram-client'; +import { respondMessage } from 'cli/remote-session/respond-router'; export { RemoteSessionConfigError }; diff --git a/apps/cli/remote-session/media-streamer.ts b/apps/cli/remote-session/media-streamer.ts index fac24a49e4..066022f49e 100644 --- a/apps/cli/remote-session/media-streamer.ts +++ b/apps/cli/remote-session/media-streamer.ts @@ -1,7 +1,7 @@ import type { JsonEvent } from '@studio/common/ai/json-events'; import type { RemoteSessionConfig } from 'cli/remote-session/config'; import type { RemoteSessionLogger } from 'cli/remote-session/logger'; -import type { respondMessage } from 'cli/remote-session/telegram-client'; +import type { respondMessage } from 'cli/remote-session/respond-router'; interface ValidMediaShare { dataBase64: string; diff --git a/apps/cli/remote-session/poll-loop.ts b/apps/cli/remote-session/poll-loop.ts index adf62c8bbe..da689a1127 100644 --- a/apps/cli/remote-session/poll-loop.ts +++ b/apps/cli/remote-session/poll-loop.ts @@ -5,15 +5,15 @@ import { type RemoteSessionConfig } from 'cli/remote-session/config'; import { RemoteSessionLogger } from 'cli/remote-session/logger'; import { MediaStreamer } from 'cli/remote-session/media-streamer'; import { ProgressStreamer } from 'cli/remote-session/progress-streamer'; +import { + RemoteAuthError, + RemoteBadRequestError, + RemoteTransientError, +} from 'cli/remote-session/remote-http'; import { chunkReply, extractReply } from 'cli/remote-session/reply-formatter'; +import { respondMessage } from 'cli/remote-session/respond-router'; import { clearSessionId, readStateForChat, writeSessionId } from 'cli/remote-session/state'; -import { - TelegramAuthError, - TelegramBadRequestError, - TelegramTransientError, - pollMessages, - respondMessage, -} from 'cli/remote-session/telegram-client'; +import { pollMessages } from 'cli/remote-session/telegram-client'; import { runTurn, type TurnOutcome, type TurnRunOptions } from 'cli/remote-session/turn-runner'; /** Injected for tests. */ @@ -403,7 +403,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL batch = await deps.poll( config, abortController.signal, { logger: deps.logger } ); backoffAttempt = 0; } catch ( error ) { - if ( error instanceof TelegramAuthError ) { + if ( error instanceof RemoteAuthError ) { deps.logger.error( 'Auth error; detaching', { status: error.status } ); if ( config.chat_id !== undefined ) { await postBestEffort( @@ -418,7 +418,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL process.exitCode = 1; break; } - if ( error instanceof TelegramTransientError ) { + if ( error instanceof RemoteTransientError ) { const delay = Math.min( 30_000, 1000 * Math.pow( 2, backoffAttempt ) ); deps.logger.warn( 'Transient poll error; backing off', { status: error.status, @@ -506,7 +506,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL { logger: deps.logger } ); } catch ( error ) { - if ( error instanceof TelegramBadRequestError ) { + if ( error instanceof RemoteBadRequestError ) { deps.logger.warn( 'Respond 4xx on /new ack', { status: error.status } ); } else { throw error; @@ -525,7 +525,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL telemetryEnabled ); } catch ( error ) { - if ( error instanceof TelegramAuthError ) { + if ( error instanceof RemoteAuthError ) { deps.logger.error( 'Auth error during respond; detaching', { status: error.status, } ); @@ -534,7 +534,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL process.exitCode = 1; break batchLoop; } - if ( error instanceof TelegramBadRequestError ) { + if ( error instanceof RemoteBadRequestError ) { deps.logger.warn( 'Respond 4xx; dropping chunk', { status: error.status } ); continue; } diff --git a/apps/cli/remote-session/progress-streamer.ts b/apps/cli/remote-session/progress-streamer.ts index 4160d44071..df001cbc10 100644 --- a/apps/cli/remote-session/progress-streamer.ts +++ b/apps/cli/remote-session/progress-streamer.ts @@ -4,7 +4,7 @@ import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { JsonEvent } from '@studio/common/ai/json-events'; import type { RemoteSessionConfig } from 'cli/remote-session/config'; import type { RemoteSessionLogger } from 'cli/remote-session/logger'; -import type { respondMessage } from 'cli/remote-session/telegram-client'; +import type { respondMessage } from 'cli/remote-session/respond-router'; export interface ProgressTarget { chatId: number; diff --git a/apps/cli/remote-session/remote-http.ts b/apps/cli/remote-session/remote-http.ts new file mode 100644 index 0000000000..93eb7fbdc9 --- /dev/null +++ b/apps/cli/remote-session/remote-http.ts @@ -0,0 +1,84 @@ +/** + * Transport-layer primitives shared by the Telegram and studio-mobile clients + * — error classes, URL helpers, and fetch/backoff utilities. None of this + * knows about message shapes; it just gets bytes to and from the wpcom proxy. + */ + +export class RemoteAuthError extends Error { + constructor( public readonly status: number ) { + super( `Remote server returned auth error (HTTP ${ status })` ); + this.name = 'RemoteAuthError'; + } +} + +export class RemoteTransientError extends Error { + constructor( + message: string, + public readonly status?: number + ) { + super( message ); + this.name = 'RemoteTransientError'; + } +} + +export class RemoteBadRequestError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super( message ); + this.name = 'RemoteBadRequestError'; + } +} + +export function assertSameHost( urlString: string, allowedHost: string ): void { + const u = new URL( urlString ); + if ( u.host !== allowedHost ) { + throw new RemoteTransientError( + `Refusing to follow redirect to different host: ${ u.host } (allowed: ${ allowedHost })` + ); + } +} + +function normalizeBase( base: string ): URL { + // Ensure a trailing slash so relative path joins work predictably. + return new URL( base.endsWith( '/' ) ? base : `${ base }/` ); +} + +export function buildUrl( baseUrl: string, pathName: string ): string { + const base = normalizeBase( baseUrl ); + const joined = new URL( pathName.replace( /^\//, '' ), base ); + return joined.toString(); +} + +export async function safeReadText( response: Response ): Promise< string > { + try { + return await response.text(); + } catch { + return ''; + } +} + +export async function backoff( attempt: number ): Promise< void > { + const baseMs = Math.min( 30_000, 500 * Math.pow( 2, attempt ) ); + const jitter = Math.random() * 200; + await new Promise( ( resolve ) => setTimeout( resolve, baseMs + jitter ) ); +} + +export function composeSignals( a?: AbortSignal, b?: AbortSignal ): AbortSignal | undefined { + if ( ! a ) { + return b; + } + if ( ! b ) { + return a; + } + const controller = new AbortController(); + const onAbort = () => controller.abort(); + if ( a.aborted || b.aborted ) { + controller.abort(); + } else { + a.addEventListener( 'abort', onAbort, { once: true } ); + b.addEventListener( 'abort', onAbort, { once: true } ); + } + return controller.signal; +} diff --git a/apps/cli/remote-session/respond-router.ts b/apps/cli/remote-session/respond-router.ts new file mode 100644 index 0000000000..069fa68f03 --- /dev/null +++ b/apps/cli/remote-session/respond-router.ts @@ -0,0 +1,366 @@ +import { + RemoteAuthError, + RemoteBadRequestError, + RemoteTransientError, + assertSameHost, + backoff, + buildUrl, + safeReadText, +} from 'cli/remote-session/remote-http'; +import { + buildMobileRespondBody, + buildMobileRespondUrl, + isStudioMobileBot, +} from 'cli/remote-session/studio-mobile-client'; +import { type RespondAction, buildTelegramRespondBody } from 'cli/remote-session/telegram-client'; +import type { RemoteSessionConfig } from 'cli/remote-session/config'; +import type { RemoteSessionLogger } from 'cli/remote-session/logger'; + +/** + * Orchestrates "post a reply back to the user" for both Telegram and + * studio-mobile bots. Picks the right client based on the bot identity, + * delegates URL + body construction to that client, and handles the shared + * HTTP concerns uniformly (retries on 5xx, auth/bad-request short-circuits, + * the wpcom response envelope). + * + * Dispatches on `params.action` (default `create`): + * - `create` (text only) — `application/json` body to /local-agent-respond + * (Telegram) or `/studio-mobile-client/respond` + * (mobile envelope). + * - `create` (text + photo) — Telegram only, `multipart/form-data` with the + * raw photo bytes. Photos on a mobile bot are + * folded into the envelope text by the mobile + * client (bytes are dropped). + * - `edit` — Telegram only. Mobile has no edit primitive in + * v1, so the router degrades it to a fresh + * envelope `create` and returns a synthetic + * outcome (empty `messageIds`, success: true) + * so the progress streamer keeps streaming. + */ + +export interface RespondParams { + chatId: number; + bot?: string; + /** + * What to do on the server side. Defaults to `create` (sendMessage / sendPhoto + * on Telegram, append envelope on mobile). + * - `edit` requires `text` + `messageId`, forbids `photo` / `caption` in v1. + * + * The wpcom endpoint also accepts `delete` and `chat_action`, but neither + * has a production caller in this codebase: the progress streamer settled + * on edit-not-delete after Beeper rendered deletions as a persistent + * tombstone. Add them back to the client when a consumer needs them. + */ + action?: RespondAction; + /** Required for `edit`. Returned by an earlier successful create. */ + messageId?: number; + /** Plain text reply. Required for `create` (when no `photo`) and for `edit`. */ + text?: string; + /** + * Base64-encoded image bytes (PNG or JPEG). `create` only. When set, the + * request goes out as `multipart/form-data` so the server forwards it to + * Telegram via `sendPhoto`. + */ + photo?: string; + /** MIME type of the photo bytes. Defaults to `image/png`. */ + photoMimeType?: 'image/png' | 'image/jpeg'; + /** Caption to send alongside `photo`. The server demotes long captions to a follow-up message. */ + caption?: string; +} + +/** + * Structured outcome of a `respondMessage` call. Always returned on 2xx, even + * when `success === false` — callers inspect `retryAfterMs` / `error` to + * decide what to do next, rather than catching exceptions. + */ +export interface RespondOutcome { + success: boolean; + /** + * Telegram message ids returned by the server. For `create`, one per chunk + * (text + optional photo). For `edit`, contains the edited message id. + * Empty on mobile (no message-id concept) and on any failure mode that + * didn't land a message. + */ + messageIds: number[]; + /** Populated when Telegram throttled the underlying API call. */ + retryAfterMs?: number; + textSent?: boolean; + photoSent?: boolean; + chunksSent?: number; + error?: string; +} + +interface RespondResponseBody { + success?: boolean; + photo_sent?: boolean; + text_sent?: boolean; + chunks_sent?: number; + message_ids?: number[]; + retry_after_ms?: number; + error?: string; +} + +/** + * POST a message back to the user. Retries up to 3 times on 5xx with exponential + * backoff. 4xx responses surface as `RemoteBadRequestError` and should be logged + * but not retried. The server always answers with HTTP 200 and a JSON body + * indicating partial outcomes (`success`, `photo_sent`, `text_sent`, + * `message_ids`, `retry_after_ms`, `error`); we log a warning when `success` + * is false but do NOT throw on `retry_after_ms` — the caller inspects the + * returned outcome and decides how long to back off. + */ +export async function respondMessage( + config: RemoteSessionConfig, + params: RespondParams, + options: { signal?: AbortSignal; maxRetries?: number; logger?: RemoteSessionLogger } = {} +): Promise< RespondOutcome > { + const action: RespondAction = params.action ?? 'create'; + + // Normalize empty strings to "absent" so every downstream check (early + // guard, body builder, debug log) agrees on what counts as present. + const text = params.text && params.text.length > 0 ? params.text : undefined; + const photo = params.photo && params.photo.length > 0 ? params.photo : undefined; + const caption = params.caption && params.caption.length > 0 ? params.caption : undefined; + const messageId = params.messageId; + + validateRespondParams( action, { text, photo, caption, messageId } ); + + const bot = params.bot ?? config.bot; + const isMobile = isStudioMobileBot( bot ); + + // Mobile has no edit primitive in v1. Degrade to a fresh `create` envelope + // — the progress streamer's `createFailed` fallback already handles the + // resulting empty `messageIds` by issuing another `create` on the next + // event, which yields a stream of agent_message envelopes (matches the + // pre-trunk behavior on the mobile path). + const effectiveAction: RespondAction = isMobile && action === 'edit' ? 'create' : action; + + const url = isMobile + ? buildMobileRespondUrl( config.base_url ) + : buildUrl( config.base_url, 'local-agent-respond' ); + const allowedHost = new URL( config.base_url ).host; + assertSameHost( url, allowedHost ); + const { body, contentType } = isMobile + ? buildMobileRespondBody( { + chatId: params.chatId, + bot, + machineId: config.machine_id, + text, + photo, + caption, + logger: options.logger, + } ) + : buildTelegramRespondBody( { + chatId: params.chatId, + bot, + action: effectiveAction, + messageId, + text, + photo, + photoMimeType: params.photoMimeType, + caption, + } ); + + const maxRetries = options.maxRetries ?? 3; + let attempt = 0; + let lastError: unknown; + const logger = options.logger; + logger?.debug( 'Respond start', { + chat_id: params.chatId, + bot, + action: effectiveAction, + message_id: messageId, + text_length: text?.length ?? 0, + text_preview: text?.slice( 0, 120 ), + has_photo: photo !== undefined, + photo_base64_chars: photo?.length ?? 0, + photo_mime_type: params.photoMimeType, + caption_length: caption?.length ?? 0, + transport: contentType === undefined ? 'multipart' : 'json', + } ); + + while ( attempt <= maxRetries ) { + let response: Response; + try { + // Note: when `body` is a FormData the runtime sets the multipart + // Content-Type with the proper boundary. Setting it manually here + // would corrupt the boundary token, so we omit it for that path. + const headers: Record< string, string > = { + Authorization: `Bearer ${ config.token }`, + }; + if ( contentType ) { + headers[ 'Content-Type' ] = contentType; + } + response = await fetch( url, { + method: 'POST', + headers, + body, + redirect: 'manual', + signal: options.signal, + } ); + } catch ( error ) { + lastError = error; + if ( error instanceof Error && error.name === 'AbortError' ) { + throw error; + } + logger?.warn( 'Respond network error', { + attempt, + chat_id: params.chatId, + error: ( error as Error ).message, + } ); + await backoff( attempt ); + attempt++; + continue; + } + + if ( response.status === 401 || response.status === 403 ) { + logger?.error( 'Respond auth error', { + status: response.status, + chat_id: params.chatId, + } ); + throw new RemoteAuthError( response.status ); + } + if ( response.status >= 500 ) { + logger?.warn( 'Respond 5xx', { status: response.status, attempt } ); + lastError = new RemoteTransientError( + `Respond returned ${ response.status }`, + response.status + ); + await backoff( attempt ); + attempt++; + continue; + } + if ( response.status >= 400 ) { + const bodyText = await safeReadText( response ); + logger?.warn( 'Respond 4xx', { + status: response.status, + chat_id: params.chatId, + body_preview: bodyText.slice( 0, 200 ), + } ); + throw new RemoteBadRequestError( + `Respond returned ${ response.status }${ bodyText ? `: ${ bodyText }` : '' }`, + response.status + ); + } + if ( ! response.ok ) { + logger?.warn( 'Respond unexpected status', { status: response.status } ); + throw new RemoteTransientError( + `Respond returned unexpected status ${ response.status }`, + response.status + ); + } + + const raw = await readRespondOutcome( response ); + const outcome = toRespondOutcome( raw ); + if ( ! outcome.success ) { + logger?.warn( 'Respond reported partial failure', { + chat_id: params.chatId, + action: effectiveAction, + photo_sent: outcome.photoSent, + text_sent: outcome.textSent, + retry_after_ms: outcome.retryAfterMs, + error: outcome.error, + } ); + } else { + logger?.debug( 'Respond ok', { + status: response.status, + chat_id: params.chatId, + action: effectiveAction, + attempt, + photo_sent: outcome.photoSent, + text_sent: outcome.textSent, + chunks_sent: outcome.chunksSent, + message_ids: outcome.messageIds, + retry_after_ms: outcome.retryAfterMs, + } ); + } + return outcome; + } + + logger?.error( 'Respond failed after retries', { + chat_id: params.chatId, + action: effectiveAction, + max_retries: maxRetries, + } ); + if ( lastError instanceof Error ) { + throw lastError; + } + throw new RemoteTransientError( 'Respond failed after retries' ); +} + +function validateRespondParams( + action: RespondAction, + parts: { + text: string | undefined; + photo: string | undefined; + caption: string | undefined; + messageId: number | undefined; + } +): void { + const { text, photo, caption, messageId } = parts; + switch ( action ) { + case 'create': + if ( ! text && ! photo ) { + throw new Error( 'respondMessage create requires `text`, `photo`, or both' ); + } + if ( messageId !== undefined ) { + throw new Error( 'respondMessage create does not accept `messageId`' ); + } + return; + case 'edit': + if ( messageId === undefined ) { + throw new Error( 'respondMessage edit requires `messageId`' ); + } + if ( ! text ) { + throw new Error( 'respondMessage edit requires `text`' ); + } + if ( photo || caption ) { + throw new Error( 'respondMessage edit does not accept `photo` or `caption` (v1)' ); + } + return; + } +} + +function toRespondOutcome( raw: RespondResponseBody | null ): RespondOutcome { + // A missing body is treated as a bare success — preserves the historical + // behavior where the server occasionally returned 200 with an empty body + // (and the studio-mobile /respond endpoint returns `{ delivered: true }` + // without `message_ids`). + if ( ! raw ) { + return { success: true, messageIds: [] }; + } + const out: RespondOutcome = { + success: raw.success !== false, + messageIds: Array.isArray( raw.message_ids ) + ? raw.message_ids.filter( ( id ): id is number => typeof id === 'number' ) + : [], + }; + if ( typeof raw.retry_after_ms === 'number' && raw.retry_after_ms > 0 ) { + out.retryAfterMs = raw.retry_after_ms; + } + if ( typeof raw.text_sent === 'boolean' ) { + out.textSent = raw.text_sent; + } + if ( typeof raw.photo_sent === 'boolean' ) { + out.photoSent = raw.photo_sent; + } + if ( typeof raw.chunks_sent === 'number' ) { + out.chunksSent = raw.chunks_sent; + } + if ( typeof raw.error === 'string' ) { + out.error = raw.error; + } + return out; +} + +async function readRespondOutcome( response: Response ): Promise< RespondResponseBody | null > { + const raw = await safeReadText( response ); + if ( ! raw.trim() ) { + return null; + } + try { + return JSON.parse( raw ) as RespondResponseBody; + } catch { + return null; + } +} diff --git a/apps/cli/remote-session/studio-mobile-client.ts b/apps/cli/remote-session/studio-mobile-client.ts new file mode 100644 index 0000000000..d7e59e4556 --- /dev/null +++ b/apps/cli/remote-session/studio-mobile-client.ts @@ -0,0 +1,91 @@ +import { randomUUID } from 'crypto'; +import { buildUrl } from 'cli/remote-session/remote-http'; +import type { RemoteSessionLogger } from 'cli/remote-session/logger'; + +/** + * Wire-format adapter for the wpcom `studio-mobile-client/respond` endpoint. + * Knows nothing about the inbound poll loop — by the time we reach here the + * router has already decided this reply is bound for a studio-mobile bot. + */ + +const STUDIO_MOBILE_BOT_PREFIX = 'studio_mobile_'; +const TELEGRAM_BOT_BASE_PATH_RE = /\/telegram-bot(\/?$)/; + +export function isStudioMobileBot( bot: string | undefined ): bot is string { + return typeof bot === 'string' && bot.startsWith( STUDIO_MOBILE_BOT_PREFIX ); +} + +/** + * Derive the studio-mobile `/respond` URL by swapping the trailing + * `/telegram-bot` segment in `base_url` for `/studio-mobile-client`. Throws + * loudly if the base URL doesn't have that segment — silently producing the + * wrong URL would surface much later as a confusing 404 from a different + * service. + */ +export function buildMobileRespondUrl( baseUrl: string ): string { + if ( ! TELEGRAM_BOT_BASE_PATH_RE.test( baseUrl ) ) { + throw new Error( + `Cannot derive studio-mobile URL from base_url ${ baseUrl } (expected it to end with /telegram-bot)` + ); + } + const mobileBase = baseUrl.replace( TELEGRAM_BOT_BASE_PATH_RE, '/studio-mobile-client$1' ); + return buildUrl( mobileBase, 'respond' ); +} + +export interface MobileBodyParams { + chatId: number; + bot: string; + machineId: string; + text?: string; + photo?: string; + caption?: string; + logger?: RemoteSessionLogger; +} + +/** + * Build the studio-mobile `/respond` body. `chat_id` + `bot` are the wpcom-side + * memcache routing keys (queue is keyed `(user_id, chat_id, bot)`); `machine_id` + * is sent for forward-compat — wpcom accepts it optionally today and will key + * on it once Phase 2 of studio-mobile SPEC.md migrates the queue. + */ +export function buildMobileRespondBody( params: MobileBodyParams ): { + body: string; + contentType: string; +} { + let text = params.text; + if ( params.photo ) { + params.logger?.warn( 'Dropping photo for studio_mobile bot (out-of-scope for mobile v1)', { + machine_id: params.machineId, + photo_base64_chars: params.photo.length, + had_caption: Boolean( params.caption ), + } ); + text = flattenPhotoToText( text, params.caption ); + } + + if ( ! text ) { + throw new Error( 'Studio mobile respond requires `text` (photos are not supported yet)' ); + } + + return { + body: JSON.stringify( { + chat_id: params.chatId, + bot: params.bot, + machine_id: params.machineId, + envelope: { type: 'agent_message', id: randomUUID(), text }, + } ), + contentType: 'application/json', + }; +} + +/** + * Collapse a photo's text + caption into a single envelope text. Photos aren't + * part of the mobile v1 surface (see studio-mobile SPEC.md "Out of scope for + * v1"), so we drop the bytes and keep whatever copy the agent emitted alongside. + */ +function flattenPhotoToText( text: string | undefined, caption: string | undefined ): string { + const trimmedCaption = caption?.trim(); + if ( text && trimmedCaption ) { + return `${ text }\n\n${ trimmedCaption }`; + } + return text || trimmedCaption || '📷 (image omitted)'; +} diff --git a/apps/cli/remote-session/telegram-client.ts b/apps/cli/remote-session/telegram-client.ts index d40e0066b6..111ef5c40e 100644 --- a/apps/cli/remote-session/telegram-client.ts +++ b/apps/cli/remote-session/telegram-client.ts @@ -1,6 +1,20 @@ -import { type RemoteSessionConfig } from 'cli/remote-session/config'; +import { + RemoteAuthError, + RemoteTransientError, + assertSameHost, + buildUrl, + composeSignals, +} from 'cli/remote-session/remote-http'; +import type { RemoteSessionConfig } from 'cli/remote-session/config'; import type { RemoteSessionLogger } from 'cli/remote-session/logger'; +/** + * Wire-format adapter for the wpcom `telegram-bot/local-agent-{poll,respond}` + * endpoints. Only the Telegram-side concerns live here — the studio-mobile + * `/respond` shape is in `studio-mobile-client.ts`, and the routing decision + * + retry loop live in `respond-router.ts`. + */ + export interface PolledMessage { chat_id: number; text: string; @@ -11,52 +25,19 @@ export interface TelegramRequestContext { logger?: RemoteSessionLogger; } -export class TelegramAuthError extends Error { - constructor( public readonly status: number ) { - super( `Telegram server returned auth error (HTTP ${ status })` ); - this.name = 'TelegramAuthError'; - } -} - -export class TelegramTransientError extends Error { - constructor( - message: string, - public readonly status?: number - ) { - super( message ); - this.name = 'TelegramTransientError'; - } -} - -export class TelegramBadRequestError extends Error { - constructor( - message: string, - public readonly status: number - ) { - super( message ); - this.name = 'TelegramBadRequestError'; - } -} - -function assertSameHost( urlString: string, allowedHost: string ): void { - const u = new URL( urlString ); - if ( u.host !== allowedHost ) { - throw new TelegramTransientError( - `Refusing to follow redirect to different host: ${ u.host } (allowed: ${ allowedHost })` - ); - } -} - -function normalizeBase( base: string ): URL { - // Ensure a trailing slash so relative path joins work predictably. - return new URL( base.endsWith( '/' ) ? base : `${ base }/` ); -} - -function buildUrl( baseUrl: string, pathName: string ): string { - const base = normalizeBase( baseUrl ); - const joined = new URL( pathName.replace( /^\//, '' ), base ); - return joined.toString(); -} +/** + * Action the wpcom side should perform with the request body. Used by both + * the Telegram and studio-mobile paths, so it's exported here as the shared + * vocabulary (the router builds the actual wire body via the per-transport + * adapters). + * + * - `create` (default): Telegram sendMessage / sendPhoto, or a new + * studio-mobile envelope appended to the outbound queue. + * - `edit`: Telegram editMessageText against the captured `message_id`. + * Studio mobile has no edit primitive today, so the router degrades it + * to a fresh `create` envelope. + */ +export type RespondAction = 'create' | 'edit'; /** * Poll the server for pending messages. Returns an empty array when nothing is queued. @@ -65,7 +46,7 @@ function buildUrl( baseUrl: string, pathName: string ): string { * A batch can contain any number of messages; the caller is expected to drain them in order * (one `studio code --json` turn per message) before polling again. * - * Throws TelegramAuthError on 401/403, TelegramTransientError on 5xx or network errors. + * Throws RemoteAuthError on 401/403, RemoteTransientError on 5xx or network errors. * No inbound retries are attempted — once polled, a dropped message is dropped. */ export async function pollMessages( @@ -107,18 +88,18 @@ export async function pollMessages( } const message = ( error as Error ).message ?? 'unknown'; context.logger?.warn( 'Poll network error', { error: message } ); - throw new TelegramTransientError( `Network error polling Telegram: ${ message }` ); + throw new RemoteTransientError( `Network error polling Telegram: ${ message }` ); } finally { clearTimeout( timeoutId ); } if ( response.status === 401 || response.status === 403 ) { context.logger?.error( 'Poll auth error', { status: response.status } ); - throw new TelegramAuthError( response.status ); + throw new RemoteAuthError( response.status ); } if ( response.status >= 500 ) { context.logger?.warn( 'Poll 5xx', { status: response.status } ); - throw new TelegramTransientError( `Poll returned ${ response.status }`, response.status ); + throw new RemoteTransientError( `Poll returned ${ response.status }`, response.status ); } if ( response.status === 204 ) { context.logger?.debug( 'Poll 204 (no messages)', { @@ -129,14 +110,14 @@ export async function pollMessages( if ( response.status >= 300 && response.status < 400 ) { context.logger?.warn( 'Poll redirect', { status: response.status } ); // Never follow server-issued redirects blindly. - throw new TelegramTransientError( + throw new RemoteTransientError( `Unexpected redirect from poll endpoint: HTTP ${ response.status }`, response.status ); } if ( ! response.ok ) { context.logger?.warn( 'Poll unexpected status', { status: response.status } ); - throw new TelegramTransientError( + throw new RemoteTransientError( `Poll returned unexpected status ${ response.status }`, response.status ); @@ -201,306 +182,7 @@ function extractMessages( payload: unknown ): PolledMessage[] { return out; } -export type RespondAction = 'create' | 'edit'; - -export interface RespondParams { - chatId: number; - bot?: string; - /** - * What to do on Telegram's side. Defaults to `create` (sendMessage / sendPhoto). - * - `edit` requires `text` + `messageId`, forbids `photo` / `caption` in v1. - * - * The wpcom endpoint also accepts `delete` and `chat_action`, but neither - * has a production caller in this codebase: the progress streamer settled - * on edit-not-delete after Beeper rendered deletions as a persistent - * tombstone. Add them back to the client when a consumer needs them. - */ - action?: RespondAction; - /** Required for `edit`. Returned by an earlier successful create. */ - messageId?: number; - /** Plain text reply. Required for `create` (when no `photo`) and for `edit`. */ - text?: string; - /** - * Base64-encoded image bytes (PNG or JPEG). `create` only. When set, the - * request goes out as `multipart/form-data` so the server forwards it to - * Telegram via `sendPhoto`. - */ - photo?: string; - /** MIME type of the photo bytes. Defaults to `image/png`. */ - photoMimeType?: 'image/png' | 'image/jpeg'; - /** Caption to send alongside `photo`. The server demotes long captions to a follow-up message. */ - caption?: string; -} - -/** - * Structured outcome of a `respondMessage` call. Always returned on 2xx, even - * when `success === false` — callers inspect `retryAfterMs` / `error` to - * decide what to do next, rather than catching exceptions. - */ -export interface RespondOutcome { - success: boolean; - /** - * Telegram message ids returned by the server. For `create`, one per chunk - * (text + optional photo). For `edit`, contains the edited message id. - * Empty for any failure mode that didn't land a message. - */ - messageIds: number[]; - /** Populated when Telegram throttled the underlying API call. */ - retryAfterMs?: number; - textSent?: boolean; - photoSent?: boolean; - chunksSent?: number; - error?: string; -} - -interface RespondResponseBody { - success?: boolean; - photo_sent?: boolean; - text_sent?: boolean; - chunks_sent?: number; - message_ids?: number[]; - retry_after_ms?: number; - error?: string; -} - -/** - * POST a message back to Telegram. Retries up to 3 times on 5xx with exponential backoff. - * 4xx responses are surfaced as TelegramBadRequestError and should be logged but not retried. - * - * Dispatches on `params.action` (default `create`). All actions share the same - * auth, retry, and outcome envelope: - * - `create` (text only) — `application/json` body to /local-agent-respond. - * - `create` (text + photo) — `multipart/form-data` with the raw photo bytes. - * - `edit` — `application/json` body, single message, no chunking. - * - * The server always answers with HTTP 200 and a JSON body indicating partial outcomes - * (`success`, `photo_sent`, `text_sent`, `message_ids`, `retry_after_ms`, `error`). - * We log a warning when `success` is false but do NOT throw on `retry_after_ms` — - * the caller inspects the returned outcome and decides how long to back off. - */ -export async function respondMessage( - config: RemoteSessionConfig, - params: RespondParams, - options: { signal?: AbortSignal; maxRetries?: number; logger?: RemoteSessionLogger } = {} -): Promise< RespondOutcome > { - const action: RespondAction = params.action ?? 'create'; - - // Normalize empty strings to "absent" so every downstream check (early - // guard, body builder, debug log) agrees on what counts as present. - const text = params.text && params.text.length > 0 ? params.text : undefined; - const photo = params.photo && params.photo.length > 0 ? params.photo : undefined; - const caption = params.caption && params.caption.length > 0 ? params.caption : undefined; - const messageId = params.messageId; - - validateRespondParams( action, { text, photo, caption, messageId } ); - - const url = buildUrl( config.base_url, 'local-agent-respond' ); - const allowedHost = new URL( config.base_url ).host; - assertSameHost( url, allowedHost ); - - const bot = params.bot ?? config.bot; - const { body, contentType } = buildRespondBody( { - chatId: params.chatId, - bot, - action, - messageId, - text, - photo, - photoMimeType: params.photoMimeType, - caption, - } ); - - const maxRetries = options.maxRetries ?? 3; - let attempt = 0; - let lastError: unknown; - const logger = options.logger; - logger?.debug( 'Respond start', { - chat_id: params.chatId, - bot, - action, - message_id: messageId, - text_length: text?.length ?? 0, - text_preview: text?.slice( 0, 120 ), - has_photo: photo !== undefined, - photo_base64_chars: photo?.length ?? 0, - photo_mime_type: params.photoMimeType, - caption_length: caption?.length ?? 0, - transport: contentType === undefined ? 'multipart' : 'json', - } ); - - while ( attempt <= maxRetries ) { - let response: Response; - try { - // Note: when `body` is a FormData the runtime sets the multipart - // Content-Type with the proper boundary. Setting it manually here - // would corrupt the boundary token, so we omit it for that path. - const headers: Record< string, string > = { - Authorization: `Bearer ${ config.token }`, - }; - if ( contentType ) { - headers[ 'Content-Type' ] = contentType; - } - response = await fetch( url, { - method: 'POST', - headers, - body, - redirect: 'manual', - signal: options.signal, - } ); - } catch ( error ) { - lastError = error; - if ( error instanceof Error && error.name === 'AbortError' ) { - throw error; - } - logger?.warn( 'Respond network error', { - attempt, - chat_id: params.chatId, - error: ( error as Error ).message, - } ); - await backoff( attempt ); - attempt++; - continue; - } - - if ( response.status === 401 || response.status === 403 ) { - logger?.error( 'Respond auth error', { - status: response.status, - chat_id: params.chatId, - } ); - throw new TelegramAuthError( response.status ); - } - if ( response.status >= 500 ) { - logger?.warn( 'Respond 5xx', { status: response.status, attempt } ); - lastError = new TelegramTransientError( - `Respond returned ${ response.status }`, - response.status - ); - await backoff( attempt ); - attempt++; - continue; - } - if ( response.status >= 400 ) { - const text = await safeReadText( response ); - logger?.warn( 'Respond 4xx', { - status: response.status, - chat_id: params.chatId, - body_preview: text.slice( 0, 200 ), - } ); - throw new TelegramBadRequestError( - `Respond returned ${ response.status }${ text ? `: ${ text }` : '' }`, - response.status - ); - } - if ( ! response.ok ) { - logger?.warn( 'Respond unexpected status', { status: response.status } ); - throw new TelegramTransientError( - `Respond returned unexpected status ${ response.status }`, - response.status - ); - } - - const raw = await readRespondOutcome( response ); - const outcome = toRespondOutcome( raw ); - if ( ! outcome.success ) { - logger?.warn( 'Respond reported partial failure', { - chat_id: params.chatId, - action, - photo_sent: outcome.photoSent, - text_sent: outcome.textSent, - retry_after_ms: outcome.retryAfterMs, - error: outcome.error, - } ); - } else { - logger?.debug( 'Respond ok', { - status: response.status, - chat_id: params.chatId, - action, - attempt, - photo_sent: outcome.photoSent, - text_sent: outcome.textSent, - chunks_sent: outcome.chunksSent, - message_ids: outcome.messageIds, - retry_after_ms: outcome.retryAfterMs, - } ); - } - return outcome; - } - - logger?.error( 'Respond failed after retries', { - chat_id: params.chatId, - action, - max_retries: maxRetries, - } ); - if ( lastError instanceof Error ) { - throw lastError; - } - throw new TelegramTransientError( 'Respond failed after retries' ); -} - -function validateRespondParams( - action: RespondAction, - parts: { - text: string | undefined; - photo: string | undefined; - caption: string | undefined; - messageId: number | undefined; - } -): void { - const { text, photo, caption, messageId } = parts; - switch ( action ) { - case 'create': - if ( ! text && ! photo ) { - throw new Error( 'respondMessage create requires `text`, `photo`, or both' ); - } - if ( messageId !== undefined ) { - throw new Error( 'respondMessage create does not accept `messageId`' ); - } - return; - case 'edit': - if ( messageId === undefined ) { - throw new Error( 'respondMessage edit requires `messageId`' ); - } - if ( ! text ) { - throw new Error( 'respondMessage edit requires `text`' ); - } - if ( photo || caption ) { - throw new Error( 'respondMessage edit does not accept `photo` or `caption` (v1)' ); - } - return; - } -} - -function toRespondOutcome( raw: RespondResponseBody | null ): RespondOutcome { - // A missing body is treated as a bare success — preserves the historical - // behavior where the server occasionally returned 200 with an empty body. - if ( ! raw ) { - return { success: true, messageIds: [] }; - } - const out: RespondOutcome = { - success: raw.success !== false, - messageIds: Array.isArray( raw.message_ids ) - ? raw.message_ids.filter( ( id ): id is number => typeof id === 'number' ) - : [], - }; - if ( typeof raw.retry_after_ms === 'number' && raw.retry_after_ms > 0 ) { - out.retryAfterMs = raw.retry_after_ms; - } - if ( typeof raw.text_sent === 'boolean' ) { - out.textSent = raw.text_sent; - } - if ( typeof raw.photo_sent === 'boolean' ) { - out.photoSent = raw.photo_sent; - } - if ( typeof raw.chunks_sent === 'number' ) { - out.chunksSent = raw.chunks_sent; - } - if ( typeof raw.error === 'string' ) { - out.error = raw.error; - } - return out; -} - -interface BuildBodyParams { +export interface TelegramBodyParams { chatId: number; bot?: string; action: RespondAction; @@ -526,7 +208,7 @@ function clampCaption( caption: string | undefined ): string | undefined { return `${ caption.slice( 0, CAPTION_MAX_CHARS - 1 ) }…`; } -function buildRespondBody( params: BuildBodyParams ): { +export function buildTelegramRespondBody( params: TelegramBodyParams ): { body: string | FormData; /** Set for the JSON path; `undefined` for multipart so fetch fills the boundary in. */ contentType?: string; @@ -573,47 +255,3 @@ function buildRespondBody( params: BuildBodyParams ): { } return { body: JSON.stringify( json ), contentType: 'application/json' }; } - -async function readRespondOutcome( response: Response ): Promise< RespondResponseBody | null > { - const raw = await safeReadText( response ); - if ( ! raw.trim() ) { - return null; - } - try { - return JSON.parse( raw ) as RespondResponseBody; - } catch { - return null; - } -} - -async function safeReadText( response: Response ): Promise< string > { - try { - return await response.text(); - } catch { - return ''; - } -} - -async function backoff( attempt: number ): Promise< void > { - const baseMs = Math.min( 30_000, 500 * Math.pow( 2, attempt ) ); - const jitter = Math.random() * 200; - await new Promise( ( resolve ) => setTimeout( resolve, baseMs + jitter ) ); -} - -function composeSignals( a?: AbortSignal, b?: AbortSignal ): AbortSignal | undefined { - if ( ! a ) { - return b; - } - if ( ! b ) { - return a; - } - const controller = new AbortController(); - const onAbort = () => controller.abort(); - if ( a.aborted || b.aborted ) { - controller.abort(); - } else { - a.addEventListener( 'abort', onAbort, { once: true } ); - b.addEventListener( 'abort', onAbort, { once: true } ); - } - return controller.signal; -} diff --git a/apps/cli/remote-session/tests/config.test.ts b/apps/cli/remote-session/tests/config.test.ts index 016bb5d299..cb00e270af 100644 --- a/apps/cli/remote-session/tests/config.test.ts +++ b/apps/cli/remote-session/tests/config.test.ts @@ -21,6 +21,7 @@ describe( 'remote-session config', () => { STUDIO_REMOTE_TOKEN: undefined, STUDIO_REMOTE_BOT: undefined, STUDIO_REMOTE_CHAT_ID: undefined, + STUDIO_REMOTE_MACHINE_ID: undefined, } as NodeJS.ProcessEnv; } ); @@ -40,6 +41,26 @@ describe( 'remote-session config', () => { expect( config.chat_id ).toBeUndefined(); expect( config.base_url ).toMatch( /telegram-bot$/ ); expect( config.poll_interval_seconds ).toBe( 2 ); + // Defaults to a sanitized hostname so multiple installs can share a token. + expect( config.machine_id ).toMatch( /^[a-z0-9_]{1,32}$/ ); + } ); + + it( 'lets STUDIO_REMOTE_MACHINE_ID override the hostname-derived default', async () => { + fs.writeFileSync( + path.join( tmpDir, 'remote-session.json' ), + JSON.stringify( { token: 't' } ) + ); + process.env.STUDIO_REMOTE_MACHINE_ID = 'gergely_mbp'; + const config = await loadRemoteSessionConfig(); + expect( config.machine_id ).toBe( 'gergely_mbp' ); + } ); + + it( 'rejects an invalid machine_id (must match [a-z0-9_]{1,32})', async () => { + fs.writeFileSync( + path.join( tmpDir, 'remote-session.json' ), + JSON.stringify( { token: 't', machine_id: 'Has Spaces & Caps' } ) + ); + await expect( loadRemoteSessionConfig() ).rejects.toBeInstanceOf( RemoteSessionConfigError ); } ); it( 'reads optional bot + chat_id when provided in the file', async () => { @@ -115,6 +136,7 @@ describe( 'remote-session config', () => { token: 't', bot: 'b', chat_id: 1, + machine_id: 'test_host', poll_interval_seconds: 2, long_poll_timeout_seconds: 25, max_message_chars: 3800, diff --git a/apps/cli/remote-session/tests/media-streamer.test.ts b/apps/cli/remote-session/tests/media-streamer.test.ts index 3c5250520a..bb267c43e9 100644 --- a/apps/cli/remote-session/tests/media-streamer.test.ts +++ b/apps/cli/remote-session/tests/media-streamer.test.ts @@ -8,6 +8,7 @@ const config: RemoteSessionConfig = { token: 't', bot: 'b', chat_id: 42, + machine_id: 'test_host', poll_interval_seconds: 1, long_poll_timeout_seconds: 5, max_message_chars: 3800, diff --git a/apps/cli/remote-session/tests/poll-loop.test.ts b/apps/cli/remote-session/tests/poll-loop.test.ts index f55ebaf759..df7fade983 100644 --- a/apps/cli/remote-session/tests/poll-loop.test.ts +++ b/apps/cli/remote-session/tests/poll-loop.test.ts @@ -18,6 +18,7 @@ const baseConfig: RemoteSessionConfig = { token: 't', bot: 'b', chat_id: 42, + machine_id: 'test_host', poll_interval_seconds: 0.001, long_poll_timeout_seconds: 5, max_message_chars: 3800, @@ -454,11 +455,11 @@ describe( 'runPollLoop', () => { ); } ); - it( 'records an auth-error detach when polling returns a TelegramAuthError', async () => { + it( 'records an auth-error detach when polling returns a RemoteAuthError', async () => { const deps = makeDeps( {} ); - const { TelegramAuthError } = await import( 'cli/remote-session/telegram-client' ); + const { RemoteAuthError } = await import( 'cli/remote-session/remote-http' ); ( deps.poll as ReturnType< typeof vi.fn > ).mockRejectedValueOnce( - new TelegramAuthError( 401 ) + new RemoteAuthError( 401 ) ); const handle = await runPollLoop( { diff --git a/apps/cli/remote-session/tests/progress-streamer.test.ts b/apps/cli/remote-session/tests/progress-streamer.test.ts index e679cab5a5..8c2b8c54db 100644 --- a/apps/cli/remote-session/tests/progress-streamer.test.ts +++ b/apps/cli/remote-session/tests/progress-streamer.test.ts @@ -3,7 +3,7 @@ import { RemoteSessionLogger } from 'cli/remote-session/logger'; import { ProgressStreamer } from 'cli/remote-session/progress-streamer'; import type { JsonEvent } from '@studio/common/ai/json-events'; import type { RemoteSessionConfig } from 'cli/remote-session/config'; -import type { RespondOutcome } from 'cli/remote-session/telegram-client'; +import type { RespondOutcome } from 'cli/remote-session/respond-router'; // The streamer reads pi events via a defensive narrowing interface // (PiAgentMessageLike). Tests assemble minimal fixtures that match the runtime @@ -18,6 +18,7 @@ const baseConfig: RemoteSessionConfig = { token: 't', bot: 'b', chat_id: 1, + machine_id: 'test_host', poll_interval_seconds: 2, long_poll_timeout_seconds: 25, max_message_chars: 3800, diff --git a/apps/cli/remote-session/tests/respond-router.test.ts b/apps/cli/remote-session/tests/respond-router.test.ts new file mode 100644 index 0000000000..94e0811065 --- /dev/null +++ b/apps/cli/remote-session/tests/respond-router.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RemoteAuthError, RemoteBadRequestError } from 'cli/remote-session/remote-http'; +import { respondMessage } from 'cli/remote-session/respond-router'; +import type { RemoteSessionConfig } from 'cli/remote-session/config'; + +const baseConfig: RemoteSessionConfig = { + base_url: 'https://api.example.test/wpcom/v2/telegram-bot', + token: 'abc', + bot: 'my_bot', + chat_id: 1, + machine_id: 'test_host', + poll_interval_seconds: 1, + long_poll_timeout_seconds: 5, + max_message_chars: 3800, + turn_timeout_seconds: 60, +}; + +describe( 'respondMessage', () => { + const fetchMock = vi.fn(); + beforeEach( () => { + fetchMock.mockReset(); + vi.stubGlobal( 'fetch', fetchMock ); + } ); + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + describe( 'Telegram path', () => { + it( 'POSTs to /local-agent-respond with the configured bot fallback', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + const outcome = await respondMessage( baseConfig, { chatId: 99, text: 'hi' } ); + const [ url, init ] = fetchMock.mock.calls[ 0 ]; + expect( url ).toBe( 'https://api.example.test/wpcom/v2/telegram-bot/local-agent-respond' ); + expect( init.method ).toBe( 'POST' ); + const body = JSON.parse( init.body as string ); + // `action` is only emitted on non-default values so older servers that + // don't know about it still accept the create body unchanged. + expect( body ).toEqual( { chat_id: 99, text: 'hi', bot: 'my_bot' } ); + expect( init.headers.Authorization ).toBe( 'Bearer abc' ); + // Empty body returns a bare success outcome. + expect( outcome ).toEqual( { success: true, messageIds: [] } ); + } ); + + it( 'parses message_ids and the new outcome fields from the server JSON envelope', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify( { + success: true, + message_ids: [ 1001, 1002 ], + text_sent: true, + chunks_sent: 2, + } ), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + const outcome = await respondMessage( baseConfig, { chatId: 1, text: 'x' } ); + expect( outcome ).toEqual( { + success: true, + messageIds: [ 1001, 1002 ], + textSent: true, + chunksSent: 2, + } ); + } ); + + it( 'surfaces retry_after_ms in the outcome without throwing', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify( { + success: false, + message_ids: [], + retry_after_ms: 3000, + error: 'Too Many Requests: retry after 3', + } ), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + const outcome = await respondMessage( + baseConfig, + { chatId: 1, action: 'edit', messageId: 42, text: 'updated' }, + { maxRetries: 0 } + ); + expect( outcome.success ).toBe( false ); + expect( outcome.retryAfterMs ).toBe( 3000 ); + expect( outcome.error ).toMatch( /Too Many Requests/i ); + } ); + + it( 'sends action=edit with message_id in the JSON body', async () => { + fetchMock.mockResolvedValueOnce( + new Response( JSON.stringify( { success: true, message_ids: [ 42 ], text_sent: true } ), { + status: 200, + headers: { 'content-type': 'application/json' }, + } ) + ); + await respondMessage( baseConfig, { + chatId: 1, + action: 'edit', + messageId: 42, + text: 'new text', + } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + expect( init.headers[ 'Content-Type' ] ).toBe( 'application/json' ); + expect( JSON.parse( init.body as string ) ).toEqual( { + chat_id: 1, + bot: 'my_bot', + action: 'edit', + message_id: 42, + text: 'new text', + } ); + } ); + + it( 'rejects edit calls without messageId or text up front', async () => { + await expect( + respondMessage( baseConfig, { chatId: 1, action: 'edit', text: 'no id' } ) + ).rejects.toThrow( /messageId/ ); + await expect( + respondMessage( baseConfig, { chatId: 1, action: 'edit', messageId: 1 } ) + ).rejects.toThrow( /text/ ); + expect( fetchMock ).not.toHaveBeenCalled(); + } ); + + it( 'retries on 5xx then succeeds', async () => { + fetchMock + .mockResolvedValueOnce( new Response( '', { status: 503 } ) ) + .mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( baseConfig, { chatId: 1, text: 'x' }, { maxRetries: 1 } ); + expect( fetchMock ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'throws RemoteBadRequestError on 4xx without retry', async () => { + fetchMock.mockResolvedValueOnce( new Response( 'bad payload', { status: 400 } ) ); + await expect( respondMessage( baseConfig, { chatId: 1, text: 'x' } ) ).rejects.toBeInstanceOf( + RemoteBadRequestError + ); + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'throws RemoteAuthError on 403 without retry', async () => { + fetchMock.mockResolvedValueOnce( new Response( 'no', { status: 403 } ) ); + await expect( respondMessage( baseConfig, { chatId: 1, text: 'x' } ) ).rejects.toBeInstanceOf( + RemoteAuthError + ); + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'POSTs photo + caption as multipart/form-data with raw image bytes', async () => { + fetchMock.mockResolvedValueOnce( + new Response( JSON.stringify( { success: true, photo_sent: true } ), { + status: 200, + headers: { 'content-type': 'application/json' }, + } ) + ); + // Tiny 1x1 PNG — base64 of the standard "transparent pixel" header. + const photoBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='; + await respondMessage( baseConfig, { + chatId: 42, + photo: photoBase64, + caption: 'Hello world', + } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + expect( init.body ).toBeInstanceOf( FormData ); + // fetch sets Content-Type with the boundary; we must NOT set it ourselves. + expect( init.headers ).not.toHaveProperty( 'Content-Type' ); + expect( init.headers.Authorization ).toBe( 'Bearer abc' ); + const fd = init.body as FormData; + expect( fd.get( 'chat_id' ) ).toBe( '42' ); + expect( fd.get( 'bot' ) ).toBe( 'my_bot' ); + expect( fd.get( 'caption' ) ).toBe( 'Hello world' ); + expect( fd.get( 'text' ) ).toBeNull(); + const photo = fd.get( 'photo' ) as Blob; + expect( photo ).toBeInstanceOf( Blob ); + expect( photo.type ).toBe( 'image/png' ); + expect( photo.size ).toBe( Buffer.from( photoBase64, 'base64' ).length ); + } ); + + it( 'POSTs photo + text together via multipart with both fields', async () => { + fetchMock.mockResolvedValueOnce( + new Response( JSON.stringify( { success: true, photo_sent: true, text_sent: true } ), { + status: 200, + headers: { 'content-type': 'application/json' }, + } ) + ); + await respondMessage( baseConfig, { + chatId: 42, + photo: 'BASE64DATA', + text: 'Follow-up', + } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const fd = init.body as FormData; + expect( fd.get( 'text' ) ).toBe( 'Follow-up' ); + expect( fd.get( 'caption' ) ).toBeNull(); + const photo = fd.get( 'photo' ) as Blob; + expect( photo ).toBeInstanceOf( Blob ); + expect( photo.type ).toBe( 'image/png' ); + } ); + + it( 'uses the requested mime type for the photo file part', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( baseConfig, { + chatId: 1, + photo: 'BASE64DATA', + photoMimeType: 'image/jpeg', + } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const fd = init.body as FormData; + const photo = fd.get( 'photo' ) as Blob; + expect( photo.type ).toBe( 'image/jpeg' ); + } ); + + it( 'omits caption from the multipart body when it is undefined', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( baseConfig, { chatId: 1, photo: 'BASE64DATA' } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const fd = init.body as FormData; + expect( fd.get( 'caption' ) ).toBeNull(); + } ); + + it( 'logs a warning but does not throw when the server reports a partial failure', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify( { + success: false, + photo_sent: true, + text_sent: false, + error: 'Telegram returned 502 on text follow-up', + } ), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + // Should resolve, not throw. + await respondMessage( baseConfig, { chatId: 1, photo: 'BASE64DATA', text: 'follow' } ); + } ); + + it( 'truncates captions over 1024 chars before sending', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + const longCaption = 'x'.repeat( 1500 ); + await respondMessage( baseConfig, { + chatId: 1, + photo: 'BASE64DATA', + caption: longCaption, + } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const fd = init.body as FormData; + const sent = fd.get( 'caption' ) as string; + expect( sent.length ).toBe( 1024 ); + expect( sent.endsWith( '…' ) ).toBe( true ); + } ); + } ); + + describe( 'studio-mobile path', () => { + it( 'routes studio_mobile_* bots to /studio-mobile-client/respond with the envelope body', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( + { ...baseConfig, bot: undefined }, + { chatId: 7, bot: 'studio_mobile_rn', text: 'hi' } + ); + const [ url, init ] = fetchMock.mock.calls[ 0 ]; + expect( url ).toBe( 'https://api.example.test/wpcom/v2/studio-mobile-client/respond' ); + expect( init.headers[ 'Content-Type' ] ).toBe( 'application/json' ); + const body = JSON.parse( init.body as string ); + expect( body.chat_id ).toBe( 7 ); + expect( body.bot ).toBe( 'studio_mobile_rn' ); + // `machine_id` comes from config (hostname-derived by default). + expect( body.machine_id ).toBe( 'test_host' ); + expect( body.envelope.type ).toBe( 'agent_message' ); + expect( body.envelope.text ).toBe( 'hi' ); + expect( typeof body.envelope.id ).toBe( 'string' ); + expect( body.envelope.id.length ).toBeGreaterThan( 0 ); + // Top-level `text` is gone — wpcom reads it from `envelope.text`. + expect( body ).not.toHaveProperty( 'text' ); + } ); + + it( 'uses the per-host machine_id from config so multiple machines can share the same wpcom token', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( + { ...baseConfig, bot: undefined, machine_id: 'gergely_mbp' }, + { chatId: 7, bot: 'studio_mobile_rn', text: 'hi' } + ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const body = JSON.parse( init.body as string ); + expect( body.machine_id ).toBe( 'gergely_mbp' ); + } ); + + it( 'demotes a photo to a text envelope for studio_mobile bots, preserving the caption', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( + { ...baseConfig, bot: undefined }, + { + chatId: 7, + bot: 'studio_mobile_rn', + photo: 'BASE64DATA', + caption: 'Screenshot of the homepage', + } + ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + expect( init.body ).toBeTypeOf( 'string' ); + const body = JSON.parse( init.body as string ); + expect( body.envelope.text ).toBe( 'Screenshot of the homepage' ); + } ); + + it( 'sends a placeholder envelope when a studio_mobile bot has only a photo (no text or caption)', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + await respondMessage( + { ...baseConfig, bot: undefined }, + { chatId: 7, bot: 'studio_mobile_rn', photo: 'BASE64DATA' } + ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + const body = JSON.parse( init.body as string ); + expect( body.envelope.text ).toMatch( /image omitted/i ); + } ); + + it( 'degrades action=edit to a fresh create envelope (mobile has no edit primitive)', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + const outcome = await respondMessage( + { ...baseConfig, bot: undefined }, + { + chatId: 7, + bot: 'studio_mobile_rn', + action: 'edit', + messageId: 42, + text: 'updated text', + } + ); + const [ url, init ] = fetchMock.mock.calls[ 0 ]; + expect( url ).toBe( 'https://api.example.test/wpcom/v2/studio-mobile-client/respond' ); + const body = JSON.parse( init.body as string ); + // Body has no `action` / `message_id` keys; mobile gets a fresh envelope. + expect( body ).not.toHaveProperty( 'action' ); + expect( body ).not.toHaveProperty( 'message_id' ); + expect( body.envelope.text ).toBe( 'updated text' ); + // Empty messageIds — caller (progress streamer) interprets this as + // "no message id captured; keep creating". + expect( outcome.messageIds ).toEqual( [] ); + } ); + + it( 'throws when base_url does not end with /telegram-bot for a mobile bot', async () => { + await expect( + respondMessage( + { + ...baseConfig, + bot: undefined, + base_url: 'https://api.example.test/wpcom/v2/something-else', + }, + { chatId: 7, bot: 'studio_mobile_rn', text: 'hi' } + ) + ).rejects.toThrow( /studio-mobile URL/ ); + expect( fetchMock ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'input validation', () => { + it( 'rejects calls with neither text nor photo', async () => { + await expect( respondMessage( baseConfig, { chatId: 1 } ) ).rejects.toThrow( /text.*photo/i ); + expect( fetchMock ).not.toHaveBeenCalled(); + } ); + + it( 'treats empty-string text or photo as absent', async () => { + fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); + + // Empty photo + real text → goes through the JSON text path, no photo dropped. + await respondMessage( baseConfig, { chatId: 1, text: 'hi', photo: '' } ); + const [ , init ] = fetchMock.mock.calls[ 0 ]; + expect( init.headers[ 'Content-Type' ] ).toBe( 'application/json' ); + expect( init.body ).toBeTypeOf( 'string' ); + expect( JSON.parse( init.body as string ) ).not.toHaveProperty( 'photo' ); + + // Both empty → throws, no fetch. + await expect( + respondMessage( baseConfig, { chatId: 1, text: '', photo: '' } ) + ).rejects.toThrow( /text.*photo/i ); + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/apps/cli/remote-session/tests/telegram-client.test.ts b/apps/cli/remote-session/tests/telegram-client.test.ts index acaa07ceb8..104fe8b8d7 100644 --- a/apps/cli/remote-session/tests/telegram-client.test.ts +++ b/apps/cli/remote-session/tests/telegram-client.test.ts @@ -1,11 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - TelegramAuthError, - TelegramBadRequestError, - TelegramTransientError, - pollMessages, - respondMessage, -} from 'cli/remote-session/telegram-client'; +import { RemoteAuthError, RemoteTransientError } from 'cli/remote-session/remote-http'; +import { pollMessages } from 'cli/remote-session/telegram-client'; import type { RemoteSessionConfig } from 'cli/remote-session/config'; const baseConfig: RemoteSessionConfig = { @@ -13,6 +8,7 @@ const baseConfig: RemoteSessionConfig = { token: 'abc', bot: 'my_bot', chat_id: 1, + machine_id: 'test_host', poll_interval_seconds: 1, long_poll_timeout_seconds: 5, max_message_chars: 3800, @@ -96,266 +92,13 @@ describe( 'pollMessages', () => { ] ); } ); - it( 'throws TelegramAuthError on 401', async () => { + it( 'throws RemoteAuthError on 401', async () => { fetchMock.mockResolvedValueOnce( new Response( 'no', { status: 401 } ) ); - await expect( pollMessages( baseConfig ) ).rejects.toBeInstanceOf( TelegramAuthError ); + await expect( pollMessages( baseConfig ) ).rejects.toBeInstanceOf( RemoteAuthError ); } ); - it( 'throws TelegramTransientError on 502', async () => { + it( 'throws RemoteTransientError on 502', async () => { fetchMock.mockResolvedValueOnce( new Response( 'oh', { status: 502 } ) ); - await expect( pollMessages( baseConfig ) ).rejects.toBeInstanceOf( TelegramTransientError ); - } ); -} ); - -describe( 'respondMessage', () => { - const fetchMock = vi.fn(); - beforeEach( () => { - fetchMock.mockReset(); - vi.stubGlobal( 'fetch', fetchMock ); - } ); - afterEach( () => { - vi.unstubAllGlobals(); - } ); - - it( 'POSTs to /local-agent-respond with the configured bot fallback', async () => { - fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - const outcome = await respondMessage( baseConfig, { chatId: 99, text: 'hi' } ); - const [ url, init ] = fetchMock.mock.calls[ 0 ]; - expect( url ).toBe( 'https://api.example.test/wpcom/v2/telegram-bot/local-agent-respond' ); - expect( init.method ).toBe( 'POST' ); - const body = JSON.parse( init.body as string ); - // `action` is only emitted on non-default values so older servers that - // don't know about it still accept the create body unchanged. - expect( body ).toEqual( { chat_id: 99, text: 'hi', bot: 'my_bot' } ); - expect( init.headers.Authorization ).toBe( 'Bearer abc' ); - // Empty body returns a bare success outcome. - expect( outcome ).toEqual( { success: true, messageIds: [] } ); - } ); - - it( 'parses message_ids and the new outcome fields from the server JSON envelope', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify( { - success: true, - message_ids: [ 1001, 1002 ], - text_sent: true, - chunks_sent: 2, - } ), - { status: 200, headers: { 'content-type': 'application/json' } } - ) - ); - const outcome = await respondMessage( baseConfig, { chatId: 1, text: 'x' } ); - expect( outcome ).toEqual( { - success: true, - messageIds: [ 1001, 1002 ], - textSent: true, - chunksSent: 2, - } ); - } ); - - it( 'surfaces retry_after_ms in the outcome without throwing', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify( { - success: false, - message_ids: [], - retry_after_ms: 3000, - error: 'Too Many Requests: retry after 3', - } ), - { status: 200, headers: { 'content-type': 'application/json' } } - ) - ); - const outcome = await respondMessage( - baseConfig, - { chatId: 1, action: 'edit', messageId: 42, text: 'updated' }, - { maxRetries: 0 } - ); - expect( outcome.success ).toBe( false ); - expect( outcome.retryAfterMs ).toBe( 3000 ); - expect( outcome.error ).toMatch( /Too Many Requests/i ); - } ); - - it( 'sends action=edit with message_id in the JSON body', async () => { - fetchMock.mockResolvedValueOnce( - new Response( JSON.stringify( { success: true, message_ids: [ 42 ], text_sent: true } ), { - status: 200, - headers: { 'content-type': 'application/json' }, - } ) - ); - await respondMessage( baseConfig, { - chatId: 1, - action: 'edit', - messageId: 42, - text: 'new text', - } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - expect( init.headers[ 'Content-Type' ] ).toBe( 'application/json' ); - expect( JSON.parse( init.body as string ) ).toEqual( { - chat_id: 1, - bot: 'my_bot', - action: 'edit', - message_id: 42, - text: 'new text', - } ); - } ); - - it( 'rejects edit calls without messageId or text up front', async () => { - await expect( - respondMessage( baseConfig, { chatId: 1, action: 'edit', text: 'no id' } ) - ).rejects.toThrow( /messageId/ ); - await expect( - respondMessage( baseConfig, { chatId: 1, action: 'edit', messageId: 1 } ) - ).rejects.toThrow( /text/ ); - expect( fetchMock ).not.toHaveBeenCalled(); - } ); - - it( 'retries on 5xx then succeeds', async () => { - fetchMock - .mockResolvedValueOnce( new Response( '', { status: 503 } ) ) - .mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - await respondMessage( baseConfig, { chatId: 1, text: 'x' }, { maxRetries: 1 } ); - expect( fetchMock ).toHaveBeenCalledTimes( 2 ); - } ); - - it( 'throws TelegramBadRequestError on 4xx without retry', async () => { - fetchMock.mockResolvedValueOnce( new Response( 'bad payload', { status: 400 } ) ); - await expect( respondMessage( baseConfig, { chatId: 1, text: 'x' } ) ).rejects.toBeInstanceOf( - TelegramBadRequestError - ); - expect( fetchMock ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'throws TelegramAuthError on 403 without retry', async () => { - fetchMock.mockResolvedValueOnce( new Response( 'no', { status: 403 } ) ); - await expect( respondMessage( baseConfig, { chatId: 1, text: 'x' } ) ).rejects.toBeInstanceOf( - TelegramAuthError - ); - expect( fetchMock ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'POSTs photo + caption as multipart/form-data with raw image bytes', async () => { - fetchMock.mockResolvedValueOnce( - new Response( JSON.stringify( { success: true, photo_sent: true } ), { - status: 200, - headers: { 'content-type': 'application/json' }, - } ) - ); - // Tiny 1x1 PNG — base64 of the standard "transparent pixel" header. - const photoBase64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII='; - await respondMessage( baseConfig, { - chatId: 42, - photo: photoBase64, - caption: 'Hello world', - } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - expect( init.body ).toBeInstanceOf( FormData ); - // fetch sets Content-Type with the boundary; we must NOT set it ourselves. - expect( init.headers ).not.toHaveProperty( 'Content-Type' ); - expect( init.headers.Authorization ).toBe( 'Bearer abc' ); - const fd = init.body as FormData; - expect( fd.get( 'chat_id' ) ).toBe( '42' ); - expect( fd.get( 'bot' ) ).toBe( 'my_bot' ); - expect( fd.get( 'caption' ) ).toBe( 'Hello world' ); - expect( fd.get( 'text' ) ).toBeNull(); - const photo = fd.get( 'photo' ) as Blob; - expect( photo ).toBeInstanceOf( Blob ); - expect( photo.type ).toBe( 'image/png' ); - expect( photo.size ).toBe( Buffer.from( photoBase64, 'base64' ).length ); - } ); - - it( 'POSTs photo + text together via multipart with both fields', async () => { - fetchMock.mockResolvedValueOnce( - new Response( JSON.stringify( { success: true, photo_sent: true, text_sent: true } ), { - status: 200, - headers: { 'content-type': 'application/json' }, - } ) - ); - await respondMessage( baseConfig, { - chatId: 42, - photo: 'BASE64DATA', - text: 'Follow-up', - } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - const fd = init.body as FormData; - expect( fd.get( 'text' ) ).toBe( 'Follow-up' ); - expect( fd.get( 'caption' ) ).toBeNull(); - const photo = fd.get( 'photo' ) as Blob; - expect( photo ).toBeInstanceOf( Blob ); - expect( photo.type ).toBe( 'image/png' ); - } ); - - it( 'uses the requested mime type for the photo file part', async () => { - fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - await respondMessage( baseConfig, { - chatId: 1, - photo: 'BASE64DATA', - photoMimeType: 'image/jpeg', - } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - const fd = init.body as FormData; - const photo = fd.get( 'photo' ) as Blob; - expect( photo.type ).toBe( 'image/jpeg' ); - } ); - - it( 'omits caption from the multipart body when it is undefined', async () => { - fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - await respondMessage( baseConfig, { chatId: 1, photo: 'BASE64DATA' } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - const fd = init.body as FormData; - expect( fd.get( 'caption' ) ).toBeNull(); - } ); - - it( 'logs a warning but does not throw when the server reports a partial failure', async () => { - fetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify( { - success: false, - photo_sent: true, - text_sent: false, - error: 'Telegram returned 502 on text follow-up', - } ), - { status: 200, headers: { 'content-type': 'application/json' } } - ) - ); - // Should resolve, not throw. - await respondMessage( baseConfig, { chatId: 1, photo: 'BASE64DATA', text: 'follow' } ); - } ); - - it( 'rejects calls with neither text nor photo', async () => { - await expect( respondMessage( baseConfig, { chatId: 1 } ) ).rejects.toThrow( /text.*photo/i ); - expect( fetchMock ).not.toHaveBeenCalled(); - } ); - - it( 'treats empty-string text or photo as absent', async () => { - fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - - // Empty photo + real text → goes through the JSON text path, no photo dropped. - await respondMessage( baseConfig, { chatId: 1, text: 'hi', photo: '' } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - expect( init.headers[ 'Content-Type' ] ).toBe( 'application/json' ); - expect( init.body ).toBeTypeOf( 'string' ); - expect( JSON.parse( init.body as string ) ).not.toHaveProperty( 'photo' ); - - // Both empty → throws, no fetch. - await expect( - respondMessage( baseConfig, { chatId: 1, text: '', photo: '' } ) - ).rejects.toThrow( /text.*photo/i ); - expect( fetchMock ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'truncates captions over 1024 chars before sending', async () => { - fetchMock.mockResolvedValueOnce( new Response( '', { status: 200 } ) ); - const longCaption = 'x'.repeat( 1500 ); - await respondMessage( baseConfig, { - chatId: 1, - photo: 'BASE64DATA', - caption: longCaption, - } ); - const [ , init ] = fetchMock.mock.calls[ 0 ]; - const fd = init.body as FormData; - const sent = fd.get( 'caption' ) as string; - expect( sent.length ).toBe( 1024 ); - expect( sent.endsWith( '…' ) ).toBe( true ); + await expect( pollMessages( baseConfig ) ).rejects.toBeInstanceOf( RemoteTransientError ); } ); } );