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 = ?
WHERE id = ?
AND status = 'open'
AND author_id = ?
AND message_revision = ?;
Treat an affected-row count of zero as a conflict, not as success. Another process may have superseded, cancelled, or resolved the request first.
For chat notices, use an outbox worker if requests must survive a server restart. Still retain an uncertain state: a process can lose its connection after the messaging service accepts a send but before the worker records success. Unless your documented integration provides an appropriate deduplication mechanism, do not promise exactly-once notification delivery.
A duplicate notice with the same clarification ID is preferable to two independent clarification records. Clients can collapse notices that point to an already-rendered card.
Failure behavior users can act on
| Failure | Internal state | User-visible response |
|---|---|---|
| Translation unsupported | Clarification unaffected | Keep original visible; offer clarification |
| Notification definitely rejected | notice: failed |
Show “Request saved; notification not sent” and a retry action |
| Notification outcome unknown | notice: uncertain |
Show “Request saved; delivery unconfirmed” rather than retrying silently |
| Source message edited | superseded |
Link to the new revision and offer a new request |
| Non-author attempts to answer | No state change | Explain that only the original author can confirm intent |
| Author deletes the source | Close or supersede according to policy | Remove quoted content and explain that the source is unavailable |
| Request contains harassment | Moderation path | Preserve access controls and allow reporting; translation is not moderation |
Two privacy rules deserve particular attention:
- Do not copy private message bodies into notifications that have broader visibility.
- Recheck thread membership when reading or answering a clarification. Possessing a clarification ID must not grant access to its source conversation.
The engineering judgment that remains valuable
It is easy to frame multilingual tooling as a test of whether a developer knows enough languages. That is not the most useful question.
The durable skill is deciding where certainty comes from:
- The translation feature can provide a readable rendering.
- The application can preserve identity, revision, and delivery state.
- The original author can confirm intent.
- A moderator can decide whether behavior violates community rules.
Those responsibilities should not collapse into one “smart” message box. Making them visible helps users name uncertainty without feeling that they have failed a language test.
Release verification checklist
Before shipping, verify the complete social-messaging flow:
- [ ] The original message remains accessible beside any translation.
- [ ] Translation is user-invoked and enabled only for a verified deployment configuration.
- [ ] Unsupported content or language combinations have a visible fallback.
- [ ] Clarification requests store the source message ID and revision.
- [ ] Only the original author can resolve a request.
- [ ] Editing or deleting the source invalidates open requests according to policy.
- [ ] A failed notification does not delete the saved request.
- [ ] An uncertain notification is not retried automatically.
- [ ] Database writes use conditional updates to prevent double resolution.
- [ ] Thread authorization is checked when creating, reading, and answering requests.
- [ ] Abuse reports remain separate from translation and clarification.
- [ ] Clients do not treat translated notification text as protocol data.
A multilingual community does not need to pretend every sentence has one mechanically recoverable meaning. It needs a reliable way to move from readable to confirmed when the distinction matters.
Disclosure: I have a content relationship with Tencent RTC. Official Tencent RTC documentation was used as the implementation reference for the product-specific portions of this tutorial.
Top comments (1)
Hi LunarDrift, thank you for this excellent tutorial! The concept that "translation is not agreement" is so accurate, and your "clarification handshake" model is a highly practical solution for real-world multilingual applications. I especially appreciated the release verification checklist and how thoughtfully you addressed edge cases like message edits and failed notifications. Thanks for sharing your expertise so clearly and transparently!😁