openclaw-plugin-yuanbao 2.13.5 → 2.14.0

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.
@@ -1,6 +1,6 @@
1
1
  /** Business-layer Protobuf codec — encode/decode for business messages. */
2
2
  import type { YuanbaoInboundMessage, YuanbaoMsgBodyElement } from "../../types.js";
3
- import type { WsSendC2CMessageData, WsSendGroupMessageData, WsSendMessageResponse, WsSendPrivateHeartbeatData, WsSendGroupHeartbeatData, WsHeartbeatResponse, WsQueryGroupInfoData, WsQueryGroupInfoResponse, WsGetGroupMemberListData, WsGetGroupMemberListResponse, WsSyncInformationData, WsSyncInformationResponse } from "./types.js";
3
+ import type { WsSendC2CMessageData, WsSendGroupMessageData, WsSendMessageResponse, WsSendPrivateHeartbeatData, WsSendGroupHeartbeatData, WsHeartbeatResponse, WsQueryGroupInfoData, WsQueryGroupInfoResponse, WsGetGroupMemberListData, WsGetGroupMemberListResponse, WsSyncInformationData, WsSyncInformationResponse, WsQueryBotInfoResponse } from "./types.js";
4
4
  export declare const BIZ_MSG_TYPES: {
5
5
  readonly MsgContent: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.MsgContent";
6
6
  readonly MsgBodyElement: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.MsgBodyElement";
@@ -19,6 +19,8 @@ export declare const BIZ_MSG_TYPES: {
19
19
  readonly SendGroupHeartbeatRsp: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.SendGroupHeartbeatRsp";
20
20
  readonly SyncInformationReq: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.SyncInformationReq";
21
21
  readonly SyncInformationRsp: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.SyncInformationRsp";
22
+ readonly QueryBotInfoReq: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.QueryBotInfoReq";
23
+ readonly QueryBotInfoRsp: "trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.QueryBotInfoRsp";
22
24
  };
23
25
  export declare function encodeBizPB(key: string, value: Record<string, unknown>): Uint8Array | null;
24
26
  export declare function decodeBizPB(key: string, data: Uint8Array | ArrayBuffer): unknown;
@@ -64,3 +66,7 @@ export declare function decodeSendGroupHeartbeatRsp(data: Uint8Array | ArrayBuff
64
66
  export declare function encodeSyncInformationReq(data: WsSyncInformationData): Uint8Array | null;
65
67
  /** Decode SyncInformationRsp. */
66
68
  export declare function decodeSyncInformationRsp(data: Uint8Array | ArrayBuffer, msgId: string): WsSyncInformationResponse | null;
69
+ /** Encode QueryBotInfoReq. */
70
+ export declare function encodeQueryBotInfoReq(botId: string): Uint8Array | null;
71
+ /** Decode QueryBotInfoRsp. */
72
+ export declare function decodeQueryBotInfoRsp(data: Uint8Array | ArrayBuffer, msgId: string): WsQueryBotInfoResponse | null;
@@ -29,6 +29,8 @@ export const BIZ_MSG_TYPES = {
29
29
  SendGroupHeartbeatRsp: `${PKG}.SendGroupHeartbeatRsp`,
30
30
  SyncInformationReq: `${PKG}.SyncInformationReq`,
31
31
  SyncInformationRsp: `${PKG}.SyncInformationRsp`,
32
+ QueryBotInfoReq: `${PKG}.QueryBotInfoReq`,
33
+ QueryBotInfoRsp: `${PKG}.QueryBotInfoRsp`,
32
34
  };
33
35
  export function encodeBizPB(key, value) {
34
36
  try {
@@ -366,3 +368,21 @@ export function decodeSyncInformationRsp(data, msgId) {
366
368
  msg: decoded.msg || "",
367
369
  };
368
370
  }
371
+ /** Encode QueryBotInfoReq. */
372
+ export function encodeQueryBotInfoReq(botId) {
373
+ return encodeBizPB(BIZ_MSG_TYPES.QueryBotInfoReq, { botId });
374
+ }
375
+ /** Decode QueryBotInfoRsp. */
376
+ export function decodeQueryBotInfoRsp(data, msgId) {
377
+ const decoded = decodeBizPB(BIZ_MSG_TYPES.QueryBotInfoRsp, data);
378
+ if (!decoded) {
379
+ return null;
380
+ }
381
+ return {
382
+ msgId,
383
+ code: decoded.code || 0,
384
+ msg: decoded.message || "",
385
+ botId: decoded.botInfo?.botId || "",
386
+ ownerId: decoded.botInfo?.encryptOwnerId || "",
387
+ };
388
+ }
@@ -1,6 +1,6 @@
1
1
  /** WebSocket client — connection management, auth, heartbeat, and auto-reconnect. */
2
2
  import type { LogSink } from "../../logger.js";
3
- import type { WsClientCallbacks, WsClientConfig, WsClientState, WsConnectionConfig, WsSendMessageResponse, WsSendC2CMessageData, WsSendGroupMessageData, WsSendPrivateHeartbeatData, WsSendGroupHeartbeatData, WsHeartbeatResponse, WsQueryGroupInfoData, WsQueryGroupInfoResponse, WsGetGroupMemberListData, WsGetGroupMemberListResponse, WsSyncInformationData, WsSyncInformationResponse } from "./types.js";
3
+ import type { WsClientCallbacks, WsClientConfig, WsClientState, WsConnectionConfig, WsSendMessageResponse, WsSendC2CMessageData, WsSendGroupMessageData, WsSendPrivateHeartbeatData, WsSendGroupHeartbeatData, WsHeartbeatResponse, WsQueryGroupInfoData, WsQueryGroupInfoResponse, WsGetGroupMemberListData, WsGetGroupMemberListResponse, WsSyncInformationData, WsSyncInformationResponse, WsQueryBotInfoResponse } from "./types.js";
4
4
  /** Outbound business commands */
5
5
  export declare const BIZ_CMD: {
6
6
  /** Send C2C message */
@@ -12,6 +12,7 @@ export declare const BIZ_CMD: {
12
12
  readonly SendPrivateHeartbeat: "send_private_heartbeat";
13
13
  readonly SendGroupHeartbeat: "send_group_heartbeat";
14
14
  readonly SyncInformation: "sync_information";
15
+ readonly QueryBotInfo: "query_bot_info";
15
16
  };
16
17
  /**
17
18
  * Yuanbao WebSocket client.
@@ -66,6 +67,8 @@ export declare class YuanbaoWsClient {
66
67
  sendGroupHeartbeat(data: WsSendGroupHeartbeatData): Promise<WsHeartbeatResponse>;
67
68
  /** Sync information to the backend (command list, etc.). */
68
69
  syncInformation(data: WsSyncInformationData): Promise<WsSyncInformationResponse>;
70
+ /** Query bot info (owner id, etc.) from the backend. */
71
+ queryBotInfo(botId: string): Promise<WsQueryBotInfoResponse>;
69
72
  /** Establish WebSocket connection and bind event handlers. */
70
73
  private doConnect;
71
74
  private onMessage;
@@ -4,7 +4,7 @@ import WebSocket from "ws";
4
4
  import { msgBodyDesensitization } from "../../business/utils/utils.js";
5
5
  import { getPluginVersion, getOpenclawVersion, getOperationSystem } from "../../infra/env.js";
6
6
  import { createLog } from "../../logger.js";
7
- import { encodeSendC2CMessageReq, encodeSendGroupMessageReq, decodeSendMessageRsp, encodeSendPrivateHeartbeatReq, encodeSendGroupHeartbeatReq, decodeSendPrivateHeartbeatRsp, decodeSendGroupHeartbeatRsp, encodeQueryGroupInfoReq, decodeQueryGroupInfoRsp, encodeGetGroupMemberListReq, decodeGetGroupMemberListRsp, encodeSyncInformationReq, decodeSyncInformationRsp, } from "./biz-codec.js";
7
+ import { encodeSendC2CMessageReq, encodeSendGroupMessageReq, decodeSendMessageRsp, encodeSendPrivateHeartbeatReq, encodeSendGroupHeartbeatReq, decodeSendPrivateHeartbeatRsp, decodeSendGroupHeartbeatRsp, encodeQueryGroupInfoReq, decodeQueryGroupInfoRsp, encodeGetGroupMemberListReq, decodeGetGroupMemberListRsp, encodeSyncInformationReq, decodeSyncInformationRsp, encodeQueryBotInfoReq, decodeQueryBotInfoRsp, } from "./biz-codec.js";
8
8
  import { decodeConnMsg, decodePB, buildAuthBindMsg, buildPingMsg, buildPushAck, buildBusinessConnMsg, PB_MSG_TYPES, CMD_TYPE, CMD, } from "./conn-codec.js";
9
9
  const DEFAULT_RECONNECT_DELAYS = [1_000, 2_000, 5_000, 10_000, 30_000, 60_000];
10
10
  const NO_RECONNECT_CLOSE_CODES = new Set([
@@ -42,6 +42,7 @@ export const BIZ_CMD = {
42
42
  SendPrivateHeartbeat: "send_private_heartbeat",
43
43
  SendGroupHeartbeat: "send_group_heartbeat",
44
44
  SyncInformation: "sync_information",
45
+ QueryBotInfo: "query_bot_info",
45
46
  };
46
47
  const BIZ_MODULE = "yuanbao_openclaw_proxy";
47
48
  /**
@@ -241,6 +242,15 @@ export class YuanbaoWsClient {
241
242
  }
242
243
  return this.sendAndWaitWith(BIZ_CMD.SyncInformation, BIZ_MODULE, encoded, decodeSyncInformationRsp);
243
244
  }
245
+ /** Query bot info (owner id, etc.) from the backend. */
246
+ queryBotInfo(botId) {
247
+ this.log.debug("[bot-info] querying bot info", { botId });
248
+ const encoded = encodeQueryBotInfoReq(botId);
249
+ if (!encoded) {
250
+ return Promise.reject(new Error("Failed to encode QueryBotInfoReq"));
251
+ }
252
+ return this.sendAndWaitWith(BIZ_CMD.QueryBotInfo, BIZ_MODULE, encoded, decodeQueryBotInfoRsp);
253
+ }
244
254
  /** Establish WebSocket connection and bind event handlers. */
245
255
  doConnect() {
246
256
  if (this.disposed) {
@@ -35,6 +35,12 @@ export async function startYuanbaoWsGateway(params) {
35
35
  error: String(err),
36
36
  });
37
37
  });
38
+ // Query and cache bot owner id (non-blocking)
39
+ queryAndCacheBotOwner(client, account, gwlog).catch((err) => {
40
+ gwlog.warn(`[${account.accountId}] failed to query bot owner (non-blocking)`, {
41
+ error: String(err),
42
+ });
43
+ });
38
44
  },
39
45
  onDispatch: (pushEvent) => {
40
46
  gwlog.debug(`[${account.accountId}] WS push: cmd=${pushEvent.cmd}, type=${pushEvent.type}`);
@@ -325,6 +331,28 @@ function handleWsDispatchEvent(params) {
325
331
  dlog.error(`[${account.accountId}][dispatch] WS ${isGroup ? "group " : ""} message handler failed: ${String(err)}`);
326
332
  });
327
333
  }
334
+ /**
335
+ * Query bot info (owner id) from the backend after connection is established,
336
+ * and cache the result on the account object for use in owner-gating middleware.
337
+ *
338
+ * Called once after auth-bind succeeds. Failures are non-fatal.
339
+ */
340
+ async function queryAndCacheBotOwner(client, account, log) {
341
+ const botId = account.botId;
342
+ if (!botId) {
343
+ log.warn(`[${account.accountId}] queryAndCacheBotOwner: botId not set, skipping`);
344
+ return;
345
+ }
346
+ const rsp = await client.queryBotInfo(botId);
347
+ if (rsp.code !== 0) {
348
+ log.warn(`[${account.accountId}] queryBotInfo returned non-zero code: ${rsp.code}`);
349
+ return;
350
+ }
351
+ if (rsp.ownerId) {
352
+ account.botOwnerId = rsp.ownerId;
353
+ log.info(`[${account.accountId}] bot owner cached: ownerId=${rsp.ownerId}`);
354
+ }
355
+ }
328
356
  /**
329
357
  * Sync the command list to the backend after connection is established.
330
358
  *
@@ -539,6 +539,42 @@
539
539
  "id": 2
540
540
  }
541
541
  }
542
+ },
543
+ "BotInfo": {
544
+ "fields": {
545
+ "botId": {
546
+ "type": "string",
547
+ "id": 1
548
+ },
549
+ "encryptOwnerId": {
550
+ "type": "string",
551
+ "id": 2
552
+ }
553
+ }
554
+ },
555
+ "QueryBotInfoReq": {
556
+ "fields": {
557
+ "botId": {
558
+ "type": "string",
559
+ "id": 1
560
+ }
561
+ }
562
+ },
563
+ "QueryBotInfoRsp": {
564
+ "fields": {
565
+ "code": {
566
+ "type": "int32",
567
+ "id": 1
568
+ },
569
+ "message": {
570
+ "type": "string",
571
+ "id": 2
572
+ },
573
+ "botInfo": {
574
+ "type": "BotInfo",
575
+ "id": 3
576
+ }
577
+ }
542
578
  }
543
579
  }
544
580
  }
@@ -173,3 +173,15 @@ export type WsSyncInformationResponse = {
173
173
  code: number;
174
174
  msg: string;
175
175
  };
176
+ /** Query bot info request */
177
+ export type WsQueryBotInfoData = {
178
+ bot_id: string;
179
+ };
180
+ /** Query bot info response */
181
+ export type WsQueryBotInfoResponse = {
182
+ msgId: string;
183
+ code: number;
184
+ msg: string;
185
+ botId: string;
186
+ ownerId: string;
187
+ };
@@ -8,7 +8,7 @@ import { YUANBAO_MARKDOWN_HINT } from "../../messaging/context.js";
8
8
  export const buildContext = {
9
9
  name: "build-context",
10
10
  handler: async (ctx, next) => {
11
- const { core, account, isGroup, fromAccount, senderNickname, groupCode, rewrittenBody, mediaPaths, mediaTypes, commandAuthorized, route, storePath, envelopeOptions, previousTimestamp, raw, } = ctx;
11
+ const { core, account, isGroup, fromAccount, senderNickname, groupCode, rewrittenBody, commandParts, mediaPaths, mediaTypes, commandAuthorized, route, storePath, envelopeOptions, previousTimestamp, raw, } = ctx;
12
12
  if (!route || !storePath || !envelopeOptions) {
13
13
  ctx.log.error("[build-context] prerequisite middleware not ready");
14
14
  return;
@@ -55,7 +55,7 @@ export const buildContext = {
55
55
  BodyForAgent: rewrittenBody,
56
56
  ...(isGroup ? { InboundHistory: inboundHistory } : {}),
57
57
  RawBody: rewrittenBody,
58
- CommandBody: rewrittenBody,
58
+ CommandBody: commandParts?.length > 0 ? commandParts.join(" ") : rewrittenBody,
59
59
  From: `yuanbao:${label}`,
60
60
  To: `yuanbao:${label}`,
61
61
  SessionKey: route.sessionKey,
@@ -3,16 +3,34 @@
3
3
  * Checks for control commands and applies DM allowFrom + useAccessGroups policies.
4
4
  */
5
5
  import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
6
+ /** Extract pure text content from msg_body (TIMTextElem only, skipping @mention custom elements). */
7
+ function extractTextOnly(ctx) {
8
+ if (!ctx.raw.msg_body)
9
+ return ctx.rawBody;
10
+ return ctx.raw.msg_body
11
+ .filter(e => e.msg_type === "TIMTextElem")
12
+ .map(e => e.msg_content?.text ?? "")
13
+ .join("")
14
+ .trim() || ctx.rawBody;
15
+ }
6
16
  export const guardCommand = {
7
17
  name: "guard-command",
8
18
  handler: async (ctx, next) => {
9
19
  const { core, config, rawBody, fromAccount, account } = ctx;
20
+ // Group chat: extract TIMTextElem-only text for command detection
21
+ // (rawBody includes @mention custom elements which break command matching).
22
+ const commandText = ctx.isGroup ? extractTextOnly(ctx) : rawBody;
10
23
  const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
11
24
  cfg: config,
12
25
  surface: "yuanbao",
13
26
  });
14
- const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config);
27
+ const rawHasControlCommand = core.channel.text.hasControlCommand(commandText, config);
28
+ const hasControlCommand = ctx.isGroup ? rawHasControlCommand && ctx.isAtBot : rawHasControlCommand;
15
29
  ctx.hasControlCommand = hasControlCommand;
30
+ if (!hasControlCommand) {
31
+ await next();
32
+ return;
33
+ }
16
34
  // Build DM policy allowFrom
17
35
  const dmPolicy = account.config.dm?.policy ?? "open";
18
36
  const rawAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
@@ -28,8 +46,9 @@ export const guardCommand = {
28
46
  ctx.commandAuthorized = commandAuthorized;
29
47
  if (shouldBlock) {
30
48
  ctx.log.info(`[guard-command] control command unauthorized, discarding <- ${ctx.isGroup ? `group:${ctx.groupCode}` : ""} from: ${fromAccount}`);
31
- return; // Abort pipeline
49
+ return;
32
50
  }
51
+ ctx.commandParts = commandText.trim().split(/\s+/);
33
52
  await next();
34
53
  },
35
54
  };
@@ -7,33 +7,36 @@
7
7
  import { getMember } from "../../../infra/cache/member.js";
8
8
  import { sendGroupMsgBody } from "../../../infra/transport.js";
9
9
  import { prepareOutboundContent, buildOutboundMsgBody } from "../../messaging/handlers/index.js";
10
- /** Public commands available to all group members */
11
- const GROUP_PUBLIC_COMMANDS = new Set([]);
10
+ /** Commands allowed in group chat (owner-only) */
11
+ const GROUP_ALLOWED_COMMANDS = new Set([
12
+ "/new", "/reset", "/retry", "/undo", "/stop",
13
+ "/approve", "/btw", "/queue",
14
+ ]);
12
15
  export const guardGroupCommand = {
13
16
  name: "guard-group-command",
14
- when: ctx => ctx.isGroup,
17
+ when: ctx => ctx.isGroup && ctx.hasControlCommand,
15
18
  handler: async (ctx, next) => {
16
- const { core, config, rawBody, raw, account, groupCode, fromAccount } = ctx;
17
- const q = rawBody.trim().split(/\s+/)
18
- .find(part => !part.trim().startsWith("@")) || rawBody;
19
- const hasRegisteredCommand = core.channel.text.hasControlCommand(q, config);
20
- const isOwner = Boolean(raw.bot_owner_id && raw.from_account === raw.bot_owner_id);
21
- const cmdMatch = q.trim().match(/^\/([a-z_-]+)/i);
22
- ctx.log.info('[guard-group-command] come in', { hasRegisteredCommand, isOwner, isCmd: !!cmdMatch });
23
- if (hasRegisteredCommand && cmdMatch) {
24
- const cmdName = cmdMatch[1].toLowerCase();
25
- if (!GROUP_PUBLIC_COMMANDS.has(cmdName) && !isOwner) {
26
- await sendGroupMsgBody({
27
- account,
28
- groupCode: groupCode,
29
- msgBody: buildOutboundMsgBody(prepareOutboundContent(`⚠️ /${cmdName} 仅限创建者${!raw?.bot_owner_id ? "并且在私聊模式下" : ""}使用哦~`, groupCode, getMember(account.accountId))),
30
- fromAccount: account.botId,
31
- refMsgId: raw.msg_id || raw.msg_key || undefined,
32
- refFromAccount: fromAccount,
33
- wsClient: ctx.wsClient,
34
- });
35
- return; // Abort pipeline
36
- }
19
+ const { commandParts, raw, account, groupCode, fromAccount } = ctx;
20
+ const cmd = commandParts?.[0]?.toLowerCase() ?? "";
21
+ const ownerId = account.botOwnerId || raw.bot_owner_id;
22
+ const isOwner = Boolean(ownerId && raw.from_account === ownerId);
23
+ ctx.log.info('[guard-group-command] come in', { isOwner, cmd });
24
+ const allowed = GROUP_ALLOWED_COMMANDS.has(cmd);
25
+ const rejected = !allowed || !isOwner;
26
+ if (rejected) {
27
+ const rejectReason = !allowed
28
+ ? `⚠️ ${cmd} 暂不支持在群聊中使用,请在私聊中发送`
29
+ : `⚠️ ${cmd} 仅限创建者使用哦~`;
30
+ await sendGroupMsgBody({
31
+ account,
32
+ groupCode: groupCode,
33
+ msgBody: buildOutboundMsgBody(prepareOutboundContent(rejectReason, groupCode, getMember(account.accountId))),
34
+ fromAccount: account.botId,
35
+ refMsgId: raw.msg_id || raw.msg_key || undefined,
36
+ refFromAccount: fromAccount,
37
+ wsClient: ctx.wsClient,
38
+ });
39
+ return;
37
40
  }
38
41
  await next();
39
42
  },
@@ -28,95 +28,72 @@ function setupMocks(t) {
28
28
  },
29
29
  });
30
30
  }
31
- void test("guard-group-command: when guard - executes in group chat", async (t) => {
31
+ void test("guard-group-command: when guard - executes for group command", async (t) => {
32
32
  setupMocks(t);
33
33
  const { guardGroupCommand } = await import("./guard-group-command.js");
34
- const ctx = createMockCtx({ isGroup: true });
34
+ const ctx = createMockCtx({ isGroup: true, hasControlCommand: true });
35
35
  assert.equal(guardGroupCommand.when(ctx), true);
36
36
  });
37
37
  void test("guard-group-command: when guard - skips in C2C", async (t) => {
38
38
  setupMocks(t);
39
39
  const { guardGroupCommand } = await import("./guard-group-command.js");
40
- const ctx = createMockCtx({ isGroup: false });
40
+ const ctx = createMockCtx({ isGroup: false, hasControlCommand: true });
41
41
  assert.equal(guardGroupCommand.when(ctx), false);
42
42
  });
43
- void test("guard-group-command: non-owner executes registered command -> abort pipeline", async (t) => {
43
+ void test("guard-group-command: when guard - skips non-command in group", async (t) => {
44
+ setupMocks(t);
45
+ const { guardGroupCommand } = await import("./guard-group-command.js");
46
+ const ctx = createMockCtx({ isGroup: true, hasControlCommand: false });
47
+ assert.equal(guardGroupCommand.when(ctx), false);
48
+ });
49
+ void test("guard-group-command: non-owner + whitelisted command -> reject (owner-only)", async (t) => {
44
50
  setupMocks(t);
45
51
  const { guardGroupCommand } = await import("./guard-group-command.js");
46
52
  const ctx = createMockCtx({
47
53
  isGroup: true,
48
- rawBody: "/some-registered-cmd",
54
+ hasControlCommand: true,
55
+ commandParts: ["/new"],
56
+ rawBody: "/new",
49
57
  groupCode: "group-001",
50
58
  fromAccount: "user-001",
51
59
  raw: { bot_owner_id: "owner-001", from_account: "user-001", msg_id: "msg-001" },
52
- core: {
53
- channel: {
54
- text: { hasControlCommand: () => true },
55
- },
56
- },
57
60
  config: {},
58
61
  });
59
62
  const { next, wasCalled } = createMockNext();
60
63
  await guardGroupCommand.handler(ctx, next);
61
64
  assert.equal(wasCalled(), false, "non-owner should abort pipeline");
62
65
  });
63
- void test("guard-group-command: owner executes registered command -> pass through", async (t) => {
66
+ void test("guard-group-command: owner + whitelisted command -> pass through", async (t) => {
64
67
  setupMocks(t);
65
68
  const { guardGroupCommand } = await import("./guard-group-command.js");
66
69
  const ctx = createMockCtx({
67
70
  isGroup: true,
68
- rawBody: "/some-registered-cmd",
71
+ hasControlCommand: true,
72
+ commandParts: ["/new"],
73
+ rawBody: "/new",
69
74
  groupCode: "group-001",
70
75
  fromAccount: "owner-001",
71
76
  raw: { bot_owner_id: "owner-001", from_account: "owner-001" },
72
- core: {
73
- channel: {
74
- text: { hasControlCommand: () => true },
75
- },
76
- },
77
77
  config: {},
78
78
  });
79
79
  const { next, wasCalled } = createMockNext();
80
80
  await guardGroupCommand.handler(ctx, next);
81
81
  assert.equal(wasCalled(), true, "owner should pass through");
82
82
  });
83
- void test("guard-group-command: unregistered command -> pass through (treated as plain text)", async (t) => {
84
- setupMocks(t);
85
- const { guardGroupCommand } = await import("./guard-group-command.js");
86
- const ctx = createMockCtx({
87
- isGroup: true,
88
- rawBody: "/random-text",
89
- groupCode: "group-001",
90
- fromAccount: "user-001",
91
- raw: { bot_owner_id: "owner-001", from_account: "user-001" },
92
- core: {
93
- channel: {
94
- text: { hasControlCommand: () => false },
95
- },
96
- },
97
- config: {},
98
- });
99
- const { next, wasCalled } = createMockNext();
100
- await guardGroupCommand.handler(ctx, next);
101
- assert.equal(wasCalled(), true, "unregistered command should pass through");
102
- });
103
- void test("guard-group-command: plain text message -> pass through", async (t) => {
83
+ void test("guard-group-command: non-whitelisted command -> reject", async (t) => {
104
84
  setupMocks(t);
105
85
  const { guardGroupCommand } = await import("./guard-group-command.js");
106
86
  const ctx = createMockCtx({
107
87
  isGroup: true,
108
- rawBody: "你好",
88
+ hasControlCommand: true,
89
+ commandParts: ["/config"],
90
+ rawBody: "/config",
109
91
  groupCode: "group-001",
110
92
  fromAccount: "user-001",
111
- raw: { from_account: "user-001" },
112
- core: {
113
- channel: {
114
- text: { hasControlCommand: () => false },
115
- },
116
- },
93
+ raw: { bot_owner_id: "owner-001", from_account: "user-001", msg_id: "msg-001" },
117
94
  config: {},
118
95
  });
119
96
  const { next, wasCalled } = createMockNext();
120
97
  await guardGroupCommand.handler(ctx, next);
121
- assert.equal(wasCalled(), true);
98
+ assert.equal(wasCalled(), false, "non-whitelisted command should abort pipeline");
122
99
  });
@@ -6,9 +6,11 @@ import { parseUpgradeCommand } from "../../commands/upgrade/index.js";
6
6
  import { performUpgrade } from "../../commands/upgrade/upgrade.js";
7
7
  /**
8
8
  * Check whether the message is from the bot owner.
9
+ * Prefers account.botOwnerId (cached via QueryBotInfoReq) over per-message bot_owner_id.
9
10
  */
10
- function isOwnerMessage(raw) {
11
- return Boolean(raw.bot_owner_id && raw.from_account === raw.bot_owner_id);
11
+ function isOwnerMessage(raw, accountOwnerId) {
12
+ const ownerId = accountOwnerId || raw.bot_owner_id;
13
+ return Boolean(ownerId && raw.from_account === ownerId);
12
14
  }
13
15
  /**
14
16
  * Send a reply message via sendText action + deliver layer.
@@ -34,7 +36,7 @@ export const guardSpecialCommand = {
34
36
  const upgradeCmd = parseUpgradeCommand(trimmedBody);
35
37
  if (upgradeCmd.matched) {
36
38
  ctx.log.info(`[guard-special-command] received ${trimmedBody} command`);
37
- if (!isOwnerMessage(raw)) {
39
+ if (!isOwnerMessage(raw, ctx.account.botOwnerId)) {
38
40
  ctx.log.warn(`[guard-special-command] non-owner attempted ${trimmedBody}, rejected`, {
39
41
  fromAccount,
40
42
  });
@@ -75,7 +77,7 @@ export const guardSpecialCommand = {
75
77
  // /issue-log owner guard
76
78
  if (trimmedBody.startsWith("/issue-log")) {
77
79
  ctx.log.info("[guard-special-command] received /issue-log command");
78
- if (!isOwnerMessage(raw)) {
80
+ if (!isOwnerMessage(raw, ctx.account.botOwnerId)) {
79
81
  ctx.log.warn("[guard-special-command] non-owner attempted /issue-log, rejected", {
80
82
  fromAccount,
81
83
  });
@@ -64,6 +64,7 @@ export function createMockCtx(overrides = {}) {
64
64
  commandAuthorized: false,
65
65
  rewrittenBody: "",
66
66
  hasControlCommand: false,
67
+ commandParts: [],
67
68
  mediaPaths: [],
68
69
  mediaTypes: [],
69
70
  quoteInfo: undefined,
@@ -59,6 +59,8 @@ export interface PipelineContext {
59
59
  commandAuthorized: boolean;
60
60
  rewrittenBody: string;
61
61
  hasControlCommand: boolean;
62
+ /** Parsed command parts: [command, ...args], e.g. ["/new"] or ["/reset", "arg1"]. Empty when not a command. */
63
+ commandParts: string[];
62
64
  effectiveWasMentioned: boolean;
63
65
  mediaPaths: string[];
64
66
  mediaTypes: string[];
@@ -32,10 +32,17 @@ function extractRawText(item) {
32
32
  .join("")
33
33
  .trim();
34
34
  }
35
+ function isOwnerMessage(item) {
36
+ const ownerId = item.account.botOwnerId || item.msg.bot_owner_id;
37
+ return Boolean(ownerId && item.msg.from_account === ownerId);
38
+ }
35
39
  function buildSessionKey(item) {
36
40
  const base = buildBaseSessionKey(item);
37
- // Group chat: single queue
38
41
  if (item.isGroup) {
42
+ const rawText = extractRawText(item);
43
+ if (isAbortRequestText(rawText) && isOwnerMessage(item)) {
44
+ return `${base}:control`;
45
+ }
39
46
  return base;
40
47
  }
41
48
  const rawText = extractRawText(item);
@@ -118,6 +125,7 @@ function buildPipelineContext(primary, items) {
118
125
  commandAuthorized: false,
119
126
  rewrittenBody: "",
120
127
  hasControlCommand: false,
128
+ commandParts: [],
121
129
  effectiveWasMentioned: false,
122
130
  mediaPaths: [],
123
131
  mediaTypes: [],
@@ -167,6 +175,11 @@ export function ensureDebouncer(config) {
167
175
  return;
168
176
  }
169
177
  const sessionKey = buildSessionKey(primary);
178
+ // Group owner /stop: invalidate base queue → skip remaining queued tasks
179
+ if (primary.isGroup && isAbortRequestText(extractRawText(primary)) && isOwnerMessage(primary)) {
180
+ const baseKey = buildBaseSessionKey(primary);
181
+ sessionQueue.invalidate(baseKey);
182
+ }
170
183
  // ⭐ Direct normal message: abort old inference + invalidate queued old tasks
171
184
  if (isDirectNormalMessage(primary)) {
172
185
  const baseKey = buildBaseSessionKey(primary);
@@ -51,6 +51,8 @@ export type ResolvedYuanbaoAccount = {
51
51
  appKey?: string;
52
52
  appSecret?: string;
53
53
  botId?: string;
54
+ /** Cached after WS connect via QueryBotInfoReq; takes priority over per-message bot_owner_id */
55
+ botOwnerId?: string;
54
56
  apiDomain?: string;
55
57
  wsUrl?: string;
56
58
  token?: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-plugin-yuanbao",
3
- "version": "2.13.5",
3
+ "version": "2.14.0",
4
4
  "name": "YuanBao",
5
5
  "description": "YuanBao channel plugin",
6
6
  "channels": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-yuanbao",
3
- "version": "2.13.5",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Tencent YuanBao intelligent bot channel plugin",