@xmoxmo/bncr 0.6.0 → 0.6.1

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 CHANGED
@@ -81,6 +81,13 @@ openclaw devices approve --latest
81
81
  - 状态诊断
82
82
  - 文件互传
83
83
 
84
+ ### 0.6.1 新增说明
85
+
86
+ - `bncr.outbound.recent`:本地 bridge 方法,可直接查询最近的出站记录;该记录来自出站重放缓存,不额外维护一份独立最近出站存储。
87
+ - 出站重放缓存:最近出站消息会在下轮入站上下文回灌时带给 agent,用于让 agent 知道自己刚发过什么;查询和回灌共享同一份 `outboundReplayCache`。
88
+ - bridge 方法调用:`message` 工具支持传入 `bridgeMethod`,可调用插件本地方法(如 `bncr.status.*`、`bncr.outbound.recent`)或转发到 OpenClawClient RPC。
89
+ - unsend / delete:`message` 工具支持撤回/删除动作,按发送缓存映射回真实平台消息,不需要业务侧单独拼平台 message id。
90
+
84
91
  补充口径:
85
92
 
86
93
  - 入站会话按正式 scene / route 语义接入 OpenClaw,区分私聊与群聊会话。
@@ -285,6 +292,7 @@ npm pack
285
292
  - `npm run typecheck`:执行 `tsconfig.typecheck.json`,确保类型边界与 OpenClaw surface 对齐
286
293
  - `npm run selfcheck`:检查插件骨架是否完整,并验证关键 OpenClaw SDK subpath 可解析
287
294
  - `npm run check-pack`:执行 `npm pack --dry-run --json`,确认发布包包含关键入口与 OpenClaw adapter 文件
295
+ - `tests/integrity/source-manifest.test.mjs`:确保 `selfcheck` / `check-pack` 的源文件清单与当前 `src/**/*.ts` 一致,新增源文件不会漏进发布清单
288
296
  - `npm run format:check`:执行 Biome `format` 只读检查(不带 `--write`),防止格式漂移混进发布门禁
289
297
  - `npm pack`:确认当前版本可正常打包
290
298
  - `npm run check-register-drift -- --duration-sec 300 --interval-sec 15`:静置采样 `bncr.diagnostics`,观察 `registerCount / apiGeneration / postWarmupRegisterCount` 是否在 warmup 后继续增长
package/dist/index.js CHANGED
@@ -626,6 +626,7 @@ function createBncrRegisterRuntime() {
626
626
  "bncr.diagnostics": (bridge, opts) => bridge.handleDiagnostics(opts),
627
627
  "bncr.deadLetter.inspect": (bridge, opts) => bridge.handleDeadLetterInspect(opts),
628
628
  "bncr.deadLetter.prune": (bridge, opts) => bridge.handleDeadLetterPrune(opts),
629
+ "bncr.rpc.response": (bridge, opts) => bridge.handleRpcResponse(opts),
629
630
  "bncr.file.init": (bridge, opts) => bridge.handleFileInit(opts),
630
631
  "bncr.file.chunk": (bridge, opts) => bridge.handleFileChunk(opts),
631
632
  "bncr.file.complete": (bridge, opts) => bridge.handleFileComplete(opts),
@@ -936,6 +937,7 @@ var plugin = {
936
937
  ensureGatewayMethodRegistered(api, "bncr.diagnostics", debugLog);
937
938
  ensureGatewayMethodRegistered(api, "bncr.deadLetter.inspect", debugLog);
938
939
  ensureGatewayMethodRegistered(api, "bncr.deadLetter.prune", debugLog);
940
+ ensureGatewayMethodRegistered(api, "bncr.rpc.response", debugLog);
939
941
  ensureGatewayMethodRegistered(api, "bncr.file.init", debugLog);
940
942
  ensureGatewayMethodRegistered(api, "bncr.file.chunk", debugLog);
941
943
  ensureGatewayMethodRegistered(api, "bncr.file.complete", debugLog);
package/index.ts CHANGED
@@ -208,6 +208,7 @@ const plugin = {
208
208
  ensureGatewayMethodRegistered(api, 'bncr.diagnostics', debugLog);
209
209
  ensureGatewayMethodRegistered(api, 'bncr.deadLetter.inspect', debugLog);
210
210
  ensureGatewayMethodRegistered(api, 'bncr.deadLetter.prune', debugLog);
211
+ ensureGatewayMethodRegistered(api, 'bncr.rpc.response', debugLog);
211
212
  ensureGatewayMethodRegistered(api, 'bncr.file.init', debugLog);
212
213
  ensureGatewayMethodRegistered(api, 'bncr.file.chunk', debugLog);
213
214
  ensureGatewayMethodRegistered(api, 'bncr.file.complete', debugLog);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,5 +62,8 @@
62
62
  "runtimeExtensions": [
63
63
  "./dist/index.js"
64
64
  ]
65
+ },
66
+ "dependencies": {
67
+ "typebox": "^1.3.10"
65
68
  }
66
69
  }
@@ -48,7 +48,6 @@ const requiredSourceFiles = [
48
48
  'src/core/outbox-summary.ts',
49
49
  'src/core/outbox-text-push-failure.ts',
50
50
  'src/core/outbox-text-push-guards.ts',
51
- 'src/core/outbox-text-push-prep.ts',
52
51
  'src/core/outbox-text-push-success.ts',
53
52
  'src/core/permissions.ts',
54
53
  'src/core/persisted-outbox-entry.ts',
@@ -68,6 +67,7 @@ const requiredSourceFiles = [
68
67
  'src/messaging/inbound/media-url-download.ts',
69
68
  'src/messaging/inbound/native-command.ts',
70
69
  'src/messaging/inbound/native-reply-delivery.ts',
70
+ 'src/messaging/inbound/outbound-replay-cache.ts',
71
71
  'src/messaging/inbound/parse.ts',
72
72
  'src/messaging/inbound/remote-media.ts',
73
73
  'src/messaging/inbound/reply-config.ts',
@@ -109,6 +109,8 @@ const requiredSourceFiles = [
109
109
  'src/plugin/channel-runtime-types.ts',
110
110
  'src/plugin/channel-send.ts',
111
111
  'src/plugin/channel-utils.ts',
112
+ 'src/plugin/bridge-call.ts',
113
+ 'src/plugin/client-rpc-runtime.ts',
112
114
  'src/plugin/config.ts',
113
115
  'src/plugin/connection-handlers.ts',
114
116
  'src/plugin/connection-state.ts',
@@ -168,6 +170,44 @@ const requiredSourceFiles = [
168
170
  'src/runtime/register-trace-runtime.ts',
169
171
  'src/runtime/status-snapshots.ts',
170
172
  'src/runtime/status-worker.ts',
173
+ 'src/bootstrap/register-runtime-gateway.ts',
174
+ 'src/bootstrap/register-runtime-singleton.ts',
175
+ 'src/core/value-sanitize.ts',
176
+ 'src/messaging/inbound/contracts.ts',
177
+ 'src/messaging/inbound/group-history.ts',
178
+ 'src/messaging/inbound/native-command-runtime.ts',
179
+ 'src/messaging/inbound/reply-dispatch-serial.ts',
180
+ 'src/messaging/inbound/scene-admin.ts',
181
+ 'src/messaging/inbound/session-meta-task.ts',
182
+ 'src/messaging/outbound/marker-parser.ts',
183
+ 'src/messaging/outbound/normalize-outbound-send.ts',
184
+ 'src/messaging/outbound/reply-enqueue-media.ts',
185
+ 'src/openclaw/channel-runtime-contracts.ts',
186
+ 'src/plugin/bridge-ack-facade.ts',
187
+ 'src/plugin/bridge-connection-facade.ts',
188
+ 'src/plugin/bridge-diagnostics-facade.ts',
189
+ 'src/plugin/bridge-drain-facade.ts',
190
+ 'src/plugin/bridge-extended-diagnostics-facade.ts',
191
+ 'src/plugin/bridge-lifecycle.ts',
192
+ 'src/plugin/bridge-media-facade.ts',
193
+ 'src/plugin/bridge-outbox-facade.ts',
194
+ 'src/plugin/bridge-runtime-helpers.ts',
195
+ 'src/plugin/bridge-runtime-snapshots.ts',
196
+ 'src/plugin/bridge-runtime-surface-facade.ts',
197
+ 'src/plugin/bridge-status-facade.ts',
198
+ 'src/plugin/bridge-status-worker-facade.ts',
199
+ 'src/plugin/bridge-support-runtime.ts',
200
+ 'src/plugin/channel-runtime-builders-delivery.ts',
201
+ 'src/plugin/channel-runtime-builders-status.ts',
202
+ 'src/plugin/connection-handlers-helpers.ts',
203
+ 'src/plugin/connection-state-helpers.ts',
204
+ 'src/plugin/error-message.ts',
205
+ 'src/plugin/file-transfer-orchestrator-chunk.ts',
206
+ 'src/plugin/outbox-unified-push-flow.ts',
207
+ 'src/plugin/runtime-diagnostics-assembler.ts',
208
+ 'src/plugin/runtime-diagnostics-helpers.ts',
209
+ 'src/plugin/runtime-diagnostics-payload-builders.ts',
210
+ 'src/plugin/scene-registry.ts',
171
211
  ];
172
212
 
173
213
  const requiredPackFiles = [
@@ -69,6 +69,7 @@ const requiredSourceFiles = [
69
69
  'src/messaging/inbound/native-command-runtime.ts',
70
70
  'src/messaging/inbound/native-command.ts',
71
71
  'src/messaging/inbound/native-reply-delivery.ts',
72
+ 'src/messaging/inbound/outbound-replay-cache.ts',
72
73
  'src/messaging/inbound/parse.ts',
73
74
  'src/messaging/inbound/remote-media.ts',
74
75
  'src/messaging/inbound/reply-config.ts',
@@ -106,6 +107,7 @@ const requiredSourceFiles = [
106
107
  'src/openclaw/session-route-runtime.ts',
107
108
  'src/plugin/ack-outbox-runtime-group.ts',
108
109
  'src/plugin/bridge-ack-facade.ts',
110
+ 'src/plugin/bridge-call.ts',
109
111
  'src/plugin/bridge-connection-facade.ts',
110
112
  'src/plugin/bridge-diagnostics-facade.ts',
111
113
  'src/plugin/bridge-drain-facade.ts',
@@ -133,6 +135,7 @@ const requiredSourceFiles = [
133
135
  'src/plugin/channel-runtime-types.ts',
134
136
  'src/plugin/channel-send.ts',
135
137
  'src/plugin/channel-utils.ts',
138
+ 'src/plugin/client-rpc-runtime.ts',
136
139
  'src/plugin/config.ts',
137
140
  'src/plugin/connection-handlers-helpers.ts',
138
141
  'src/plugin/connection-handlers.ts',
@@ -23,6 +23,7 @@ export type BncrGatewayMethodName =
23
23
  | 'bncr.diagnostics'
24
24
  | 'bncr.deadLetter.inspect'
25
25
  | 'bncr.deadLetter.prune'
26
+ | 'bncr.rpc.response'
26
27
  | 'bncr.file.init'
27
28
  | 'bncr.file.chunk'
28
29
  | 'bncr.file.complete'
@@ -97,6 +97,7 @@ export function createBncrRegisterRuntime() {
97
97
  'bncr.diagnostics': (bridge, opts) => bridge.handleDiagnostics(opts),
98
98
  'bncr.deadLetter.inspect': (bridge, opts) => bridge.handleDeadLetterInspect(opts),
99
99
  'bncr.deadLetter.prune': (bridge, opts) => bridge.handleDeadLetterPrune(opts),
100
+ 'bncr.rpc.response': (bridge, opts) => bridge.handleRpcResponse(opts),
100
101
  'bncr.file.init': (bridge, opts) => bridge.handleFileInit(opts),
101
102
  'bncr.file.chunk': (bridge, opts) => bridge.handleFileChunk(opts),
102
103
  'bncr.file.complete': (bridge, opts) => bridge.handleFileComplete(opts),
package/src/channel.ts CHANGED
@@ -4,7 +4,12 @@ import type {
4
4
  OpenClawPluginApi,
5
5
  OpenClawPluginServiceContext,
6
6
  } from 'openclaw/plugin-sdk/core';
7
- import { BNCR_DEFAULT_ACCOUNT_ID, CHANNEL_ID, normalizeAccountId } from './core/accounts.ts';
7
+ import {
8
+ BNCR_DEFAULT_ACCOUNT_ID,
9
+ CHANNEL_ID,
10
+ normalizeAccountId,
11
+ resolveAccount,
12
+ } from './core/accounts.ts';
8
13
  import {
9
14
  countInvalidOutboxSessionKeys as countInvalidOutboxSessionKeysFromRuntime,
10
15
  countLegacyAccountResidue as countLegacyAccountResidueFromRuntime,
@@ -39,6 +44,7 @@ import type {
39
44
  OutboxEntry,
40
45
  } from './core/types.ts';
41
46
  import type { BncrGroupHistoryMap } from './messaging/inbound/group-history.ts';
47
+ import type { BncrOutboundReplayCache } from './messaging/inbound/outbound-replay-cache.ts';
42
48
  import type { parseBncrInboundParams } from './messaging/inbound/parse.ts';
43
49
  import { buildEnqueueFromReplyDebugInfo } from './messaging/outbound/diagnostics.ts';
44
50
  import { buildBncrMediaOutboundFrame } from './messaging/outbound/media.ts';
@@ -160,6 +166,7 @@ import {
160
166
  now,
161
167
  resolveOutboundFileName,
162
168
  } from './plugin/channel-utils.ts';
169
+ import { createBncrClientRpcRuntime } from './plugin/client-rpc-runtime.ts';
163
170
  import { BNCR_CONFIG_SURFACE } from './plugin/config.ts';
164
171
  import { createBncrConnectionStateRuntimeGroup } from './plugin/connection-state-runtime-group.ts';
165
172
  import type { FileAckPayloadState } from './plugin/file-ack-runtime.ts';
@@ -287,6 +294,7 @@ class BncrBridgeRuntime {
287
294
  return this.sceneRegistry;
288
295
  }
289
296
  private groupHistories: BncrGroupHistoryMap = new Map();
297
+ private outboundReplayCache: BncrOutboundReplayCache = new Map();
290
298
  private routeAliases = new Map<
291
299
  string,
292
300
  { accountId: string; route: BncrRoute; updatedAt: number }
@@ -373,6 +381,14 @@ class BncrBridgeRuntime {
373
381
  >();
374
382
  private earlyFileAcks = new Map<string, FileAckPayloadState>();
375
383
 
384
+ private readonly clientRpcRuntime = createBncrClientRpcRuntime({
385
+ resolvePushConnIds: (accountId) => this.resolvePushConnIds(accountId),
386
+ broadcastToConnIds: (event, payload, connIds) =>
387
+ this.gatewayContext?.broadcastToConnIds(event, payload, connIds),
388
+ now,
389
+ logInfo: (scope, message, options) => this.logInfo(scope, message, options),
390
+ });
391
+
376
392
  private readonly bridgeSupportRuntime = createBridgeSupportRuntime({
377
393
  isStopped: () => this.stopped,
378
394
  hasSaveTimer: () => Boolean(this.saveTimer),
@@ -851,6 +867,7 @@ class BncrBridgeRuntime {
851
867
  }
852
868
 
853
869
  private cleanupRuntimeWaitersAndTimers(reason: string) {
870
+ this.clientRpcRuntime.shutdown();
854
871
  cleanupBncrBridgeRuntime(
855
872
  {
856
873
  bridgeId: this.bridgeId,
@@ -1759,6 +1776,7 @@ class BncrBridgeRuntime {
1759
1776
  setOutboxEntry: (messageId, entry) => this.outbox.set(messageId, entry),
1760
1777
  resolveMessageAck: (messageId, result) => this.resolveMessageAck(messageId, result),
1761
1778
  moveToDeadLetter: (entry, reason) => this.moveToDeadLetter(entry, reason),
1779
+ markRecentOutboundAcked: (entry) => this.markRecentOutboundAcked(entry),
1762
1780
  recordAckTimeoutTelemetry: (accountId) => this.recordAckTimeoutTelemetry(accountId),
1763
1781
  degradeOutboundCapability: (args) => this.degradeOutboundCapability(args),
1764
1782
  flushPushQueueBestEffort: (args) => this.flushPushQueueBestEffort(args),
@@ -1884,6 +1902,7 @@ class BncrBridgeRuntime {
1884
1902
  maxAccountActivityEntries: MAX_ACCOUNT_ACTIVITY_ENTRIES,
1885
1903
  sceneRegistry: this.sceneRegistry,
1886
1904
  groupHistories: this.groupHistories,
1905
+ outboundReplayCache: this.outboundReplayCache,
1887
1906
  outbox: this.outbox,
1888
1907
  getDeadLetter: () => this.deadLetter,
1889
1908
  setDeadLetter: (entries) => {
@@ -2148,6 +2167,16 @@ class BncrBridgeRuntime {
2148
2167
  lastPrePushGuardSkipReasonByAccount: this.lastPrePushGuardSkipReasonByAccount,
2149
2168
  deadLetterSinceStartByAccount: this.deadLetterSinceStartByAccount,
2150
2169
  lastOutboundByAccount: this.lastOutboundByAccount,
2170
+ outboundReplayCache: this.outboundReplayCache,
2171
+ resolveOutboundSender: (entry) => {
2172
+ try {
2173
+ const account = resolveAccount(getOpenClawRuntimeConfig(this.api), entry.accountId);
2174
+ return { sender: account.name, senderId: entry.accountId };
2175
+ } catch {
2176
+ return { sender: 'OpenClaw', senderId: entry.accountId };
2177
+ }
2178
+ },
2179
+ isOutboundAckRequired: (accountId) => this.isOutboundAckRequired(accountId),
2151
2180
  scheduleSave: () => this.scheduleSave(),
2152
2181
  flushPushQueueBestEffort: (args) => this.flushPushQueueBestEffort(args),
2153
2182
  logInfo: (scope, message, options) => this.logInfo(scope, message, options),
@@ -2722,6 +2751,18 @@ class BncrBridgeRuntime {
2722
2751
  return this.bridgeMediaFacade.enqueueFromReply(params);
2723
2752
  }
2724
2753
 
2754
+ listRecentOutbound(sessionKey: string) {
2755
+ return this.bridgeOutboxFacade.listRecentOutbound(sessionKey);
2756
+ }
2757
+
2758
+ listRecentOutboundByAccount(accountId: string) {
2759
+ return this.bridgeOutboxFacade.listRecentOutboundByAccount(accountId);
2760
+ }
2761
+
2762
+ private markRecentOutboundAcked(entry: OutboxEntry) {
2763
+ this.bridgeOutboxFacade.markRecentOutboundAcked(entry);
2764
+ }
2765
+
2725
2766
  private logEnqueueFromReply(args: {
2726
2767
  accountId: string;
2727
2768
  sessionKey: string;
@@ -2853,6 +2894,7 @@ class BncrBridgeRuntime {
2853
2894
  defaultPublicAgentId: () => this.defaultPublicAgentId(),
2854
2895
  sceneRegistry: this.sceneRegistry,
2855
2896
  groupHistories: this.groupHistories,
2897
+ outboundReplayCache: this.outboundReplayCache,
2856
2898
  prepareInboundAcceptance: (args) => this.prepareInboundAcceptance(args),
2857
2899
  logInboundSummary: (args) => this.logInboundSummary(args),
2858
2900
  flushPushQueueBestEffort: (args) => this.flushPushQueueBestEffort(args),
@@ -2922,6 +2964,12 @@ class BncrBridgeRuntime {
2922
2964
  handleDeadLetterPrune = async (ctx: GatewayRequestHandlerOptions) =>
2923
2965
  this.diagnosticsHandlers.handleDeadLetterPrune(ctx);
2924
2966
 
2967
+ handleRpcResponse = async (ctx: GatewayRequestHandlerOptions) =>
2968
+ this.clientRpcRuntime.handleResponse(ctx);
2969
+
2970
+ callClientRpc = async (method: string, args: Record<string, unknown>, accountId: string) =>
2971
+ this.clientRpcRuntime.call(method, args, normalizeAccountId(accountId));
2972
+
2925
2973
  handleFileInit = async (ctx: GatewayRequestHandlerOptions) =>
2926
2974
  this.fileInboundHandlers.handleFileInit(ctx);
2927
2975
 
@@ -3024,6 +3072,7 @@ export function createBncrChannelPlugin(getBridge: () => BncrBridgeRuntime) {
3024
3072
  getStatusBridge: bridgeGroup.getStatusBridge,
3025
3073
  getToolActionBridge: bridgeGroup.getToolActionBridge,
3026
3074
  getGatewayBridge: bridgeGroup.getGatewayBridge,
3075
+ getBridgeCallBridge: bridgeGroup.getBridgeCallBridge,
3027
3076
  channelMeta: BNCR_CHANNEL_META,
3028
3077
  channelCapabilities: BNCR_CHANNEL_CAPABILITIES,
3029
3078
  gatewayMethods: BNCR_GATEWAY_METHODS,
package/src/core/types.ts CHANGED
@@ -118,6 +118,47 @@ export type OutboxEntry = {
118
118
  awaitingRetryPush?: boolean;
119
119
  };
120
120
 
121
+ export type BncrRecentOutboundEntry = {
122
+ messageId: string;
123
+ accountId: string;
124
+ sessionKey: string;
125
+ route: BncrRoute;
126
+ type?: string;
127
+ kind?: string;
128
+ text: string;
129
+ mediaUrl?: string;
130
+ createdAt: number;
131
+ lastPushAt?: number;
132
+ lastError?: string;
133
+ ackedAt?: number;
134
+ status: 'queued' | 'pushed' | 'failed' | 'acked';
135
+ };
136
+
137
+ export type BncrOutboundReplayMediaEntry = {
138
+ path?: string;
139
+ contentType?: string;
140
+ kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
141
+ messageId?: string;
142
+ };
143
+
144
+ export type BncrOutboundReplayStatus = 'pushed' | 'acked';
145
+
146
+ export type BncrOutboundReplayEntry = {
147
+ sender: string;
148
+ senderId?: string;
149
+ body: string;
150
+ timestamp?: number;
151
+ messageId?: string;
152
+ media?: BncrOutboundReplayMediaEntry[];
153
+ accountId?: string;
154
+ sessionKey?: string;
155
+ route?: BncrRoute;
156
+ type?: string;
157
+ mediaUrl?: string;
158
+ createdAt?: number;
159
+ status?: BncrOutboundReplayStatus;
160
+ };
161
+
121
162
  export type BncrDiagnosticsSummary = {
122
163
  health: {
123
164
  connected: boolean;
@@ -27,6 +27,12 @@ import {
27
27
  recordBncrPendingGroupMedia,
28
28
  recordBncrPendingGroupText,
29
29
  } from './group-history.ts';
30
+ import {
31
+ type BncrOutboundReplayCache,
32
+ type BncrOutboundReplayEntry,
33
+ clearBncrOutboundReplay,
34
+ readBncrOutboundReplaySnapshot,
35
+ } from './outbound-replay-cache.ts';
30
36
  import { runBncrInboundReplyDispatch } from './reply-dispatch.ts';
31
37
  import { buildBncrInboundTurnContext } from './turn-context.ts';
32
38
 
@@ -48,6 +54,7 @@ export async function dispatchBncrInbound(params: {
48
54
  resolvedAgentId?: string;
49
55
  sceneRegistry: Map<string, BncrSceneRecord>;
50
56
  groupHistories: BncrGroupHistoryMap;
57
+ outboundReplayCache?: BncrOutboundReplayCache;
51
58
  defaultAdminAgentId: string;
52
59
  defaultPublicAgentId: string;
53
60
  now: () => number;
@@ -68,6 +75,7 @@ export async function dispatchBncrInbound(params: {
68
75
  resolvedAgentId,
69
76
  sceneRegistry = new Map(),
70
77
  groupHistories = new Map(),
78
+ outboundReplayCache = new Map(),
71
79
  defaultAdminAgentId,
72
80
  defaultPublicAgentId,
73
81
  now,
@@ -193,7 +201,7 @@ export async function dispatchBncrInbound(params: {
193
201
  }
194
202
  }
195
203
 
196
- if (shouldDispatch && !silentHistoryFlush && parsed.peer.kind === 'group') {
204
+ if (shouldDispatch && !silentHistoryFlush) {
197
205
  pendingHistoryEntries = readBncrPendingGroupHistorySnapshot({
198
206
  historyMap: groupHistories,
199
207
  parsed,
@@ -208,6 +216,21 @@ export async function dispatchBncrInbound(params: {
208
216
  }
209
217
  }
210
218
 
219
+ let outboundReplayEntries: BncrOutboundReplayEntry[] = [];
220
+ if (shouldDispatch && !silentHistoryFlush) {
221
+ outboundReplayEntries = readBncrOutboundReplaySnapshot({
222
+ cache: outboundReplayCache,
223
+ parsed,
224
+ accountId,
225
+ excludeMessageId: msgId,
226
+ });
227
+ clearBncrOutboundReplay({
228
+ cache: outboundReplayCache,
229
+ parsed,
230
+ accountId,
231
+ });
232
+ }
233
+
211
234
  const ctxPayload = await buildBncrInboundTurnContext({
212
235
  api,
213
236
  cfg,
@@ -227,6 +250,7 @@ export async function dispatchBncrInbound(params: {
227
250
  shouldDispatch,
228
251
  silentHistoryFlush,
229
252
  pendingHistoryEntries,
253
+ outboundReplayEntries,
230
254
  });
231
255
 
232
256
  await runBncrInboundReplyDispatch({
@@ -250,13 +274,7 @@ export async function dispatchBncrInbound(params: {
250
274
  enqueueFromReply,
251
275
  });
252
276
 
253
- if (shouldDispatch && pendingHistoryEntries.length === 0) {
254
- clearBncrPendingGroupHistory({
255
- historyMap: groupHistories,
256
- parsed,
257
- historyLimit: resolvedHistoryLimit,
258
- });
259
- }
277
+ scheduleSave();
260
278
 
261
279
  return {
262
280
  accountId,
@@ -0,0 +1,236 @@
1
+ import type {
2
+ BncrOutboundReplayEntry,
3
+ BncrOutboundReplayMediaEntry,
4
+ BncrOutboundReplayStatus,
5
+ OutboxEntry,
6
+ } from '../../core/types.ts';
7
+ import type { ParsedInbound } from './dispatch-prep.ts';
8
+
9
+ export type {
10
+ BncrOutboundReplayEntry,
11
+ BncrOutboundReplayMediaEntry,
12
+ BncrOutboundReplayStatus,
13
+ } from '../../core/types.ts';
14
+
15
+ export type BncrOutboundReplayCache = Map<string, BncrOutboundReplayEntry[]>;
16
+
17
+ function normalizeTextBody(value: string): string {
18
+ return String(value || '')
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+
23
+ function cloneMediaEntry(entry: BncrOutboundReplayMediaEntry): BncrOutboundReplayMediaEntry {
24
+ return {
25
+ ...(entry?.path ? { path: entry.path } : {}),
26
+ ...(entry?.contentType ? { contentType: entry.contentType } : {}),
27
+ ...(entry?.kind ? { kind: entry.kind } : {}),
28
+ ...(entry?.messageId ? { messageId: entry.messageId } : {}),
29
+ };
30
+ }
31
+
32
+ export function cloneBncrOutboundReplayEntry(
33
+ entry: BncrOutboundReplayEntry,
34
+ ): BncrOutboundReplayEntry {
35
+ return {
36
+ sender: entry.sender,
37
+ ...(entry.senderId ? { senderId: entry.senderId } : {}),
38
+ body: entry.body,
39
+ ...(typeof entry.timestamp === 'number' ? { timestamp: entry.timestamp } : {}),
40
+ ...(entry.messageId ? { messageId: entry.messageId } : {}),
41
+ ...(Array.isArray(entry.media)
42
+ ? { media: entry.media.map((item) => cloneMediaEntry(item)) }
43
+ : {}),
44
+ ...(entry.accountId ? { accountId: entry.accountId } : {}),
45
+ ...(entry.sessionKey ? { sessionKey: entry.sessionKey } : {}),
46
+ ...(entry.route ? { route: { ...entry.route } } : {}),
47
+ ...(entry.type ? { type: entry.type } : {}),
48
+ ...(entry.mediaUrl ? { mediaUrl: entry.mediaUrl } : {}),
49
+ ...(typeof entry.createdAt === 'number' ? { createdAt: entry.createdAt } : {}),
50
+ ...(entry.status ? { status: entry.status } : {}),
51
+ };
52
+ }
53
+
54
+ export function buildBncrOutboundReplayKeyFromRoute(args: {
55
+ accountId?: string;
56
+ platform?: string;
57
+ groupId?: string;
58
+ userId?: string;
59
+ }): string | null {
60
+ const accountId = String(args.accountId || '').trim() || 'default';
61
+ const platform = String(args.platform || '').trim();
62
+ const groupId = String(args.groupId || '0').trim() || '0';
63
+ const userId = String(args.userId || '0').trim() || '0';
64
+ if (!platform) return null;
65
+ if (groupId !== '0') return `${accountId}:${platform}:${groupId}`;
66
+ if (userId !== '0') return `${accountId}:${platform}:${userId}`;
67
+ return null;
68
+ }
69
+
70
+ export function buildBncrOutboundReplayKey(
71
+ parsed: ParsedInbound,
72
+ accountId: string,
73
+ ): string | null {
74
+ return buildBncrOutboundReplayKeyFromRoute({
75
+ accountId,
76
+ platform: parsed.platform,
77
+ groupId: parsed.groupId,
78
+ userId: parsed.userId,
79
+ });
80
+ }
81
+
82
+ function recordOutboundReplayEntry(args: {
83
+ cache: BncrOutboundReplayCache;
84
+ cacheKey: string;
85
+ entry: BncrOutboundReplayEntry;
86
+ }) {
87
+ const msgId = String(args.entry.messageId || '').trim();
88
+ const existing = args.cache.get(args.cacheKey) || [];
89
+ if (msgId && existing.some((entry) => entry.messageId === msgId)) return;
90
+
91
+ const next = [...existing, cloneBncrOutboundReplayEntry(args.entry)].sort(
92
+ (a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
93
+ );
94
+
95
+ if (args.cache.has(args.cacheKey)) {
96
+ args.cache.delete(args.cacheKey);
97
+ }
98
+ args.cache.set(args.cacheKey, next);
99
+ }
100
+
101
+ function inferMediaKind(args: {
102
+ msgType?: string;
103
+ contentType?: string;
104
+ mediaUrl?: string;
105
+ type?: string;
106
+ }): NonNullable<BncrOutboundReplayMediaEntry['kind']> {
107
+ const msgType = String(args.msgType || '')
108
+ .trim()
109
+ .toLowerCase();
110
+ const type = String(args.type || '')
111
+ .trim()
112
+ .toLowerCase();
113
+ const contentType = String(args.contentType || '')
114
+ .trim()
115
+ .toLowerCase();
116
+ const mediaUrl = String(args.mediaUrl || '')
117
+ .trim()
118
+ .toLowerCase();
119
+
120
+ if (msgType === 'image' || type === 'image' || contentType.startsWith('image/')) return 'image';
121
+ if (msgType === 'video' || type === 'video' || contentType.startsWith('video/')) return 'video';
122
+ if (
123
+ msgType === 'audio' ||
124
+ msgType === 'voice' ||
125
+ type === 'audio' ||
126
+ type === 'voice' ||
127
+ contentType.startsWith('audio/')
128
+ ) {
129
+ return 'audio';
130
+ }
131
+ if (type === 'file' || type === 'document' || msgType === 'file' || msgType === 'document') {
132
+ return 'document';
133
+ }
134
+ if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(mediaUrl)) return 'image';
135
+ if (/\.(mp4|mov|webm|mkv|avi)$/.test(mediaUrl)) return 'video';
136
+ if (/\.(mp3|ogg|oga|silk|amr|wav|m4a|aac|flac)$/.test(mediaUrl)) return 'audio';
137
+ if (contentType) return 'document';
138
+ return 'unknown';
139
+ }
140
+
141
+ function buildMediaBody(kind: NonNullable<BncrOutboundReplayMediaEntry['kind']>): string {
142
+ return `<media:${kind}>`;
143
+ }
144
+
145
+ function isAppMsg(type: string | undefined, msg: string): boolean {
146
+ return (
147
+ String(type || '')
148
+ .trim()
149
+ .toLowerCase() === 'appmsg' || /^<appmsg/i.test(msg)
150
+ );
151
+ }
152
+
153
+ export function recordBncrOutboundReplay(args: {
154
+ cache: BncrOutboundReplayCache;
155
+ entry: OutboxEntry;
156
+ sender: string;
157
+ senderId?: string;
158
+ status?: BncrOutboundReplayStatus;
159
+ }) {
160
+ const cacheKey = buildBncrOutboundReplayKeyFromRoute({
161
+ accountId: args.entry.accountId,
162
+ platform: args.entry.route?.platform,
163
+ groupId: args.entry.route?.groupId,
164
+ userId: args.entry.route?.userId,
165
+ });
166
+ if (!cacheKey) return;
167
+
168
+ const message =
169
+ args.entry.payload?.message && typeof args.entry.payload.message === 'object'
170
+ ? (args.entry.payload.message as Record<string, unknown>)
171
+ : {};
172
+ const type = typeof message.type === 'string' ? message.type : undefined;
173
+ const rawBody = typeof message.msg === 'string' ? message.msg : '';
174
+ const mediaUrl = typeof message.mediaUrl === 'string' ? message.mediaUrl.trim() : '';
175
+ const kind = inferMediaKind({ type, mediaUrl });
176
+ const isMedia =
177
+ message.transferMode === 'media' || Boolean(mediaUrl) || (!!type && type !== 'text');
178
+ const body = isAppMsg(type, rawBody)
179
+ ? buildMediaBody('document')
180
+ : normalizeTextBody(rawBody) || (isMedia ? buildMediaBody(kind) : '');
181
+ if (!body) return;
182
+
183
+ recordOutboundReplayEntry({
184
+ cache: args.cache,
185
+ cacheKey,
186
+ entry: {
187
+ sender: args.sender,
188
+ ...(args.senderId ? { senderId: args.senderId } : {}),
189
+ body,
190
+ timestamp: Date.now(),
191
+ messageId: args.entry.messageId,
192
+ accountId: args.entry.accountId,
193
+ sessionKey: args.entry.sessionKey,
194
+ route: { ...args.entry.route },
195
+ ...(type ? { type } : {}),
196
+ ...(mediaUrl ? { mediaUrl } : {}),
197
+ createdAt: args.entry.createdAt,
198
+ ...(args.status ? { status: args.status } : {}),
199
+ ...(mediaUrl
200
+ ? {
201
+ media: [
202
+ {
203
+ path: mediaUrl,
204
+ kind,
205
+ messageId: args.entry.messageId,
206
+ },
207
+ ],
208
+ }
209
+ : {}),
210
+ },
211
+ });
212
+ }
213
+
214
+ export function readBncrOutboundReplaySnapshot(args: {
215
+ cache: BncrOutboundReplayCache;
216
+ parsed: ParsedInbound;
217
+ accountId: string;
218
+ excludeMessageId?: string | null;
219
+ }): BncrOutboundReplayEntry[] {
220
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
221
+ if (!cacheKey) return [];
222
+ const excludedMessageId = String(args.excludeMessageId || '').trim();
223
+ return (args.cache.get(cacheKey) || [])
224
+ .filter((entry) => !excludedMessageId || entry.messageId !== excludedMessageId)
225
+ .map(cloneBncrOutboundReplayEntry);
226
+ }
227
+
228
+ export function clearBncrOutboundReplay(args: {
229
+ cache: BncrOutboundReplayCache;
230
+ parsed: ParsedInbound;
231
+ accountId: string;
232
+ }) {
233
+ const cacheKey = buildBncrOutboundReplayKey(args.parsed, args.accountId);
234
+ if (!cacheKey) return;
235
+ args.cache.delete(cacheKey);
236
+ }