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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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).
81 changes: 81 additions & 0 deletions apps/api/src/discord-translation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve an operator-named language when translating a newer customer message

This new command calls requestMessageTranslation, which reaches TranslationWorkflow.save. The existing update at apps/api/src/workflows/translation.ts:126 only compares timestamps. An operator can select Spanish with /reply translate:es, then receive a newer Hindi customer message and invoke this command. The translation changes thread.customer_language from es to hi, so subsequent /reply translate:customer and default draft approvals use Hindi. This violates RK-7's explicit requirement that translation never overwrite a language an operator named.

Keep explicit language choices authoritative and add a regression covering an explicit choice followed by a newer customer message and this menu action. The save SQL predates the PR, but restoring this entry point exposes it through the new user flow.

Reproduced at this head against wrangler dev --local: before {customer_language:"es", customer_language_updated_at:1792328152338}, after {customer_language:"hi", customer_language_updated_at:1792328155344}. Runtime evidence, walkthrough script.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: 486a613 adds the regression (an operator-named es, then Translate to English on a newer Hindi message; it failed with hi), and 8835fe0 makes TranslationWorkflow.save fill thread.customer_language only while it is NULL, so detection never replaces a named language. I re-ran your walkthrough against 1e454ba: "operator language preserved" shows es before and after, and all its other cases pass. I didn't add a column recording where the language came from, because that is a D1 migration. Tradeoff: a detected language also stays fixed until an operator names another one.

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
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down
129 changes: 127 additions & 2 deletions apps/api/src/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown>[] = [];
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<string, unknown>;
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<string, unknown>;
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(
Expand All @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/workflows/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,17 @@ export class TranslationWorkflow extends WorkflowEntrypoint<Env, TranslationWork
"UPDATE translation_job SET status = 'succeeded', result_json = ?, error_code = NULL, provider_status = NULL, updated_at = ? WHERE id = ? AND generation = ?",
).bind(JSON.stringify(result), Date.now(), job.id, job.generation),
this.env.DB.prepare(
"UPDATE thread SET customer_language = ?, customer_language_updated_at = (SELECT accepted_at FROM message WHERE id = ?) WHERE id = ? AND workspace_id = ? AND inbox_id = ? AND ? = 0 AND (customer_language_updated_at IS NULL OR customer_language_updated_at <= (SELECT accepted_at FROM message WHERE id = ?))",
"UPDATE thread SET customer_language = ?, customer_language_updated_at = (SELECT accepted_at FROM message WHERE id = ?) WHERE id = ? AND workspace_id = ? AND inbox_id = ? AND ? = 0 AND customer_language IS NULL",
).bind(
result.sourceLanguage,
job.message_id,
job.thread_id,
job.workspace_id,
job.inbox_id,
result.needsReview ? 1 : 0,
job.message_id,
),
]);
// Detection only fills an unknown language: one an operator named stays until they name another.
// The original message, its processing status, and its display text stay untouched.
}

Expand Down
11 changes: 2 additions & 9 deletions apps/web/content/docs/discord-inbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,13 @@ The customer receives exactly the text you enter. Translation is optional:
/reply message:Please reopen the app. translate:hi
```

`customer` uses a reliably detected language from the conversation. If it is unknown, translate an incoming message first or supply a language name or code (`Burmese`, `简体中文`, `my`).
`customer` uses the language you last named for the thread, or else a reliably detected one; translating a newer customer message never replaces it. If it is unknown, translate an incoming message first or supply a language name or code (`Burmese`, `简体中文`, `my`).

You can also approve a draft with `/approve id:<draft message link or ID>`, 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:<customer | off | language name or code>`, 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:<interaction ID>` 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:<Discord message link> 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.

Expand Down
Loading
Loading