@xmoxmo/bncr 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,20 @@ openclaw gateway restart
37
37
 
38
38
  ---
39
39
 
40
- ## 3. 当前能力
40
+ ## 3. 客户端接入流程(最简)
41
+
42
+ 1. 在客户端插件配置中,将 **OpenClaw Token** 填写为 **gateway token**,并正确填写 host / port / ssl 后启用插件。
43
+ 2. 启动(或重启)bncr 客户端后,在 OpenClaw 侧执行:
44
+
45
+ ```bash
46
+ openclaw devices approve --latest
47
+ ```
48
+
49
+ 完成后,客户端会使用自己的身份并自动保存后续授权。
50
+
51
+ ---
52
+
53
+ ## 4. 当前能力
41
54
 
42
55
  - 文本
43
56
  - 图片
@@ -54,7 +67,7 @@ openclaw gateway restart
54
67
 
55
68
  ---
56
69
 
57
- ## 4. 架构定位
70
+ ## 5. 架构定位
58
71
 
59
72
  bncr 当前采用两层模型:
60
73
 
@@ -77,7 +90,7 @@ plugins/bncr/src/
77
90
 
78
91
  ---
79
92
 
80
- ## 5. 配置项总览
93
+ ## 6. 配置项总览
81
94
 
82
95
  当前主要配置字段:
83
96
 
@@ -98,7 +111,7 @@ plugins/bncr/src/
98
111
 
99
112
  ---
100
113
 
101
- ## 6. 状态与诊断
114
+ ## 7. 状态与诊断
102
115
 
103
116
  常用检查:
104
117
 
@@ -116,7 +129,7 @@ openclaw health --json
116
129
 
117
130
  ---
118
131
 
119
- ## 7. 自检与测试
132
+ ## 8. 自检与测试
120
133
 
121
134
  ```bash
122
135
  cd plugins/bncr
@@ -133,11 +146,12 @@ npm pack
133
146
 
134
147
  ---
135
148
 
136
- ## 8. 上线前检查
149
+ ## 9. 上线前检查
137
150
 
138
151
  上线前建议至少确认:
139
152
 
140
153
  - README 与当前实现一致
154
+ - **隐私清理**:测试/示例/日志中的 scope、ID、账号等做去标识化(必要时用占位值)
141
155
  - 配置 schema 与实际字段一致
142
156
  - 测试通过
143
157
  - 自检通过
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmoxmo/bncr",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/channel.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  OpenClawPluginServiceContext,
7
7
  GatewayRequestHandlerOptions,
8
8
  ChatType,
9
+ ChannelMessageActionAdapter,
9
10
  } from 'openclaw/plugin-sdk';
10
11
  import {
11
12
  createDefaultChannelRuntimeState,
@@ -13,6 +14,10 @@ import {
13
14
  applyAccountNameToChannelSection,
14
15
  writeJsonFileAtomically,
15
16
  readJsonFileWithFallback,
17
+ readStringParam,
18
+ readBooleanParam,
19
+ extractToolSend,
20
+ jsonResult,
16
21
  } from 'openclaw/plugin-sdk';
17
22
  import { CHANNEL_ID, BNCR_DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveDefaultDisplayName, resolveAccount, listAccountIds } from './core/accounts.js';
18
23
  import type { BncrRoute, BncrConnection, OutboxEntry } from './core/types.js';
@@ -290,11 +295,17 @@ class BncrBridgeRuntime {
290
295
  return map.get(normalizeAccountId(accountId)) || 0;
291
296
  }
292
297
 
293
- private syncDebugFlag() {
294
- const next = this.isDebugEnabled();
295
- if (next !== BNCR_DEBUG_VERBOSE) {
296
- BNCR_DEBUG_VERBOSE = next;
297
- this.api.logger.info?.(`[bncr-debug] verbose=${BNCR_DEBUG_VERBOSE}`);
298
+ private async syncDebugFlag() {
299
+ try {
300
+ const cfg = await this.api.runtime.config.loadConfig();
301
+ const raw = (cfg as any)?.channels?.[CHANNEL_ID]?.debug?.verbose;
302
+ if (typeof raw !== 'boolean') return;
303
+ if (raw !== BNCR_DEBUG_VERBOSE) {
304
+ BNCR_DEBUG_VERBOSE = raw;
305
+ this.api.logger.info?.(`[bncr-debug] verbose=${BNCR_DEBUG_VERBOSE}`);
306
+ }
307
+ } catch {
308
+ // ignore config read errors
298
309
  }
299
310
  }
300
311
 
@@ -1593,11 +1604,11 @@ class BncrBridgeRuntime {
1593
1604
  };
1594
1605
  }
1595
1606
 
1596
- private async enqueueFromReply(params: {
1607
+ public async enqueueFromReply(params: {
1597
1608
  accountId: string;
1598
1609
  sessionKey: string;
1599
1610
  route: BncrRoute;
1600
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
1611
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; asVoice?: boolean; audioAsVoice?: boolean };
1601
1612
  mediaLocalRoots?: readonly string[];
1602
1613
  }) {
1603
1614
  const { accountId, sessionKey, route, payload, mediaLocalRoots } = params;
@@ -1620,6 +1631,7 @@ class BncrBridgeRuntime {
1620
1631
  });
1621
1632
  const messageId = randomUUID();
1622
1633
  const mediaMsg = first ? asString(payload.text || '') : '';
1634
+ const wantsVoice = payload.asVoice === true || payload.audioAsVoice === true;
1623
1635
  const frame = buildBncrMediaOutboundFrame({
1624
1636
  messageId,
1625
1637
  sessionKey,
@@ -1632,6 +1644,7 @@ class BncrBridgeRuntime {
1632
1644
  fileName: media.fileName,
1633
1645
  mimeType: media.mimeType,
1634
1646
  }),
1647
+ hintedType: wantsVoice ? 'voice' : undefined,
1635
1648
  now: now(),
1636
1649
  });
1637
1650
 
@@ -1785,7 +1798,7 @@ class BncrBridgeRuntime {
1785
1798
  };
1786
1799
 
1787
1800
  handleActivity = async ({ params, respond, client, context }: GatewayRequestHandlerOptions) => {
1788
- this.syncDebugFlag();
1801
+ void this.syncDebugFlag();
1789
1802
  const accountId = normalizeAccountId(asString(params?.accountId || ''));
1790
1803
  const connId = asString(client?.connId || '').trim() || `no-conn-${Date.now()}`;
1791
1804
  const clientId = asString((params as any)?.clientId || '').trim() || undefined;
@@ -2287,6 +2300,8 @@ class BncrBridgeRuntime {
2287
2300
  channelSendMedia = async (ctx: any) => {
2288
2301
  const accountId = normalizeAccountId(ctx.accountId);
2289
2302
  const to = asString(ctx.to || '').trim();
2303
+ const asVoice = ctx?.asVoice === true;
2304
+ const audioAsVoice = ctx?.audioAsVoice === true;
2290
2305
 
2291
2306
  if (BNCR_DEBUG_VERBOSE) {
2292
2307
  this.api.logger.info?.(
@@ -2296,6 +2311,8 @@ class BncrBridgeRuntime {
2296
2311
  text: asString(ctx?.text || ''),
2297
2312
  mediaUrl: asString(ctx?.mediaUrl || ''),
2298
2313
  mediaUrls: Array.isArray(ctx?.mediaUrls) ? ctx.mediaUrls : undefined,
2314
+ asVoice,
2315
+ audioAsVoice,
2299
2316
  sessionKey: asString(ctx?.sessionKey || ''),
2300
2317
  mirrorSessionKey: asString(ctx?.mirror?.sessionKey || ''),
2301
2318
  rawCtx: {
@@ -2314,6 +2331,8 @@ class BncrBridgeRuntime {
2314
2331
  to,
2315
2332
  text: asString(ctx.text || ''),
2316
2333
  mediaUrl: asString(ctx.mediaUrl || ''),
2334
+ asVoice,
2335
+ audioAsVoice,
2317
2336
  mediaLocalRoots: ctx.mediaLocalRoots,
2318
2337
  resolveVerifiedTarget: (to, accountId) => this.resolveVerifiedTarget(to, accountId),
2319
2338
  rememberSessionRoute: (sessionKey, accountId, route) => this.rememberSessionRoute(sessionKey, accountId, route),
@@ -2328,6 +2347,58 @@ export function createBncrBridge(api: OpenClawPluginApi) {
2328
2347
  }
2329
2348
 
2330
2349
  export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2350
+ const messageActions: ChannelMessageActionAdapter = {
2351
+ listActions: () => ['send'],
2352
+ supportsAction: ({ action }) => action === 'send',
2353
+ extractToolSend: ({ args }) => extractToolSend(args, 'sendMessage'),
2354
+ handleAction: async ({ action, params, accountId, mediaLocalRoots }) => {
2355
+ if (action !== 'send') throw new Error(`Action ${action} is not supported for provider ${CHANNEL_ID}.`);
2356
+ const to = readStringParam(params, 'to', { required: true });
2357
+ const message = readStringParam(params, 'message', { allowEmpty: true }) ?? '';
2358
+ const caption = readStringParam(params, 'caption', { allowEmpty: true }) ?? '';
2359
+ const content = message || caption || '';
2360
+ const mediaUrl =
2361
+ readStringParam(params, 'media', { trim: false }) ??
2362
+ readStringParam(params, 'path', { trim: false }) ??
2363
+ readStringParam(params, 'filePath', { trim: false }) ??
2364
+ readStringParam(params, 'mediaUrl', { trim: false });
2365
+ const asVoice = readBooleanParam(params, 'asVoice') ?? false;
2366
+ const audioAsVoice = readBooleanParam(params, 'audioAsVoice') ?? false;
2367
+ const resolvedAccountId = normalizeAccountId(readStringParam(params, 'accountId') ?? accountId);
2368
+
2369
+ if (!content.trim() && !mediaUrl) throw new Error('send requires text or media');
2370
+
2371
+ const result = mediaUrl
2372
+ ? await sendBncrMedia({
2373
+ channelId: CHANNEL_ID,
2374
+ accountId: resolvedAccountId,
2375
+ to,
2376
+ text: content,
2377
+ mediaUrl,
2378
+ asVoice,
2379
+ audioAsVoice,
2380
+ mediaLocalRoots,
2381
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2382
+ rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2383
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2384
+ createMessageId: () => randomUUID(),
2385
+ })
2386
+ : await sendBncrText({
2387
+ channelId: CHANNEL_ID,
2388
+ accountId: resolvedAccountId,
2389
+ to,
2390
+ text: content,
2391
+ mediaLocalRoots,
2392
+ resolveVerifiedTarget: (to, accountId) => bridge.resolveVerifiedTarget(to, accountId),
2393
+ rememberSessionRoute: (sessionKey, accountId, route) => bridge.rememberSessionRoute(sessionKey, accountId, route),
2394
+ enqueueFromReply: (args) => bridge.enqueueFromReply(args as any),
2395
+ createMessageId: () => randomUUID(),
2396
+ });
2397
+
2398
+ return jsonResult({ ok: true, ...result });
2399
+ },
2400
+ };
2401
+
2331
2402
  const plugin = {
2332
2403
  id: CHANNEL_ID,
2333
2404
  meta: {
@@ -2338,6 +2409,7 @@ export function createBncrChannelPlugin(bridge: BncrBridgeRuntime) {
2338
2409
  blurb: 'Bncr Channel.',
2339
2410
  aliases: ['bncr'],
2340
2411
  },
2412
+ actions: messageActions,
2341
2413
  capabilities: {
2342
2414
  chatTypes: ['direct'] as ChatType[],
2343
2415
  media: true,
@@ -113,7 +113,7 @@ export async function handleBncrNativeCommand(params: {
113
113
  disableBlockStreaming: true,
114
114
  },
115
115
  dispatcherOptions: {
116
- deliver: async (payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] }, info?: { kind?: 'tool' | 'block' | 'final' }) => {
116
+ deliver: async (payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean }, info?: { kind?: 'tool' | 'block' | 'final' }) => {
117
117
  if (info?.kind && info.kind !== 'final') return;
118
118
  const hasPayload = Boolean(payload?.text || payload?.mediaUrl || (Array.isArray(payload?.mediaUrls) && payload.mediaUrls.length > 0));
119
119
  if (!hasPayload) return;
@@ -151,7 +151,7 @@ export async function dispatchBncrInbound(params: {
151
151
  },
152
152
  dispatcherOptions: {
153
153
  deliver: async (
154
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] },
154
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
155
155
  info?: { kind?: 'tool' | 'block' | 'final' },
156
156
  ) => {
157
157
  if (info?.kind && info.kind !== 'final') return;
@@ -4,6 +4,11 @@ function asString(v: unknown, fallback = ''): string {
4
4
  return String(v);
5
5
  }
6
6
 
7
+ function isAudioMimeType(mimeType?: string): boolean {
8
+ const mt = asString(mimeType || '').toLowerCase();
9
+ return mt.startsWith('audio/');
10
+ }
11
+
7
12
  export function resolveBncrOutboundMessageType(params: { mimeType?: string; fileName?: string; hintedType?: string; hasPayload?: boolean }): 'text' | 'image' | 'video' | 'voice' | 'audio' | 'file' {
8
13
  const hinted = asString(params.hintedType || '').toLowerCase();
9
14
  const hasPayload = !!params.hasPayload;
@@ -12,6 +17,11 @@ export function resolveBncrOutboundMessageType(params: { mimeType?: string; file
12
17
  const isStandard = hinted === 'text' || hinted === 'image' || hinted === 'video' || hinted === 'voice' || hinted === 'audio' || hinted === 'file';
13
18
 
14
19
  if (hasPayload && major === 'text' && (hinted === 'text' || !isStandard)) return 'file';
20
+ if (hinted === 'voice') {
21
+ if (isAudioMimeType(mt)) return 'voice';
22
+ if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
23
+ return 'file';
24
+ }
15
25
  if (isStandard) return hinted as any;
16
26
  if (major === 'text' || major === 'image' || major === 'video' || major === 'audio') return major as any;
17
27
  return 'file';
@@ -25,6 +35,7 @@ export function buildBncrMediaOutboundFrame(params: {
25
35
  mediaUrl: string;
26
36
  mediaMsg: string;
27
37
  fileName: string;
38
+ hintedType?: string;
28
39
  now: number;
29
40
  }) {
30
41
  return {
@@ -40,6 +51,7 @@ export function buildBncrMediaOutboundFrame(params: {
40
51
  mimeType: params.media.mimeType,
41
52
  fileName: params.media.fileName,
42
53
  hasPayload: !!(params.media.path || params.media.mediaBase64),
54
+ hintedType: params.hintedType,
43
55
  }),
44
56
  mimeType: params.media.mimeType || '',
45
57
  msg: params.mediaMsg,
@@ -37,6 +37,8 @@ export async function sendBncrMedia(params: {
37
37
  to: string;
38
38
  text?: string;
39
39
  mediaUrl?: string;
40
+ asVoice?: boolean;
41
+ audioAsVoice?: boolean;
40
42
  mediaLocalRoots?: readonly string[];
41
43
  resolveVerifiedTarget: (to: string, accountId: string) => { sessionKey: string; route: any; displayScope: string };
42
44
  rememberSessionRoute: (sessionKey: string, accountId: string, route: any) => void;
@@ -44,7 +46,7 @@ export async function sendBncrMedia(params: {
44
46
  accountId: string;
45
47
  sessionKey: string;
46
48
  route: any;
47
- payload: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
49
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; asVoice?: boolean; audioAsVoice?: boolean };
48
50
  mediaLocalRoots?: readonly string[];
49
51
  }) => Promise<void>;
50
52
  createMessageId: () => string;
@@ -59,6 +61,8 @@ export async function sendBncrMedia(params: {
59
61
  payload: {
60
62
  text: params.text || '',
61
63
  mediaUrl: params.mediaUrl || '',
64
+ asVoice: params.asVoice === true ? true : undefined,
65
+ audioAsVoice: params.audioAsVoice === true ? true : undefined,
62
66
  },
63
67
  mediaLocalRoots: params.mediaLocalRoots,
64
68
  });