@gakr-gakr/qqbot 0.1.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.
Files changed (149) hide show
  1. package/api.ts +56 -0
  2. package/autobot.plugin.json +167 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/index.ts +33 -0
  5. package/package.json +64 -0
  6. package/runtime-api.ts +9 -0
  7. package/secret-contract-api.ts +5 -0
  8. package/setup-entry.ts +13 -0
  9. package/setup-plugin-api.ts +3 -0
  10. package/skills/qqbot-channel/SKILL.md +262 -0
  11. package/skills/qqbot-channel/references/api_references.md +521 -0
  12. package/skills/qqbot-media/SKILL.md +37 -0
  13. package/skills/qqbot-remind/SKILL.md +153 -0
  14. package/src/bridge/approval/capability.ts +225 -0
  15. package/src/bridge/approval/handler-runtime.ts +204 -0
  16. package/src/bridge/bootstrap.ts +135 -0
  17. package/src/bridge/channel-entry.ts +18 -0
  18. package/src/bridge/commands/framework-context-adapter.ts +60 -0
  19. package/src/bridge/commands/framework-registration.ts +66 -0
  20. package/src/bridge/commands/from-parser.ts +60 -0
  21. package/src/bridge/commands/result-dispatcher.ts +76 -0
  22. package/src/bridge/config-shared.ts +132 -0
  23. package/src/bridge/config.ts +176 -0
  24. package/src/bridge/gateway.ts +178 -0
  25. package/src/bridge/logger.ts +31 -0
  26. package/src/bridge/narrowing.ts +31 -0
  27. package/src/bridge/plugin-version.ts +102 -0
  28. package/src/bridge/runtime.ts +25 -0
  29. package/src/bridge/sdk-adapter.ts +164 -0
  30. package/src/bridge/setup/finalize.ts +144 -0
  31. package/src/bridge/setup/surface.ts +34 -0
  32. package/src/bridge/tools/channel.ts +58 -0
  33. package/src/bridge/tools/index.ts +15 -0
  34. package/src/bridge/tools/remind.ts +91 -0
  35. package/src/channel.setup.ts +33 -0
  36. package/src/channel.ts +399 -0
  37. package/src/config-schema.ts +84 -0
  38. package/src/engine/access/index.ts +2 -0
  39. package/src/engine/access/resolve-policy.ts +30 -0
  40. package/src/engine/access/sender-match.ts +55 -0
  41. package/src/engine/access/types.ts +2 -0
  42. package/src/engine/adapter/audio.port.ts +27 -0
  43. package/src/engine/adapter/commands.port.ts +22 -0
  44. package/src/engine/adapter/history.port.ts +52 -0
  45. package/src/engine/adapter/index.ts +76 -0
  46. package/src/engine/adapter/mention-gate.port.ts +50 -0
  47. package/src/engine/adapter/types.ts +38 -0
  48. package/src/engine/api/api-client.ts +212 -0
  49. package/src/engine/api/media-chunked.ts +644 -0
  50. package/src/engine/api/media.ts +218 -0
  51. package/src/engine/api/messages.ts +293 -0
  52. package/src/engine/api/retry.ts +217 -0
  53. package/src/engine/api/routes.ts +95 -0
  54. package/src/engine/api/token.ts +277 -0
  55. package/src/engine/approval/index.ts +224 -0
  56. package/src/engine/commands/builtin/log-helpers.ts +341 -0
  57. package/src/engine/commands/builtin/register-all.ts +17 -0
  58. package/src/engine/commands/builtin/register-approve.ts +201 -0
  59. package/src/engine/commands/builtin/register-basic.ts +95 -0
  60. package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
  61. package/src/engine/commands/builtin/register-logs.ts +20 -0
  62. package/src/engine/commands/builtin/register-streaming.ts +138 -0
  63. package/src/engine/commands/builtin/state.ts +31 -0
  64. package/src/engine/commands/slash-command-auth.ts +88 -0
  65. package/src/engine/commands/slash-command-handler.ts +168 -0
  66. package/src/engine/commands/slash-command-test-support.ts +39 -0
  67. package/src/engine/commands/slash-commands-impl.ts +61 -0
  68. package/src/engine/commands/slash-commands.ts +202 -0
  69. package/src/engine/config/credential-backup.ts +108 -0
  70. package/src/engine/config/credentials.ts +76 -0
  71. package/src/engine/config/group.ts +227 -0
  72. package/src/engine/config/resolve.ts +283 -0
  73. package/src/engine/config/setup-logic.ts +84 -0
  74. package/src/engine/gateway/active-cfg.ts +52 -0
  75. package/src/engine/gateway/codec.ts +47 -0
  76. package/src/engine/gateway/constants.ts +117 -0
  77. package/src/engine/gateway/event-dispatcher.ts +177 -0
  78. package/src/engine/gateway/gateway-connection.ts +356 -0
  79. package/src/engine/gateway/gateway.ts +267 -0
  80. package/src/engine/gateway/inbound-attachments.ts +360 -0
  81. package/src/engine/gateway/inbound-context.ts +82 -0
  82. package/src/engine/gateway/inbound-pipeline.ts +171 -0
  83. package/src/engine/gateway/interaction-handler.ts +345 -0
  84. package/src/engine/gateway/message-queue.ts +404 -0
  85. package/src/engine/gateway/outbound-dispatch.ts +590 -0
  86. package/src/engine/gateway/reconnect.ts +199 -0
  87. package/src/engine/gateway/stages/access-stage.ts +99 -0
  88. package/src/engine/gateway/stages/assembly-stage.ts +156 -0
  89. package/src/engine/gateway/stages/content-stage.ts +77 -0
  90. package/src/engine/gateway/stages/envelope-stage.ts +144 -0
  91. package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
  92. package/src/engine/gateway/stages/index.ts +18 -0
  93. package/src/engine/gateway/stages/quote-stage.ts +113 -0
  94. package/src/engine/gateway/stages/refidx-stage.ts +62 -0
  95. package/src/engine/gateway/stages/stub-contexts.ts +77 -0
  96. package/src/engine/gateway/types.ts +230 -0
  97. package/src/engine/gateway/typing-keepalive.ts +102 -0
  98. package/src/engine/gateway/ws-client.ts +16 -0
  99. package/src/engine/group/activation.ts +88 -0
  100. package/src/engine/group/history.ts +321 -0
  101. package/src/engine/group/mention.ts +114 -0
  102. package/src/engine/group/message-gating.ts +108 -0
  103. package/src/engine/messaging/decode-media-path.ts +82 -0
  104. package/src/engine/messaging/media-source.ts +210 -0
  105. package/src/engine/messaging/media-type-detect.ts +27 -0
  106. package/src/engine/messaging/outbound-audio-port.ts +38 -0
  107. package/src/engine/messaging/outbound-deliver.ts +810 -0
  108. package/src/engine/messaging/outbound-media-send.ts +658 -0
  109. package/src/engine/messaging/outbound-reply.ts +27 -0
  110. package/src/engine/messaging/outbound-result-helpers.ts +54 -0
  111. package/src/engine/messaging/outbound-types.ts +47 -0
  112. package/src/engine/messaging/outbound.ts +485 -0
  113. package/src/engine/messaging/reply-dispatcher.ts +597 -0
  114. package/src/engine/messaging/reply-limiter.ts +164 -0
  115. package/src/engine/messaging/sender.ts +741 -0
  116. package/src/engine/messaging/streaming-c2c.ts +1192 -0
  117. package/src/engine/messaging/streaming-media-send.ts +544 -0
  118. package/src/engine/messaging/target-parser.ts +104 -0
  119. package/src/engine/ref/format-message-ref.ts +142 -0
  120. package/src/engine/ref/format-ref-entry.ts +27 -0
  121. package/src/engine/ref/store.ts +211 -0
  122. package/src/engine/ref/types.ts +27 -0
  123. package/src/engine/session/known-users.ts +138 -0
  124. package/src/engine/session/session-store.ts +207 -0
  125. package/src/engine/tools/channel-api.ts +244 -0
  126. package/src/engine/tools/remind-logic.ts +377 -0
  127. package/src/engine/types.ts +313 -0
  128. package/src/engine/utils/attachment-tags.ts +174 -0
  129. package/src/engine/utils/audio.ts +525 -0
  130. package/src/engine/utils/data-paths.ts +38 -0
  131. package/src/engine/utils/diagnostics.ts +93 -0
  132. package/src/engine/utils/file-utils.ts +215 -0
  133. package/src/engine/utils/format.ts +70 -0
  134. package/src/engine/utils/image-size.ts +249 -0
  135. package/src/engine/utils/log.ts +77 -0
  136. package/src/engine/utils/media-tags.ts +177 -0
  137. package/src/engine/utils/payload.ts +157 -0
  138. package/src/engine/utils/platform.ts +265 -0
  139. package/src/engine/utils/request-context.ts +60 -0
  140. package/src/engine/utils/string-normalize.ts +91 -0
  141. package/src/engine/utils/stt.ts +103 -0
  142. package/src/engine/utils/text-parsing.ts +155 -0
  143. package/src/engine/utils/upload-cache.ts +96 -0
  144. package/src/engine/utils/voice-text.ts +15 -0
  145. package/src/exec-approvals.ts +237 -0
  146. package/src/qqbot-test-support.ts +29 -0
  147. package/src/secret-contract.ts +82 -0
  148. package/src/types.ts +210 -0
  149. package/tsconfig.json +16 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pre-dispatch authorization for requireAuth slash commands.
3
+ *
4
+ * Unlike the inbound message ingress command projection (which permits
5
+ * open-policy chat senders), this function requires the sender to appear in an
6
+ * **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
+ type SlashCommandAuthEntry = string | number;
17
+
18
+ function isSlashCommandAuthEntry(value: unknown): value is SlashCommandAuthEntry {
19
+ return typeof value === "string" || typeof value === "number";
20
+ }
21
+
22
+ function readSlashCommandAuthList(value: unknown): SlashCommandAuthEntry[] | undefined {
23
+ if (!Array.isArray(value)) {
24
+ return undefined;
25
+ }
26
+ return value.filter(isSlashCommandAuthEntry);
27
+ }
28
+
29
+ /**
30
+ * Resolve the command-specific QQBot allowlist from the root AutoBot config.
31
+ *
32
+ * `commands.allowFrom.qqbot` takes precedence over the global
33
+ * `commands.allowFrom["*"]`, matching the framework command authorization
34
+ * contract used by registered plugin commands.
35
+ */
36
+ export function resolveQQBotCommandsAllowFrom(cfg: unknown): SlashCommandAuthEntry[] | undefined {
37
+ if (!cfg || typeof cfg !== "object") {
38
+ return undefined;
39
+ }
40
+ const commands = (cfg as { commands?: unknown }).commands;
41
+ if (!commands || typeof commands !== "object") {
42
+ return undefined;
43
+ }
44
+ const allowFrom = (commands as { allowFrom?: unknown }).allowFrom;
45
+ if (!allowFrom || typeof allowFrom !== "object" || Array.isArray(allowFrom)) {
46
+ return undefined;
47
+ }
48
+ const byProvider = allowFrom as Record<string, unknown>;
49
+ return readSlashCommandAuthList(byProvider.qqbot) ?? readSlashCommandAuthList(byProvider["*"]);
50
+ }
51
+
52
+ /**
53
+ * Determine whether `senderId` is authorized to execute `requireAuth`
54
+ * slash commands for the given account configuration.
55
+ *
56
+ * Authorization rules:
57
+ * - `commands.allowFrom.qqbot` / `commands.allowFrom["*"]` configured →
58
+ * use that command-specific list instead of channel allowFrom
59
+ * - `allowFrom` not configured / empty / only `["*"]` → **false**
60
+ * (wildcard means "open to everyone", not explicit authorization)
61
+ * - `allowFrom` contains at least one concrete entry AND sender
62
+ * matches a concrete entry → **true**
63
+ * - Group messages use `groupAllowFrom` when present, falling back
64
+ * to `allowFrom`.
65
+ */
66
+ export function resolveSlashCommandAuth(params: {
67
+ senderId: string;
68
+ isGroup: boolean;
69
+ allowFrom?: Array<string | number>;
70
+ groupAllowFrom?: Array<string | number>;
71
+ commandsAllowFrom?: Array<string | number>;
72
+ }): boolean {
73
+ const rawList =
74
+ params.commandsAllowFrom ??
75
+ (params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
76
+ ? params.groupAllowFrom
77
+ : params.allowFrom);
78
+
79
+ const normalized = normalizeQQBotAllowFrom(rawList);
80
+
81
+ // Require and match only explicit (non-wildcard) entries.
82
+ const explicitEntries = normalized.filter((entry) => entry !== "*");
83
+ if (explicitEntries.length === 0) {
84
+ return false;
85
+ }
86
+
87
+ return createQQBotSenderMatcher(params.senderId)(explicitEntries);
88
+ }
@@ -0,0 +1,168 @@
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 { resolveQQBotCommandsAllowFrom, 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
+ cfg?: unknown;
25
+ log?: EngineLogger;
26
+ getMessagePeerId: (msg: QueuedMessage) => string;
27
+ getQueueSnapshot: (peerId: string) => QueueSnapshot;
28
+ resolveCommandAuthorized?: (params: {
29
+ isGroup: boolean;
30
+ senderId: string;
31
+ conversationId: string;
32
+ allowFrom?: Array<string | number>;
33
+ groupAllowFrom?: Array<string | number>;
34
+ commandsAllowFrom?: Array<string | number>;
35
+ }) => boolean | Promise<boolean>;
36
+ }
37
+
38
+ // ============ Constants ============
39
+
40
+ const URGENT_COMMANDS = ["/stop"];
41
+
42
+ // ============ trySlashCommandOrEnqueue ============
43
+
44
+ /**
45
+ * Check if the message is a slash command and handle it.
46
+ *
47
+ * @returns `true` if handled (command executed or enqueued as urgent),
48
+ * `false` if the message should be queued for normal processing.
49
+ */
50
+ export async function trySlashCommand(
51
+ msg: QueuedMessage,
52
+ ctx: SlashCommandHandlerContext,
53
+ ): Promise<"handled" | "urgent" | "enqueue"> {
54
+ const { account, log } = ctx;
55
+ const content = (msg.content ?? "").trim();
56
+
57
+ if (!content.startsWith("/")) {
58
+ return "enqueue";
59
+ }
60
+
61
+ // Urgent command detection — bypass queue and execute immediately.
62
+ const contentLower = content.toLowerCase();
63
+ const isUrgentCommand = URGENT_COMMANDS.some(
64
+ (cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
65
+ );
66
+ if (isUrgentCommand) {
67
+ log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
68
+ return "urgent";
69
+ }
70
+
71
+ // Normal slash command — try to match and execute.
72
+ const receivedAt = Date.now();
73
+ const peerId = ctx.getMessagePeerId(msg);
74
+ const isGroup = msg.type === "group" || msg.type === "guild";
75
+ const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
76
+ const commandAuthorized = ctx.resolveCommandAuthorized
77
+ ? await ctx.resolveCommandAuthorized({
78
+ isGroup,
79
+ senderId: msg.senderId,
80
+ conversationId: msg.groupOpenid ?? msg.channelId ?? msg.senderId,
81
+ allowFrom: account.config?.allowFrom,
82
+ groupAllowFrom: account.config?.groupAllowFrom,
83
+ commandsAllowFrom,
84
+ })
85
+ : resolveSlashCommandAuth({
86
+ senderId: msg.senderId,
87
+ isGroup,
88
+ allowFrom: account.config?.allowFrom,
89
+ groupAllowFrom: account.config?.groupAllowFrom,
90
+ commandsAllowFrom,
91
+ });
92
+ const cmdCtx: SlashCommandContext = {
93
+ type: msg.type,
94
+ senderId: msg.senderId,
95
+ senderName: msg.senderName,
96
+ messageId: msg.messageId,
97
+ eventTimestamp: msg.timestamp,
98
+ receivedAt,
99
+ rawContent: content,
100
+ args: "",
101
+ channelId: msg.channelId,
102
+ groupOpenid: msg.groupOpenid,
103
+ accountId: account.accountId,
104
+ appId: account.appId,
105
+ accountConfig: account.config,
106
+ commandAuthorized,
107
+ queueSnapshot: ctx.getQueueSnapshot(peerId),
108
+ };
109
+
110
+ try {
111
+ const reply = await matchSlashCommand(cmdCtx);
112
+ if (reply === null) {
113
+ return "enqueue";
114
+ }
115
+
116
+ log?.debug?.(`Slash command matched: ${content}`);
117
+
118
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
119
+ const replyText = isFileResult ? (reply as { text: string }).text : reply;
120
+ const replyFile = isFileResult ? (reply as { filePath: string }).filePath : null;
121
+
122
+ // Send text reply.
123
+ if (msg.type === "c2c" || msg.type === "group" || msg.type === "dm" || msg.type === "guild") {
124
+ const slashTarget = buildDeliveryTarget(msg);
125
+ const slashCreds = accountToCreds(account);
126
+ await senderSendText(slashTarget, replyText, slashCreds, { msgId: msg.messageId });
127
+ }
128
+
129
+ // Send file attachment if present.
130
+ if (replyFile) {
131
+ try {
132
+ const targetType =
133
+ msg.type === "group"
134
+ ? "group"
135
+ : msg.type === "dm"
136
+ ? "dm"
137
+ : msg.type === "c2c"
138
+ ? "c2c"
139
+ : "channel";
140
+ const targetId =
141
+ msg.type === "group"
142
+ ? msg.groupOpenid || msg.senderId
143
+ : msg.type === "dm"
144
+ ? msg.guildId || msg.senderId
145
+ : msg.type === "c2c"
146
+ ? msg.senderId
147
+ : msg.channelId || msg.senderId;
148
+ await sendDocument(
149
+ {
150
+ targetType,
151
+ targetId,
152
+ account,
153
+ replyToId: msg.messageId,
154
+ },
155
+ replyFile,
156
+ { allowQQBotDataDownloads: true },
157
+ );
158
+ } catch (fileErr) {
159
+ log?.error(`Failed to send slash command file: ${String(fileErr)}`);
160
+ }
161
+ }
162
+
163
+ return "handled";
164
+ } catch (err) {
165
+ log?.error(`Slash command error: ${String(err)}`);
166
+ return "enqueue";
167
+ }
168
+ }
@@ -0,0 +1,39 @@
1
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
2
+ import type { CommandsPort } from "../adapter/commands.port.js";
3
+ import { initCommands } from "./slash-commands-impl.js";
4
+
5
+ type RuntimeConfigApi = ReturnType<NonNullable<CommandsPort["approveRuntimeGetter"]>>["config"];
6
+ type ReplaceConfigFile = RuntimeConfigApi["replaceConfigFile"];
7
+ type ReplaceConfigFileResult = Awaited<ReturnType<ReplaceConfigFile>>;
8
+
9
+ export type WrittenQQBotConfig = {
10
+ streaming?: unknown;
11
+ accounts?: { default?: { streaming?: unknown } };
12
+ };
13
+
14
+ export function installCommandRuntime(
15
+ currentConfig: AutoBotConfig,
16
+ writes: AutoBotConfig[],
17
+ ): void {
18
+ const replaceConfigFile: ReplaceConfigFile = async (params) => {
19
+ writes.push(params.nextConfig);
20
+ return undefined as unknown as ReplaceConfigFileResult;
21
+ };
22
+
23
+ initCommands({
24
+ resolveVersion: () => "test",
25
+ pluginVersion: "0.0.0-test",
26
+ approveRuntimeGetter: () => ({
27
+ config: {
28
+ current: () => currentConfig,
29
+ replaceConfigFile,
30
+ },
31
+ }),
32
+ });
33
+ }
34
+
35
+ export function getWrittenQQBotConfig(
36
+ write: AutoBotConfig | undefined,
37
+ ): WrittenQQBotConfig | undefined {
38
+ return write?.channels?.qqbot as WrittenQQBotConfig | undefined;
39
+ }
@@ -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 commands that may be registered with the framework via
36
+ * 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,202 @@
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
+ c2cOnly?: boolean;
89
+ handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
90
+ }
91
+
92
+ // ============ Command Registry ============
93
+
94
+ /** Lowercase and trim a string. */
95
+ function lc(s: string): string {
96
+ return (s ?? "").toLowerCase().trim();
97
+ }
98
+
99
+ /**
100
+ * Slash command registry.
101
+ *
102
+ * Maintains two maps:
103
+ * - `commands` — QQBot message-flow commands
104
+ * - `frameworkCommands` — auth-gated commands that are safe on the framework surface
105
+ */
106
+ export class SlashCommandRegistry {
107
+ private readonly commands = new Map<string, SlashCommand>();
108
+ private readonly frameworkCommands = new Map<string, SlashCommand>();
109
+
110
+ /** Register one command. */
111
+ register(cmd: SlashCommand): void {
112
+ const key = lc(cmd.name);
113
+ // Always register in the pre-dispatch map so QQ message-flow slash
114
+ // commands can match and execute directly (with requireAuth gating).
115
+ this.commands.set(key, cmd);
116
+ // Auth-gated commands are exposed to the framework command surface.
117
+ // Private-chat-only metadata is preserved so the bridge can enforce the
118
+ // same routing restriction before dispatching handlers.
119
+ if (cmd.requireAuth) {
120
+ this.frameworkCommands.set(key, cmd);
121
+ }
122
+ }
123
+
124
+ /** Return all commands that may be registered on the framework surface. */
125
+ getFrameworkCommands(): QQBotFrameworkCommand[] {
126
+ return Array.from(this.frameworkCommands.values()).map((cmd) => ({
127
+ name: cmd.name,
128
+ description: cmd.description,
129
+ usage: cmd.usage,
130
+ c2cOnly: cmd.c2cOnly,
131
+ handler: cmd.handler,
132
+ }));
133
+ }
134
+
135
+ /** Return all pre-dispatch commands. */
136
+ getPreDispatchCommands(): Map<string, SlashCommand> {
137
+ return this.commands;
138
+ }
139
+
140
+ /** Return all registered commands (both maps) for help listing. */
141
+ getAllCommands(): Map<string, SlashCommand> {
142
+ const all = new Map<string, SlashCommand>();
143
+ for (const [k, v] of this.commands) {
144
+ all.set(k, v);
145
+ }
146
+ for (const [k, v] of this.frameworkCommands) {
147
+ all.set(k, v);
148
+ }
149
+ return all;
150
+ }
151
+
152
+ /**
153
+ * Try to match and execute a pre-dispatch slash command.
154
+ *
155
+ * @returns A reply when matched, or null when the message should continue
156
+ * through normal routing.
157
+ */
158
+ async matchSlashCommand(
159
+ ctx: SlashCommandContext,
160
+ log?: { info?: (msg: string) => void },
161
+ ): Promise<SlashCommandResult> {
162
+ const content = ctx.rawContent.trim();
163
+ if (!content.startsWith("/")) {
164
+ return null;
165
+ }
166
+
167
+ const spaceIdx = content.indexOf(" ");
168
+ const cmdName = lc(spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx));
169
+ const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
170
+
171
+ const cmd = this.commands.get(cmdName);
172
+ if (!cmd) {
173
+ return null;
174
+ }
175
+
176
+ // Reject c2cOnly commands when invoked outside private chat.
177
+ if (cmd.c2cOnly && ctx.type !== "c2c") {
178
+ return `💡 请在私聊中使用此指令`;
179
+ }
180
+
181
+ // Gate sensitive commands behind the allowFrom authorization check.
182
+ if (cmd.requireAuth && !ctx.commandAuthorized) {
183
+ log?.info?.(
184
+ `[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
185
+ );
186
+ const isGroup = ctx.type === "group" || ctx.type === "guild";
187
+ const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
188
+ return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}。`;
189
+ }
190
+
191
+ // `/command ?` returns usage help.
192
+ if (args === "?") {
193
+ if (cmd.usage) {
194
+ return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
195
+ }
196
+ return `/${cmd.name} - ${cmd.description}`;
197
+ }
198
+
199
+ ctx.args = args;
200
+ return await cmd.handler(ctx);
201
+ }
202
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Credential backup & recovery.
3
+ * 凭证暂存与恢复。
4
+ *
5
+ * Solves the "hot-upgrade interrupted, appId/secret vanished from
6
+ * autobot.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 `~/.autobot/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 { loadJsonFile } from "autobot/plugin-sdk/json-store";
30
+ import { replaceFileAtomicSync } from "autobot/plugin-sdk/security-runtime";
31
+ import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "../utils/data-paths.js";
32
+
33
+ interface CredentialBackup {
34
+ accountId: string;
35
+ appId: string;
36
+ clientSecret: string;
37
+ savedAt: string;
38
+ }
39
+
40
+ /** Persist a credential snapshot (called once gateway reaches READY). */
41
+ export function saveCredentialBackup(accountId: string, appId: string, clientSecret: string): void {
42
+ if (!appId || !clientSecret) {
43
+ return;
44
+ }
45
+ try {
46
+ const backupPath = getCredentialBackupFile(accountId);
47
+ const data: CredentialBackup = {
48
+ accountId,
49
+ appId,
50
+ clientSecret,
51
+ savedAt: new Date().toISOString(),
52
+ };
53
+ replaceFileAtomicSync({
54
+ filePath: backupPath,
55
+ content: `${JSON.stringify(data, null, 2)}\n`,
56
+ tempPrefix: ".qqbot-credential-backup",
57
+ });
58
+ } catch {
59
+ /* best-effort — ignore */
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Load a credential snapshot for `accountId`.
65
+ *
66
+ * Consults the new per-account file first; falls back to the legacy
67
+ * global backup file and migrates it when the embedded `accountId`
68
+ * matches the request. Returns `null` when no usable backup exists.
69
+ */
70
+ export function loadCredentialBackup(accountId?: string): CredentialBackup | null {
71
+ try {
72
+ if (accountId) {
73
+ const newPath = getCredentialBackupFile(accountId);
74
+ const data = loadJsonFile<CredentialBackup>(newPath);
75
+ if (data?.appId && data.clientSecret) {
76
+ return data;
77
+ }
78
+ }
79
+
80
+ const legacy = getLegacyCredentialBackupFile();
81
+ const data = loadJsonFile<CredentialBackup>(legacy);
82
+ if (data) {
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
+ replaceFileAtomicSync({
93
+ filePath: backupPath,
94
+ content: `${JSON.stringify(data, null, 2)}\n`,
95
+ tempPrefix: ".qqbot-credential-backup",
96
+ });
97
+ fs.unlinkSync(legacy);
98
+ } catch {
99
+ /* ignore migration errors */
100
+ }
101
+ }
102
+ return data;
103
+ }
104
+ } catch {
105
+ /* corrupt file — ignore */
106
+ }
107
+ return null;
108
+ }