@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.
@@ -20,6 +20,7 @@ import {
20
20
  buildBncrGroupContextFromEntries,
21
21
  collectBncrHistoryMediaFromEntries,
22
22
  } from './group-history.ts';
23
+ import type { BncrOutboundReplayEntry } from './outbound-replay-cache.ts';
23
24
  import { resolveBncrChannelInboundRuntime } from './runtime-compat.ts';
24
25
 
25
26
  function parseSlashCommandName(body: string): string | undefined {
@@ -78,6 +79,7 @@ export function buildBncrInboundTurnContext(args: {
78
79
  historyLimit?: number;
79
80
  silentHistoryFlush?: boolean;
80
81
  pendingHistoryEntries?: readonly BncrHistoryEntry[];
82
+ outboundReplayEntries?: readonly BncrOutboundReplayEntry[];
81
83
  }): BncrInboundContextPayload | Promise<BncrInboundContextPayload> {
82
84
  const {
83
85
  api,
@@ -98,7 +100,13 @@ export function buildBncrInboundTurnContext(args: {
98
100
  const pendingHistoryEntries = Array.isArray(args.pendingHistoryEntries)
99
101
  ? args.pendingHistoryEntries
100
102
  : [];
103
+ const outboundReplayEntries = Array.isArray(args.outboundReplayEntries)
104
+ ? args.outboundReplayEntries
105
+ : [];
101
106
  const silentHistoryFlush = args.silentHistoryFlush === true;
107
+ const contextEntries = silentHistoryFlush
108
+ ? pendingHistoryEntries
109
+ : [...pendingHistoryEntries, ...outboundReplayEntries];
102
110
  const flushSenderId = 'bncr-history-system';
103
111
  const flushSenderName = 'Bncr History System';
104
112
  const flushRawBody =
@@ -123,11 +131,11 @@ export function buildBncrInboundTurnContext(args: {
123
131
  const senderIsOwner = parsed.isAdmin === true || Boolean(ownerAllowFrom?.length);
124
132
  const senderIsAuthorized = senderIsOwner;
125
133
  const inboundHistory = undefined;
126
- const pendingHistoryMedia = shouldDispatch
127
- ? collectBncrHistoryMediaFromEntries({ entries: pendingHistoryEntries })
134
+ const contextMedia = shouldDispatch
135
+ ? collectBncrHistoryMediaFromEntries({ entries: contextEntries })
128
136
  : [];
129
- const pendingHistoryContext = shouldDispatch
130
- ? pendingHistoryEntries.map((entry) => ({
137
+ const structuredContextEntries = shouldDispatch
138
+ ? contextEntries.map((entry) => ({
131
139
  messageId: entry.messageId,
132
140
  sender: entry.sender,
133
141
  senderId: entry.senderId,
@@ -155,7 +163,7 @@ export function buildBncrInboundTurnContext(args: {
155
163
  bridgeSenderName,
156
164
  senderIsOwner,
157
165
  senderIsAuthorized,
158
- pendingHistoryContext,
166
+ pendingHistoryContext: structuredContextEntries,
159
167
  });
160
168
  const promptVisibleContextFacts = buildBncrPromptVisibleContextFacts(structuredContextFacts);
161
169
  const supplementalUntrustedContext = [
@@ -177,10 +185,10 @@ export function buildBncrInboundTurnContext(args: {
177
185
  const wasMentioned = parsed.isBotMentioned === true || platformWantsReply;
178
186
  const isAuthorizedTextCommand = Boolean(ownerAllowFrom?.length);
179
187
  const bodyForAgent =
180
- shouldDispatch && pendingHistoryEntries.length > 0
188
+ shouldDispatch && contextEntries.length > 0
181
189
  ? buildBncrGroupContextFromEntries({
182
190
  api,
183
- entries: pendingHistoryEntries,
191
+ entries: contextEntries,
184
192
  channelLabel: resolution.canonicalTo,
185
193
  currentTimestamp: Date.now(),
186
194
  previousTimestamp: readBncrSessionUpdatedAt(api, {
@@ -199,7 +207,7 @@ export function buildBncrInboundTurnContext(args: {
199
207
  kind: item.kind,
200
208
  messageId: msgId ?? undefined,
201
209
  }));
202
- const media = [...pendingHistoryMedia, ...currentTurnMedia];
210
+ const media = [...contextMedia, ...currentTurnMedia];
203
211
 
204
212
  return Promise.resolve(
205
213
  resolveBncrChannelInboundRuntime(api).buildContext({
@@ -105,6 +105,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
105
105
  setOutboxEntry: (messageId: string, entry: OutboxEntry) => void;
106
106
  resolveMessageAck: (messageId: string, result: 'acked' | 'timeout') => boolean;
107
107
  moveToDeadLetter: (entry: OutboxEntry, reason: string) => void;
108
+ markRecentOutboundAcked?: (entry: OutboxEntry) => void;
108
109
  recordAckTimeoutTelemetry: (accountId: string) => void;
109
110
  degradeOutboundCapability: (args: {
110
111
  accountId: string;
@@ -154,6 +155,7 @@ export function createBncrAckOutboxRuntimeGroup(runtime: {
154
155
  runtime.resolveMessageAck(messageId, result);
155
156
  },
156
157
  moveToDeadLetter: runtime.moveToDeadLetter,
158
+ markRecentOutboundAcked: runtime.markRecentOutboundAcked,
157
159
  logOutboxAckSummary: outboxAckLogs.logOutboxAckSummary,
158
160
  });
159
161
 
@@ -0,0 +1,168 @@
1
+ import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
+ import type { BncrRecentOutboundEntry } from '../core/types.ts';
3
+ import type { BncrStatusRuntimeSnapshot } from './channel-runtime-types.ts';
4
+
5
+ export const BNCR_LOCAL_BRIDGE_METHODS = [
6
+ 'bncr.methods',
7
+ 'bncr.status.channel',
8
+ 'bncr.status.account',
9
+ 'bncr.status.headline',
10
+ 'bncr.outbound.recent',
11
+ 'bncr.diagnostics',
12
+ 'bncr.deadLetter.inspect',
13
+ 'bncr.deadLetter.prune',
14
+ ] as const;
15
+
16
+ export type BncrBridgeCallDiagnosticsBridge = {
17
+ diagnostics: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
18
+ deadLetterInspect: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
19
+ deadLetterPrune: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
20
+ };
21
+
22
+ export type BncrBridgeCallBridge = {
23
+ call: (
24
+ method: string,
25
+ args: Record<string, unknown>,
26
+ accountId?: string,
27
+ ) => Promise<Record<string, unknown>>;
28
+ };
29
+
30
+ export type BncrBridgeCallRecentOutboundBridge = {
31
+ listRecentOutbound: (sessionKey: string) => BncrRecentOutboundEntry[];
32
+ listRecentOutboundByAccount: (accountId: string) => BncrRecentOutboundEntry[];
33
+ };
34
+
35
+ /**
36
+ * Runs a gateway-style handler with a captured respond callback so local
37
+ * diagnostics/dead-letter handlers can be exposed through the message tool.
38
+ */
39
+ export async function invokeBncrBridgeGatewayHandler(
40
+ handler: (ctx: GatewayRequestHandlerOptions) => Promise<void>,
41
+ args: Record<string, unknown>,
42
+ ): Promise<Record<string, unknown>> {
43
+ let ok = false;
44
+ let payload: unknown;
45
+ const ctx = {
46
+ params: args,
47
+ respond: (accepted: boolean, value: unknown) => {
48
+ ok = accepted;
49
+ payload = value;
50
+ },
51
+ } as unknown as GatewayRequestHandlerOptions;
52
+
53
+ await handler(ctx);
54
+
55
+ if (!ok) {
56
+ const message =
57
+ payload &&
58
+ typeof payload === 'object' &&
59
+ typeof (payload as { message?: unknown }).message === 'string'
60
+ ? (payload as { message: string }).message
61
+ : 'bridge handler rejected request';
62
+ throw new Error(message);
63
+ }
64
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
65
+ throw new Error('bridge handler returned an invalid payload');
66
+ }
67
+ return payload as Record<string, unknown>;
68
+ }
69
+
70
+ /**
71
+ * Local methods run inside the plugin process; any other method is forwarded
72
+ * dynamically to the OpenClawClient RPC bridge.
73
+ */
74
+ export function createBncrBridgeCallBridge(runtime: {
75
+ defaultAccountId: string;
76
+ getStatusBridge: () => {
77
+ getChannelSummary: (
78
+ defaultAccountId: string,
79
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
80
+ getAccountRuntimeSnapshot: (accountId?: string) => BncrStatusRuntimeSnapshot;
81
+ getStatusHeadline: (accountId?: string) => string;
82
+ };
83
+ getDiagnosticsBridge: () => BncrBridgeCallDiagnosticsBridge;
84
+ getRecentOutboundBridge: () => BncrBridgeCallRecentOutboundBridge;
85
+ callClientRpc: (
86
+ method: string,
87
+ args: Record<string, unknown>,
88
+ accountId: string,
89
+ ) => Promise<Record<string, unknown>>;
90
+ }): BncrBridgeCallBridge {
91
+ const resolveAccountId = (args: Record<string, unknown>, accountId?: string) => {
92
+ const argsAccountId = typeof args.accountId === 'string' ? args.accountId.trim() : '';
93
+ return accountId || argsAccountId || runtime.defaultAccountId;
94
+ };
95
+
96
+ const localHandlers: Record<
97
+ string,
98
+ (args: Record<string, unknown>, accountId: string) => Promise<Record<string, unknown>>
99
+ > = {
100
+ 'bncr.methods': async (_args, accountId) => {
101
+ const localMethods = [...BNCR_LOCAL_BRIDGE_METHODS];
102
+ let clientMethods: string[] = [];
103
+ let clientError: string | null = null;
104
+ try {
105
+ const clientResult = await runtime.callClientRpc('bncr.methods', { accountId }, accountId);
106
+ const raw = (clientResult as { result?: { methods?: unknown } })?.result?.methods;
107
+ clientMethods = Array.isArray(raw)
108
+ ? raw.filter((item): item is string => typeof item === 'string')
109
+ : [];
110
+ } catch (error) {
111
+ clientError = error instanceof Error ? error.message : String(error);
112
+ }
113
+ return {
114
+ methods: [...new Set([...localMethods, ...clientMethods])],
115
+ ...(clientError ? { clientError } : {}),
116
+ };
117
+ },
118
+ 'bncr.status.channel': async (_args, accountId) => ({
119
+ value: await runtime.getStatusBridge().getChannelSummary(accountId),
120
+ }),
121
+ 'bncr.status.account': async (_args, accountId) => ({
122
+ value: runtime.getStatusBridge().getAccountRuntimeSnapshot(accountId),
123
+ }),
124
+ 'bncr.status.headline': async (_args, accountId) => ({
125
+ value: runtime.getStatusBridge().getStatusHeadline(accountId),
126
+ }),
127
+ 'bncr.outbound.recent': async (args, accountId) => {
128
+ const sessionKey = typeof args.sessionKey === 'string' ? args.sessionKey.trim() : '';
129
+ const rawLimit = typeof args.limit === 'number' ? args.limit : Number(args.limit);
130
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : undefined;
131
+ const recentBridge = runtime.getRecentOutboundBridge();
132
+ const entries = sessionKey
133
+ ? recentBridge.listRecentOutbound(sessionKey)
134
+ : recentBridge.listRecentOutboundByAccount(accountId);
135
+ return {
136
+ accountId,
137
+ sessionKey: sessionKey || undefined,
138
+ limit,
139
+ total: entries.length,
140
+ entries: typeof limit === 'number' ? entries.slice(0, limit) : entries,
141
+ now: Date.now(),
142
+ };
143
+ },
144
+ 'bncr.diagnostics': (args) => runtime.getDiagnosticsBridge().diagnostics(args),
145
+ 'bncr.deadLetter.inspect': (args) => runtime.getDiagnosticsBridge().deadLetterInspect(args),
146
+ 'bncr.deadLetter.prune': (args) => runtime.getDiagnosticsBridge().deadLetterPrune(args),
147
+ };
148
+
149
+ return {
150
+ call: async (method, args, accountId) => {
151
+ const normalizedMethod = String(method || '').trim();
152
+ if (!normalizedMethod) {
153
+ throw new Error('bridge method is required');
154
+ }
155
+ const normalizedArgs = args ?? {};
156
+ const resolvedAccountId = resolveAccountId(normalizedArgs, accountId);
157
+ const localHandler = localHandlers[normalizedMethod];
158
+ if (localHandler) {
159
+ return {
160
+ ok: true,
161
+ method: normalizedMethod,
162
+ result: await localHandler(normalizedArgs, resolvedAccountId),
163
+ };
164
+ }
165
+ return runtime.callClientRpc(normalizedMethod, normalizedArgs, resolvedAccountId);
166
+ },
167
+ };
168
+ }
@@ -6,7 +6,12 @@ import {
6
6
  collectDueOutboxEntries,
7
7
  } from '../core/outbox-queue.ts';
8
8
  import { formatDisplayScope } from '../core/targets.ts';
9
- import type { OutboxEntry } from '../core/types.ts';
9
+ import type { BncrRecentOutboundEntry, OutboxEntry } from '../core/types.ts';
10
+ import {
11
+ type BncrOutboundReplayCache,
12
+ type BncrOutboundReplayEntry,
13
+ recordBncrOutboundReplay,
14
+ } from '../messaging/inbound/outbound-replay-cache.ts';
10
15
  import {
11
16
  buildBncrOutboxFailureEntryPatch,
12
17
  buildBncrOutboxPushSuccessEntryPatch,
@@ -36,6 +41,9 @@ export function createBncrBridgeOutboxFacade(runtime: {
36
41
  lastPrePushGuardSkipReasonByAccount: Map<string, string>;
37
42
  deadLetterSinceStartByAccount: Map<string, number>;
38
43
  lastOutboundByAccount: Map<string, number>;
44
+ outboundReplayCache?: BncrOutboundReplayCache;
45
+ resolveOutboundSender?: (entry: OutboxEntry) => { sender: string; senderId?: string };
46
+ isOutboundAckRequired?: (accountId: string) => boolean;
39
47
  scheduleSave: () => void;
40
48
  flushPushQueueBestEffort: (args?: {
41
49
  accountId?: string;
@@ -48,6 +56,25 @@ export function createBncrBridgeOutboxFacade(runtime: {
48
56
  resolveMessageAck: (messageId: string, result?: 'acked' | 'timeout') => boolean;
49
57
  markActivity: (accountId: string, at?: number) => void;
50
58
  }) {
59
+ const recordOutboundMessage = (entry: OutboxEntry, status: 'pushed' | 'acked') => {
60
+ if (!runtime.outboundReplayCache) return;
61
+ const senderInfo = runtime.resolveOutboundSender?.(entry) ?? {
62
+ sender: entry.accountId,
63
+ senderId: entry.accountId,
64
+ };
65
+ recordBncrOutboundReplay({
66
+ cache: runtime.outboundReplayCache,
67
+ entry,
68
+ sender: senderInfo.sender,
69
+ senderId: senderInfo.senderId,
70
+ status,
71
+ });
72
+ };
73
+
74
+ const markRecentOutboundAcked = (entry: OutboxEntry) => {
75
+ recordOutboundMessage(entry, 'acked');
76
+ };
77
+
51
78
  const recordOutboxPrePushFailure = (args: {
52
79
  entry: OutboxEntry;
53
80
  lastError: string;
@@ -120,6 +147,9 @@ export function createBncrBridgeOutboxFacade(runtime: {
120
147
  });
121
148
  Object.assign(args.entry, nextEntry);
122
149
  runtime.outbox.set(nextEntry.messageId, args.entry);
150
+ if (runtime.isOutboundAckRequired?.(args.entry.accountId) === false) {
151
+ recordOutboundMessage(args.entry, 'pushed');
152
+ }
123
153
  runtime.lastOutboundByAccount.set(nextEntry.accountId, pushedAt);
124
154
  runtime.markActivity(nextEntry.accountId, pushedAt);
125
155
  runtime.scheduleSave();
@@ -181,6 +211,41 @@ export function createBncrBridgeOutboxFacade(runtime: {
181
211
  return result.duePayloads;
182
212
  };
183
213
 
214
+ const buildRecentOutboundEntry = (
215
+ cacheKey: string,
216
+ replay: BncrOutboundReplayEntry,
217
+ ): BncrRecentOutboundEntry => {
218
+ const keyParts = cacheKey.split(':');
219
+ const accountId = replay.accountId || keyParts[0] || '';
220
+ const route = replay.route
221
+ ? replay.route
222
+ : keyParts[1] && keyParts[2]
223
+ ? keyParts[2].startsWith('-')
224
+ ? { platform: keyParts[1], groupId: keyParts[2], userId: '0' }
225
+ : { platform: keyParts[1], groupId: '0', userId: keyParts[2] }
226
+ : { platform: '', groupId: '0', userId: '0' };
227
+ return {
228
+ messageId: replay.messageId || '',
229
+ accountId,
230
+ sessionKey: replay.sessionKey || '',
231
+ route,
232
+ ...(replay.type ? { type: replay.type } : {}),
233
+ ...(replay.type ? { kind: replay.type } : {}),
234
+ text: replay.body,
235
+ ...(replay.mediaUrl ? { mediaUrl: replay.mediaUrl } : {}),
236
+ createdAt: replay.createdAt ?? replay.timestamp ?? runtime.now(),
237
+ ...(replay.timestamp ? { lastPushAt: replay.timestamp } : {}),
238
+ status: replay.status === 'acked' ? 'acked' : 'pushed',
239
+ };
240
+ };
241
+
242
+ const listReplayEntries = () => {
243
+ if (!runtime.outboundReplayCache) return [];
244
+ return Array.from(runtime.outboundReplayCache.entries()).flatMap(([cacheKey, entries]) =>
245
+ entries.map((entry) => buildRecentOutboundEntry(cacheKey, entry)),
246
+ );
247
+ };
248
+
184
249
  return {
185
250
  recordOutboxPrePushFailure,
186
251
  recordPrePushGuardSkip,
@@ -190,5 +255,18 @@ export function createBncrBridgeOutboxFacade(runtime: {
190
255
  enqueueOutbound,
191
256
  moveToDeadLetter,
192
257
  collectDue,
258
+ listRecentOutbound: (sessionKey: string) => {
259
+ const normalized = runtime.asString(sessionKey || '').trim();
260
+ return listReplayEntries()
261
+ .filter((entry) => entry.sessionKey === normalized)
262
+ .sort((a, b) => b.createdAt - a.createdAt);
263
+ },
264
+ listRecentOutboundByAccount: (accountId: string) => {
265
+ const normalized = runtime.normalizeAccountId(accountId);
266
+ return listReplayEntries()
267
+ .filter((entry) => entry.accountId === normalized)
268
+ .sort((a, b) => b.createdAt - a.createdAt);
269
+ },
270
+ markRecentOutboundAcked,
193
271
  };
194
272
  }
@@ -6,6 +6,7 @@ import type { BncrRoute } from '../core/types.ts';
6
6
  import type { BncrInboundParamsInput } from '../messaging/inbound/contracts.ts';
7
7
  import { dispatchBncrInbound } from '../messaging/inbound/dispatch.ts';
8
8
  import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
9
+ import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
9
10
  import { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
10
11
  import { OUTBOUND_FLUSH_REASON, OUTBOUND_FLUSH_TRIGGER } from '../messaging/outbound/reasons.ts';
11
12
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
@@ -211,6 +212,7 @@ export function createBncrInboundHandlersComponent(runtime: {
211
212
  defaultPublicAgentId: Parameters<typeof createBncrInboundHandlers>[0]['defaultPublicAgentId'];
212
213
  sceneRegistry: Parameters<typeof createBncrInboundHandlers>[0]['sceneRegistry'];
213
214
  groupHistories: BncrGroupHistoryMap;
215
+ outboundReplayCache: BncrOutboundReplayCache;
214
216
  prepareInboundAcceptance: Parameters<
215
217
  typeof createBncrInboundHandlers
216
218
  >[0]['prepareInboundAcceptance'];
@@ -256,6 +258,7 @@ export function createBncrInboundHandlersComponent(runtime: {
256
258
  defaultPublicAgentId: runtime.defaultPublicAgentId,
257
259
  sceneRegistry: runtime.sceneRegistry,
258
260
  groupHistories: runtime.groupHistories,
261
+ outboundReplayCache: runtime.outboundReplayCache,
259
262
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
260
263
  formatDisplayScope,
261
264
  logInboundSummary: runtime.logInboundSummary,
@@ -275,6 +278,7 @@ export function createBncrInboundHandlersComponent(runtime: {
275
278
  shouldAccumulate,
276
279
  sceneRegistry,
277
280
  groupHistories,
281
+ outboundReplayCache,
278
282
  defaultAdminAgentId,
279
283
  defaultPublicAgentId,
280
284
  now,
@@ -290,6 +294,7 @@ export function createBncrInboundHandlersComponent(runtime: {
290
294
  shouldAccumulate,
291
295
  sceneRegistry,
292
296
  groupHistories,
297
+ outboundReplayCache,
293
298
  defaultAdminAgentId,
294
299
  defaultPublicAgentId,
295
300
  now,
@@ -1,7 +1,13 @@
1
1
  import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
2
- import type { BncrRoute } from '../core/types.ts';
2
+ import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
3
+ import type { BncrRecentOutboundEntry, BncrRoute } from '../core/types.ts';
3
4
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
4
5
  import type { OutboundReplyTargetPolicy } from '../messaging/outbound/reply-target-policy.ts';
6
+ import {
7
+ type BncrBridgeCallDiagnosticsBridge,
8
+ createBncrBridgeCallBridge,
9
+ invokeBncrBridgeGatewayHandler,
10
+ } from './bridge-call.ts';
5
11
  import type {
6
12
  BncrChannelConfigRoot,
7
13
  BncrChannelSendContext,
@@ -34,6 +40,8 @@ export type BncrChannelPluginBridge = {
34
40
  getStatusHeadline: (accountId: string) => string;
35
41
  resolveVerifiedTarget: (to: string, accountId: string) => BncrVerifiedTarget;
36
42
  rememberSessionRoute: (sessionKey: string, accountId: string, route: BncrRoute) => void;
43
+ listRecentOutbound: (sessionKey: string) => BncrRecentOutboundEntry[];
44
+ listRecentOutboundByAccount: (accountId: string) => BncrRecentOutboundEntry[];
37
45
  enqueueFromReply: (args: {
38
46
  accountId: string;
39
47
  sessionKey: string;
@@ -44,6 +52,14 @@ export type BncrChannelPluginBridge = {
44
52
  }) => Promise<void>;
45
53
  channelStartAccount: (ctx: unknown) => void | Promise<void>;
46
54
  channelStopAccount: (ctx: unknown) => void | Promise<void>;
55
+ handleDiagnostics: (ctx: GatewayRequestHandlerOptions) => Promise<void>;
56
+ handleDeadLetterInspect: (ctx: GatewayRequestHandlerOptions) => Promise<void>;
57
+ handleDeadLetterPrune: (ctx: GatewayRequestHandlerOptions) => Promise<void>;
58
+ callClientRpc: (
59
+ method: string,
60
+ args: Record<string, unknown>,
61
+ accountId: string,
62
+ ) => Promise<Record<string, unknown>>;
47
63
  };
48
64
 
49
65
  export function createBncrChannelPluginBridgeGroup(runtime: {
@@ -118,6 +134,31 @@ export function createBncrChannelPluginBridgeGroup(runtime: {
118
134
  };
119
135
  };
120
136
 
137
+ const getDiagnosticsBridge = (): BncrBridgeCallDiagnosticsBridge => {
138
+ const bridge = runtime.getBridge();
139
+ return {
140
+ diagnostics: async (args) => invokeBncrBridgeGatewayHandler(bridge.handleDiagnostics, args),
141
+ deadLetterInspect: async (args) =>
142
+ invokeBncrBridgeGatewayHandler(bridge.handleDeadLetterInspect, args),
143
+ deadLetterPrune: async (args) =>
144
+ invokeBncrBridgeGatewayHandler(bridge.handleDeadLetterPrune, args),
145
+ };
146
+ };
147
+
148
+ const getBridgeCallBridge = () => {
149
+ const bridge = runtime.getBridge();
150
+ return createBncrBridgeCallBridge({
151
+ defaultAccountId: runtime.defaultAccountId,
152
+ getStatusBridge,
153
+ getDiagnosticsBridge,
154
+ getRecentOutboundBridge: () => ({
155
+ listRecentOutbound: (sessionKey) => bridge.listRecentOutbound(sessionKey),
156
+ listRecentOutboundByAccount: (accountId) => bridge.listRecentOutboundByAccount(accountId),
157
+ }),
158
+ callClientRpc: (method, args, accountId) => bridge.callClientRpc(method, args, accountId),
159
+ });
160
+ };
161
+
121
162
  return {
122
163
  getMessageSendBridge,
123
164
  getOutboundBridge,
@@ -125,5 +166,6 @@ export function createBncrChannelPluginBridgeGroup(runtime: {
125
166
  getStatusBridge,
126
167
  getToolActionBridge,
127
168
  getGatewayBridge,
169
+ getBridgeCallBridge,
128
170
  };
129
171
  }
@@ -1,4 +1,5 @@
1
1
  import type { ChannelMessageSendResult } from 'openclaw/plugin-sdk/channel-message';
2
+ import { Type } from 'typebox';
2
3
  import { listAccountIds, normalizeAccountId, resolveAccount } from '../core/accounts.ts';
3
4
  import { BncrConfigSchema } from '../core/config-schema.ts';
4
5
  import { resolveBncrChannelPolicy } from '../core/policy.ts';
@@ -6,9 +7,11 @@ import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
6
7
  import type { OutboundReplyTargetPolicy } from '../messaging/outbound/reply-target-policy.ts';
7
8
  import type { OpenClawChannelToolSend, openClawJsonResult } from '../openclaw/sdk-helpers.ts';
8
9
  import { readOpenClawStringParam } from '../openclaw/sdk-helpers.ts';
10
+ import type { BncrBridgeCallBridge } from './bridge-call.ts';
9
11
  import type {
10
12
  BncrChannelConfigRoot,
11
13
  BncrChannelSendContext,
14
+ BncrMessageToolSchemaContribution,
12
15
  BncrSceneRecord,
13
16
  BncrStatusRuntimeSnapshot,
14
17
  BncrVerifiedTarget,
@@ -35,6 +38,33 @@ type BncrChannelMeta = {
35
38
 
36
39
  type PluginSurfaceResult = ReturnType<typeof openClawJsonResult>;
37
40
 
41
+ const BNCR_MESSAGE_TOOL_SCHEMA: BncrMessageToolSchemaContribution = {
42
+ visibility: 'current-channel',
43
+ properties: {
44
+ bridgeMethod: Type.Optional(
45
+ Type.String({
46
+ description:
47
+ 'Bncr-only bridge method forwarded to the OpenClawClient RPC bridge; use bncr.methods to list calls, or bncr.client.adapters.call to invoke a Bncr adapter method generically.',
48
+ }),
49
+ ),
50
+ extra: Type.Optional(
51
+ Type.Record(Type.String(), Type.Unknown(), {
52
+ description: 'Bncr-specific structured params merged into the unified outbound normalizer.',
53
+ }),
54
+ ),
55
+ type: Type.Optional(
56
+ Type.String({
57
+ description: 'Explicit outbound type such as text, audio, video, voice, file, or appmsg.',
58
+ }),
59
+ ),
60
+ downloadMedia: Type.Optional(
61
+ Type.Boolean({
62
+ description: 'Force URL media to be downloaded before sending.',
63
+ }),
64
+ ),
65
+ },
66
+ };
67
+
38
68
  export function createBncrChannelPluginSurfaceGroup(runtime: {
39
69
  channelId: string;
40
70
  sceneRegistry: Map<string, BncrSceneRecord>;
@@ -80,6 +110,7 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
80
110
  channelStartAccount: BncrGatewayAccountBridge['channelStartAccount'];
81
111
  channelStopAccount: BncrGatewayAccountBridge['channelStopAccount'];
82
112
  };
113
+ getBridgeCallBridge: () => BncrBridgeCallBridge;
83
114
  channelMeta: BncrChannelMeta;
84
115
  channelCapabilities: {
85
116
  chatTypes: Array<'direct' | 'group' | 'thread'>;
@@ -121,11 +152,12 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
121
152
  }
122
153
 
123
154
  return {
124
- actions: ['send'] as const,
155
+ actions: ['send', 'delete', 'unsend'] as const,
125
156
  capabilities: [] as const,
157
+ schema: BNCR_MESSAGE_TOOL_SCHEMA,
126
158
  };
127
159
  },
128
- supportsAction: ({ action }) => action === 'send',
160
+ supportsAction: ({ action }) => action === 'send' || action === 'delete' || action === 'unsend',
129
161
  extractToolSend: ({ args }) =>
130
162
  runtime.extractToolSend(
131
163
  (args && typeof args === 'object' ? (args as Record<string, unknown>) : {}) as Record<
@@ -135,19 +167,62 @@ export function createBncrChannelPluginSurfaceGroup(runtime: {
135
167
  'sendMessage',
136
168
  ) || null,
137
169
  handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
138
- if (action !== 'send') {
139
- throw new Error(`Action ${action} is not supported for provider ${runtime.channelId}.`);
140
- }
141
-
142
170
  /* -- extract raw params (no normalization) ----------------------- */
143
171
  const paramsObj = (params && typeof params === 'object' ? params : {}) as Record<
144
172
  string,
145
173
  unknown
146
174
  >;
147
- const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
148
175
  const resolvedAccountId = normalizeAccountId(
149
176
  (readOpenClawStringParam(paramsObj, 'accountId') ?? accountId) || '',
150
177
  );
178
+ if (action === 'delete' || action === 'unsend') {
179
+ const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
180
+ const messageId =
181
+ (typeof paramsObj.messageId === 'number'
182
+ ? String(paramsObj.messageId)
183
+ : readOpenClawStringParam(paramsObj, 'messageId')) ||
184
+ readOpenClawStringParam(paramsObj, 'message_id');
185
+ if (!messageId) {
186
+ throw new Error(`${action} requires messageId`);
187
+ }
188
+
189
+ const verified = runtime.getToolActionBridge().resolveVerifiedTarget(to, resolvedAccountId);
190
+ const bridgeResult = await runtime.getBridgeCallBridge().call(
191
+ 'bncr.client.adapters.call',
192
+ {
193
+ msgInfo: {
194
+ groupId: verified.route.groupId || '0',
195
+ userId: verified.route.userId || '0',
196
+ },
197
+ from: verified.route.platform,
198
+ method: 'delMsg',
199
+ messageId,
200
+ },
201
+ resolvedAccountId,
202
+ );
203
+ return runtime.openClawJsonResult({ ok: true, ...bridgeResult });
204
+ }
205
+
206
+ if (action !== 'send') {
207
+ throw new Error(`Action ${action} is not supported for provider ${runtime.channelId}.`);
208
+ }
209
+
210
+ const bridgeMethod =
211
+ typeof paramsObj.bridgeMethod === 'string' ? paramsObj.bridgeMethod.trim() : '';
212
+ if (bridgeMethod) {
213
+ const rawExtra = paramsObj.extra;
214
+ const bridgeArgs =
215
+ rawExtra && typeof rawExtra === 'object' && !Array.isArray(rawExtra)
216
+ ? { ...(rawExtra as Record<string, unknown>) }
217
+ : {};
218
+ if (!('accountId' in bridgeArgs)) {
219
+ bridgeArgs.accountId = resolvedAccountId || undefined;
220
+ }
221
+ const bridgeResult = await runtime.getBridgeCallBridge().call(bridgeMethod, bridgeArgs);
222
+ return runtime.openClawJsonResult(bridgeResult);
223
+ }
224
+
225
+ const to = readOpenClawStringParam(paramsObj, 'to', { required: true });
151
226
  const toolMediaUrl =
152
227
  (typeof paramsObj.mediaUrl === 'string' ? paramsObj.mediaUrl : '') ||
153
228
  (typeof paramsObj.media === 'string' ? paramsObj.media : '') ||
@@ -1,6 +1,7 @@
1
1
  import type { GatewayRequestHandlerOptions } from 'openclaw/plugin-sdk/core';
2
2
  import type { BncrRoute, OutboxEntry } from '../core/types.ts';
3
3
  import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
4
+ import type { BncrOutboundReplayCache } from '../messaging/inbound/outbound-replay-cache.ts';
4
5
  import type { parseBncrInboundParams } from '../messaging/inbound/parse.ts';
5
6
  import type { ReplyPayloadInput } from '../messaging/outbound/reply-enqueue.ts';
6
7
  import type { OutboundReplyTargetPolicy } from '../messaging/outbound/reply-target-policy.ts';
@@ -107,6 +108,7 @@ export function buildBncrInboundSurfaceRuntime(deps: {
107
108
  defaultPublicAgentId: () => string;
108
109
  sceneRegistry: Map<string, BncrSceneRecord>;
109
110
  groupHistories: BncrGroupHistoryMap;
111
+ outboundReplayCache: BncrOutboundReplayCache;
110
112
  prepareInboundAcceptance: (args: {
111
113
  parsed: ReturnType<typeof parseBncrInboundParams>;
112
114
  canonicalAgentId: string;