@xmoxmo/bncr 0.6.0 → 0.6.2

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 (35) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +2 -0
  3. package/index.ts +1 -0
  4. package/package.json +4 -1
  5. package/scripts/check-pack.mjs +41 -1
  6. package/scripts/selfcheck.mjs +4 -1
  7. package/src/bootstrap/register-runtime-gateway.ts +1 -0
  8. package/src/bootstrap/register-runtime.ts +1 -0
  9. package/src/channel.ts +66 -5
  10. package/src/core/types.ts +41 -0
  11. package/src/messaging/inbound/context-facts.ts +88 -58
  12. package/src/messaging/inbound/{group-history.ts → conversation-history.ts} +92 -25
  13. package/src/messaging/inbound/dispatch.ts +90 -47
  14. package/src/messaging/inbound/native-command.ts +1 -1
  15. package/src/messaging/inbound/outbound-replay-cache.ts +312 -0
  16. package/src/messaging/inbound/scene-admin.ts +24 -9
  17. package/src/messaging/inbound/turn-context.ts +105 -33
  18. package/src/plugin/ack-outbox-runtime-group.ts +2 -0
  19. package/src/plugin/bridge-call.ts +168 -0
  20. package/src/plugin/bridge-outbox-facade.ts +88 -1
  21. package/src/plugin/channel-components.ts +10 -5
  22. package/src/plugin/channel-plugin-bridge-group.ts +43 -1
  23. package/src/plugin/channel-plugin-surface-group.ts +82 -7
  24. package/src/plugin/channel-runtime-builders-delivery.ts +4 -2
  25. package/src/plugin/channel-runtime-types.ts +34 -8
  26. package/src/plugin/channel-send.ts +12 -19
  27. package/src/plugin/client-rpc-runtime.ts +129 -0
  28. package/src/plugin/gateway-methods.ts +1 -0
  29. package/src/plugin/inbound-acceptance.ts +9 -24
  30. package/src/plugin/inbound-handlers.ts +8 -4
  31. package/src/plugin/inbound-surface-handlers-group.ts +7 -2
  32. package/src/plugin/outbox-ack-outcome.ts +2 -0
  33. package/src/plugin/scene-registry.ts +4 -4
  34. package/src/plugin/state-store.ts +153 -29
  35. package/src/plugin/state-transient-runtime-group.ts +10 -3
@@ -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,13 @@ 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 type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
11
+ import {
12
+ type BncrOutboundReplayCache,
13
+ type BncrOutboundReplayEntry,
14
+ recordBncrOutboundReplay,
15
+ } from '../messaging/inbound/outbound-replay-cache.ts';
10
16
  import {
11
17
  buildBncrOutboxFailureEntryPatch,
12
18
  buildBncrOutboxPushSuccessEntryPatch,
@@ -36,6 +42,11 @@ export function createBncrBridgeOutboxFacade(runtime: {
36
42
  lastPrePushGuardSkipReasonByAccount: Map<string, string>;
37
43
  deadLetterSinceStartByAccount: Map<string, number>;
38
44
  lastOutboundByAccount: Map<string, number>;
45
+ outboundReplayCache?: BncrOutboundReplayCache;
46
+ conversationHistories?: BncrConversationHistoryMap;
47
+ resolveOutboundHistoryLimit?: (entry: OutboxEntry) => number;
48
+ resolveOutboundSender?: (entry: OutboxEntry) => { sender: string; senderId?: string };
49
+ isOutboundAckRequired?: (accountId: string) => boolean;
39
50
  scheduleSave: () => void;
40
51
  flushPushQueueBestEffort: (args?: {
41
52
  accountId?: string;
@@ -48,6 +59,31 @@ export function createBncrBridgeOutboxFacade(runtime: {
48
59
  resolveMessageAck: (messageId: string, result?: 'acked' | 'timeout') => boolean;
49
60
  markActivity: (accountId: string, at?: number) => void;
50
61
  }) {
62
+ const recordOutboundMessage = (entry: OutboxEntry, status: 'pushed' | 'acked') => {
63
+ if (!runtime.outboundReplayCache) return;
64
+ const senderInfo = runtime.resolveOutboundSender?.(entry) ?? {
65
+ sender: entry.accountId,
66
+ senderId: entry.accountId,
67
+ };
68
+ recordBncrOutboundReplay({
69
+ cache: runtime.outboundReplayCache,
70
+ ...(runtime.conversationHistories
71
+ ? { conversationHistories: runtime.conversationHistories }
72
+ : {}),
73
+ ...(runtime.resolveOutboundHistoryLimit
74
+ ? { historyLimit: runtime.resolveOutboundHistoryLimit(entry) }
75
+ : {}),
76
+ entry,
77
+ sender: senderInfo.sender,
78
+ senderId: senderInfo.senderId,
79
+ status,
80
+ });
81
+ };
82
+
83
+ const markRecentOutboundAcked = (entry: OutboxEntry) => {
84
+ recordOutboundMessage(entry, 'acked');
85
+ };
86
+
51
87
  const recordOutboxPrePushFailure = (args: {
52
88
  entry: OutboxEntry;
53
89
  lastError: string;
@@ -120,6 +156,9 @@ export function createBncrBridgeOutboxFacade(runtime: {
120
156
  });
121
157
  Object.assign(args.entry, nextEntry);
122
158
  runtime.outbox.set(nextEntry.messageId, args.entry);
159
+ if (runtime.isOutboundAckRequired?.(args.entry.accountId) === false) {
160
+ recordOutboundMessage(args.entry, 'pushed');
161
+ }
123
162
  runtime.lastOutboundByAccount.set(nextEntry.accountId, pushedAt);
124
163
  runtime.markActivity(nextEntry.accountId, pushedAt);
125
164
  runtime.scheduleSave();
@@ -181,6 +220,41 @@ export function createBncrBridgeOutboxFacade(runtime: {
181
220
  return result.duePayloads;
182
221
  };
183
222
 
223
+ const buildRecentOutboundEntry = (
224
+ cacheKey: string,
225
+ replay: BncrOutboundReplayEntry,
226
+ ): BncrRecentOutboundEntry => {
227
+ const keyParts = cacheKey.split(':');
228
+ const accountId = replay.accountId || keyParts[0] || '';
229
+ const route = replay.route
230
+ ? replay.route
231
+ : keyParts[1] && keyParts[2]
232
+ ? keyParts[2].startsWith('-')
233
+ ? { platform: keyParts[1], groupId: keyParts[2], userId: '0' }
234
+ : { platform: keyParts[1], groupId: '0', userId: keyParts[2] }
235
+ : { platform: '', groupId: '0', userId: '0' };
236
+ return {
237
+ messageId: replay.messageId || '',
238
+ accountId,
239
+ sessionKey: replay.sessionKey || '',
240
+ route,
241
+ ...(replay.type ? { type: replay.type } : {}),
242
+ ...(replay.type ? { kind: replay.type } : {}),
243
+ text: replay.body,
244
+ ...(replay.mediaUrl ? { mediaUrl: replay.mediaUrl } : {}),
245
+ createdAt: replay.createdAt ?? replay.timestamp ?? runtime.now(),
246
+ ...(replay.timestamp ? { lastPushAt: replay.timestamp } : {}),
247
+ status: replay.status === 'acked' ? 'acked' : 'pushed',
248
+ };
249
+ };
250
+
251
+ const listReplayEntries = () => {
252
+ if (!runtime.outboundReplayCache) return [];
253
+ return Array.from(runtime.outboundReplayCache.entries()).flatMap(([cacheKey, entries]) =>
254
+ entries.map((entry) => buildRecentOutboundEntry(cacheKey, entry)),
255
+ );
256
+ };
257
+
184
258
  return {
185
259
  recordOutboxPrePushFailure,
186
260
  recordPrePushGuardSkip,
@@ -190,5 +264,18 @@ export function createBncrBridgeOutboxFacade(runtime: {
190
264
  enqueueOutbound,
191
265
  moveToDeadLetter,
192
266
  collectDue,
267
+ listRecentOutbound: (sessionKey: string) => {
268
+ const normalized = runtime.asString(sessionKey || '').trim();
269
+ return listReplayEntries()
270
+ .filter((entry) => entry.sessionKey === normalized)
271
+ .sort((a, b) => b.createdAt - a.createdAt);
272
+ },
273
+ listRecentOutboundByAccount: (accountId: string) => {
274
+ const normalized = runtime.normalizeAccountId(accountId);
275
+ return listReplayEntries()
276
+ .filter((entry) => entry.accountId === normalized)
277
+ .sort((a, b) => b.createdAt - a.createdAt);
278
+ },
279
+ markRecentOutboundAcked,
193
280
  };
194
281
  }
@@ -4,8 +4,9 @@ import { emitBncrLogLine } from '../core/logging.ts';
4
4
  import { formatDisplayScope, normalizeStoredSessionKey, parseRouteLike } from '../core/targets.ts';
5
5
  import type { BncrRoute } from '../core/types.ts';
6
6
  import type { BncrInboundParamsInput } from '../messaging/inbound/contracts.ts';
7
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-history.ts';
7
8
  import { dispatchBncrInbound } from '../messaging/inbound/dispatch.ts';
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';
@@ -210,7 +211,8 @@ export function createBncrInboundHandlersComponent(runtime: {
210
211
  defaultAdminAgentId: Parameters<typeof createBncrInboundHandlers>[0]['defaultAdminAgentId'];
211
212
  defaultPublicAgentId: Parameters<typeof createBncrInboundHandlers>[0]['defaultPublicAgentId'];
212
213
  sceneRegistry: Parameters<typeof createBncrInboundHandlers>[0]['sceneRegistry'];
213
- groupHistories: BncrGroupHistoryMap;
214
+ conversationHistories: BncrConversationHistoryMap;
215
+ outboundReplayCache: BncrOutboundReplayCache;
214
216
  prepareInboundAcceptance: Parameters<
215
217
  typeof createBncrInboundHandlers
216
218
  >[0]['prepareInboundAcceptance'];
@@ -255,7 +257,8 @@ export function createBncrInboundHandlersComponent(runtime: {
255
257
  defaultAdminAgentId: runtime.defaultAdminAgentId,
256
258
  defaultPublicAgentId: runtime.defaultPublicAgentId,
257
259
  sceneRegistry: runtime.sceneRegistry,
258
- groupHistories: runtime.groupHistories,
260
+ conversationHistories: runtime.conversationHistories,
261
+ outboundReplayCache: runtime.outboundReplayCache,
259
262
  prepareInboundAcceptance: runtime.prepareInboundAcceptance,
260
263
  formatDisplayScope,
261
264
  logInboundSummary: runtime.logInboundSummary,
@@ -274,7 +277,8 @@ export function createBncrInboundHandlersComponent(runtime: {
274
277
  shouldDispatch,
275
278
  shouldAccumulate,
276
279
  sceneRegistry,
277
- groupHistories,
280
+ conversationHistories,
281
+ outboundReplayCache,
278
282
  defaultAdminAgentId,
279
283
  defaultPublicAgentId,
280
284
  now,
@@ -289,7 +293,8 @@ export function createBncrInboundHandlersComponent(runtime: {
289
293
  shouldDispatch,
290
294
  shouldAccumulate,
291
295
  sceneRegistry,
292
- groupHistories,
296
+ conversationHistories,
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
- import type { BncrGroupHistoryMap } from '../messaging/inbound/group-history.ts';
3
+ import type { BncrConversationHistoryMap } from '../messaging/inbound/conversation-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';
@@ -106,7 +107,8 @@ export function buildBncrInboundSurfaceRuntime(deps: {
106
107
  }) => string;
107
108
  defaultPublicAgentId: () => string;
108
109
  sceneRegistry: Map<string, BncrSceneRecord>;
109
- groupHistories: BncrGroupHistoryMap;
110
+ conversationHistories: BncrConversationHistoryMap;
111
+ outboundReplayCache: BncrOutboundReplayCache;
110
112
  prepareInboundAcceptance: (args: {
111
113
  parsed: ReturnType<typeof parseBncrInboundParams>;
112
114
  canonicalAgentId: string;
@@ -1,5 +1,11 @@
1
+ import type { TSchema } from 'typebox';
1
2
  import type { RegisterDriftSnapshot } from '../core/register-trace.ts';
2
- import type { BncrDiagnosticsSummary, BncrRoute, OutboxEntry } from '../core/types.ts';
3
+ import type {
4
+ BncrDiagnosticsSummary,
5
+ BncrOutboundReplayEntry,
6
+ BncrRoute,
7
+ OutboxEntry,
8
+ } from '../core/types.ts';
3
9
  import type { OpenClawChannelToolSend, openClawJsonResult } from '../openclaw/sdk-helpers.ts';
4
10
 
5
11
  type OpenClawJsonResultPayload = ReturnType<typeof openClawJsonResult>;
@@ -67,25 +73,33 @@ export type BncrSceneRecord = {
67
73
  lastSeenAt: number;
68
74
  };
69
75
 
70
- export type BncrPersistedGroupHistoryEntry = {
76
+ export type BncrPersistedConversationHistoryEntry = {
71
77
  sender: string;
72
78
  senderId?: string;
79
+ role?: 'user' | 'assistant' | 'system';
73
80
  body: string;
74
81
  timestamp?: number;
75
82
  messageId?: string;
76
- media?: BncrPersistedGroupHistoryMediaEntry[];
83
+ media?: BncrPersistedConversationHistoryMediaEntry[];
77
84
  };
78
85
 
79
- export type BncrPersistedGroupHistoryMediaEntry = {
86
+ export type BncrPersistedConversationHistoryMediaEntry = {
80
87
  path?: string;
81
88
  contentType?: string;
82
89
  kind?: 'image' | 'video' | 'audio' | 'document' | 'unknown';
83
90
  messageId?: string;
84
91
  };
85
92
 
86
- export type BncrPersistedGroupHistoryBucket = {
93
+ export type BncrPersistedConversationHistoryBucket = {
87
94
  key: string;
88
- entries: BncrPersistedGroupHistoryEntry[];
95
+ entries: BncrPersistedConversationHistoryEntry[];
96
+ };
97
+
98
+ export type BncrPersistedOutboundReplayEntry = BncrOutboundReplayEntry;
99
+ export type BncrPersistedOutboundReplayMediaEntry = BncrPersistedConversationHistoryMediaEntry;
100
+ export type BncrPersistedOutboundReplayBucket = {
101
+ key: string;
102
+ entries: BncrPersistedOutboundReplayEntry[];
89
103
  };
90
104
 
91
105
  export type BncrStatusRuntimeSnapshot = {
@@ -143,10 +157,20 @@ export type BncrChannelSendContext = {
143
157
  extra?: Record<string, unknown>;
144
158
  };
145
159
 
160
+ /**
161
+ * TypeBox schema contribution for the shared `message` tool.
162
+ * The host wraps each property with Type.Optional before validation.
163
+ */
164
+ export type BncrMessageToolSchemaContribution = {
165
+ visibility?: 'current-channel' | 'all-configured';
166
+ properties: Record<string, TSchema>;
167
+ };
168
+
146
169
  export type ChannelMessageActionAdapter = {
147
170
  describeMessageTool: (ctx: { cfg: BncrChannelConfigRoot }) => {
148
- actions: readonly ['send'];
171
+ actions: readonly ('send' | 'delete' | 'unsend')[];
149
172
  capabilities: readonly [];
173
+ schema?: BncrMessageToolSchemaContribution;
150
174
  } | null;
151
175
  supportsAction: (ctx: { action: string }) => boolean;
152
176
  extractToolSend: (ctx: { args: unknown }) => OpenClawChannelToolSend | null;
@@ -163,7 +187,9 @@ export type PersistedState = {
163
187
  deadLetter: OutboxEntry[];
164
188
  sessionRoutes: BncrPersistedSessionRoute[];
165
189
  sceneRegistry?: BncrSceneRecord[];
166
- groupHistories?: BncrPersistedGroupHistoryBucket[];
190
+ conversationHistories?: BncrPersistedConversationHistoryBucket[];
191
+ groupHistories?: BncrPersistedConversationHistoryBucket[];
192
+ outboundReplayCache?: BncrPersistedOutboundReplayBucket[];
167
193
  lastSessionByAccount?: BncrPersistedLastSession[];
168
194
  lastActivityByAccount?: BncrPersistedAccountTimestamp[];
169
195
  lastInboundByAccount?: BncrPersistedAccountTimestamp[];