SDK · Dart/Flutter
Pure-Dart client SDK for the imcore IM server. WebSocket + JSON; works in
Flutter (iOS/Android/desktop), Dart CLI/server, and Dart web. Only runtime
dependency: web_socket_channel.
Versioning & compatibility: this SDK (current release v1.10.0) is versioned on its own line (
clients/SDK_VERSION), independent of the imcore server release tag. It targets the imcore wire protocol as of server v1.2.0; because the protocol is additive/forward-compatible, it works with any server ≥ v1.2.0.
Add it
# pubspec.yaml
dependencies:
imcore_sdk:
path: ../path/to/clients/dart/imcore_sdk # or a git/hosted ref
Usage
import 'package:imcore_sdk/imcore_sdk.dart';
final im = ImcoreClient(
url: 'wss://your-host/acc',
auth: AuthConfig(
token: '<jwt>', // or tokenProvider: () async => ...
login: LoginPayload(userId: '1001', userName: 'alice'),
),
// Optional weak-network controls (defaults shown):
connectTimeout: const Duration(seconds: 10),
heartbeatTimeout: const Duration(seconds: 75), // reset by any inbound frame
reconnect: const ReconnectConfig(jitter: true),
// heartbeatInterval / requestTimeout / clock / socketFactory are also optional.
// maxRequestBytes: 262144 — local pre-send size check → RequestError 1011; <=0 disables.
);
im.onStatus((s) => print('status $s')); // idle/connecting/open/authenticated/closed/reauthRequired
im.on('chat_message', (payload) => print('msg $payload'));
im.onReconnected(() {
// Optional: re-pull conversation lists + cursor history for stricter reconciliation.
});
// connect() resolves AFTER the server acknowledges login, and THROWS if login
// is refused (e.g. bad token) — always guard it.
try {
await im.connect();
} on RequestError catch (e) {
print('login failed: $e');
}
// Explicitly read the deployment's effective limits. When maxRequestBytes
// was omitted, this also updates the local pre-send frame guard.
final limits = await im.refreshMessageLimits();
print('request limit: ${limits.maxRequestBytes} bytes');
await im.dm.send(targetUserId: '1002', text: 'hi');
final queued = await im.dm.sendQueued(targetUserId: '1002', text: 'works offline');
print('${queued.status} ${queued.tempId}');
final history = await im.dm.history(targetUserId: '1002', limit: 20);
final page = await im.dm.historyPage(targetUserId: '1002', limit: 20);
// Older: historyPage(targetUserId: '1002', beforeId: page.nextCursor)
// Newer/reconnect: historyPage(targetUserId: '1002', afterId: page.prevCursor)
final cached = await im.cache.getMessages('dm:1001:1002');
await im.push.register(
deviceId: '<stable-device-id>',
platform: 'ios',
pushToken: '<apns-or-fcm-token>',
vendor: 'apns',
appId: '<bundle-id>',
);
// Escape hatch for any command not yet wrapped:
await im.request<Map<String, dynamic>>('chat_group_create', {'name': 'team'});
im.send('chat_dm_typing', {'targetUserID': '1002'});
refreshMessageLimits() calls authenticated GET /user/message-limits and
returns the message-limits.v1 snapshot. It is explicit rather than part of
connect() so Flutter and other runtimes can control HTTP availability. An
explicit maxRequestBytes value, including <= 0 to disable local checking,
always wins; failed HTTP, authentication, business-code, or schema responses
leave the previous local limit unchanged. Pass an injectable http.Client to
ImcoreClient when the platform requires a custom HTTP adapter.
Reconnect catch-up(重连补拉)
onReconnected fires on every successful reconnect (not the first
connect()). The SDK does not auto-reconcile state across a reconnect gap —
a typical handler re-pulls what changed while offline:
im.onReconnected(() async {
// 1. Re-pull conversation lists — labels/mute/unread may have changed while offline.
final dms = await im.dm.conversations();
final groups = await im.group.conversations();
final rooms = await im.room.conversations();
// 2. For conversations still open in the UI, backfill with the cursor you
// remembered from the last rendered page — afterId, not beforeId.
final page = await im.dm.historyPage(targetUserId: '1002', afterId: lastKnownCursor);
// 3. Multi-device: pull the cross-device conversation-state snapshot too.
await im.operations.deviceSyncState();
});
historyPage results do not get written into im.cache — reconciling
paged history against the local cache is the app’s responsibility.
Conversations you’ve been removed from (e.g. kicked from a group) are already
purged from the cache by the removed push as it arrives, and the server’s
own conversation list won’t include them again after reconnect either — there
is nothing left to reconcile for those.
Customer-service workspace
Customer-service commands have typed request/response DTOs under
im.customerService:
final workspace = await im.customerService.workspaceBootstrap();
final routing = await im.customerService.transfer(CSTransferRequestWire(
targetUserID: 'customer-1001',
toAgentUserID: 'agent-2002',
note: 'Billing specialist requested',
));
final profile = await im.customerService.profileGet(
CSProfileGetRequestWire(targetUserID: 'customer-1001'),
);
await im.customerService.noteAdd(
CSNoteAddRequestWire(targetUserID: 'customer-1001', body: 'Requested a refund'),
);
await im.customerService.claimGroup(
CSGroupClaimRequestWire(groupID: 7, customerUserID: 'customer-1001'),
);
final off = im.customerService.onDMRoutingChanged((event) {
print('routing changed ${event.conversation_id} ${event.routing_mode}');
});
off();
AI streaming (customer-service bots)
final handle = im.ai.ask(
targetUserId: 'bot:cs',
text: '退款怎么弄',
onDelta: (messageId, fullSoFar, chunk) => render(fullSoFar),
onDone: (messageId, fullText, createdAt) => finalize(fullText),
onError: (messageId, code, detail) => showError(code),
);
handle.cancel(); // 停本地聚合;不影响服务端生成
// 首个流帧拿到 messageId 后,才可请求服务端停止:
await im.ai.cancel(messageId);
final off = im.ai.onReply(
conversationId: 'dm:1001:bot:cs',
onDone: (m, full, _) => finalize(full),
);
off();
onDone 的 fullText 为权威全文;onDelta 用于渐进渲染;code 是服务端错误枚举。
ask() 具备断线恢复能力:重连后自动做一次历史核对,命中则从落库的最终
全文交付 onDone;若在空闲超时内始终没有匹配的流帧,则以
onError(code: 'stream_timeout') 结束,不再永久挂起。
ask() 使用流帧的 request_message_id 与问题消息精确关联;早于
chat_dm_send ACK 到达的帧会暂存,ACK 返回后只回放本请求帧。旧服务端缺少该字段时
仍兼容按首帧绑定。
final handle = im.ai.ask(
targetUserId: 'bot:cs',
text: '退款怎么弄',
idleTimeout: const Duration(seconds: 15),
onDone: (messageId, fullText, createdAt) => finalize(fullText),
onError: (messageId, code, detail) {
if (code == 'stream_timeout') return retry();
showError(code);
},
);
- 空闲超时:
ask()的idleTimeout覆盖ImcoreClient(...)构造时的aiStreamIdleTimeout;两者默认都是 30 秒,<= 0(或Duration.zero)关闭 计时器。超时内没有任何匹配的 delta/done/error 帧,即触发onError(messageId, 'stream_timeout', null)并自动解订阅。 - 重连恢复:bot 回复无条件落库,断线重连(
reconnected)时若本次ask()仍在途,会做一次范围历史读取;回复已生成则直接从历史里的全文 交付onDone(messageId, fullText, createdAt),未命中则重置空闲窗口继续等。 im.ai.onReply(...)(开放观察者)不带上述超时/恢复,只是被动订阅。handle.cancel()仅本地退订;im.ai.cancel(messageId)才请求服务端取消, 正常以onError(..., 'canceled', ...)交付终态。受理仅代表取消请求已记录; 若最终 DM 已进入落库边界,落库正文和onDone仍可能胜出。首帧前可把chat_dm_sendACK 中的问题消息 ID 传给im.ai.cancelRequest(requestMessageId), 取消排队中或尚未产生首帧的请求。
Relations (friends / blocks / whitelist)
await im.relations.friendAdd(targetUserId: '1002', requestMessage: '你好');
final list = await im.relations.friendList(); // {items, userIDs, count}
await im.relations.friendAccept('1002');
await im.relations.blockAdd('1003');
final off = im.relations.onFriendRequestUpdate((req) {
// req['direction'], req['status'], req['requesterUserID']
});
off();
Enterprise directory
await im.enterprise.createDepartment(name: 'Engineering', parentId: 'root');
final depts = await im.enterprise.listDepartments();
await im.enterprise.upsertMember(userId: '1002', departmentId: 'd1', title: 'Dev');
final members = await im.enterprise.listMembers(departmentId: 'd1', includeChildren: true);
final hits = await im.enterprise.searchMembers(keyword: '王');
Message lifecycle (edit / recall / delete, typing, receipts)
await im.dm.edit(targetUserId: '1002', messageId: 'm1', text: 'fixed typo');
await im.dm.recall(targetUserId: '1002', messageId: 'm1');
await im.group.delete(messageId: 'm2'); // active group
await im.dm.setTyping(targetUserId: '1002', isTyping: true);
final summaries = await im.group.receipts(groupId: 7, messageIds: ['m1']);
final off = im.dm.onTyping((t) { /* t['userID'], t['isTyping'] */ });
im.dm.onMutation((m) { /* m['operation']: edit|recall|delete, m['messageID'] */ });
im.dm.onReadReceipt((r) { /* r['readerUserID'], r['lastReadMessageID'] */ });
off();
group/room 的 edit/recall/delete/setTyping 作用于当前活跃会话(先 join);发“已读”用既有 markRead。
Message expansion KV (polls / sign-up chains / task status)
给任意消息附加可增改删的 Key/Value:u:<userID>: 前缀的 key 只有本人能写删(投票/接龙的防篡改槽位),其余 key 只有消息发送者能写删(任务状态等)。value 是字符串,结构化数据自行 JSON 编码。
// 投票:发一条"投票"消息,各人把票写进自己的私有槽位
final poll = await im.group.send(groupId: 7, text: '午饭吃什么?A 火锅 / B 烧烤');
await im.group.setExpansion(
messageId: poll['messageID'].toString(),
entries: {'u:$myUserId:vote': 'A'}, // 每人只能写自己的 u:<uid>:*
);
// 计票:客户端遍历 expansions 聚合(服务端不做统计)
im.group.onMutation((m) {
if (m['operation'] != 'expansion') return;
final expansions = (m['message'] as Map?)?['expansions'] as Map? ?? {};
final votes = expansions.entries
.where((e) => (e.key as String).endsWith(':vote'))
.map((e) => (e.value as Map)['value']);
// votes => ('A', 'B', 'A', ...)
});
// 改票 = 再 set(每 key last-write-wins);弃票 = 删除自己的 key
await im.group.setExpansion(messageId: id, entries: {'u:$myUserId:vote': 'B'});
await im.group.deleteExpansion(messageId: id, keys: ['u:$myUserId:vote']);
// 任务状态:公共 key(无 u: 前缀)仅消息发送者可写
await im.dm.setExpansion(targetUserId: '1002', messageId: id, entries: {'task.status': 'done'});
离线端上线后从历史里直接拿到最新 message['expansions'](无需补事件);单条消息默认上限 100 个 key、单次最多 20 个。
Group governance
Owner/admin-only calls to manage a group, plus directed-visibility send params.
// Governance (owner/admin)
await im.group.muteAll(groupId: 7, muted: true);
await im.group.setRole(groupId: 7, targetUserId: '1002', role: 'admin');
await im.group.transferOwner(groupId: 7, targetUserId: '1002');
await im.group.disband(groupId: 7);
// Join requests: apply to join, list pending requests, approve/reject
await im.group.apply(groupId: 7, message: '想加入');
final requests = await im.group.joinRequests();
// raw ChatGroupRequestList: incomingInvites/outgoingInvites/incomingApplies/outgoingApplies
await im.group.approveRequest(requestId: 123);
await im.group.rejectRequest(requestId: 123);
await im.group.setHistoryPolicy(groupId: 7, allowHistoryBeforeJoin: false);
// send()/sendQueued() also accept mention/target params:
await im.group.send(groupId: 7, text: '@all 通知', mentionAll: true);
await im.group.send(groupId: 7, text: '@bob', mentionUserIds: ['1002']);
await im.group.send(groupId: 7, text: '仅这几位可见', targetUserIds: ['1002', '1003']);
Realtime signaling (RTC)
im.rtc is a signaling-only wrapper around WebRTC call setup: typed send
methods (1-1 call invite / accept / reject / hangup / offer / answer / ICE,
plus multi-party mesh join / signal / leave) and typed observers for the
matching inbound pushes. The SDK never creates a peer connection or touches
media — pair it with a WebRTC plugin (e.g. flutter_webrtc) that you own;
the SDK only carries the signaling to/from your peer connection.
// Caller
final invite = await im.rtc.invite(targetUserId: '1002', callType: 'video');
final callId = invite['callID'] as String;
// Wire your own RTCPeerConnection's ICE candidates to sendIce, and its
// local offer to sendOffer:
await im.rtc.sendOffer(callId: callId, targetUserId: '1002', callType: 'video', sdp: offerSdp);
await im.rtc.sendIce(
callId: callId,
targetUserId: '1002',
candidate: candidateSdp,
sdpMid: sdpMid,
sdpMLineIndex: sdpMLineIndex,
);
// Callee / peer
im.rtc.onInvite((s) { /* ring UI: s['callID'], s['fromUserID'] */ });
im.rtc.onOffer((s) async {
// setRemoteDescription with s['sdp'], create your answer, then:
// await im.rtc.sendAnswer(callId: s['callID'], targetUserId: s['fromUserID'], callType: 'video', sdp: answerSdp);
});
im.rtc.onIceCandidate((s) { /* feed s['candidate']/s['sdpMid']/s['sdpMLineIndex'] into your peer connection */ });
await im.rtc.hangup(callId: callId, targetUserId: '1002', reason: 'normal');
Multi-party mesh (room/group) uses the same shape: im.rtc.multiJoin(...) /
im.rtc.multiSignal(...) / im.rtc.multiLeave(...) plus
im.rtc.onMultiState(...) / im.rtc.onMultiSignal(...) — each participant
still runs its own peer connection(s); the SDK only relays the mesh signaling.
Operations (the complete command surface)
im.operations wraps the final 26 registered server commands: conversation
removal/labels, DM policy and AI memory deletion, multi-device snapshots,
presence/status, room governance and compatibility room history.
await im.operations.setDmPolicy('friends_only');
await im.operations.subscribePresence(targetUserIds: ['1002', '1003']);
await im.operations.setConversationLabels(scene: 'dm', targetUserId: '1002', labels: ['vip']);
await im.operations.setRoomAttributes(7, {'topic': 'launch'});
await im.operations.setRoomMuteAll(7, true);
// Manual mark-unread (conversation UX trio, feature 1): flip a conversation's
// unread flag without a new message arriving.
await im.operations.markUnread(scene: 'dm', unread: true, targetUserId: '1002');
Drafts (cross-device compose sync)
im.drafts persists an in-progress compose (text/segments/reply reference)
per conversation server-side, so it follows the user across devices.
await im.drafts.set(scene: 'dm', targetUserId: '1002', text: '还没发送的草稿');
await im.drafts.clear(scene: 'dm', targetUserId: '1002');
Scheduled do-not-disturb (push quiet hours)
im.push.getDnd() / im.push.setDnd() wrap the server’s daily DND window for
push notifications. days is an opaque bitmask (bit N = day N); an end before
start (e.g. 22:00→08:00) means the window wraps past midnight.
final dnd = await im.push.getDnd();
await im.push.setDnd(DndSettings(
enabled: true,
startMinutes: 22 * 60, // 22:00
endMinutes: 8 * 60, // 08:00 next day
timezone: 'Asia/Shanghai',
days: 0x7f, // bitmask, all 7 days
));
Scope (v1)
Transport core + seq-correlated request/response + login-ack-gated auto-login +
typed DM/group/room/community send/receive/history + local message/conversation
cache + tempID-backed offline send queue + push device registration wrapper +
media upload (im.media).
Not included: E2EE, Flutter widgets.
Development
dart pub get
dart test # unit tests (fake socket + fake clock, no server needed)
dart analyze # lints + type check
# Integration test against a live server:
IMCORE_TEST_WS_URL=wss://host/acc IMCORE_TEST_WS_TOKEN=<jwt> \
IMCORE_TEST_USER_ID=1001 IMCORE_TEST_PEER_ID=1002 dart test test/integration_test.dart
Mint the test token with the repo’s token CLI (same HS256 signing path as the server):
go run ./cmd/demo_token -user 1001 # secret from $IMCORE_AUTH_JWT_SECRET or -secret; issuer/audience default to the demo config