multi-openim-channel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Multi-OpenIM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # multi-openim-channel
2
+
3
+ An OpenClaw gateway plugin for connecting to **OpenIM** servers
4
+ (via `@openim/client-sdk`) with multi-account / multi-server support,
5
+ config-driven token refresh, and an optional friend-API guard.
6
+
7
+ ## Highlights
8
+
9
+ - **Multi-account, multi-server out of the box.** Each `accounts.<id>` has its
10
+ own `wsAddr` / `apiAddr` / `token`, so a single plugin instance can hold
11
+ long-lived connections to several different OpenIM servers in parallel.
12
+ - **No silent `default` account.** The single-account fallback (top-level
13
+ `token` / `wsAddr` / `apiAddr` on the channel block) is intentionally
14
+ removed. Named accounts under `accounts.<id>` are mandatory.
15
+ - **Per-account session keys.** Direct-chat session keys are always
16
+ `multi-openim:<sendID>:<accountId>` (lower-cased), so two accounts in
17
+ the same conversation with the same peer never collide.
18
+ - **Optional friend SDK guard.** When `disableFriendSdk` is true (the
19
+ default), the OpenIM JS SDK's friend methods (`addFriend` /
20
+ `acceptFriendApplication` / ...) are stubbed with throwing functions
21
+ that surface a grep-able error code (`MULTI_OPENIM_FRIEND_API_DISABLED`).
22
+ Use this when an external system owns friend relationships; set
23
+ `disableFriendSdk: false` to restore raw SDK behavior.
24
+ - **Config-driven HTTP token refresh.** When the SDK emits
25
+ `OnUserTokenExpired` / `OnUserTokenInvalid` / `OnConnectFailed` /
26
+ `OnKickedOffline`, the plugin performs an in-process `fetch` against an
27
+ operator-supplied refresh endpoint, parses the response by configurable
28
+ dot-paths, optionally writes fields back into a sidecar JSON state file,
29
+ and patches `openclaw.json` so the new token survives a restart. The
30
+ channel does not embed any backend-specific strings — every backend
31
+ detail is operator input. No subprocesses, no bridge plugin. A
32
+ `globalThis.__multiOpenimTokenRefresher` hook is still supported for
33
+ power users who need imperative recovery. On terminal failure a
34
+ per-account manual-login marker JSON is written.
35
+ - **Strict accountId in tools.** MCP tools (`multi_openim_send_text` /
36
+ `multi_openim_send_image` / `multi_openim_send_video` /
37
+ `multi_openim_send_file`) require `accountId`; no "first connected wins"
38
+ surprises.
39
+ - **Per-channel health-monitor interval.** `healthCheckIntervalMinutes`
40
+ (default 30) overrides the gateway global without an unrelated
41
+ `gateway.channelHealthCheckMinutes` knob.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ # from a local checkout (recommended while iterating):
47
+ openclaw plugins install /path/to/multi-openim-channel
48
+
49
+ # from the npm registry (when published):
50
+ openclaw plugins install multi-openim-channel
51
+ ```
52
+
53
+ After install:
54
+
55
+ ```bash
56
+ openclaw multi-openim setup # interactive (writes channels.multi-openim.accounts.primary)
57
+ openclaw gateway restart # load the channel
58
+ openclaw plugins inspect multi-openim-channel --json
59
+ ```
60
+
61
+ ## Identity mapping
62
+
63
+ - **npm package**: `multi-openim-channel`
64
+ - **plugin id**: `multi-openim-channel`
65
+ - **channel id**: `multi-openim` (used in `openclaw.json` under `channels.multi-openim`)
66
+ - **setup command**: `openclaw multi-openim setup [--account <id>]`
67
+ - **MCP tools**: `multi_openim_send_text`, `multi_openim_send_image`,
68
+ `multi_openim_send_video`, `multi_openim_send_file`
69
+
70
+ ## Configuration (`~/.openclaw/openclaw.json`)
71
+
72
+ ```json
73
+ {
74
+ "channels": {
75
+ "multi-openim": {
76
+ "enabled": true,
77
+ "healthCheckIntervalMinutes": 30,
78
+ "disableFriendSdk": true,
79
+ "tokenRefresh": {
80
+ "mode": "http",
81
+ "http": {
82
+ "stateFile": "/abs/path/to/tokens.json",
83
+ "endpoint": "https://auth.example.com/refresh",
84
+ "headers": { "content-type": "application/json" },
85
+ "body": { "refreshToken": "{state.refresh_token}" },
86
+ "responseTokenPath": "token"
87
+ }
88
+ },
89
+ "accounts": {
90
+ "primary": {
91
+ "enabled": true,
92
+ "token": "<JWT for OpenIM server A>",
93
+ "wsAddr": "ws://im-a.example.com:10001",
94
+ "apiAddr": "http://im-a.example.com:10002"
95
+ },
96
+ "team-b": {
97
+ "enabled": true,
98
+ "token": "<JWT for OpenIM server B>",
99
+ "wsAddr": "ws://im-b.example.com:10001",
100
+ "apiAddr": "http://im-b.example.com:10002",
101
+ "requireMention": true,
102
+ "inboundWhitelist": []
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ `userID` / `platformID` are optional and auto-derived from JWT `UserID` /
111
+ `PlatformID` claims when omitted.
112
+
113
+ See [SCHEMA.md](./SCHEMA.md) for the full field reference.
114
+
115
+ ## Token refresh
116
+
117
+ Three modes, selected by `channels.multi-openim.tokenRefresh.mode`:
118
+
119
+ | Mode | Behavior |
120
+ |---|---|
121
+ | `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`. |
122
+ | `hook` *(default for back-compat)* | Call `globalThis.__multiOpenimTokenRefresher(accountId, reason)`. If unset, or it returns null / throws, write the manual-login marker. |
123
+ | `off` | Don't try to recover — write the manual-login marker immediately. |
124
+
125
+ Manual-login marker default path template:
126
+ `~/.openclaw/multi-openim/manual-login.json`. The configured value is
127
+ treated as a template — the accountId is inserted before the extension,
128
+ so the actual files written are
129
+ `~/.openclaw/multi-openim/manual-login.<accountId>.json`. This guarantees
130
+ N accounts produce N independent markers instead of silently overwriting
131
+ each other. Override via `tokenRefresh.manualLoginMarkerPath`.
132
+
133
+ ### `tokenRefresh.http` (config-driven HTTP refresh)
134
+
135
+ The channel does not know your auth backend. Every backend-specific
136
+ string — the URL, the request body shape, the JSON paths to find the new
137
+ token — is configuration. `{accountId}` and `{state.<field>}` are
138
+ substituted into string templates (URL, header values, body string leaves,
139
+ `clearOnSuccess` paths). `state.<field>` reads `stateFile[accountId][field]`.
140
+
141
+ Minimal example, against an auth server that exposes `POST /refresh` with
142
+ a `{refreshToken}` body and a `{token: ...}` response:
143
+
144
+ ```jsonc
145
+ {
146
+ "channels": {
147
+ "multi-openim": {
148
+ "tokenRefresh": {
149
+ "mode": "http",
150
+ "http": {
151
+ "stateFile": "/abs/path/to/tokens.json",
152
+ "endpoint": "https://auth.example.com/refresh",
153
+ "method": "POST",
154
+ "headers": { "content-type": "application/json" },
155
+ "body": { "refreshToken": "{state.refresh_token}" },
156
+ "responseTokenPath": "token",
157
+ "stateWriteBack": { "refresh_token": "refresh_token" }
158
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ The expected `tokens.json` shape is keyed by `accountId`:
166
+
167
+ ```json
168
+ {
169
+ "primary": { "refresh_token": "..." },
170
+ "team-b": { "refresh_token": "..." }
171
+ }
172
+ ```
173
+
174
+ Multi-server example (different auth backend per account, response has
175
+ the new token under `tokens.openimToken` with a `data.openimToken`
176
+ fallback, and three response fields are persisted back into state):
177
+
178
+ ```jsonc
179
+ {
180
+ "tokenRefresh": {
181
+ "mode": "http",
182
+ "http": {
183
+ "stateFile": "/abs/path/to/tokens.json",
184
+ "endpoint": "{state.auth_server_url}/api/refresh",
185
+ "method": "POST",
186
+ "headers": { "content-type": "application/json" },
187
+ "body": { "refreshToken": "{state.refresh_token}" },
188
+ "timeoutMs": 15000,
189
+ "responseTokenPath": ["tokens.openimToken", "data.openimToken"],
190
+ "stateWriteBack": {
191
+ "openim_token": ["tokens.openimToken", "data.openimToken"],
192
+ "access_token": ["tokens.accessToken", "data.accessToken"],
193
+ "refresh_token": ["tokens.refreshToken", "data.refreshToken"]
194
+ },
195
+ "clearOnSuccess": ["/abs/path/to/manual-login.{accountId}.json"]
196
+ }
197
+ }
198
+ }
199
+ ```
200
+
201
+ On a successful refresh the new token is also written to
202
+ `channels.multi-openim.accounts.<accountId>.token` in
203
+ `~/.openclaw/openclaw.json` (or `$OPENCLAW_CONFIG_PATH`) so it survives a
204
+ gateway restart.
205
+
206
+ ### `globalThis.__multiOpenimTokenRefresher` (programmatic hook)
207
+
208
+ Available for `mode: "hook"`. Use this when the refresh logic is too
209
+ imperative for HTTP-template config:
210
+
211
+ ```ts
212
+ globalThis.__multiOpenimTokenRefresher = async (accountId, reason) => {
213
+ // reason looks like "OnUserTokenExpired" or "OnConnectFailed errCode=10001"
214
+ const newToken = await yourTokenStore.refresh(accountId, reason);
215
+ if (!newToken) return null;
216
+ return { token: newToken }; // optionally include `userID` if your store rotates identity
217
+ };
218
+ ```
219
+
220
+ ## Friend-guard (default ON)
221
+
222
+ ```text
223
+ MULTI_OPENIM_FRIEND_API_DISABLED: SDK addFriend() is disabled by multi-openim-channel
224
+ (account=primary). Route friend operations through your own backend.
225
+ ```
226
+
227
+ The following SDK methods are replaced with throwing stubs on every
228
+ connected account: `addFriend`, `addFriendV2`, `applyFriend`,
229
+ `acceptFriendApplication`, `refuseFriendApplication`, `deleteFriend`,
230
+ `checkFriend`, `setFriendRemark`, `addBlack`, `removeBlack`,
231
+ `getFriendApplicationListAsApplicant`,
232
+ `getFriendApplicationListAsRecipient`, `getFriendApplicationList`,
233
+ `getRecvFriendApplicationList`, `getSendFriendApplicationList`,
234
+ `getFriendList`.
235
+
236
+ Set `disableFriendSdk: false` on the channel block (or per account) to
237
+ restore raw SDK behavior. Recommended when an external system is the
238
+ authoritative store for friend relationships and you want a single source
239
+ of truth.
240
+
241
+ ## Development
242
+
243
+ ```bash
244
+ npm install
245
+ npm run build
246
+ npm run validate # static checks on dist + manifest
247
+ npm run smoke # optional live SDK login test (needs .env)
248
+ ```
249
+
250
+ For `npm run smoke`, copy `.env.example` to `.env` and fill in test
251
+ credentials.
252
+
253
+ ## License
254
+
255
+ MIT. See [LICENSE](./LICENSE).
package/SCHEMA.md ADDED
@@ -0,0 +1,167 @@
1
+ # Configuration Schema
2
+
3
+ Lives under `channels.multi-openim` in `~/.openclaw/openclaw.json`.
4
+
5
+ ## Channel block
6
+
7
+ ```jsonc
8
+ {
9
+ "channels": {
10
+ "multi-openim": {
11
+ "enabled": true, // optional, default true
12
+ "healthCheckIntervalMinutes": 30, // optional, default 30; min 1
13
+ "disableFriendSdk": true, // optional, default true
14
+ "tokenRefresh": { ... }, // optional
15
+ "accounts": { ... } // REQUIRED; must contain ≥ 1 named entry
16
+ }
17
+ }
18
+ }
19
+ ```
20
+
21
+ The plugin **rejects** the single-account fallback. Any of
22
+ `token` / `wsAddr` / `apiAddr` / `userID` / `platformID` placed at the
23
+ channel level (next to `accounts`) will be logged as a warning and ignored.
24
+
25
+ ### `tokenRefresh`
26
+
27
+ ```jsonc
28
+ {
29
+ "tokenRefresh": {
30
+ "mode": "http", // "http" | "hook" | "off"
31
+ "manualLoginMarkerPath": "/abs/path.json", // optional, template — <accountId> is inserted before the extension, so the actual path is /abs/path.<accountId>.json. Default: ~/.openclaw/multi-openim/manual-login.json → manual-login.<accountId>.json
32
+ "http": { ... } // required when mode="http"; see below
33
+ }
34
+ }
35
+ ```
36
+
37
+ | Mode | Behavior |
38
+ |---|---|
39
+ | `http` *(recommended)* | Pure in-process `fetch` driven by `tokenRefresh.http`. See "`tokenRefresh.http`" below. |
40
+ | `hook` *(default for back-compat)* | Call `globalThis.__multiOpenimTokenRefresher(accountId, reason)`. If unset, or it returns null/throws, write the manual-login marker. |
41
+ | `off` | Don't try to recover; write the manual-login marker immediately. |
42
+
43
+ The plugin never spawns subprocesses; both `http` and `hook` are pure JS.
44
+ The `globalThis.__multiOpenimTokenRefresher(accountId, reason) => Promise<{token, userID?} | null>`
45
+ hook is supported when imperative recovery is needed.
46
+
47
+ ### `tokenRefresh.http`
48
+
49
+ Fully config-driven. The channel knows nothing about your auth backend's
50
+ domain model — every backend-specific string is provided here. Templates
51
+ support these placeholders:
52
+
53
+ - `{accountId}` → the account being refreshed
54
+ - `{state.<field>}` → `stateFile[accountId][field]`, coerced to string
55
+
56
+ ```jsonc
57
+ {
58
+ "tokenRefresh": {
59
+ "mode": "http",
60
+ "http": {
61
+ "stateFile": "/abs/path/to/tokens.json", // optional; JSON keyed by accountId
62
+ "endpoint": "{state.server_url}/auth/refresh", // REQUIRED
63
+ "method": "POST", // default POST
64
+ "headers": { "content-type": "application/json" },
65
+ "body": { "refreshToken": "{state.refresh_token}" }, // object → JSON; string → verbatim
66
+ "timeoutMs": 15000, // default 15000
67
+
68
+ // REQUIRED — first non-empty wins
69
+ "responseTokenPath": ["tokens.openimToken", "data.openimToken"],
70
+
71
+ // optional userID rotation
72
+ "responseUserIdPath": "tokens.userId",
73
+
74
+ // optional: persist response fields back into stateFile
75
+ "stateWriteBack": {
76
+ "refresh_token": "tokens.refreshToken",
77
+ "access_token": "tokens.accessToken"
78
+ },
79
+
80
+ // optional: extra paths to delete on success (supports {accountId})
81
+ "clearOnSuccess": ["/abs/path/manual-login.{accountId}.json"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ Behavior:
88
+
89
+ 1. Read `stateFile[accountId]` if `stateFile` is set.
90
+ 2. Substitute `{accountId}` and `{state.<field>}` placeholders into
91
+ `endpoint`, header values, `body` (recursively into string leaves of
92
+ object bodies), and `clearOnSuccess` items.
93
+ 3. `fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) })`.
94
+ 4. Treat any non-2xx as failure (logged with response body truncated to
95
+ 300 chars).
96
+ 5. Parse response as JSON; extract `responseTokenPath` (and optionally
97
+ `responseUserIdPath`) by dot-path. First non-empty path wins.
98
+ 6. If `stateWriteBack` is set and any field resolved, merge them into
99
+ `stateFile[accountId]` and write the file back atomically.
100
+ 7. Patch `~/.openclaw/openclaw.json` (or `$OPENCLAW_CONFIG_PATH`)
101
+ `channels.multi-openim.accounts.<accountId>.token` so the new JWT
102
+ survives a gateway restart.
103
+ 8. `unlink` each `clearOnSuccess` entry (best-effort; "missing" is fine).
104
+ 9. Return the new token to the recovery loop, which logs the SDK out and
105
+ back in.
106
+
107
+ When `mode: "http"` but `tokenRefresh.http.endpoint` or
108
+ `responseTokenPath` is missing, the plugin logs a warning at startup and
109
+ downgrades the mode to `off` (so you get a clear manual-login marker
110
+ rather than silent retries).
111
+
112
+ ## Account block
113
+
114
+ ```jsonc
115
+ {
116
+ "accounts": {
117
+ "<accountId>": {
118
+ "enabled": true, // optional, default true
119
+ "token": "<JWT>", // REQUIRED — OpenIM JWT
120
+ "wsAddr": "ws://host:10001", // REQUIRED — OpenIM long-poll endpoint
121
+ "apiAddr": "http://host:10002", // REQUIRED — OpenIM REST endpoint
122
+ "userID": "<openim user id>", // optional — auto-derived from JWT "UserID" claim
123
+ "platformID": 5, // optional — auto-derived from JWT "PlatformID" or defaults to 5
124
+ "requireMention": true, // optional, default true (in groups, reply only if @-mentioned)
125
+ "inboundWhitelist": [], // optional, default [] (when non-empty, ONLY listed userIDs trigger)
126
+ "disableFriendSdk": true // optional, overrides channel-level
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ Failure modes (logged + skipped on startup):
133
+
134
+ | Missing | Plugin behavior |
135
+ |---|---|
136
+ | `token` / `wsAddr` / `apiAddr` | Skip the account; log a warning. Other accounts continue. |
137
+ | `userID` (and no JWT `UserID` claim) | Skip the account; log a warning. |
138
+
139
+ The accountId you pick is significant — it shows up as the suffix of
140
+ every direct session key (`multi-openim:<sendID>:<accountId>`), in MCP
141
+ tool calls, in log lines, and in the manual-login marker. Pick something
142
+ stable and short: `primary`, `team-b`, `support`, etc. **Do not name it
143
+ `default`** unless you truly mean it.
144
+
145
+ ## Status reporting
146
+
147
+ Each account reports lifecycle status back to the gateway via OpenClaw's
148
+ `setStatus` callback. Snapshot shape:
149
+
150
+ ```ts
151
+ {
152
+ accountId: string;
153
+ enabled: boolean;
154
+ configured: true;
155
+ running: boolean;
156
+ connected: boolean;
157
+ lastConnectedAt?: number; // ms epoch
158
+ lastError?: string | null; // truncated to 500 chars
159
+ }
160
+ ```
161
+
162
+ ## Environment overrides
163
+
164
+ The plugin does not read OpenIM-style `OPENIM_TOKEN` / `OPENIM_WS_ADDR`
165
+ env vars at runtime. The smoke test env vars (`MULTI_OPENIM_TEST_*`, see
166
+ `.env.example`) are only used by `npm run smoke`. Production accounts
167
+ must be declared in `openclaw.json`.
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Channel descriptor factory. The descriptor closes over the active
3
+ * PluginContext, so a hypothetical re-register cleanly produces a fresh
4
+ * descriptor instead of sharing mutable state with the previous one.
5
+ */
6
+ import type { PluginContext } from "./context.js";
7
+ import type { AccountConfig, GatewayStartAccountCtx, GatewayStopAccountCtx } from "./types.js";
8
+ export interface OutboundSendTextArgs {
9
+ to: string;
10
+ text: string;
11
+ accountId?: string;
12
+ }
13
+ export interface OutboundResult {
14
+ ok: boolean;
15
+ provider?: string;
16
+ error?: Error;
17
+ }
18
+ export declare function createChannelPlugin(ctx: PluginContext): {
19
+ id: "multi-openim";
20
+ meta: {
21
+ id: "multi-openim";
22
+ label: string;
23
+ selectionLabel: string;
24
+ docsPath: string;
25
+ blurb: string;
26
+ aliases: string[];
27
+ };
28
+ capabilities: {
29
+ chatTypes: readonly ["direct", "group"];
30
+ };
31
+ config: {
32
+ listAccountIds: (cfg: unknown) => string[];
33
+ resolveAccount: (cfg: unknown, accountId?: string) => {
34
+ accountId: string;
35
+ } & Partial<AccountConfig>;
36
+ };
37
+ gateway: {
38
+ healthCheckIntervalMinutes: number;
39
+ startAccount: (lifecycleCtx: GatewayStartAccountCtx) => Promise<void>;
40
+ stopAccount: (lifecycleCtx: GatewayStopAccountCtx) => Promise<void>;
41
+ };
42
+ outbound: {
43
+ deliveryMode: "direct";
44
+ resolveTarget: ({ to }: {
45
+ to?: string;
46
+ }) => {
47
+ ok: false;
48
+ error: Error;
49
+ to?: undefined;
50
+ } | {
51
+ ok: true;
52
+ to: string;
53
+ error?: undefined;
54
+ };
55
+ sendText: ({ to, text, accountId }: OutboundSendTextArgs) => Promise<OutboundResult>;
56
+ };
57
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Channel descriptor factory. The descriptor closes over the active
3
+ * PluginContext, so a hypothetical re-register cleanly produces a fresh
4
+ * descriptor instead of sharing mutable state with the previous one.
5
+ */
6
+ import { getConnectedClient, hasConnectedAccountClient, snapshotAccountStatus, startAccountClient, stopAccountClient, } from "./clients.js";
7
+ import { getAccountConfig, listAccountIds, resolveAccountConfig } from "./config.js";
8
+ import { sendTextToTarget } from "./media.js";
9
+ import { parseTarget } from "./targets.js";
10
+ import { CHANNEL_ID } from "./types.js";
11
+ import { formatSdkError, logTag } from "./utils.js";
12
+ function waitUntilAborted(signal) {
13
+ if (!signal)
14
+ return new Promise(() => undefined);
15
+ if (signal.aborted)
16
+ return Promise.resolve();
17
+ return new Promise((resolve) => {
18
+ signal.addEventListener("abort", () => resolve(), { once: true });
19
+ });
20
+ }
21
+ export function createChannelPlugin(ctx) {
22
+ return {
23
+ id: CHANNEL_ID,
24
+ meta: {
25
+ id: CHANNEL_ID,
26
+ label: "Multi-OpenIM",
27
+ selectionLabel: "Multi-OpenIM",
28
+ docsPath: `/channels/${CHANNEL_ID}`,
29
+ blurb: "Multi-account / multi-server OpenIM channel with config-driven token refresh",
30
+ aliases: [CHANNEL_ID, "multi-openim-im", "mopenim"],
31
+ },
32
+ capabilities: {
33
+ chatTypes: ["direct", "group"],
34
+ },
35
+ config: {
36
+ listAccountIds: (cfg) => listAccountIds(cfg),
37
+ resolveAccount: (cfg, accountId) => resolveAccountConfig(cfg, accountId),
38
+ },
39
+ gateway: {
40
+ healthCheckIntervalMinutes: ctx.channel.healthCheckIntervalMinutes,
41
+ startAccount: async (lifecycleCtx) => {
42
+ const account = getAccountConfig(lifecycleCtx.cfg, lifecycleCtx.accountId);
43
+ if (!account || !account.enabled) {
44
+ throw new Error(`${CHANNEL_ID} account "${lifecycleCtx.accountId}" is not configured or disabled`);
45
+ }
46
+ lifecycleCtx.setStatus?.({
47
+ accountId: account.accountId,
48
+ enabled: account.enabled,
49
+ configured: true,
50
+ lastError: null,
51
+ });
52
+ if (!hasConnectedAccountClient(account.accountId)) {
53
+ await startAccountClient(ctx, account);
54
+ }
55
+ if (!hasConnectedAccountClient(account.accountId)) {
56
+ throw new Error(`${CHANNEL_ID} account "${account.accountId}" failed to connect`);
57
+ }
58
+ lifecycleCtx.setStatus?.(snapshotAccountStatus(account.accountId));
59
+ lifecycleCtx.log?.info?.(`${logTag("lifecycle")} account=${account.accountId} running`);
60
+ try {
61
+ await waitUntilAborted(lifecycleCtx.abortSignal);
62
+ }
63
+ finally {
64
+ await stopAccountClient(ctx, account.accountId);
65
+ lifecycleCtx.log?.info?.(`${logTag("lifecycle")} account=${account.accountId} stopped`);
66
+ }
67
+ },
68
+ stopAccount: async (lifecycleCtx) => {
69
+ await stopAccountClient(ctx, lifecycleCtx.accountId);
70
+ },
71
+ },
72
+ outbound: {
73
+ deliveryMode: "direct",
74
+ resolveTarget: ({ to }) => {
75
+ const target = parseTarget(to);
76
+ if (!target) {
77
+ return { ok: false, error: new Error("multi-openim requires --to <user:ID|group:ID>") };
78
+ }
79
+ return { ok: true, to: `${target.kind}:${target.id}` };
80
+ },
81
+ sendText: async ({ to, text, accountId }) => {
82
+ const target = parseTarget(to);
83
+ if (!target) {
84
+ return { ok: false, error: new Error("invalid target, expected user:<id> or group:<id>") };
85
+ }
86
+ const id = String(accountId ?? "").trim();
87
+ if (!id) {
88
+ return { ok: false, error: new Error("accountId is required (no default fallback)") };
89
+ }
90
+ const client = getConnectedClient(id);
91
+ if (!client) {
92
+ return { ok: false, error: new Error(`multi-openim account "${id}" is not connected`) };
93
+ }
94
+ try {
95
+ await sendTextToTarget(client, target, text);
96
+ return { ok: true, provider: CHANNEL_ID };
97
+ }
98
+ catch (e) {
99
+ return { ok: false, error: new Error(formatSdkError(e)) };
100
+ }
101
+ },
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Per-account OpenIM SDK client lifecycle. Strict accountId lookups (no
3
+ * "default" fallback), per-account recovery backoff, and a decoupled
4
+ * inbound dispatcher slot so this module can be imported without forming
5
+ * a static cycle with inbound.ts.
6
+ */
7
+ import type { MessageItem } from "@openim/client-sdk";
8
+ import type { PluginContext } from "./context.js";
9
+ import type { AccountConfig, ClientState, GatewayLifecycleStatus } from "./types.js";
10
+ export type InboundDispatcher = (ctx: PluginContext, state: ClientState, msg: MessageItem) => Promise<void>;
11
+ export declare function setInboundDispatcher(fn: InboundDispatcher): void;
12
+ export declare function getConnectedClient(accountId: string | undefined | null): ClientState | null;
13
+ export declare function hasConnectedAccountClient(accountId: string | undefined | null): boolean;
14
+ export declare function connectedClientCount(): number;
15
+ export declare function listConnectedAccountIds(): string[];
16
+ export declare function startAccountClient(ctx: PluginContext, account: AccountConfig): Promise<void>;
17
+ export declare function stopAccountClient(ctx: PluginContext, accountId: string): Promise<boolean>;
18
+ export declare function stopAllClients(ctx: PluginContext): Promise<void>;
19
+ export declare function snapshotAccountStatus(accountId: string): GatewayLifecycleStatus;
20
+ export declare function _internal_resetClients(): void;