@xmoxmo/bncr 0.2.6 → 0.2.8

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 (45) hide show
  1. package/README.md +7 -1
  2. package/index.ts +30 -15
  3. package/package.json +4 -3
  4. package/scripts/check-pack.mjs +77 -0
  5. package/scripts/selfcheck.mjs +10 -0
  6. package/src/channel.ts +398 -642
  7. package/src/core/extended-diagnostics.ts +10 -0
  8. package/src/core/file-ack.ts +9 -0
  9. package/src/core/file-transfer-payloads.ts +72 -0
  10. package/src/core/register-trace.ts +79 -0
  11. package/src/core/targets.ts +10 -1
  12. package/src/messaging/inbound/commands.ts +20 -10
  13. package/src/messaging/inbound/context-facts.ts +200 -0
  14. package/src/messaging/inbound/dispatch.ts +66 -14
  15. package/src/messaging/inbound/gate.ts +66 -26
  16. package/src/messaging/inbound/runtime-compat.ts +41 -0
  17. package/src/messaging/inbound/session-label.ts +7 -7
  18. package/src/messaging/outbound/durable-message-adapter.ts +107 -0
  19. package/src/messaging/outbound/durable-queue-adapter.ts +157 -0
  20. package/src/messaging/outbound/session-route.ts +2 -2
  21. package/src/openclaw/config-runtime.ts +52 -0
  22. package/src/openclaw/inbound-session-runtime.ts +94 -0
  23. package/src/openclaw/ingress-runtime.ts +35 -0
  24. package/src/openclaw/media-runtime.ts +73 -0
  25. package/src/openclaw/reply-runtime.ts +104 -0
  26. package/src/openclaw/routing-runtime.ts +48 -0
  27. package/src/openclaw/sdk-helpers.ts +20 -0
  28. package/src/openclaw/session-route-runtime.ts +15 -0
  29. package/src/plugin/capabilities.ts +8 -0
  30. package/src/plugin/config.ts +35 -0
  31. package/src/plugin/gateway-methods.ts +12 -0
  32. package/src/plugin/gateway-runtime.ts +11 -0
  33. package/src/plugin/message-policy.ts +4 -0
  34. package/src/plugin/message-send.ts +13 -0
  35. package/src/plugin/messaging.ts +142 -0
  36. package/src/plugin/meta.ts +10 -0
  37. package/src/plugin/outbound.ts +51 -0
  38. package/src/plugin/setup.ts +24 -0
  39. package/src/plugin/status.ts +38 -0
  40. package/src/runtime/log-dedupe.ts +56 -0
  41. package/src/runtime/outbound-ack-timeout.ts +96 -0
  42. package/src/runtime/outbound-flags.ts +81 -0
  43. package/src/runtime/outbox-transitions.ts +119 -0
  44. package/src/runtime/status-snapshots.ts +108 -0
  45. package/src/runtime/status-worker.ts +172 -0
@@ -1,6 +1,4 @@
1
1
  import fs from 'node:fs';
2
- import { resolvePinnedMainDmOwnerFromAllowlist } from 'openclaw/plugin-sdk/conversation-runtime';
3
- import { resolveInboundLastRouteSessionKey } from 'openclaw/plugin-sdk/routing';
4
2
  import { emitBncrLogLine } from '../../core/logging.ts';
5
3
  import { resolveBncrChannelPolicy } from '../../core/policy.ts';
6
4
  import {
@@ -9,8 +7,29 @@ import {
9
7
  withTaskSessionKey,
10
8
  } from '../../core/targets.ts';
11
9
  import { handleBncrNativeCommand } from './commands.ts';
10
+ import {
11
+ buildBncrPromptVisibleContextFacts,
12
+ buildBncrStructuredContextFactsFromInboundParts,
13
+ } from './context-facts.ts';
12
14
  import { buildBncrReplyConfig } from './reply-config.ts';
15
+ import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
13
16
  import { wrapBncrInboundRecordSessionLabelCorrection } from './session-label.ts';
17
+ import { saveOpenClawChannelMediaBuffer } from '../../openclaw/media-runtime.ts';
18
+ import {
19
+ dispatchOpenClawReplyWithBufferedBlockDispatcher,
20
+ formatOpenClawAgentEnvelope,
21
+ resolveOpenClawEnvelopeFormatOptions,
22
+ } from '../../openclaw/reply-runtime.ts';
23
+ import {
24
+ resolveOpenClawAgentRoute,
25
+ resolveOpenClawInboundLastRouteSessionKey,
26
+ } from '../../openclaw/routing-runtime.ts';
27
+ import {
28
+ readBncrSessionUpdatedAt,
29
+ recordBncrInboundSession,
30
+ resolveBncrInboundSessionStorePath,
31
+ resolveBncrPinnedMainDmOwnerFromAllowlist,
32
+ } from '../../openclaw/inbound-session-runtime.ts';
14
33
 
15
34
  type ParsedInbound = ReturnType<typeof import('./parse.ts')['parseBncrInboundParams']>;
16
35
 
@@ -93,7 +112,7 @@ export function resolveBncrInboundConversation(args: {
93
112
  const { api, cfg, channelId, parsed, canonicalAgentId } = args;
94
113
  const { accountId, route, peer, sessionKeyfromroute, providedOriginatingTo, extracted } = parsed;
95
114
 
96
- const resolvedRoute = api.runtime.channel.routing.resolveAgentRoute({
115
+ const resolvedRoute = resolveOpenClawAgentRoute(api, {
97
116
  cfg,
98
117
  channel: channelId,
99
118
  accountId,
@@ -149,14 +168,16 @@ async function prepareBncrInboundSessionContext(args: {
149
168
  rememberSessionRoute(taskSessionKey, accountId, route);
150
169
  }
151
170
 
152
- const storePath = api.runtime.channel.session.resolveStorePath(cfg?.session?.store, {
171
+ const storePath = resolveBncrInboundSessionStorePath({
172
+ storeConfig: cfg?.session?.store,
153
173
  agentId: resolvedRoute.agentId,
154
174
  });
155
175
 
156
176
  let mediaPath: string | undefined;
157
177
  if (mediaBase64) {
158
178
  const mediaBuf = decodeInboundMediaBase64(mediaBase64);
159
- const saved = await api.runtime.channel.media.saveMediaBuffer(
179
+ const saved = await saveOpenClawChannelMediaBuffer(
180
+ api,
160
181
  mediaBuf,
161
182
  mimeType,
162
183
  'inbound',
@@ -169,15 +190,15 @@ async function prepareBncrInboundSessionContext(args: {
169
190
  }
170
191
 
171
192
  const rawBody = extracted.text || (msgType === 'text' ? '' : `[${msgType}]`);
172
- const body = api.runtime.channel.reply.formatAgentEnvelope({
193
+ const body = formatOpenClawAgentEnvelope(api, {
173
194
  channel: 'Bncr',
174
195
  from: `${platform}:${groupId}:${userId}`,
175
196
  timestamp: Date.now(),
176
- previousTimestamp: api.runtime.channel.session.readSessionUpdatedAt({
197
+ previousTimestamp: readBncrSessionUpdatedAt(api, {
177
198
  storePath,
178
199
  sessionKey: dispatchSessionKey,
179
200
  }),
180
- envelope: api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg),
201
+ envelope: resolveOpenClawEnvelopeFormatOptions(api, cfg),
181
202
  body: rawBody,
182
203
  });
183
204
 
@@ -192,6 +213,7 @@ async function prepareBncrInboundSessionContext(args: {
192
213
  function buildBncrInboundTurnContext(args: {
193
214
  api: any;
194
215
  channelId: string;
216
+ parsed: ParsedInbound;
195
217
  msgId?: string | null;
196
218
  mimeType?: string;
197
219
  mediaPath?: string;
@@ -207,6 +229,7 @@ function buildBncrInboundTurnContext(args: {
207
229
  const {
208
230
  api,
209
231
  channelId,
232
+ parsed,
210
233
  msgId,
211
234
  mimeType,
212
235
  mediaPath,
@@ -216,8 +239,31 @@ function buildBncrInboundTurnContext(args: {
216
239
  resolution,
217
240
  prepared,
218
241
  } = args;
242
+ const structuredContextFacts = buildBncrStructuredContextFactsFromInboundParts({
243
+ channelId,
244
+ parsed,
245
+ resolution,
246
+ prepared: {
247
+ rawBody: prepared.rawBody,
248
+ body: prepared.body,
249
+ mediaPath,
250
+ },
251
+ senderIdForContext,
252
+ senderDisplayName,
253
+ });
254
+ const promptVisibleContextFacts = buildBncrPromptVisibleContextFacts(structuredContextFacts);
255
+ const supplementalUntrustedContext = Object.keys(promptVisibleContextFacts).length
256
+ ? [
257
+ {
258
+ label: 'Bncr inbound context',
259
+ source: channelId,
260
+ type: 'bncr.inbound_context',
261
+ payload: promptVisibleContextFacts,
262
+ },
263
+ ]
264
+ : [];
219
265
 
220
- return api.runtime.channel.turn.buildContext({
266
+ return resolveBncrChannelInboundRuntime(api).buildContext({
221
267
  channel: channelId,
222
268
  provider: channelId,
223
269
  surface: channelId,
@@ -275,8 +321,13 @@ function buildBncrInboundTurnContext(args: {
275
321
  },
276
322
  ]
277
323
  : [],
324
+ supplemental: {
325
+ untrustedContext: supplementalUntrustedContext,
326
+ },
278
327
  extra: {
279
328
  OriginatingChannel: channelId,
329
+ BncrStructuredContextFacts: structuredContextFacts,
330
+ StructuredContextFacts: structuredContextFacts,
280
331
  },
281
332
  });
282
333
  }
@@ -291,7 +342,7 @@ function buildBncrInboundRecordUpdateLastRoute(args: {
291
342
  const { channelId, peer, senderIdForContext, resolution, pinnedMainDmOwner } = args;
292
343
  if (peer.kind !== 'direct') return undefined;
293
344
 
294
- const sessionKey = resolveInboundLastRouteSessionKey({
345
+ const sessionKey = resolveOpenClawInboundLastRouteSessionKey({
295
346
  route: resolution.resolvedRoute,
296
347
  sessionKey: resolution.dispatchSessionKey,
297
348
  });
@@ -406,6 +457,7 @@ export async function dispatchBncrInbound(params: {
406
457
  const ctxPayload = buildBncrInboundTurnContext({
407
458
  api,
408
459
  channelId,
460
+ parsed,
409
461
  msgId,
410
462
  mimeType,
411
463
  mediaPath,
@@ -420,7 +472,7 @@ export async function dispatchBncrInbound(params: {
420
472
  const channelPolicy = resolveBncrChannelPolicy(cfg?.channels?.bncr || {});
421
473
  const pinnedMainDmOwner =
422
474
  peer.kind === 'direct'
423
- ? resolvePinnedMainDmOwnerFromAllowlist({
475
+ ? resolveBncrPinnedMainDmOwnerFromAllowlist({
424
476
  dmScope: cfg?.session?.dmScope,
425
477
  allowFrom: channelPolicy.allowFrom,
426
478
  normalizeEntry: (entry: string) => String(entry || '').trim(),
@@ -434,7 +486,7 @@ export async function dispatchBncrInbound(params: {
434
486
  pinnedMainDmOwner,
435
487
  });
436
488
 
437
- await api.runtime.channel.turn.run({
489
+ await resolveBncrChannelInboundRuntime(api).run({
438
490
  channel: channelId,
439
491
  accountId,
440
492
  raw: parsed,
@@ -454,7 +506,7 @@ export async function dispatchBncrInbound(params: {
454
506
  storePath,
455
507
  ctxPayload,
456
508
  recordInboundSession: wrapBncrInboundRecordSessionLabelCorrection({
457
- recordInboundSession: api.runtime.channel.session.recordInboundSession,
509
+ recordInboundSession: recordBncrInboundSession,
458
510
  expectedLabel: canonicalTo,
459
511
  }),
460
512
  record: {
@@ -464,7 +516,7 @@ export async function dispatchBncrInbound(params: {
464
516
  },
465
517
  },
466
518
  runDispatch: () =>
467
- api.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
519
+ dispatchOpenClawReplyWithBufferedBlockDispatcher(api, {
468
520
  ctx: ctxPayload,
469
521
  cfg: effectiveReply.replyCfg,
470
522
  dispatcherOptions: {
@@ -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
  }
@@ -0,0 +1,41 @@
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
+ const channelRuntimeKeys = Object.keys(channelRuntime ?? {}).sort().join(',') || 'none';
24
+ const inboundRuntimeKeys = Object.keys(inboundRuntime ?? {}).sort().join(',') || 'none';
25
+ emitBncrLogLine(
26
+ 'warn',
27
+ `[bncr] inbound runtime fallback=turn|preferred=inbound|channelKeys=${channelRuntimeKeys}|inboundKeys=${inboundRuntimeKeys}`,
28
+ );
29
+ }
30
+ return {
31
+ buildContext: legacyTurnRuntime.buildContext,
32
+ run: legacyTurnRuntime.run,
33
+ runPreparedReply: legacyTurnRuntime.runPrepared,
34
+ dispatchReply: legacyTurnRuntime.dispatchAssembled ?? legacyTurnRuntime.runAssembled,
35
+ };
36
+ }
37
+
38
+ throw new Error(
39
+ 'OpenClaw channel inbound runtime is unavailable: expected runtime.channel.inbound.* or legacy runtime.channel.turn.*',
40
+ );
41
+ }
@@ -1,8 +1,8 @@
1
- import {
2
- recordSessionMetaFromInbound,
3
- updateSessionStoreEntry,
4
- } from 'openclaw/plugin-sdk/session-store-runtime';
5
1
  import { emitBncrLogLine } from '../../core/logging.ts';
2
+ import {
3
+ recordBncrSessionMetaFromInbound,
4
+ updateBncrSessionStoreEntry,
5
+ } from '../../openclaw/inbound-session-runtime.ts';
6
6
 
7
7
  type RecordInboundSessionFn = (args: any) => Promise<unknown> | unknown;
8
8
 
@@ -57,7 +57,7 @@ export async function correctBncrInboundSessionLabel(args: {
57
57
  if (!storePath || !sessionKey || !expectedLabel) return;
58
58
 
59
59
  try {
60
- await updateSessionStoreEntry({
60
+ await updateBncrSessionStoreEntry({
61
61
  storePath,
62
62
  sessionKey,
63
63
  update: (entry: any) => {
@@ -82,14 +82,14 @@ export async function recordAndPatchBncrInboundSessionEntry(args: {
82
82
 
83
83
  try {
84
84
  if (args.ctx) {
85
- await recordSessionMetaFromInbound({
85
+ await recordBncrSessionMetaFromInbound({
86
86
  storePath,
87
87
  sessionKey,
88
88
  ctx: args.ctx as any,
89
89
  createIfMissing: true,
90
90
  });
91
91
  }
92
- await updateSessionStoreEntry({
92
+ await updateBncrSessionStoreEntry({
93
93
  storePath,
94
94
  sessionKey,
95
95
  update: () => args.patch,
@@ -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,4 +1,4 @@
1
- import { buildChannelOutboundSessionRoute } from 'openclaw/plugin-sdk/core';
1
+ import { buildOpenClawChannelOutboundSessionRoute } from '../../openclaw/session-route-runtime.ts';
2
2
  import {
3
3
  buildCanonicalBncrSessionKey,
4
4
  formatDisplayScope,
@@ -72,7 +72,7 @@ export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessi
72
72
  const sessionKey = buildCanonicalBncrSessionKey(route, canonicalAgentId);
73
73
  const displayTo = formatDisplayScope(route);
74
74
 
75
- const built = buildChannelOutboundSessionRoute({
75
+ const built = buildOpenClawChannelOutboundSessionRoute({
76
76
  cfg: params.cfg,
77
77
  agentId: canonicalAgentId,
78
78
  channel: params.channel,