Skip to content

Chat accounts, routing and identity

This is the shared contract for WeCom, WeChat, Feishu, Lark and DingTalk. Read it together with the platform page: their transports and send capabilities are not interchangeable. Accounts hold credentials/connections; triggers select messages and bind them to existing script/workflow targets. No endpoint targets. Configuration and target drafts are live; no publish step. This subsystem does not add native HomeAssistant, MQTT or Slurm connectors.

All API examples are source-derived/unexecuted. Real QR scans, chat sends and provider calls require explicit opt-in. Account/trigger CRUD and account QR APIs currently require a project member. Minting/revoking self-service links requires content-edit rights; listing links/uses requires membership. Sender identity create/delete requires Owner/Admin/Developer project rights; listing requires membership. These are management credentials, separate from the end-user identities a message can resolve to.

POST /projects/<slug>/im/accounts creates; GET …/im/accounts lists; PUT|DELETE …/im/accounts/<name> updates/deletes. Account fields:

FieldCreate / update
nameRequired nonblank string; PUT can rename the account addressed by the old name.
platformRequired `wecom
enabledDefault true, including PUT; preserve false explicitly when editing a paused account.
resourceName of a credential resource, mutually exclusive with credentials.
credentialsInline JSON, encrypted at rest, never serialized back. Exactly one of resource/credentials is required on creation; WeChat starts with {} before QR. On update both omitted/null preserve the current credential source. Supplying one switches source; there is no plaintext secret-read API.
metaCreation default {}; update omission/null preserves, {} clears metadata. Carried in message.account.meta; provider-specific bot_open_id/robot_code are interpreted by their connector.
groupsDefault []; update omission/null preserves, [] clears. Account populations, not provider chat rooms.
peer_identityOptional platform-side human identity; unique per platform deployment-wide. Omission/null on update preserves, not clears. A duplicate is 409 rather than a silent binding takeover.

Credential JSON for static platforms uses app_id, app_secret; optional base_url is confined by platform host allowlists. Put credentials in the resource’s secret portion or the encrypted inline field, not in meta/repo prose. Returned AccountPublic has all id,name,platform,enabled,resource,has_inline_credentials,meta,groups,peer_identity,status,status_message,created_at,updated_at; resource/peer/status_message can be null. Status is ok|degraded|needs_reauth, not proof of message delivery. GET …/<name>/health reports connection/lease health, GET …/<name>/messages returns recent processing ledger. Default ceilings: 500 accounts/project, 2000 total, 200 served/instance, configurable by operator; reaching a cap is not solved by retrying account creation.

POST /projects/<slug>/im/triggers; GET …/im/triggers; PUT|DELETE …/im/triggers/<name>. Create/update use the same full-form fields:

FieldDefault / conditions
name, platform, targetRequired name, platform enum above, existing `{kind:script
enabledtrue.
accountOmitted/null matches every enabled platform account in this project; a name pins one and must match platform.
account_groups[] means no population restriction; nonempty narrows account fan-in by population membership.
chat_typeany; also direct, group. A filter cannot create unsupported platform traffic.
keywordOmitted/null matches all; otherwise case-insensitive substring of normalized text. Media-only text may be empty.
require_mentionfalse; meaningful only for Feishu/Lark, since WeCom/DingTalk group arrival already implies mention, and native WeChat group support is not established.
inputsDefaults null; execution merges object constants, otherwise no constants. Prefer explicit {}.
params{}: argument name → {from:<dotted path>,required:false,default?:<JSON>}; paths address the envelope, e.g. message.text/message.raw.priority. Missing/null leaves use default if present; missing required without default fails; missing optional remains absent. No HTTP type-coercion declaration here.
params_strictfalse: include message as well as declared params. true omits message; constants still merge. Declared values override same-named constants.
max_concurrent_runs4; connection-time concurrency bound per account, changes apply on reconnect. It is not a global per-trigger ordering guarantee.
identity_modeoff; optional or required described below.
identity_keyOmitted/null uses message.sender_id; dotted envelope path.
identity_ruleOptional template, must contain {key}.
identity_promptOptional unbound reply, must contain {url}. Identity fields must be blank/absent when identity_mode is off.

PUT is full replacement for trigger policy: omitted optional values reset to their defaults; use [], {}, null and explicit false as appropriate, not a guessed merge patch. Account update has the special preservation semantics above. A message can match multiple triggers and run each once; fan-in is not first-match-only.

Response TriggerPublic uses account_id (nullable UUID), not the submitted account name, and target_kind,target_name, not nested target; other returned fields are id,name,enabled,platform,account_groups,chat_type,keyword,require_mention,inputs,params,params_strict,max_concurrent_runs,identity_mode,identity_key,identity_rule,identity_prompt,created_at,updated_at.

Default args are constants plus message:{platform,account:{id,name,meta},chat_type,chat_id,sender_id,kind,text,mentioned,raw,reply_handle}. kind is text/image/voice/file/video/event; raw/media fields are provider-specific, not automatically downloaded/transcribed in all connectors. reply_handle is sensitive routing capability, not a durable universal destination. With identity_mode off, no message.identity is added and run principal is system:im. optional adds a bound project-user object or null; required blocks an unbound sender. Bound runs carry that active user’s principal into authorization/resource identity forwarding, without granting permission merely because binding succeeded.

Bindings key (project,platform,sender_key) independently of the particular bot account. Lookup existing binding before trying identity_rule; a rule renders an existing active project-user identity and persists the binding. identity_rule:"jwt:corp:{key}" is only safe when the provider key and IdP subject genuinely align. It does not create a user or prove an arbitrary metadata claim. Manual binding:

POST /projects/demo/im/identities
Content-Type: application/json
{"platform":"feishu","sender_key":"synthetic-open-id","user":"alice"}

Response includes id,platform,sender_key,project_user_id,identity,display_name,bound_via:"admin",bound_at. List with optional ?user=alice&platform=feishu, remove DELETE …/im/identities/<id>. Under required mode an unbound direct sender may receive a signed 15-minute project-login binding link; the page under /p/<slug>/_chat/bind requires the project’s own credentials and login pre-hook. A group gets a request to DM instead, never a sender binding link exposed to all members. Disabled users are not treated as valid bound users. Account populations, peer_identity, invitation meta and project-user identity bindings are distinct mechanisms; none automatically grants another’s trust.

Return text/JSON appropriate to the provider to reply; a null output sends no automatic reply. WeCom replies over its held connection, the others have their documented sending constraints. A successful script or accepted provider API response is not proof the human read the message.

One leased instance holds each account connection. Messages claim a dedup ledger before acknowledging/persisting cursor; each matching trigger has its own message identity. This suppresses ordinary redelivery, not exactly-once external side effects. Connector acknowledgement may occur after durable claim but before target success, so it is not RabbitMQ’s success→ack contract. No generic auto-retry of every failed script or replay API is promised. Stranded old work is retired as failed (one-hour threshold); the ledger is not a complete replay payload archive. Use a business id and separate durable outbox/work table when losing/repeating an action is unacceptable.

Transient connection failures back off 2–60 seconds; a healthy session resets delay. Revoked/misconfigured credentials stop in needs_reauth, requiring a human fix. Updating credentials clears that stuck state; toggling enabled alone is not credential repair. Concurrency is per account, no strict global order; edits to scripts are live, and connection-level settings take effect on reconnect. Runtime timeout does not undo resource writes or provider sends. Disabling/removing a trigger/account stops new routing/connections after reconciliation, not atomic cancellation of in-flight side effects. Inspect health, ledger and execution logs, repair credentials/target, then deliberately retry business work after checking prior effects.

Self-service account invitation and repo lifecycle

Section titled “Self-service account invitation and repo lifecycle”

Native WeChat QR account provisioning is separate from sender→project-user binding. An editor may mint POST …/im/bind-links with {platform:"weixin",label?,ttl_hours:24,max_uses:50,groups:[],meta?:{…}}. TTL must be positive and is capped by deployment default maximum 168h; max_uses clamps 1–configured ceiling (default 500). meta must be an object <=4096 encoded bytes. Response {id,url,expires_at,max_uses} shows URL once. Requires VIAT_IM_BIND_BASE_URL; page prefix defaults /ui, proxy IP throttling requires trusted proxy configuration. The URL is an unauthenticated account-creation capability. A per-person identity assertion needs a short, one-use link issued after your system authenticates them; invitation meta outranks self-typed display name. List/revoke links and inspect uses before cleanup. Revocation prevents future use, not messages from accounts already created.

Repo sync supports chat, not invented standalone chat CLI commands: im/accounts/<name>.yaml and im/triggers/<name>.yaml, viat pull/push --only chat. Resource-backed accounts and triggers mirror normally; inline/QR credential accounts are skipped on pull and protected from mirror deletion on push. Triggers can therefore reference accounts that are not portable. Missing resource-backed accounts/triggers locally can delete remote ones; diff first. Disable isolated triggers/accounts, revoke invitation links, remove test identity bindings and only then delete test configuration. Removing local files does not revoke provider credentials or undo external messages.

Evidence: crates/viat-api/src/handlers/{im,im_bind,im_identities}.rs, im/{mod,dispatch}.rs, im/connector/*; crates/viat-core/src/types/im.rs; crates/viat-cli/src/repo/im.rs. No live provider behavior was checked in this documentation task.