// Tinode WS {ctrl} error translation. Registration/login go over the native WS protocol, not // REST, so client.ts's REASON_MESSAGES table (REST-only) never sees these -- the SDK's own // CommError text is raw English with a status-code suffix baked in ("authentication failed (401)"), // confirmed live against the real backend. Ported from the old app's tinode-web.jsx (CTRL_ERROR_TEXT_CN // / translateCtrlError / loginErrorMessage), not guessed -- this exact table is what made login/register // error copy match the product spec instead of leaking SDK internals. const CTRL_ERROR_TEXT_CN: Record = { 'authentication failed': '账号或密码错误', 'authentication required': '请先登录', 'unknown authentication scheme': '登录方式不受支持,请刷新页面重试', 'policy violation': '当前操作不符合安全策略要求', 'permission denied': '没有权限执行此操作,请重新登录', 'internal error': '服务器繁忙,请稍后重试', // Confirmed live 2026-09-09: registering with a username or phone number that's already taken // returns this exact raw SDK text ("duplicate credential (409)") -- without this entry, // registerErrorMessage()'s fallback showed the raw English straight to a Chinese-language user. 'duplicate credential': '该用户名或手机号已被注册,请更换后重试', // docs/IM-前后端对接文档.md's own worked example (`{"login":...,"secret":"其他租户 token"}` -> // `403 tenant mismatch`) -- without this entry, loginErrorMessage's generic `code===403` fallback // below shows "账号或密码错误" for what's actually a stale-token-from-a-different-tenant case, which // is a materially different, more confusing message (the password wasn't wrong at all). 'tenant mismatch': '登录状态异常,请重新进入企业码', }; function translateCtrlError(err: unknown): string | null { const raw = (err as Error)?.message; if (!raw) return null; const normalized = raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase(); return CTRL_ERROR_TEXT_CN[normalized] || null; } /** docs/IM-前后端对接文档.md: this specific reason means the client is holding a session token * issued by a DIFFERENT tenant than the one it's currently connected to -- the doc's own prescribed * recovery is "清 Token 并回企业码页" (clear the token and return to the tenant-code entry screen), * not just show an error and let the user retry the same broken state. */ export function isTenantMismatch(err: unknown): boolean { const raw = (err as Error)?.message; if (!raw) return false; return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch'; } /** * `openConversation()`'s `{sub}` genuinely rejects with a clean `CommError` (code 404, message * "topic not found (404)") for a topic that no longer exists -- confirmed live: a group dissolved * by its owner, then deep-linked/refreshed into directly by a former member (not the "I'm actively * watching it get dissolved right now" case subscribeTopicGone already covers), reproducibly 404s * exactly like this. Before this check existed, ConversationView's generic catch just showed * "加载会话失败,请重试" and left the screen sitting on a half-initialized topic stub (raw topic id * as the name, "1970/1/1" for `touched`, "0" members -- `getTinode().getTopic()` lazily creates that * stub the moment anything asks for the name, whether or not the subscribe behind it ever succeeds), * a real reported bug ("当打开的群聊被删除后, 刷新还在群聊中 但是显示的不对"). 404 alone is treated * as sufficiently unambiguous for "this topic doesn't exist" -- there's no other real reason a * `{sub}` would come back with this exact code. */ export function isTopicGone(err: unknown): boolean { return (err as { code?: number })?.code === 404; } export function loginErrorMessage(err: unknown): string { const known = translateCtrlError(err); if (known) return known; const code = (err as { code?: number })?.code; if (code === 401 || code === 403) return '账号或密码错误'; return (err as Error)?.message || '登录失败,请重试'; } export function registerErrorMessage(err: unknown): string { const known = translateCtrlError(err); return known || (err as Error)?.message || '注册失败,请稍后重试'; } /** * Only INVITE_CODE_REQUIRED/INVALID were confirmed against a live server; REVOKED/EXPIRED are * inferred siblings under the same naming pattern (not individually exercised). */ const INVITE_CODE_REASON_TEXT_CN: Record = { INVITE_CODE_REQUIRED: '请填写邀请码', INVITE_CODE_INVALID: '邀请码错误,请重新输入', INVITE_CODE_REVOKED: '邀请码已被撤销,请联系管理员获取新的邀请码', INVITE_CODE_EXPIRED: '邀请码已过期,请联系管理员获取新的邀请码', }; export interface ValidateCredentialsParams { reason?: string; cred?: string[]; token?: string; code?: string; } /** * ctrl.code>=300 / ctrl.text=='validate credentials' is standard Tinode for "account created, now * confirm a real credential (email/SMS code)" -- ctrl.params.cred is a non-empty array in that case. * This backend reuses the exact same code/text pair for its own invite-code rejections, which carry * `reason` instead of `cred`. This app's registration flow always pre-verifies the phone credential * over REST before calling createAccountBasic, so a genuine pending-credential case here would mean * an account created some other way (e.g. by an admin) -- there's no credential-confirmation screen * built yet for that (out of scope for now), so it gets an honest, terminal message rather than a * silent dead end. */ export function validateCredentialsMessage(params: ValidateCredentialsParams | undefined): string { if (Array.isArray(params?.cred) && params.cred.length) { return '账号存在未确认的验证信息,请联系企业管理员处理'; } const reason = params?.reason; return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试'; } /** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording, * so this stays a straight lookup rather than something to rephrase later without checking the doc * again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table * merges them into a single row). */ const KICKED_REASON_TEXT_CN: Record = { replaced_by_new_login: { title: '账号已在其他设备登录', message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。', }, admin_user_evict: { title: '账号已被管理员下线', message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', }, admin_device_kick: { title: '账号已被管理员下线', message: '您的账号已被管理员强制下线,如有疑问请联系管理员。', }, user_deleted: { title: '账号已注销', message: '您的账号已注销,当前会话已退出。', }, user_state_changed: { title: '账号已被限制登录', message: '您的账号已被管理员限制登录,如有疑问请联系管理员。', }, }; const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' }; export function kickedNoticeText(reason: string | undefined): { title: string; message: string } { return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK; }