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/dist/clients.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
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 { CbEvents, getSDK } from "@openim/client-sdk";
|
|
8
|
+
import { applyFriendGuard } from "./friend-guard.js";
|
|
9
|
+
import { clearManualLoginMarker, refreshAccountToken, writeManualLoginMarker, } from "./token-refresh.js";
|
|
10
|
+
import { formatSdkError, logTag, truncate } from "./utils.js";
|
|
11
|
+
const clients = new Map();
|
|
12
|
+
const refreshing = new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Per-account exponential-backoff state for token-recovery attempts.
|
|
15
|
+
*
|
|
16
|
+
* State machine:
|
|
17
|
+
* - First failure: attempt=1, schedule retry +1s; subsequent retries
|
|
18
|
+
* follow the sequence 2s/4s/8s/16s/32s.
|
|
19
|
+
* - After RECOVERY_BACKOFF_MS_SEQUENCE.length attempts: givenUp=true,
|
|
20
|
+
* manual-login marker written. SDK events for this account are silently
|
|
21
|
+
* dropped until either startAccountClient succeeds again, or
|
|
22
|
+
* GIVENUP_RETRY_AFTER_MS elapses (whichever comes first).
|
|
23
|
+
* - The cool-off window lets a long-running gateway survive a transient
|
|
24
|
+
* auth-server outage without requiring a manual restart.
|
|
25
|
+
* - Any successful login (initial or post-recovery) clears the entry.
|
|
26
|
+
*/
|
|
27
|
+
const RECOVERY_BACKOFF_MS_SEQUENCE = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000];
|
|
28
|
+
const RECOVERY_BACKOFF_STEPS = RECOVERY_BACKOFF_MS_SEQUENCE.length;
|
|
29
|
+
const GIVENUP_RETRY_AFTER_MS = 30 * 60 * 1000;
|
|
30
|
+
const recoveryStateByAccount = new Map();
|
|
31
|
+
function resetRecoveryState(accountId) {
|
|
32
|
+
const prev = recoveryStateByAccount.get(accountId);
|
|
33
|
+
if (prev?.pendingTimer)
|
|
34
|
+
clearTimeout(prev.pendingTimer);
|
|
35
|
+
recoveryStateByAccount.delete(accountId);
|
|
36
|
+
}
|
|
37
|
+
let inboundDispatcher = async (ctx) => {
|
|
38
|
+
ctx.logger.warn?.(`${logTag("clients")} inbound dispatcher not registered; dropping message`);
|
|
39
|
+
};
|
|
40
|
+
export function setInboundDispatcher(fn) {
|
|
41
|
+
inboundDispatcher = fn;
|
|
42
|
+
}
|
|
43
|
+
export function getConnectedClient(accountId) {
|
|
44
|
+
const id = String(accountId ?? "").trim();
|
|
45
|
+
if (!id)
|
|
46
|
+
return null;
|
|
47
|
+
return clients.get(id) ?? null;
|
|
48
|
+
}
|
|
49
|
+
export function hasConnectedAccountClient(accountId) {
|
|
50
|
+
return getConnectedClient(accountId) !== null;
|
|
51
|
+
}
|
|
52
|
+
export function connectedClientCount() {
|
|
53
|
+
return clients.size;
|
|
54
|
+
}
|
|
55
|
+
export function listConnectedAccountIds() {
|
|
56
|
+
return Array.from(clients.keys());
|
|
57
|
+
}
|
|
58
|
+
function buildHandlers(ctx, state) {
|
|
59
|
+
const consumeMessage = (msg) => {
|
|
60
|
+
if (!msg)
|
|
61
|
+
return;
|
|
62
|
+
inboundDispatcher(ctx, state, msg).catch((e) => {
|
|
63
|
+
ctx.logger.error?.(`${logTag("clients")} inbound dispatcher failed (account=${state.config.accountId}): ${formatSdkError(e)}`);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const onTokenIssue = (reason) => {
|
|
67
|
+
void recoverAccount(ctx, state, reason);
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
onRecvNewMessage: (event) => {
|
|
71
|
+
const data = event?.data;
|
|
72
|
+
if (data)
|
|
73
|
+
consumeMessage(data);
|
|
74
|
+
},
|
|
75
|
+
onRecvNewMessages: (event) => {
|
|
76
|
+
const list = Array.isArray(event?.data) ? event.data : [];
|
|
77
|
+
for (const msg of list)
|
|
78
|
+
consumeMessage(msg);
|
|
79
|
+
},
|
|
80
|
+
onRecvOfflineNewMessages: (event) => {
|
|
81
|
+
const list = Array.isArray(event?.data) ? event.data : [];
|
|
82
|
+
for (const msg of list)
|
|
83
|
+
consumeMessage(msg);
|
|
84
|
+
},
|
|
85
|
+
onConnectFailed: (evt) => {
|
|
86
|
+
const errCode = (() => {
|
|
87
|
+
if (evt && typeof evt === "object") {
|
|
88
|
+
const e = evt;
|
|
89
|
+
const raw = e.errCode ?? e.data?.errCode;
|
|
90
|
+
return Number(raw ?? 0);
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
})();
|
|
94
|
+
onTokenIssue(`OnConnectFailed errCode=${errCode}`);
|
|
95
|
+
},
|
|
96
|
+
onUserTokenExpired: () => onTokenIssue("OnUserTokenExpired"),
|
|
97
|
+
onUserTokenInvalid: () => onTokenIssue("OnUserTokenInvalid"),
|
|
98
|
+
onKickedOffline: () => onTokenIssue("OnKickedOffline"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function attachHandlers(state) {
|
|
102
|
+
const { sdk, handlers } = state;
|
|
103
|
+
sdk.on(CbEvents.OnRecvNewMessage, handlers.onRecvNewMessage);
|
|
104
|
+
sdk.on(CbEvents.OnRecvNewMessages, handlers.onRecvNewMessages);
|
|
105
|
+
sdk.on(CbEvents.OnRecvOfflineNewMessages, handlers.onRecvOfflineNewMessages);
|
|
106
|
+
sdk.on(CbEvents.OnConnectFailed, handlers.onConnectFailed);
|
|
107
|
+
sdk.on(CbEvents.OnUserTokenExpired, handlers.onUserTokenExpired);
|
|
108
|
+
sdk.on(CbEvents.OnUserTokenInvalid, handlers.onUserTokenInvalid);
|
|
109
|
+
sdk.on(CbEvents.OnKickedOffline, handlers.onKickedOffline);
|
|
110
|
+
}
|
|
111
|
+
function detachHandlers(ctx, state) {
|
|
112
|
+
const { sdk, handlers } = state;
|
|
113
|
+
try {
|
|
114
|
+
sdk.off(CbEvents.OnRecvNewMessage, handlers.onRecvNewMessage);
|
|
115
|
+
sdk.off(CbEvents.OnRecvNewMessages, handlers.onRecvNewMessages);
|
|
116
|
+
sdk.off(CbEvents.OnRecvOfflineNewMessages, handlers.onRecvOfflineNewMessages);
|
|
117
|
+
sdk.off(CbEvents.OnConnectFailed, handlers.onConnectFailed);
|
|
118
|
+
sdk.off(CbEvents.OnUserTokenExpired, handlers.onUserTokenExpired);
|
|
119
|
+
sdk.off(CbEvents.OnUserTokenInvalid, handlers.onUserTokenInvalid);
|
|
120
|
+
sdk.off(CbEvents.OnKickedOffline, handlers.onKickedOffline);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
ctx.logger.warn?.(`${logTag("clients")} detachHandlers errored (account=${state.config.accountId}): ${formatSdkError(e)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function noteRecoveryFailure(accountId) {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const prev = recoveryStateByAccount.get(accountId);
|
|
129
|
+
const attempt = (prev?.attempt ?? 0) + 1;
|
|
130
|
+
if (attempt > RECOVERY_BACKOFF_STEPS) {
|
|
131
|
+
recoveryStateByAccount.set(accountId, {
|
|
132
|
+
attempt,
|
|
133
|
+
nextEarliestAt: Number.POSITIVE_INFINITY,
|
|
134
|
+
givenUp: true,
|
|
135
|
+
givenUpAt: now,
|
|
136
|
+
});
|
|
137
|
+
return { givenUp: true, attempt };
|
|
138
|
+
}
|
|
139
|
+
const delayMs = RECOVERY_BACKOFF_MS_SEQUENCE[attempt - 1] ?? 32_000;
|
|
140
|
+
recoveryStateByAccount.set(accountId, { attempt, nextEarliestAt: now + delayMs, givenUp: false });
|
|
141
|
+
return { givenUp: false, attempt };
|
|
142
|
+
}
|
|
143
|
+
function scheduleNextRetry(ctx, state, reason) {
|
|
144
|
+
const aid = state.config.accountId;
|
|
145
|
+
const backoff = recoveryStateByAccount.get(aid);
|
|
146
|
+
if (!backoff || backoff.givenUp)
|
|
147
|
+
return;
|
|
148
|
+
if (backoff.pendingTimer)
|
|
149
|
+
clearTimeout(backoff.pendingTimer);
|
|
150
|
+
const delay = Math.max(0, backoff.nextEarliestAt - Date.now());
|
|
151
|
+
backoff.pendingTimer = setTimeout(() => {
|
|
152
|
+
backoff.pendingTimer = undefined;
|
|
153
|
+
void recoverAccount(ctx, state, reason);
|
|
154
|
+
}, delay);
|
|
155
|
+
}
|
|
156
|
+
async function recoverAccount(ctx, state, reason) {
|
|
157
|
+
const aid = state.config.accountId;
|
|
158
|
+
const channel = ctx.channel;
|
|
159
|
+
const backoff = recoveryStateByAccount.get(aid);
|
|
160
|
+
if (backoff?.givenUp) {
|
|
161
|
+
const givenUpFor = backoff.givenUpAt ? Date.now() - backoff.givenUpAt : 0;
|
|
162
|
+
if (givenUpFor < GIVENUP_RETRY_AFTER_MS) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
ctx.logger.info?.(`${logTag("clients")} account=${aid} givenUp cool-off (${Math.round(givenUpFor / 60_000)}min) elapsed; retrying recovery (reason=${reason})`);
|
|
166
|
+
resetRecoveryState(aid);
|
|
167
|
+
}
|
|
168
|
+
if (backoff && Date.now() < backoff.nextEarliestAt && !backoff.pendingTimer) {
|
|
169
|
+
scheduleNextRetry(ctx, state, reason);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (backoff && Date.now() < backoff.nextEarliestAt) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (refreshing.has(aid)) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
refreshing.add(aid);
|
|
179
|
+
try {
|
|
180
|
+
ctx.logger.warn?.(`${logTag("clients")} ${reason} (account=${aid}) — kicking off token-refresh flow (attempt=${(backoff?.attempt ?? 0) + 1})`);
|
|
181
|
+
const outcome = await refreshAccountToken({
|
|
182
|
+
ctx,
|
|
183
|
+
accountId: aid,
|
|
184
|
+
reason,
|
|
185
|
+
});
|
|
186
|
+
if (!outcome.ok || !outcome.token) {
|
|
187
|
+
const detail = outcome.error ?? "unknown";
|
|
188
|
+
state.lastError = truncate(detail, 500);
|
|
189
|
+
const { givenUp, attempt } = noteRecoveryFailure(aid);
|
|
190
|
+
if (givenUp) {
|
|
191
|
+
writeManualLoginMarker(ctx, aid, detail);
|
|
192
|
+
ctx.logger.error?.(`${logTag("clients")} account=${aid} recovery exhausted after ${attempt} attempts; manual login required (last reason: ${reason})`);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
scheduleNextRetry(ctx, state, reason);
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
state.config = {
|
|
200
|
+
...state.config,
|
|
201
|
+
token: outcome.token,
|
|
202
|
+
userID: outcome.userID ?? state.config.userID,
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
await state.sdk.logout();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// logout failures here are non-fatal: re-login follows
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await state.sdk.login({
|
|
212
|
+
userID: state.config.userID,
|
|
213
|
+
token: state.config.token,
|
|
214
|
+
wsAddr: state.config.wsAddr,
|
|
215
|
+
apiAddr: state.config.apiAddr,
|
|
216
|
+
platformID: state.config.platformID,
|
|
217
|
+
});
|
|
218
|
+
state.connected = true;
|
|
219
|
+
state.lastConnectedAt = Date.now();
|
|
220
|
+
state.lastError = undefined;
|
|
221
|
+
clients.set(aid, state);
|
|
222
|
+
resetRecoveryState(aid);
|
|
223
|
+
clearManualLoginMarker(channel.tokenRefresh, aid);
|
|
224
|
+
ctx.logger.info?.(`${logTag("clients")} account=${aid} recovered after token refresh (via=${outcome.via})`);
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const detail = formatSdkError(e);
|
|
229
|
+
state.connected = false;
|
|
230
|
+
state.lastError = truncate(detail, 500);
|
|
231
|
+
const { givenUp, attempt } = noteRecoveryFailure(aid);
|
|
232
|
+
if (givenUp) {
|
|
233
|
+
writeManualLoginMarker(ctx, aid, detail);
|
|
234
|
+
ctx.logger.error?.(`${logTag("clients")} account=${aid} relogin exhausted after ${attempt} attempts: ${truncate(detail, 200)}`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
ctx.logger.warn?.(`${logTag("clients")} relogin failed after refresh (account=${aid}, attempt=${attempt}): ${truncate(detail, 200)}`);
|
|
238
|
+
scheduleNextRetry(ctx, state, reason);
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
refreshing.delete(aid);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
export async function startAccountClient(ctx, account) {
|
|
248
|
+
const aid = account.accountId;
|
|
249
|
+
if (clients.has(aid)) {
|
|
250
|
+
ctx.logger.info?.(`${logTag("clients")} startAccountClient: account=${aid} already started`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const sdk = getSDK();
|
|
254
|
+
const state = {
|
|
255
|
+
sdk,
|
|
256
|
+
config: account,
|
|
257
|
+
handlers: undefined,
|
|
258
|
+
connected: false,
|
|
259
|
+
};
|
|
260
|
+
applyFriendGuard(sdk, account, ctx.logger);
|
|
261
|
+
state.handlers = buildHandlers(ctx, state);
|
|
262
|
+
attachHandlers(state);
|
|
263
|
+
try {
|
|
264
|
+
await sdk.login({
|
|
265
|
+
userID: account.userID,
|
|
266
|
+
token: account.token,
|
|
267
|
+
wsAddr: account.wsAddr,
|
|
268
|
+
apiAddr: account.apiAddr,
|
|
269
|
+
platformID: account.platformID,
|
|
270
|
+
});
|
|
271
|
+
state.connected = true;
|
|
272
|
+
state.lastConnectedAt = Date.now();
|
|
273
|
+
clients.set(aid, state);
|
|
274
|
+
resetRecoveryState(aid);
|
|
275
|
+
clearManualLoginMarker(ctx.channel.tokenRefresh, aid);
|
|
276
|
+
ctx.logger.info?.(`${logTag("clients")} account=${aid} connected`);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
const detail = formatSdkError(e);
|
|
280
|
+
state.lastError = truncate(detail, 500);
|
|
281
|
+
ctx.logger.error?.(`${logTag("clients")} initial login failed (account=${aid}): ${truncate(detail, 200)}`);
|
|
282
|
+
const recovered = await recoverAccount(ctx, state, "initial login failed");
|
|
283
|
+
if (!recovered) {
|
|
284
|
+
detachHandlers(ctx, state);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
export async function stopAccountClient(ctx, accountId) {
|
|
289
|
+
const aid = String(accountId ?? "").trim();
|
|
290
|
+
if (!aid)
|
|
291
|
+
return false;
|
|
292
|
+
const state = clients.get(aid);
|
|
293
|
+
if (!state) {
|
|
294
|
+
resetRecoveryState(aid);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
clients.delete(aid);
|
|
298
|
+
resetRecoveryState(aid);
|
|
299
|
+
detachHandlers(ctx, state);
|
|
300
|
+
try {
|
|
301
|
+
await state.sdk.logout();
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
ctx.logger.warn?.(`${logTag("clients")} logout failed (account=${aid}): ${formatSdkError(e)}`);
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
export async function stopAllClients(ctx) {
|
|
309
|
+
const ids = Array.from(clients.keys());
|
|
310
|
+
for (const aid of ids) {
|
|
311
|
+
await stopAccountClient(ctx, aid);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
export function snapshotAccountStatus(accountId) {
|
|
315
|
+
const state = clients.get(accountId);
|
|
316
|
+
return {
|
|
317
|
+
accountId,
|
|
318
|
+
enabled: state ? state.config.enabled : undefined,
|
|
319
|
+
configured: !!state,
|
|
320
|
+
running: !!state,
|
|
321
|
+
connected: state?.connected ?? false,
|
|
322
|
+
lastConnectedAt: state?.lastConnectedAt,
|
|
323
|
+
lastError: state?.lastError ?? null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
export function _internal_resetClients() {
|
|
327
|
+
clients.clear();
|
|
328
|
+
refreshing.clear();
|
|
329
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration normalization. Reads `channels.multi-openim` from the
|
|
3
|
+
* gateway config:
|
|
4
|
+
*
|
|
5
|
+
* - The single-account fallback (top-level token/wsAddr/apiAddr) is
|
|
6
|
+
* intentionally not supported. Every account must be declared under
|
|
7
|
+
* `accounts.<id>`.
|
|
8
|
+
* - `userID` and `platformID` are auto-derived from JWT claims when
|
|
9
|
+
* omitted.
|
|
10
|
+
* - Token-refresh mode and manual-login marker path are validated and
|
|
11
|
+
* defaulted here.
|
|
12
|
+
*/
|
|
13
|
+
import type { AccountConfig, AccountConfigRaw, ChannelConfig, ChannelConfigRaw, PluginApi } from "./types.js";
|
|
14
|
+
export declare function getChannelConfigRaw(apiOrCfg?: PluginApi | Record<string, unknown>): ChannelConfigRaw;
|
|
15
|
+
export interface NormalizeAccountIssue {
|
|
16
|
+
accountId: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function normalizeAccount(accountId: string, raw: AccountConfigRaw | null | undefined, channelDefaults: {
|
|
20
|
+
disableFriendSdk: boolean;
|
|
21
|
+
}): {
|
|
22
|
+
account?: AccountConfig;
|
|
23
|
+
issue?: NormalizeAccountIssue;
|
|
24
|
+
};
|
|
25
|
+
export interface NormalizedChannelResult {
|
|
26
|
+
channel: ChannelConfig;
|
|
27
|
+
issues: NormalizeAccountIssue[];
|
|
28
|
+
}
|
|
29
|
+
export declare function normalizeChannelConfig(apiOrCfg: PluginApi | Record<string, unknown> | undefined, logger?: {
|
|
30
|
+
warn?: (msg: string) => void;
|
|
31
|
+
}): NormalizedChannelResult;
|
|
32
|
+
export declare function listAccountIds(apiOrCfg?: PluginApi | Record<string, unknown>): string[];
|
|
33
|
+
export declare function listEnabledAccountConfigs(apiOrCfg?: PluginApi | Record<string, unknown>): AccountConfig[];
|
|
34
|
+
export declare function getAccountConfig(apiOrCfg: PluginApi | Record<string, unknown> | undefined, accountId: string): AccountConfig | null;
|
|
35
|
+
export declare function resolveAccountConfig(apiOrCfg: PluginApi | Record<string, unknown> | undefined, accountId?: string): {
|
|
36
|
+
accountId: string;
|
|
37
|
+
} & Partial<AccountConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration normalization. Reads `channels.multi-openim` from the
|
|
3
|
+
* gateway config:
|
|
4
|
+
*
|
|
5
|
+
* - The single-account fallback (top-level token/wsAddr/apiAddr) is
|
|
6
|
+
* intentionally not supported. Every account must be declared under
|
|
7
|
+
* `accounts.<id>`.
|
|
8
|
+
* - `userID` and `platformID` are auto-derived from JWT claims when
|
|
9
|
+
* omitted.
|
|
10
|
+
* - Token-refresh mode and manual-login marker path are validated and
|
|
11
|
+
* defaulted here.
|
|
12
|
+
*/
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { CHANNEL_ID } from "./types.js";
|
|
16
|
+
import { decodeJwtPayload, logTag, toFiniteNumber } from "./utils.js";
|
|
17
|
+
const DEFAULT_HEALTH_CHECK_MINUTES = 30;
|
|
18
|
+
const DEFAULT_PLATFORM_ID = 5;
|
|
19
|
+
const VALID_TOKEN_REFRESH_MODES = new Set(["hook", "off", "http"]);
|
|
20
|
+
function getConfigRoot(apiOrCfg) {
|
|
21
|
+
if (!apiOrCfg || typeof apiOrCfg !== "object")
|
|
22
|
+
return {};
|
|
23
|
+
const obj = apiOrCfg;
|
|
24
|
+
if ("config" in obj && obj.config && typeof obj.config === "object") {
|
|
25
|
+
return obj.config;
|
|
26
|
+
}
|
|
27
|
+
if ("channels" in obj && obj.channels && typeof obj.channels === "object") {
|
|
28
|
+
return obj;
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
export function getChannelConfigRaw(apiOrCfg) {
|
|
33
|
+
const root = getConfigRoot(apiOrCfg);
|
|
34
|
+
const channels = root.channels ?? {};
|
|
35
|
+
const block = channels[CHANNEL_ID];
|
|
36
|
+
return (block && typeof block === "object" ? block : {});
|
|
37
|
+
}
|
|
38
|
+
function normalizeStringList(raw) {
|
|
39
|
+
if (raw == null)
|
|
40
|
+
return [];
|
|
41
|
+
if (Array.isArray(raw)) {
|
|
42
|
+
return raw.map((x) => String(x ?? "").trim()).filter((s) => s.length > 0);
|
|
43
|
+
}
|
|
44
|
+
const s = String(raw ?? "").trim();
|
|
45
|
+
return s ? [s] : [];
|
|
46
|
+
}
|
|
47
|
+
function normalizeHttpRefresh(raw, logger) {
|
|
48
|
+
if (!raw || typeof raw !== "object")
|
|
49
|
+
return undefined;
|
|
50
|
+
const r = raw;
|
|
51
|
+
const endpoint = String(r.endpoint ?? "").trim();
|
|
52
|
+
const responseTokenPath = normalizeStringList(r.responseTokenPath);
|
|
53
|
+
if (!endpoint) {
|
|
54
|
+
logger?.warn?.(`${logTag("config")} tokenRefresh.http.endpoint missing — http refresh disabled`);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (responseTokenPath.length === 0) {
|
|
58
|
+
logger?.warn?.(`${logTag("config")} tokenRefresh.http.responseTokenPath missing — http refresh disabled`);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const stateFileRaw = String(r.stateFile ?? "").trim();
|
|
62
|
+
const method = String(r.method ?? "POST").trim().toUpperCase() || "POST";
|
|
63
|
+
const headersRaw = r.headers && typeof r.headers === "object" ? r.headers : {};
|
|
64
|
+
const headers = {};
|
|
65
|
+
for (const [k, v] of Object.entries(headersRaw)) {
|
|
66
|
+
if (v == null)
|
|
67
|
+
continue;
|
|
68
|
+
headers[String(k)] = String(v);
|
|
69
|
+
}
|
|
70
|
+
const timeoutMs = toFiniteNumber(r.timeoutMs, 15_000);
|
|
71
|
+
const responseUserIdPath = normalizeStringList(r.responseUserIdPath);
|
|
72
|
+
const stateWriteBackRaw = r.stateWriteBack && typeof r.stateWriteBack === "object"
|
|
73
|
+
? r.stateWriteBack
|
|
74
|
+
: null;
|
|
75
|
+
let stateWriteBack;
|
|
76
|
+
if (stateWriteBackRaw) {
|
|
77
|
+
stateWriteBack = {};
|
|
78
|
+
for (const [field, spec] of Object.entries(stateWriteBackRaw)) {
|
|
79
|
+
const paths = normalizeStringList(spec);
|
|
80
|
+
if (paths.length > 0)
|
|
81
|
+
stateWriteBack[String(field)] = paths;
|
|
82
|
+
}
|
|
83
|
+
if (Object.keys(stateWriteBack).length === 0)
|
|
84
|
+
stateWriteBack = undefined;
|
|
85
|
+
}
|
|
86
|
+
const clearOnSuccess = normalizeStringList(r.clearOnSuccess);
|
|
87
|
+
return {
|
|
88
|
+
stateFile: stateFileRaw || undefined,
|
|
89
|
+
endpoint,
|
|
90
|
+
method,
|
|
91
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
92
|
+
body: r.body,
|
|
93
|
+
timeoutMs,
|
|
94
|
+
responseTokenPath: responseTokenPath.length === 1 ? responseTokenPath[0] : responseTokenPath,
|
|
95
|
+
responseUserIdPath: responseUserIdPath.length === 0
|
|
96
|
+
? undefined
|
|
97
|
+
: responseUserIdPath.length === 1
|
|
98
|
+
? responseUserIdPath[0]
|
|
99
|
+
: responseUserIdPath,
|
|
100
|
+
stateWriteBack,
|
|
101
|
+
clearOnSuccess: clearOnSuccess.length > 0 ? clearOnSuccess : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function normalizeTokenRefresh(raw, logger) {
|
|
105
|
+
const rawMode = String(raw?.mode ?? "hook").trim();
|
|
106
|
+
let mode = VALID_TOKEN_REFRESH_MODES.has(rawMode) ? rawMode : "hook";
|
|
107
|
+
const markerRaw = String(raw?.manualLoginMarkerPath ?? "").trim();
|
|
108
|
+
const manualLoginMarkerPath = markerRaw || join(homedir(), ".openclaw", CHANNEL_ID, "manual-login.json");
|
|
109
|
+
const http = normalizeHttpRefresh(raw?.http, logger);
|
|
110
|
+
if (mode === "http" && !http) {
|
|
111
|
+
logger?.warn?.(`${logTag("config")} tokenRefresh.mode="http" but http config invalid — falling back to "off"`);
|
|
112
|
+
mode = "off";
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
mode,
|
|
116
|
+
manualLoginMarkerPath,
|
|
117
|
+
http,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function normalizeInboundWhitelist(raw) {
|
|
121
|
+
const values = Array.isArray(raw)
|
|
122
|
+
? raw
|
|
123
|
+
: typeof raw === "string"
|
|
124
|
+
? raw.split(",")
|
|
125
|
+
: [];
|
|
126
|
+
const out = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const item of values) {
|
|
129
|
+
const s = String(item ?? "").trim();
|
|
130
|
+
if (!s || seen.has(s))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(s);
|
|
133
|
+
out.push(s);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
function extractAccountHintsFromToken(token) {
|
|
138
|
+
const payload = decodeJwtPayload(token);
|
|
139
|
+
if (!payload)
|
|
140
|
+
return {};
|
|
141
|
+
const userIDRaw = payload.UserID ?? payload.userID;
|
|
142
|
+
const userID = String(userIDRaw ?? "").trim();
|
|
143
|
+
const platformRaw = payload.PlatformID ?? payload.platformID;
|
|
144
|
+
const platformID = toFiniteNumber(platformRaw, NaN);
|
|
145
|
+
const hints = {};
|
|
146
|
+
if (userID)
|
|
147
|
+
hints.userID = userID;
|
|
148
|
+
if (Number.isFinite(platformID))
|
|
149
|
+
hints.platformID = platformID;
|
|
150
|
+
return hints;
|
|
151
|
+
}
|
|
152
|
+
export function normalizeAccount(accountId, raw, channelDefaults) {
|
|
153
|
+
if (!raw || typeof raw !== "object") {
|
|
154
|
+
return { issue: { accountId, reason: "missing account config block" } };
|
|
155
|
+
}
|
|
156
|
+
const token = String(raw.token ?? "").trim();
|
|
157
|
+
const wsAddr = String(raw.wsAddr ?? "").trim();
|
|
158
|
+
const apiAddr = String(raw.apiAddr ?? "").trim();
|
|
159
|
+
if (!token)
|
|
160
|
+
return { issue: { accountId, reason: "missing token" } };
|
|
161
|
+
if (!wsAddr)
|
|
162
|
+
return { issue: { accountId, reason: "missing wsAddr" } };
|
|
163
|
+
if (!apiAddr)
|
|
164
|
+
return { issue: { accountId, reason: "missing apiAddr" } };
|
|
165
|
+
const hints = extractAccountHintsFromToken(token);
|
|
166
|
+
const userID = String(raw.userID ?? hints.userID ?? "").trim();
|
|
167
|
+
if (!userID) {
|
|
168
|
+
return {
|
|
169
|
+
issue: { accountId, reason: "userID missing and JWT lacks UserID claim" },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const platformID = toFiniteNumber(raw.platformID ?? hints.platformID, DEFAULT_PLATFORM_ID);
|
|
173
|
+
const enabled = raw.enabled !== false;
|
|
174
|
+
const requireMention = raw.requireMention !== false;
|
|
175
|
+
const inboundWhitelist = normalizeInboundWhitelist(raw.inboundWhitelist);
|
|
176
|
+
const disableFriendSdk = raw.disableFriendSdk ?? channelDefaults.disableFriendSdk;
|
|
177
|
+
return {
|
|
178
|
+
account: {
|
|
179
|
+
accountId,
|
|
180
|
+
enabled,
|
|
181
|
+
userID,
|
|
182
|
+
token,
|
|
183
|
+
wsAddr,
|
|
184
|
+
apiAddr,
|
|
185
|
+
platformID,
|
|
186
|
+
requireMention,
|
|
187
|
+
inboundWhitelist,
|
|
188
|
+
disableFriendSdk,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
export function normalizeChannelConfig(apiOrCfg, logger) {
|
|
193
|
+
const raw = getChannelConfigRaw(apiOrCfg);
|
|
194
|
+
const enabled = raw.enabled !== false;
|
|
195
|
+
const healthCheckIntervalMinutes = Math.max(1, toFiniteNumber(raw.healthCheckIntervalMinutes, DEFAULT_HEALTH_CHECK_MINUTES));
|
|
196
|
+
const disableFriendSdk = raw.disableFriendSdk !== false;
|
|
197
|
+
const tokenRefresh = normalizeTokenRefresh(raw.tokenRefresh, logger);
|
|
198
|
+
const topLevel = raw;
|
|
199
|
+
const offenders = [];
|
|
200
|
+
for (const k of ["token", "wsAddr", "apiAddr", "userID", "platformID"]) {
|
|
201
|
+
if (k in topLevel && topLevel[k] !== undefined && topLevel[k] !== null && topLevel[k] !== "") {
|
|
202
|
+
offenders.push(k);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (offenders.length > 0) {
|
|
206
|
+
logger?.warn?.(`${logTag("config")} top-level field(s) [${offenders.join(", ")}] are ignored — use channels.${CHANNEL_ID}.accounts.<id> instead`);
|
|
207
|
+
}
|
|
208
|
+
const accounts = {};
|
|
209
|
+
const issues = [];
|
|
210
|
+
const rawAccounts = raw.accounts && typeof raw.accounts === "object" ? raw.accounts : {};
|
|
211
|
+
for (const [aid, accRaw] of Object.entries(rawAccounts)) {
|
|
212
|
+
const id = String(aid).trim();
|
|
213
|
+
if (!id)
|
|
214
|
+
continue;
|
|
215
|
+
const { account, issue } = normalizeAccount(id, accRaw, { disableFriendSdk });
|
|
216
|
+
if (account) {
|
|
217
|
+
accounts[id] = account;
|
|
218
|
+
}
|
|
219
|
+
else if (issue) {
|
|
220
|
+
issues.push(issue);
|
|
221
|
+
logger?.warn?.(`${logTag("config")} account "${issue.accountId}" skipped: ${issue.reason}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
channel: {
|
|
226
|
+
enabled,
|
|
227
|
+
healthCheckIntervalMinutes,
|
|
228
|
+
tokenRefresh,
|
|
229
|
+
disableFriendSdk,
|
|
230
|
+
accounts,
|
|
231
|
+
},
|
|
232
|
+
issues,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
export function listAccountIds(apiOrCfg) {
|
|
236
|
+
const { channel } = normalizeChannelConfig(apiOrCfg);
|
|
237
|
+
return Object.keys(channel.accounts);
|
|
238
|
+
}
|
|
239
|
+
export function listEnabledAccountConfigs(apiOrCfg) {
|
|
240
|
+
const { channel } = normalizeChannelConfig(apiOrCfg);
|
|
241
|
+
return Object.values(channel.accounts).filter((a) => a.enabled);
|
|
242
|
+
}
|
|
243
|
+
export function getAccountConfig(apiOrCfg, accountId) {
|
|
244
|
+
const id = String(accountId ?? "").trim();
|
|
245
|
+
if (!id)
|
|
246
|
+
return null;
|
|
247
|
+
const { channel } = normalizeChannelConfig(apiOrCfg);
|
|
248
|
+
return channel.accounts[id] ?? null;
|
|
249
|
+
}
|
|
250
|
+
export function resolveAccountConfig(apiOrCfg, accountId) {
|
|
251
|
+
const id = String(accountId ?? "").trim();
|
|
252
|
+
if (!id)
|
|
253
|
+
return { accountId: "" };
|
|
254
|
+
const acc = getAccountConfig(apiOrCfg, id);
|
|
255
|
+
return acc ?? { accountId: id };
|
|
256
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChannelConfig, PluginApi, PluginLogger } from "./types.js";
|
|
2
|
+
export interface PluginContext {
|
|
3
|
+
readonly api: PluginApi;
|
|
4
|
+
readonly logger: PluginLogger;
|
|
5
|
+
readonly channel: ChannelConfig;
|
|
6
|
+
}
|
|
7
|
+
export declare function createPluginContext(api: PluginApi, channel: ChannelConfig): PluginContext;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional shield for the OpenIM SDK's friend-relationship methods. When the
|
|
3
|
+
* channel-level `disableFriendSdk` flag is true (the default), every friend
|
|
4
|
+
* SDK method on a connected client is replaced by a throwing stub that
|
|
5
|
+
* surfaces a grep-able error code (`MULTI_OPENIM_FRIEND_API_DISABLED`).
|
|
6
|
+
*
|
|
7
|
+
* Use case: when an external system (your own backend, a CRM, a directory
|
|
8
|
+
* service, etc.) is the authoritative store for friend relationships,
|
|
9
|
+
* letting the SDK's friend methods run directly would split the source of
|
|
10
|
+
* truth and — under multi-account load — has been observed to raise
|
|
11
|
+
* uncaught rejections during token rotation. Operators who want raw SDK
|
|
12
|
+
* friend behavior can opt out per channel or per account via
|
|
13
|
+
* `disableFriendSdk: false`.
|
|
14
|
+
*/
|
|
15
|
+
import type { ApiService } from "@openim/client-sdk";
|
|
16
|
+
import type { AccountConfig, PluginLogger } from "./types.js";
|
|
17
|
+
export declare const FRIEND_API_DISABLED_CODE = "MULTI_OPENIM_FRIEND_API_DISABLED";
|
|
18
|
+
export declare function applyFriendGuard(sdk: ApiService, account: AccountConfig, logger?: PluginLogger): number;
|
|
19
|
+
export declare function listGuardedMethods(): readonly string[];
|