A multilingual chat can translate every word correctly and still leave someone wondering: Was that a suggestion, a deadline, or a polite refusal?
That uncertainty is not evidence that you are bad at communication—or that translation has failed. Natural languages encode tone, obligation, and context differently. The engineering mistake is treating translated text as if it removed those trade-offs.
This tutorial builds a clarification handshake for a Tencent RTC Social Messaging group:
- A reader requests clarification about a specific message revision.
- The request records what is unclear without rewriting the original message.
- Only the original author can provide the authoritative answer.
- Editing the source message supersedes outstanding requests.
- Chat-notification failure remains visible instead of destroying the saved request.
Tencent RTC's Social Messaging scenario covers group discussions, communities, rich media, and other social experiences: https://trtc.io/solutions/social-messaging
TUIChat also provides on-demand text-message translation. Its documentation defines supported content types and languages as well as applicable edition limits: https://trtc.io/document/60772
Translation helps the reader understand the text. The handshake below answers a different question: Who gets to confirm what the message meant?
Start with three different kinds of uncertainty
Before adding a button, classify the user's problem:
| User uncertainty | Appropriate action |
|---|---|
| “I cannot read this language” | Offer documented on-demand translation when supported |
| “I can read the words, but the intent is ambiguous” | Ask the original author for clarification |
| “This may violate a community rule” | Use the moderation or reporting workflow |
Combining these paths creates dangerous shortcuts. A translated sentence should not automatically create a task, settle a dispute, or become a moderator's evidence of intent.
Our example UI will therefore place Translate and Ask what this means beside each other. Neither replaces the original message.
Define the protocol before the interface
A clarification request is attached to an exact source revision:
open ├── original author answers ──> resolved ├── source message changes ──> superseded └── requester cancels ───────> cancelled Delivery of the chat notification is a separate state:
pending -> sent pending -> failed // definitely rejected; safe to retry pending -> uncertain // timeout or disconnect; do not retry blindly That separation matters. If the request is saved but sending its notification times out, deleting the request would lose user work. Blindly retrying could instead produce duplicate messages.
The invariants are:
- A translation is never stored as the author's statement.
- Only the source-message author can resolve the request.
- A request cannot silently move to a newer message revision.
- Notification failure does not roll back the clarification record.
- An uncertain send requires reconciliation or a deliberate user retry.
Create the reproducible project
Use Node.js 20 or later:
mkdir multilingual-clarification cd multilingual-clarification npm init -y npm install --save-dev typescript tsx @types/node mkdir src test Add this test command to package.json:
{ "scripts": { "test": "tsx --test test/*.test.ts" } } Implement the state model
Create src/clarifications.ts:
export type ClarificationStatus = | "open" | "resolved" | "superseded" | "cancelled"; export type NoticeState = | "pending" | "sent" | "failed" | "uncertain"; export type QuestionKind = | "literal_meaning" | "tone" | "required_action" | "other"; export interface Clarification { id: string; threadId: string; messageId: string; messageRevision: number; requesterId: string; authorId: string; kind: QuestionKind; note: string; status: ClarificationStatus; notice: NoticeState; createdAt: number; answer?: string; resolvedAt?: number; closedReason?: string; } export interface ChatPort { sendText(threadId: string, text: string): Promise<void>; } export class ChatSendError extends Error { constructor(public readonly certainty: "rejected" | "unknown") { super(`Chat send ${certainty}`); } } export class ClarificationService { private readonly rows = new Map<string, Clarification>(); private sequence = 0; constructor( private readonly chat: ChatPort, private readonly now: () => number = Date.now, ) {} async create(input: { threadId: string; messageId: string; messageRevision: number; requesterId: string; authorId: string; kind: QuestionKind; note: string; }): Promise<Clarification> { if (input.requesterId === input.authorId) { throw new Error("Authors do not need a clarification request to themselves"); } const note = input.note.trim(); if (note.length > 280) { throw new Error("Clarification note is too long"); } const id = `clarification-${++this.sequence}`; const row: Clarification = { ...input, id, note, status: "open", notice: "pending", createdAt: this.now(), }; // Persist first. Sending a chat message is an external side effect. this.rows.set(id, row); await this.deliverNotice(id); return this.get(id); } async deliverNotice(id: string): Promise<void> { const row = this.mutable(id); if (row.notice === "sent") return; if (row.notice === "uncertain") { throw new Error("Reconcile an uncertain send before retrying"); } row.notice = "pending"; try { await this.chat.sendText( row.threadId, `Clarification ${row.id} requested for message ${row.messageId}.`, ); row.notice = "sent"; } catch (error) { row.notice = error instanceof ChatSendError && error.certainty === "rejected" ? "failed" : "uncertain"; } } resolve(input: { id: string; actorId: string; currentMessageRevision: number; answer: string; }): Clarification { const row = this.mutable(input.id); if (row.status !== "open") { throw new Error(`Cannot resolve a ${row.status} request`); } if (input.actorId !== row.authorId) { throw new Error("Only the original author may resolve this request"); } if (input.currentMessageRevision !== row.messageRevision) { row.status = "superseded"; row.closedReason = "source_message_changed"; throw new Error("The source message changed; create a new request"); } const answer = input.answer.trim(); if (!answer) throw new Error("An answer is required"); row.status = "resolved"; row.answer = answer; row.resolvedAt = this.now(); return this.get(row.id); } sourceRevised(messageId: string, newRevision: number): void { for (const row of this.rows.values()) { if ( row.messageId === messageId && row.status === "open" && row.messageRevision !== newRevision ) { row.status = "superseded"; row.closedReason = "source_message_changed"; } } } cancel(id: string, requesterId: string): Clarification { const row = this.mutable(id); if (row.requesterId !== requesterId) { throw new Error("Only the requester may cancel this request"); } if (row.status !== "open") { throw new Error(`Cannot cancel a ${row.status} request`); } row.status = "cancelled"; row.closedReason = "requester_cancelled"; return this.get(id); } get(id: string): Clarification { return { ...this.mutable(id) }; } private mutable(id: string): Clarification { const row = this.rows.get(id); if (!row) throw new Error(`Unknown clarification: ${id}`); return row; } } The ChatPort is intentionally small. It does not invent a Tencent RTC SDK method name. The application adapter should call the documented text-send operation from the Tencent RTC integration you actually use.
Verify authority, revisions, and delivery uncertainty
Create test/clarifications.test.ts:
import assert from "node:assert/strict"; import test from "node:test"; import { ChatPort, ChatSendError, ClarificationService, } from "../src/clarifications.js"; const baseRequest = { threadId: "group-7", messageId: "message-42", messageRevision: 1, requesterId: "reader", authorId: "author", kind: "required_action" as const, note: "Is this optional, or should it be done today?", }; test("only the original author can resolve a request", async () => { const chat: ChatPort = { sendText: async () => {} }; const service = new ClarificationService(chat, () => 1000); const request = await service.create(baseRequest); assert.throws( () => service.resolve({ id: request.id, actorId: "another-member", currentMessageRevision: 1, answer: "It is required", }), /Only the original author/, ); const resolved = service.resolve({ id: request.id, actorId: "author", currentMessageRevision: 1, answer: "It is optional; tomorrow is also fine.", }); assert.equal(resolved.status, "resolved"); assert.equal(resolved.answer, "It is optional; tomorrow is also fine."); }); test("editing the source supersedes an open request", async () => { const chat: ChatPort = { sendText: async () => {} }; const service = new ClarificationService(chat); const request = await service.create(baseRequest); service.sourceRevised("message-42", 2); const current = service.get(request.id); assert.equal(current.status, "superseded"); assert.equal(current.closedReason, "source_message_changed"); }); test("an ambiguous send failure is not retried automatically", async () => { let attempts = 0; const chat: ChatPort = { sendText: async () => { attempts += 1; throw new ChatSendError("unknown"); }, }; const service = new ClarificationService(chat); const request = await service.create(baseRequest); assert.equal(request.status, "open"); assert.equal(request.notice, "uncertain"); assert.equal(attempts, 1); await assert.rejects( service.deliverNotice(request.id), /Reconcile an uncertain send/, ); assert.equal(attempts, 1); }); test("a definitely rejected notification can be retried", async () => { let attempts = 0; const chat: ChatPort = { sendText: async () => { attempts += 1; if (attempts === 1) throw new ChatSendError("rejected"); }, }; const service = new ClarificationService(chat); const request = await service.create(baseRequest); assert.equal(request.notice, "failed"); await service.deliverNotice(request.id); assert.equal(service.get(request.id).notice, "sent"); assert.equal(attempts, 2); }); Run the suite:
npm test These tests verify the decisions that matter more than the button styling:
- A fluent bystander cannot speak for the author.
- An old clarification cannot be applied to edited text.
- A timeout does not trigger an uncontrolled duplicate send.
- A definite rejection remains recoverable.
Connect the workflow to TUIChat
Keep translation and clarification as neighboring capabilities rather than one pipeline.
Translation path
For a supported text message, let the user explicitly invoke TUIChat's documented on-demand translation experience. Before enabling it in a deployment, check the official documentation for:
- supported message content types;
- supported source and target languages;
- applicable edition requirements;
- the platform-specific integration instructions.
Do not copy a language list into application code and assume it will remain accurate. Treat the documentation and your verified deployment configuration as the capability source.
If translation is unavailable, preserve the original message and show an honest state such as:
Translation is not available for this message. You can still ask the author for clarification. Clarification path
The Ask what this means action should receive source data from the rendered message:
await clarifications.create({ threadId: currentThread.id, messageId: selectedMessage.id, messageRevision: selectedMessage.revision, requesterId: currentUser.id, authorId: selectedMessage.authorId, kind: "tone", note: "Does this mean you agree, or that you want more discussion?", }); The field names above belong to this tutorial's application model. Map them to identifiers and revision tracking that your own backend controls; they are not presented as Tencent RTC API names.
Render the resulting record as a card associated with the source message:
Clarification requested · Tone “Does this mean you agree, or that you want more discussion?” Waiting for the original author When resolved:
Answered by the original author “I agree with the direction, but not the proposed deadline.” Do not parse the translated notification text to reconstruct state. The clarification record is the state; the chat message is only a user-visible notification.
Production persistence needs conditional writes
The in-memory map makes the state machine easy to reproduce, but a production backend should perform conditional database updates.
For example, resolving a request should be equivalent to:
UPDATE clarifications SET status = 'resolved', answer = ?, resolved_at = ? Source:https://dev.to/susiewang/translation-is-not-agreement-build-a-clarification-handshake-for-multilingual-chat-4eeb