multi-openim-channel
v0.2.0
Published
Multi-account / multi-server OpenIM channel plugin for OpenClaw. Pure IM transport (no business decision logic). Config-driven token refresh, optional friend-API guard, dedup + per-account exponential backoff on recovery.
Maintainers
Readme
multi-openim-channel
An OpenClaw gateway plugin for connecting to OpenIM servers
(via @openim/client-sdk) with multi-account / multi-server support,
config-driven token refresh, and an optional friend-API guard.
Highlights
- Multi-account, multi-server out of the box. Each
accounts.<id>has its ownwsAddr/apiAddr/token, so a single plugin instance can hold long-lived connections to several different OpenIM servers in parallel. - No silent
defaultaccount. The single-account fallback (top-leveltoken/wsAddr/apiAddron the channel block) is intentionally removed. Named accounts underaccounts.<id>are mandatory. - Host-canonical session keys. Direct + group inbounds always reuse
whatever
resolveAgentRoutereturns (which respectssession.dmScope,agents.bindings, andidentityLinks), so plugin / host store / startup-migration all agree on the same key string. Previously the plugin emitted a bare key (e.g.multi-openim:<peer>:primary) that the host's canonicalization pass rewrote with anagent:main:prefix, producing two stored sessions for the same logical conversation and visibly cross-talking across peers. Fixed in 0.1.3. The plugin still passespeerto the host so unresolved peers don't collapse ontoagent:main:main(the 0.1.2 fix). - Optional friend SDK guard. When
disableFriendSdkis true (the default), the OpenIM JS SDK's friend methods (addFriend/acceptFriendApplication/ ...) are stubbed with throwing functions that surface a grep-able error code (MULTI_OPENIM_FRIEND_API_DISABLED). Use this when an external system owns friend relationships; setdisableFriendSdk: falseto restore raw SDK behavior. - Config-driven HTTP token refresh. When the SDK emits
OnUserTokenExpired/OnUserTokenInvalid/OnConnectFailed/OnKickedOffline, the plugin performs an in-processfetchagainst an operator-supplied refresh endpoint, parses the response by configurable dot-paths, optionally writes fields back into a sidecar JSON state file, and patchesopenclaw.jsonso the new token survives a restart. The channel does not embed any backend-specific strings — every backend detail is operator input. No subprocesses, no bridge plugin. AglobalThis.__multiOpenimTokenRefresherhook is still supported for power users who need imperative recovery. On terminal failure a per-account manual-login marker JSON is written. - Strict accountId in tools. MCP tools (
multi_openim_send_text/multi_openim_send_image/multi_openim_send_video/multi_openim_send_file) requireaccountId; no "first connected wins" surprises. - Per-channel health-monitor interval.
healthCheckIntervalMinutes(default 30) overrides the gateway global without an unrelatedgateway.channelHealthCheckMinutesknob.
Install
# from a local checkout (recommended while iterating):
openclaw plugins install /path/to/multi-openim-channel
# from the npm registry (when published):
openclaw plugins install multi-openim-channelAfter install:
openclaw multi-openim setup # interactive (writes channels.multi-openim.accounts.primary)
openclaw gateway restart # load the channel
openclaw plugins inspect multi-openim-channel --jsonIdentity mapping
- npm package:
multi-openim-channel - plugin id:
multi-openim-channel - channel id:
multi-openim(used inopenclaw.jsonunderchannels.multi-openim) - setup command:
openclaw multi-openim setup [--account <id>] - MCP tools:
multi_openim_send_text,multi_openim_send_image,multi_openim_send_video,multi_openim_send_file
Configuration (~/.openclaw/openclaw.json)
{
"channels": {
"multi-openim": {
"enabled": true,
"healthCheckIntervalMinutes": 30,
"disableFriendSdk": true,
"tokenRefresh": {
"mode": "http",
"http": {
"stateFile": "/abs/path/to/tokens.json",
"endpoint": "https://auth.example.com/refresh",
"headers": { "content-type": "application/json" },
"body": { "refreshToken": "{state.refresh_token}" },
"responseTokenPath": "token"
}
},
"accounts": {
"primary": {
"enabled": true,
"token": "<JWT for OpenIM server A>",
"wsAddr": "ws://im-a.example.com:10001",
"apiAddr": "http://im-a.example.com:10002"
},
"team-b": {
"enabled": true,
"token": "<JWT for OpenIM server B>",
"wsAddr": "ws://im-b.example.com:10001",
"apiAddr": "http://im-b.example.com:10002",
"requireMention": true,
"inboundWhitelist": []
}
}
}
}
}userID / platformID are optional and auto-derived from JWT UserID /
PlatformID claims when omitted.
See SCHEMA.md for the full field reference.
Token refresh
Three modes, selected by channels.multi-openim.tokenRefresh.mode:
| Mode | Behavior |
|---|---|
| http (recommended) | Pure in-process fetch driven by tokenRefresh.http. Reads optional context from a stateFile, substitutes {accountId} / {state.<field>} placeholders into the configured endpoint / headers / body, posts the request, extracts the new token by dot-path, optionally writes-back response fields into the state file, and patches openclaw.json. |
| hook (default for back-compat) | Call globalThis.__multiOpenimTokenRefresher(accountId, reason). If unset, or it returns null / throws, write the manual-login marker. |
| off | Don't try to recover — write the manual-login marker immediately. |
Manual-login marker default path template:
~/.openclaw/multi-openim/manual-login.json. The configured value is
treated as a template — the accountId is inserted before the extension,
so the actual files written are
~/.openclaw/multi-openim/manual-login.<accountId>.json. This guarantees
N accounts produce N independent markers instead of silently overwriting
each other. Override via tokenRefresh.manualLoginMarkerPath.
tokenRefresh.http (config-driven HTTP refresh)
The channel does not know your auth backend. Every backend-specific
string — the URL, the request body shape, the JSON paths to find the new
token — is configuration. {accountId} and {state.<field>} are
substituted into string templates (URL, header values, body string leaves,
clearOnSuccess paths). state.<field> reads stateFile[accountId][field].
Minimal example, against an auth server that exposes POST /refresh with
a {refreshToken} body and a {token: ...} response:
{
"channels": {
"multi-openim": {
"tokenRefresh": {
"mode": "http",
"http": {
"stateFile": "/abs/path/to/tokens.json",
"endpoint": "https://auth.example.com/refresh",
"method": "POST",
"headers": { "content-type": "application/json" },
"body": { "refreshToken": "{state.refresh_token}" },
"responseTokenPath": "token",
"stateWriteBack": { "refresh_token": "refresh_token" }
}
}
}
}
}The expected tokens.json shape is keyed by accountId:
{
"primary": { "refresh_token": "..." },
"team-b": { "refresh_token": "..." }
}Multi-server example (different auth backend per account, response has
the new token under tokens.openimToken with a data.openimToken
fallback, and three response fields are persisted back into state):
{
"tokenRefresh": {
"mode": "http",
"http": {
"stateFile": "/abs/path/to/tokens.json",
"endpoint": "{state.auth_server_url}/api/refresh",
"method": "POST",
"headers": { "content-type": "application/json" },
"body": { "refreshToken": "{state.refresh_token}" },
"timeoutMs": 15000,
"responseTokenPath": ["tokens.openimToken", "data.openimToken"],
"stateWriteBack": {
"openim_token": ["tokens.openimToken", "data.openimToken"],
"access_token": ["tokens.accessToken", "data.accessToken"],
"refresh_token": ["tokens.refreshToken", "data.refreshToken"]
},
"clearOnSuccess": ["/abs/path/to/manual-login.{accountId}.json"]
}
}
}On a successful refresh the new token is also written to
channels.multi-openim.accounts.<accountId>.token in
~/.openclaw/openclaw.json (or $OPENCLAW_CONFIG_PATH) so it survives a
gateway restart.
globalThis.__multiOpenimTokenRefresher (programmatic hook)
Available for mode: "hook". Use this when the refresh logic is too
imperative for HTTP-template config:
globalThis.__multiOpenimTokenRefresher = async (accountId, reason) => {
// reason looks like "OnUserTokenExpired" or "OnConnectFailed errCode=10001"
const newToken = await yourTokenStore.refresh(accountId, reason);
if (!newToken) return null;
return { token: newToken }; // optionally include `userID` if your store rotates identity
};Friend-guard (default ON)
MULTI_OPENIM_FRIEND_API_DISABLED: SDK addFriend() is disabled by multi-openim-channel
(account=primary). Route friend operations through your own backend.The following SDK methods are replaced with throwing stubs on every
connected account: addFriend, addFriendV2, applyFriend,
acceptFriendApplication, refuseFriendApplication, deleteFriend,
checkFriend, setFriendRemark, addBlack, removeBlack,
getFriendApplicationListAsApplicant,
getFriendApplicationListAsRecipient, getFriendApplicationList,
getRecvFriendApplicationList, getSendFriendApplicationList,
getFriendList.
Set disableFriendSdk: false on the channel block (or per account) to
restore raw SDK behavior. Recommended when an external system is the
authoritative store for friend relationships and you want a single source
of truth.
Development
npm install
npm run build
npm run validate # static checks on dist + manifest
npm run smoke # optional live SDK login test (needs .env)For npm run smoke, copy .env.example to .env and fill in test
credentials.
License
MIT. See LICENSE.
