{"record":{"id":"5f45a0adb4b6297a","repo":"multica-ai/multica","slug":"wecom-binding-token-invalid-or-expired","errorCode":null,"errorMessage":"wecom: binding token invalid or expired","messagePattern":"wecom: binding token invalid or expired","errorType":"exception","errorClass":null,"httpStatus":410,"severity":"error","filePath":"server/internal/integrations/wecom/binding.go","lineNumber":71,"sourceCode":"// transport refused outright (no live connection mid-reconnect or lease flip)\n// still suppresses the next minute of mints. Nothing here reacts to that\n// error — the window itself, not a delivery receipt, is what bounds the\n// damage, which is the other reason to keep it short.\n//\n// Sixty seconds does the job the throttle was written for: six lines typed in\n// one breath still write one row, at a cost of at most one row a minute for a\n// user who keeps going, against rows that expire in fifteen. The price of\n// being wrong is one more message, not ten minutes of a bot insisting it\n// already answered.\n//\n// It must stay comfortably inside BindingTokenTTL so a link a throttled user\n// is pointed back at still has real time left on it.\nconst BindingTokenMintInterval = time.Minute\n\nvar (\n\t// ErrBindingTokenInvalid: token unknown / already consumed / expired.\n\t// One opaque error for all three avoids a replay timing oracle.\n\tErrBindingTokenInvalid = errors.New(\"wecom: binding token invalid or expired\")\n\t// ErrBindingAlreadyAssigned: this WeCom userid is already bound to a\n\t// different Multica user (account transfer must go through explicit\n\t// unbind, not implemented in iter 1 — an admin can DELETE the row).\n\tErrBindingAlreadyAssigned = errors.New(\"wecom: user id is already bound to a different user\")\n\t// ErrBindingNotWorkspaceMember: the redeemer is not a member of the\n\t// token's workspace. Translated to 403 at the HTTP boundary.\n\tErrBindingNotWorkspaceMember = errors.New(\"wecom: redeemer is not a workspace member\")\n)\n\n// BindingToken is a freshly minted token. The raw value is returned exactly\n// once (embedded in the binding URL); only its hash is persisted.\ntype BindingToken struct {\n\tRaw       string\n\tExpiresAt time.Time\n\n\t// Reused says the throttle suppressed the mint because a live link is\n\t// already sitting in the user's chat. Raw is empty in that case and there\n\t// is no way to recover it — the table only ever held the hash — so the","sourceCodeStart":53,"sourceCodeEnd":89,"githubUrl":"https://github.com/multica-ai/multica/blob/2c0912b6ec764b373d44eeea1e80f0d9f11ab417/server/internal/integrations/wecom/binding.go#L53-L89","documentation":"WeCom binding sentinel error: the token hash is unknown, already consumed, or expired (tokens live ~15 minutes; a CHECK enforces the TTL cap). One opaque error for all three sub-cases by design, to prevent a replay timing oracle — mirroring the slack/lark binding services.","triggerScenarios":"Redeeming a WeCom binding token a second time (single-use: only the hash is stored and redemption consumes it), redeeming after the 15-minute TTL, or redeeming a mistyped/truncated token from the chat message. Note the mint throttle (BindingTokenMintInterval = 1 minute) can hand back a REUSED live link whose raw token is unrecoverable — pointing the user at the earlier message is the only path.","commonSituations":"User double-clicks the WeCom bot's binding link; user delays past the TTL; WeCom client renders the URL with ellipsis/truncation; user re-requests a link within the 1-minute throttle and gets the old one back, then mis-copies it.","solutions":["Reply to the WeCom bot after the 1-minute mint interval has passed to get a genuinely new link, and redeem it promptly.","If the throttle reused the existing link (Reused=true), scroll back to the earlier bot message and use that link while it still has time left.","Ensure the URL is copied whole — truncation makes the hash unknown and yields the same opaque error."],"exampleFix":"// before\nres, err := svc.Redeem(ctx, rawToken)\nif err != nil {\n\tpanic(err)\n}\n\n// after\nres, err := svc.Redeem(ctx, rawToken)\nif errors.Is(err, wecom.ErrBindingTokenInvalid) {\n\t// unknown / consumed / expired are indistinguishable by design\n\trenderLinkExpired(w) // \"link invalid or expired, ask the bot for a new one\"\n\treturn\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"res, err := wecomSvc.Redeem(ctx, rawToken)\nif err != nil {\n\tif errors.Is(err, wecom.ErrBindingTokenInvalid) {\n\t\t// unknown/consumed/expired are collapsed by design — no branching on sub-case\n\t\treturn renderLinkExpired(w)\n\t}\n\treturn err\n}","preventionTips":["Redeem the WeCom link immediately; the 1-minute mint throttle means asking again too soon returns the same link.","Copy the full URL from the bot message; truncated tokens hash to unknown values.","Handle Reused=true mint results by pointing the user at the earlier message, not by minting."],"tags":["wecom","binding","token-expiry","single-use-token","integration","go"],"backgroundTag":null,"analyzedSha":"2c0912b6ec764b373d44eeea1e80f0d9f11ab417","analyzedAt":"2026-08-15T13:25:18.241Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}