@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralised filename helpers for persisted QQBot state.
|
|
3
|
+
*
|
|
4
|
+
* Every persistence module routes file paths through these helpers so the
|
|
5
|
+
* naming convention stays in sync and legacy migrations are handled
|
|
6
|
+
* consistently.
|
|
7
|
+
*
|
|
8
|
+
* Key design decisions:
|
|
9
|
+
* - Credential backup is keyed only by `accountId` because recovery runs
|
|
10
|
+
* exactly when the appId is missing from config.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { getQQBotDataPath } from "./platform.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalise an identifier so it is safe to embed in a filename.
|
|
18
|
+
* Keeps alphanumerics, dot, underscore, dash; everything else becomes `_`.
|
|
19
|
+
*/
|
|
20
|
+
function safeName(id: string): string {
|
|
21
|
+
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- credential backup ----
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-accountId credential backup file. Not keyed by appId because the
|
|
28
|
+
* whole point of this file is to recover credentials when appId is
|
|
29
|
+
* missing from the live config.
|
|
30
|
+
*/
|
|
31
|
+
export function getCredentialBackupFile(accountId: string): string {
|
|
32
|
+
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Legacy single-file credential backup (pre-multi-account-isolation). */
|
|
36
|
+
export function getLegacyCredentialBackupFile(): string {
|
|
37
|
+
return path.join(getQQBotDataPath("data"), "credential-backup.json");
|
|
38
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway startup diagnostics — extracted from utils/platform.ts.
|
|
3
|
+
*
|
|
4
|
+
* Depends on utils/platform.ts for detection functions, but no plugin-sdk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { debugLog } from "./log.js";
|
|
11
|
+
import {
|
|
12
|
+
getHomeDir,
|
|
13
|
+
getTempDir,
|
|
14
|
+
getQQBotDataDir,
|
|
15
|
+
isWindows,
|
|
16
|
+
checkSilkWasmAvailable,
|
|
17
|
+
} from "./platform.js";
|
|
18
|
+
|
|
19
|
+
interface DiagnosticReport {
|
|
20
|
+
platform: string;
|
|
21
|
+
arch: string;
|
|
22
|
+
nodeVersion: string;
|
|
23
|
+
homeDir: string;
|
|
24
|
+
tempDir: string;
|
|
25
|
+
dataDir: string;
|
|
26
|
+
silkWasm: boolean;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run startup diagnostics and return an environment report.
|
|
32
|
+
* Called during gateway startup to log environment details and warnings.
|
|
33
|
+
*/
|
|
34
|
+
export async function runDiagnostics(): Promise<DiagnosticReport> {
|
|
35
|
+
const warnings: string[] = [];
|
|
36
|
+
|
|
37
|
+
const platform = `${process.platform} (${os.release()})`;
|
|
38
|
+
const arch = process.arch;
|
|
39
|
+
const nodeVersion = process.version;
|
|
40
|
+
const homeDir = getHomeDir();
|
|
41
|
+
const tempDir = getTempDir();
|
|
42
|
+
const dataDir = getQQBotDataDir();
|
|
43
|
+
|
|
44
|
+
const silkWasm = await checkSilkWasmAvailable();
|
|
45
|
+
if (!silkWasm) {
|
|
46
|
+
warnings.push(
|
|
47
|
+
"⚠️ silk-wasm is unavailable. QQ voice send/receive will not work. Ensure Node.js >= 16 and WASM support are available.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const testFile = path.join(dataDir, ".write-test");
|
|
53
|
+
fs.writeFileSync(testFile, "test");
|
|
54
|
+
fs.unlinkSync(testFile);
|
|
55
|
+
} catch {
|
|
56
|
+
warnings.push(`⚠️ Data directory is not writable: ${dataDir}. Check filesystem permissions.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (isWindows()) {
|
|
60
|
+
if (/[\u4e00-\u9fa5]/.test(homeDir) || homeDir.includes(" ")) {
|
|
61
|
+
warnings.push(
|
|
62
|
+
`⚠️ Home directory contains Chinese characters or spaces: ${homeDir}. Some tools may fail. Consider setting QQBOT_DATA_DIR to an ASCII-only path.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const report: DiagnosticReport = {
|
|
68
|
+
platform,
|
|
69
|
+
arch,
|
|
70
|
+
nodeVersion,
|
|
71
|
+
homeDir,
|
|
72
|
+
tempDir,
|
|
73
|
+
dataDir,
|
|
74
|
+
silkWasm,
|
|
75
|
+
warnings,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
debugLog("=== QQBot Environment Diagnostics ===");
|
|
79
|
+
debugLog(` Platform: ${platform} (${arch})`);
|
|
80
|
+
debugLog(` Node: ${nodeVersion}`);
|
|
81
|
+
debugLog(` Home: ${homeDir}`);
|
|
82
|
+
debugLog(` Data dir: ${dataDir}`);
|
|
83
|
+
debugLog(` silk-wasm: ${silkWasm ? "available" : "unavailable"}`);
|
|
84
|
+
if (warnings.length > 0) {
|
|
85
|
+
debugLog(" --- Warnings ---");
|
|
86
|
+
for (const w of warnings) {
|
|
87
|
+
debugLog(` ${w}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
debugLog("======================");
|
|
91
|
+
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { mimeTypeFromFilePath } from "autobot/plugin-sdk/media-mime";
|
|
5
|
+
import {
|
|
6
|
+
openLocalFileSafely,
|
|
7
|
+
readRegularFile,
|
|
8
|
+
statRegularFileSync,
|
|
9
|
+
} from "autobot/plugin-sdk/security-runtime";
|
|
10
|
+
import { getPlatformAdapter } from "../adapter/index.js";
|
|
11
|
+
import type { SsrfPolicyConfig } from "../adapter/types.js";
|
|
12
|
+
import { MediaFileType } from "../types.js";
|
|
13
|
+
import { formatErrorMessage } from "./format.js";
|
|
14
|
+
import { normalizeOptionalString } from "./string-normalize.js";
|
|
15
|
+
|
|
16
|
+
/** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */
|
|
17
|
+
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** Absolute upper bound enforced on the chunked upload path (matches server policy). */
|
|
20
|
+
const CHUNKED_UPLOAD_MAX_SIZE = 100 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Threshold used to treat an upload as a large file (dispatch to chunked path). */
|
|
23
|
+
export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-{@link MediaFileType} upload metadata: the QQ Open Platform size
|
|
27
|
+
* ceiling and the Chinese display name used in user-facing error messages.
|
|
28
|
+
*
|
|
29
|
+
* Keyed by the enum value so call sites read as
|
|
30
|
+
* `MEDIA_FILE_TYPE_INFO[MediaFileType.IMAGE].maxSize`, and adding a new
|
|
31
|
+
* type forces both fields to be supplied in a single place.
|
|
32
|
+
*/
|
|
33
|
+
const MEDIA_FILE_TYPE_INFO: Record<MediaFileType, { maxSize: number; name: string }> = {
|
|
34
|
+
[MediaFileType.IMAGE]: { maxSize: 30 * 1024 * 1024, name: "图片" },
|
|
35
|
+
[MediaFileType.VIDEO]: { maxSize: 100 * 1024 * 1024, name: "视频" },
|
|
36
|
+
[MediaFileType.VOICE]: { maxSize: 20 * 1024 * 1024, name: "语音" },
|
|
37
|
+
[MediaFileType.FILE]: { maxSize: 100 * 1024 * 1024, name: "文件" },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Return the Chinese display name for a media file type code. Defaults to "文件". */
|
|
41
|
+
export function getFileTypeName(fileType: number): string {
|
|
42
|
+
return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.name ?? "文件";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Return the upload ceiling for a given media file type. Defaults to 100MB. */
|
|
46
|
+
export function getMaxUploadSize(fileType: number): number {
|
|
47
|
+
return MEDIA_FILE_TYPE_INFO[fileType as MediaFileType]?.maxSize ?? CHUNKED_UPLOAD_MAX_SIZE;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const QQBOT_MEDIA_HOSTNAME_ALLOWLIST = [
|
|
51
|
+
// QQ rich media
|
|
52
|
+
"*.qpic.cn",
|
|
53
|
+
"*.qq.com",
|
|
54
|
+
"*.weiyun.com",
|
|
55
|
+
"*.qq.com.cn",
|
|
56
|
+
|
|
57
|
+
// QQ Bot
|
|
58
|
+
"*.ugcimg.cn",
|
|
59
|
+
|
|
60
|
+
// Tencent Cloud COS
|
|
61
|
+
"*.myqcloud.com",
|
|
62
|
+
"*.tencentcos.cn",
|
|
63
|
+
"*.tencentcos.com",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export const QQBOT_MEDIA_SSRF_POLICY: SsrfPolicyConfig = {
|
|
67
|
+
hostnameAllowlist: QQBOT_MEDIA_HOSTNAME_ALLOWLIST,
|
|
68
|
+
allowRfc2544BenchmarkRange: true,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Result of local file-size validation. */
|
|
72
|
+
interface FileSizeCheckResult {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
size: number;
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Validate that a file is within the allowed upload size. */
|
|
79
|
+
export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult {
|
|
80
|
+
try {
|
|
81
|
+
const result = statRegularFileSync(filePath);
|
|
82
|
+
if (result.missing) {
|
|
83
|
+
throw Object.assign(new Error(`File not found: ${filePath}`), { code: "ENOENT" });
|
|
84
|
+
}
|
|
85
|
+
if (result.stat.size > maxSize) {
|
|
86
|
+
const sizeMB = (result.stat.size / (1024 * 1024)).toFixed(1);
|
|
87
|
+
const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
size: result.stat.size,
|
|
91
|
+
error: `File is too large (${sizeMB}MB); QQ Bot API limit is ${limitMB}MB`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, size: result.stat.size };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
size: 0,
|
|
99
|
+
error: `Failed to read file metadata: ${formatErrorMessage(err)}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read file contents asynchronously. */
|
|
105
|
+
export async function readFileAsync(filePath: string): Promise<Buffer> {
|
|
106
|
+
return (await readRegularFile({ filePath })).buffer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Check file readability asynchronously. */
|
|
110
|
+
export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
|
111
|
+
const opened = await openLocalFileSafely({ filePath }).catch(() => null);
|
|
112
|
+
if (!opened) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
} finally {
|
|
120
|
+
await opened.handle.close().catch(() => undefined);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Format a byte count into a human-readable size string. */
|
|
125
|
+
export function formatFileSize(bytes: number): string {
|
|
126
|
+
if (bytes < 1024) {
|
|
127
|
+
return `${bytes}B`;
|
|
128
|
+
}
|
|
129
|
+
if (bytes < 1024 * 1024) {
|
|
130
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
131
|
+
}
|
|
132
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Infer a MIME type from the file extension. */
|
|
136
|
+
export function getMimeType(filePath: string): string {
|
|
137
|
+
return mimeTypeFromFilePath(filePath) ?? "application/octet-stream";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Extensions accepted as image uploads by the QQ Bot media pipeline. */
|
|
141
|
+
const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return the image MIME type for a local file path, or `null` if the
|
|
145
|
+
* extension is not in the supported image whitelist.
|
|
146
|
+
*
|
|
147
|
+
* Use this instead of `getMimeType` when the caller must enforce
|
|
148
|
+
* "image formats only" as a business rule (e.g. constructing a
|
|
149
|
+
* `data:image/...;base64,` URL).
|
|
150
|
+
*/
|
|
151
|
+
export function getImageMimeType(filePath: string): string | null {
|
|
152
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
153
|
+
if (!IMAGE_EXTENSIONS.has(ext)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const mime = mimeTypeFromFilePath(filePath);
|
|
157
|
+
return mime?.startsWith("image/") ? mime : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Download a remote file into a local directory. */
|
|
161
|
+
export async function downloadFile(
|
|
162
|
+
url: string,
|
|
163
|
+
destDir: string,
|
|
164
|
+
originalFilename?: string,
|
|
165
|
+
): Promise<string | null> {
|
|
166
|
+
try {
|
|
167
|
+
let parsedUrl: URL;
|
|
168
|
+
try {
|
|
169
|
+
parsedUrl = new URL(url);
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (parsedUrl.protocol !== "https:") {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(destDir)) {
|
|
178
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fetched = await getPlatformAdapter().fetchMedia({
|
|
182
|
+
url: parsedUrl.toString(),
|
|
183
|
+
filePathHint: originalFilename,
|
|
184
|
+
ssrfPolicy: QQBOT_MEDIA_SSRF_POLICY,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
let filename = normalizeOptionalString(originalFilename) ?? "";
|
|
188
|
+
if (!filename) {
|
|
189
|
+
filename =
|
|
190
|
+
(normalizeOptionalString(fetched.fileName) ?? path.basename(parsedUrl.pathname)) ||
|
|
191
|
+
"download";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const ts = Date.now();
|
|
195
|
+
const ext = path.extname(filename);
|
|
196
|
+
const base = path.basename(filename, ext) || "file";
|
|
197
|
+
const rand = crypto.randomBytes(3).toString("hex");
|
|
198
|
+
const safeFilename = `${base}_${ts}_${rand}${ext}`;
|
|
199
|
+
|
|
200
|
+
const destPath = path.join(destDir, safeFilename);
|
|
201
|
+
await fs.promises.writeFile(destPath, fetched.buffer);
|
|
202
|
+
return destPath;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error(
|
|
205
|
+
`[qqbot:downloadFile] FAILED url=${url.slice(0, 120)} error=${err instanceof Error ? err.message : String(err)}`,
|
|
206
|
+
);
|
|
207
|
+
if (err instanceof Error && err.stack) {
|
|
208
|
+
console.error(`[qqbot:downloadFile] stack=${err.stack.split("\n").slice(0, 3).join(" | ")}`);
|
|
209
|
+
}
|
|
210
|
+
if (err instanceof Error && err.cause) {
|
|
211
|
+
console.error(`[qqbot:downloadFile] cause=${formatErrorMessage(err.cause)}`);
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General formatting and string utilities.
|
|
3
|
+
* 通用格式化与字符串工具。
|
|
4
|
+
*
|
|
5
|
+
* Pure utility functions with zero external dependencies.
|
|
6
|
+
* Replaces `autobot/plugin-sdk/error-runtime` and `text-runtime`
|
|
7
|
+
* helpers for use inside engine/.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: The framework `formatErrorMessage` also applies `redactSensitiveText()`
|
|
10
|
+
* for token masking. We intentionally omit that here — the framework's log
|
|
11
|
+
* pipeline handles redaction at a higher level.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format any error object into a readable string.
|
|
16
|
+
* 将任意错误对象格式化为可读字符串。
|
|
17
|
+
*
|
|
18
|
+
* Traverses the `.cause` chain for nested Error objects to include
|
|
19
|
+
* the full error context (e.g. network errors wrapped inside HTTP errors).
|
|
20
|
+
*/
|
|
21
|
+
export function formatErrorMessage(err: unknown): string {
|
|
22
|
+
if (err instanceof Error) {
|
|
23
|
+
let formatted = err.message || err.name || "Error";
|
|
24
|
+
let cause: unknown = err.cause;
|
|
25
|
+
const seen = new Set<unknown>([err]);
|
|
26
|
+
while (cause && !seen.has(cause)) {
|
|
27
|
+
seen.add(cause);
|
|
28
|
+
if (cause instanceof Error) {
|
|
29
|
+
if (cause.message) {
|
|
30
|
+
formatted += ` | ${cause.message}`;
|
|
31
|
+
}
|
|
32
|
+
cause = cause.cause;
|
|
33
|
+
} else if (typeof cause === "string") {
|
|
34
|
+
formatted += ` | ${cause}`;
|
|
35
|
+
break;
|
|
36
|
+
} else {
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return formatted;
|
|
41
|
+
}
|
|
42
|
+
if (typeof err === "string") {
|
|
43
|
+
return err;
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
err === null ||
|
|
47
|
+
err === undefined ||
|
|
48
|
+
typeof err === "number" ||
|
|
49
|
+
typeof err === "boolean" ||
|
|
50
|
+
typeof err === "bigint"
|
|
51
|
+
) {
|
|
52
|
+
return String(err);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.stringify(err);
|
|
56
|
+
} catch {
|
|
57
|
+
return Object.prototype.toString.call(err);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Format a millisecond duration into a human-readable string (e.g. "5m 30s"). */
|
|
62
|
+
export function formatDuration(durationMs: number): string {
|
|
63
|
+
const seconds = Math.round(durationMs / 1000);
|
|
64
|
+
if (seconds < 60) {
|
|
65
|
+
return `${seconds}s`;
|
|
66
|
+
}
|
|
67
|
+
const minutes = Math.floor(seconds / 60);
|
|
68
|
+
const remainSeconds = seconds % 60;
|
|
69
|
+
return remainSeconds > 0 ? `${minutes}m ${remainSeconds}s` : `${minutes}m`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image dimension helpers for QQ Bot markdown image syntax.
|
|
3
|
+
*
|
|
4
|
+
* QQ Bot markdown images use ``.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Buffer } from "node:buffer";
|
|
8
|
+
import { getPlatformAdapter } from "../adapter/index.js";
|
|
9
|
+
import type { SsrfPolicyConfig } from "../adapter/types.js";
|
|
10
|
+
import { formatErrorMessage } from "./format.js";
|
|
11
|
+
import { debugLog } from "./log.js";
|
|
12
|
+
|
|
13
|
+
interface ImageSize {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default dimensions used when probing fails. */
|
|
19
|
+
const DEFAULT_IMAGE_SIZE: ImageSize = { width: 512, height: 512 };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse image dimensions from the PNG header.
|
|
23
|
+
*/
|
|
24
|
+
function parsePngSize(buffer: Buffer): ImageSize | null {
|
|
25
|
+
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
|
26
|
+
if (buffer.length < 24) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// The IHDR chunk begins at byte 8, with width/height at 16..23.
|
|
33
|
+
const width = buffer.readUInt32BE(16);
|
|
34
|
+
const height = buffer.readUInt32BE(20);
|
|
35
|
+
return { width, height };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse image dimensions from JPEG SOF0/SOF2 markers. */
|
|
39
|
+
function parseJpegSize(buffer: Buffer): ImageSize | null {
|
|
40
|
+
// JPEG signature: FF D8 FF
|
|
41
|
+
if (buffer.length < 4) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let offset = 2;
|
|
49
|
+
while (offset < buffer.length - 9) {
|
|
50
|
+
if (buffer[offset] !== 0xff) {
|
|
51
|
+
offset++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const marker = buffer[offset + 1];
|
|
56
|
+
// SOF0 (0xC0) and SOF2 (0xC2) contain dimensions.
|
|
57
|
+
if (marker === 0xc0 || marker === 0xc2) {
|
|
58
|
+
// Layout: FF C0 length(2) precision(1) height(2) width(2)
|
|
59
|
+
if (offset + 9 <= buffer.length) {
|
|
60
|
+
const height = buffer.readUInt16BE(offset + 5);
|
|
61
|
+
const width = buffer.readUInt16BE(offset + 7);
|
|
62
|
+
return { width, height };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Skip the current block.
|
|
67
|
+
if (offset + 3 < buffer.length) {
|
|
68
|
+
const blockLength = buffer.readUInt16BE(offset + 2);
|
|
69
|
+
offset += 2 + blockLength;
|
|
70
|
+
} else {
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse image dimensions from the GIF header. */
|
|
79
|
+
function parseGifSize(buffer: Buffer): ImageSize | null {
|
|
80
|
+
if (buffer.length < 10) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const signature = buffer.toString("ascii", 0, 6);
|
|
84
|
+
if (signature !== "GIF87a" && signature !== "GIF89a") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const width = buffer.readUInt16LE(6);
|
|
88
|
+
const height = buffer.readUInt16LE(8);
|
|
89
|
+
return { width, height };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse image dimensions from WebP headers. */
|
|
93
|
+
function parseWebpSize(buffer: Buffer): ImageSize | null {
|
|
94
|
+
if (buffer.length < 30) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check the RIFF and WEBP signatures.
|
|
99
|
+
const riff = buffer.toString("ascii", 0, 4);
|
|
100
|
+
const webp = buffer.toString("ascii", 8, 12);
|
|
101
|
+
if (riff !== "RIFF" || webp !== "WEBP") {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const chunkType = buffer.toString("ascii", 12, 16);
|
|
106
|
+
|
|
107
|
+
// VP8 (lossy)
|
|
108
|
+
if (chunkType === "VP8 ") {
|
|
109
|
+
// The VP8 frame header starts at byte 23 and uses the 9D 01 2A signature.
|
|
110
|
+
if (buffer.length >= 30 && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) {
|
|
111
|
+
const width = buffer.readUInt16LE(26) & 0x3fff;
|
|
112
|
+
const height = buffer.readUInt16LE(28) & 0x3fff;
|
|
113
|
+
return { width, height };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// VP8L (lossless)
|
|
118
|
+
if (chunkType === "VP8L") {
|
|
119
|
+
// VP8L signature: 0x2F
|
|
120
|
+
if (buffer.length >= 25 && buffer[20] === 0x2f) {
|
|
121
|
+
const bits = buffer.readUInt32LE(21);
|
|
122
|
+
const width = (bits & 0x3fff) + 1;
|
|
123
|
+
const height = ((bits >> 14) & 0x3fff) + 1;
|
|
124
|
+
return { width, height };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// VP8X (extended format)
|
|
129
|
+
if (chunkType === "VP8X") {
|
|
130
|
+
if (buffer.length >= 30) {
|
|
131
|
+
// Width and height live at 24..26 and 27..29 as 24-bit little-endian values.
|
|
132
|
+
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
|
|
133
|
+
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
|
|
134
|
+
return { width, height };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse image dimensions from raw image bytes. */
|
|
142
|
+
export function parseImageSize(buffer: Buffer): ImageSize | null {
|
|
143
|
+
// Try each supported image format in sequence.
|
|
144
|
+
return (
|
|
145
|
+
parsePngSize(buffer) ?? parseJpegSize(buffer) ?? parseGifSize(buffer) ?? parseWebpSize(buffer)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* SSRF policy for image-dimension probing. Generic public-network-only blocking
|
|
151
|
+
* (no hostname allowlist) because markdown image URLs can legitimately point to
|
|
152
|
+
* any public host, not just QQ-owned CDNs.
|
|
153
|
+
*/
|
|
154
|
+
const IMAGE_PROBE_SSRF_POLICY: SsrfPolicyConfig = {};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fetch image dimensions from a public URL using only the first 64 KB.
|
|
158
|
+
*
|
|
159
|
+
* Uses {@link readRemoteMediaBuffer} with SSRF guard to block probes against
|
|
160
|
+
* private/reserved/loopback/link-local/metadata destinations.
|
|
161
|
+
*/
|
|
162
|
+
export async function getImageSizeFromUrl(
|
|
163
|
+
url: string,
|
|
164
|
+
timeoutMs = 5000,
|
|
165
|
+
): Promise<ImageSize | null> {
|
|
166
|
+
try {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const { buffer } = await getPlatformAdapter().fetchMedia({
|
|
172
|
+
url,
|
|
173
|
+
maxBytes: 65_536,
|
|
174
|
+
maxRedirects: 0,
|
|
175
|
+
ssrfPolicy: IMAGE_PROBE_SSRF_POLICY,
|
|
176
|
+
requestInit: {
|
|
177
|
+
signal: controller.signal,
|
|
178
|
+
headers: {
|
|
179
|
+
Range: "bytes=0-65535",
|
|
180
|
+
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const size = parseImageSize(buffer);
|
|
186
|
+
if (size) {
|
|
187
|
+
debugLog(
|
|
188
|
+
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return size;
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timeoutId);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${formatErrorMessage(err)}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Parse image dimensions from a Base64 data URL. */
|
|
202
|
+
function getImageSizeFromDataUrl(dataUrl: string): ImageSize | null {
|
|
203
|
+
try {
|
|
204
|
+
// Format: data:image/png;base64,xxxxx
|
|
205
|
+
const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
|
|
206
|
+
if (!matches) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const base64Data = matches[1];
|
|
211
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
212
|
+
|
|
213
|
+
const size = parseImageSize(buffer);
|
|
214
|
+
if (size) {
|
|
215
|
+
debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return size;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
debugLog(`[image-size] Error parsing Base64: ${formatErrorMessage(err)}`);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve image dimensions from either an HTTP URL or a Base64 data URL.
|
|
227
|
+
*/
|
|
228
|
+
export async function getImageSize(source: string): Promise<ImageSize | null> {
|
|
229
|
+
if (source.startsWith("data:")) {
|
|
230
|
+
return getImageSizeFromDataUrl(source);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (source.startsWith("http://") || source.startsWith("https://")) {
|
|
234
|
+
return getImageSizeFromUrl(source);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Format a markdown image with QQ Bot width/height annotations. */
|
|
241
|
+
export function formatQQBotMarkdownImage(url: string, size: ImageSize | null): string {
|
|
242
|
+
const { width, height } = size ?? DEFAULT_IMAGE_SIZE;
|
|
243
|
+
return ``;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Return true when markdown already contains QQ Bot size annotations. */
|
|
247
|
+
export function hasQQBotImageSize(markdownImage: string): boolean {
|
|
248
|
+
return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
|
|
249
|
+
}
|