@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.
- package/api.ts +56 -0
- package/autobot.plugin.json +167 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +33 -0
- package/package.json +64 -0
- package/runtime-api.ts +9 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +225 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +66 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +176 -0
- package/src/bridge/gateway.ts +178 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +164 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +399 -0
- package/src/config-schema.ts +84 -0
- package/src/engine/access/index.ts +2 -0
- package/src/engine/access/resolve-policy.ts +30 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +2 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +76 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.ts +644 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +277 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +341 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +138 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +88 -0
- package/src/engine/commands/slash-command-handler.ts +168 -0
- package/src/engine/commands/slash-command-test-support.ts +39 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +202 -0
- package/src/engine/config/credential-backup.ts +108 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.ts +227 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/gateway/active-cfg.ts +52 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +356 -0
- package/src/engine/gateway/gateway.ts +267 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +82 -0
- package/src/engine/gateway/inbound-pipeline.ts +171 -0
- package/src/engine/gateway/interaction-handler.ts +345 -0
- package/src/engine/gateway/message-queue.ts +404 -0
- package/src/engine/gateway/outbound-dispatch.ts +590 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +99 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +77 -0
- package/src/engine/gateway/types.ts +230 -0
- package/src/engine/gateway/typing-keepalive.ts +102 -0
- package/src/engine/gateway/ws-client.ts +16 -0
- package/src/engine/group/activation.ts +88 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.ts +114 -0
- package/src/engine/group/message-gating.ts +108 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +210 -0
- package/src/engine/messaging/media-type-detect.ts +27 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +658 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +47 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +741 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +211 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +138 -0
- package/src/engine/session/session-store.ts +207 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.ts +525 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +93 -0
- package/src/engine/utils/file-utils.ts +215 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.ts +77 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.ts +157 -0
- package/src/engine/utils/platform.ts +265 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.ts +103 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +237 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/secret-contract.ts +82 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval helpers — pure functions, zero framework dependencies.
|
|
3
|
+
*
|
|
4
|
+
* - Build approval message text + inline keyboard
|
|
5
|
+
* - Resolve delivery target from session metadata
|
|
6
|
+
* - Parse INTERACTION_CREATE button data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChatScope, InlineKeyboard, KeyboardButton } from "../types.js";
|
|
10
|
+
|
|
11
|
+
// ============ Types ============
|
|
12
|
+
|
|
13
|
+
export interface ExecApprovalRequest {
|
|
14
|
+
id: string;
|
|
15
|
+
expiresAtMs: number;
|
|
16
|
+
request: {
|
|
17
|
+
commandPreview?: string;
|
|
18
|
+
command?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
agentId?: string;
|
|
21
|
+
turnSourceAccountId?: string;
|
|
22
|
+
sessionKey?: string;
|
|
23
|
+
turnSourceTo?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PluginApprovalRequest {
|
|
29
|
+
id: string;
|
|
30
|
+
request: {
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
severity?: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
toolName?: string;
|
|
36
|
+
pluginId?: string;
|
|
37
|
+
agentId?: string;
|
|
38
|
+
turnSourceAccountId?: string;
|
|
39
|
+
sessionKey?: string;
|
|
40
|
+
turnSourceTo?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ApprovalDecision = "allow-once" | "allow-always" | "deny";
|
|
46
|
+
|
|
47
|
+
interface ApprovalTarget {
|
|
48
|
+
type: ChatScope;
|
|
49
|
+
id: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ParsedApprovalAction {
|
|
53
|
+
approvalId: string;
|
|
54
|
+
decision: ApprovalDecision;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============ Text Builders ============
|
|
58
|
+
|
|
59
|
+
export function buildExecApprovalText(request: ExecApprovalRequest): string {
|
|
60
|
+
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
|
|
61
|
+
const lines: string[] = ["\u{1f510} \u547d\u4ee4\u6267\u884c\u5ba1\u6279", ""];
|
|
62
|
+
const cmd = request.request.commandPreview ?? request.request.command ?? "";
|
|
63
|
+
if (cmd) {
|
|
64
|
+
lines.push(`\`\`\`\n${cmd.slice(0, 300)}\n\`\`\``);
|
|
65
|
+
}
|
|
66
|
+
if (request.request.cwd) {
|
|
67
|
+
lines.push(`\u{1f4c1} \u76ee\u5f55: ${request.request.cwd}`);
|
|
68
|
+
}
|
|
69
|
+
if (request.request.agentId) {
|
|
70
|
+
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${expiresIn} \u79d2`);
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildPluginApprovalText(request: PluginApprovalRequest): string {
|
|
77
|
+
const timeoutSec = Math.round((request.request.timeoutMs ?? 120_000) / 1000);
|
|
78
|
+
const severityIcon =
|
|
79
|
+
request.request.severity === "critical"
|
|
80
|
+
? "\u{1f534}"
|
|
81
|
+
: request.request.severity === "info"
|
|
82
|
+
? "\u{1f535}"
|
|
83
|
+
: "\u{1f7e1}";
|
|
84
|
+
|
|
85
|
+
const lines: string[] = [`${severityIcon} \u5ba1\u6279\u8bf7\u6c42`, ""];
|
|
86
|
+
lines.push(`\u{1f4cb} ${request.request.title}`);
|
|
87
|
+
if (request.request.description) {
|
|
88
|
+
lines.push(`\u{1f4dd} ${request.request.description}`);
|
|
89
|
+
}
|
|
90
|
+
if (request.request.toolName) {
|
|
91
|
+
lines.push(`\u{1f527} \u5de5\u5177: ${request.request.toolName}`);
|
|
92
|
+
}
|
|
93
|
+
if (request.request.pluginId) {
|
|
94
|
+
lines.push(`\u{1f50c} \u63d2\u4ef6: ${request.request.pluginId}`);
|
|
95
|
+
}
|
|
96
|
+
if (request.request.agentId) {
|
|
97
|
+
lines.push(`\u{1f916} Agent: ${request.request.agentId}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push("", `\u23f1\ufe0f \u8d85\u65f6: ${timeoutSec} \u79d2`);
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============ Keyboard Builder ============
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the three-button inline keyboard for approval messages.
|
|
107
|
+
*
|
|
108
|
+
* type=1 (Callback): click triggers INTERACTION_CREATE, button_data = data field.
|
|
109
|
+
* group_id "approval": clicking one button grays out the others (mutual exclusion).
|
|
110
|
+
* click_limit=1: each user can only click once.
|
|
111
|
+
* permission.type=2: all users can interact.
|
|
112
|
+
*/
|
|
113
|
+
export function buildApprovalKeyboard(
|
|
114
|
+
approvalId: string,
|
|
115
|
+
allowedDecisions: readonly ApprovalDecision[] = ["allow-once", "allow-always", "deny"],
|
|
116
|
+
): InlineKeyboard {
|
|
117
|
+
const makeBtn = (
|
|
118
|
+
id: string,
|
|
119
|
+
label: string,
|
|
120
|
+
visitedLabel: string,
|
|
121
|
+
data: string,
|
|
122
|
+
style: 0 | 1,
|
|
123
|
+
): KeyboardButton => ({
|
|
124
|
+
id,
|
|
125
|
+
render_data: { label, visited_label: visitedLabel, style },
|
|
126
|
+
action: {
|
|
127
|
+
type: 1,
|
|
128
|
+
data,
|
|
129
|
+
permission: { type: 2 },
|
|
130
|
+
click_limit: 1,
|
|
131
|
+
},
|
|
132
|
+
group_id: "approval",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const buttons: KeyboardButton[] = [];
|
|
136
|
+
if (allowedDecisions.includes("allow-once")) {
|
|
137
|
+
buttons.push(
|
|
138
|
+
makeBtn(
|
|
139
|
+
"allow",
|
|
140
|
+
"\u2705 \u5141\u8bb8\u4e00\u6b21",
|
|
141
|
+
"\u5df2\u5141\u8bb8",
|
|
142
|
+
`approve:${approvalId}:allow-once`,
|
|
143
|
+
1,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (allowedDecisions.includes("allow-always")) {
|
|
148
|
+
buttons.push(
|
|
149
|
+
makeBtn(
|
|
150
|
+
"always",
|
|
151
|
+
"\u2b50 \u59cb\u7ec8\u5141\u8bb8",
|
|
152
|
+
"\u5df2\u59cb\u7ec8\u5141\u8bb8",
|
|
153
|
+
`approve:${approvalId}:allow-always`,
|
|
154
|
+
1,
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (allowedDecisions.includes("deny")) {
|
|
159
|
+
buttons.push(
|
|
160
|
+
makeBtn("deny", "\u274c \u62d2\u7edd", "\u5df2\u62d2\u7edd", `approve:${approvalId}:deny`, 0),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
content: {
|
|
166
|
+
rows: [
|
|
167
|
+
{
|
|
168
|
+
buttons,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============ Target Resolver ============
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the delivery target from a sessionKey or turnSourceTo string.
|
|
179
|
+
*
|
|
180
|
+
* Expected formats:
|
|
181
|
+
* agent:main:qqbot:direct:OPENID -> { type: "c2c", id: "OPENID" }
|
|
182
|
+
* agent:main:qqbot:c2c:OPENID -> { type: "c2c", id: "OPENID" }
|
|
183
|
+
* agent:main:qqbot:group:GROUPID -> { type: "group", id: "GROUPID" }
|
|
184
|
+
*
|
|
185
|
+
* Returns null if neither field matches the expected pattern.
|
|
186
|
+
*/
|
|
187
|
+
export function resolveApprovalTarget(
|
|
188
|
+
sessionKey: string | null | undefined,
|
|
189
|
+
turnSourceTo: string | null | undefined,
|
|
190
|
+
): ApprovalTarget | null {
|
|
191
|
+
const sk = sessionKey ?? turnSourceTo;
|
|
192
|
+
if (!sk) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const m = sk.match(/qqbot:(c2c|direct|group):([A-F0-9]+)/i);
|
|
196
|
+
if (!m) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const type: ChatScope = m[1].toLowerCase() === "group" ? "group" : "c2c";
|
|
200
|
+
return { type, id: m[2] };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ============ Interaction Parser ============
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Parse the button_data string from an INTERACTION_CREATE event.
|
|
207
|
+
*
|
|
208
|
+
* Expected format: `approve:<approvalId>:<decision>`
|
|
209
|
+
* where approvalId may be prefixed with "exec:" or "plugin:".
|
|
210
|
+
*
|
|
211
|
+
* Returns null if the data does not match the approval button format.
|
|
212
|
+
*/
|
|
213
|
+
export function parseApprovalButtonData(buttonData: string): ParsedApprovalAction | null {
|
|
214
|
+
const m = buttonData.match(
|
|
215
|
+
/^approve:((?:(?:exec|plugin):)?[0-9a-f-]+):(allow-once|allow-always|deny)$/i,
|
|
216
|
+
);
|
|
217
|
+
if (!m) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
approvalId: m[1],
|
|
222
|
+
decision: m[2] as ApprovalDecision,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadJsonFile } from "autobot/plugin-sdk/json-store";
|
|
4
|
+
import { getHomeDir, getQQBotDataDir, isWindows } from "../../utils/platform.js";
|
|
5
|
+
import type { SlashCommandResult } from "../slash-commands.js";
|
|
6
|
+
|
|
7
|
+
/** Read user-configured log file paths from local config files. */
|
|
8
|
+
function getConfiguredLogFiles(): string[] {
|
|
9
|
+
const homeDir = getHomeDir();
|
|
10
|
+
const files: string[] = [];
|
|
11
|
+
for (const cli of ["autobot", "clawdbot", "moltbot"]) {
|
|
12
|
+
try {
|
|
13
|
+
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
|
|
14
|
+
const cfg = loadJsonFile<{ logging?: { file?: unknown } }>(cfgPath);
|
|
15
|
+
const logFile = cfg?.logging?.file;
|
|
16
|
+
if (logFile && typeof logFile === "string") {
|
|
17
|
+
files.push(path.resolve(logFile));
|
|
18
|
+
}
|
|
19
|
+
break;
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return files;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Collect directories that may contain runtime logs across common install layouts. */
|
|
28
|
+
function collectCandidateLogDirs(): string[] {
|
|
29
|
+
const homeDir = getHomeDir();
|
|
30
|
+
const dirs = new Set<string>();
|
|
31
|
+
|
|
32
|
+
const pushDir = (p?: string) => {
|
|
33
|
+
if (!p) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const normalized = path.resolve(p);
|
|
37
|
+
dirs.add(normalized);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const pushStateDir = (stateDir?: string) => {
|
|
41
|
+
if (!stateDir) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
pushDir(stateDir);
|
|
45
|
+
pushDir(path.join(stateDir, "logs"));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const logFile of getConfiguredLogFiles()) {
|
|
49
|
+
pushDir(path.dirname(logFile));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
53
|
+
if (!value) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (/STATE_DIR$/i.test(key) && /(AUTOBOT|CLAWDBOT|MOLTBOT)/i.test(key)) {
|
|
57
|
+
pushStateDir(value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const name of [".autobot", ".clawdbot", ".moltbot", "autobot", "clawdbot", "moltbot"]) {
|
|
62
|
+
pushDir(path.join(homeDir, name));
|
|
63
|
+
pushDir(path.join(homeDir, name, "logs"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const searchRoots = new Set<string>([homeDir, process.cwd(), path.dirname(process.cwd())]);
|
|
67
|
+
if (process.env.APPDATA) {
|
|
68
|
+
searchRoots.add(process.env.APPDATA);
|
|
69
|
+
}
|
|
70
|
+
if (process.env.LOCALAPPDATA) {
|
|
71
|
+
searchRoots.add(process.env.LOCALAPPDATA);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const root of searchRoots) {
|
|
75
|
+
try {
|
|
76
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isDirectory()) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!/(autobot|clawdbot|moltbot)/i.test(entry.name)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const base = path.join(root, entry.name);
|
|
85
|
+
pushDir(base);
|
|
86
|
+
pushDir(path.join(base, "logs"));
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore missing or inaccessible directories.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!isWindows()) {
|
|
94
|
+
for (const name of ["autobot", "clawdbot", "moltbot"]) {
|
|
95
|
+
pushDir(path.join("/var/log", name));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const tmpRoots = new Set<string>();
|
|
100
|
+
if (isWindows()) {
|
|
101
|
+
tmpRoots.add("C:\\tmp");
|
|
102
|
+
if (process.env.TEMP) {
|
|
103
|
+
tmpRoots.add(process.env.TEMP);
|
|
104
|
+
}
|
|
105
|
+
if (process.env.TMP) {
|
|
106
|
+
tmpRoots.add(process.env.TMP);
|
|
107
|
+
}
|
|
108
|
+
if (process.env.LOCALAPPDATA) {
|
|
109
|
+
tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
tmpRoots.add("/tmp");
|
|
113
|
+
}
|
|
114
|
+
for (const tmpRoot of tmpRoots) {
|
|
115
|
+
for (const name of ["autobot", "clawdbot", "moltbot"]) {
|
|
116
|
+
pushDir(path.join(tmpRoot, name));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return Array.from(dirs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type LogCandidate = {
|
|
124
|
+
filePath: string;
|
|
125
|
+
sourceDir: string;
|
|
126
|
+
mtimeMs: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function addCollisionSuffix(filePath: string, suffix: number): string {
|
|
130
|
+
const ext = path.extname(filePath);
|
|
131
|
+
const baseName = path.basename(filePath, ext);
|
|
132
|
+
return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function writeNewTextFileSync(filePath: string, contents: string): string {
|
|
136
|
+
for (let suffix = 1; suffix <= 100; suffix++) {
|
|
137
|
+
const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix);
|
|
138
|
+
try {
|
|
139
|
+
fs.writeFileSync(candidate, contents, { encoding: "utf8", flag: "wx" });
|
|
140
|
+
return candidate;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`Could not find an unused log export filename near ${filePath}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
|
|
152
|
+
const candidates: LogCandidate[] = [];
|
|
153
|
+
const dedupe = new Set<string>();
|
|
154
|
+
|
|
155
|
+
const pushFile = (filePath: string, sourceDir: string) => {
|
|
156
|
+
const normalized = path.resolve(filePath);
|
|
157
|
+
if (dedupe.has(normalized)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const stat = fs.statSync(normalized);
|
|
162
|
+
if (!stat.isFile()) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
dedupe.add(normalized);
|
|
166
|
+
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
|
|
167
|
+
} catch {
|
|
168
|
+
// Ignore missing or inaccessible files.
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const logFile of getConfiguredLogFiles()) {
|
|
173
|
+
pushFile(logFile, path.dirname(logFile));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const dir of logDirs) {
|
|
177
|
+
pushFile(path.join(dir, "gateway.log"), dir);
|
|
178
|
+
pushFile(path.join(dir, "gateway.err.log"), dir);
|
|
179
|
+
pushFile(path.join(dir, "autobot.log"), dir);
|
|
180
|
+
pushFile(path.join(dir, "clawdbot.log"), dir);
|
|
181
|
+
pushFile(path.join(dir, "moltbot.log"), dir);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
if (!entry.isFile()) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!/\.(log|txt)$/i.test(entry.name)) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!/(gateway|autobot|clawdbot|moltbot)/i.test(entry.name)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
pushFile(path.join(dir, entry.name), dir);
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Ignore missing or inaccessible directories.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
203
|
+
return candidates;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read the last N lines of a file without loading the entire file into memory.
|
|
208
|
+
*/
|
|
209
|
+
function tailFileLines(
|
|
210
|
+
filePath: string,
|
|
211
|
+
maxLines: number,
|
|
212
|
+
): { tail: string[]; totalFileLines: number } {
|
|
213
|
+
const fd = fs.openSync(filePath, "r");
|
|
214
|
+
try {
|
|
215
|
+
const stat = fs.fstatSync(fd);
|
|
216
|
+
const fileSize = stat.size;
|
|
217
|
+
if (fileSize === 0) {
|
|
218
|
+
return { tail: [], totalFileLines: 0 };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
222
|
+
const chunks: Buffer[] = [];
|
|
223
|
+
let bytesRead = 0;
|
|
224
|
+
let position = fileSize;
|
|
225
|
+
let newlineCount = 0;
|
|
226
|
+
|
|
227
|
+
while (position > 0 && newlineCount <= maxLines) {
|
|
228
|
+
const readSize = Math.min(CHUNK_SIZE, position);
|
|
229
|
+
position -= readSize;
|
|
230
|
+
const buf = Buffer.alloc(readSize);
|
|
231
|
+
fs.readSync(fd, buf, 0, readSize, position);
|
|
232
|
+
chunks.unshift(buf);
|
|
233
|
+
bytesRead += readSize;
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < readSize; i++) {
|
|
236
|
+
if (buf[i] === 0x0a) {
|
|
237
|
+
newlineCount++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const tailContent = Buffer.concat(chunks).toString("utf8");
|
|
243
|
+
const allLines = tailContent.split("\n");
|
|
244
|
+
|
|
245
|
+
const tail = allLines.slice(-maxLines);
|
|
246
|
+
|
|
247
|
+
let totalFileLines: number;
|
|
248
|
+
if (bytesRead >= fileSize) {
|
|
249
|
+
totalFileLines = allLines.length;
|
|
250
|
+
} else {
|
|
251
|
+
const avgBytesPerLine = bytesRead / Math.max(allLines.length, 1);
|
|
252
|
+
totalFileLines = Math.round(fileSize / avgBytesPerLine);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { tail, totalFileLines };
|
|
256
|
+
} finally {
|
|
257
|
+
fs.closeSync(fd);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build the /bot-logs result: collect recent log files, write them to a temp file.
|
|
263
|
+
*/
|
|
264
|
+
export function buildBotLogsResult(): SlashCommandResult {
|
|
265
|
+
const logDirs = collectCandidateLogDirs();
|
|
266
|
+
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
|
|
267
|
+
|
|
268
|
+
if (recentFiles.length === 0) {
|
|
269
|
+
const existingDirs = logDirs.filter((d) => {
|
|
270
|
+
try {
|
|
271
|
+
return fs.existsSync(d);
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
const searched =
|
|
277
|
+
existingDirs.length > 0
|
|
278
|
+
? existingDirs.map((d) => ` • ${d}`).join("\n")
|
|
279
|
+
: logDirs
|
|
280
|
+
.slice(0, 6)
|
|
281
|
+
.map((d) => ` • ${d}`)
|
|
282
|
+
.join("\n") + (logDirs.length > 6 ? `\n …以及另外 ${logDirs.length - 6} 个路径` : "");
|
|
283
|
+
return [
|
|
284
|
+
`⚠️ 未找到日志文件`,
|
|
285
|
+
``,
|
|
286
|
+
`已搜索以下${existingDirs.length > 0 ? "存在的" : ""}路径:`,
|
|
287
|
+
searched,
|
|
288
|
+
``,
|
|
289
|
+
`💡 如果日志存放在自定义路径,请在配置中添加:`,
|
|
290
|
+
` "logging": { "file": "/path/to/your/logfile.log" }`,
|
|
291
|
+
].join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const lines: string[] = [];
|
|
295
|
+
let totalIncluded = 0;
|
|
296
|
+
let totalOriginal = 0;
|
|
297
|
+
let truncatedCount = 0;
|
|
298
|
+
const MAX_LINES_PER_FILE = 1000;
|
|
299
|
+
for (const logFile of recentFiles) {
|
|
300
|
+
try {
|
|
301
|
+
const { tail, totalFileLines } = tailFileLines(logFile.filePath, MAX_LINES_PER_FILE);
|
|
302
|
+
if (tail.length > 0) {
|
|
303
|
+
const fileName = path.basename(logFile.filePath);
|
|
304
|
+
lines.push(
|
|
305
|
+
`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`,
|
|
306
|
+
);
|
|
307
|
+
lines.push(`from: ${logFile.sourceDir}`);
|
|
308
|
+
lines.push(...tail);
|
|
309
|
+
totalIncluded += tail.length;
|
|
310
|
+
totalOriginal += totalFileLines;
|
|
311
|
+
if (totalFileLines > MAX_LINES_PER_FILE) {
|
|
312
|
+
truncatedCount++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
lines.push(`[Failed to read ${path.basename(logFile.filePath)}]`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (lines.length === 0) {
|
|
321
|
+
return `⚠️ 找到了日志文件,但无法读取。请检查文件权限。`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tmpDir = getQQBotDataDir("downloads");
|
|
325
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
326
|
+
const tmpFile = writeNewTextFileSync(
|
|
327
|
+
path.join(tmpDir, `bot-logs-${timestamp}.txt`),
|
|
328
|
+
lines.join("\n"),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const fileCount = recentFiles.length;
|
|
332
|
+
const topSources = Array.from(new Set(recentFiles.map((item) => item.sourceDir))).slice(0, 3);
|
|
333
|
+
let summaryText = `共 ${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`;
|
|
334
|
+
if (truncatedCount > 0) {
|
|
335
|
+
summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
|
|
339
|
+
filePath: tmpFile,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SlashCommandRegistry } from "../slash-commands.js";
|
|
2
|
+
import { registerApproveCommands } from "./register-approve.js";
|
|
3
|
+
import { registerBasicBotCommands } from "./register-basic.js";
|
|
4
|
+
import { registerClearStorageCommands } from "./register-clear-storage.js";
|
|
5
|
+
import { registerLogCommands } from "./register-logs.js";
|
|
6
|
+
import { registerStreamingCommands } from "./register-streaming.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register all built-in slash commands on the shared registry instance.
|
|
10
|
+
*/
|
|
11
|
+
export function registerBuiltinSlashCommands(registry: SlashCommandRegistry): void {
|
|
12
|
+
registerBasicBotCommands(registry);
|
|
13
|
+
registerLogCommands(registry);
|
|
14
|
+
registerClearStorageCommands(registry);
|
|
15
|
+
registerStreamingCommands(registry);
|
|
16
|
+
registerApproveCommands(registry);
|
|
17
|
+
}
|