@xmoxmo/bncr 0.0.2
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 +350 -0
- package/index.ts +43 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +30 -0
- package/src/channel.ts +1761 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import type {
|
|
4
|
+
OpenClawPluginApi,
|
|
5
|
+
OpenClawPluginServiceContext,
|
|
6
|
+
GatewayRequestHandlerOptions,
|
|
7
|
+
ChatType,
|
|
8
|
+
} from 'openclaw/plugin-sdk';
|
|
9
|
+
import {
|
|
10
|
+
createDefaultChannelRuntimeState,
|
|
11
|
+
setAccountEnabledInConfigSection,
|
|
12
|
+
applyAccountNameToChannelSection,
|
|
13
|
+
writeJsonFileAtomically,
|
|
14
|
+
readJsonFileWithFallback,
|
|
15
|
+
} from 'openclaw/plugin-sdk';
|
|
16
|
+
|
|
17
|
+
const CHANNEL_ID = 'bncr';
|
|
18
|
+
const BNCR_DEFAULT_ACCOUNT_ID = 'Primary';
|
|
19
|
+
const BRIDGE_VERSION = 2;
|
|
20
|
+
const BNCR_PUSH_EVENT = 'bncr.push';
|
|
21
|
+
const CONNECT_TTL_MS = 120_000;
|
|
22
|
+
const MAX_RETRY = 10;
|
|
23
|
+
|
|
24
|
+
const BncrConfigSchema = {
|
|
25
|
+
schema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
additionalProperties: true,
|
|
28
|
+
properties: {
|
|
29
|
+
accounts: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
additionalProperties: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
additionalProperties: true,
|
|
34
|
+
properties: {
|
|
35
|
+
enabled: { type: 'boolean' },
|
|
36
|
+
name: { type: 'string' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type BncrRoute = {
|
|
45
|
+
platform: string;
|
|
46
|
+
groupId: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type BncrConnection = {
|
|
51
|
+
accountId: string;
|
|
52
|
+
connId: string;
|
|
53
|
+
clientId?: string;
|
|
54
|
+
connectedAt: number;
|
|
55
|
+
lastSeenAt: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type OutboxEntry = {
|
|
59
|
+
messageId: string;
|
|
60
|
+
accountId: string;
|
|
61
|
+
sessionKey: string;
|
|
62
|
+
route: BncrRoute;
|
|
63
|
+
payload: Record<string, unknown>;
|
|
64
|
+
createdAt: number;
|
|
65
|
+
retryCount: number;
|
|
66
|
+
nextAttemptAt: number;
|
|
67
|
+
lastAttemptAt?: number;
|
|
68
|
+
lastError?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type PersistedState = {
|
|
72
|
+
outbox: OutboxEntry[];
|
|
73
|
+
deadLetter: OutboxEntry[];
|
|
74
|
+
sessionRoutes: Array<{
|
|
75
|
+
sessionKey: string;
|
|
76
|
+
accountId: string;
|
|
77
|
+
route: BncrRoute;
|
|
78
|
+
updatedAt: number;
|
|
79
|
+
}>;
|
|
80
|
+
lastSessionByAccount?: Array<{
|
|
81
|
+
accountId: string;
|
|
82
|
+
sessionKey: string;
|
|
83
|
+
scope: string;
|
|
84
|
+
updatedAt: number;
|
|
85
|
+
}>;
|
|
86
|
+
lastActivityByAccount?: Array<{
|
|
87
|
+
accountId: string;
|
|
88
|
+
updatedAt: number;
|
|
89
|
+
}>;
|
|
90
|
+
lastInboundByAccount?: Array<{
|
|
91
|
+
accountId: string;
|
|
92
|
+
updatedAt: number;
|
|
93
|
+
}>;
|
|
94
|
+
lastOutboundByAccount?: Array<{
|
|
95
|
+
accountId: string;
|
|
96
|
+
updatedAt: number;
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function now() {
|
|
101
|
+
return Date.now();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function asString(v: unknown, fallback = ''): string {
|
|
105
|
+
if (typeof v === 'string') return v;
|
|
106
|
+
if (v == null) return fallback;
|
|
107
|
+
return String(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeAccountId(accountId?: string | null): string {
|
|
111
|
+
const v = asString(accountId || '').trim();
|
|
112
|
+
return v || BNCR_DEFAULT_ACCOUNT_ID;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseRouteFromScope(scope: string): BncrRoute | null {
|
|
116
|
+
const parts = asString(scope).trim().split(':');
|
|
117
|
+
if (parts.length < 3) return null;
|
|
118
|
+
const [platform, groupId, userId] = parts;
|
|
119
|
+
if (!platform || !groupId || !userId) return null;
|
|
120
|
+
return { platform, groupId, userId };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
|
|
124
|
+
const raw = asString(scope).trim();
|
|
125
|
+
if (!raw) return null;
|
|
126
|
+
|
|
127
|
+
// 支持展示标签:Bncr-platform:group:user
|
|
128
|
+
const stripped = raw.replace(/^Bncr-/i, '');
|
|
129
|
+
return parseRouteFromScope(stripped);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatDisplayScope(route: BncrRoute): string {
|
|
133
|
+
return `Bncr-${route.platform}:${route.groupId}:${route.userId}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isLowerHex(input: string): boolean {
|
|
137
|
+
const raw = asString(input).trim();
|
|
138
|
+
return !!raw && /^[0-9a-f]+$/.test(raw) && raw.length % 2 === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function routeScopeToHex(route: BncrRoute): string {
|
|
142
|
+
const raw = `${route.platform}:${route.groupId}:${route.userId}`;
|
|
143
|
+
return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
|
|
147
|
+
const rawHex = asString(scopeHex).trim().toLowerCase();
|
|
148
|
+
if (!isLowerHex(rawHex)) return null;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const decoded = Buffer.from(rawHex, 'hex').toString('utf8');
|
|
152
|
+
return parseRouteFromScope(decoded);
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseRouteLike(input: unknown): BncrRoute | null {
|
|
159
|
+
const platform = asString((input as any)?.platform || '').trim();
|
|
160
|
+
const groupId = asString((input as any)?.groupId || '').trim();
|
|
161
|
+
const userId = asString((input as any)?.userId || '').trim();
|
|
162
|
+
if (!platform || !groupId || !userId) return null;
|
|
163
|
+
return { platform, groupId, userId };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseLegacySessionKeyToStrict(input: string): string | null {
|
|
167
|
+
const raw = asString(input).trim();
|
|
168
|
+
if (!raw) return null;
|
|
169
|
+
|
|
170
|
+
const directLegacy = raw.match(/^agent:main:bncr:direct:([0-9a-fA-F]+):0$/);
|
|
171
|
+
if (directLegacy?.[1]) {
|
|
172
|
+
const route = parseRouteFromHexScope(directLegacy[1].toLowerCase());
|
|
173
|
+
if (route) return buildFallbackSessionKey(route);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const bncrLegacy = raw.match(/^bncr:([0-9a-fA-F]+):0$/);
|
|
177
|
+
if (bncrLegacy?.[1]) {
|
|
178
|
+
const route = parseRouteFromHexScope(bncrLegacy[1].toLowerCase());
|
|
179
|
+
if (route) return buildFallbackSessionKey(route);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const agentLegacy = raw.match(/^agent:main:bncr:([0-9a-fA-F]+):0$/);
|
|
183
|
+
if (agentLegacy?.[1]) {
|
|
184
|
+
const route = parseRouteFromHexScope(agentLegacy[1].toLowerCase());
|
|
185
|
+
if (route) return buildFallbackSessionKey(route);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isLowerHex(raw.toLowerCase())) {
|
|
189
|
+
const route = parseRouteFromHexScope(raw.toLowerCase());
|
|
190
|
+
if (route) return buildFallbackSessionKey(route);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isLegacyNoiseRoute(route: BncrRoute): boolean {
|
|
197
|
+
const platform = asString(route.platform).trim().toLowerCase();
|
|
198
|
+
const groupId = asString(route.groupId).trim().toLowerCase();
|
|
199
|
+
const userId = asString(route.userId).trim().toLowerCase();
|
|
200
|
+
|
|
201
|
+
// 明确排除历史污染:agent:main:bncr(不是实际外部会话路由)
|
|
202
|
+
if (platform === 'agent' && groupId === 'main' && userId === 'bncr') return true;
|
|
203
|
+
|
|
204
|
+
// 明确排除嵌套遗留:bncr:<hex>:0(非真实外部 peer)
|
|
205
|
+
if (platform === 'bncr' && userId === '0' && isLowerHex(groupId)) return true;
|
|
206
|
+
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeStoredSessionKey(input: string): { sessionKey: string; route: BncrRoute } | null {
|
|
211
|
+
const raw = asString(input).trim();
|
|
212
|
+
if (!raw) return null;
|
|
213
|
+
|
|
214
|
+
let taskKey: string | null = null;
|
|
215
|
+
let base = raw;
|
|
216
|
+
|
|
217
|
+
const taskTagged = raw.match(/^(.*):task:([a-z0-9_-]{1,32})$/i);
|
|
218
|
+
if (taskTagged) {
|
|
219
|
+
base = asString(taskTagged[1]).trim();
|
|
220
|
+
taskKey = normalizeTaskKey(taskTagged[2]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const strict = parseStrictBncrSessionKey(base);
|
|
224
|
+
if (strict) {
|
|
225
|
+
if (isLegacyNoiseRoute(strict.route)) return null;
|
|
226
|
+
return {
|
|
227
|
+
sessionKey: taskKey ? `${strict.sessionKey}:task:${taskKey}` : strict.sessionKey,
|
|
228
|
+
route: strict.route,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const migrated = parseLegacySessionKeyToStrict(base);
|
|
233
|
+
if (!migrated) return null;
|
|
234
|
+
|
|
235
|
+
const parsed = parseStrictBncrSessionKey(migrated);
|
|
236
|
+
if (!parsed) return null;
|
|
237
|
+
if (isLegacyNoiseRoute(parsed.route)) return null;
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
sessionKey: taskKey ? `${parsed.sessionKey}:task:${taskKey}` : parsed.sessionKey,
|
|
241
|
+
route: parsed.route,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const BNCR_SESSION_KEY_PREFIX = 'agent:main:bncr:direct:';
|
|
246
|
+
|
|
247
|
+
function hex2utf8SessionKey(str: string): { sessionKey: string; scope: string } {
|
|
248
|
+
const raw = {
|
|
249
|
+
sessionKey: '',
|
|
250
|
+
scope: '',
|
|
251
|
+
};
|
|
252
|
+
if (!str) return raw;
|
|
253
|
+
|
|
254
|
+
const strarr = asString(str).trim().split(':');
|
|
255
|
+
const newarr: string[] = [];
|
|
256
|
+
|
|
257
|
+
for (const s of strarr) {
|
|
258
|
+
const part = asString(s).trim();
|
|
259
|
+
if (!part) {
|
|
260
|
+
newarr.push(part);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const decoded = Buffer.from(part, 'hex').toString('utf8');
|
|
265
|
+
if (decoded?.split(':')?.length === 3) {
|
|
266
|
+
newarr.push(decoded);
|
|
267
|
+
raw.scope = decoded;
|
|
268
|
+
} else {
|
|
269
|
+
newarr.push(part);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
raw.sessionKey = newarr.join(':').trim();
|
|
274
|
+
return raw;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
|
|
278
|
+
const raw = asString(input).trim();
|
|
279
|
+
if (!raw) return null;
|
|
280
|
+
if (!raw.startsWith(BNCR_SESSION_KEY_PREFIX)) return null;
|
|
281
|
+
|
|
282
|
+
const parts = raw.split(':');
|
|
283
|
+
// 仅接受:agent:main:bncr:direct:<hexScope>
|
|
284
|
+
if (parts.length !== 5) return null;
|
|
285
|
+
if (parts[0] !== 'agent' || parts[1] !== 'main' || parts[2] !== 'bncr' || parts[3] !== 'direct') {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const scopeHex = asString(parts[4]).trim().toLowerCase();
|
|
290
|
+
if (!isLowerHex(scopeHex)) return null;
|
|
291
|
+
|
|
292
|
+
const decoded = hex2utf8SessionKey(raw);
|
|
293
|
+
const route = parseRouteFromScope(decoded.scope);
|
|
294
|
+
if (!route) return null;
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
sessionKey: raw,
|
|
298
|
+
scopeHex,
|
|
299
|
+
route,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeInboundSessionKey(scope: string, route: BncrRoute): string | null {
|
|
304
|
+
const raw = asString(scope).trim();
|
|
305
|
+
if (!raw) return buildFallbackSessionKey(route);
|
|
306
|
+
|
|
307
|
+
const parsed = parseStrictBncrSessionKey(raw);
|
|
308
|
+
if (!parsed) return null;
|
|
309
|
+
return parsed.sessionKey;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function normalizeTaskKey(input: unknown): string | null {
|
|
313
|
+
const raw = asString(input).trim().toLowerCase();
|
|
314
|
+
if (!raw) return null;
|
|
315
|
+
|
|
316
|
+
const normalized = raw.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
|
317
|
+
return normalized || null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function extractInlineTaskKey(text: string): { taskKey: string | null; text: string } {
|
|
321
|
+
const raw = asString(text);
|
|
322
|
+
if (!raw) return { taskKey: null, text: '' };
|
|
323
|
+
|
|
324
|
+
// 形如:#task:xxx 或 /task:xxx
|
|
325
|
+
const tagged = raw.match(/^\s*(?:#task|\/task)\s*[:=]\s*([a-zA-Z0-9_-]{1,32})\s*\n?\s*([\s\S]*)$/i);
|
|
326
|
+
if (tagged) {
|
|
327
|
+
return {
|
|
328
|
+
taskKey: normalizeTaskKey(tagged[1]),
|
|
329
|
+
text: asString(tagged[2]),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 形如:/task xxx 后跟正文
|
|
334
|
+
const spaced = raw.match(/^\s*\/task\s+([a-zA-Z0-9_-]{1,32})\s+([\s\S]*)$/i);
|
|
335
|
+
if (spaced) {
|
|
336
|
+
return {
|
|
337
|
+
taskKey: normalizeTaskKey(spaced[1]),
|
|
338
|
+
text: asString(spaced[2]),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { taskKey: null, text: raw };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string {
|
|
346
|
+
const base = asString(sessionKey).trim();
|
|
347
|
+
const tk = normalizeTaskKey(taskKey);
|
|
348
|
+
if (!base || !tk) return base;
|
|
349
|
+
|
|
350
|
+
if (/:task:[a-z0-9_-]+(?:$|:)/i.test(base)) return base;
|
|
351
|
+
return `${base}:task:${tk}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildFallbackSessionKey(route: BncrRoute): string {
|
|
355
|
+
return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function backoffMs(retryCount: number): number {
|
|
359
|
+
// 1s,2s,4s,8s... capped by retry count checks
|
|
360
|
+
return Math.max(1_000, 1_000 * 2 ** Math.max(0, retryCount - 1));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function inboundDedupKey(params: {
|
|
364
|
+
accountId: string;
|
|
365
|
+
platform: string;
|
|
366
|
+
groupId: string;
|
|
367
|
+
userId: string;
|
|
368
|
+
msgId?: string;
|
|
369
|
+
text?: string;
|
|
370
|
+
mediaBase64?: string;
|
|
371
|
+
}): string {
|
|
372
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
373
|
+
const platform = asString(params.platform).trim().toLowerCase();
|
|
374
|
+
const groupId = asString(params.groupId).trim();
|
|
375
|
+
const userId = asString(params.userId).trim();
|
|
376
|
+
const msgId = asString(params.msgId || '').trim();
|
|
377
|
+
|
|
378
|
+
if (msgId) return `${accountId}|${platform}|${groupId}|${userId}|msg:${msgId}`;
|
|
379
|
+
|
|
380
|
+
const text = asString(params.text || '').trim();
|
|
381
|
+
const media = asString(params.mediaBase64 || '');
|
|
382
|
+
const digest = createHash('sha1').update(`${text}\n${media.slice(0, 256)}`).digest('hex').slice(0, 16);
|
|
383
|
+
return `${accountId}|${platform}|${groupId}|${userId}|hash:${digest}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveChatType(route: BncrRoute): 'direct' | 'group' {
|
|
387
|
+
return route.groupId === '0' ? 'direct' : 'group';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function routeKey(accountId: string, route: BncrRoute): string {
|
|
391
|
+
return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
class BncrBridgeRuntime {
|
|
395
|
+
private api: OpenClawPluginApi;
|
|
396
|
+
private statePath: string | null = null;
|
|
397
|
+
|
|
398
|
+
private connections = new Map<string, BncrConnection>(); // connectionKey -> connection
|
|
399
|
+
private activeConnectionByAccount = new Map<string, string>(); // accountId -> connectionKey
|
|
400
|
+
private outbox = new Map<string, OutboxEntry>(); // messageId -> entry
|
|
401
|
+
private deadLetter: OutboxEntry[] = [];
|
|
402
|
+
|
|
403
|
+
private sessionRoutes = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
|
|
404
|
+
private routeAliases = new Map<string, { accountId: string; route: BncrRoute; updatedAt: number }>();
|
|
405
|
+
|
|
406
|
+
private recentInbound = new Map<string, number>();
|
|
407
|
+
private lastSessionByAccount = new Map<string, { sessionKey: string; scope: string; updatedAt: number }>();
|
|
408
|
+
private lastActivityByAccount = new Map<string, number>();
|
|
409
|
+
private lastInboundByAccount = new Map<string, number>();
|
|
410
|
+
private lastOutboundByAccount = new Map<string, number>();
|
|
411
|
+
|
|
412
|
+
private saveTimer: NodeJS.Timeout | null = null;
|
|
413
|
+
private pushTimer: NodeJS.Timeout | null = null;
|
|
414
|
+
private waiters = new Map<string, Array<() => void>>();
|
|
415
|
+
private gatewayContext: GatewayRequestHandlerOptions['context'] | null = null;
|
|
416
|
+
|
|
417
|
+
constructor(api: OpenClawPluginApi) {
|
|
418
|
+
this.api = api;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
startService = async (ctx: OpenClawPluginServiceContext) => {
|
|
422
|
+
this.statePath = path.join(ctx.stateDir, 'bncr-bridge-state.json');
|
|
423
|
+
await this.loadState();
|
|
424
|
+
this.api.logger.info('bncr-channel service started');
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
stopService = async () => {
|
|
428
|
+
if (this.pushTimer) {
|
|
429
|
+
clearTimeout(this.pushTimer);
|
|
430
|
+
this.pushTimer = null;
|
|
431
|
+
}
|
|
432
|
+
await this.flushState();
|
|
433
|
+
this.api.logger.info('bncr-channel service stopped');
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
private scheduleSave() {
|
|
437
|
+
if (this.saveTimer) return;
|
|
438
|
+
this.saveTimer = setTimeout(() => {
|
|
439
|
+
this.saveTimer = null;
|
|
440
|
+
void this.flushState();
|
|
441
|
+
}, 300);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private async loadState() {
|
|
445
|
+
if (!this.statePath) return;
|
|
446
|
+
const loaded = await readJsonFileWithFallback(this.statePath, {
|
|
447
|
+
outbox: [],
|
|
448
|
+
deadLetter: [],
|
|
449
|
+
sessionRoutes: [],
|
|
450
|
+
});
|
|
451
|
+
const data = loaded.value as PersistedState;
|
|
452
|
+
|
|
453
|
+
this.outbox.clear();
|
|
454
|
+
for (const entry of data.outbox || []) {
|
|
455
|
+
if (!entry?.messageId) continue;
|
|
456
|
+
const accountId = normalizeAccountId(entry.accountId);
|
|
457
|
+
const sessionKey = asString(entry.sessionKey || '').trim();
|
|
458
|
+
const normalized = normalizeStoredSessionKey(sessionKey);
|
|
459
|
+
if (!normalized) continue;
|
|
460
|
+
|
|
461
|
+
const route = parseRouteLike(entry.route) || normalized.route;
|
|
462
|
+
const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
|
|
463
|
+
(payload as any).sessionKey = normalized.sessionKey;
|
|
464
|
+
(payload as any).platform = route.platform;
|
|
465
|
+
(payload as any).groupId = route.groupId;
|
|
466
|
+
(payload as any).userId = route.userId;
|
|
467
|
+
|
|
468
|
+
const migratedEntry: OutboxEntry = {
|
|
469
|
+
...entry,
|
|
470
|
+
accountId,
|
|
471
|
+
sessionKey: normalized.sessionKey,
|
|
472
|
+
route,
|
|
473
|
+
payload,
|
|
474
|
+
createdAt: Number(entry.createdAt || now()),
|
|
475
|
+
retryCount: Number(entry.retryCount || 0),
|
|
476
|
+
nextAttemptAt: Number(entry.nextAttemptAt || now()),
|
|
477
|
+
lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
|
|
478
|
+
lastError: entry.lastError ? asString(entry.lastError) : undefined,
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
this.outbox.set(migratedEntry.messageId, migratedEntry);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
this.deadLetter = [];
|
|
485
|
+
for (const entry of Array.isArray(data.deadLetter) ? data.deadLetter : []) {
|
|
486
|
+
if (!entry?.messageId) continue;
|
|
487
|
+
const accountId = normalizeAccountId(entry.accountId);
|
|
488
|
+
const sessionKey = asString(entry.sessionKey || '').trim();
|
|
489
|
+
const normalized = normalizeStoredSessionKey(sessionKey);
|
|
490
|
+
if (!normalized) continue;
|
|
491
|
+
|
|
492
|
+
const route = parseRouteLike(entry.route) || normalized.route;
|
|
493
|
+
const payload = (entry.payload && typeof entry.payload === 'object') ? { ...entry.payload } : {};
|
|
494
|
+
(payload as any).sessionKey = normalized.sessionKey;
|
|
495
|
+
(payload as any).platform = route.platform;
|
|
496
|
+
(payload as any).groupId = route.groupId;
|
|
497
|
+
(payload as any).userId = route.userId;
|
|
498
|
+
|
|
499
|
+
this.deadLetter.push({
|
|
500
|
+
...entry,
|
|
501
|
+
accountId,
|
|
502
|
+
sessionKey: normalized.sessionKey,
|
|
503
|
+
route,
|
|
504
|
+
payload,
|
|
505
|
+
createdAt: Number(entry.createdAt || now()),
|
|
506
|
+
retryCount: Number(entry.retryCount || 0),
|
|
507
|
+
nextAttemptAt: Number(entry.nextAttemptAt || now()),
|
|
508
|
+
lastAttemptAt: entry.lastAttemptAt ? Number(entry.lastAttemptAt) : undefined,
|
|
509
|
+
lastError: entry.lastError ? asString(entry.lastError) : undefined,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
this.sessionRoutes.clear();
|
|
514
|
+
this.routeAliases.clear();
|
|
515
|
+
for (const item of data.sessionRoutes || []) {
|
|
516
|
+
const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
|
|
517
|
+
if (!normalized) continue;
|
|
518
|
+
|
|
519
|
+
const route = parseRouteLike(item?.route) || normalized.route;
|
|
520
|
+
const accountId = normalizeAccountId(item?.accountId);
|
|
521
|
+
const updatedAt = Number(item?.updatedAt || now());
|
|
522
|
+
|
|
523
|
+
const info = {
|
|
524
|
+
accountId,
|
|
525
|
+
route,
|
|
526
|
+
updatedAt,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
this.sessionRoutes.set(normalized.sessionKey, info);
|
|
530
|
+
this.routeAliases.set(routeKey(accountId, route), info);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.lastSessionByAccount.clear();
|
|
534
|
+
for (const item of data.lastSessionByAccount || []) {
|
|
535
|
+
const accountId = normalizeAccountId(item?.accountId);
|
|
536
|
+
const normalized = normalizeStoredSessionKey(asString(item?.sessionKey || ''));
|
|
537
|
+
const updatedAt = Number(item?.updatedAt || 0);
|
|
538
|
+
if (!normalized || !Number.isFinite(updatedAt) || updatedAt <= 0) continue;
|
|
539
|
+
|
|
540
|
+
this.lastSessionByAccount.set(accountId, {
|
|
541
|
+
sessionKey: normalized.sessionKey,
|
|
542
|
+
// 展示统一为 Bncr-platform:group:user
|
|
543
|
+
scope: formatDisplayScope(normalized.route),
|
|
544
|
+
updatedAt,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
this.lastActivityByAccount.clear();
|
|
549
|
+
for (const item of data.lastActivityByAccount || []) {
|
|
550
|
+
const accountId = normalizeAccountId(item?.accountId);
|
|
551
|
+
const updatedAt = Number(item?.updatedAt || 0);
|
|
552
|
+
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
|
|
553
|
+
this.lastActivityByAccount.set(accountId, updatedAt);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
this.lastInboundByAccount.clear();
|
|
557
|
+
for (const item of data.lastInboundByAccount || []) {
|
|
558
|
+
const accountId = normalizeAccountId(item?.accountId);
|
|
559
|
+
const updatedAt = Number(item?.updatedAt || 0);
|
|
560
|
+
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
|
|
561
|
+
this.lastInboundByAccount.set(accountId, updatedAt);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.lastOutboundByAccount.clear();
|
|
565
|
+
for (const item of data.lastOutboundByAccount || []) {
|
|
566
|
+
const accountId = normalizeAccountId(item?.accountId);
|
|
567
|
+
const updatedAt = Number(item?.updatedAt || 0);
|
|
568
|
+
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
|
|
569
|
+
this.lastOutboundByAccount.set(accountId, updatedAt);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 兼容旧状态文件:若尚未持久化 lastSession*/lastActivity*,从 sessionRoutes 回填。
|
|
573
|
+
if (this.lastSessionByAccount.size === 0 && this.sessionRoutes.size > 0) {
|
|
574
|
+
for (const [sessionKey, info] of this.sessionRoutes.entries()) {
|
|
575
|
+
const acc = normalizeAccountId(info.accountId);
|
|
576
|
+
const updatedAt = Number(info.updatedAt || 0);
|
|
577
|
+
if (!Number.isFinite(updatedAt) || updatedAt <= 0) continue;
|
|
578
|
+
|
|
579
|
+
const current = this.lastSessionByAccount.get(acc);
|
|
580
|
+
if (!current || updatedAt >= current.updatedAt) {
|
|
581
|
+
this.lastSessionByAccount.set(acc, {
|
|
582
|
+
sessionKey,
|
|
583
|
+
// 回填时统一展示为 Bncr-platform:group:user
|
|
584
|
+
scope: formatDisplayScope(info.route),
|
|
585
|
+
updatedAt,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const lastAct = this.lastActivityByAccount.get(acc) || 0;
|
|
590
|
+
if (updatedAt > lastAct) this.lastActivityByAccount.set(acc, updatedAt);
|
|
591
|
+
|
|
592
|
+
const lastIn = this.lastInboundByAccount.get(acc) || 0;
|
|
593
|
+
if (updatedAt > lastIn) this.lastInboundByAccount.set(acc, updatedAt);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async flushState() {
|
|
599
|
+
if (!this.statePath) return;
|
|
600
|
+
|
|
601
|
+
const sessionRoutes = Array.from(this.sessionRoutes.entries())
|
|
602
|
+
.map(([sessionKey, v]) => ({
|
|
603
|
+
sessionKey,
|
|
604
|
+
accountId: v.accountId,
|
|
605
|
+
route: v.route,
|
|
606
|
+
updatedAt: v.updatedAt,
|
|
607
|
+
}))
|
|
608
|
+
.slice(-1000);
|
|
609
|
+
|
|
610
|
+
const data: PersistedState = {
|
|
611
|
+
outbox: Array.from(this.outbox.values()),
|
|
612
|
+
deadLetter: this.deadLetter.slice(-1000),
|
|
613
|
+
sessionRoutes,
|
|
614
|
+
lastSessionByAccount: Array.from(this.lastSessionByAccount.entries()).map(([accountId, v]) => ({
|
|
615
|
+
accountId,
|
|
616
|
+
sessionKey: v.sessionKey,
|
|
617
|
+
scope: v.scope,
|
|
618
|
+
updatedAt: v.updatedAt,
|
|
619
|
+
})),
|
|
620
|
+
lastActivityByAccount: Array.from(this.lastActivityByAccount.entries()).map(([accountId, updatedAt]) => ({
|
|
621
|
+
accountId,
|
|
622
|
+
updatedAt,
|
|
623
|
+
})),
|
|
624
|
+
lastInboundByAccount: Array.from(this.lastInboundByAccount.entries()).map(([accountId, updatedAt]) => ({
|
|
625
|
+
accountId,
|
|
626
|
+
updatedAt,
|
|
627
|
+
})),
|
|
628
|
+
lastOutboundByAccount: Array.from(this.lastOutboundByAccount.entries()).map(([accountId, updatedAt]) => ({
|
|
629
|
+
accountId,
|
|
630
|
+
updatedAt,
|
|
631
|
+
})),
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
await writeJsonFileAtomically(this.statePath, data);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private wakeAccountWaiters(accountId: string) {
|
|
638
|
+
const key = normalizeAccountId(accountId);
|
|
639
|
+
const waits = this.waiters.get(key);
|
|
640
|
+
if (!waits?.length) return;
|
|
641
|
+
this.waiters.delete(key);
|
|
642
|
+
for (const resolve of waits) resolve();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private rememberGatewayContext(context: GatewayRequestHandlerOptions['context']) {
|
|
646
|
+
if (context) this.gatewayContext = context;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private resolvePushConnIds(accountId: string): Set<string> {
|
|
650
|
+
const acc = normalizeAccountId(accountId);
|
|
651
|
+
const t = now();
|
|
652
|
+
const connIds = new Set<string>();
|
|
653
|
+
|
|
654
|
+
const primaryKey = this.activeConnectionByAccount.get(acc);
|
|
655
|
+
if (primaryKey) {
|
|
656
|
+
const primary = this.connections.get(primaryKey);
|
|
657
|
+
if (primary?.connId && t - primary.lastSeenAt <= CONNECT_TTL_MS) {
|
|
658
|
+
connIds.add(primary.connId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (connIds.size > 0) return connIds;
|
|
663
|
+
|
|
664
|
+
for (const c of this.connections.values()) {
|
|
665
|
+
if (c.accountId !== acc) continue;
|
|
666
|
+
if (!c.connId) continue;
|
|
667
|
+
if (t - c.lastSeenAt > CONNECT_TTL_MS) continue;
|
|
668
|
+
connIds.add(c.connId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return connIds;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private tryPushEntry(entry: OutboxEntry): boolean {
|
|
675
|
+
const ctx = this.gatewayContext;
|
|
676
|
+
if (!ctx) return false;
|
|
677
|
+
|
|
678
|
+
const connIds = this.resolvePushConnIds(entry.accountId);
|
|
679
|
+
if (!connIds.size) return false;
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const payload = {
|
|
683
|
+
...entry.payload,
|
|
684
|
+
idempotencyKey: entry.messageId,
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
ctx.broadcastToConnIds(BNCR_PUSH_EVENT, payload, connIds);
|
|
688
|
+
this.outbox.delete(entry.messageId);
|
|
689
|
+
this.lastOutboundByAccount.set(entry.accountId, now());
|
|
690
|
+
this.markActivity(entry.accountId);
|
|
691
|
+
this.scheduleSave();
|
|
692
|
+
return true;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
entry.lastError = asString((error as any)?.message || error || 'push-error');
|
|
695
|
+
this.outbox.set(entry.messageId, entry);
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
private schedulePushDrain(delayMs = 0) {
|
|
701
|
+
if (this.pushTimer) return;
|
|
702
|
+
const delay = Math.max(0, Math.min(Number(delayMs || 0), 30_000));
|
|
703
|
+
this.pushTimer = setTimeout(() => {
|
|
704
|
+
this.pushTimer = null;
|
|
705
|
+
this.flushPushQueue();
|
|
706
|
+
}, delay);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private flushPushQueue(accountId?: string) {
|
|
710
|
+
const t = now();
|
|
711
|
+
const filterAcc = accountId ? normalizeAccountId(accountId) : null;
|
|
712
|
+
const entries = Array.from(this.outbox.values())
|
|
713
|
+
.filter((entry) => (filterAcc ? entry.accountId === filterAcc : true))
|
|
714
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
715
|
+
|
|
716
|
+
let changed = false;
|
|
717
|
+
let nextDelay: number | null = null;
|
|
718
|
+
|
|
719
|
+
for (const entry of entries) {
|
|
720
|
+
if (!this.isOnline(entry.accountId)) continue;
|
|
721
|
+
|
|
722
|
+
if (entry.nextAttemptAt > t) {
|
|
723
|
+
const wait = entry.nextAttemptAt - t;
|
|
724
|
+
nextDelay = nextDelay == null ? wait : Math.min(nextDelay, wait);
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const pushed = this.tryPushEntry(entry);
|
|
729
|
+
if (pushed) {
|
|
730
|
+
changed = true;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const nextAttempt = entry.retryCount + 1;
|
|
735
|
+
if (nextAttempt > MAX_RETRY) {
|
|
736
|
+
this.moveToDeadLetter(entry, entry.lastError || 'push-retry-limit');
|
|
737
|
+
changed = true;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
entry.retryCount = nextAttempt;
|
|
742
|
+
entry.lastAttemptAt = t;
|
|
743
|
+
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
744
|
+
entry.lastError = entry.lastError || 'push-retry';
|
|
745
|
+
this.outbox.set(entry.messageId, entry);
|
|
746
|
+
changed = true;
|
|
747
|
+
|
|
748
|
+
const wait = entry.nextAttemptAt - t;
|
|
749
|
+
nextDelay = nextDelay == null ? wait : Math.min(nextDelay, wait);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (changed) this.scheduleSave();
|
|
753
|
+
if (nextDelay != null) this.schedulePushDrain(nextDelay);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private async waitForOutbound(accountId: string, waitMs: number): Promise<void> {
|
|
757
|
+
const key = normalizeAccountId(accountId);
|
|
758
|
+
const timeoutMs = Math.max(0, Math.min(waitMs, 25_000));
|
|
759
|
+
if (!timeoutMs) return;
|
|
760
|
+
|
|
761
|
+
await new Promise<void>((resolve) => {
|
|
762
|
+
const timer = setTimeout(() => {
|
|
763
|
+
const arr = this.waiters.get(key) || [];
|
|
764
|
+
this.waiters.set(
|
|
765
|
+
key,
|
|
766
|
+
arr.filter((fn) => fn !== done),
|
|
767
|
+
);
|
|
768
|
+
resolve();
|
|
769
|
+
}, timeoutMs);
|
|
770
|
+
|
|
771
|
+
const done = () => {
|
|
772
|
+
clearTimeout(timer);
|
|
773
|
+
resolve();
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const arr = this.waiters.get(key) || [];
|
|
777
|
+
arr.push(done);
|
|
778
|
+
this.waiters.set(key, arr);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private connectionKey(accountId: string, clientId?: string): string {
|
|
783
|
+
const acc = normalizeAccountId(accountId);
|
|
784
|
+
const cid = asString(clientId || '').trim();
|
|
785
|
+
return `${acc}::${cid || 'default'}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private gcTransientState() {
|
|
789
|
+
const t = now();
|
|
790
|
+
|
|
791
|
+
// 清理过期连接
|
|
792
|
+
const staleBefore = t - CONNECT_TTL_MS * 2;
|
|
793
|
+
for (const [key, c] of this.connections.entries()) {
|
|
794
|
+
if (c.lastSeenAt < staleBefore) this.connections.delete(key);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// 清理去重窗口(90s)
|
|
798
|
+
const dedupWindowMs = 90_000;
|
|
799
|
+
for (const [key, ts] of this.recentInbound.entries()) {
|
|
800
|
+
if (t - ts > dedupWindowMs) this.recentInbound.delete(key);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private markSeen(accountId: string, connId: string, clientId?: string) {
|
|
805
|
+
this.gcTransientState();
|
|
806
|
+
|
|
807
|
+
const acc = normalizeAccountId(accountId);
|
|
808
|
+
const key = this.connectionKey(acc, clientId);
|
|
809
|
+
const t = now();
|
|
810
|
+
const prev = this.connections.get(key);
|
|
811
|
+
|
|
812
|
+
const nextConn: BncrConnection = {
|
|
813
|
+
accountId: acc,
|
|
814
|
+
connId,
|
|
815
|
+
clientId: asString(clientId || '').trim() || undefined,
|
|
816
|
+
connectedAt: prev?.connectedAt || t,
|
|
817
|
+
lastSeenAt: t,
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
this.connections.set(key, nextConn);
|
|
821
|
+
|
|
822
|
+
const current = this.activeConnectionByAccount.get(acc);
|
|
823
|
+
if (!current) {
|
|
824
|
+
this.activeConnectionByAccount.set(acc, key);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const curConn = this.connections.get(current);
|
|
829
|
+
if (!curConn || t - curConn.lastSeenAt > CONNECT_TTL_MS || nextConn.connectedAt >= curConn.connectedAt) {
|
|
830
|
+
this.activeConnectionByAccount.set(acc, key);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private isOnline(accountId: string): boolean {
|
|
835
|
+
const acc = normalizeAccountId(accountId);
|
|
836
|
+
const t = now();
|
|
837
|
+
for (const c of this.connections.values()) {
|
|
838
|
+
if (c.accountId !== acc) continue;
|
|
839
|
+
if (t - c.lastSeenAt <= CONNECT_TTL_MS) return true;
|
|
840
|
+
}
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
private activeConnectionCount(accountId: string): number {
|
|
845
|
+
const acc = normalizeAccountId(accountId);
|
|
846
|
+
const t = now();
|
|
847
|
+
let n = 0;
|
|
848
|
+
for (const c of this.connections.values()) {
|
|
849
|
+
if (c.accountId !== acc) continue;
|
|
850
|
+
if (t - c.lastSeenAt <= CONNECT_TTL_MS) n += 1;
|
|
851
|
+
}
|
|
852
|
+
return n;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private isPrimaryConnection(accountId: string, clientId?: string): boolean {
|
|
856
|
+
const acc = normalizeAccountId(accountId);
|
|
857
|
+
const key = this.connectionKey(acc, clientId);
|
|
858
|
+
const primary = this.activeConnectionByAccount.get(acc);
|
|
859
|
+
if (!primary) return true;
|
|
860
|
+
return primary === key;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private markInboundDedupSeen(key: string): boolean {
|
|
864
|
+
const t = now();
|
|
865
|
+
const last = this.recentInbound.get(key);
|
|
866
|
+
this.recentInbound.set(key, t);
|
|
867
|
+
|
|
868
|
+
// 90s 内重复包直接丢弃
|
|
869
|
+
return typeof last === 'number' && t - last <= 90_000;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private rememberSessionRoute(sessionKey: string, accountId: string, route: BncrRoute) {
|
|
873
|
+
const key = asString(sessionKey).trim();
|
|
874
|
+
if (!key) return;
|
|
875
|
+
|
|
876
|
+
const acc = normalizeAccountId(accountId);
|
|
877
|
+
const t = now();
|
|
878
|
+
const info = { accountId: acc, route, updatedAt: t };
|
|
879
|
+
|
|
880
|
+
this.sessionRoutes.set(key, info);
|
|
881
|
+
// 同步维护旧格式与新格式,便于平滑切换
|
|
882
|
+
this.sessionRoutes.set(buildFallbackSessionKey(route), info);
|
|
883
|
+
|
|
884
|
+
this.routeAliases.set(routeKey(acc, route), info);
|
|
885
|
+
this.lastSessionByAccount.set(acc, {
|
|
886
|
+
sessionKey: key,
|
|
887
|
+
// 状态展示统一为 Bncr-platform:group:user
|
|
888
|
+
scope: formatDisplayScope(route),
|
|
889
|
+
updatedAt: t,
|
|
890
|
+
});
|
|
891
|
+
this.markActivity(acc, t);
|
|
892
|
+
this.scheduleSave();
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private resolveRouteBySession(sessionKey: string, accountId: string): BncrRoute | null {
|
|
896
|
+
const key = asString(sessionKey).trim();
|
|
897
|
+
const hit = this.sessionRoutes.get(key);
|
|
898
|
+
if (hit && normalizeAccountId(accountId) === normalizeAccountId(hit.accountId)) {
|
|
899
|
+
return hit.route;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const parsed = parseStrictBncrSessionKey(key);
|
|
903
|
+
if (!parsed) return null;
|
|
904
|
+
|
|
905
|
+
const alias = this.routeAliases.get(routeKey(normalizeAccountId(accountId), parsed.route));
|
|
906
|
+
return alias?.route || parsed.route;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 严谨目标解析:
|
|
910
|
+
// 1) 先接受任意标签输入(strict / platform:group:user / Bncr-platform:group:user)
|
|
911
|
+
// 2) 再通过已知会话路由反查“真实 sessionKey”
|
|
912
|
+
// 3) 若反查不到或不属于 bncr,会直接失败(禁止拼凑 key 发送)
|
|
913
|
+
private resolveVerifiedTarget(rawTarget: string, accountId: string): { sessionKey: string; route: BncrRoute; displayScope: string } {
|
|
914
|
+
const acc = normalizeAccountId(accountId);
|
|
915
|
+
const raw = asString(rawTarget).trim();
|
|
916
|
+
if (!raw) throw new Error('bncr invalid target(empty)');
|
|
917
|
+
|
|
918
|
+
this.api.logger.info?.(`[bncr-target-incoming] raw=${raw} accountId=${acc}`);
|
|
919
|
+
|
|
920
|
+
let route: BncrRoute | null = null;
|
|
921
|
+
|
|
922
|
+
const strict = parseStrictBncrSessionKey(raw);
|
|
923
|
+
if (strict) {
|
|
924
|
+
route = strict.route;
|
|
925
|
+
} else {
|
|
926
|
+
route = parseRouteFromDisplayScope(raw) || this.resolveRouteBySession(raw, acc);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!route) {
|
|
930
|
+
this.api.logger.warn?.(`[bncr-target-invalid] raw=${raw} accountId=${acc} reason=unparseable-or-unknown`);
|
|
931
|
+
throw new Error(`bncr invalid target(label/sessionKey required): ${raw}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const wantedRouteKey = routeKey(acc, route);
|
|
935
|
+
let best: { sessionKey: string; route: BncrRoute; updatedAt: number } | null = null;
|
|
936
|
+
|
|
937
|
+
for (const [key, info] of this.sessionRoutes.entries()) {
|
|
938
|
+
if (normalizeAccountId(info.accountId) !== acc) continue;
|
|
939
|
+
const parsed = parseStrictBncrSessionKey(key);
|
|
940
|
+
if (!parsed) continue;
|
|
941
|
+
if (routeKey(acc, parsed.route) !== wantedRouteKey) continue;
|
|
942
|
+
|
|
943
|
+
const updatedAt = Number(info.updatedAt || 0);
|
|
944
|
+
if (!best || updatedAt >= best.updatedAt) {
|
|
945
|
+
best = {
|
|
946
|
+
sessionKey: parsed.sessionKey,
|
|
947
|
+
route: parsed.route,
|
|
948
|
+
updatedAt,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (!best) {
|
|
954
|
+
this.api.logger.warn?.(`[bncr-target-miss] raw=${raw} accountId=${acc} sessionRoutes=${this.sessionRoutes.size}`);
|
|
955
|
+
throw new Error(`bncr target not found in known sessions: ${raw}`);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
sessionKey: best.sessionKey,
|
|
960
|
+
route: best.route,
|
|
961
|
+
displayScope: formatDisplayScope(best.route),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private markActivity(accountId: string, at = now()) {
|
|
966
|
+
this.lastActivityByAccount.set(normalizeAccountId(accountId), at);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
private fmtAgo(ts?: number | null): string {
|
|
970
|
+
if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
|
|
971
|
+
const diff = Math.max(0, now() - ts);
|
|
972
|
+
if (diff < 1_000) return 'just now';
|
|
973
|
+
if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`;
|
|
974
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
975
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
976
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private buildStatusMeta(accountId: string) {
|
|
980
|
+
const acc = normalizeAccountId(accountId);
|
|
981
|
+
const pending = Array.from(this.outbox.values()).filter((v) => v.accountId === acc).length;
|
|
982
|
+
const dead = this.deadLetter.filter((v) => v.accountId === acc).length;
|
|
983
|
+
const last = this.lastSessionByAccount.get(acc);
|
|
984
|
+
const lastActAt = this.lastActivityByAccount.get(acc) || null;
|
|
985
|
+
const lastInboundAt = this.lastInboundByAccount.get(acc) || null;
|
|
986
|
+
const lastOutboundAt = this.lastOutboundByAccount.get(acc) || null;
|
|
987
|
+
|
|
988
|
+
const lastSessionAgo = this.fmtAgo(last?.updatedAt || null);
|
|
989
|
+
const lastActivityAgo = this.fmtAgo(lastActAt);
|
|
990
|
+
const lastInboundAgo = this.fmtAgo(lastInboundAt);
|
|
991
|
+
const lastOutboundAgo = this.fmtAgo(lastOutboundAt);
|
|
992
|
+
|
|
993
|
+
return {
|
|
994
|
+
pending,
|
|
995
|
+
deadLetter: dead,
|
|
996
|
+
lastSessionKey: last?.sessionKey || null,
|
|
997
|
+
lastSessionScope: last?.scope || null,
|
|
998
|
+
lastSessionAt: last?.updatedAt || null,
|
|
999
|
+
lastSessionAgo,
|
|
1000
|
+
lastActivityAt: lastActAt,
|
|
1001
|
+
lastActivityAgo,
|
|
1002
|
+
lastInboundAt,
|
|
1003
|
+
lastInboundAgo,
|
|
1004
|
+
lastOutboundAt,
|
|
1005
|
+
lastOutboundAgo,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
getAccountRuntimeSnapshot(accountId: string) {
|
|
1010
|
+
const acc = normalizeAccountId(accountId);
|
|
1011
|
+
const connected = this.isOnline(acc);
|
|
1012
|
+
const lastEventAt = this.lastActivityByAccount.get(acc) || null;
|
|
1013
|
+
const lastInboundAt = this.lastInboundByAccount.get(acc) || null;
|
|
1014
|
+
const lastOutboundAt = this.lastOutboundByAccount.get(acc) || null;
|
|
1015
|
+
return {
|
|
1016
|
+
accountId: acc,
|
|
1017
|
+
running: true,
|
|
1018
|
+
connected,
|
|
1019
|
+
linked: connected,
|
|
1020
|
+
lastEventAt,
|
|
1021
|
+
lastInboundAt,
|
|
1022
|
+
lastOutboundAt,
|
|
1023
|
+
// 状态映射:在线=linked,离线=configured
|
|
1024
|
+
mode: connected ? 'linked' : 'configured',
|
|
1025
|
+
meta: this.buildStatusMeta(acc),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
getChannelSummary(defaultAccountId: string) {
|
|
1030
|
+
const runtime = this.getAccountRuntimeSnapshot(defaultAccountId);
|
|
1031
|
+
if (runtime.connected) {
|
|
1032
|
+
return { linked: true };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 顶层汇总不绑定某个 accountId:任一账号在线都应显示 linked
|
|
1036
|
+
const t = now();
|
|
1037
|
+
for (const c of this.connections.values()) {
|
|
1038
|
+
if (t - c.lastSeenAt <= CONNECT_TTL_MS) {
|
|
1039
|
+
return { linked: true };
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return { linked: false };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private enqueueOutbound(entry: OutboxEntry) {
|
|
1047
|
+
this.outbox.set(entry.messageId, entry);
|
|
1048
|
+
this.scheduleSave();
|
|
1049
|
+
this.wakeAccountWaiters(entry.accountId);
|
|
1050
|
+
this.flushPushQueue(entry.accountId);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private moveToDeadLetter(entry: OutboxEntry, reason: string) {
|
|
1054
|
+
const dead: OutboxEntry = {
|
|
1055
|
+
...entry,
|
|
1056
|
+
lastError: reason,
|
|
1057
|
+
};
|
|
1058
|
+
this.deadLetter.push(dead);
|
|
1059
|
+
if (this.deadLetter.length > 1000) this.deadLetter = this.deadLetter.slice(-1000);
|
|
1060
|
+
this.outbox.delete(entry.messageId);
|
|
1061
|
+
this.scheduleSave();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
private collectDue(accountId: string, maxBatch: number): Array<Record<string, unknown>> {
|
|
1065
|
+
const due: Array<Record<string, unknown>> = [];
|
|
1066
|
+
const t = now();
|
|
1067
|
+
const key = normalizeAccountId(accountId);
|
|
1068
|
+
|
|
1069
|
+
for (const entry of this.outbox.values()) {
|
|
1070
|
+
if (entry.accountId !== key) continue;
|
|
1071
|
+
if (entry.nextAttemptAt > t) continue;
|
|
1072
|
+
|
|
1073
|
+
const nextAttempt = entry.retryCount + 1;
|
|
1074
|
+
if (nextAttempt > MAX_RETRY) {
|
|
1075
|
+
this.moveToDeadLetter(entry, 'retry-limit');
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
entry.retryCount = nextAttempt;
|
|
1080
|
+
entry.lastAttemptAt = t;
|
|
1081
|
+
entry.nextAttemptAt = t + backoffMs(nextAttempt);
|
|
1082
|
+
this.outbox.set(entry.messageId, entry);
|
|
1083
|
+
|
|
1084
|
+
due.push({
|
|
1085
|
+
...entry.payload,
|
|
1086
|
+
_meta: {
|
|
1087
|
+
retryCount: entry.retryCount,
|
|
1088
|
+
nextAttemptAt: entry.nextAttemptAt,
|
|
1089
|
+
},
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
if (due.length >= maxBatch) break;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
if (due.length) this.scheduleSave();
|
|
1096
|
+
return due;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private async payloadMediaToBase64(
|
|
1100
|
+
mediaUrl: string,
|
|
1101
|
+
mediaLocalRoots?: readonly string[],
|
|
1102
|
+
): Promise<{ mediaBase64: string; mimeType?: string; fileName?: string }> {
|
|
1103
|
+
const loaded = await this.api.runtime.media.loadWebMedia(mediaUrl, {
|
|
1104
|
+
localRoots: mediaLocalRoots,
|
|
1105
|
+
maxBytes: 20 * 1024 * 1024,
|
|
1106
|
+
});
|
|
1107
|
+
return {
|
|
1108
|
+
mediaBase64: loaded.buffer.toString('base64'),
|
|
1109
|
+
mimeType: loaded.contentType,
|
|
1110
|
+
fileName: loaded.fileName,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private async enqueueFromReply(params: {
|
|
1115
|
+
accountId: string;
|
|
1116
|
+
sessionKey: string;
|
|
1117
|
+
route: BncrRoute;
|
|
1118
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
|
1119
|
+
mediaLocalRoots?: readonly string[];
|
|
1120
|
+
}) {
|
|
1121
|
+
const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
|
|
1122
|
+
|
|
1123
|
+
const mediaList = payload.mediaUrls?.length
|
|
1124
|
+
? payload.mediaUrls
|
|
1125
|
+
: payload.mediaUrl
|
|
1126
|
+
? [payload.mediaUrl]
|
|
1127
|
+
: [];
|
|
1128
|
+
|
|
1129
|
+
if (mediaList.length > 0) {
|
|
1130
|
+
let first = true;
|
|
1131
|
+
for (const mediaUrl of mediaList) {
|
|
1132
|
+
const media = await this.payloadMediaToBase64(mediaUrl, mediaLocalRoots);
|
|
1133
|
+
const messageId = randomUUID();
|
|
1134
|
+
const mediaMsg = first ? asString(payload.text || '') : '';
|
|
1135
|
+
const frame = {
|
|
1136
|
+
type: 'message.outbound',
|
|
1137
|
+
messageId,
|
|
1138
|
+
idempotencyKey: messageId,
|
|
1139
|
+
sessionKey,
|
|
1140
|
+
message: {
|
|
1141
|
+
platform: route.platform,
|
|
1142
|
+
groupId: route.groupId,
|
|
1143
|
+
userId: route.userId,
|
|
1144
|
+
type: media.mimeType,
|
|
1145
|
+
msg: mediaMsg,
|
|
1146
|
+
path: mediaUrl,
|
|
1147
|
+
base64: media.mediaBase64,
|
|
1148
|
+
fileName: media.fileName,
|
|
1149
|
+
},
|
|
1150
|
+
ts: now(),
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
this.enqueueOutbound({
|
|
1154
|
+
messageId,
|
|
1155
|
+
accountId: normalizeAccountId(accountId),
|
|
1156
|
+
sessionKey,
|
|
1157
|
+
route,
|
|
1158
|
+
payload: frame,
|
|
1159
|
+
createdAt: now(),
|
|
1160
|
+
retryCount: 0,
|
|
1161
|
+
nextAttemptAt: now(),
|
|
1162
|
+
});
|
|
1163
|
+
first = false;
|
|
1164
|
+
}
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const text = asString(payload.text || '').trim();
|
|
1169
|
+
if (!text) return;
|
|
1170
|
+
|
|
1171
|
+
const messageId = randomUUID();
|
|
1172
|
+
const frame = {
|
|
1173
|
+
type: 'message.outbound',
|
|
1174
|
+
messageId,
|
|
1175
|
+
idempotencyKey: messageId,
|
|
1176
|
+
sessionKey,
|
|
1177
|
+
message: {
|
|
1178
|
+
platform: route.platform,
|
|
1179
|
+
groupId: route.groupId,
|
|
1180
|
+
userId: route.userId,
|
|
1181
|
+
type: 'text',
|
|
1182
|
+
msg: text,
|
|
1183
|
+
path: '',
|
|
1184
|
+
base64: '',
|
|
1185
|
+
fileName: '',
|
|
1186
|
+
},
|
|
1187
|
+
ts: now(),
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
this.enqueueOutbound({
|
|
1191
|
+
messageId,
|
|
1192
|
+
accountId: normalizeAccountId(accountId),
|
|
1193
|
+
sessionKey,
|
|
1194
|
+
route,
|
|
1195
|
+
payload: frame,
|
|
1196
|
+
createdAt: now(),
|
|
1197
|
+
retryCount: 0,
|
|
1198
|
+
nextAttemptAt: now(),
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
handleConnect = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1203
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1204
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1205
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1206
|
+
|
|
1207
|
+
this.rememberGatewayContext(context);
|
|
1208
|
+
this.markSeen(accountId, connId, clientId);
|
|
1209
|
+
this.markActivity(accountId);
|
|
1210
|
+
|
|
1211
|
+
respond(true, {
|
|
1212
|
+
channel: CHANNEL_ID,
|
|
1213
|
+
accountId,
|
|
1214
|
+
bridgeVersion: BRIDGE_VERSION,
|
|
1215
|
+
pushEvent: BNCR_PUSH_EVENT,
|
|
1216
|
+
online: true,
|
|
1217
|
+
isPrimary: this.isPrimaryConnection(accountId, clientId),
|
|
1218
|
+
activeConnections: this.activeConnectionCount(accountId),
|
|
1219
|
+
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
1220
|
+
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
1221
|
+
now: now(),
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
// WS 一旦在线,立即尝试把离线期间积压队列直推出去
|
|
1225
|
+
this.flushPushQueue(accountId);
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
handleAck = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1229
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1230
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1231
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1232
|
+
this.rememberGatewayContext(context);
|
|
1233
|
+
this.markSeen(accountId, connId, clientId);
|
|
1234
|
+
|
|
1235
|
+
const messageId = asString(params?.messageId || '').trim();
|
|
1236
|
+
if (!messageId) {
|
|
1237
|
+
respond(false, { error: 'messageId required' });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const entry = this.outbox.get(messageId);
|
|
1242
|
+
if (!entry) {
|
|
1243
|
+
respond(true, { ok: true, message: 'already-acked-or-missing' });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (entry.accountId !== accountId) {
|
|
1248
|
+
respond(false, { error: 'account mismatch' });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const ok = params?.ok !== false;
|
|
1253
|
+
const fatal = params?.fatal === true;
|
|
1254
|
+
|
|
1255
|
+
if (ok) {
|
|
1256
|
+
this.outbox.delete(messageId);
|
|
1257
|
+
this.scheduleSave();
|
|
1258
|
+
respond(true, { ok: true });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (fatal) {
|
|
1263
|
+
this.moveToDeadLetter(entry, asString(params?.error || 'fatal-ack'));
|
|
1264
|
+
respond(true, { ok: true, movedToDeadLetter: true });
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
entry.nextAttemptAt = now() + 1_000;
|
|
1269
|
+
entry.lastError = asString(params?.error || 'retryable-ack');
|
|
1270
|
+
this.outbox.set(messageId, entry);
|
|
1271
|
+
this.scheduleSave();
|
|
1272
|
+
|
|
1273
|
+
respond(true, { ok: true, willRetry: true });
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1277
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1278
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1279
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1280
|
+
this.rememberGatewayContext(context);
|
|
1281
|
+
this.markSeen(accountId, connId, clientId);
|
|
1282
|
+
this.markActivity(accountId);
|
|
1283
|
+
|
|
1284
|
+
// 轻量活动心跳:仅刷新在线活跃状态,不承担拉取职责。
|
|
1285
|
+
respond(true, {
|
|
1286
|
+
accountId,
|
|
1287
|
+
ok: true,
|
|
1288
|
+
event: 'activity',
|
|
1289
|
+
activeConnections: this.activeConnectionCount(accountId),
|
|
1290
|
+
pending: Array.from(this.outbox.values()).filter((v) => v.accountId === accountId).length,
|
|
1291
|
+
deadLetter: this.deadLetter.filter((v) => v.accountId === accountId).length,
|
|
1292
|
+
now: now(),
|
|
1293
|
+
});
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
handleInbound = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
|
|
1297
|
+
const accountId = normalizeAccountId(asString(params?.accountId || ''));
|
|
1298
|
+
const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
|
|
1299
|
+
const clientId = asString((params as any)?.clientId || '').trim() || undefined;
|
|
1300
|
+
this.rememberGatewayContext(context);
|
|
1301
|
+
this.markSeen(accountId, connId, clientId);
|
|
1302
|
+
this.markActivity(accountId);
|
|
1303
|
+
|
|
1304
|
+
const platform = asString(params?.platform || '').trim();
|
|
1305
|
+
const groupId = asString(params?.groupId || '0').trim() || '0';
|
|
1306
|
+
const userId = asString(params?.userId || '').trim();
|
|
1307
|
+
const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
|
|
1308
|
+
|
|
1309
|
+
if (!platform || (!userId && !groupId)) {
|
|
1310
|
+
respond(false, { error: 'platform/groupId/userId required' });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const route: BncrRoute = {
|
|
1315
|
+
platform,
|
|
1316
|
+
groupId,
|
|
1317
|
+
userId,
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const text = asString(params?.msg || '');
|
|
1321
|
+
const msgType = asString(params?.type || 'text') || 'text';
|
|
1322
|
+
const mediaBase64 = asString(params?.base64 || '');
|
|
1323
|
+
const mimeType = asString(params?.mimeType || '').trim() || undefined;
|
|
1324
|
+
const fileName = asString(params?.fileName || '').trim() || undefined;
|
|
1325
|
+
const msgId = asString(params?.msgId || '').trim() || undefined;
|
|
1326
|
+
|
|
1327
|
+
const dedupKey = inboundDedupKey({
|
|
1328
|
+
accountId,
|
|
1329
|
+
platform,
|
|
1330
|
+
groupId,
|
|
1331
|
+
userId,
|
|
1332
|
+
msgId,
|
|
1333
|
+
text,
|
|
1334
|
+
mediaBase64,
|
|
1335
|
+
});
|
|
1336
|
+
if (this.markInboundDedupSeen(dedupKey)) {
|
|
1337
|
+
respond(true, {
|
|
1338
|
+
accepted: true,
|
|
1339
|
+
duplicated: true,
|
|
1340
|
+
accountId,
|
|
1341
|
+
msgId: msgId ?? null,
|
|
1342
|
+
});
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const peer = {
|
|
1347
|
+
kind: resolveChatType(route),
|
|
1348
|
+
id: route.groupId === '0' ? route.userId : route.groupId,
|
|
1349
|
+
} as const;
|
|
1350
|
+
|
|
1351
|
+
const cfg = await this.api.runtime.config.loadConfig();
|
|
1352
|
+
const resolvedRoute = this.api.runtime.channel.routing.resolveAgentRoute({
|
|
1353
|
+
cfg,
|
|
1354
|
+
channel: CHANNEL_ID,
|
|
1355
|
+
accountId,
|
|
1356
|
+
peer,
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
const baseSessionKey = normalizeInboundSessionKey(sessionKeyfromroute, route) || resolvedRoute.sessionKey;
|
|
1360
|
+
|
|
1361
|
+
// 轻量任务拆分:允许在消息前缀中声明 task key,将任务分流到子会话,降低单会话上下文压力。
|
|
1362
|
+
// 支持:#task:foo / /task:foo / /task foo <正文>
|
|
1363
|
+
const extracted = extractInlineTaskKey(text);
|
|
1364
|
+
const agentText = extracted.text;
|
|
1365
|
+
const taskSessionKey = withTaskSessionKey(baseSessionKey, extracted.taskKey);
|
|
1366
|
+
const sessionKey = taskSessionKey || baseSessionKey;
|
|
1367
|
+
|
|
1368
|
+
this.rememberSessionRoute(baseSessionKey, accountId, route);
|
|
1369
|
+
if (taskSessionKey && taskSessionKey !== baseSessionKey) {
|
|
1370
|
+
this.rememberSessionRoute(taskSessionKey, accountId, route);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// 先回 ACK,后异步处理 AI 回复
|
|
1374
|
+
respond(true, {
|
|
1375
|
+
accepted: true,
|
|
1376
|
+
accountId,
|
|
1377
|
+
sessionKey,
|
|
1378
|
+
msgId: msgId ?? null,
|
|
1379
|
+
taskKey: extracted.taskKey ?? null,
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
void (async () => {
|
|
1383
|
+
try {
|
|
1384
|
+
const storePath = this.api.runtime.channel.session.resolveStorePath(cfg?.session?.store, {
|
|
1385
|
+
agentId: resolvedRoute.agentId,
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
let mediaPath: string | undefined;
|
|
1389
|
+
if (mediaBase64) {
|
|
1390
|
+
const mediaBuf = Buffer.from(mediaBase64, 'base64');
|
|
1391
|
+
const saved = await this.api.runtime.channel.media.saveMediaBuffer(
|
|
1392
|
+
mediaBuf,
|
|
1393
|
+
mimeType,
|
|
1394
|
+
'inbound',
|
|
1395
|
+
30 * 1024 * 1024,
|
|
1396
|
+
fileName,
|
|
1397
|
+
);
|
|
1398
|
+
mediaPath = saved.path;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const rawBody = agentText || (msgType === 'text' ? '' : `[${msgType}]`);
|
|
1402
|
+
const body = this.api.runtime.channel.reply.formatAgentEnvelope({
|
|
1403
|
+
channel: 'Bncr',
|
|
1404
|
+
from: `${platform}:${groupId}:${userId}`,
|
|
1405
|
+
timestamp: Date.now(),
|
|
1406
|
+
previousTimestamp: this.api.runtime.channel.session.readSessionUpdatedAt({
|
|
1407
|
+
storePath,
|
|
1408
|
+
sessionKey,
|
|
1409
|
+
}),
|
|
1410
|
+
envelope: this.api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
|
|
1411
|
+
body: rawBody,
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const displayTo = formatDisplayScope(route);
|
|
1415
|
+
const ctxPayload = this.api.runtime.channel.reply.finalizeInboundContext({
|
|
1416
|
+
Body: body,
|
|
1417
|
+
BodyForAgent: rawBody,
|
|
1418
|
+
RawBody: rawBody,
|
|
1419
|
+
CommandBody: rawBody,
|
|
1420
|
+
MediaPath: mediaPath,
|
|
1421
|
+
MediaType: mimeType,
|
|
1422
|
+
From: `${CHANNEL_ID}:${platform}:${groupId}:${userId}`,
|
|
1423
|
+
To: displayTo,
|
|
1424
|
+
SessionKey: sessionKey,
|
|
1425
|
+
AccountId: accountId,
|
|
1426
|
+
ChatType: peer.kind,
|
|
1427
|
+
ConversationLabel: displayTo,
|
|
1428
|
+
SenderId: userId,
|
|
1429
|
+
Provider: CHANNEL_ID,
|
|
1430
|
+
Surface: CHANNEL_ID,
|
|
1431
|
+
MessageSid: msgId,
|
|
1432
|
+
Timestamp: Date.now(),
|
|
1433
|
+
OriginatingChannel: CHANNEL_ID,
|
|
1434
|
+
OriginatingTo: displayTo,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
await this.api.runtime.channel.session.recordInboundSession({
|
|
1438
|
+
storePath,
|
|
1439
|
+
sessionKey,
|
|
1440
|
+
ctx: ctxPayload,
|
|
1441
|
+
onRecordError: (err) => {
|
|
1442
|
+
this.api.logger.warn?.(`bncr: record session failed: ${String(err)}`);
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// 记录真正的业务活动时间(入站已完成解析并落会话)
|
|
1447
|
+
const inboundAt = now();
|
|
1448
|
+
this.lastInboundByAccount.set(accountId, inboundAt);
|
|
1449
|
+
this.markActivity(accountId, inboundAt);
|
|
1450
|
+
this.scheduleSave();
|
|
1451
|
+
|
|
1452
|
+
await this.api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1453
|
+
ctx: ctxPayload,
|
|
1454
|
+
cfg,
|
|
1455
|
+
// BNCR 侧仅推送最终回复,不要流式 block 推送
|
|
1456
|
+
replyOptions: {
|
|
1457
|
+
disableBlockStreaming: true,
|
|
1458
|
+
},
|
|
1459
|
+
dispatcherOptions: {
|
|
1460
|
+
deliver: async (
|
|
1461
|
+
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] },
|
|
1462
|
+
info?: { kind?: 'tool' | 'block' | 'final' },
|
|
1463
|
+
) => {
|
|
1464
|
+
// 过滤掉流式 block/tool,仅投递 final
|
|
1465
|
+
if (info?.kind && info.kind !== 'final') return;
|
|
1466
|
+
|
|
1467
|
+
await this.enqueueFromReply({
|
|
1468
|
+
accountId,
|
|
1469
|
+
sessionKey,
|
|
1470
|
+
route,
|
|
1471
|
+
payload,
|
|
1472
|
+
});
|
|
1473
|
+
},
|
|
1474
|
+
onError: (err: unknown) => {
|
|
1475
|
+
this.api.logger.error?.(`bncr reply failed: ${String(err)}`);
|
|
1476
|
+
},
|
|
1477
|
+
},
|
|
1478
|
+
});
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
this.api.logger.error?.(`bncr inbound process failed: ${String(err)}`);
|
|
1481
|
+
}
|
|
1482
|
+
})();
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
channelStartAccount = async (ctx: any) => {
|
|
1486
|
+
const accountId = normalizeAccountId(ctx.accountId);
|
|
1487
|
+
|
|
1488
|
+
const tick = () => {
|
|
1489
|
+
const connected = this.isOnline(accountId);
|
|
1490
|
+
const previous = ctx.getStatus?.() || {};
|
|
1491
|
+
const lastActAt = this.lastActivityByAccount.get(accountId) || previous?.lastEventAt || null;
|
|
1492
|
+
|
|
1493
|
+
ctx.setStatus?.({
|
|
1494
|
+
...previous,
|
|
1495
|
+
accountId,
|
|
1496
|
+
running: true,
|
|
1497
|
+
connected,
|
|
1498
|
+
lastEventAt: lastActAt,
|
|
1499
|
+
// 状态映射:在线=linked,离线=configured
|
|
1500
|
+
mode: connected ? 'linked' : 'configured',
|
|
1501
|
+
lastError: previous?.lastError ?? null,
|
|
1502
|
+
meta: this.buildStatusMeta(accountId),
|
|
1503
|
+
});
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
tick();
|
|
1507
|
+
const timer = setInterval(tick, 5_000);
|
|
1508
|
+
|
|
1509
|
+
await new Promise<void>((resolve) => {
|
|
1510
|
+
const onAbort = () => {
|
|
1511
|
+
clearInterval(timer);
|
|
1512
|
+
resolve();
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
if (ctx.abortSignal?.aborted) {
|
|
1516
|
+
onAbort();
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
ctx.abortSignal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1521
|
+
});
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
channelStopAccount = async (_ctx: any) => {
|
|
1525
|
+
// no-op
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
channelSendText = async (ctx: any) => {
|
|
1529
|
+
const accountId = normalizeAccountId(ctx.accountId);
|
|
1530
|
+
const to = asString(ctx.to || '').trim();
|
|
1531
|
+
|
|
1532
|
+
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1533
|
+
|
|
1534
|
+
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
1535
|
+
|
|
1536
|
+
await this.enqueueFromReply({
|
|
1537
|
+
accountId,
|
|
1538
|
+
sessionKey: verified.sessionKey,
|
|
1539
|
+
route: verified.route,
|
|
1540
|
+
payload: {
|
|
1541
|
+
text: asString(ctx.text || ''),
|
|
1542
|
+
},
|
|
1543
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
return { channel: CHANNEL_ID, messageId: randomUUID(), chatId: verified.sessionKey };
|
|
1547
|
+
};
|
|
1548
|
+
|
|
1549
|
+
channelSendMedia = async (ctx: any) => {
|
|
1550
|
+
const accountId = normalizeAccountId(ctx.accountId);
|
|
1551
|
+
const to = asString(ctx.to || '').trim();
|
|
1552
|
+
|
|
1553
|
+
const verified = this.resolveVerifiedTarget(to, accountId);
|
|
1554
|
+
|
|
1555
|
+
this.rememberSessionRoute(verified.sessionKey, accountId, verified.route);
|
|
1556
|
+
|
|
1557
|
+
await this.enqueueFromReply({
|
|
1558
|
+
accountId,
|
|
1559
|
+
sessionKey: verified.sessionKey,
|
|
1560
|
+
route: verified.route,
|
|
1561
|
+
payload: {
|
|
1562
|
+
text: asString(ctx.text || ''),
|
|
1563
|
+
mediaUrl: asString(ctx.mediaUrl || ''),
|
|
1564
|
+
},
|
|
1565
|
+
mediaLocalRoots: ctx.mediaLocalRoots,
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
return { channel: CHANNEL_ID, messageId: randomUUID(), chatId: verified.sessionKey };
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
function resolveDefaultDisplayName(rawName: unknown, accountId: string): string {
|
|
1573
|
+
const raw = asString(rawName || '').trim();
|
|
1574
|
+
// 统一兜底:空名 / 与 accountId 重复 / 历史默认名 => Monitor
|
|
1575
|
+
if (!raw || raw === accountId || /^bncr$/i.test(raw) || /^status$/i.test(raw) || /^runtime$/i.test(raw)) return 'Monitor';
|
|
1576
|
+
return raw;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function resolveAccount(cfg: any, accountId?: string | null) {
|
|
1580
|
+
const accounts = cfg?.channels?.[CHANNEL_ID]?.accounts || {};
|
|
1581
|
+
let key = normalizeAccountId(accountId);
|
|
1582
|
+
|
|
1583
|
+
// 若请求的 accountId 不存在(例如框架仍传 default),回退到首个已配置账号
|
|
1584
|
+
if (!accounts[key]) {
|
|
1585
|
+
const first = Object.keys(accounts)[0];
|
|
1586
|
+
if (first) key = first;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const account = accounts[key] || {};
|
|
1590
|
+
const displayName = resolveDefaultDisplayName(account?.name, key);
|
|
1591
|
+
return {
|
|
1592
|
+
accountId: key,
|
|
1593
|
+
// accountId(default) 无法隐藏时,给稳定默认名,避免空名或 default(default)
|
|
1594
|
+
name: displayName,
|
|
1595
|
+
enabled: account?.enabled !== false,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function listAccountIds(cfg: any): string[] {
|
|
1600
|
+
const ids = Object.keys(cfg?.channels?.[CHANNEL_ID]?.accounts || {});
|
|
1601
|
+
return ids.length ? ids : [BNCR_DEFAULT_ACCOUNT_ID];
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
export function createBncrBridge(api: OpenClawPluginApi) {
|
|
1605
|
+
return new BncrBridgeRuntime(api);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
|
|
1609
|
+
const plugin = {
|
|
1610
|
+
id: CHANNEL_ID,
|
|
1611
|
+
meta: {
|
|
1612
|
+
id: CHANNEL_ID,
|
|
1613
|
+
label: 'Bncr',
|
|
1614
|
+
selectionLabel: 'Bncr Client',
|
|
1615
|
+
docsPath: '/channels/bncr',
|
|
1616
|
+
blurb: 'Bncr Channel.',
|
|
1617
|
+
aliases: ['bncr'],
|
|
1618
|
+
},
|
|
1619
|
+
capabilities: {
|
|
1620
|
+
chatTypes: ['direct'] as ChatType[],
|
|
1621
|
+
media: true,
|
|
1622
|
+
reply: true,
|
|
1623
|
+
nativeCommands: false,
|
|
1624
|
+
},
|
|
1625
|
+
messaging: {
|
|
1626
|
+
// 接收任意标签输入;不在 normalize 阶段做格式门槛,统一下沉到发送前验证。
|
|
1627
|
+
normalizeTarget: (raw: string) => {
|
|
1628
|
+
const input = asString(raw).trim();
|
|
1629
|
+
return input || undefined;
|
|
1630
|
+
},
|
|
1631
|
+
targetResolver: {
|
|
1632
|
+
looksLikeId: (raw: string, normalized?: string) => {
|
|
1633
|
+
return Boolean(asString(normalized || raw).trim());
|
|
1634
|
+
},
|
|
1635
|
+
hint: 'Any label accepted; will be validated against known bncr sessions before send',
|
|
1636
|
+
},
|
|
1637
|
+
},
|
|
1638
|
+
configSchema: BncrConfigSchema,
|
|
1639
|
+
config: {
|
|
1640
|
+
listAccountIds,
|
|
1641
|
+
resolveAccount,
|
|
1642
|
+
setAccountEnabled: ({ cfg, accountId, enabled }: any) =>
|
|
1643
|
+
setAccountEnabledInConfigSection({
|
|
1644
|
+
cfg,
|
|
1645
|
+
sectionKey: CHANNEL_ID,
|
|
1646
|
+
accountId,
|
|
1647
|
+
enabled,
|
|
1648
|
+
allowTopLevel: true,
|
|
1649
|
+
}),
|
|
1650
|
+
isEnabled: (account: any) => account?.enabled !== false,
|
|
1651
|
+
isConfigured: () => true,
|
|
1652
|
+
describeAccount: (account: any) => {
|
|
1653
|
+
const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
|
|
1654
|
+
return {
|
|
1655
|
+
accountId: account.accountId,
|
|
1656
|
+
name: displayName,
|
|
1657
|
+
enabled: account.enabled !== false,
|
|
1658
|
+
configured: true,
|
|
1659
|
+
};
|
|
1660
|
+
},
|
|
1661
|
+
},
|
|
1662
|
+
setup: {
|
|
1663
|
+
applyAccountName: ({ cfg, accountId, name }: any) =>
|
|
1664
|
+
applyAccountNameToChannelSection({
|
|
1665
|
+
cfg,
|
|
1666
|
+
channelKey: CHANNEL_ID,
|
|
1667
|
+
accountId,
|
|
1668
|
+
name,
|
|
1669
|
+
alwaysUseAccounts: true,
|
|
1670
|
+
}),
|
|
1671
|
+
applyAccountConfig: ({ cfg, accountId }: any) => {
|
|
1672
|
+
const next = { ...(cfg || {}) } as any;
|
|
1673
|
+
next.channels = next.channels || {};
|
|
1674
|
+
next.channels[CHANNEL_ID] = next.channels[CHANNEL_ID] || {};
|
|
1675
|
+
next.channels[CHANNEL_ID].accounts = next.channels[CHANNEL_ID].accounts || {};
|
|
1676
|
+
next.channels[CHANNEL_ID].accounts[accountId] = {
|
|
1677
|
+
...(next.channels[CHANNEL_ID].accounts[accountId] || {}),
|
|
1678
|
+
enabled: true,
|
|
1679
|
+
};
|
|
1680
|
+
return next;
|
|
1681
|
+
},
|
|
1682
|
+
},
|
|
1683
|
+
outbound: {
|
|
1684
|
+
deliveryMode: 'gateway' as const,
|
|
1685
|
+
textChunkLimit: 4000,
|
|
1686
|
+
sendText: bridge.channelSendText,
|
|
1687
|
+
sendMedia: bridge.channelSendMedia,
|
|
1688
|
+
},
|
|
1689
|
+
status: {
|
|
1690
|
+
defaultRuntime: createDefaultChannelRuntimeState(BNCR_DEFAULT_ACCOUNT_ID, {
|
|
1691
|
+
mode: 'ws-offline',
|
|
1692
|
+
}),
|
|
1693
|
+
buildChannelSummary: async ({ defaultAccountId }: any) => {
|
|
1694
|
+
return bridge.getChannelSummary(defaultAccountId || BNCR_DEFAULT_ACCOUNT_ID);
|
|
1695
|
+
},
|
|
1696
|
+
buildAccountSnapshot: async ({ account, runtime }: any) => {
|
|
1697
|
+
const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
|
|
1698
|
+
const meta = rt?.meta || {};
|
|
1699
|
+
|
|
1700
|
+
const pending = Number(rt?.pending ?? meta.pending ?? 0);
|
|
1701
|
+
const deadLetter = Number(rt?.deadLetter ?? meta.deadLetter ?? 0);
|
|
1702
|
+
const lastSessionKey = rt?.lastSessionKey ?? meta.lastSessionKey ?? null;
|
|
1703
|
+
const lastSessionScope = rt?.lastSessionScope ?? meta.lastSessionScope ?? null;
|
|
1704
|
+
const lastSessionAt = rt?.lastSessionAt ?? meta.lastSessionAt ?? null;
|
|
1705
|
+
const lastSessionAgo = rt?.lastSessionAgo ?? meta.lastSessionAgo ?? '-';
|
|
1706
|
+
const lastActivityAt = rt?.lastActivityAt ?? meta.lastActivityAt ?? null;
|
|
1707
|
+
const lastActivityAgo = rt?.lastActivityAgo ?? meta.lastActivityAgo ?? '-';
|
|
1708
|
+
const lastInboundAt = rt?.lastInboundAt ?? meta.lastInboundAt ?? null;
|
|
1709
|
+
const lastInboundAgo = rt?.lastInboundAgo ?? meta.lastInboundAgo ?? '-';
|
|
1710
|
+
const lastOutboundAt = rt?.lastOutboundAt ?? meta.lastOutboundAt ?? null;
|
|
1711
|
+
const lastOutboundAgo = rt?.lastOutboundAgo ?? meta.lastOutboundAgo ?? '-';
|
|
1712
|
+
// 右侧状态字段统一:离线时也显示 Status(避免出现 configured 文案)
|
|
1713
|
+
const normalizedMode = rt?.mode === 'linked'
|
|
1714
|
+
? 'linked'
|
|
1715
|
+
: 'Status';
|
|
1716
|
+
|
|
1717
|
+
const displayName = resolveDefaultDisplayName(account?.name, account?.accountId);
|
|
1718
|
+
|
|
1719
|
+
return {
|
|
1720
|
+
accountId: account.accountId,
|
|
1721
|
+
// default 名不可隐藏时,统一展示稳定默认值
|
|
1722
|
+
name: displayName,
|
|
1723
|
+
enabled: account.enabled !== false,
|
|
1724
|
+
configured: true,
|
|
1725
|
+
linked: Boolean(rt?.connected),
|
|
1726
|
+
running: rt?.running ?? false,
|
|
1727
|
+
connected: rt?.connected ?? false,
|
|
1728
|
+
lastEventAt: rt?.lastEventAt ?? null,
|
|
1729
|
+
lastError: rt?.lastError ?? null,
|
|
1730
|
+
mode: normalizedMode,
|
|
1731
|
+
pending,
|
|
1732
|
+
deadLetter,
|
|
1733
|
+
lastSessionKey,
|
|
1734
|
+
lastSessionScope,
|
|
1735
|
+
lastSessionAt,
|
|
1736
|
+
lastSessionAgo,
|
|
1737
|
+
lastActivityAt,
|
|
1738
|
+
lastActivityAgo,
|
|
1739
|
+
lastInboundAt,
|
|
1740
|
+
lastInboundAgo,
|
|
1741
|
+
lastOutboundAt,
|
|
1742
|
+
lastOutboundAgo,
|
|
1743
|
+
};
|
|
1744
|
+
},
|
|
1745
|
+
resolveAccountState: ({ enabled, configured, account, cfg, runtime }: any) => {
|
|
1746
|
+
if (!enabled) return 'disabled';
|
|
1747
|
+
const resolved = resolveAccount(cfg, account?.accountId);
|
|
1748
|
+
if (!(resolved.enabled && configured)) return 'not configured';
|
|
1749
|
+
const rt = runtime || bridge.getAccountRuntimeSnapshot(account?.accountId);
|
|
1750
|
+
return rt?.connected ? 'linked' : 'configured';
|
|
1751
|
+
},
|
|
1752
|
+
},
|
|
1753
|
+
gatewayMethods: ['bncr.connect', 'bncr.inbound', 'bncr.activity', 'bncr.ack'],
|
|
1754
|
+
gateway: {
|
|
1755
|
+
startAccount: bridge.channelStartAccount,
|
|
1756
|
+
stopAccount: bridge.channelStopAccount,
|
|
1757
|
+
},
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
return plugin;
|
|
1761
|
+
}
|