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 +21 -0
- package/README.md +255 -0
- package/SCHEMA.md +167 -0
- package/dist/channel.d.ts +57 -0
- package/dist/channel.js +104 -0
- package/dist/clients.d.ts +20 -0
- package/dist/clients.js +329 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.js +256 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.js +8 -0
- package/dist/friend-guard.d.ts +19 -0
- package/dist/friend-guard.js +66 -0
- package/dist/inbound.d.ts +17 -0
- package/dist/inbound.js +639 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +71 -0
- package/dist/media.d.ts +10 -0
- package/dist/media.js +157 -0
- package/dist/polyfills.d.ts +5 -0
- package/dist/polyfills.js +27 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.js +69 -0
- package/dist/targets.d.ts +7 -0
- package/dist/targets.js +38 -0
- package/dist/token-refresh.d.ts +50 -0
- package/dist/token-refresh.js +383 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +153 -0
- package/dist/types.d.ts +183 -0
- package/dist/types.js +4 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.js +68 -0
- package/openclaw.plugin.json +258 -0
- package/package.json +59 -0
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
|
+
};
|
package/dist/channel.js
ADDED
|
@@ -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;
|