@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.
@@ -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: asString(params.replyToId || '').trim() || undefined,
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, Number(nextAttemptAt || 0) - nowMs);
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(Number(delayMs || 0), 30_000));
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 { normalizeReplyToId } from './media-dedupe.ts';
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: normalizeReplyToId(payload?.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 nextRetryCount = Number(input.currentRetryCount || 0) + 1;
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 ? Number(input.currentRouteAttemptRound || 0) : Number(input.currentRouteAttemptRound || 0) + 1;
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 nextRetryCount = Number(input.currentRetryCount || 0) + 1;
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,