diff --git a/README.md b/README.md index b749c74..20cb6be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # RespondKit -RespondKit is a small, multilingual support stack for indie products. Customers use a React or native chat widget, and each support thread appears in a Discord forum post in its original language. Optional Gemini translation is available through `/reply translate:…`; `/approve` hands an agent's draft back to the agent, which translates and sends it. Plain `/reply` sends as written. +RespondKit is a small, multilingual support stack for indie products. Customers use a React or native chat widget, and each support thread appears in a Discord forum post in its original language. Optional Gemini translation is available through **Translate to English** on a customer message and `/reply translate:…`; `/approve` hands an agent's draft back to the agent, which translates and sends it. Plain `/reply` sends as written. The runtime is one Cloudflare Worker bundle with D1 and Cloudflare Workflows. It deliberately has no control-plane dashboard, Better Auth, Queue, Cron trigger, Durable Object, WebSocket, or Discord Gateway. @@ -64,7 +64,7 @@ pnpm config:apply --local pnpm discord:commands:apply --dry-run ``` -`config:apply` validates the complete workspace → product → inbox topology, applies the checked-in D1 migration, and idempotently seeds the local database. The Discord dry run prints the guild-scoped command registration requests (`/agent`, `/approve`, **Approve draft**, `/reply`, `/status`, `/retry`, `/activity`) without contacting Discord. See the [optional translation setup and test guide](docs/architecture/optional-translation.md) for inbox enablement, permissions, and rollout. +`config:apply` validates the complete workspace → product → inbox topology, applies the checked-in D1 migration, and idempotently seeds the local database. The Discord dry run prints the guild-scoped command registration requests (`/agent`, `/approve`, **Approve draft**, `/reply`, `/status`, `/retry`, **Translate to English**, `/activity`) without contacting Discord. See the [optional translation setup and test guide](docs/architecture/optional-translation.md) for inbox enablement, permissions, and rollout. Inbox origin allowlists accept exact production origins and loopback any-port patterns such as `http://localhost:*` or `http://127.0.0.1:*`. Wildcards are rejected for non-loopback hosts so a development convenience cannot expose an inbox to arbitrary websites. @@ -112,4 +112,4 @@ The implementation and automated tests are key-free. Before a real-world pilot, - a Discord application/bot (`DISCORD_BOT_TOKEN`, application ID, and Ed25519 public key) - a random customer-session signing secret (`SESSION_SIGNING_KEY`) -Then run one Thai and one Burmese customer message end to end, verify original-language Discord delivery, and invoke `/reply translate:th` then `/reply translate:customer` to validate localized customer delivery. Also verify plain `/reply` works with translation disabled. Follow the [translation test checklist](docs/architecture/optional-translation.md#test-checklist). +Then run one Thai and one Burmese customer message end to end, verify original-language Discord delivery, use **Translate to English**, and invoke `/reply translate:th` then `/reply translate:customer` to validate localized customer delivery. Also verify plain `/reply` works with translation disabled. Follow the [translation test checklist](docs/architecture/optional-translation.md#test-checklist). diff --git a/apps/api/src/discord-translation.ts b/apps/api/src/discord-translation.ts index 4354da3..7fb7d77 100644 --- a/apps/api/src/discord-translation.ts +++ b/apps/api/src/discord-translation.ts @@ -1,10 +1,18 @@ import { resolveAuthorizedDiscordThread, type ParsedDiscordCommandInteraction, + type ParsedDiscordTranslateInteraction, } from "@respondkit/discord"; import type { TranslationResult } from "@respondkit/translation"; import type { Env } from "./env"; import { createDatabase } from "./db"; +import { + getTranslationJob, + requestMessageTranslation, + startTranslationWorkflow, + translationResult, + type TranslationScope, +} from "./translation-service"; export interface PrivateReply { content: string; @@ -48,6 +56,27 @@ export async function updatePrivateInteraction( } } +export async function resolveTranslationMessage( + env: Env, + scope: TranslationScope, + interaction: ParsedDiscordTranslateInteraction, +) { + const row = await env.DB.prepare( + "SELECT m.id FROM message m JOIN discord_message dm ON dm.message_id = m.id AND dm.workspace_id = m.workspace_id AND dm.inbox_id = m.inbox_id AND dm.thread_id = m.thread_id WHERE m.workspace_id = ? AND m.inbox_id = ? AND m.thread_id = ? AND m.direction = 'customer_to_operator' AND dm.discord_message_id = ? AND dm.discord_thread_id = ? AND dm.projection_kind IN ('customer_projection', 'failure_audit')", + ) + .bind( + scope.workspaceId, + scope.inboxId, + scope.threadId, + interaction.targetMessageId, + interaction.discordThreadId, + ) + .first<{ id: string }>(); + if (!row) + throw new Error("Select an original customer message posted by RespondKit in this thread."); + return row.id; +} + export async function handleTranslationInteraction( env: Env, interaction: ParsedDiscordCommandInteraction, @@ -102,6 +131,58 @@ export async function handleTranslationInteraction( }); return; } + if (interaction.command !== "translate") return; + const messageId = await resolveTranslationMessage(env, scope, interaction); + const job = await requestMessageTranslation(env, scope, messageId, interaction.targetLanguage); + await startTranslationWorkflow(env, job, "publish"); + // Bounded notification only; the durable job and its publication outlive this request. + for (let attempt = 0; attempt < 10; attempt++) { + const current = await getTranslationJob(env, scope, job.id); + if (current?.status === "failed") { + await updatePrivateInteraction(env, interaction, { + content: `Translation unavailable (${current.error_code ?? "unknown"}${current.provider_status === null ? "" : `, HTTP ${current.provider_status}`}). The original message is available. Run Translate to English again to retry.`, + }); + return; + } + if (current?.status === "succeeded") { + const result = translationResult(current); + if ( + result && + new Intl.Locale(result.sourceLanguage).language === + new Intl.Locale(result.targetLanguage).language + ) { + await updatePrivateInteraction(env, interaction, { + content: `This message is already in ${result.targetLanguage}. No duplicate was posted.`, + }); + return; + } + const post = await env.DB.prepare( + "SELECT discord_message_id FROM translation_post WHERE job_id = ? AND chunk_index = 0", + ) + .bind(job.id) + .first<{ discord_message_id: string }>(); + const publication = await env.TRANSLATION_WORKFLOW.get(`tp_${job.id}_${job.generation}`); + const publicationState = await publication.status(); + if (publicationState.status === "errored" || publicationState.status === "terminated") { + await updatePrivateInteraction(env, interaction, { + content: + "Translation is saved, but posting it to Discord failed. Run Translate to English again to retry posting.", + }); + return; + } + if (post && publicationState.status === "complete") { + await updatePrivateInteraction(env, interaction, { + content: `Translation: https://discord.com/channels/${interaction.guildId}/${interaction.discordThreadId}/${post.discord_message_id}`, + }); + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + await updatePrivateInteraction(env, interaction, { + content: + "Translation is still being prepared or posted. The result will appear as a reply to the original message. Run Translate to English again to check or retry publication.", + }); } catch (error) { const message = error instanceof Error diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index 53e8ff8..23ef5d0 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -1009,12 +1009,13 @@ export function createHttpApp() { context.executionCtx.waitUntil(handleActivityInteraction(context.env, interaction)); return context.json(createDeferredEphemeralResponse()); } - if (interaction.command === "confirm_translation") { + if (interaction.command === "translate" || interaction.command === "confirm_translation") { context.executionCtx.waitUntil(handleTranslationInteraction(context.env, interaction)); return context.json(createDeferredEphemeralResponse()); } const command = normalizeDiscordCommand(interaction); if ( + command.command === "translate" || command.command === "confirm_translation" || command.command === "activity" || command.command === "agent_decision" || diff --git a/apps/api/src/translation.test.ts b/apps/api/src/translation.test.ts index 1027d69..a95b162 100644 --- a/apps/api/src/translation.test.ts +++ b/apps/api/src/translation.test.ts @@ -16,7 +16,11 @@ import { requestMessageTranslation, startTranslationWorkflow, } from "./translation-service"; -import { handleTranslationInteraction, replyReview } from "./discord-translation"; +import { + handleTranslationInteraction, + replyReview, + resolveTranslationMessage, +} from "./discord-translation"; import { TEST_TOPOLOGY, createCustomerFixture, @@ -201,7 +205,110 @@ describe("optional message translation", () => { } }); - it("rejects other-thread targets and disabled inboxes", async () => { + it("Translate to English posts the translation as a reply to the selected message and links it privately", async () => { + const { identity } = await fixture(); + const workflows = await introspectWorkflow(env.TRANSLATION_WORKFLOW); + const posted: Record[] = []; + const privateReplies: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (request, init) => { + if (init?.method === "GET") return Response.json([]); + const body = JSON.parse(String(init?.body)) as Record; + if (init?.method === "PATCH" && String(request).includes("/webhooks/")) { + privateReplies.push(String(body.content)); + return Response.json({}); + } + posted.push(body); + return Response.json({ + id: "100000000000000082", + channel_id: TEST_TOPOLOGY.discordThreadId, + content: body.content, + nonce: body.nonce, + }); + }), + ); + try { + await workflows.modifyAll(async (modifier) => { + await modifier.disableRetryDelays(); + await modifier.mockStepResult({ name: "translate-message" }, result); + }); + await handleTranslationInteraction(createTestEnv(), { + ...interaction(), + command: "translate", + targetLanguage: "en", + targetMessageId: "100000000000000081", + }); + expect(posted).toHaveLength(1); + expect(posted[0]).toMatchObject({ + message_reference: { message_id: "100000000000000081", fail_if_not_exists: false }, + }); + expect(String(posted[0]?.content)).toContain(result.translatedText); + expect(privateReplies).toEqual([ + `Translation: https://discord.com/channels/${TEST_TOPOLOGY.guildId}/${TEST_TOPOLOGY.discordThreadId}/100000000000000082`, + ]); + const job = await env.DB.prepare( + "SELECT status, target_language FROM translation_job WHERE message_id = ?", + ) + .bind(identity.messageId) + .first(); + expect(job).toEqual({ status: "succeeded", target_language: "en" }); + } finally { + vi.unstubAllGlobals(); + await workflows.dispose(); + } + }); + + it("keeps a language the operator named when Translate to English runs on a newer customer message", async () => { + const { scope, identity } = await fixture(); + const workflows = await introspectWorkflow(env.TRANSLATION_WORKFLOW); + vi.stubGlobal( + "fetch", + vi.fn(async (_request, init) => { + if (init?.method === "GET" || init?.method === "PATCH") return Response.json([]); + const body = JSON.parse(String(init?.body)) as Record; + return Response.json({ + id: "100000000000000082", + channel_id: TEST_TOPOLOGY.discordThreadId, + content: body.content, + nonce: body.nonce, + }); + }), + ); + try { + await workflows.modifyAll(async (modifier) => { + await modifier.disableRetryDelays(); + await modifier.mockStepResult({ name: "translate-message" }, result); + }); + // The write `/reply translate:es` makes, before the Hindi message arrived. + await env.DB.prepare( + "UPDATE thread SET customer_language='es', customer_language_updated_at=(SELECT accepted_at - 60000 FROM message WHERE id=?) WHERE id=?", + ) + .bind(identity.messageId, scope.threadId) + .run(); + await handleTranslationInteraction(createTestEnv(), { + ...interaction(), + command: "translate", + targetLanguage: "en", + targetMessageId: "100000000000000081", + }); + expect( + await env.DB.prepare("SELECT status FROM translation_job WHERE message_id = ?") + .bind(identity.messageId) + .first("status"), + ).toBe("succeeded"); + expect( + await env.DB.prepare("SELECT customer_language FROM thread WHERE id = ?") + .bind(scope.threadId) + .first("customer_language"), + ).toBe("es"); + } finally { + vi.unstubAllGlobals(); + await workflows.dispose(); + } + }); + + it("rejects other-thread targets and disabled inboxes, and resolves exact Discord messages", async () => { const { scope, identity } = await fixture(); const apiEnv = createTestEnv(); await expect( @@ -220,6 +327,24 @@ describe("optional message translation", () => { "en", ), ).rejects.toThrow("not enabled"); + const translate = (targetMessageId: string) => ({ + ...interaction(), + command: "translate" as const, + targetLanguage: "en" as const, + targetMessageId, + }); + expect(await resolveTranslationMessage(apiEnv, scope, translate("100000000000000081"))).toBe( + identity.messageId, + ); + await expect( + resolveTranslationMessage(apiEnv, scope, translate("999999999999999999")), + ).rejects.toThrow("original customer message"); + await expect( + resolveTranslationMessage(apiEnv, scope, { + ...translate("100000000000000081"), + discordThreadId: "100000000000000099", + }), + ).rejects.toThrow("original customer message"); }); it("records a provider rejection without leaking its response body or failing delivery", async () => { diff --git a/apps/api/src/workflows/translation.ts b/apps/api/src/workflows/translation.ts index 9a4f8c0..b7f4fe6 100644 --- a/apps/api/src/workflows/translation.ts +++ b/apps/api/src/workflows/translation.ts @@ -123,7 +123,7 @@ export class TranslationWorkflow extends WorkflowEntrypoint`, which is the same as **Approve draft**. To send it in another language, for example when it was translated into the wrong language or not at all because the customer's language is unknown, add `translate:`, such as `translate:Burmese` or `translate:my`. RespondKit then sends the same English text in that language as a new reply in one step: the old draft can no longer be approved, and the language is recorded on the thread so later `customer` translations follow it. This translation is not previewed first, so if it needs review the **Approve translation** button still appears before anything reaches the customer. Repeating the command never sends twice. Browser locale is not treated as proof of the customer's language. A requested translation must succeed before the reply becomes available to the customer. If it needs review, RespondKit shows a private preview and a **Send this translation** button; nothing is sent until you confirm. `/status reference:` restores a pending preview. ## Translate an incoming message -Right-click or long-press a customer message and choose **Apps → Translate to English**. Alternatively: - -```text -/translate -/translate message: to:en -``` - -Without a link, `/translate` selects the latest customer message in the current thread. A translation appears as a reply to its original message. Repeated requests reuse the saved result. Translation failures are private and do not mark the original message as failed. +Right-click or long-press a customer message and choose **Apps → Translate to English**. A translation appears as a reply to its original message. Repeated requests reuse the saved result. Translation failures are private and do not mark the original message as failed. Translation must be enabled for the inbox using `TRANSLATION_ENABLED_INBOXES`, with a working Gemini credential. The new commands also need access enabled for your operator role under **Server Settings → Integrations → RespondKit**. No additional Discord OAuth scope or Message Content intent is needed. diff --git a/docs/architecture/optional-translation.md b/docs/architecture/optional-translation.md index 5fe9488..48275ca 100644 --- a/docs/architecture/optional-translation.md +++ b/docs/architecture/optional-translation.md @@ -11,10 +11,11 @@ This supersedes the mandatory-translation flows in `base-v1.md`. Incoming messag | `/reply message: translate:customer` | Translate into the thread's customer language. | | `/approve id: translate:` | Hand an agent draft back to the agent, which translates and sends it (see below). | | Message menu → Apps → **Approve draft** | The same as `/approve` on that draft with the default language, `customer`. | +| Message menu → Apps → **Translate to English** | Translate that exact customer message and post the English as a reply to it. | -`translate:off` is equivalent to omitting the option. `customer` never falls back to browser locale. It uses the language last named in `/reply` or `/approve` for this thread, or else a reliably detected one; if neither exists, name the language. The original reply text, requested mode, and resolved language are persisted before workflow acceptance. `/retry` cannot choose a different language. +`translate:off` is equivalent to omitting the option. `customer` never falls back to browser locale. It uses the language last named in `/reply` or `/approve` for this thread, or else a reliably detected one; if neither exists, name the language. Detection, including **Translate to English**, only fills a thread whose language is unknown: it never replaces a language an operator named, or an earlier detected one. The original reply text, requested mode, and resolved language are persisted before workflow acceptance. `/retry` cannot choose a different language. -There is no command to translate an incoming message any more (`/translate` and **Translate to English** were removed): the support agent reads the customer's language, and the operator replies with `/reply`, or approves the agent's draft with `/approve`. +**Translate to English** works on any original customer message RespondKit posted in the current support thread; selecting any chunk maps to the whole message. Repeated requests reuse the saved translation and do not post it twice. The private answer links the posted translation, or says why there is none. There is no `/translate` slash command. ## Approving an agent draft @@ -36,14 +37,14 @@ Incoming translation errors are private to the operator. Jobs retain a bounded e Explicit outgoing translation fails closed. If a result needs review, the customer receives nothing until an authorized operator clicks **Send this translation** in the private preview. Large previews include the full text as a private attachment. A pending review expires after one day. `/status reference:` restores the preview after the first interaction expires. Approval is bound to the message's processing generation, so old buttons cannot approve a retry's new translation. Duplicate approvals do not send duplicate replies. -Translation commands immediately defer privately. The existing HTTP interaction endpoint validates signatures and all actions recheck application, guild, forum, thread and operator permissions. Background notification is bounded to ten seconds; durable workflows continue independently. If a result takes longer, `/status` checks an outgoing reply. Interaction credentials are kept only in request memory. No Gateway connection or privileged Message Content intent is needed. +Translation commands immediately defer privately. The existing HTTP interaction endpoint validates signatures and all actions recheck application, guild, forum, thread and operator permissions. Background notification is bounded to ten seconds; durable workflows continue independently. If a result takes longer, running **Translate to English** again checks or retries incoming work and `/status` checks an outgoing reply. Interaction credentials are kept only in request memory. No Gateway connection or privileged Message Content intent is needed. ## Implementation - `packages/translation`: provider adapter, language detection, bounded context, structured results and protected-text handling. - `apps/api/src/translation-service.ts`: inbox capability checks, scope-bound jobs, cached results and the agent adapter. - `TranslationWorkflow`: generation and publication modes, with independent workflow IDs. Cached results survive workflow retention. Publication reuses recorded per-chunk IDs and the existing Discord nonce reconciliation. -- `translation_job`, `translation_post`: enrichment state and Discord projections, used by the agent adapter below. `translation_selection` holds the selections of the removed `/translate` command. +- `translation_job`, `translation_post`: enrichment state and Discord projections, used by **Translate to English** and the agent adapter below. `translation_selection` holds the selections of the removed `/translate` command. - `reply_review`: generation-bound outgoing approval, without exposing drafts to customer transcript polling. - `message.reply_translation` and `reply_translation_request`: frozen resolved language/mode and original command option. Null retains legacy automatic-translation semantics for already accepted outgoing envelopes. @@ -55,7 +56,7 @@ An agent host can register `createTranslationTool(env, authorizedScope)` from `a 1. Apply the additive D1 migration before deploying the Worker: `pnpm db:migrate:staging`. Existing message text and translation records are preserved. In-flight outgoing envelopes without the new fields retain their previous behavior; incoming workflow replays now project the original text. 2. In `apps/api/wrangler.jsonc`, set `env.staging.vars.TRANSLATION_ENABLED_INBOXES` to a JSON string such as `"[\"inbox_captioner_public\"]"`. Staging enables the RespondKit test and Captioner inboxes; local development defaults to `"[]"` (disabled). Keep the existing `GEMINI_API_KEY` secret and a model available to that key. Local development uses `.dev.vars`. No new secret is required. -3. Deploy with `pnpm deploy:staging`; Wrangler provisions the new `respondkit-translation-staging` workflow binding. Then run `pnpm discord:commands:apply --dry-run`, followed by `pnpm discord:commands:apply` with the existing Discord credentials and topology. Registration bulk-overwrites this app's guild commands: `/agent`, `/approve`, **Approve draft**, `/reply`, `/status`, `/retry` and `/activity`. Commands no longer in the list (`/translate`, **Translate to English**, **Reject draft**) disappear. +3. Deploy with `pnpm deploy:staging`; Wrangler provisions the new `respondkit-translation-staging` workflow binding. Then run `pnpm discord:commands:apply --dry-run`, followed by `pnpm discord:commands:apply` with the existing Discord credentials and topology. Registration bulk-overwrites this app's guild commands: `/agent`, `/approve`, **Approve draft**, `/reply`, `/status`, `/retry`, **Translate to English** and `/activity`. Commands no longer in the list (`/translate`, **Reject draft**) disappear. 4. In Discord **Server Settings → Integrations → RespondKit**, enable the new commands for the operator role/users. Commands default to administrator-only visibility until configured; the existing server-side operator allowlist still applies. Keep View Channel, Read Message History, Send Messages in Threads and the existing forum permissions. Allow **Attach Files** for long private review previews if absent. No new OAuth scope, bot installation, Gateway intent, or separate Discord application is required. 5. Notify operators that **plain `/reply` now sends as written**. Existing `/retry` and `/status` references continue to describe the original request. An unrecoverable reply without stored options is not reconstructed with guessed defaults. @@ -76,9 +77,10 @@ On Node 26, the existing jsdom suite conflicts with Node's global Web Storage. U In a test inbox after rollout: 1. Send a Hindi, Thai, or Burmese customer message. Its original text should appear promptly with no translation or processing-failed warning. Repeat with a deliberately invalid development Gemini key: normal incoming delivery and plain `/reply` must still work. -2. Send `/reply message:Please try again.` and verify the customer sees exactly that text. Send `/reply message:Please try again. translate:hi` and verify the customer sees the translated text. In a new thread, `translate:customer` must ask for an explicit language; after `translate:hi` it should use Hindi. -3. Have the agent post a draft, then run `/approve id: translate:Spanish`. Expect the private answer "Handed to the agent: it will translate this reply into Spanish (es), send it, and post both versions here.", an operator note with the `approve:` line, and, once the agent runs, one Spanish reply and a note with both versions. **Approve draft** on the same draft must answer that it was already handed back. -4. Exercise a review-required translation. Use `/status` to restore its private preview, confirm it, and verify one customer reply. Check that an unauthorized operator and a stale review button cannot approve it. Automated tests deterministically force this condition; live model responses may not flag a particular phrase. -5. With an invalid development Gemini key, request an incoming translation and an explicitly translated reply. The first leaves the original available; the second sends nothing. Inspect `translation_job.error_code` and `provider_status`. Restore the key and retry; cached translations and delivered replies must not duplicate. +2. Use **Apps → Translate to English** on the customer message. Expect one English translation replying to the original and a private link to it; running it again posts nothing new. An English message produces only a private acknowledgment. +3. Send `/reply message:Please try again.` and verify the customer sees exactly that text. Send `/reply message:Please try again. translate:hi` and verify the customer sees the translated text. In a new thread, `translate:customer` must ask for an explicit language; after `translate:hi` it should use Hindi. +4. Have the agent post a draft, then run `/approve id: translate:Spanish`. Expect the private answer "Handed to the agent: it will translate this reply into Spanish (es), send it, and post both versions here.", an operator note with the `approve:` line, and, once the agent runs, one Spanish reply and a note with both versions. **Approve draft** on the same draft must answer that it was already handed back. +5. Exercise a review-required translation. Use `/status` to restore its private preview, confirm it, and verify one customer reply. Check that an unauthorized operator and a stale review button cannot approve it. Automated tests deterministically force this condition; live model responses may not flag a particular phrase. +6. With an invalid development Gemini key, request an incoming translation and an explicitly translated reply. The first leaves the original available; the second sends nothing. Inspect `translation_job.error_code` and `provider_status`. Restore the key and retry; cached translations and delivered replies must not duplicate. Automated tests cover provider rejection diagnostics, provider outages, direct delivery without a model call, scope checks, immutable selection, caching/publication, same-language behavior, browser-locale rejection, explicit reply translation, and generation-bound review authorization. Live Discord client rendering and a credential-backed Gemini success need the test-inbox smoke check above. diff --git a/packages/discord/src/commands.test.ts b/packages/discord/src/commands.test.ts index 73f198f..e89b918 100644 --- a/packages/discord/src/commands.test.ts +++ b/packages/discord/src/commands.test.ts @@ -16,6 +16,7 @@ describe("Discord guild commands", () => { "reply", "status", "retry", + "Translate to English", "activity", ]); expect( diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts index a5d71ee..3542602 100644 --- a/packages/discord/src/commands.ts +++ b/packages/discord/src/commands.ts @@ -65,6 +65,12 @@ export const DISCORD_REPLY_COMMAND = { ], } as const satisfies DiscordChatInputCommandDefinition; +export const DISCORD_TRANSLATE_MESSAGE_COMMAND = { + type: DiscordApplicationCommandType.Message, + name: "Translate to English", + default_member_permissions: "0", +} as const; + export const DISCORD_STATUS_COMMAND = { type: DiscordApplicationCommandType.ChatInput, name: "status", @@ -212,5 +218,6 @@ export const DISCORD_GUILD_COMMANDS = [ DISCORD_REPLY_COMMAND, DISCORD_STATUS_COMMAND, DISCORD_RETRY_COMMAND, + DISCORD_TRANSLATE_MESSAGE_COMMAND, DISCORD_ACTIVITY_COMMAND, ] as const; diff --git a/packages/discord/src/interactions.test.ts b/packages/discord/src/interactions.test.ts index 3e9fff3..194981d 100644 --- a/packages/discord/src/interactions.test.ts +++ b/packages/discord/src/interactions.test.ts @@ -144,16 +144,28 @@ describe("Discord interaction parsing and authorization", () => { expect(normalized).not.toHaveProperty("token"); }); - it("parses the Approve draft action, optional translation arguments, and approval buttons; /translate, Translate to English and Reject draft are gone", () => { + it("parses the Approve draft and Translate to English actions, optional translation arguments, and approval buttons; /translate and Reject draft are gone", () => { const base = replyPayload(); for (const data of [ - { type: 3, name: "Translate to English", target_id: "12345" }, { type: 3, name: "Reject draft", target_id: "12345" }, { type: 1, name: "translate" }, ]) expect(() => parseDiscordInteraction(JSON.stringify({ ...base, data }))).toThrow( "Unsupported Discord command", ); + expect( + parseDiscordInteraction( + JSON.stringify({ + ...base, + data: { type: 3, name: "Translate to English", target_id: "12345" }, + }), + ), + ).toMatchObject({ command: "translate", targetMessageId: "12345", targetLanguage: "en" }); + expect(() => + parseDiscordInteraction( + JSON.stringify({ ...base, data: { type: 1, name: "Translate to English" } }), + ), + ).toThrow("Invalid command type"); expect( parseDiscordInteraction( JSON.stringify({ ...base, data: { type: 3, name: "Approve draft", target_id: "12345" } }), diff --git a/packages/discord/src/interactions.ts b/packages/discord/src/interactions.ts index f11b37f..bc7e2b9 100644 --- a/packages/discord/src/interactions.ts +++ b/packages/discord/src/interactions.ts @@ -144,6 +144,13 @@ export interface ParsedDiscordRetryInteraction extends ParsedDiscordCommandBase readonly message: string; } +/** Translate to English: translate one customer message and post the result as a reply to it. */ +export interface ParsedDiscordTranslateInteraction extends ParsedDiscordCommandBase { + readonly command: "translate"; + readonly targetLanguage: "en"; + readonly targetMessageId: string; +} + export interface ParsedDiscordActivityInteraction extends ParsedDiscordCommandBase { readonly command: "activity"; readonly count: number; @@ -195,6 +202,7 @@ export type ParsedDiscordCommandInteraction = | ParsedDiscordAgentContextInteraction | ParsedDiscordAgentDecisionInteraction | ParsedDiscordActivityInteraction + | ParsedDiscordTranslateInteraction | ParsedDiscordConfirmInteraction | ParsedDiscordReplyInteraction | ParsedDiscordRetryInteraction @@ -360,12 +368,18 @@ function parseCommandInteraction( : "confirm_translation" : requiredString(data, "name", "command name"); const draftContext = commandName === "Approve draft"; - const command = draftContext ? "agent_context" : commandName; + const messageCommand = draftContext || commandName === "Translate to English"; + const command = draftContext + ? "agent_context" + : commandName === "Translate to English" + ? "translate" + : commandName; if ( ![ "reply", "retry", "status", + "translate", "confirm_translation", "activity", "agent_decision", @@ -376,7 +390,7 @@ function parseCommandInteraction( ) { throw new DiscordInteractionParseError("unsupported_command", "Unsupported Discord command"); } - if (draftContext !== (data.type === 3) && payload.type !== 3) { + if (messageCommand !== (data.type === 3) && payload.type !== 3) { throw new DiscordInteractionParseError("invalid_payload", "Invalid command type"); } const options = parseOptions(data); @@ -438,6 +452,16 @@ function parseCommandInteraction( targetMessageId: requiredSnowflake(data, "target_id"), }; } + if (command === "translate") { + if (commandName !== "Translate to English") + throw new DiscordInteractionParseError("unsupported_command", "Unsupported Discord command"); + return { + ...commandBase, + command, + targetLanguage: "en", + targetMessageId: requiredSnowflake(data, "target_id"), + }; + } if (command === "agent_decision") { const match = /^agent:(approve|reject|translate):(msg_agent_[a-f0-9]{64})(?::([1-9][0-9]{0,8}))?$/.exec( @@ -652,6 +676,8 @@ export type NormalizedDiscordCommand = ParsedDiscordActivityInteraction, "command" | "count" | "minutes" | "activityKind" | "until" >) + | (NormalizedDiscordCommandBase & + Pick) | (NormalizedDiscordCommandBase & Pick) | (NormalizedDiscordCommandBase & { @@ -721,6 +747,13 @@ export function normalizeDiscordCommand( activityKind: interaction.activityKind, until: interaction.until, }; + if (interaction.command === "translate") + return { + ...base, + command: interaction.command, + targetLanguage: interaction.targetLanguage, + targetMessageId: interaction.targetMessageId, + }; if (interaction.command === "confirm_translation") return { ...base,