@xmoxmo/bncr 0.2.5 → 0.2.7

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.
Files changed (41) hide show
  1. package/README.md +9 -3
  2. package/index.ts +30 -15
  3. package/package.json +4 -3
  4. package/scripts/check-pack.mjs +61 -0
  5. package/scripts/selfcheck.mjs +10 -0
  6. package/src/channel.ts +892 -255
  7. package/src/core/connection-reachability.ts +41 -14
  8. package/src/core/diagnostics.ts +7 -2
  9. package/src/core/downlink-health.ts +7 -2
  10. package/src/core/outbox-entry-builders.ts +3 -2
  11. package/src/core/policy.ts +9 -0
  12. package/src/core/register-trace.ts +6 -1
  13. package/src/core/status.ts +7 -2
  14. package/src/core/targets.ts +10 -1
  15. package/src/core/types.ts +1 -0
  16. package/src/messaging/inbound/commands.ts +330 -77
  17. package/src/messaging/inbound/context-facts.ts +200 -0
  18. package/src/messaging/inbound/dispatch.ts +429 -119
  19. package/src/messaging/inbound/gate.ts +66 -26
  20. package/src/messaging/inbound/parse.ts +8 -0
  21. package/src/messaging/inbound/runtime-compat.ts +39 -0
  22. package/src/messaging/inbound/session-label.ts +115 -0
  23. package/src/messaging/outbound/diagnostics.ts +16 -0
  24. package/src/messaging/outbound/durable-message-adapter.ts +107 -0
  25. package/src/messaging/outbound/durable-queue-adapter.ts +157 -0
  26. package/src/messaging/outbound/media.ts +3 -1
  27. package/src/messaging/outbound/queue-selectors.ts +7 -2
  28. package/src/messaging/outbound/reasons.ts +4 -0
  29. package/src/messaging/outbound/reply-enqueue.ts +2 -2
  30. package/src/messaging/outbound/reply-target-policy.ts +13 -0
  31. package/src/messaging/outbound/retry-policy.ts +12 -3
  32. package/src/messaging/outbound/send.ts +6 -0
  33. package/src/messaging/outbound/session-route.ts +2 -2
  34. package/src/openclaw/config-runtime.ts +52 -0
  35. package/src/openclaw/inbound-session-runtime.ts +94 -0
  36. package/src/openclaw/ingress-runtime.ts +35 -0
  37. package/src/openclaw/media-runtime.ts +73 -0
  38. package/src/openclaw/reply-runtime.ts +104 -0
  39. package/src/openclaw/routing-runtime.ts +48 -0
  40. package/src/openclaw/sdk-helpers.ts +20 -0
  41. package/src/openclaw/session-route-runtime.ts +15 -0
@@ -1,6 +1,10 @@
1
1
  import { normalizeAccountId } from '../../core/accounts.ts';
2
2
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
3
3
  import { buildDisplayScopeCandidates } from '../../core/targets.ts';
4
+ import {
5
+ defineOpenClawStableChannelIngressIdentity,
6
+ resolveOpenClawChannelMessageIngress,
7
+ } from '../../openclaw/ingress-runtime.ts';
4
8
 
5
9
  export type BncrGateResult = { allowed: true } | { allowed: false; reason: string };
6
10
 
@@ -10,13 +14,40 @@ function asString(v: unknown, fallback = ''): string {
10
14
  return String(v);
11
15
  }
12
16
 
13
- function matchesAllowList(list: string[], candidates: string[]): boolean {
14
- if (!list.length) return false;
15
- const normalized = new Set(list.map((x) => asString(x).trim()).filter(Boolean));
16
- return candidates.some((x) => normalized.has(asString(x).trim()));
17
+ const bncrIngressIdentity = defineOpenClawStableChannelIngressIdentity({
18
+ key: 'displayScope',
19
+ kind: 'plugin:bncr-display-scope',
20
+ normalize: (value: string) => asString(value).trim() || null,
21
+ sensitivity: 'pii',
22
+ entryIdPrefix: 'bncr-allow',
23
+ aliases: [
24
+ {
25
+ key: 'routeKey',
26
+ kind: 'plugin:bncr-route-key',
27
+ normalize: (value: string) => asString(value).trim() || null,
28
+ sensitivity: 'pii',
29
+ },
30
+ ],
31
+ });
32
+
33
+ function gateReasonFromIngress(reasonCode?: string): string {
34
+ switch (reasonCode) {
35
+ case 'dm_policy_disabled':
36
+ return 'dm disabled';
37
+ case 'dm_policy_not_allowlisted':
38
+ case 'dm_policy_pairing_required':
39
+ return 'dm allowlist blocked';
40
+ case 'group_policy_disabled':
41
+ return 'group disabled';
42
+ case 'group_policy_not_allowlisted':
43
+ case 'group_policy_empty_allowlist':
44
+ return 'group allowlist blocked';
45
+ default:
46
+ return reasonCode || 'ingress blocked';
47
+ }
17
48
  }
18
49
 
19
- export function checkBncrMessageGate(params: {
50
+ export async function checkBncrMessageGate(params: {
20
51
  parsed: any;
21
52
  cfg: any;
22
53
  account: { accountId: string; enabled?: boolean };
@@ -34,31 +65,40 @@ export function checkBncrMessageGate(params: {
34
65
  const route = parsed?.route;
35
66
  const isGroup = asString(route?.groupId || '0') !== '0';
36
67
 
37
- if (!isGroup && policy.dmPolicy === 'disabled') {
38
- return { allowed: false, reason: 'dm disabled' };
39
- }
40
-
41
- if (isGroup && policy.groupPolicy === 'disabled') {
42
- return { allowed: false, reason: 'group disabled' };
43
- }
44
-
45
68
  const candidates = buildDisplayScopeCandidates(route);
46
-
47
- if (!isGroup && policy.dmPolicy === 'allowlist') {
48
- if (!matchesAllowList(policy.allowFrom, candidates)) {
49
- return { allowed: false, reason: 'dm allowlist blocked' };
50
- }
51
- }
52
-
53
- if (isGroup && policy.groupPolicy === 'allowlist') {
54
- if (!matchesAllowList(policy.groupAllowFrom, candidates)) {
55
- return { allowed: false, reason: 'group allowlist blocked' };
56
- }
57
- }
69
+ const displayScope = candidates[0] || '';
70
+ const routeKey = candidates.find((candidate) => candidate !== displayScope) || displayScope;
58
71
 
59
72
  // requireMention 默认值为 false。
60
73
  // 设计目标:当它未来真正生效时,含义是“群消息只有在明确提到机器人时才允许进入处理链”。
61
74
  // 但当前 parse 层尚未稳定提取 mentions,上游客户端也未统一透传 mention 信号,
62
75
  // 因此现阶段即使配置为 true,也仍不做实际拦截,避免出现半实现状态。
63
- return { allowed: true };
76
+ const resolved = await resolveOpenClawChannelMessageIngress({
77
+ channelId: 'bncr',
78
+ accountId,
79
+ identity: bncrIngressIdentity,
80
+ subject: {
81
+ stableId: displayScope,
82
+ aliases: { routeKey },
83
+ },
84
+ conversation: {
85
+ kind: isGroup ? 'group' : 'direct',
86
+ id: isGroup ? asString(route?.groupId) : asString(route?.userId || displayScope),
87
+ },
88
+ event: { kind: 'message', authMode: 'inbound', mayPair: !isGroup },
89
+ policy: {
90
+ dmPolicy: policy.dmPolicy,
91
+ groupPolicy: policy.groupPolicy,
92
+ groupAllowFromFallbackToAllowFrom: false,
93
+ },
94
+ allowFrom: policy.dmPolicy === 'open' ? ['*', ...policy.allowFrom] : policy.allowFrom,
95
+ groupAllowFrom: policy.groupAllowFrom,
96
+ accessGroups: cfg?.accessGroups,
97
+ });
98
+
99
+ if (resolved.ingress.admission === 'dispatch' || resolved.ingress.admission === 'observe') {
100
+ return { allowed: true };
101
+ }
102
+
103
+ return { allowed: false, reason: gateReasonFromIngress(resolved.ingress.reasonCode) };
64
104
  }
@@ -36,6 +36,10 @@ export function inboundDedupKey(params: {
36
36
  }
37
37
 
38
38
  export function resolveChatType(_route: BncrRoute): 'direct' | 'group' {
39
+ // Compatibility boundary: bncr currently records and dispatches all conversations as direct
40
+ // sessions even when the display scope contains a group id. Do not change this to true
41
+ // group semantics without updating session routing, reply target policy, and requireMention
42
+ // behavior together.
39
43
  return 'direct';
40
44
  }
41
45
 
@@ -45,6 +49,9 @@ export function parseBncrInboundParams(params: any) {
45
49
  const groupId = asString(params?.groupId || '0').trim() || '0';
46
50
  const userId = asString(params?.userId || '').trim();
47
51
  const sessionKeyfromroute = asString(params?.sessionKey || '').trim();
52
+ const providedOriginatingTo = asString(
53
+ params?.originatingTo || params?.providedOriginatingTo || params?.to || '',
54
+ ).trim() || undefined;
48
55
  const clientId = asString(params?.clientId || '').trim() || undefined;
49
56
 
50
57
  const route: BncrRoute = {
@@ -84,6 +91,7 @@ export function parseBncrInboundParams(params: any) {
84
91
  groupId,
85
92
  userId,
86
93
  sessionKeyfromroute,
94
+ providedOriginatingTo,
87
95
  clientId,
88
96
  route,
89
97
  text,
@@ -0,0 +1,39 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+
3
+ type ChannelRuntimeCompat = {
4
+ buildContext: (...args: any[]) => any;
5
+ run: (...args: any[]) => Promise<any> | any;
6
+ runPreparedReply?: (...args: any[]) => Promise<any> | any;
7
+ dispatchReply?: (...args: any[]) => Promise<any> | any;
8
+ };
9
+
10
+ let warnedLegacyTurnRuntime = false;
11
+
12
+ export function resolveBncrChannelInboundRuntime(api: any): ChannelRuntimeCompat {
13
+ const channelRuntime = api?.runtime?.channel;
14
+ const inboundRuntime = channelRuntime?.inbound;
15
+ if (inboundRuntime?.buildContext && inboundRuntime?.run) {
16
+ return inboundRuntime;
17
+ }
18
+
19
+ const legacyTurnRuntime = channelRuntime?.turn;
20
+ if (legacyTurnRuntime?.buildContext && legacyTurnRuntime?.run) {
21
+ if (!warnedLegacyTurnRuntime) {
22
+ warnedLegacyTurnRuntime = true;
23
+ emitBncrLogLine(
24
+ 'warn',
25
+ '[bncr] using legacy runtime.channel.turn compatibility path; upgrade path prefers runtime.channel.inbound',
26
+ );
27
+ }
28
+ return {
29
+ buildContext: legacyTurnRuntime.buildContext,
30
+ run: legacyTurnRuntime.run,
31
+ runPreparedReply: legacyTurnRuntime.runPrepared,
32
+ dispatchReply: legacyTurnRuntime.dispatchAssembled ?? legacyTurnRuntime.runAssembled,
33
+ };
34
+ }
35
+
36
+ throw new Error(
37
+ 'OpenClaw channel inbound runtime is unavailable: expected runtime.channel.inbound.* or legacy runtime.channel.turn.*',
38
+ );
39
+ }
@@ -0,0 +1,115 @@
1
+ import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import {
3
+ recordBncrSessionMetaFromInbound,
4
+ updateBncrSessionStoreEntry,
5
+ } from '../../openclaw/inbound-session-runtime.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 updateBncrSessionStoreEntry({
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 recordBncrSessionMetaFromInbound({
86
+ storePath,
87
+ sessionKey,
88
+ ctx: args.ctx as any,
89
+ createIfMissing: true,
90
+ });
91
+ }
92
+ await updateBncrSessionStoreEntry({
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) } : {}),
@@ -0,0 +1,107 @@
1
+ import { defineChannelMessageAdapter } from 'openclaw/plugin-sdk/channel-outbound';
2
+ import type {
3
+ ChannelMessageAdapterShape,
4
+ ChannelMessageSendMediaContext,
5
+ ChannelMessageSendPayloadContext,
6
+ ChannelMessageSendResult,
7
+ ChannelMessageSendTextContext,
8
+ } from 'openclaw/plugin-sdk/channel-outbound';
9
+
10
+ import { buildFileTransferOutboxEntry, buildTextOutboxEntry } from '../../core/outbox-entry-builders.ts';
11
+ import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
12
+ import { buildBncrDurableQueuedResult } from './durable-queue-adapter.ts';
13
+
14
+ export type BncrDurableMessageQueuedAdapterDeps<TConfig = unknown> = {
15
+ enqueueText: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
16
+ enqueueMedia?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
17
+ enqueuePayload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<OutboxEntry> | OutboxEntry;
18
+ now?: () => number;
19
+ };
20
+
21
+ export type BncrDurableMessageQueuedAdapterBuilderDeps<TConfig = unknown> = {
22
+ createMessageId: () => string;
23
+ now: () => number;
24
+ normalizeAccountId: (accountId?: string | null) => string;
25
+ normalizeReplyToId: (value?: string | null) => string;
26
+ resolveTarget: (ctx: ChannelMessageSendTextContext<TConfig>) => {
27
+ route: BncrRoute;
28
+ sessionKey: string;
29
+ accountId?: string | null;
30
+ };
31
+ filePushEvent?: string;
32
+ };
33
+
34
+ // This adapter intentionally models only the OpenClaw -> bncr-plugin handoff.
35
+ // Once a message is accepted into bncr's own outbox, OpenClaw should stop managing it;
36
+ // client/platform ACK, retry, and deadLetter remain owned by the bncr service framework.
37
+ export function createBncrDurableMessageQueuedAdapter<TConfig = unknown>(
38
+ deps: BncrDurableMessageQueuedAdapterDeps<TConfig>,
39
+ ): ChannelMessageAdapterShape<TConfig, ChannelMessageSendResult> {
40
+ return defineChannelMessageAdapter({
41
+ id: 'bncr-queued-outbox',
42
+ receive: {
43
+ defaultAckPolicy: 'manual',
44
+ supportedAckPolicies: ['manual'],
45
+ },
46
+ send: {
47
+ text: async (ctx) => toChannelMessageSendResult(await deps.enqueueText(ctx), deps.now),
48
+ media: deps.enqueueMedia
49
+ ? async (ctx) => toChannelMessageSendResult(await deps.enqueueMedia?.(ctx), deps.now)
50
+ : undefined,
51
+ payload: deps.enqueuePayload
52
+ ? async (ctx) => toChannelMessageSendResult(await deps.enqueuePayload?.(ctx), deps.now)
53
+ : undefined,
54
+ },
55
+ });
56
+ }
57
+
58
+ export function createBncrDurableMessageQueuedAdapterFromBuilders<TConfig = unknown>(
59
+ deps: BncrDurableMessageQueuedAdapterBuilderDeps<TConfig>,
60
+ ): ChannelMessageAdapterShape<TConfig, ChannelMessageSendResult> {
61
+ return createBncrDurableMessageQueuedAdapter<TConfig>({
62
+ now: deps.now,
63
+ enqueueText: (ctx) => {
64
+ const resolved = deps.resolveTarget(ctx);
65
+ return buildTextOutboxEntry({
66
+ createMessageId: deps.createMessageId,
67
+ now: deps.now,
68
+ normalizeAccountId: deps.normalizeAccountId,
69
+ normalizeReplyToId: deps.normalizeReplyToId,
70
+ accountId: resolved.accountId ?? ctx.accountId ?? undefined,
71
+ sessionKey: resolved.sessionKey,
72
+ route: resolved.route,
73
+ text: ctx.text,
74
+ kind: 'final',
75
+ replyToId: ctx.replyToId ?? undefined,
76
+ });
77
+ },
78
+ enqueueMedia: (ctx) => {
79
+ const resolved = deps.resolveTarget(ctx);
80
+ return buildFileTransferOutboxEntry({
81
+ createMessageId: deps.createMessageId,
82
+ now: deps.now,
83
+ normalizeAccountId: deps.normalizeAccountId,
84
+ pushEvent: deps.filePushEvent ?? 'bncr.file.push',
85
+ accountId: resolved.accountId ?? ctx.accountId ?? undefined,
86
+ sessionKey: resolved.sessionKey,
87
+ route: resolved.route,
88
+ mediaUrl: ctx.mediaUrl,
89
+ mediaLocalRoots: ctx.mediaLocalRoots,
90
+ text: ctx.text,
91
+ asVoice: ctx.audioAsVoice,
92
+ audioAsVoice: ctx.audioAsVoice,
93
+ kind: 'final',
94
+ replyToId: ctx.replyToId ?? undefined,
95
+ });
96
+ },
97
+ });
98
+ }
99
+
100
+ function toChannelMessageSendResult(entry: OutboxEntry | undefined, now?: () => number): ChannelMessageSendResult {
101
+ if (!entry) throw new Error('bncr durable message adapter did not receive an outbox entry');
102
+ const queued = buildBncrDurableQueuedResult({ entry, sentAt: now?.() });
103
+ return {
104
+ receipt: queued.receipt as any,
105
+ messageId: queued.receipt.primaryPlatformMessageId,
106
+ };
107
+ }
@@ -0,0 +1,157 @@
1
+ import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
+ import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
3
+
4
+ export type BncrDurableQueuedReceipt = {
5
+ primaryPlatformMessageId: string;
6
+ platformMessageIds: string[];
7
+ parts: Array<{
8
+ platformMessageId: string;
9
+ kind: 'text' | 'media' | 'voice' | 'unknown';
10
+ index: number;
11
+ threadId?: string;
12
+ replyToId?: string;
13
+ raw: {
14
+ channel: 'bncr';
15
+ channelId: 'bncr';
16
+ messageId: string;
17
+ chatId: string;
18
+ conversationId: string;
19
+ timestamp: number;
20
+ meta: BncrDurableQueuedReceiptMeta;
21
+ };
22
+ }>;
23
+ threadId?: string;
24
+ replyToId?: string;
25
+ sentAt: number;
26
+ raw: Array<{
27
+ channel: 'bncr';
28
+ channelId: 'bncr';
29
+ messageId: string;
30
+ chatId: string;
31
+ conversationId: string;
32
+ timestamp: number;
33
+ meta: BncrDurableQueuedReceiptMeta;
34
+ }>;
35
+ };
36
+
37
+ export type BncrDurableQueuedReceiptMeta = {
38
+ status: 'accepted';
39
+ deliveryStage: 'queued';
40
+ queue: 'bncr.outbox';
41
+ finalAckManagedBy: 'bncr-outbox';
42
+ ackSemantics: 'plugin-accepted-not-client-acked';
43
+ accountId: string;
44
+ sessionKey: string;
45
+ route: BncrRoute;
46
+ outboxPayloadType?: string;
47
+ };
48
+
49
+ export type BncrDurableQueuedResult = {
50
+ status: 'sent';
51
+ results: Array<{
52
+ channel: 'bncr';
53
+ channelId: 'bncr';
54
+ messageId: string;
55
+ chatId: string;
56
+ conversationId: string;
57
+ timestamp: number;
58
+ meta: BncrDurableQueuedReceiptMeta;
59
+ }>;
60
+ receipt: BncrDurableQueuedReceipt;
61
+ payloadOutcomes: Array<{
62
+ index: number;
63
+ status: 'sent';
64
+ results: BncrDurableQueuedResult['results'];
65
+ }>;
66
+ };
67
+
68
+ export function buildBncrDurableQueuedResult(args: {
69
+ entry: OutboxEntry;
70
+ index?: number;
71
+ threadId?: string;
72
+ replyToId?: string;
73
+ sentAt?: number;
74
+ }): BncrDurableQueuedResult {
75
+ const sentAt = Number.isFinite(args.sentAt) ? Number(args.sentAt) : args.entry.createdAt;
76
+ const platformMessageId = args.entry.messageId;
77
+ const replyToId = normalizeOutboundReplyToId({ replyToId: args.replyToId ?? extractReplyToId(args.entry) }) || undefined;
78
+ const chatId = formatQueuedReceiptChatId(args.entry.route);
79
+ const meta: BncrDurableQueuedReceiptMeta = {
80
+ status: 'accepted',
81
+ deliveryStage: 'queued',
82
+ queue: 'bncr.outbox',
83
+ finalAckManagedBy: 'bncr-outbox',
84
+ ackSemantics: 'plugin-accepted-not-client-acked',
85
+ accountId: args.entry.accountId,
86
+ sessionKey: args.entry.sessionKey,
87
+ route: args.entry.route,
88
+ outboxPayloadType: extractPayloadType(args.entry),
89
+ };
90
+ const result = {
91
+ channel: 'bncr' as const,
92
+ channelId: 'bncr' as const,
93
+ messageId: platformMessageId,
94
+ chatId,
95
+ conversationId: args.entry.sessionKey,
96
+ timestamp: sentAt,
97
+ meta,
98
+ };
99
+ const receipt: BncrDurableQueuedReceipt = {
100
+ primaryPlatformMessageId: platformMessageId,
101
+ platformMessageIds: [platformMessageId],
102
+ parts: [
103
+ {
104
+ platformMessageId,
105
+ kind: inferReceiptKind(args.entry),
106
+ index: args.index ?? 0,
107
+ threadId: args.threadId,
108
+ replyToId,
109
+ raw: result,
110
+ },
111
+ ],
112
+ threadId: args.threadId,
113
+ replyToId,
114
+ sentAt,
115
+ raw: [result],
116
+ };
117
+ return {
118
+ status: 'sent',
119
+ results: [result],
120
+ receipt,
121
+ payloadOutcomes: [
122
+ {
123
+ index: args.index ?? 0,
124
+ status: 'sent',
125
+ results: [result],
126
+ },
127
+ ],
128
+ };
129
+ }
130
+
131
+ function extractPayloadType(entry: OutboxEntry): string | undefined {
132
+ const payload = entry.payload as any;
133
+ return typeof payload?.type === 'string' ? payload.type : undefined;
134
+ }
135
+
136
+ function extractReplyToId(entry: OutboxEntry): string | undefined {
137
+ const payload = entry.payload as any;
138
+ const metaReply = payload?._meta?.replyToId;
139
+ const replyToId = payload?.replyToId ?? metaReply;
140
+ return typeof replyToId === 'string' ? replyToId : undefined;
141
+ }
142
+
143
+ function inferReceiptKind(entry: OutboxEntry): 'text' | 'media' | 'voice' | 'unknown' {
144
+ const payload = entry.payload as any;
145
+ if (payload?._meta?.kind === 'file-transfer') {
146
+ if (payload?._meta?.asVoice === true || payload?._meta?.audioAsVoice === true) return 'voice';
147
+ return 'media';
148
+ }
149
+ if (payload?.message?.type === 'text') return 'text';
150
+ return 'unknown';
151
+ }
152
+
153
+ function formatQueuedReceiptChatId(route: BncrRoute): string {
154
+ const platform = route.platform || 'unknown';
155
+ if (route.groupId) return `Bncr:${platform}:${route.groupId}:${route.userId}`;
156
+ return `Bncr:${platform}:${route.userId}`;
157
+ }
@@ -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
+ }