@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,48 @@
1
+ /**
2
+ * Pre-dispatch authorization for requireAuth slash commands.
3
+ *
4
+ * Unlike the access-stage's `resolveCommandAuthorized` (which permits
5
+ * `dm_policy_open` senders — i.e. anyone), this function requires the
6
+ * sender to appear in an **explicit non-wildcard** allowFrom list.
7
+ *
8
+ * Rationale: sensitive operations (log export, file deletion, approval
9
+ * config changes) must be gated behind a deliberate operator decision.
10
+ * A wide-open DM policy means "anyone can chat", not "anyone can run
11
+ * admin commands".
12
+ */
13
+
14
+ import { createQQBotSenderMatcher, normalizeQQBotAllowFrom } from "../access/index.js";
15
+
16
+ /**
17
+ * Determine whether `senderId` is authorized to execute `requireAuth`
18
+ * slash commands for the given account configuration.
19
+ *
20
+ * Authorization rules:
21
+ * - `allowFrom` not configured / empty / only `["*"]` → **false**
22
+ * (wildcard means "open to everyone", not explicit authorization)
23
+ * - `allowFrom` contains at least one concrete entry AND sender
24
+ * matches → **true**
25
+ * - Group messages use `groupAllowFrom` when present, falling back
26
+ * to `allowFrom`.
27
+ */
28
+ export function resolveSlashCommandAuth(params: {
29
+ senderId: string;
30
+ isGroup: boolean;
31
+ allowFrom?: Array<string | number>;
32
+ groupAllowFrom?: Array<string | number>;
33
+ }): boolean {
34
+ const rawList =
35
+ params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
36
+ ? params.groupAllowFrom
37
+ : params.allowFrom;
38
+
39
+ const normalized = normalizeQQBotAllowFrom(rawList);
40
+
41
+ // Require at least one explicit (non-wildcard) entry.
42
+ const hasExplicitEntry = normalized.some((entry) => entry !== "*");
43
+ if (!hasExplicitEntry) {
44
+ return false;
45
+ }
46
+
47
+ return createQQBotSenderMatcher(params.senderId)(normalized);
48
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Slash command handler — intercept slash commands before message queue.
3
+ *
4
+ * Extracted from gateway.ts to keep the gateway connection logic thin.
5
+ * Handles urgent commands, normal slash commands, and file delivery.
6
+ */
7
+
8
+ import type { QueuedMessage } from "../gateway/message-queue.js";
9
+ import type { GatewayAccount, EngineLogger } from "../gateway/types.js";
10
+ import { sendDocument } from "../messaging/outbound.js";
11
+ import {
12
+ sendText as senderSendText,
13
+ buildDeliveryTarget,
14
+ accountToCreds,
15
+ } from "../messaging/sender.js";
16
+ import { resolveSlashCommandAuth } from "./slash-command-auth.js";
17
+ import { matchSlashCommand } from "./slash-commands-impl.js";
18
+ import type { SlashCommandContext, QueueSnapshot } from "./slash-commands.js";
19
+
20
+ // ============ Types ============
21
+
22
+ export interface SlashCommandHandlerContext {
23
+ account: GatewayAccount;
24
+ log?: EngineLogger;
25
+ getMessagePeerId: (msg: QueuedMessage) => string;
26
+ getQueueSnapshot: (peerId: string) => QueueSnapshot;
27
+ }
28
+
29
+ // ============ Constants ============
30
+
31
+ const URGENT_COMMANDS = ["/stop"];
32
+
33
+ // ============ trySlashCommandOrEnqueue ============
34
+
35
+ /**
36
+ * Check if the message is a slash command and handle it.
37
+ *
38
+ * @returns `true` if handled (command executed or enqueued as urgent),
39
+ * `false` if the message should be queued for normal processing.
40
+ */
41
+ export async function trySlashCommand(
42
+ msg: QueuedMessage,
43
+ ctx: SlashCommandHandlerContext,
44
+ ): Promise<"handled" | "urgent" | "enqueue"> {
45
+ const { account, log } = ctx;
46
+ const content = (msg.content ?? "").trim();
47
+
48
+ if (!content.startsWith("/")) {
49
+ return "enqueue";
50
+ }
51
+
52
+ // Urgent command detection — bypass queue and execute immediately.
53
+ const contentLower = content.toLowerCase();
54
+ const isUrgentCommand = URGENT_COMMANDS.some(
55
+ (cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
56
+ );
57
+ if (isUrgentCommand) {
58
+ log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
59
+ return "urgent";
60
+ }
61
+
62
+ // Normal slash command — try to match and execute.
63
+ const receivedAt = Date.now();
64
+ const peerId = ctx.getMessagePeerId(msg);
65
+ const cmdCtx: SlashCommandContext = {
66
+ type: msg.type,
67
+ senderId: msg.senderId,
68
+ senderName: msg.senderName,
69
+ messageId: msg.messageId,
70
+ eventTimestamp: msg.timestamp,
71
+ receivedAt,
72
+ rawContent: content,
73
+ args: "",
74
+ channelId: msg.channelId,
75
+ groupOpenid: msg.groupOpenid,
76
+ accountId: account.accountId,
77
+ appId: account.appId,
78
+ accountConfig: account.config,
79
+ commandAuthorized: resolveSlashCommandAuth({
80
+ senderId: msg.senderId,
81
+ isGroup: msg.type === "group" || msg.type === "guild",
82
+ allowFrom: account.config?.allowFrom,
83
+ groupAllowFrom: account.config?.groupAllowFrom,
84
+ }),
85
+ queueSnapshot: ctx.getQueueSnapshot(peerId),
86
+ };
87
+
88
+ try {
89
+ const reply = await matchSlashCommand(cmdCtx);
90
+ if (reply === null) {
91
+ return "enqueue";
92
+ }
93
+
94
+ log?.debug?.(`Slash command matched: ${content}`);
95
+
96
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
97
+ const replyText = isFileResult ? (reply as { text: string }).text : reply;
98
+ const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null;
99
+
100
+ // Send text reply.
101
+ if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") {
102
+ const slashTarget = buildDeliveryTarget(msg);
103
+ const slashCreds = accountToCreds(account);
104
+ await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId });
105
+ }
106
+
107
+ // Send file attachment if present.
108
+ if (replyFile) {
109
+ try {
110
+ const targetType =
111
+ msg.type === "group"
112
+ ? "group"
113
+ : msg.type === "dm"
114
+ ? "dm"
115
+ : msg.type === "c2c"
116
+ ? "c2c"
117
+ : "channel";
118
+ const targetId =
119
+ msg.type === "group"
120
+ ? msg.groupOpenid || msg.senderId
121
+ : msg.type === "dm"
122
+ ? msg.guildId || msg.senderId
123
+ : msg.type === "c2c"
124
+ ? msg.senderId
125
+ : msg.channelId || msg.senderId;
126
+ await sendDocument(
127
+ {
128
+ targetType,
129
+ targetId,
130
+ account,
131
+ replyToId: msg.messageId,
132
+ },
133
+ replyFile,
134
+ { allowQQBotDataDownloads: true },
135
+ );
136
+ } catch (fileErr) {
137
+ log?.error(`Failed to send slash command file: ${String(fileErr)}`);
138
+ }
139
+ }
140
+
141
+ return "handled";
142
+ } catch (err) {
143
+ log?.error(`Slash command error: ${String(err)}`);
144
+ return "enqueue";
145
+ }
146
+ }
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getFrameworkCommands } from "./slash-commands-impl.js";
3
+
4
+ describe("QQBot framework slash commands", () => {
5
+ it("routes bot-approve through the auth-gated framework registry", () => {
6
+ expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-approve");
7
+ });
8
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * QQBot plugin-level slash command handler.
3
+ *
4
+ * Type definitions and the command registry/dispatcher are in
5
+ * `./slash-commands.ts`. Built-in command bodies live under `./builtin/`.
6
+ */
7
+
8
+ import type { CommandsPort } from "../adapter/commands.port.js";
9
+ import { debugLog } from "../utils/log.js";
10
+ import { registerBuiltinSlashCommands } from "./builtin/register-all.js";
11
+ import {
12
+ getFrameworkVersionString,
13
+ getPluginVersionString,
14
+ initSlashCommandDeps,
15
+ } from "./builtin/state.js";
16
+ import {
17
+ SlashCommandRegistry,
18
+ type SlashCommandContext,
19
+ type SlashCommandResult,
20
+ type QQBotFrameworkCommand,
21
+ } from "./slash-commands.js";
22
+
23
+ const registry = new SlashCommandRegistry();
24
+ registerBuiltinSlashCommands(registry);
25
+
26
+ /**
27
+ * Initialize command dependencies from the EngineAdapters.commands port.
28
+ * Called once by the bridge layer during startup.
29
+ */
30
+ export function initCommands(port: CommandsPort): void {
31
+ initSlashCommandDeps(port);
32
+ }
33
+
34
+ /**
35
+ * Return all commands that require authorization, for registration with the
36
+ * framework via api.registerCommand() in registerFull().
37
+ */
38
+ export function getFrameworkCommands(): QQBotFrameworkCommand[] {
39
+ return registry.getFrameworkCommands();
40
+ }
41
+
42
+ // Slash command entry point — delegates to core/ registry.
43
+
44
+ /**
45
+ * Try to match and execute a plugin-level slash command.
46
+ *
47
+ * @returns A reply when matched, or null when the message should continue through normal routing.
48
+ */
49
+ export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
50
+ return registry.matchSlashCommand(ctx, { info: debugLog });
51
+ }
52
+
53
+ /** Return the plugin version for external callers. */
54
+ export function getPluginVersion(): string {
55
+ return getPluginVersionString();
56
+ }
57
+
58
+ /** Return the framework version for external callers. */
59
+ export function getFrameworkVersion(): string {
60
+ return getFrameworkVersionString();
61
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Slash command registration and dispatch framework.
3
+ *
4
+ * This module provides the type definitions, command registry, and
5
+ * `matchSlashCommand` dispatcher that both plugin versions share.
6
+ *
7
+ * Concrete command implementations (e.g. `/bot-ping`, `/bot-logs`) are
8
+ * registered by the upper-layer bootstrap code, NOT defined here.
9
+ *
10
+ * Zero external dependencies.
11
+ */
12
+
13
+ // ============ Types ============
14
+
15
+ /** Slash command context (message metadata plus runtime state). */
16
+ export interface SlashCommandContext {
17
+ /** Message type. */
18
+ type: "c2c" | "guild" | "dm" | "group";
19
+ /** Sender ID. */
20
+ senderId: string;
21
+ /** Sender display name. */
22
+ senderName?: string;
23
+ /** Message ID used for passive replies. */
24
+ messageId: string;
25
+ /** Event timestamp from QQ as an ISO string. */
26
+ eventTimestamp: string;
27
+ /** Local receipt timestamp in milliseconds. */
28
+ receivedAt: number;
29
+ /** Raw message content. */
30
+ rawContent: string;
31
+ /** Command arguments after stripping the command name. */
32
+ args: string;
33
+ /** Channel ID for guild messages. */
34
+ channelId?: string;
35
+ /** Group openid for group messages. */
36
+ groupOpenid?: string;
37
+ /** Account ID. */
38
+ accountId: string;
39
+ /** Bot App ID. */
40
+ appId: string;
41
+ /** Account config available to the command handler. */
42
+ accountConfig?: Record<string, unknown>;
43
+ /** Whether the sender is authorized per the allowFrom config. */
44
+ commandAuthorized: boolean;
45
+ /** Queue snapshot for the current sender. */
46
+ queueSnapshot: QueueSnapshot;
47
+ }
48
+
49
+ /** Queue status snapshot. */
50
+ export interface QueueSnapshot {
51
+ totalPending: number;
52
+ activeUsers: number;
53
+ maxConcurrentUsers: number;
54
+ senderPending: number;
55
+ }
56
+
57
+ /** Slash command result: text, a text+file result, or null to skip handling. */
58
+ export type SlashCommandResult = string | SlashCommandFileResult | null;
59
+
60
+ /** Slash command result that sends text first and then a local file. */
61
+ interface SlashCommandFileResult {
62
+ text: string;
63
+ /** Local file path to send. */
64
+ filePath: string;
65
+ }
66
+
67
+ /** Slash command definition. */
68
+ interface SlashCommand {
69
+ /** Command name without the leading slash. */
70
+ name: string;
71
+ /** Short description. */
72
+ description: string;
73
+ /** Detailed usage text shown by `/command ?`. */
74
+ usage?: string;
75
+ /** When true, the command requires the sender to pass the allowFrom authorization check. */
76
+ requireAuth?: boolean;
77
+ /** When true, the command is only available in c2c (private) chat. Group invocations are rejected automatically. */
78
+ c2cOnly?: boolean;
79
+ /** Command handler. */
80
+ handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
81
+ }
82
+
83
+ /** Framework command definition for commands that require authorization. */
84
+ export interface QQBotFrameworkCommand {
85
+ name: string;
86
+ description: string;
87
+ usage?: string;
88
+ handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
89
+ }
90
+
91
+ // ============ Command Registry ============
92
+
93
+ /** Lowercase and trim a string. */
94
+ function lc(s: string): string {
95
+ return (s ?? "").toLowerCase().trim();
96
+ }
97
+
98
+ /**
99
+ * Slash command registry.
100
+ *
101
+ * Maintains two maps:
102
+ * - `commands` — pre-dispatch commands (requireAuth: false)
103
+ * - `frameworkCommands` — auth-gated commands (requireAuth: true)
104
+ */
105
+ export class SlashCommandRegistry {
106
+ private readonly commands = new Map<string, SlashCommand>();
107
+ private readonly frameworkCommands = new Map<string, SlashCommand>();
108
+
109
+ /** Register one command. */
110
+ register(cmd: SlashCommand): void {
111
+ const key = lc(cmd.name);
112
+ // Always register in the pre-dispatch map so QQ message-flow slash
113
+ // commands can match and execute directly (with requireAuth gating).
114
+ this.commands.set(key, cmd);
115
+ // Auth-gated commands are additionally exposed to the framework command
116
+ // surface (api.registerCommand) for CLI / control-plane invocation.
117
+ if (cmd.requireAuth) {
118
+ this.frameworkCommands.set(key, cmd);
119
+ }
120
+ }
121
+
122
+ /** Return all auth-gated commands for framework registration. */
123
+ getFrameworkCommands(): QQBotFrameworkCommand[] {
124
+ return Array.from(this.frameworkCommands.values()).map((cmd) => ({
125
+ name: cmd.name,
126
+ description: cmd.description,
127
+ usage: cmd.usage,
128
+ handler: cmd.handler,
129
+ }));
130
+ }
131
+
132
+ /** Return all pre-dispatch commands. */
133
+ getPreDispatchCommands(): Map<string, SlashCommand> {
134
+ return this.commands;
135
+ }
136
+
137
+ /** Return all registered commands (both maps) for help listing. */
138
+ getAllCommands(): Map<string, SlashCommand> {
139
+ const all = new Map<string, SlashCommand>();
140
+ for (const [k, v] of this.commands) {
141
+ all.set(k, v);
142
+ }
143
+ for (const [k, v] of this.frameworkCommands) {
144
+ all.set(k, v);
145
+ }
146
+ return all;
147
+ }
148
+
149
+ /**
150
+ * Try to match and execute a pre-dispatch slash command.
151
+ *
152
+ * @returns A reply when matched, or null when the message should continue
153
+ * through normal routing.
154
+ */
155
+ async matchSlashCommand(
156
+ ctx: SlashCommandContext,
157
+ log?: { info?: (msg: string) => void },
158
+ ): Promise<SlashCommandResult> {
159
+ const content = ctx.rawContent.trim();
160
+ if (!content.startsWith("/")) {
161
+ return null;
162
+ }
163
+
164
+ const spaceIdx = content.indexOf(" ");
165
+ const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx));
166
+ const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
167
+
168
+ const cmd = this.commands.get(cmdName);
169
+ if (!cmd) {
170
+ return null;
171
+ }
172
+
173
+ // Reject c2cOnly commands when invoked outside private chat.
174
+ if (cmd.c2cOnly && ctx.type !== "c2c") {
175
+ return `💡 请在私聊中使用此指令`;
176
+ }
177
+
178
+ // Gate sensitive commands behind the allowFrom authorization check.
179
+ if (cmd.requireAuth && !ctx.commandAuthorized) {
180
+ log?.info?.(
181
+ `[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
182
+ );
183
+ const isGroup = ctx.type === "group" || ctx.type === "guild";
184
+ const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
185
+ return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}。`;
186
+ }
187
+
188
+ // `/command ?` returns usage help.
189
+ if (args === "?") {
190
+ if (cmd.usage) {
191
+ return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
192
+ }
193
+ return `/${cmd.name} - ${cmd.description}`;
194
+ }
195
+
196
+ ctx.args = args;
197
+ return await cmd.handler(ctx);
198
+ }
199
+ }
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
4
+ import { loadCredentialBackup, saveCredentialBackup } from "./credential-backup.js";
5
+
6
+ /**
7
+ * These tests write to `~/.openclaw/qqbot/data` under a test-specific
8
+ * accountId prefix and clean up after themselves. Mirrors the approach
9
+ * used by `platform.test.ts` in the same package.
10
+ */
11
+ describe("engine/config/credential-backup", () => {
12
+ const acct = `test-cb-${process.pid}-${Date.now()}`;
13
+ const legacyPath = getLegacyCredentialBackupFile();
14
+ let legacyBackup: string | null = null;
15
+
16
+ beforeEach(() => {
17
+ // Preserve any legacy backup that might happen to live in the user's
18
+ // real home so we can restore it after the test.
19
+ legacyBackup = null;
20
+ if (fs.existsSync(legacyPath)) {
21
+ legacyBackup = fs.readFileSync(legacyPath, "utf8");
22
+ fs.unlinkSync(legacyPath);
23
+ }
24
+ });
25
+
26
+ afterEach(() => {
27
+ try {
28
+ fs.unlinkSync(getCredentialBackupFile(acct));
29
+ } catch {
30
+ /* ignore */
31
+ }
32
+ if (fs.existsSync(legacyPath)) {
33
+ fs.unlinkSync(legacyPath);
34
+ }
35
+ if (legacyBackup != null) {
36
+ fs.writeFileSync(legacyPath, legacyBackup);
37
+ }
38
+ });
39
+
40
+ it("round-trips a credential snapshot", () => {
41
+ saveCredentialBackup(acct, "app-1", "secret-1");
42
+ const loaded = loadCredentialBackup(acct);
43
+ expect(loaded?.appId).toBe("app-1");
44
+ expect(loaded?.clientSecret).toBe("secret-1");
45
+ expect(loaded?.accountId).toBe(acct);
46
+ expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true);
47
+ });
48
+
49
+ it("returns null when no backup exists", () => {
50
+ expect(loadCredentialBackup(acct)).toBeNull();
51
+ });
52
+
53
+ it("returns null when legacy backup belongs to a different accountId", () => {
54
+ fs.writeFileSync(
55
+ legacyPath,
56
+ JSON.stringify({
57
+ accountId: "other-acct",
58
+ appId: "app-old",
59
+ clientSecret: "secret-old",
60
+ savedAt: new Date().toISOString(),
61
+ }),
62
+ );
63
+ expect(loadCredentialBackup(acct)).toBeNull();
64
+ });
65
+
66
+ it("migrates legacy single-file backup to per-account path on load", () => {
67
+ fs.writeFileSync(
68
+ legacyPath,
69
+ JSON.stringify({
70
+ accountId: acct,
71
+ appId: "app-1",
72
+ clientSecret: "secret-1",
73
+ savedAt: new Date().toISOString(),
74
+ }),
75
+ );
76
+
77
+ const loaded = loadCredentialBackup(acct);
78
+ expect(loaded?.appId).toBe("app-1");
79
+ expect(fs.existsSync(legacyPath)).toBe(false);
80
+ expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(true);
81
+ });
82
+
83
+ it("ignores empty appId/clientSecret on save", () => {
84
+ saveCredentialBackup(acct, "", "secret");
85
+ saveCredentialBackup(acct, "app", "");
86
+ expect(fs.existsSync(getCredentialBackupFile(acct))).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Credential backup & recovery.
3
+ * 凭证暂存与恢复。
4
+ *
5
+ * Solves the "hot-upgrade interrupted, appId/secret vanished from
6
+ * openclaw.json" failure mode.
7
+ *
8
+ * Mechanics:
9
+ * - After each successful gateway start we snapshot the currently
10
+ * resolved `appId` / `clientSecret` to a per-account backup file.
11
+ * - During plugin startup, if the live config has an empty appId or
12
+ * secret, the gateway consults the backup and restores the values
13
+ * via the config mutation API.
14
+ * - Backups live under `~/.openclaw/qqbot/data/` so they survive
15
+ * plugin directory replacement.
16
+ *
17
+ * Safety notes:
18
+ * - Only restore when credentials are **actually empty** — never
19
+ * overwrite a user's intentional config change.
20
+ * - Atomic write (temp file + rename) to avoid torn files.
21
+ * - Per-account file: `credential-backup-<accountId>.json`. We do
22
+ * **not** also key by appId because recovery happens precisely
23
+ * when appId is unknown.
24
+ * - Legacy single `credential-backup.json` is migrated automatically
25
+ * when the stored accountId matches the caller.
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
31
+
32
+ interface CredentialBackup {
33
+ accountId: string;
34
+ appId: string;
35
+ clientSecret: string;
36
+ savedAt: string;
37
+ }
38
+
39
+ /** Persist a credential snapshot (called once gateway reaches READY). */
40
+ export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
41
+ if (!appId || !clientSecret) {
42
+ return;
43
+ }
44
+ try {
45
+ const backupPath = getCredentialBackupFile(accountId);
46
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
47
+ const data: CredentialBackup = {
48
+ accountId,
49
+ appId,
50
+ clientSecret,
51
+ savedAt: new Date().toISOString(),
52
+ };
53
+ const tmpPath = `${backupPath}.tmp`;
54
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
55
+ fs.renameSync(tmpPath, backupPath);
56
+ } catch {
57
+ /* best-effort — ignore */
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load a credential snapshot for `accountId`.
63
+ *
64
+ * Consults the new per-account file first; falls back to the legacy
65
+ * global backup file and migrates it when the embedded `accountId`
66
+ * matches the request. Returns `null` when no usable backup exists.
67
+ */
68
+ export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
69
+ try {
70
+ if (accountId) {
71
+ const newPath = getCredentialBackupFile(accountId);
72
+ if (fs.existsSync(newPath)) {
73
+ const data = JSON.parse(fs.readFileSync(newPath, "utf8")) as CredentialBackup;
74
+ if (data?.appId && data.clientSecret) {
75
+ return data;
76
+ }
77
+ }
78
+ }
79
+
80
+ const legacy = getLegacyCredentialBackupFile();
81
+ if (fs.existsSync(legacy)) {
82
+ const data = JSON.parse(fs.readFileSync(legacy, "utf8")) as CredentialBackup;
83
+ if (!data?.appId || !data?.clientSecret) {
84
+ return null;
85
+ }
86
+ if (accountId && data.accountId !== accountId) {
87
+ return null;
88
+ }
89
+ if (data.accountId) {
90
+ try {
91
+ const backupPath = getCredentialBackupFile(data.accountId);
92
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
93
+ const tmpPath = `${backupPath}.tmp`;
94
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
95
+ fs.renameSync(tmpPath, backupPath);
96
+ fs.unlinkSync(legacy);
97
+ } catch {
98
+ /* ignore migration errors */
99
+ }
100
+ }
101
+ return data;
102
+ }
103
+ } catch {
104
+ /* corrupt file — ignore */
105
+ }
106
+ return null;
107
+ }