Client Integration Guide

1. Overview

Tip: for JS/TS, Dart, Swift, or Kotlin, prefer the official typed SDK (see “SDK Overview”) instead of hand-rolling the protocol below.

This document describes how Android, iOS, and Web clients integrate with the IM service in this repository.

The current IM stack includes:

  • WebSocket long connection
  • JWT-based connection authentication
  • 1-to-1 chat
  • Room chat
  • Conversation list / unread count / mute
  • Typing status
  • Edit / recall / delete / reaction
  • Emoji reaction / nudge
  • Reply / quote / forward / search
  • Friend request / block list / DM policy
  • OSS-based media upload
  • Inline image / video rendering
  • Voice messages / press-to-talk
  • Speech-to-text for voice messages
  • In-page PDF preview
  • Offline push device registration

Core server entry points:

  • WebSocket: /acc
  • Upload: POST /user/chat/upload
  • Audio transcription: POST /user/chat/audio/transcribe
  • File access: GET /user/chat/upload/access
  • Profile lookup: GET /user/chat/profile
  • User search: GET /user/chat/search
  • Presence: GET /user/online

2. Connection Prerequisites

2.1 JWT

The IM WebSocket now requires JWT at handshake time.

JWT validation rules:

  • signing algorithm: HS256
  • secret: auth.jwt_secret
  • issuer: auth.jwt_issuer
  • audience: auth.jwt_audience
  • user id claim: one of user_id, uid, sub

2.2 Origin

WebSocket handshake also validates Origin.

Config:

auth:
  jwt_required: true
  allow_empty_origin: false
  allowed_origins:
    - "http://127.0.0.1:8080"
    - "http://localhost:8080"

Notes:

  • Web clients must use an origin in auth.allowed_origins
  • Native clients should set Origin explicitly if the WebSocket library allows it
  • If a native SDK cannot set Origin, you must set auth.allow_empty_origin=true

3. Endpoints

3.1 WebSocket

Example:

ws://<your-host>/acc?token=<JWT>

If the service is exposed over HTTPS, use:

wss://<your-host>/acc?token=<JWT>

Recommendations:

  • Web clients should connect to /acc on the current site
  • Browsers should not directly connect to :8089 in production
  • If Nginx / Ingress / CDN is used, proxy /acc to the WebSocket backend

Token may also be passed with headers:

  • Authorization: Bearer <JWT>
  • X-Auth-Token: <JWT>

3.2 HTTP

  • Upload: POST /user/chat/upload
  • Audio transcription: POST /user/chat/audio/transcribe
  • Access signed file URL: GET /user/chat/upload/access
  • Load profile: GET /user/chat/profile?userID=<uid>
  • Search users: GET /user/chat/search?keyword=<kw>&limit=<n>
  • Query online status: GET /user/online?userID=<uid>

Both require JWT in one of:

  • Authorization: Bearer <JWT>
  • X-Auth-Token: <JWT>
  • query param token

4. WebSocket Message Format

4.1 Client request

{
  "seq": "10001",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "text": "hello"
  }
}

4.2 Server response / push

{
  "seq": "10001",
  "cmd": "chat_dm_send",
  "response": {
    "code": 200,
    "codeMsg": "Success",
    "data": {}
  }
}

Push events use the same envelope, but cmd is an event command such as:

  • chat_message
  • chat_typing
  • chat_dm_conversation_update
  • chat_room_conversation_update
  • chat_group_conversation_update
  • chat_dm_read_receipt
  • chat_group_read_receipt
  • chat_dm_delivery_receipt
  • chat_message_mutation
  • chat_friend_request_update

5. Connection Lifecycle

5.1 Step 1: connect WebSocket with JWT

The connection is rejected if JWT is missing or invalid.

5.2 Step 2: send login

Even after handshake succeeds, the client should still send login to initialize presence, profile snapshot, and session state.

Request:

{
  "seq": "1",
  "cmd": "login",
  "data": {
    "userID": "1001",
    "userName": "alice",
    "avatar": "https://cdn.example.com/a.png"
  }
}

Notes:

  • serviceToken is no longer required when handshake JWT is already used
  • userID must match the JWT claim
  • Prefer storing only userID on the client and resolving userName / avatar from /user/chat/profile
  • userName and avatar can still be sent in login for first-frame rendering and profile backfill

5.3 Step 3: heartbeat

The client should send heartbeat periodically.

Recommended interval:

  • every 30s to 60s

Request:

{
  "seq": "2",
  "cmd": "heartbeat",
  "data": {}
}

There is also a lightweight ping:

{
  "seq": "3",
  "cmd": "ping",
  "data": {}
}

6. Common Commands

6.1 Room chat

Before sending room chat, the user must join a room.

Join room:

{
  "seq": "10",
  "cmd": "room_join",
  "data": {
    "roomID": 123,
    "userID": "1001",
    "userName": "alice"
  }
}

Send room chat:

{
  "seq": "11",
  "cmd": "chat_room_send",
  "data": {
    "text": "hello room"
  }
}

Load room chat history:

{
  "seq": "12",
  "cmd": "chat_room_history",
  "data": {
    "limit": 20,
    "beforeID": 0,
    "afterID": 0
  }
}

Search room chat:

{
  "seq": "13",
  "cmd": "chat_room_search",
  "data": {
    "keyword": "hello",
    "limit": 20
  }
}

Room conversation list:

{
  "seq": "14",
  "cmd": "chat_room_conversations",
  "data": {
    "limit": 50
  }
}

Mark room as read:

{
  "seq": "15",
  "cmd": "chat_room_mark_read",
  "data": {
    "roomID": 123
  }
}

Set room mute:

{
  "seq": "16",
  "cmd": "chat_room_mute_set",
  "data": {
    "roomID": 123,
    "muted": true
  }
}

Room typing:

{
  "seq": "17",
  "cmd": "chat_room_typing",
  "data": {
    "isTyping": true
  }
}

Transient status message:

{
  "seq": "18",
  "cmd": "chat_status_send",
  "data": {
    "scene": "room",
    "statusType": "recording",
    "text": "recording audio",
    "data": {
      "durationMs": 1200
    },
    "expiresAt": 1779292800
  }
}

Transient status messages also support scene=dm and scene=group:

  • scene=dm: send targetUserID or conversationID
  • scene=group: send groupID, or omit it to use the current connection groupID
  • scene=room: send roomID, or omit it to use the current connection roomID

Status messages are delivered only to online sessions. They are not persisted, not returned in history, do not update conversation lists, do not increase unread counts, and do not create offline push tasks.

Advanced chatroom governance:

{ "seq": "room-info-1", "cmd": "chat_room_info", "data": { "roomID": 123 } }
{ "seq": "room-members-1", "cmd": "chat_room_members", "data": { "roomID": 123, "limit": 500 } }
{ "seq": "room-global-mute-1", "cmd": "chat_room_global_mute_set", "data": { "targetUserID": "1002", "muted": true } }
{ "seq": "room-ban-1", "cmd": "chat_room_ban_set", "data": { "roomID": 123, "targetUserID": "1002", "banned": true, "reason": "spam" } }
{ "seq": "room-priority-1", "cmd": "chat_room_priority_set", "data": { "roomID": 123, "lowPriorityTypes": ["gift", "emoji"], "protectedTypes": ["text"] } }
{ "seq": "room-protect-1", "cmd": "chat_room_user_protect_set", "data": { "roomID": 123, "targetUserID": "1002", "protected": true } }
{ "seq": "room-keepalive-1", "cmd": "chat_room_keepalive_set", "data": { "roomID": 123, "keepAlive": true } }
{ "seq": "room-attr-1", "cmd": "chat_room_attribute_set", "data": { "roomID": 123, "attributes": { "topic": "final", "mode": "ranked" } } }
{ "seq": "room-attr-del-1", "cmd": "chat_room_attribute_delete", "data": { "roomID": 123, "keys": ["topic"] } }

Notes:

  • chat_room_members returns at most 500 members.
  • Room owners can set room bans, all-muted state, member mute, protected users, low-priority/protected message types, keepalive, and room attributes.
  • Global chatroom mute prevents a user from sending messages in every chatroom.
  • Banned users are forced out and future room_join calls fail.
  • Low-priority/protected message types are reflected in chat_message.priority: low or protected.
  • Low-priority messages are actively degraded under load. After a room reaches chat.room_priority.low_drop.min_members, the server allows at most chat.room_priority.low_drop.per_room_per_second messages per room and message type per second. Excess low-priority messages are acknowledged with dropped=true, priority=dropped, and dropReason, and are not persisted, broadcast, routed, callbacked, or used for offline push.
  • Protected message types are never dropped by the low-priority policy.
  • Default server config: enabled=true, min_members=500, per_room_per_second=20.
  • Room attributes are limited to 100 keys.
  • Governance changes are pushed to online room users through chat_room_state_update.

6.2 Group chat

Send group chat:

{
  "seq": "18",
  "cmd": "chat_group_send",
  "data": {
    "groupID": 3000000001,
    "text": "please check",
    "mentionUserIDs": ["1002", "1003"]
  }
}

Directed group messages are delivered only to targetUserIDs and the sender. Group history, search results, and conversation summaries are visible only to those users:

{
  "seq": "19",
  "cmd": "chat_group_send",
  "data": {
    "groupID": 3000000001,
    "text": "only selected members can read this",
    "targetUserIDs": ["1002", "1003"]
  }
}

Group owners can send @all messages with mentionAll: true.

Marking a group conversation as read emits chat_group_read_receipt:

{
  "seq": "20",
  "cmd": "chat_group_mark_read",
  "data": {
    "groupID": 3000000001
  }
}

Group owners / admins can control whether invites sent by regular members require approval:

{
  "seq": "20-1",
  "cmd": "chat_group_invite_approval_set",
  "data": {
    "groupID": 3000000001,
    "memberInviteRequiresApproval": true
  }
}

Notes:

  • chat_group_create / chat_group_update also accept memberInviteRequiresApproval
  • false: regular member invites continue through the direct invite flow
  • true: invites sent by regular members require owner / admin approval
  • invites sent by owners / admins are not gated by this setting

6.3 DM chat

Send DM:

{
  "seq": "20",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "text": "hello"
  }
}

Load DM history:

{
  "seq": "21",
  "cmd": "chat_dm_history",
  "data": {
    "targetUserID": "1002",
    "limit": 20
  }
}

Cursor pagination:

{
  "seq": "22",
  "cmd": "chat_dm_history",
  "data": {
    "conversationID": "dm:1001:1002",
    "beforeID": 12345,
    "limit": 20
  }
}

Search DM:

{
  "seq": "23",
  "cmd": "chat_dm_search",
  "data": {
    "targetUserID": "1002",
    "keyword": "hello",
    "limit": 20
  }
}

Conversation list:

{
  "seq": "24",
  "cmd": "chat_dm_conversations",
  "data": {
    "limit": 50
  }
}

Filter any conversation list by labels with label or labels:

{
  "seq": "24-label",
  "cmd": "chat_dm_conversations",
  "data": {
    "label": "work",
    "limit": 50
  }
}

Generic conversation labels:

{ "seq": "label-set-1", "cmd": "chat_conversation_labels_set", "data": { "scene": "dm", "targetUserID": "1002", "labels": ["work", "vip"] } }
{ "seq": "label-list-1", "cmd": "chat_conversation_labels_list", "data": { "scene": "dm" } }
{ "seq": "label-query-1", "cmd": "chat_conversations_by_label", "data": { "scene": "dm", "labels": ["work"], "limit": 50 } }

Labels are per-user conversation state. Supported scenes are dm, room, and group. Setting labels to an empty array clears the labels for that conversation.

Mark DM read:

{
  "seq": "25",
  "cmd": "chat_dm_mark_read",
  "data": {
    "targetUserID": "1002"
  }
}

Remove a conversation from the recent list (WeChat-style “delete chat”, soft-remove):

{ "seq": "rm-1", "cmd": "chat_dm_conversation_remove", "data": { "targetUserID": "1002" } }
{ "seq": "rm-2", "cmd": "chat_room_conversation_remove", "data": { "roomID": 12 } }
{ "seq": "rm-3", "cmd": "chat_group_conversation_remove", "data": { "groupID": 66 } }

Semantics (identical for DM / room / group):

  • Soft remove: the conversation disappears from the recent list, but message history is fully preserved on the server.
  • Clears unread on remove: the read cursor is pushed to the latest message, so the unread badge is cleared.
  • Reappears on a new message: when the peer/group has a new message (or you send again in that conversation), it returns to the list with unread starting from 1.
  • Per user: only affects the caller (and all their online devices); the other side’s list is untouched and no messages are deleted. Removing a group conversation is not the same as leaving the group.
  • Removal is delivered via chat_dm_conversation_update / chat_room_conversation_update / chat_group_conversation_update (payload contains "removed": true) to all of the user’s online connections, hiding it across devices. For DM you may pass conversationID instead of targetUserID.

DM typing:

{
  "seq": "26",
  "cmd": "chat_dm_typing",
  "data": {
    "targetUserID": "1002",
    "isTyping": true
  }
}

Send a nudge / strong reminder:

{
  "seq": "27",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "segments": [
      {
        "type": "nudge",
        "text": "nudged you"
      }
    ]
  }
}

Send a gift:

{
  "seq": "28",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "segments": [
      {
        "type": "gift",
        "giftID": "rose",
        "key": "rose",
        "count": 1,
        "receiverID": "1002"
      }
    ]
  }
}

Room and group chats use the same segments shape:

  • room: chat_room_send
  • group: chat_group_send
  • DM: chat_dm_send

Gift fields:

  • type: fixed to gift
  • giftID: required gift ID; key with the same value is also accepted for compatibility
  • key: should match giftID so older clients can render a fallback
  • count: gift quantity; missing or non-positive values are normalized to 1; current maximum is 99
  • receiverID / receiverName: optional; recommended for DM receiver display and targeted room/group UI

The server validates the gift against the built-in catalog and fills these fields. Clients should not trust local price data:

  • giftName
  • giftIcon: built-in gifts now return /assets/gift-*.svg image paths; clients should still tolerate legacy emoji strings
  • giftAnimation
  • unitPrice
  • totalPrice
  • currencyType

Current built-in gift catalog:

giftIDNameIconSimulated unit priceAnimation
roseRose/assets/gift-rose.svg1 coinfloat
coffeeCoffee/assets/gift-coffee.svg3 coinpop
starStar/assets/gift-star.svg5 coinsparkle
heartHeart/assets/gift-heart.svg10 coinpulse
rocketRocket/assets/gift-rocket.svg20 coinlaunch
crownCrown/assets/gift-crown.svg50 coinshine

This version only displays simulated coins and does not deduct a real balance. If real wallet/payment support is added later, implement server-side balance check, debit transaction, and idempotent ledger records in the send path.

6.4 Message edit / recall / delete / reaction

DM edit:

{
  "seq": "30",
  "cmd": "chat_dm_edit",
  "data": {
    "targetUserID": "1002",
    "messageID": "msg-1",
    "text": "edited"
  }
}

DM recall:

{
  "seq": "31",
  "cmd": "chat_dm_recall",
  "data": {
    "conversationID": "dm:1001:1002",
    "messageID": "msg-1"
  }
}

DM delete:

{
  "seq": "32",
  "cmd": "chat_dm_delete",
  "data": {
    "conversationID": "dm:1001:1002",
    "messageID": "msg-1"
  }
}

DM reaction:

{
  "seq": "33",
  "cmd": "chat_dm_react",
  "data": {
    "targetUserID": "1002",
    "messageID": "msg-1",
    "emoji": "👍",
    "action": "toggle"
  }
}

Room commands are the same shape:

  • chat_room_edit
  • chat_room_recall
  • chat_room_delete
  • chat_room_react

Notes:

  • edit and recall are time-window limited on server side
  • delete is currently not time-window limited

6.5 Reply / quote / forward

Send with reference:

{
  "seq": "40",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "text": "reply message",
    "reference": {
      "type": "reply",
      "messageID": "msg-1"
    }
  }
}

Forward:

{
  "seq": "41",
  "cmd": "chat_room_send",
  "data": {
    "reference": {
      "type": "forward",
      "scene": "dm",
      "conversationID": "dm:1001:1002",
      "messageID": "msg-1"
    }
  }
}

6.6 Friend / block / DM policy

Create friend request:

{
  "seq": "50",
  "cmd": "chat_friend_add",
  "data": {
    "targetUserID": "1002",
    "requestMessage": "hi, let's connect"
  }
}

Friend request list:

{
  "seq": "51",
  "cmd": "chat_friend_request_list",
  "data": {
    "processedLimit": 20
  }
}

Accept / reject:

{
  "seq": "52",
  "cmd": "chat_friend_accept",
  "data": {
    "targetUserID": "1002"
  }
}
{
  "seq": "53",
  "cmd": "chat_friend_reject",
  "data": {
    "targetUserID": "1002"
  }
}

Friend list:

{
  "seq": "54",
  "cmd": "chat_friend_list",
  "data": {}
}

Set remark:

{
  "seq": "55",
  "cmd": "chat_friend_remark_set",
  "data": {
    "targetUserID": "1002",
    "remark": "Product"
  }
}

Block / unblock:

{
  "seq": "56",
  "cmd": "chat_block_add",
  "data": {
    "targetUserID": "1002"
  }
}
{
  "seq": "57",
  "cmd": "chat_block_remove",
  "data": {
    "targetUserID": "1002"
  }
}

DM whitelist add / remove / list:

{
  "seq": "58",
  "cmd": "chat_dm_whitelist_add",
  "data": {
    "targetUserID": "1002"
  }
}
{
  "seq": "59",
  "cmd": "chat_dm_whitelist_remove",
  "data": {
    "targetUserID": "1002"
  }
}
{
  "seq": "60",
  "cmd": "chat_dm_whitelist_list",
  "data": {}
}

DM policy:

{
  "seq": "61",
  "cmd": "chat_dm_policy_set",
  "data": {
    "policy": "white_list"
  }
}

Valid policy values:

  • all
  • friends_only
  • white_list
  • no_one

Notes:

  • white_list means only users in the current user’s DM whitelist can send DMs to that user.
  • Blocks take precedence over the whitelist. A DM is still rejected if either side blocks the other.

7. Push Events That Clients Must Handle

7.1 chat_message

Main incoming room / DM message event.

Payload shape:

  • messageID
  • scene
  • roomID
  • conversationID
  • targetUserID
  • senderID
  • senderName
  • senderAvatar
  • contentType
  • text
  • segments
  • reference
  • reactions
  • sentAt
  • mutation flags such as isEdited, isRecalled, isDeleted

Common segments types:

  • text
  • image
  • video
  • audio
  • file
  • emoji
  • nudge
  • gift

Example gift segment pushed by the server:

{
  "type": "gift",
  "giftID": "rose",
  "key": "rose",
  "giftName": "Rose",
  "giftIcon": "/assets/gift-rose.svg",
  "giftAnimation": "float",
  "count": 1,
  "unitPrice": 1,
  "totalPrice": 1,
  "currencyType": "coin",
  "receiverID": "1002"
}

Recommended client behavior:

  • render conversation preview as [Gift] Rose x1
  • render an in-bubble gift card with at least giftIcon, giftName, and count
  • when a gift arrives in the current conversation, or after the local send succeeds, play a non-blocking 1-2 second animation based on giftAnimation
  • fall back to a generic gift card / pop animation for unknown giftID or animation names
  • do not replay animations when rendering historical messages; only play them for realtime pushes or successful local sends

7.2 chat_typing

Payload:

  • scene
  • roomID
  • conversationID
  • targetUserID
  • userID
  • userName
  • avatar
  • isTyping
  • expiresAt

7.3 chat_status

Transient status event. It uses the same basic shape as chat_message, with:

  • contentType: status
  • statusType
  • statusData
  • expiresAt

Clients should treat it as temporary UI state and should not write it into local message history.

7.4 chat_presence_*

Presence subscriptions are per-user. A user receives chat_presence_update when a subscribed target user goes online or offline.

Subscribe / unsubscribe:

{ "seq": "presence-sub-1", "cmd": "chat_presence_subscribe", "data": { "targetUserIDs": ["1002", "1003"] } }
{ "seq": "presence-unsub-1", "cmd": "chat_presence_unsubscribe", "data": { "targetUserID": "1003" } }

List subscriptions and query current state:

{ "seq": "presence-list-1", "cmd": "chat_presence_subscriptions", "data": {} }
{ "seq": "presence-query-1", "cmd": "chat_presence_query", "data": { "userIDs": ["1002", "1003"] } }

chat_presence_update payload:

  • userID
  • online
  • updatedAt

7.5 chat_device_sync

Multi-device sync is per account. When one session changes local conversation state or mutates a message, other online sessions of the same user receive chat_device_sync.

Snapshot after reconnect:

{
  "seq": "device-sync-state-1",
  "cmd": "chat_device_sync_state",
  "data": {
    "scenes": ["dm", "room", "group"],
    "limit": 100
  }
}

chat_device_sync payload:

  • event: conversation_read, conversation_muted, conversation_pinned, conversation_labels, or message_mutation
  • scene
  • conversationID, roomID, groupID
  • sourceSessionID
  • conversation: latest conversation summary when the event changes conversation state
  • mutation: message mutation payload when event=message_mutation

7.6 chat_dm_conversation_update / chat_room_conversation_update / chat_group_conversation_update

Use these to update conversation list without polling. When the payload contains "removed": true, remove that conversation from the list (a WeChat-style soft remove; it returns on the next message).

7.7 chat_dm_read_receipt

Payload:

  • conversationID
  • readerUserID
  • readAt

7.7.1 chat_group_read_receipt

Payload:

  • scene: group
  • conversationID
  • groupID
  • readerUserID
  • lastReadMessageID
  • readAt

7.8 chat_dm_delivery_receipt

Payload:

  • conversationID
  • messageID
  • receiverUserID
  • deliveredAt

7.9 chat_receipts

Use this after reconnect to reload persisted message receipt state. Example:

{
  "cmd": "chat_receipts",
  "seq": "receipt-1",
  "data": {
    "scene": "dm",
    "conversationID": "dm:1001:1002",
    "messageIDs": ["msg-1", "msg-2"]
  }
}

Each response item contains:

  • messageID
  • deliveredCount
  • readCount
  • users: userID, deliveredAt, readAt

For scene=group, pass groupID; the server filters directed group messages that are not visible to the current user. For scene=room, only the currently joined room can be queried.

When chat_dm_mark_read or chat_group_mark_read succeeds, the server persists read receipts for messages up to lastReadMessageID in that conversation, capped by the server batch limit. This lets chat_receipts return read state for older messages after reconnect.

7.8 chat_message_mutation

Use this to patch messages after:

  • edit
  • recall
  • delete
  • reaction

7.9 chat_friend_request_update

Use this to update the friend request center in real time.

8. Media Upload Integration

8.1 Upload endpoint

POST /user/chat/upload
Content-Type: multipart/form-data
Authorization: Bearer <JWT>

Form fields:

  • scene: room / group / dm
  • roomID: required when scene=room
  • groupID: required when scene=group
  • conversationID: optional when scene=dm
  • targetUserID: optional fallback for scene=dm
  • type: image / video / audio / file
  • file: binary file

Example:

curl -H "Authorization: Bearer <JWT>" \
  -F "scene=dm" \
  -F "targetUserID=1002" \
  -F "type=image" \
  -F "file=@/tmp/demo.png" \
  http://127.0.0.1:8080/user/chat/upload

Response contains:

  • asset
  • segment

The client should put segment into the chat send request.

Recommended client rendering:

  • image: render inline thumbnail and open in an in-page viewer
  • video: render inline with <video controls> or a native player
  • audio: render with audio controls
  • file: render as a file card
  • pdf: uploaded as file, but should be previewed in-page when possible

Voice file notes:

  • Upload voice messages with type=audio
  • Common accepted extensions: mp3 / wav / aac / m4a / ogg / opus / webm
  • Web MediaRecorder usually produces audio/webm;codecs=opus
  • Some browsers label recorded audio as video/webm; if the upload type is audio and the extension is .webm, the server accepts it as a voice file

8.2 Access signed file URL

GET /user/chat/upload/access?scene=dm&conversationID=dm:1001:1002&objectKey=...
Authorization: Bearer <JWT>

If redirect=1 is set, server responds with 302 to OSS signed URL.

8.3 Voice messages / press-to-talk

Recommended client interaction:

  • Place a voice button in the composer
  • Start recording on pointerdown / long press
  • Stop recording and send immediately on pointerup
  • Cancel on pointercancel or permission failure
  • Web clients need HTTPS or localhost to request microphone permission

Send flow:

  1. Record audio on the client. Web clients can use navigator.mediaDevices.getUserMedia({ audio: true }) and MediaRecorder
  2. Create an audio file after recording, for example voice-20260512.webm
  3. Call POST /user/chat/upload with scene, roomID/groupID/conversationID/targetUserID, type=audio, and file
  4. Put the returned segment into chat_dm_send / chat_group_send / chat_room_send

DM voice message example:

{
  "seq": "audio-1",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "segments": [
      {
        "type": "audio",
        "url": "https://cdn.example.com/chat/dm/dm:1001:1002/audio/voice.webm",
        "objectKey": "chat/dm/dm:1001:1002/audio/voice.webm",
        "fileName": "voice.webm",
        "mimeType": "audio/webm",
        "durationMs": 8200
      }
    ]
  }
}

Clients should include durationMs in the segment so receivers can display the voice duration.

8.4 Chatroom broadcast messages

POST /user/chat/room/broadcast
Authorization: Bearer <admin token>
Content-Type: application/json
{
  "fromUserID": "system",
  "broadcastID": "room-broadcast-20260521-1",
  "objectName": "BALALA:ChatroomBroadcast",
  "content": "{\"content\":\"maintenance in 5 minutes\"}",
  "text": "maintenance in 5 minutes",
  "data": { "level": "notice" },
  "isIncludeSender": false
}

The server delivers chat_room_broadcast only to users currently joined to any chatroom. Users who join later do not receive it. Broadcast messages are not written to chatroom history, do not update conversation summaries, do not create unread counts, and do not trigger offline push. By default the sender’s online clients are excluded; pass isIncludeSender=true to include them. objectName defaults to BALALA:ChatroomBroadcast, and content is capped at 128 KB.

8.5 End-to-end encrypted messages

chat_dm_send, chat_group_send, and chat_room_send accept an encrypted payload. The server stores, routes, callbacks, and pushes the message as contentType=encrypted, but never decrypts the ciphertext.

{
  "seq": "enc-1",
  "cmd": "chat_dm_send",
  "data": {
    "targetUserID": "1002",
    "encrypted": {
      "algorithm": "x3dh-aes-gcm",
      "ciphertext": "BASE64_CIPHERTEXT",
      "keyID": "session-key-1",
      "senderKeyID": "device-key-1001",
      "recipientKeyIDs": ["device-key-1002"],
      "nonce": "BASE64_NONCE",
      "signature": "BASE64_SIGNATURE",
      "digest": "sha256:...",
      "version": 1
    }
  }
}

Encrypted sends must not include text, segments, or reference. Conversation summaries and offline push use the generic preview [加密消息]. Local text moderation is skipped because plaintext is unavailable; send permissions, rate limits, persistence, callbacks, routing, and delivery still apply.

8.6 Speech-to-text

POST /user/chat/audio/transcribe
Content-Type: application/json
Authorization: Bearer <JWT>

Request example:

{
  "scene": "dm",
  "conversationID": "dm:1001:1002",
  "messageID": "msg-1",
  "objectKey": "chat/dm/dm:1001:1002/audio/voice.webm",
  "language": "zh",
  "mimeType": "audio/webm",
  "fileName": "voice.webm",
  "durationMs": 8200
}

Fields:

  • scene: dm / group / room
  • conversationID or targetUserID: required for scene=dm
  • groupID: required for scene=group
  • roomID: required for scene=room
  • objectKey: audio object key returned by upload; it must point to a file under an /audio/ path
  • language: optional; pass the user’s current language or the expected speech language when available

Response example:

{
  "code": 0,
  "msg": "",
  "data": {
    "result": {
      "text": "Hello, let's meet later.",
      "language": "en",
      "provider": "webhook",
      "durationMs": 8200
    },
    "text": "Hello, let's meet later.",
    "language": "en",
    "provider": "webhook",
    "durationMs": 8200
  }
}

The server verifies that the JWT user can access the DM / group / room, generates a short-lived OSS signed URL, and calls the configured transcription provider. If speech.transcribe.enabled=true is not configured, the endpoint returns “语音转文字未启用”.

8.7 Message Callback Service

The server can call a pre-send message callback for external moderation, bots, or content rewriting. It runs for chat_dm_send / chat_group_send / chat_room_send after permission checks and local moderation, but before history persistence and delivery.

Config:

chat:
  message_callback:
    enabled: true
    url: "https://example.com/im/message-callback"
    app_key: "your-app-key"
    token: ""
    secret: ""
    timeout_seconds: 5
    fail_open: true

Request method: POST application/x-www-form-urlencoded. Key fields:

  • fromUserId
  • targetId
  • toUserIds
  • msgType
  • content: this project’s ChatMessage JSON
  • channelType: PERSON / GROUP / TEMPGROUP
  • messageId
  • msgTimeStamp

Allow:

{ "pass": 1 }

Block:

{ "pass": 0, "extra": "message rejected" }

Rewrite text:

{ "pass": 1, "replaceContent": "{\"content\":\"rewritten text\"}" }

The callback may also return { "allow": true, "text": "rewritten text" }. When secret is configured, the server sends X-Chat-Callback-Timestamp, X-Chat-Callback-Nonce, and X-Chat-Callback-Signature; the signature is HMAC-SHA256 over timestamp + "\n" + nonce + "\n" + body.

8.8 Full Message Route

The server can route a copy of every successfully sent chat_dm_send / chat_group_send / chat_room_send message to a business server asynchronously. Route failures do not affect persistence, realtime delivery, conversation updates, or offline push.

Config:

chat:
  message_route:
    enabled: true
    url: "https://example.com/im/message-route"
    app_key: "your-app-key"
    app_secret: "your-app-secret"
    token: ""
    timeout_seconds: 5

Request method: POST application/x-www-form-urlencoded. Key fields:

  • event: currently always message
  • scene: dm / group / room
  • fromUserId
  • targetId: DM target user ID, group ID, or chatroom room ID
  • toUserIds: group direct-message audience
  • msgType
  • content: this project’s ChatMessage JSON
  • channelType: PERSON / GROUP / TEMPGROUP
  • messageId / msgUID
  • msgTimeStamp
  • conversationID / roomID / groupID

When app_key / app_secret are configured, the server appends appKey, nonce, timestamp, and signature to the callback URL query. signature is sha1(app_secret + nonce + timestamp).

8.9 Full User Notification Service

The business server can send a system-conversation notification to all users through the admin HTTP API. Online users receive chat_system_notification; clients should render it as a non-replyable system conversation message.

POST /user/chat/notification/all
Authorization: Bearer <admin token>
Content-Type: application/json

Request example:

{
  "fromUserID": "system",
  "notificationID": "notice-20260521-1",
  "title": "Event reminder",
  "text": "The weekend event is starting soon.",
  "data": { "url": "https://example.com/events/1" },
  "push": true,
  "pushTitle": "Event reminder",
  "pushBody": "The weekend event is starting soon.",
  "ttlSeconds": 86400
}

Fields:

  • fromUserID: system conversation target ID, defaults to system
  • notificationID: generated when omitted; push tasks are deduped per user by this ID
  • objectName: defaults to BALALA:SystemNotification
  • title / text / content: at least one is required
  • push: when true, the server enqueues offline push tasks for users with active push devices
  • data: business extension fields

Tag user notification:

POST /user/chat/notification/tag
Authorization: Bearer <admin token>
Content-Type: application/json

Additional request fields:

  • tags: target user tags, for example ["vip", "me-region"]
  • matchAllTags: when true, users must have all tags; by default, any tag match is enough

Set user notification tags:

POST /user/chat/notification/tags
Authorization: Bearer <admin token>
Content-Type: application/json
{
  "userID": "1001",
  "tags": ["vip", "me-region"]
}

Query user notification tags:

GET /user/chat/notification/tags?userID=1001
Authorization: Bearer <admin token>

Online user notification:

POST /user/chat/notification/online
Authorization: Bearer <admin token>
Content-Type: application/json

The request fields are the same as full notification, but the server forces:

  • delivery only to currently online WebSocket users
  • no offline push task
  • chat_online_notification.landing=false

Clients should treat chat_online_notification as a transient notification and should not write it into local message history.

8.10 Message Log Download

Admins can download local IM message logs by hour. The response is a gzip-compressed JSON Lines file. Each line is one message and covers DM, group, and chatroom messages.

GET /user/chat/message-log?date=2026052117
Authorization: Bearer <admin token>

date must be YYYYMMDDHH, meaning the requested hour in the server’s local time zone. The response file is chat-message-log-<date>.jsonl.gz.

Each line contains:

  • scene: dm / group / room
  • conversationID
  • roomID / groupID
  • messageID
  • payload: original server-to-client envelope JSON
  • createdAt

chat_system_notification payload:

  • notificationID
  • fromUserID
  • scope: all
  • tags
  • objectName
  • title
  • text
  • content
  • data
  • landing: true
  • sentAt

9. Offline Push Device Registration

Native clients should register push token after successful login.

Register:

{
  "seq": "60",
  "cmd": "push_device_register",
  "data": {
    "deviceID": "ios-001",
    "platform": "ios",
    "pushToken": "device-token",
    "vendor": "apns",
    "appID": "com.demo.app"
  }
}

Unregister:

{
  "seq": "61",
  "cmd": "push_device_unregister",
  "data": {
    "deviceID": "ios-001"
  }
}

Suggested vendor values:

  • iOS: apns
  • Android Google: fcm
  • Android vendor push gateway: huawei, xiaomi, oppo, vivo, honor

10. API Rate Limit Plans

The server supports package-style API rate limits for both WebSocket commands and HTTP routes. When enabled, the same identity shares a token bucket for the same plan and endpoint. When exceeded:

  • WebSocket returns a normal response envelope with code=OperationFailure and msg=API call frequency exceeded
  • HTTP returns 429 Too Many Requests with plan, kind, name, perMinute, and burst
  • /system/state exposes total_api_rate_limited

Configuration example:

api_rate_limit:
  enabled: true
  active_plan: "ultimate"
  plans:
    default:
      per_minute: 120
      burst: 30
    premium:
      per_minute: 600
      burst: 120
    ultimate:
      per_minute: 3000
      burst: 600
  overrides:
    ws:
      chat_room_send:
        per_minute: 1200
        burst: 240
      chat_room_broadcast:
        per_minute: 60
        burst: 10
    http:
      "POST_/user/chat/room/broadcast":
        per_minute: 60
        burst: 10

Notes:

  • active_plan selects the service package; missing plans fall back to plans.default.
  • overrides.ws.<cmd> overrides a specific WebSocket command.
  • overrides.http.<METHOD>_<route> overrides a specific HTTP route, for example POST_/user/chat/room/broadcast.
  • Identity uses JWT user first, then admin token hash, then source IP.

11. Platform Integration Notes

11.1 Web

Browser example:

const token = "<JWT>";
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${window.location.host}/acc?token=${encodeURIComponent(token)}`);

ws.onopen = () => {
  ws.send(JSON.stringify({
    seq: "1",
    cmd: "login",
    data: {
      userID: "1001",
      userName: "alice",
      avatar: "https://cdn.example.com/a.png"
    }
  }));

  setInterval(() => {
    ws.send(JSON.stringify({
      seq: String(Date.now()),
      cmd: "heartbeat",
      data: {}
    }));
  }, 30000);
};

ws.onmessage = (event) => {
  const packet = JSON.parse(event.data);
  console.log(packet);
};

Notes:

  • Browser automatically sends Origin
  • Frontend origin must match server whitelist
  • Upload, image preview, and PDF preview should use current-site relative URLs instead of hard-coded 127.0.0.1

11.2 Android

OkHttp example:

val request = Request.Builder()
    .url("wss://im.example.com/acc?token=$jwt")
    .addHeader("Origin", "https://im.example.com")
    .build()

val listener = object : WebSocketListener() {
    override fun onOpen(webSocket: WebSocket, response: Response) {
        val login = """
            {
              "seq":"1",
              "cmd":"login",
              "data":{
                "userID":"1001",
                "userName":"alice"
              }
            }
        """.trimIndent()
        webSocket.send(login)
    }

    override fun onMessage(webSocket: WebSocket, text: String) {
        Log.d("IM", text)
    }
}

OkHttpClient().newWebSocket(request, listener)

Android recommendations:

  • heartbeat every 30s
  • reconnect with exponential backoff
  • cache unsent messages locally by seq
  • re-register push token after app reinstall or token rotation

11.3 iOS

URLSessionWebSocketTask example:

var request = URLRequest(url: URL(string: "wss://im.example.com/acc?token=\(jwt)")!)
request.setValue("https://im.example.com", forHTTPHeaderField: "Origin")

let task = URLSession.shared.webSocketTask(with: request)
task.resume()

let login = """
{
  "seq":"1",
  "cmd":"login",
  "data":{
    "userID":"1001",
    "userName":"alice"
  }
}
"""

task.send(.string(login)) { error in
    if let error = error {
        print("send error: \(error)")
    }
}

iOS recommendations:

  • if Origin cannot be controlled in your networking stack, set auth.allow_empty_origin=true
  • register APNs token with push_device_register
  • reconnect on app foreground

12. Error Codes

Common codes:

  • 200: success
  • 1000: not logged in
  • 1001: parameter illegal
  • 1002: unauthorized user id
  • 1003: unauthorized
  • 1004: server error
  • 1009: operation failure
  • 1010: routing not exist

13. Client Recommendations

13.1 Required client state

At minimum, keep these locally:

  • current JWT
  • current user id
  • conversation list cache
  • message list by conversation
  • message sending state by seq
  • upload asset metadata
  • last read cursor

13.2 Reconnect strategy

Recommended:

  1. reconnect with exponential backoff
  2. after reconnect, send login
  3. resume heartbeat
  4. reload conversation list
  5. reload incremental history by cursor if needed

13.3 Message deduplication

Use these keys:

  • request side: seq
  • message side: messageID

13.4 UI behavior

Recommended UI mapping:

  • chat_typing: transient typing indicator
  • chat_message_mutation: patch local message item
  • chat_dm_conversation_update / chat_room_conversation_update / chat_group_conversation_update: patch list item (or remove it when payload has "removed": true)
  • chat_dm_read_receipt: update read status
  • chat_group_read_receipt: update group read status
  • chat_dm_delivery_receipt: update delivered status
  • segments.image: render thumbnail + in-page image viewer
  • segments.video: inline playback
  • segments.file with mimeType=application/pdf: in-page PDF preview
  • segments.nudge: strong reminder, e.g. vibration, title flash, modal prompt
  • segments.gift: render a gift card; play a short giftAnimation only for realtime messages or successful local sends, not for historical replay

14. Minimal Integration Checklist

Shared

  • obtain JWT from business server
  • connect /acc with JWT
  • send login
  • start heartbeat
  • implement generic message envelope parsing
  • handle reconnect

Web

  • ensure browser origin is whitelisted
  • implement DM + room chat commands
  • implement upload flow

Android

  • set Origin if possible
  • register push token
  • reconnect on network change

iOS

  • set Origin if possible or allow empty origin on server
  • register APNs token
  • refresh UI from push events after reconnect