@xmoxmo/bncr 0.0.3 → 0.0.5
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/README.md +92 -336
- package/index.ts +2 -2
- package/openclaw.plugin.json +33 -2
- package/package.json +7 -2
- package/scripts/selfcheck.mjs +38 -0
- package/src/channel.ts +287 -772
- package/src/core/accounts.ts +50 -0
- package/src/core/config-schema.ts +42 -0
- package/src/core/permissions.ts +29 -0
- package/src/core/policy.ts +27 -0
- package/src/core/probe.ts +45 -0
- package/src/core/status.ts +145 -0
- package/src/core/targets.ts +243 -0
- package/src/core/types.ts +59 -0
- package/src/messaging/inbound/commands.ts +136 -0
- package/src/messaging/inbound/dispatch.ts +178 -0
- package/src/messaging/inbound/gate.ts +66 -0
- package/src/messaging/inbound/parse.ts +97 -0
- package/src/messaging/outbound/actions.ts +42 -0
- package/src/messaging/outbound/media.ts +53 -0
- package/src/messaging/outbound/send.ts +67 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const CHANNEL_ID = 'bncr';
|
|
2
|
+
const BNCR_DEFAULT_ACCOUNT_ID = 'Primary';
|
|
3
|
+
|
|
4
|
+
function asString(v: unknown, fallback = ''): string {
|
|
5
|
+
if (typeof v === 'string') return v;
|
|
6
|
+
if (v == null) return fallback;
|
|
7
|
+
return String(v);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeAccountId(accountId?: string | null): string {
|
|
11
|
+
const v = asString(accountId || '').trim();
|
|
12
|
+
if (!v) return BNCR_DEFAULT_ACCOUNT_ID;
|
|
13
|
+
const lower = v.toLowerCase();
|
|
14
|
+
if (lower === 'default' || lower === 'primary') return BNCR_DEFAULT_ACCOUNT_ID;
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveDefaultDisplayName(rawName: unknown, accountId: string): string {
|
|
19
|
+
const raw = asString(rawName || '').trim();
|
|
20
|
+
if (!raw || raw === accountId || /^bncr$/i.test(raw) || /^status$/i.test(raw) || /^runtime$/i.test(raw)) {
|
|
21
|
+
return 'Monitor';
|
|
22
|
+
}
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveAccount(cfg: any, accountId?: string | null) {
|
|
27
|
+
const accounts = cfg?.channels?.[CHANNEL_ID]?.accounts || {};
|
|
28
|
+
let key = normalizeAccountId(accountId);
|
|
29
|
+
|
|
30
|
+
if (!accounts[key]) {
|
|
31
|
+
const first = Object.keys(accounts)[0];
|
|
32
|
+
if (first) key = first;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const account = accounts[key] || {};
|
|
36
|
+
const displayName = resolveDefaultDisplayName(account?.name, key);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
accountId: key,
|
|
40
|
+
name: displayName,
|
|
41
|
+
enabled: account?.enabled !== false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listAccountIds(cfg: any): string[] {
|
|
46
|
+
const ids = Object.keys(cfg?.channels?.[CHANNEL_ID]?.accounts || {});
|
|
47
|
+
return ids.length ? ids : [BNCR_DEFAULT_ACCOUNT_ID];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const BncrConfigSchema = {
|
|
2
|
+
schema: {
|
|
3
|
+
type: 'object',
|
|
4
|
+
additionalProperties: true,
|
|
5
|
+
properties: {
|
|
6
|
+
enabled: { type: 'boolean' },
|
|
7
|
+
dmPolicy: {
|
|
8
|
+
type: 'string',
|
|
9
|
+
enum: ['open', 'allowlist', 'disabled'],
|
|
10
|
+
},
|
|
11
|
+
groupPolicy: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['open', 'allowlist', 'disabled'],
|
|
14
|
+
},
|
|
15
|
+
allowFrom: {
|
|
16
|
+
type: 'array',
|
|
17
|
+
items: { type: 'string' },
|
|
18
|
+
},
|
|
19
|
+
groupAllowFrom: {
|
|
20
|
+
type: 'array',
|
|
21
|
+
items: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
requireMention: {
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
default: false,
|
|
26
|
+
description:
|
|
27
|
+
'Whether group messages must explicitly mention the bot before bncr handles them. Default false. Current version keeps this as a reserved field and does not enforce it yet.',
|
|
28
|
+
},
|
|
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
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function asString(v: unknown, fallback = ''): string {
|
|
2
|
+
if (typeof v === 'string') return v;
|
|
3
|
+
if (v == null) return fallback;
|
|
4
|
+
return String(v);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getBncrElevatedConfig(rootCfg: any) {
|
|
8
|
+
const elevated = rootCfg?.tools?.elevated || {};
|
|
9
|
+
const allowFrom = elevated?.allowFrom || {};
|
|
10
|
+
const bncrRules = Array.isArray(allowFrom?.bncr) ? allowFrom.bncr.map((x: unknown) => asString(x).trim()).filter(Boolean) : [];
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
enabled: elevated?.enabled === true,
|
|
14
|
+
bncrAllowed: bncrRules.length > 0,
|
|
15
|
+
bncrRules,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildBncrPermissionSummary(rootCfg: any) {
|
|
20
|
+
const elevated = getBncrElevatedConfig(rootCfg);
|
|
21
|
+
return {
|
|
22
|
+
elevatedEnabled: elevated.enabled,
|
|
23
|
+
bncrElevatedAllowed: elevated.bncrAllowed,
|
|
24
|
+
bncrElevatedRules: elevated.bncrRules,
|
|
25
|
+
note: elevated.bncrAllowed
|
|
26
|
+
? 'bncr can request elevated operations; final execution may still be gated by approvals policy'
|
|
27
|
+
: 'bncr elevated not explicitly allowed',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function asString(v: unknown, fallback = ''): string {
|
|
2
|
+
if (typeof v === 'string') return v;
|
|
3
|
+
if (v == null) return fallback;
|
|
4
|
+
return String(v);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function asList(v: unknown): string[] {
|
|
8
|
+
if (!Array.isArray(v)) return [];
|
|
9
|
+
return v.map((x) => asString(x).trim()).filter(Boolean);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function asBoolean(v: unknown, fallback = false): boolean {
|
|
13
|
+
if (typeof v === 'boolean') return v;
|
|
14
|
+
if (v == null) return fallback;
|
|
15
|
+
return String(v).trim().toLowerCase() === 'true';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveBncrChannelPolicy(channelCfg: any) {
|
|
19
|
+
return {
|
|
20
|
+
enabled: channelCfg?.enabled !== false,
|
|
21
|
+
dmPolicy: asString(channelCfg?.dmPolicy || 'open').toLowerCase(),
|
|
22
|
+
groupPolicy: asString(channelCfg?.groupPolicy || 'open').toLowerCase(),
|
|
23
|
+
allowFrom: asList(channelCfg?.allowFrom),
|
|
24
|
+
groupAllowFrom: asList(channelCfg?.groupAllowFrom),
|
|
25
|
+
requireMention: asBoolean(channelCfg?.requireMention, false),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function probeBncrAccount(params: {
|
|
2
|
+
accountId: string;
|
|
3
|
+
connected: boolean;
|
|
4
|
+
pending: number;
|
|
5
|
+
deadLetter: number;
|
|
6
|
+
activeConnections: number;
|
|
7
|
+
invalidOutboxSessionKeys: number;
|
|
8
|
+
legacyAccountResidue: number;
|
|
9
|
+
lastActivityAt?: number | null;
|
|
10
|
+
structure?: {
|
|
11
|
+
coreComplete: boolean;
|
|
12
|
+
inboundComplete: boolean;
|
|
13
|
+
outboundComplete: boolean;
|
|
14
|
+
};
|
|
15
|
+
}) {
|
|
16
|
+
const issues: string[] = [];
|
|
17
|
+
|
|
18
|
+
if (!params.connected) issues.push('not-connected');
|
|
19
|
+
if (params.pending > 20) issues.push('pending-high');
|
|
20
|
+
if (params.deadLetter > 0) issues.push('dead-letter');
|
|
21
|
+
if (params.activeConnections > 3) issues.push('too-many-connections');
|
|
22
|
+
if (params.invalidOutboxSessionKeys > 0) issues.push('invalid-session-keys');
|
|
23
|
+
if (params.legacyAccountResidue > 0) issues.push('legacy-account-residue');
|
|
24
|
+
|
|
25
|
+
let level: 'ok' | 'warn' | 'error' = 'ok';
|
|
26
|
+
if (issues.length > 0) level = 'warn';
|
|
27
|
+
if (!params.connected && (params.deadLetter > 0 || params.invalidOutboxSessionKeys > 0)) level = 'error';
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
ok: level === 'ok',
|
|
31
|
+
level,
|
|
32
|
+
summary: issues.length ? issues.join(', ') : 'healthy',
|
|
33
|
+
details: {
|
|
34
|
+
accountId: params.accountId,
|
|
35
|
+
connected: params.connected,
|
|
36
|
+
pending: params.pending,
|
|
37
|
+
deadLetter: params.deadLetter,
|
|
38
|
+
activeConnections: params.activeConnections,
|
|
39
|
+
invalidOutboxSessionKeys: params.invalidOutboxSessionKeys,
|
|
40
|
+
legacyAccountResidue: params.legacyAccountResidue,
|
|
41
|
+
lastActivityAt: params.lastActivityAt ?? null,
|
|
42
|
+
structure: params.structure ?? null,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { BncrDiagnosticsSummary, PendingAdmission } from './types.js';
|
|
4
|
+
|
|
5
|
+
type RuntimeStatusInput = {
|
|
6
|
+
accountId: string;
|
|
7
|
+
connected: boolean;
|
|
8
|
+
pending: number;
|
|
9
|
+
deadLetter: number;
|
|
10
|
+
activeConnections: number;
|
|
11
|
+
connectEvents: number;
|
|
12
|
+
inboundEvents: number;
|
|
13
|
+
activityEvents: number;
|
|
14
|
+
ackEvents: number;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
pendingAdmissions?: PendingAdmission[];
|
|
17
|
+
lastSession?: { sessionKey: string; scope: string; updatedAt: number } | null;
|
|
18
|
+
lastActivityAt?: number | null;
|
|
19
|
+
lastInboundAt?: number | null;
|
|
20
|
+
lastOutboundAt?: number | null;
|
|
21
|
+
sessionRoutesCount: number;
|
|
22
|
+
invalidOutboxSessionKeys: number;
|
|
23
|
+
legacyAccountResidue: number;
|
|
24
|
+
running?: boolean;
|
|
25
|
+
lastError?: string | null;
|
|
26
|
+
channelRoot?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function now() {
|
|
30
|
+
return Date.now();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtAgo(ts?: number | null): string {
|
|
34
|
+
if (!ts || !Number.isFinite(ts) || ts <= 0) return '-';
|
|
35
|
+
const diff = Math.max(0, now() - ts);
|
|
36
|
+
if (diff < 1_000) return 'just now';
|
|
37
|
+
if (diff < 60_000) return `${Math.floor(diff / 1_000)}s ago`;
|
|
38
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
39
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
40
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildIntegratedDiagnostics(input: RuntimeStatusInput): BncrDiagnosticsSummary {
|
|
44
|
+
const root = input.channelRoot || path.join(process.cwd(), 'plugins', 'bncr');
|
|
45
|
+
const pluginIndexExists = fs.existsSync(path.join(root, 'index.ts'));
|
|
46
|
+
const pluginChannelExists = fs.existsSync(path.join(root, 'src', 'channel.ts'));
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
health: {
|
|
50
|
+
connected: input.connected,
|
|
51
|
+
pending: input.pending,
|
|
52
|
+
pendingAdmissions: Array.isArray(input.pendingAdmissions) ? input.pendingAdmissions.length : 0,
|
|
53
|
+
deadLetter: input.deadLetter,
|
|
54
|
+
activeConnections: input.activeConnections,
|
|
55
|
+
connectEvents: input.connectEvents,
|
|
56
|
+
inboundEvents: input.inboundEvents,
|
|
57
|
+
activityEvents: input.activityEvents,
|
|
58
|
+
ackEvents: input.ackEvents,
|
|
59
|
+
uptimeSec: Math.floor((now() - input.startedAt) / 1000),
|
|
60
|
+
},
|
|
61
|
+
regression: {
|
|
62
|
+
pluginFilesPresent: pluginIndexExists && pluginChannelExists,
|
|
63
|
+
pluginIndexExists,
|
|
64
|
+
pluginChannelExists,
|
|
65
|
+
totalKnownRoutes: input.sessionRoutesCount,
|
|
66
|
+
invalidOutboxSessionKeys: input.invalidOutboxSessionKeys,
|
|
67
|
+
legacyAccountResidue: input.legacyAccountResidue,
|
|
68
|
+
ok: input.invalidOutboxSessionKeys === 0 && input.legacyAccountResidue === 0,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildStatusHeadlineFromRuntime(input: RuntimeStatusInput): string {
|
|
74
|
+
const diag = buildIntegratedDiagnostics(input);
|
|
75
|
+
const h = diag.health;
|
|
76
|
+
const r = diag.regression;
|
|
77
|
+
|
|
78
|
+
const parts = [
|
|
79
|
+
r.ok ? 'diag:ok' : 'diag:warn',
|
|
80
|
+
`p:${h.pending}`,
|
|
81
|
+
`d:${h.deadLetter}`,
|
|
82
|
+
`c:${h.activeConnections}`,
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
if (!r.ok) {
|
|
86
|
+
if (r.invalidOutboxSessionKeys > 0) parts.push(`invalid:${r.invalidOutboxSessionKeys}`);
|
|
87
|
+
if (r.legacyAccountResidue > 0) parts.push(`legacy:${r.legacyAccountResidue}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return parts.join(' ');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildStatusMetaFromRuntime(input: RuntimeStatusInput) {
|
|
94
|
+
const diagnostics = buildIntegratedDiagnostics(input);
|
|
95
|
+
return {
|
|
96
|
+
pending: input.pending,
|
|
97
|
+
pendingAdmissionsCount: Array.isArray(input.pendingAdmissions) ? input.pendingAdmissions.length : 0,
|
|
98
|
+
pendingAdmissions: Array.isArray(input.pendingAdmissions)
|
|
99
|
+
? input.pendingAdmissions.map((item) => ({
|
|
100
|
+
clientId: item.clientId,
|
|
101
|
+
scope: item.route ? `${item.route.platform}:${item.route.groupId}:${item.route.userId}` : null,
|
|
102
|
+
scopes: Array.isArray(item.routes)
|
|
103
|
+
? item.routes.map((route) => `${route.platform}:${route.groupId}:${route.userId}`)
|
|
104
|
+
: [],
|
|
105
|
+
firstSeenAt: item.firstSeenAt,
|
|
106
|
+
lastSeenAt: item.lastSeenAt,
|
|
107
|
+
attempts: item.attempts,
|
|
108
|
+
}))
|
|
109
|
+
: [],
|
|
110
|
+
deadLetter: input.deadLetter,
|
|
111
|
+
lastSessionScope: input.lastSession?.scope || null,
|
|
112
|
+
lastSessionAt: input.lastSession?.updatedAt || null,
|
|
113
|
+
lastSessionAgo: fmtAgo(input.lastSession?.updatedAt || null),
|
|
114
|
+
lastActivityAt: input.lastActivityAt || null,
|
|
115
|
+
lastActivityAgo: fmtAgo(input.lastActivityAt || null),
|
|
116
|
+
lastInboundAt: input.lastInboundAt || null,
|
|
117
|
+
lastInboundAgo: fmtAgo(input.lastInboundAt || null),
|
|
118
|
+
lastOutboundAt: input.lastOutboundAt || null,
|
|
119
|
+
lastOutboundAgo: fmtAgo(input.lastOutboundAt || null),
|
|
120
|
+
diagnostics,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildAccountRuntimeSnapshot(input: RuntimeStatusInput) {
|
|
125
|
+
return {
|
|
126
|
+
accountId: input.accountId,
|
|
127
|
+
running: input.running ?? true,
|
|
128
|
+
connected: input.connected,
|
|
129
|
+
linked: input.connected,
|
|
130
|
+
lastEventAt: input.lastActivityAt || null,
|
|
131
|
+
lastInboundAt: input.lastInboundAt || null,
|
|
132
|
+
lastOutboundAt: input.lastOutboundAt || null,
|
|
133
|
+
mode: input.connected ? 'linked' : 'configured',
|
|
134
|
+
lastError: input.lastError ?? null,
|
|
135
|
+
meta: buildStatusMetaFromRuntime(input),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildChannelSummaryFromRuntime(input: RuntimeStatusInput) {
|
|
140
|
+
const headline = buildStatusHeadlineFromRuntime(input);
|
|
141
|
+
return {
|
|
142
|
+
linked: input.connected,
|
|
143
|
+
self: { e164: headline },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { BncrRoute } from './types.js';
|
|
2
|
+
|
|
3
|
+
const BNCR_SESSION_KEY_PREFIX = 'agent:main:bncr:direct:';
|
|
4
|
+
|
|
5
|
+
function asString(v: unknown, fallback = ''): string {
|
|
6
|
+
if (typeof v === 'string') return v;
|
|
7
|
+
if (v == null) return fallback;
|
|
8
|
+
return String(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseRouteFromScope(scope: string): BncrRoute | null {
|
|
12
|
+
const parts = asString(scope).trim().split(':');
|
|
13
|
+
if (parts.length < 3) return null;
|
|
14
|
+
const [platform, groupId, userId] = parts;
|
|
15
|
+
if (!platform || !groupId || !userId) return null;
|
|
16
|
+
return { platform, groupId, userId };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseRouteFromStandardDisplayScope(scope: string): BncrRoute | null {
|
|
20
|
+
const parts = asString(scope).trim().split(':');
|
|
21
|
+
if (parts.length === 2) {
|
|
22
|
+
const [platform, userId] = parts;
|
|
23
|
+
if (!platform || !userId) return null;
|
|
24
|
+
return { platform, groupId: '0', userId };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (parts.length === 3) {
|
|
28
|
+
const [platform, groupId, userId] = parts;
|
|
29
|
+
if (!platform || !groupId || !userId) return null;
|
|
30
|
+
return { platform, groupId, userId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseRouteFromDisplayScope(scope: string): BncrRoute | null {
|
|
37
|
+
const raw = asString(scope).trim();
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
|
|
40
|
+
const payload = raw.match(/^Bncr:(.+)$/)?.[1];
|
|
41
|
+
if (!payload) return null;
|
|
42
|
+
return parseRouteFromStandardDisplayScope(payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatDisplayScope(route: BncrRoute): string {
|
|
46
|
+
if (route.groupId === '0' && route.userId !== '0') {
|
|
47
|
+
return `Bncr:${route.platform}:${route.userId}`;
|
|
48
|
+
}
|
|
49
|
+
return `Bncr:${route.platform}:${route.groupId}:${route.userId}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildDisplayScopeCandidates(route: BncrRoute): string[] {
|
|
53
|
+
const candidates = [formatDisplayScope(route)].filter(Boolean);
|
|
54
|
+
return Array.from(new Set(candidates.map((x) => asString(x).trim()).filter(Boolean)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isLowerHex(input: string): boolean {
|
|
58
|
+
const raw = asString(input).trim();
|
|
59
|
+
return !!raw && /^[0-9a-fA-F]+$/.test(raw) && raw.length % 2 === 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function routeScopeToHex(route: BncrRoute): string {
|
|
63
|
+
const raw = `${route.platform}:${route.groupId}:${route.userId}`;
|
|
64
|
+
return Buffer.from(raw, 'utf8').toString('hex').toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseRouteFromHexScope(scopeHex: string): BncrRoute | null {
|
|
68
|
+
const rawHex = asString(scopeHex).trim();
|
|
69
|
+
if (!isLowerHex(rawHex)) return null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const decoded = Buffer.from(rawHex, 'hex').toString('utf8');
|
|
73
|
+
return parseRouteFromScope(decoded);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseRouteLike(input: unknown): BncrRoute | null {
|
|
80
|
+
const platform = asString((input as any)?.platform || '').trim();
|
|
81
|
+
const groupId = asString((input as any)?.groupId || '').trim();
|
|
82
|
+
const userId = asString((input as any)?.userId || '').trim();
|
|
83
|
+
if (!platform || !groupId || !userId) return null;
|
|
84
|
+
return { platform, groupId, userId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseLegacySessionKeyToStrict(input: string): string | null {
|
|
88
|
+
const raw = asString(input).trim();
|
|
89
|
+
if (!raw) return null;
|
|
90
|
+
|
|
91
|
+
const directLegacy = raw.match(/^agent:main:bncr:direct:([0-9a-fA-F]+):0$/);
|
|
92
|
+
if (directLegacy?.[1]) {
|
|
93
|
+
const route = parseRouteFromHexScope(directLegacy[1].toLowerCase());
|
|
94
|
+
if (route) return buildFallbackSessionKey(route);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const bncrLegacy = raw.match(/^bncr:([0-9a-fA-F]+):0$/);
|
|
98
|
+
if (bncrLegacy?.[1]) {
|
|
99
|
+
const route = parseRouteFromHexScope(bncrLegacy[1].toLowerCase());
|
|
100
|
+
if (route) return buildFallbackSessionKey(route);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const agentLegacy = raw.match(/^agent:main:bncr:([0-9a-fA-F]+):0$/);
|
|
104
|
+
if (agentLegacy?.[1]) {
|
|
105
|
+
const route = parseRouteFromHexScope(agentLegacy[1].toLowerCase());
|
|
106
|
+
if (route) return buildFallbackSessionKey(route);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isLowerHex(raw.toLowerCase())) {
|
|
110
|
+
const route = parseRouteFromHexScope(raw.toLowerCase());
|
|
111
|
+
if (route) return buildFallbackSessionKey(route);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function isLegacyNoiseRoute(route: BncrRoute): boolean {
|
|
118
|
+
const platform = asString(route.platform).trim().toLowerCase();
|
|
119
|
+
const groupId = asString(route.groupId).trim().toLowerCase();
|
|
120
|
+
const userId = asString(route.userId).trim().toLowerCase();
|
|
121
|
+
|
|
122
|
+
if (platform === 'agent' && groupId === 'main' && userId === 'bncr') return true;
|
|
123
|
+
if (platform === 'bncr' && userId === '0' && isLowerHex(groupId)) return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function parseStrictBncrSessionKey(input: string): { sessionKey: string; scopeHex: string; route: BncrRoute } | null {
|
|
128
|
+
const raw = asString(input).trim();
|
|
129
|
+
if (!raw) return null;
|
|
130
|
+
|
|
131
|
+
const m = raw.match(/^agent:main:bncr:(direct|group):(.+)$/);
|
|
132
|
+
if (!m?.[1] || !m?.[2]) return null;
|
|
133
|
+
|
|
134
|
+
const payload = asString(m[2]).trim();
|
|
135
|
+
let route: BncrRoute | null = null;
|
|
136
|
+
let scopeHex = '';
|
|
137
|
+
|
|
138
|
+
if (isLowerHex(payload)) {
|
|
139
|
+
scopeHex = payload;
|
|
140
|
+
route = parseRouteFromHexScope(payload);
|
|
141
|
+
} else {
|
|
142
|
+
route = parseRouteFromScope(payload);
|
|
143
|
+
if (route) scopeHex = routeScopeToHex(route);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!route || !scopeHex) return null;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
sessionKey: `${BNCR_SESSION_KEY_PREFIX}${scopeHex}`,
|
|
150
|
+
scopeHex,
|
|
151
|
+
route,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function normalizeTaskKey(input: unknown): string | null {
|
|
156
|
+
const raw = asString(input).trim().toLowerCase();
|
|
157
|
+
if (!raw) return null;
|
|
158
|
+
const normalized = raw.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
|
159
|
+
return normalized || null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function normalizeStoredSessionKey(input: string): { sessionKey: string; route: BncrRoute } | null {
|
|
163
|
+
const raw = asString(input).trim();
|
|
164
|
+
if (!raw) return null;
|
|
165
|
+
|
|
166
|
+
let taskKey: string | null = null;
|
|
167
|
+
let base = raw;
|
|
168
|
+
|
|
169
|
+
const taskTagged = raw.match(/^(.*):task:([a-z0-9_-]{1,32})$/i);
|
|
170
|
+
if (taskTagged) {
|
|
171
|
+
base = asString(taskTagged[1]).trim();
|
|
172
|
+
taskKey = normalizeTaskKey(taskTagged[2]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const strict = parseStrictBncrSessionKey(base);
|
|
176
|
+
if (strict) {
|
|
177
|
+
if (isLegacyNoiseRoute(strict.route)) return null;
|
|
178
|
+
return {
|
|
179
|
+
sessionKey: taskKey ? `${strict.sessionKey}:task:${taskKey}` : strict.sessionKey,
|
|
180
|
+
route: strict.route,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const migrated = parseLegacySessionKeyToStrict(base);
|
|
185
|
+
if (!migrated) return null;
|
|
186
|
+
|
|
187
|
+
const parsed = parseStrictBncrSessionKey(migrated);
|
|
188
|
+
if (!parsed) return null;
|
|
189
|
+
if (isLegacyNoiseRoute(parsed.route)) return null;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
sessionKey: taskKey ? `${parsed.sessionKey}:task:${taskKey}` : parsed.sessionKey,
|
|
193
|
+
route: parsed.route,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function normalizeInboundSessionKey(scope: string, route: BncrRoute): string | null {
|
|
198
|
+
const raw = asString(scope).trim();
|
|
199
|
+
if (!raw) return buildFallbackSessionKey(route);
|
|
200
|
+
|
|
201
|
+
const parsed = parseStrictBncrSessionKey(raw);
|
|
202
|
+
if (!parsed) return null;
|
|
203
|
+
return parsed.sessionKey;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function extractInlineTaskKey(text: string): { taskKey: string | null; text: string } {
|
|
207
|
+
const raw = asString(text);
|
|
208
|
+
if (!raw) return { taskKey: null, text: '' };
|
|
209
|
+
|
|
210
|
+
const tagged = raw.match(/^\s*(?:#task|\/task)\s*[:=]\s*([a-zA-Z0-9_-]{1,32})\s*\n?\s*([\s\S]*)$/i);
|
|
211
|
+
if (tagged) {
|
|
212
|
+
return {
|
|
213
|
+
taskKey: normalizeTaskKey(tagged[1]),
|
|
214
|
+
text: asString(tagged[2]),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const spaced = raw.match(/^\s*\/task\s+([a-zA-Z0-9_-]{1,32})\s+([\s\S]*)$/i);
|
|
219
|
+
if (spaced) {
|
|
220
|
+
return {
|
|
221
|
+
taskKey: normalizeTaskKey(spaced[1]),
|
|
222
|
+
text: asString(spaced[2]),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { taskKey: null, text: raw };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function withTaskSessionKey(sessionKey: string, taskKey?: string | null): string {
|
|
230
|
+
const base = asString(sessionKey).trim();
|
|
231
|
+
const tk = normalizeTaskKey(taskKey);
|
|
232
|
+
if (!base || !tk) return base;
|
|
233
|
+
if (/:task:[a-z0-9_-]+(?:$|:)/i.test(base)) return base;
|
|
234
|
+
return `${base}:task:${tk}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function buildFallbackSessionKey(route: BncrRoute): string {
|
|
238
|
+
return `${BNCR_SESSION_KEY_PREFIX}${routeScopeToHex(route)}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function routeKey(accountId: string, route: BncrRoute): string {
|
|
242
|
+
return `${accountId}:${route.platform}:${route.groupId}:${route.userId}`.toLowerCase();
|
|
243
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type BncrRoute = {
|
|
2
|
+
platform: string;
|
|
3
|
+
groupId: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type BncrConnection = {
|
|
8
|
+
accountId: string;
|
|
9
|
+
connId: string;
|
|
10
|
+
clientId?: string;
|
|
11
|
+
connectedAt: number;
|
|
12
|
+
lastSeenAt: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PendingAdmission = {
|
|
16
|
+
clientId: string;
|
|
17
|
+
route: BncrRoute;
|
|
18
|
+
routes: BncrRoute[];
|
|
19
|
+
firstSeenAt: number;
|
|
20
|
+
lastSeenAt: number;
|
|
21
|
+
attempts: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type OutboxEntry = {
|
|
25
|
+
messageId: string;
|
|
26
|
+
accountId: string;
|
|
27
|
+
sessionKey: string;
|
|
28
|
+
route: BncrRoute;
|
|
29
|
+
payload: Record<string, unknown>;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
retryCount: number;
|
|
32
|
+
nextAttemptAt: number;
|
|
33
|
+
lastAttemptAt?: number;
|
|
34
|
+
lastError?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type BncrDiagnosticsSummary = {
|
|
38
|
+
health: {
|
|
39
|
+
connected: boolean;
|
|
40
|
+
pending: number;
|
|
41
|
+
pendingAdmissions: number;
|
|
42
|
+
deadLetter: number;
|
|
43
|
+
activeConnections: number;
|
|
44
|
+
connectEvents: number;
|
|
45
|
+
inboundEvents: number;
|
|
46
|
+
activityEvents: number;
|
|
47
|
+
ackEvents: number;
|
|
48
|
+
uptimeSec: number;
|
|
49
|
+
};
|
|
50
|
+
regression: {
|
|
51
|
+
pluginFilesPresent: boolean;
|
|
52
|
+
pluginIndexExists: boolean;
|
|
53
|
+
pluginChannelExists: boolean;
|
|
54
|
+
totalKnownRoutes: number;
|
|
55
|
+
invalidOutboxSessionKeys: number;
|
|
56
|
+
legacyAccountResidue: number;
|
|
57
|
+
ok: boolean;
|
|
58
|
+
};
|
|
59
|
+
};
|