@openclaw/qqbot 2026.5.1-beta.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.
Files changed (187) hide show
  1. package/api.ts +56 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/dist/.boundary-tsc.stamp +1 -0
  4. package/dist/.boundary-tsc.tsbuildinfo +1 -0
  5. package/index.ts +29 -0
  6. package/openclaw.plugin.json +814 -0
  7. package/package.json +66 -0
  8. package/runtime-api.ts +9 -0
  9. package/setup-entry.ts +9 -0
  10. package/setup-plugin-api.ts +3 -0
  11. package/skills/qqbot-channel/SKILL.md +262 -0
  12. package/skills/qqbot-channel/references/api_references.md +521 -0
  13. package/skills/qqbot-media/SKILL.md +37 -0
  14. package/skills/qqbot-remind/SKILL.md +153 -0
  15. package/src/bridge/approval/capability.ts +237 -0
  16. package/src/bridge/approval/handler-runtime.ts +204 -0
  17. package/src/bridge/bootstrap.ts +135 -0
  18. package/src/bridge/channel-entry.ts +18 -0
  19. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  20. package/src/bridge/commands/framework-registration.ts +47 -0
  21. package/src/bridge/commands/from-parser.test.ts +86 -0
  22. package/src/bridge/commands/from-parser.ts +60 -0
  23. package/src/bridge/commands/result-dispatcher.ts +76 -0
  24. package/src/bridge/config-shared.ts +132 -0
  25. package/src/bridge/config.ts +111 -0
  26. package/src/bridge/gateway.ts +174 -0
  27. package/src/bridge/logger.ts +31 -0
  28. package/src/bridge/narrowing.ts +31 -0
  29. package/src/bridge/plugin-version.test.ts +146 -0
  30. package/src/bridge/plugin-version.ts +102 -0
  31. package/src/bridge/runtime.ts +25 -0
  32. package/src/bridge/sdk-adapter.ts +131 -0
  33. package/src/bridge/setup/finalize.ts +144 -0
  34. package/src/bridge/setup/surface.ts +34 -0
  35. package/src/bridge/tools/channel.ts +58 -0
  36. package/src/bridge/tools/index.ts +15 -0
  37. package/src/bridge/tools/remind.test.ts +124 -0
  38. package/src/bridge/tools/remind.ts +91 -0
  39. package/src/channel.setup.ts +33 -0
  40. package/src/channel.ts +288 -0
  41. package/src/command-auth.test.ts +62 -0
  42. package/src/config-schema.ts +84 -0
  43. package/src/config.test.ts +364 -0
  44. package/src/engine/access/access-control.test.ts +198 -0
  45. package/src/engine/access/access-control.ts +226 -0
  46. package/src/engine/access/index.ts +16 -0
  47. package/src/engine/access/resolve-policy.test.ts +59 -0
  48. package/src/engine/access/resolve-policy.ts +57 -0
  49. package/src/engine/access/sender-match.test.ts +60 -0
  50. package/src/engine/access/sender-match.ts +55 -0
  51. package/src/engine/access/types.ts +53 -0
  52. package/src/engine/adapter/audio.port.ts +27 -0
  53. package/src/engine/adapter/commands.port.ts +22 -0
  54. package/src/engine/adapter/history.port.ts +52 -0
  55. package/src/engine/adapter/index.ts +139 -0
  56. package/src/engine/adapter/mention-gate.port.ts +50 -0
  57. package/src/engine/adapter/types.ts +38 -0
  58. package/src/engine/api/api-client.ts +212 -0
  59. package/src/engine/api/media-chunked.test.ts +336 -0
  60. package/src/engine/api/media-chunked.ts +622 -0
  61. package/src/engine/api/media.ts +218 -0
  62. package/src/engine/api/messages.ts +293 -0
  63. package/src/engine/api/retry.ts +217 -0
  64. package/src/engine/api/routes.ts +95 -0
  65. package/src/engine/api/token.ts +271 -0
  66. package/src/engine/approval/index.test.ts +22 -0
  67. package/src/engine/approval/index.ts +224 -0
  68. package/src/engine/commands/builtin/log-helpers.ts +319 -0
  69. package/src/engine/commands/builtin/register-all.ts +17 -0
  70. package/src/engine/commands/builtin/register-approve.ts +201 -0
  71. package/src/engine/commands/builtin/register-basic.ts +95 -0
  72. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  73. package/src/engine/commands/builtin/register-logs.ts +20 -0
  74. package/src/engine/commands/builtin/register-streaming.ts +137 -0
  75. package/src/engine/commands/builtin/state.ts +31 -0
  76. package/src/engine/commands/slash-command-auth.ts +48 -0
  77. package/src/engine/commands/slash-command-handler.ts +146 -0
  78. package/src/engine/commands/slash-commands-impl.test.ts +8 -0
  79. package/src/engine/commands/slash-commands-impl.ts +61 -0
  80. package/src/engine/commands/slash-commands.ts +199 -0
  81. package/src/engine/config/credential-backup.test.ts +88 -0
  82. package/src/engine/config/credential-backup.ts +107 -0
  83. package/src/engine/config/credentials.ts +76 -0
  84. package/src/engine/config/group.test.ts +234 -0
  85. package/src/engine/config/group.ts +299 -0
  86. package/src/engine/config/resolve.test.ts +152 -0
  87. package/src/engine/config/resolve.ts +283 -0
  88. package/src/engine/config/setup-logic.ts +84 -0
  89. package/src/engine/engine-import-boundary.test.ts +73 -0
  90. package/src/engine/gateway/codec.ts +47 -0
  91. package/src/engine/gateway/constants.ts +117 -0
  92. package/src/engine/gateway/event-dispatcher.ts +177 -0
  93. package/src/engine/gateway/gateway-connection.ts +371 -0
  94. package/src/engine/gateway/gateway.ts +291 -0
  95. package/src/engine/gateway/inbound-attachments.test.ts +126 -0
  96. package/src/engine/gateway/inbound-attachments.ts +360 -0
  97. package/src/engine/gateway/inbound-context.ts +195 -0
  98. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
  99. package/src/engine/gateway/inbound-pipeline.ts +235 -0
  100. package/src/engine/gateway/interaction-handler.ts +220 -0
  101. package/src/engine/gateway/message-queue.test.ts +282 -0
  102. package/src/engine/gateway/message-queue.ts +499 -0
  103. package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
  104. package/src/engine/gateway/outbound-dispatch.ts +575 -0
  105. package/src/engine/gateway/reconnect.ts +199 -0
  106. package/src/engine/gateway/stages/access-stage.ts +132 -0
  107. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  108. package/src/engine/gateway/stages/content-stage.test.ts +77 -0
  109. package/src/engine/gateway/stages/content-stage.ts +77 -0
  110. package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
  111. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  112. package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
  113. package/src/engine/gateway/stages/index.ts +18 -0
  114. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  115. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  116. package/src/engine/gateway/stages/stub-contexts.ts +116 -0
  117. package/src/engine/gateway/types.ts +264 -0
  118. package/src/engine/gateway/typing-keepalive.ts +79 -0
  119. package/src/engine/group/activation.test.ts +114 -0
  120. package/src/engine/group/activation.ts +147 -0
  121. package/src/engine/group/history.test.ts +314 -0
  122. package/src/engine/group/history.ts +321 -0
  123. package/src/engine/group/mention.test.ts +141 -0
  124. package/src/engine/group/mention.ts +197 -0
  125. package/src/engine/group/message-gating.test.ts +188 -0
  126. package/src/engine/group/message-gating.ts +216 -0
  127. package/src/engine/messaging/decode-media-path.ts +82 -0
  128. package/src/engine/messaging/media-source.ts +215 -0
  129. package/src/engine/messaging/media-type-detect.ts +37 -0
  130. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  131. package/src/engine/messaging/outbound-deliver.ts +810 -0
  132. package/src/engine/messaging/outbound-media-send.ts +702 -0
  133. package/src/engine/messaging/outbound-reply.ts +27 -0
  134. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  135. package/src/engine/messaging/outbound-types.ts +45 -0
  136. package/src/engine/messaging/outbound.ts +485 -0
  137. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  138. package/src/engine/messaging/reply-limiter.ts +164 -0
  139. package/src/engine/messaging/sender.ts +729 -0
  140. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  141. package/src/engine/messaging/streaming-media-send.ts +544 -0
  142. package/src/engine/messaging/target-parser.ts +104 -0
  143. package/src/engine/ref/format-message-ref.ts +142 -0
  144. package/src/engine/ref/format-ref-entry.test.ts +60 -0
  145. package/src/engine/ref/format-ref-entry.ts +27 -0
  146. package/src/engine/ref/store.ts +224 -0
  147. package/src/engine/ref/types.ts +27 -0
  148. package/src/engine/session/known-users.ts +254 -0
  149. package/src/engine/session/session-store.ts +284 -0
  150. package/src/engine/tools/channel-api.ts +244 -0
  151. package/src/engine/tools/remind-logic.test.ts +280 -0
  152. package/src/engine/tools/remind-logic.ts +377 -0
  153. package/src/engine/types.ts +313 -0
  154. package/src/engine/utils/attachment-tags.test.ts +186 -0
  155. package/src/engine/utils/attachment-tags.ts +174 -0
  156. package/src/engine/utils/audio.test.ts +250 -0
  157. package/src/engine/utils/audio.ts +585 -0
  158. package/src/engine/utils/data-paths.ts +38 -0
  159. package/src/engine/utils/diagnostics.ts +109 -0
  160. package/src/engine/utils/file-utils.test.ts +72 -0
  161. package/src/engine/utils/file-utils.ts +225 -0
  162. package/src/engine/utils/format.test.ts +68 -0
  163. package/src/engine/utils/format.ts +70 -0
  164. package/src/engine/utils/image-size.test.ts +158 -0
  165. package/src/engine/utils/image-size.ts +249 -0
  166. package/src/engine/utils/log.test.ts +28 -0
  167. package/src/engine/utils/log.ts +61 -0
  168. package/src/engine/utils/media-tags.test.ts +32 -0
  169. package/src/engine/utils/media-tags.ts +177 -0
  170. package/src/engine/utils/payload.test.ts +68 -0
  171. package/src/engine/utils/payload.ts +145 -0
  172. package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
  173. package/src/engine/utils/platform.test.ts +148 -0
  174. package/src/engine/utils/platform.ts +343 -0
  175. package/src/engine/utils/request-context.ts +60 -0
  176. package/src/engine/utils/string-normalize.ts +91 -0
  177. package/src/engine/utils/stt.test.ts +104 -0
  178. package/src/engine/utils/stt.ts +100 -0
  179. package/src/engine/utils/text-parsing.test.ts +29 -0
  180. package/src/engine/utils/text-parsing.ts +155 -0
  181. package/src/engine/utils/upload-cache.ts +96 -0
  182. package/src/engine/utils/voice-text.ts +15 -0
  183. package/src/exec-approvals.ts +218 -0
  184. package/src/manifest-schema.test.ts +56 -0
  185. package/src/qqbot-test-support.ts +29 -0
  186. package/src/types.ts +210 -0
  187. package/tsconfig.json +16 -0
@@ -0,0 +1,58 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
+ import { getAccessToken } from "../../engine/messaging/sender.js";
3
+ import { ChannelApiSchema, executeChannelApi } from "../../engine/tools/channel-api.js";
4
+ import type { ChannelApiParams } from "../../engine/tools/channel-api.js";
5
+ import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js";
6
+
7
+ /**
8
+ * Register the QQ channel API proxy tool.
9
+ *
10
+ * The tool acts as an authenticated HTTP proxy for the QQ Open Platform
11
+ * channel APIs. Agents learn endpoint details from the skill docs and
12
+ * send requests through this proxy.
13
+ */
14
+ export function registerChannelTool(api: OpenClawPluginApi): void {
15
+ const cfg = api.config;
16
+ if (!cfg) {
17
+ return;
18
+ }
19
+
20
+ const accountIds = listQQBotAccountIds(cfg);
21
+ if (accountIds.length === 0) {
22
+ return;
23
+ }
24
+
25
+ const firstAccountId = accountIds[0];
26
+ const account = resolveQQBotAccount(cfg, firstAccountId);
27
+
28
+ if (!account.appId || !account.clientSecret) {
29
+ return;
30
+ }
31
+
32
+ api.registerTool(
33
+ {
34
+ name: "qqbot_channel_api",
35
+ label: "QQBot Channel API",
36
+ description:
37
+ "Authenticated HTTP proxy for QQ Open Platform channel APIs. " +
38
+ "Common endpoints: " +
39
+ "list guilds GET /users/@me/guilds | " +
40
+ "list channels GET /guilds/{guild_id}/channels | " +
41
+ "get channel GET /channels/{channel_id} | " +
42
+ "create channel POST /guilds/{guild_id}/channels | " +
43
+ "list members GET /guilds/{guild_id}/members?after=0&limit=100 | " +
44
+ "get member GET /guilds/{guild_id}/members/{user_id} | " +
45
+ "list threads GET /channels/{channel_id}/threads | " +
46
+ "create thread PUT /channels/{channel_id}/threads | " +
47
+ "create announce POST /guilds/{guild_id}/announces | " +
48
+ "create schedule POST /channels/{channel_id}/schedules. " +
49
+ "See the qqbot-channel skill for full endpoint details.",
50
+ parameters: ChannelApiSchema,
51
+ async execute(_toolCallId, params) {
52
+ const accessToken = await getAccessToken(account.appId, account.clientSecret);
53
+ return executeChannelApi(params as ChannelApiParams, { accessToken });
54
+ },
55
+ },
56
+ { name: "qqbot_channel_api" },
57
+ );
58
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Aggregate QQBot plugin tool registrations.
3
+ *
4
+ * New tools should be added here rather than in the channel-entry contract
5
+ * file so that the plugin-level `index.ts` stays a pure declaration.
6
+ */
7
+
8
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
9
+ import { registerChannelTool } from "./channel.js";
10
+ import { registerRemindTool } from "./remind.js";
11
+
12
+ export function registerQQBotTools(api: OpenClawPluginApi): void {
13
+ registerChannelTool(api);
14
+ registerRemindTool(api);
15
+ }
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { callGatewayToolMock } = vi.hoisted(() => ({
4
+ callGatewayToolMock: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", () => ({
8
+ callGatewayTool: callGatewayToolMock,
9
+ }));
10
+
11
+ import { createRemindTool } from "./remind.js";
12
+
13
+ describe("bridge/tools/remind", () => {
14
+ beforeEach(() => {
15
+ callGatewayToolMock.mockReset();
16
+ callGatewayToolMock.mockResolvedValue({ ok: true });
17
+ });
18
+
19
+ it("marks qqbot_remind as owner-only", () => {
20
+ const tool = createRemindTool();
21
+ expect(tool.ownerOnly).toBe(true);
22
+ });
23
+
24
+ it("schedules reminders directly through Gateway cron with ambient QQ delivery context", async () => {
25
+ callGatewayToolMock.mockResolvedValue({ id: "job-1" });
26
+ const tool = createRemindTool({
27
+ senderIsOwner: true,
28
+ deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
29
+ });
30
+
31
+ const result = await tool.execute("tool-call-1", {
32
+ action: "add",
33
+ content: "drink water",
34
+ time: "5m",
35
+ });
36
+
37
+ expect(callGatewayToolMock).toHaveBeenCalledWith(
38
+ "cron.add",
39
+ { timeoutMs: 60_000 },
40
+ {
41
+ job: expect.objectContaining({
42
+ sessionTarget: "isolated",
43
+ payload: { kind: "agentTurn", message: expect.stringContaining("drink water") },
44
+ delivery: {
45
+ mode: "announce",
46
+ channel: "qqbot",
47
+ to: "qqbot:c2c:user-openid",
48
+ accountId: "bot2",
49
+ },
50
+ }),
51
+ },
52
+ );
53
+ expect(result.details).toEqual({
54
+ ok: true,
55
+ action: "add",
56
+ summary: '⏰ Reminder in 5m: "drink water"',
57
+ cronResult: { id: "job-1" },
58
+ });
59
+ });
60
+
61
+ it("routes list and remove through Gateway cron without exposing generic cron to the model", async () => {
62
+ const tool = createRemindTool({ senderIsOwner: true });
63
+
64
+ await tool.execute("tool-call-1", { action: "list" });
65
+ await tool.execute("tool-call-2", { action: "remove", jobId: "job-1" });
66
+
67
+ expect(callGatewayToolMock).toHaveBeenNthCalledWith(1, "cron.list", { timeoutMs: 60_000 }, {});
68
+ expect(callGatewayToolMock).toHaveBeenNthCalledWith(
69
+ 2,
70
+ "cron.remove",
71
+ { timeoutMs: 60_000 },
72
+ { jobId: "job-1" },
73
+ );
74
+ });
75
+
76
+ it("supports injected cron scheduler dependencies for engine-level tests", async () => {
77
+ const callCron = vi.fn(async () => ({ id: "job-1" }));
78
+ const tool = createRemindTool(
79
+ {
80
+ senderIsOwner: true,
81
+ deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
82
+ },
83
+ { callCron },
84
+ );
85
+
86
+ await tool.execute("tool-call-1", {
87
+ action: "add",
88
+ content: "drink water",
89
+ time: "5m",
90
+ });
91
+
92
+ expect(callCron).toHaveBeenCalledWith(
93
+ expect.objectContaining({
94
+ action: "add",
95
+ job: expect.objectContaining({
96
+ delivery: expect.objectContaining({ to: "qqbot:c2c:user-openid", accountId: "bot2" }),
97
+ }),
98
+ }),
99
+ );
100
+ expect(callGatewayToolMock).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it("does not schedule when sender ownership is missing", async () => {
104
+ const callCron = vi.fn(async () => ({ id: "job-1" }));
105
+ const tool = createRemindTool(
106
+ {
107
+ deliveryContext: { to: "qqbot:c2c:user-openid", accountId: "bot2" },
108
+ },
109
+ { callCron },
110
+ );
111
+
112
+ const result = await tool.execute("tool-call-1", {
113
+ action: "add",
114
+ content: "drink water",
115
+ time: "5m",
116
+ });
117
+
118
+ expect(callCron).not.toHaveBeenCalled();
119
+ expect(callGatewayToolMock).not.toHaveBeenCalled();
120
+ expect(result.details).toEqual({
121
+ error: "QQ reminders require an owner-authorized sender.",
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,91 @@
1
+ import { callGatewayTool } from "openclaw/plugin-sdk/agent-harness-runtime";
2
+ import type {
3
+ AnyAgentTool,
4
+ OpenClawPluginApi,
5
+ OpenClawPluginToolContext,
6
+ } from "openclaw/plugin-sdk/core";
7
+ import { RemindSchema, executeScheduledRemind } from "../../engine/tools/remind-logic.js";
8
+ import type { RemindCronAction, RemindParams } from "../../engine/tools/remind-logic.js";
9
+ import { getRequestContext } from "../../engine/utils/request-context.js";
10
+
11
+ type CronGatewayCaller = (params: RemindCronAction) => Promise<unknown>;
12
+
13
+ type RemindToolDeps = {
14
+ callCron: CronGatewayCaller;
15
+ };
16
+
17
+ const DEFAULT_GATEWAY_TIMEOUT_MS = 60_000;
18
+
19
+ function unexpectedCronParams(params: never): never {
20
+ throw new Error(`Unsupported reminder cron action: ${JSON.stringify(params)}`);
21
+ }
22
+
23
+ const defaultDeps: RemindToolDeps = {
24
+ callCron: async (params) => {
25
+ switch (params.action) {
26
+ case "list":
27
+ return await callGatewayTool("cron.list", { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, {});
28
+ case "remove":
29
+ return await callGatewayTool(
30
+ "cron.remove",
31
+ { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS },
32
+ { jobId: params.jobId },
33
+ );
34
+ case "add":
35
+ return await callGatewayTool(
36
+ "cron.add",
37
+ { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS },
38
+ { job: params.job },
39
+ );
40
+ }
41
+ return unexpectedCronParams(params);
42
+ },
43
+ };
44
+
45
+ export function createRemindTool(
46
+ toolContext: OpenClawPluginToolContext = {},
47
+ deps: RemindToolDeps = defaultDeps,
48
+ ): AnyAgentTool {
49
+ return {
50
+ name: "qqbot_remind",
51
+ label: "QQBot Reminder",
52
+ ownerOnly: true,
53
+ description:
54
+ "Create, list, and remove QQ reminders. " +
55
+ "This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" +
56
+ "Create: action=add, content=message, time=schedule (to is optional, " +
57
+ "resolved automatically from the current conversation)\n" +
58
+ "List: action=list\n" +
59
+ "Remove: action=remove, jobId=job id from list\n" +
60
+ 'Time examples: "5m", "1h", "0 8 * * *"',
61
+ parameters: RemindSchema,
62
+ async execute(_toolCallId, params) {
63
+ if (toolContext.senderIsOwner !== true) {
64
+ return {
65
+ content: [
66
+ {
67
+ type: "text" as const,
68
+ text: JSON.stringify({
69
+ error: "QQ reminders require an owner-authorized sender.",
70
+ }),
71
+ },
72
+ ],
73
+ details: { error: "QQ reminders require an owner-authorized sender." },
74
+ };
75
+ }
76
+ const ctx = getRequestContext();
77
+ return await executeScheduledRemind(
78
+ params as RemindParams,
79
+ {
80
+ fallbackTo: ctx?.target ?? toolContext.deliveryContext?.to,
81
+ fallbackAccountId: ctx?.accountId ?? toolContext.deliveryContext?.accountId,
82
+ },
83
+ deps.callCron,
84
+ );
85
+ },
86
+ };
87
+ }
88
+
89
+ export function registerRemindTool(api: OpenClawPluginApi): void {
90
+ api.registerTool((ctx) => createRemindTool(ctx), { name: "qqbot_remind" });
91
+ }
@@ -0,0 +1,33 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import "./bridge/bootstrap.js";
3
+ import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
4
+ import { qqbotSetupWizard } from "./bridge/setup/surface.js";
5
+ import { qqbotChannelConfigSchema } from "./config-schema.js";
6
+ import type { ResolvedQQBotAccount } from "./types.js";
7
+
8
+ /**
9
+ * Setup-only QQBot plugin — lightweight subset used during `openclaw onboard`
10
+ * and `openclaw configure` without pulling the full runtime dependencies.
11
+ */
12
+ export const qqbotSetupPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
13
+ id: "qqbot",
14
+ setupWizard: qqbotSetupWizard,
15
+ meta: {
16
+ ...qqbotMeta,
17
+ },
18
+ capabilities: {
19
+ chatTypes: ["direct", "group"],
20
+ media: true,
21
+ reactions: false,
22
+ threads: false,
23
+ blockStreaming: true,
24
+ },
25
+ reload: { configPrefixes: ["channels.qqbot"] },
26
+ configSchema: qqbotChannelConfigSchema,
27
+ config: {
28
+ ...qqbotConfigAdapter,
29
+ },
30
+ setup: {
31
+ ...qqbotSetupAdapterShared,
32
+ },
33
+ };
package/src/channel.ts ADDED
@@ -0,0 +1,288 @@
1
+ import { getExecApprovalReplyMetadata } from "openclaw/plugin-sdk/approval-runtime";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
3
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
4
+ // Register the PlatformAdapter before any core/ module is used.
5
+ import "./bridge/bootstrap.js";
6
+ import { getQQBotApprovalCapability } from "./bridge/approval/capability.js";
7
+ import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
8
+ import {
9
+ applyQQBotAccountConfig,
10
+ DEFAULT_ACCOUNT_ID,
11
+ resolveQQBotAccount,
12
+ } from "./bridge/config.js";
13
+ import type { GatewayContext } from "./bridge/gateway.js";
14
+ import { toGatewayAccount, writeOpenClawConfigThroughRuntime } from "./bridge/narrowing.js";
15
+ import { getQQBotRuntime } from "./bridge/runtime.js";
16
+ import { qqbotSetupWizard } from "./bridge/setup/surface.js";
17
+ import { qqbotChannelConfigSchema } from "./config-schema.js";
18
+ import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
19
+ import { clearAccountCredentials } from "./engine/config/credentials.js";
20
+ import {
21
+ normalizeTarget as coreNormalizeTarget,
22
+ looksLikeQQBotTarget,
23
+ } from "./engine/messaging/target-parser.js";
24
+ import type { ResolvedQQBotAccount } from "./types.js";
25
+
26
+ // Shared promise so concurrent multi-account startups serialize the dynamic
27
+ // import of the gateway module, avoiding an ESM circular-dependency race.
28
+ let _gatewayModulePromise: Promise<typeof import("./bridge/gateway.js")> | undefined;
29
+ function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
30
+ _gatewayModulePromise ??= import("./bridge/gateway.js");
31
+ return _gatewayModulePromise;
32
+ }
33
+
34
+ const EXEC_APPROVAL_COMMAND_RE =
35
+ /\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i;
36
+
37
+ function persistAccountCredentialSnapshot(account: ResolvedQQBotAccount): void {
38
+ if (account.appId && account.clientSecret) {
39
+ saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
40
+ }
41
+ }
42
+
43
+ function shouldSuppressLocalQQBotApprovalPrompt(params: {
44
+ cfg: OpenClawConfig;
45
+ accountId?: string | null;
46
+ payload: { text?: string; channelData?: unknown };
47
+ hint?: { kind: "approval-pending" | "approval-resolved"; approvalKind: "exec" | "plugin" };
48
+ }): boolean {
49
+ if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") {
50
+ return false;
51
+ }
52
+ const account = resolveQQBotAccount(params.cfg, params.accountId);
53
+ if (!account.enabled || account.secretSource === "none") {
54
+ return false;
55
+ }
56
+ if (getExecApprovalReplyMetadata(params.payload as never)) {
57
+ return true;
58
+ }
59
+ const text = typeof params.payload.text === "string" ? params.payload.text : "";
60
+ return EXEC_APPROVAL_COMMAND_RE.test(text);
61
+ }
62
+
63
+ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
64
+ id: "qqbot",
65
+ setupWizard: qqbotSetupWizard,
66
+ meta: {
67
+ ...qqbotMeta,
68
+ },
69
+ capabilities: {
70
+ chatTypes: ["direct", "group"],
71
+ media: true,
72
+ reactions: false,
73
+ threads: false,
74
+ blockStreaming: true,
75
+ },
76
+ reload: { configPrefixes: ["channels.qqbot"] },
77
+ configSchema: qqbotChannelConfigSchema,
78
+ config: {
79
+ ...qqbotConfigAdapter,
80
+ /**
81
+ * Treat an account as configured when either the live config has
82
+ * credentials OR a recoverable credential backup exists. This mirrors
83
+ * the standalone plugin and lets the gateway survive a hot upgrade
84
+ * that wiped openclaw.json mid-flight.
85
+ */
86
+ isConfigured: (account: ResolvedQQBotAccount | undefined) => {
87
+ if (qqbotConfigAdapter.isConfigured(account)) {
88
+ return true;
89
+ }
90
+ if (!account) {
91
+ return false;
92
+ }
93
+ const backup = loadCredentialBackup(account.accountId);
94
+ return Boolean(backup?.appId && backup?.clientSecret);
95
+ },
96
+ },
97
+ setup: {
98
+ ...qqbotSetupAdapterShared,
99
+ },
100
+ approvalCapability: getQQBotApprovalCapability(),
101
+ messaging: {
102
+ targetPrefixes: ["qqbot"],
103
+ /** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
104
+ normalizeTarget: coreNormalizeTarget,
105
+ targetResolver: {
106
+ /** Return true when the id looks like a QQ Bot target. */
107
+ looksLikeId: looksLikeQQBotTarget,
108
+ hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)",
109
+ },
110
+ },
111
+ outbound: {
112
+ deliveryMode: "direct",
113
+ chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
114
+ chunkerMode: "markdown",
115
+ textChunkLimit: 5000,
116
+ shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
117
+ shouldSuppressLocalQQBotApprovalPrompt({
118
+ cfg,
119
+ accountId,
120
+ payload,
121
+ hint,
122
+ }),
123
+ sendText: async ({ to, text, accountId, replyToId, cfg }) => {
124
+ // Ensure bridge/gateway.ts module-level registrations (audio adapter factory,
125
+ // platform adapter, etc.) have executed before engine code runs.
126
+ await loadGatewayModule();
127
+ const account = resolveQQBotAccount(cfg, accountId);
128
+ const { sendText } = await import("./engine/messaging/outbound.js");
129
+ const result = await sendText({
130
+ to,
131
+ text,
132
+ accountId,
133
+ replyToId,
134
+ account: toGatewayAccount(account),
135
+ });
136
+ return {
137
+ channel: "qqbot" as const,
138
+ messageId: result.messageId ?? "",
139
+ meta: result.error ? { error: result.error } : undefined,
140
+ };
141
+ },
142
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) => {
143
+ // Same guard as sendText — ensure adapters are registered.
144
+ await loadGatewayModule();
145
+ const account = resolveQQBotAccount(cfg, accountId);
146
+ const { sendMedia } = await import("./engine/messaging/outbound.js");
147
+ const result = await sendMedia({
148
+ to,
149
+ text: text ?? "",
150
+ mediaUrl: mediaUrl ?? "",
151
+ accountId,
152
+ replyToId,
153
+ account: toGatewayAccount(account),
154
+ });
155
+ return {
156
+ channel: "qqbot" as const,
157
+ messageId: result.messageId ?? "",
158
+ meta: result.error ? { error: result.error } : undefined,
159
+ };
160
+ },
161
+ },
162
+ gateway: {
163
+ startAccount: async (ctx) => {
164
+ let { account, cfg } = ctx;
165
+ const { abortSignal, log } = ctx;
166
+
167
+ // Recover credentials from the per-account backup if the live
168
+ // config is missing appId/secret (e.g. a hot-upgrade wiped
169
+ // openclaw.json). We only restore when both fields are empty so a
170
+ // user's intentional clear isn't silently undone.
171
+ if (!account.appId || !account.clientSecret) {
172
+ const backup = loadCredentialBackup(account.accountId);
173
+ if (backup?.appId && backup?.clientSecret) {
174
+ try {
175
+ const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, {
176
+ appId: backup.appId,
177
+ clientSecret: backup.clientSecret,
178
+ });
179
+ await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg);
180
+ cfg = nextCfg;
181
+ account = resolveQQBotAccount(nextCfg, account.accountId);
182
+ log?.info(
183
+ `[qqbot:${account.accountId}] Restored credentials from backup (appId=${account.appId})`,
184
+ );
185
+ } catch (err) {
186
+ log?.error(
187
+ `[qqbot:${account.accountId}] Failed to restore credentials from backup: ${err instanceof Error ? err.message : String(err)}`,
188
+ );
189
+ }
190
+ }
191
+ }
192
+
193
+ // Serialize the dynamic import so concurrent multi-account startups
194
+ // do not hit an ESM circular-dependency race where the gateway chunk's
195
+ // transitive imports have not finished evaluating yet.
196
+ const { startGateway } = await loadGatewayModule();
197
+
198
+ log?.info(
199
+ `[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`,
200
+ );
201
+
202
+ await startGateway({
203
+ account,
204
+ abortSignal,
205
+ cfg,
206
+ log,
207
+ channelRuntime: ctx.channelRuntime as GatewayContext["channelRuntime"],
208
+ onReady: () => {
209
+ log?.info(`[qqbot:${account.accountId}] Gateway ready`);
210
+ ctx.setStatus({
211
+ ...ctx.getStatus(),
212
+ running: true,
213
+ connected: true,
214
+ lastConnectedAt: Date.now(),
215
+ });
216
+ // Snapshot credentials so we can recover from the next hot
217
+ // upgrade that might wipe openclaw.json mid-flight.
218
+ persistAccountCredentialSnapshot(account);
219
+ },
220
+ onResumed: () => {
221
+ log?.info(`[qqbot:${account.accountId}] Gateway resumed`);
222
+ ctx.setStatus({
223
+ ...ctx.getStatus(),
224
+ running: true,
225
+ connected: true,
226
+ lastConnectedAt: Date.now(),
227
+ });
228
+ persistAccountCredentialSnapshot(account);
229
+ },
230
+ onError: (error) => {
231
+ log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
232
+ ctx.setStatus({
233
+ ...ctx.getStatus(),
234
+ lastError: error.message,
235
+ });
236
+ },
237
+ });
238
+ },
239
+ logoutAccount: async ({ accountId, cfg }) => {
240
+ const { nextCfg, cleared, changed } = clearAccountCredentials(
241
+ cfg as unknown as Record<string, unknown>,
242
+ accountId,
243
+ );
244
+
245
+ if (changed) {
246
+ await writeOpenClawConfigThroughRuntime(getQQBotRuntime(), nextCfg as OpenClawConfig);
247
+ }
248
+
249
+ const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as OpenClawConfig, accountId);
250
+ const loggedOut = resolved.secretSource === "none";
251
+ const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
252
+
253
+ return { ok: true, cleared, envToken, loggedOut };
254
+ },
255
+ },
256
+ status: {
257
+ defaultRuntime: {
258
+ accountId: DEFAULT_ACCOUNT_ID,
259
+ running: false,
260
+ connected: false,
261
+ lastConnectedAt: null,
262
+ lastError: null,
263
+ lastInboundAt: null,
264
+ lastOutboundAt: null,
265
+ },
266
+ buildChannelSummary: ({ snapshot }) => ({
267
+ configured: snapshot.configured ?? false,
268
+ tokenSource: snapshot.tokenSource ?? "none",
269
+ running: snapshot.running ?? false,
270
+ connected: snapshot.connected ?? false,
271
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
272
+ lastError: snapshot.lastError ?? null,
273
+ }),
274
+ buildAccountSnapshot: ({ account, runtime }) => ({
275
+ accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
276
+ name: account?.name,
277
+ enabled: account?.enabled ?? false,
278
+ configured: Boolean(account?.appId && account?.clientSecret),
279
+ tokenSource: account?.secretSource,
280
+ running: runtime?.running ?? false,
281
+ connected: runtime?.connected ?? false,
282
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
283
+ lastError: runtime?.lastError ?? null,
284
+ lastInboundAt: runtime?.lastInboundAt ?? null,
285
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
286
+ }),
287
+ },
288
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Regression tests for QQBot command authorization alignment with the shared
3
+ * command-auth model.
4
+ *
5
+ * Covers the regression identified in the code review:
6
+ *
7
+ * allowFrom entries with the qqbot: prefix must normalize correctly so that
8
+ * "qqbot:<id>" in channel.allowFrom matches the inbound event.senderId "<id>".
9
+ * Verified against the normalization logic in the gateway.ts inbound path.
10
+ *
11
+ * Note: commands.allowFrom.qqbot precedence over channel allowFrom is enforced
12
+ * by the framework's resolveCommandAuthorization(). QQBot routes requireAuth:true
13
+ * commands through the framework (api.registerCommand), so that behavior is
14
+ * covered by the framework's own tests rather than duplicated here.
15
+ */
16
+
17
+ import { describe, expect, it } from "vitest";
18
+ import { qqbotPlugin } from "./channel.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // qqbot: prefix normalization for inbound commandAuthorized
22
+ //
23
+ // Uses qqbotPlugin.config.formatAllowFrom directly — the same function the
24
+ // fixed gateway.ts inbound path calls — so the test stays in sync with the
25
+ // actual implementation without duplicating the logic.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("qqbot: prefix normalization for inbound commandAuthorized", () => {
29
+ const formatAllowFrom = qqbotPlugin.config.formatAllowFrom!;
30
+
31
+ /** Mirrors the fixed gateway.ts inbound commandAuthorized computation. */
32
+ function resolveInboundCommandAuthorized(rawAllowFrom: string[], senderId: string): boolean {
33
+ const normalizedAllowFrom = formatAllowFrom({
34
+ cfg: {} as never,
35
+ accountId: null,
36
+ allowFrom: rawAllowFrom,
37
+ });
38
+ const normalizedSenderId = senderId.replace(/^qqbot:/i, "").toUpperCase();
39
+ const allowAll = normalizedAllowFrom.length === 0 || normalizedAllowFrom.some((e) => e === "*");
40
+ return allowAll || normalizedAllowFrom.includes(normalizedSenderId);
41
+ }
42
+
43
+ it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", () => {
44
+ expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).toBe(true);
45
+ });
46
+
47
+ it("authorizes when qqbot: prefix is mixed case", () => {
48
+ expect(resolveInboundCommandAuthorized(["QQBot:user123"], "USER123")).toBe(true);
49
+ });
50
+
51
+ it("denies a sender not in the qqbot:-prefixed allowFrom list", () => {
52
+ expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "OTHER")).toBe(false);
53
+ });
54
+
55
+ it("authorizes any sender when allowFrom is empty (open)", () => {
56
+ expect(resolveInboundCommandAuthorized([], "ANYONE")).toBe(true);
57
+ });
58
+
59
+ it("authorizes any sender when allowFrom contains wildcard *", () => {
60
+ expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).toBe(true);
61
+ });
62
+ });