@xmoxmo/bncr 0.2.5 → 0.2.6
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 +2 -2
- package/package.json +1 -1
- package/src/channel.ts +762 -209
- package/src/core/connection-reachability.ts +41 -14
- package/src/core/diagnostics.ts +7 -2
- package/src/core/downlink-health.ts +7 -2
- package/src/core/outbox-entry-builders.ts +3 -2
- package/src/core/policy.ts +9 -0
- package/src/core/register-trace.ts +6 -1
- package/src/core/status.ts +7 -2
- package/src/core/types.ts +1 -0
- package/src/messaging/inbound/commands.ts +318 -75
- package/src/messaging/inbound/dispatch.ts +372 -114
- package/src/messaging/inbound/parse.ts +8 -0
- package/src/messaging/inbound/session-label.ts +115 -0
- package/src/messaging/outbound/diagnostics.ts +16 -0
- package/src/messaging/outbound/media.ts +3 -1
- package/src/messaging/outbound/queue-selectors.ts +7 -2
- package/src/messaging/outbound/reasons.ts +4 -0
- package/src/messaging/outbound/reply-enqueue.ts +2 -2
- package/src/messaging/outbound/reply-target-policy.ts +13 -0
- package/src/messaging/outbound/retry-policy.ts +12 -3
- package/src/messaging/outbound/send.ts +6 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
recordSessionMetaFromInbound,
|
|
3
|
+
updateSessionStoreEntry,
|
|
4
|
+
} from 'openclaw/plugin-sdk/session-store-runtime';
|
|
5
|
+
import { emitBncrLogLine } from '../../core/logging.ts';
|
|
6
|
+
|
|
7
|
+
type RecordInboundSessionFn = (args: any) => Promise<unknown> | unknown;
|
|
8
|
+
|
|
9
|
+
export function buildBncrInboundSessionIdentityPatch(args: {
|
|
10
|
+
channelId: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
chatType: 'direct' | 'group';
|
|
13
|
+
displayTo: string;
|
|
14
|
+
senderId: string;
|
|
15
|
+
}) {
|
|
16
|
+
const { channelId, accountId, chatType, displayTo, senderId } = args;
|
|
17
|
+
return {
|
|
18
|
+
label: displayTo,
|
|
19
|
+
channel: channelId,
|
|
20
|
+
chatType,
|
|
21
|
+
origin: {
|
|
22
|
+
label: displayTo,
|
|
23
|
+
provider: channelId,
|
|
24
|
+
surface: channelId,
|
|
25
|
+
chatType,
|
|
26
|
+
from: senderId,
|
|
27
|
+
to: displayTo,
|
|
28
|
+
accountId,
|
|
29
|
+
},
|
|
30
|
+
deliveryContext: {
|
|
31
|
+
channel: channelId,
|
|
32
|
+
to: displayTo,
|
|
33
|
+
accountId,
|
|
34
|
+
},
|
|
35
|
+
route: {
|
|
36
|
+
channel: channelId,
|
|
37
|
+
accountId,
|
|
38
|
+
target: { to: displayTo },
|
|
39
|
+
},
|
|
40
|
+
lastTo: displayTo,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeNonEmptyString(value: unknown): string | null {
|
|
45
|
+
const normalized = String(value ?? '').trim();
|
|
46
|
+
return normalized || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function correctBncrInboundSessionLabel(args: {
|
|
50
|
+
storePath: string;
|
|
51
|
+
sessionKey: string;
|
|
52
|
+
expectedLabel: string;
|
|
53
|
+
}) {
|
|
54
|
+
const storePath = normalizeNonEmptyString(args.storePath);
|
|
55
|
+
const sessionKey = normalizeNonEmptyString(args.sessionKey);
|
|
56
|
+
const expectedLabel = normalizeNonEmptyString(args.expectedLabel);
|
|
57
|
+
if (!storePath || !sessionKey || !expectedLabel) return;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await updateSessionStoreEntry({
|
|
61
|
+
storePath,
|
|
62
|
+
sessionKey,
|
|
63
|
+
update: (entry: any) => {
|
|
64
|
+
if (entry?.label === expectedLabel) return null;
|
|
65
|
+
return { label: expectedLabel };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
emitBncrLogLine('warn', `[bncr] inbound session label correction failed: ${String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function recordAndPatchBncrInboundSessionEntry(args: {
|
|
74
|
+
storePath: string;
|
|
75
|
+
sessionKey: string;
|
|
76
|
+
ctx?: Record<string, unknown>;
|
|
77
|
+
patch: Record<string, unknown>;
|
|
78
|
+
}) {
|
|
79
|
+
const storePath = normalizeNonEmptyString(args.storePath);
|
|
80
|
+
const sessionKey = normalizeNonEmptyString(args.sessionKey);
|
|
81
|
+
if (!storePath || !sessionKey) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
if (args.ctx) {
|
|
85
|
+
await recordSessionMetaFromInbound({
|
|
86
|
+
storePath,
|
|
87
|
+
sessionKey,
|
|
88
|
+
ctx: args.ctx as any,
|
|
89
|
+
createIfMissing: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
await updateSessionStoreEntry({
|
|
93
|
+
storePath,
|
|
94
|
+
sessionKey,
|
|
95
|
+
update: () => args.patch,
|
|
96
|
+
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
emitBncrLogLine('warn', `[bncr] inbound session patch failed: ${String(err)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function wrapBncrInboundRecordSessionLabelCorrection(args: {
|
|
103
|
+
recordInboundSession: RecordInboundSessionFn;
|
|
104
|
+
expectedLabel: string;
|
|
105
|
+
}): RecordInboundSessionFn {
|
|
106
|
+
return async (recordArgs: any) => {
|
|
107
|
+
const result = await args.recordInboundSession(recordArgs);
|
|
108
|
+
await correctBncrInboundSessionLabel({
|
|
109
|
+
storePath: recordArgs?.storePath,
|
|
110
|
+
sessionKey: recordArgs?.sessionKey,
|
|
111
|
+
expectedLabel: args.expectedLabel,
|
|
112
|
+
});
|
|
113
|
+
return result;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -119,15 +119,31 @@ export function buildOutboxAckDebugInfo(args: {
|
|
|
119
119
|
connIds?: Iterable<string>;
|
|
120
120
|
ownerConnId?: string;
|
|
121
121
|
ownerClientId?: string;
|
|
122
|
+
sessionKey?: string;
|
|
123
|
+
to?: string;
|
|
124
|
+
ackStage?: string;
|
|
125
|
+
ackOutcome?: string;
|
|
126
|
+
reason?: string;
|
|
122
127
|
kind?: string;
|
|
123
128
|
event?: string;
|
|
129
|
+
ackTimeoutMs?: number;
|
|
130
|
+
adaptiveAckTimeoutEnabled?: boolean;
|
|
124
131
|
}) {
|
|
125
132
|
return {
|
|
126
133
|
messageId: args.messageId,
|
|
127
134
|
accountId: args.accountId,
|
|
135
|
+
...(args.sessionKey ? { sessionKey: args.sessionKey } : {}),
|
|
136
|
+
...(args.to ? { to: args.to } : {}),
|
|
128
137
|
...(args.kind ? { kind: args.kind } : {}),
|
|
129
138
|
requireAck: args.requireAck,
|
|
130
139
|
ackResult: args.ackResult,
|
|
140
|
+
ackStage: args.ackStage || 'message',
|
|
141
|
+
ackOutcome: args.ackOutcome || args.ackResult,
|
|
142
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
143
|
+
...(typeof args.ackTimeoutMs === 'number' ? { ackTimeoutMs: args.ackTimeoutMs } : {}),
|
|
144
|
+
...(typeof args.adaptiveAckTimeoutEnabled === 'boolean'
|
|
145
|
+
? { adaptiveAckTimeoutEnabled: args.adaptiveAckTimeoutEnabled }
|
|
146
|
+
: {}),
|
|
131
147
|
onlineNow: args.onlineNow,
|
|
132
148
|
recentInboundReachable: args.recentInboundReachable,
|
|
133
149
|
...(args.connIds ? { connIds: Array.from(args.connIds) } : {}),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
2
|
+
|
|
1
3
|
function asString(v: unknown, fallback = ''): string {
|
|
2
4
|
if (typeof v === 'string') return v;
|
|
3
5
|
if (v == null) return fallback;
|
|
@@ -64,7 +66,7 @@ export function buildBncrMediaOutboundFrame(params: {
|
|
|
64
66
|
messageId: params.messageId,
|
|
65
67
|
idempotencyKey: params.messageId,
|
|
66
68
|
sessionKey: params.sessionKey,
|
|
67
|
-
replyToId:
|
|
69
|
+
replyToId: normalizeOutboundReplyToId({ kind: params.kind, replyToId: params.replyToId }) || undefined,
|
|
68
70
|
message: {
|
|
69
71
|
platform: params.route.platform,
|
|
70
72
|
groupId: params.route.groupId,
|
|
@@ -25,8 +25,13 @@ export type OutboxFileTransferRouteSelection = {
|
|
|
25
25
|
ownerConnId?: string;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
function finiteNumberOr(value: unknown, fallback: number): number {
|
|
29
|
+
const n = Number(value);
|
|
30
|
+
return Number.isFinite(n) ? n : fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export function computeOutboxRetryWait(nextAttemptAt: number, nowMs: number): number {
|
|
29
|
-
return Math.max(0,
|
|
34
|
+
return Math.max(0, finiteNumberOr(nextAttemptAt, 0) - finiteNumberOr(nowMs, 0));
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay: number | null): number | null {
|
|
@@ -35,7 +40,7 @@ export function updateMinOutboxDelay(currentDelay: number | null, candidateDelay
|
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export function clampOutboxDrainDelay(delayMs: number): number {
|
|
38
|
-
return Math.max(0, Math.min(
|
|
43
|
+
return Math.max(0, Math.min(finiteNumberOr(delayMs, 0), 30_000));
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export function selectOutboxTargetAccounts(args: {
|
|
@@ -38,6 +38,10 @@ export const OUTBOUND_SCHEDULE_SOURCE = {
|
|
|
38
38
|
RETRY_REROUTE_WAIT: 'retry-reroute-wait',
|
|
39
39
|
// Direct push failure kept entry in outbox and scheduled backoff.
|
|
40
40
|
PUSH_FAIL_WAIT: 'push-fail-wait',
|
|
41
|
+
// Per-account flush processed its single-run item budget and yielded to the next drain.
|
|
42
|
+
ACCOUNT_BUDGET_YIELD: 'account-budget-yield',
|
|
43
|
+
// Per-account flush spent its single-run time budget and yielded to the next drain.
|
|
44
|
+
ACCOUNT_TIME_BUDGET_YIELD: 'account-time-budget-yield',
|
|
41
45
|
// Account-local next delay was merged into bridge-global next delay.
|
|
42
46
|
ACCOUNT_NEXT_DELAY_MERGE: 'account-next-delay-merge',
|
|
43
47
|
// flushPushQueue(...) finished and armed the next bridge-level drain.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
|
|
2
2
|
import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
|
|
4
4
|
|
|
5
5
|
export type ReplyPayloadInput = {
|
|
6
6
|
text?: string;
|
|
@@ -324,6 +324,6 @@ export function normalizeReplyPayload(
|
|
|
324
324
|
asVoice: payload?.asVoice === true,
|
|
325
325
|
audioAsVoice: payload?.audioAsVoice === true,
|
|
326
326
|
kind: payload?.kind,
|
|
327
|
-
replyToId:
|
|
327
|
+
replyToId: normalizeOutboundReplyToId({ kind: payload?.kind, replyToId: payload?.replyToId }),
|
|
328
328
|
};
|
|
329
329
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { normalizeReplyToId } from './media-dedupe.ts';
|
|
2
|
+
|
|
3
|
+
export type OutboundReplyKind = 'tool' | 'block' | 'final';
|
|
4
|
+
|
|
5
|
+
const STRIP_TOOL_REPLY_TO_ID = true;
|
|
6
|
+
|
|
7
|
+
export function normalizeOutboundReplyToId(params: {
|
|
8
|
+
kind?: OutboundReplyKind;
|
|
9
|
+
replyToId?: string | null;
|
|
10
|
+
}) {
|
|
11
|
+
if (params.kind === 'tool' && STRIP_TOOL_REPLY_TO_ID) return '';
|
|
12
|
+
return normalizeReplyToId(params.replyToId);
|
|
13
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { BncrRoute } from '../../channel.ts';
|
|
2
2
|
|
|
3
|
+
function finiteNonNegativeIntegerOr(value: unknown, fallback: number): number {
|
|
4
|
+
const n = Number(value);
|
|
5
|
+
if (!Number.isFinite(n) || n < 0) return fallback;
|
|
6
|
+
return Math.floor(n);
|
|
7
|
+
}
|
|
8
|
+
|
|
3
9
|
export type RetryRerouteDecisionInput = {
|
|
4
10
|
nowMs: number;
|
|
5
11
|
maxRetry: number;
|
|
@@ -51,7 +57,9 @@ export function computeRetryRerouteDecision(
|
|
|
51
57
|
const hasUntriedAlternative = availableConnIds.some((connId) => !attemptedConnIds.includes(connId));
|
|
52
58
|
const shouldFastReroute = input.requireAck && input.currentFastReroutePending !== true && hasUntriedAlternative;
|
|
53
59
|
|
|
54
|
-
const
|
|
60
|
+
const currentRetryCount = finiteNonNegativeIntegerOr(input.currentRetryCount, 0);
|
|
61
|
+
const currentRouteAttemptRound = finiteNonNegativeIntegerOr(input.currentRouteAttemptRound, 0);
|
|
62
|
+
const nextRetryCount = currentRetryCount + 1;
|
|
55
63
|
const lastAttemptAt = input.nowMs;
|
|
56
64
|
const terminalReason =
|
|
57
65
|
input.lastError || (input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed');
|
|
@@ -67,7 +75,7 @@ export function computeRetryRerouteDecision(
|
|
|
67
75
|
|
|
68
76
|
const nextAttemptAt = shouldFastReroute ? input.nowMs + 1_000 : input.nowMs + deps.backoffMs(nextRetryCount);
|
|
69
77
|
const lastError = input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
|
|
70
|
-
const routeAttemptRound = hasUntriedAlternative ?
|
|
78
|
+
const routeAttemptRound = hasUntriedAlternative ? currentRouteAttemptRound : currentRouteAttemptRound + 1;
|
|
71
79
|
const fastReroutePending = hasUntriedAlternative ? shouldFastReroute || input.currentFastReroutePending === true : false;
|
|
72
80
|
|
|
73
81
|
return {
|
|
@@ -111,7 +119,8 @@ export function computePushFailureDecision(
|
|
|
111
119
|
input: PushFailureDecisionInput,
|
|
112
120
|
deps: { backoffMs: (retryCount: number) => number },
|
|
113
121
|
): PushFailureDecision {
|
|
114
|
-
const
|
|
122
|
+
const currentRetryCount = finiteNonNegativeIntegerOr(input.currentRetryCount, 0);
|
|
123
|
+
const nextRetryCount = currentRetryCount + 1;
|
|
115
124
|
const lastAttemptAt = input.nowMs;
|
|
116
125
|
|
|
117
126
|
if (nextRetryCount > input.maxRetry) {
|
|
@@ -3,6 +3,7 @@ export async function sendBncrText(params: {
|
|
|
3
3
|
accountId: string;
|
|
4
4
|
to: string;
|
|
5
5
|
text: string;
|
|
6
|
+
kind?: 'tool' | 'block' | 'final';
|
|
6
7
|
replyToId?: string;
|
|
7
8
|
mediaLocalRoots?: readonly string[];
|
|
8
9
|
resolveVerifiedTarget: (
|
|
@@ -18,6 +19,7 @@ export async function sendBncrText(params: {
|
|
|
18
19
|
text?: string;
|
|
19
20
|
mediaUrl?: string;
|
|
20
21
|
mediaUrls?: string[];
|
|
22
|
+
kind?: 'tool' | 'block' | 'final';
|
|
21
23
|
replyToId?: string;
|
|
22
24
|
};
|
|
23
25
|
mediaLocalRoots?: readonly string[];
|
|
@@ -33,6 +35,7 @@ export async function sendBncrText(params: {
|
|
|
33
35
|
route: verified.route,
|
|
34
36
|
payload: {
|
|
35
37
|
text: params.text,
|
|
38
|
+
kind: params.kind,
|
|
36
39
|
replyToId: params.replyToId,
|
|
37
40
|
},
|
|
38
41
|
mediaLocalRoots: params.mediaLocalRoots,
|
|
@@ -54,6 +57,7 @@ export async function sendBncrMedia(params: {
|
|
|
54
57
|
mediaUrls?: string[];
|
|
55
58
|
asVoice?: boolean;
|
|
56
59
|
audioAsVoice?: boolean;
|
|
60
|
+
kind?: 'tool' | 'block' | 'final';
|
|
57
61
|
replyToId?: string;
|
|
58
62
|
mediaLocalRoots?: readonly string[];
|
|
59
63
|
resolveVerifiedTarget: (
|
|
@@ -71,6 +75,7 @@ export async function sendBncrMedia(params: {
|
|
|
71
75
|
mediaUrls?: string[];
|
|
72
76
|
asVoice?: boolean;
|
|
73
77
|
audioAsVoice?: boolean;
|
|
78
|
+
kind?: 'tool' | 'block' | 'final';
|
|
74
79
|
replyToId?: string;
|
|
75
80
|
};
|
|
76
81
|
mediaLocalRoots?: readonly string[];
|
|
@@ -90,6 +95,7 @@ export async function sendBncrMedia(params: {
|
|
|
90
95
|
mediaUrls: params.mediaUrls?.length ? params.mediaUrls : undefined,
|
|
91
96
|
asVoice: params.asVoice === true ? true : undefined,
|
|
92
97
|
audioAsVoice: params.audioAsVoice === true ? true : undefined,
|
|
98
|
+
kind: params.kind,
|
|
93
99
|
replyToId: params.replyToId,
|
|
94
100
|
},
|
|
95
101
|
mediaLocalRoots: params.mediaLocalRoots,
|