@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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot debug logging utilities.
|
|
3
|
+
* QQBot 调试日志工具。
|
|
4
|
+
*
|
|
5
|
+
* Only outputs when the QQBOT_DEBUG environment variable is set,
|
|
6
|
+
* preventing user message content from leaking in production logs.
|
|
7
|
+
*
|
|
8
|
+
* Self-contained within engine/ — no framework SDK dependency.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function isQqbotDebugEnabled(): boolean {
|
|
12
|
+
const value = process.env.QQBOT_DEBUG;
|
|
13
|
+
if (typeof value !== "string") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
switch (value.trim().toLowerCase()) {
|
|
17
|
+
case "1":
|
|
18
|
+
case "on":
|
|
19
|
+
case "true":
|
|
20
|
+
case "yes":
|
|
21
|
+
return true;
|
|
22
|
+
default:
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const isDebug = () => isQqbotDebugEnabled();
|
|
28
|
+
const MAX_LOG_VALUE_CHARS = 4096;
|
|
29
|
+
|
|
30
|
+
export function sanitizeDebugLogValue(value: unknown): string {
|
|
31
|
+
let text: string;
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
text = value;
|
|
34
|
+
} else if (value instanceof Error) {
|
|
35
|
+
text = value.stack || value.message;
|
|
36
|
+
} else {
|
|
37
|
+
try {
|
|
38
|
+
text = JSON.stringify(value) ?? String(value);
|
|
39
|
+
} catch {
|
|
40
|
+
text = String(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sanitized = text
|
|
45
|
+
.replace(/\p{Cc}/gu, " ")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim();
|
|
48
|
+
if (sanitized.length <= MAX_LOG_VALUE_CHARS) {
|
|
49
|
+
return sanitized;
|
|
50
|
+
}
|
|
51
|
+
return `${sanitized.slice(0, MAX_LOG_VALUE_CHARS)}...`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatDebugLogArgs(args: unknown[]): string {
|
|
55
|
+
return args.map(sanitizeDebugLogValue).join(" ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Debug-level log; only outputs when QQBOT_DEBUG is enabled. */
|
|
59
|
+
export function debugLog(...args: unknown[]): void {
|
|
60
|
+
if (isDebug()) {
|
|
61
|
+
console.log(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Debug-level warning; only outputs when QQBOT_DEBUG is enabled. */
|
|
66
|
+
export function debugWarn(...args: unknown[]): void {
|
|
67
|
+
if (isDebug()) {
|
|
68
|
+
console.warn(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Debug-level error; only outputs when QQBOT_DEBUG is enabled. */
|
|
73
|
+
export function debugError(...args: unknown[]): void {
|
|
74
|
+
if (isDebug()) {
|
|
75
|
+
console.error(formatDebugLogArgs(args).replace(/\n|\r/g, ""));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media tag normalization for QQ Bot messages.
|
|
3
|
+
*
|
|
4
|
+
* Normalizes malformed `<qqimg>`, `<qqvoice>`, etc. tags emitted by
|
|
5
|
+
* smaller models into canonical wrapped-tag format.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Lowercase and trim a string, returning empty string for falsy input. */
|
|
11
|
+
function lc(s: string): string {
|
|
12
|
+
return (s ?? "").toLowerCase().trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Expand `~` prefix to the process home directory. */
|
|
16
|
+
function expandTilde(p: string): string {
|
|
17
|
+
if (!p) {
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
const home =
|
|
21
|
+
typeof process !== "undefined" ? (process.env.HOME ?? process.env.USERPROFILE) : undefined;
|
|
22
|
+
if (!home) {
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
if (p === "~") {
|
|
26
|
+
return home;
|
|
27
|
+
}
|
|
28
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
29
|
+
return `${home}/${p.slice(2)}`;
|
|
30
|
+
}
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Canonical media tags. `qqmedia` is the generic auto-routing tag.
|
|
35
|
+
const VALID_TAGS = ["qqimg", "qqvoice", "qqvideo", "qqfile", "qqmedia"] as const;
|
|
36
|
+
|
|
37
|
+
// Lowercased aliases that should normalize to the canonical tag set.
|
|
38
|
+
const TAG_ALIASES: Record<string, (typeof VALID_TAGS)[number]> = {
|
|
39
|
+
qq_img: "qqimg",
|
|
40
|
+
qqimage: "qqimg",
|
|
41
|
+
qq_image: "qqimg",
|
|
42
|
+
qqpic: "qqimg",
|
|
43
|
+
qq_pic: "qqimg",
|
|
44
|
+
qqpicture: "qqimg",
|
|
45
|
+
qq_picture: "qqimg",
|
|
46
|
+
qqphoto: "qqimg",
|
|
47
|
+
qq_photo: "qqimg",
|
|
48
|
+
img: "qqimg",
|
|
49
|
+
image: "qqimg",
|
|
50
|
+
pic: "qqimg",
|
|
51
|
+
picture: "qqimg",
|
|
52
|
+
photo: "qqimg",
|
|
53
|
+
qq_voice: "qqvoice",
|
|
54
|
+
qqaudio: "qqvoice",
|
|
55
|
+
qq_audio: "qqvoice",
|
|
56
|
+
voice: "qqvoice",
|
|
57
|
+
audio: "qqvoice",
|
|
58
|
+
qq_video: "qqvideo",
|
|
59
|
+
video: "qqvideo",
|
|
60
|
+
qq_file: "qqfile",
|
|
61
|
+
qqdoc: "qqfile",
|
|
62
|
+
qq_doc: "qqfile",
|
|
63
|
+
file: "qqfile",
|
|
64
|
+
doc: "qqfile",
|
|
65
|
+
document: "qqfile",
|
|
66
|
+
qq_media: "qqmedia",
|
|
67
|
+
media: "qqmedia",
|
|
68
|
+
attachment: "qqmedia",
|
|
69
|
+
attach: "qqmedia",
|
|
70
|
+
qqattachment: "qqmedia",
|
|
71
|
+
qq_attachment: "qqmedia",
|
|
72
|
+
qqsend: "qqmedia",
|
|
73
|
+
qq_send: "qqmedia",
|
|
74
|
+
send: "qqmedia",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const ALL_TAG_NAMES = [...VALID_TAGS, ...Object.keys(TAG_ALIASES)];
|
|
78
|
+
ALL_TAG_NAMES.sort((a, b) => b.length - a.length);
|
|
79
|
+
|
|
80
|
+
const TAG_NAME_PATTERN = ALL_TAG_NAMES.join("|");
|
|
81
|
+
|
|
82
|
+
const LEFT_BRACKET = "(?:[<\uff1c\u003c]|<)";
|
|
83
|
+
const RIGHT_BRACKET = "(?:[>\uff1e\u003e]|>)";
|
|
84
|
+
|
|
85
|
+
/** Match self-closing media-tag syntax with file/src/path/url attributes. */
|
|
86
|
+
export const SELF_CLOSING_TAG_REGEX = new RegExp(
|
|
87
|
+
"`?" +
|
|
88
|
+
LEFT_BRACKET +
|
|
89
|
+
"\\s*(" +
|
|
90
|
+
TAG_NAME_PATTERN +
|
|
91
|
+
")" +
|
|
92
|
+
"(?:\\s+(?!file|src|path|url)[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" +
|
|
93
|
+
"\\s+(?:file|src|path|url)\\s*=\\s*" +
|
|
94
|
+
"[\"']?" +
|
|
95
|
+
"([^\"'\\s>\uff1e]+?)" +
|
|
96
|
+
"[\"']?" +
|
|
97
|
+
"(?:\\s+[a-z_-]+\\s*=\\s*[\"']?[^\"'\\s\uff1c<>\uff1e>]*?[\"']?)*" +
|
|
98
|
+
"\\s*/?" +
|
|
99
|
+
"\\s*" +
|
|
100
|
+
RIGHT_BRACKET +
|
|
101
|
+
"`?",
|
|
102
|
+
"gi",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** Match malformed wrapped media tags that should be normalized. */
|
|
106
|
+
export const FUZZY_MEDIA_TAG_REGEX = new RegExp(
|
|
107
|
+
"`?" +
|
|
108
|
+
LEFT_BRACKET +
|
|
109
|
+
"\\s*(" +
|
|
110
|
+
TAG_NAME_PATTERN +
|
|
111
|
+
")\\s*" +
|
|
112
|
+
RIGHT_BRACKET +
|
|
113
|
+
"[\"']?\\s*" +
|
|
114
|
+
"([^<\uff1c<\uff1e>\"'`]+?)" +
|
|
115
|
+
"\\s*[\"']?" +
|
|
116
|
+
LEFT_BRACKET +
|
|
117
|
+
"\\s*/?\\s*(?:" +
|
|
118
|
+
TAG_NAME_PATTERN +
|
|
119
|
+
")\\s*" +
|
|
120
|
+
RIGHT_BRACKET +
|
|
121
|
+
"`?",
|
|
122
|
+
"gi",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** Normalize a raw tag name into the canonical tag set. */
|
|
126
|
+
function resolveTagName(raw: string): (typeof VALID_TAGS)[number] {
|
|
127
|
+
const lower = lc(raw);
|
|
128
|
+
if ((VALID_TAGS as readonly string[]).includes(lower)) {
|
|
129
|
+
return lower as (typeof VALID_TAGS)[number];
|
|
130
|
+
}
|
|
131
|
+
return TAG_ALIASES[lower] ?? "qqimg";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Match wrapped tags whose bodies need newline and tab cleanup. */
|
|
135
|
+
const MULTILINE_TAG_CLEANUP = new RegExp(
|
|
136
|
+
"(" +
|
|
137
|
+
LEFT_BRACKET +
|
|
138
|
+
"\\s*(?:" +
|
|
139
|
+
TAG_NAME_PATTERN +
|
|
140
|
+
")\\s*" +
|
|
141
|
+
RIGHT_BRACKET +
|
|
142
|
+
")" +
|
|
143
|
+
"([\\s\\S]*?)" +
|
|
144
|
+
"(" +
|
|
145
|
+
LEFT_BRACKET +
|
|
146
|
+
"\\s*/?\\s*(?:" +
|
|
147
|
+
TAG_NAME_PATTERN +
|
|
148
|
+
")\\s*" +
|
|
149
|
+
RIGHT_BRACKET +
|
|
150
|
+
")",
|
|
151
|
+
"gi",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/** Normalize malformed media-tag output into canonical wrapped tags. */
|
|
155
|
+
export function normalizeMediaTags(text: string): string {
|
|
156
|
+
const normalizeWrappedTag = (_match: string, rawTag: string, content: string): string => {
|
|
157
|
+
const tag = resolveTagName(rawTag);
|
|
158
|
+
const trimmed = content.trim();
|
|
159
|
+
if (!trimmed) {
|
|
160
|
+
return _match;
|
|
161
|
+
}
|
|
162
|
+
const expanded = expandTilde(trimmed);
|
|
163
|
+
return `<${tag}>${expanded}</${tag}>`;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, normalizeWrappedTag);
|
|
167
|
+
|
|
168
|
+
cleaned = cleaned.replace(
|
|
169
|
+
MULTILINE_TAG_CLEANUP,
|
|
170
|
+
(_m, open: string, body: string, close: string) => {
|
|
171
|
+
const flat = body.replace(/[\r\n\t]+/g, " ").replace(/ {2,}/g, " ");
|
|
172
|
+
return open + flat + close;
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, normalizeWrappedTag);
|
|
177
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured payload parsing and encoding for QQ Bot messages.
|
|
3
|
+
*
|
|
4
|
+
* Handles `QQBOT_PAYLOAD:` (model-emitted structured payloads) and
|
|
5
|
+
* `QQBOT_CRON:` (persisted cron reminder payloads).
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChatScope } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** Structured reminder payload emitted by the model. */
|
|
13
|
+
export interface CronReminderPayload {
|
|
14
|
+
type: "cron_reminder";
|
|
15
|
+
content: string;
|
|
16
|
+
targetType: ChatScope;
|
|
17
|
+
targetAddress: string;
|
|
18
|
+
originalMessageId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Structured media payload emitted by the model. */
|
|
22
|
+
export interface MediaPayload {
|
|
23
|
+
type: "media";
|
|
24
|
+
mediaType: "image" | "audio" | "video" | "file";
|
|
25
|
+
source: "url" | "file";
|
|
26
|
+
path: string;
|
|
27
|
+
caption?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type QQBotPayload = CronReminderPayload | MediaPayload;
|
|
31
|
+
|
|
32
|
+
/** Result of parsing model output into a structured payload. */
|
|
33
|
+
interface ParseResult {
|
|
34
|
+
isPayload: boolean;
|
|
35
|
+
payload?: QQBotPayload;
|
|
36
|
+
text?: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PAYLOAD_PREFIX = "QQBOT_PAYLOAD:";
|
|
41
|
+
const CRON_PREFIX = "QQBOT_CRON:";
|
|
42
|
+
|
|
43
|
+
function formatErr(e: unknown): string {
|
|
44
|
+
return e instanceof Error ? e.message : String(e);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeBase64ForCompare(value: string): string {
|
|
48
|
+
return value.replace(/=+$/u, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function decodeStrictBase64Utf8(value: string): string {
|
|
52
|
+
const buffer = Buffer.from(value, "base64");
|
|
53
|
+
if (normalizeBase64ForCompare(buffer.toString("base64")) !== normalizeBase64ForCompare(value)) {
|
|
54
|
+
throw new Error("Cron payload body is not valid base64");
|
|
55
|
+
}
|
|
56
|
+
return buffer.toString("utf-8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse model output that may start with the QQ Bot structured payload prefix. */
|
|
60
|
+
export function parseQQBotPayload(text: string): ParseResult {
|
|
61
|
+
const trimmedText = text.trim();
|
|
62
|
+
|
|
63
|
+
if (!trimmedText.startsWith(PAYLOAD_PREFIX)) {
|
|
64
|
+
return { isPayload: false, text };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const jsonContent = trimmedText.slice(PAYLOAD_PREFIX.length).trim();
|
|
68
|
+
|
|
69
|
+
if (!jsonContent) {
|
|
70
|
+
return { isPayload: true, error: "Payload body is empty" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const payload = JSON.parse(jsonContent) as QQBotPayload;
|
|
75
|
+
|
|
76
|
+
if (!payload.type) {
|
|
77
|
+
return { isPayload: true, error: "Payload is missing the type field" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (payload.type === "cron_reminder") {
|
|
81
|
+
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
|
82
|
+
return {
|
|
83
|
+
isPayload: true,
|
|
84
|
+
error:
|
|
85
|
+
"cron_reminder payload is missing required fields (content, targetType, targetAddress)",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
} else if (payload.type === "media") {
|
|
89
|
+
if (!payload.mediaType || !payload.source || !payload.path) {
|
|
90
|
+
return {
|
|
91
|
+
isPayload: true,
|
|
92
|
+
error: "media payload is missing required fields (mediaType, source, path)",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { isPayload: true, payload };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return { isPayload: true, error: `Failed to parse JSON: ${formatErr(e)}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Encode a cron reminder payload into the stored cron-message format. */
|
|
104
|
+
export function encodePayloadForCron(payload: CronReminderPayload): string {
|
|
105
|
+
const jsonString = JSON.stringify(payload);
|
|
106
|
+
const base64 = Buffer.from(jsonString, "utf-8").toString("base64");
|
|
107
|
+
return `${CRON_PREFIX}${base64}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Decode a stored cron payload. */
|
|
111
|
+
export function decodeCronPayload(message: string): {
|
|
112
|
+
isCronPayload: boolean;
|
|
113
|
+
payload?: CronReminderPayload;
|
|
114
|
+
error?: string;
|
|
115
|
+
} {
|
|
116
|
+
const trimmedMessage = message.trim();
|
|
117
|
+
|
|
118
|
+
if (!trimmedMessage.startsWith(CRON_PREFIX)) {
|
|
119
|
+
return { isCronPayload: false };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const base64Content = trimmedMessage.slice(CRON_PREFIX.length);
|
|
123
|
+
|
|
124
|
+
if (!base64Content) {
|
|
125
|
+
return { isCronPayload: true, error: "Cron payload body is empty" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const jsonString = decodeStrictBase64Utf8(base64Content);
|
|
130
|
+
const payload = JSON.parse(jsonString) as CronReminderPayload;
|
|
131
|
+
|
|
132
|
+
if (payload.type !== "cron_reminder") {
|
|
133
|
+
return {
|
|
134
|
+
isCronPayload: true,
|
|
135
|
+
error: `Expected type cron_reminder but got ${String(payload.type)}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!payload.content || !payload.targetType || !payload.targetAddress) {
|
|
140
|
+
return { isCronPayload: true, error: "Cron payload is missing required fields" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { isCronPayload: true, payload };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { isCronPayload: true, error: `Failed to decode cron payload: ${formatErr(e)}` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Type guard for cron reminder payloads. */
|
|
150
|
+
export function isCronReminderPayload(payload: QQBotPayload): payload is CronReminderPayload {
|
|
151
|
+
return payload.type === "cron_reminder";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Type guard for media payloads. */
|
|
155
|
+
export function isMediaPayload(payload: QQBotPayload): payload is MediaPayload {
|
|
156
|
+
return payload.type === "media";
|
|
157
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform path and detection helpers for core/ modules.
|
|
3
|
+
*
|
|
4
|
+
* Provides home/data/media directory helpers, platform detection,
|
|
5
|
+
* silk-wasm availability checks — all without importing `autobot/plugin-sdk`.
|
|
6
|
+
* The temp-directory fallback is delegated to the PlatformAdapter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { getPlatformAdapter } from "../adapter/index.js";
|
|
13
|
+
import { formatErrorMessage } from "./format.js";
|
|
14
|
+
import { debugLog, debugWarn } from "./log.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the current user's home directory safely across platforms.
|
|
18
|
+
*
|
|
19
|
+
* Priority:
|
|
20
|
+
* 1. `os.homedir()`
|
|
21
|
+
* 2. `$HOME` or `%USERPROFILE%`
|
|
22
|
+
* 3. PlatformAdapter.getTempDir() as a last resort
|
|
23
|
+
*/
|
|
24
|
+
export function getHomeDir(): string {
|
|
25
|
+
try {
|
|
26
|
+
const home = os.homedir();
|
|
27
|
+
if (home && fs.existsSync(home)) {
|
|
28
|
+
return home;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* fallback */
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const envHome = process.env.HOME || process.env.USERPROFILE;
|
|
35
|
+
if (envHome && fs.existsSync(envHome)) {
|
|
36
|
+
return envHome;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return getPlatformAdapter().getTempDir();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return a path under `~/.autobot/qqbot` without creating it. */
|
|
43
|
+
export function getQQBotDataPath(...subPaths: string[]): string {
|
|
44
|
+
return path.join(getHomeDir(), ".autobot", "qqbot", ...subPaths);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return a path under `~/.autobot/qqbot`, creating it on demand. */
|
|
48
|
+
export function getQQBotDataDir(...subPaths: string[]): string {
|
|
49
|
+
const dir = getQQBotDataPath(...subPaths);
|
|
50
|
+
if (!fs.existsSync(dir)) {
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return a path under `~/.autobot/media/qqbot` without creating it.
|
|
58
|
+
*
|
|
59
|
+
* Unlike `getQQBotDataPath`, this lives under AutoBot's core media allowlist so
|
|
60
|
+
* downloaded images and audio can be accessed by framework media tooling.
|
|
61
|
+
*/
|
|
62
|
+
export function getQQBotMediaPath(...subPaths: string[]): string {
|
|
63
|
+
return path.join(getHomeDir(), ".autobot", "media", "qqbot", ...subPaths);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Return a path under `~/.autobot/media/qqbot`, creating it on demand. */
|
|
67
|
+
export function getQQBotMediaDir(...subPaths: string[]): string {
|
|
68
|
+
const dir = getQQBotMediaPath(...subPaths);
|
|
69
|
+
if (!fs.existsSync(dir)) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Return `~/.autobot/media`, AutoBot's shared media root.
|
|
77
|
+
*
|
|
78
|
+
* This mirrors the directory that core's `buildMediaLocalRoots` exposes as an
|
|
79
|
+
* allowlisted location (see `autobot/src/media/local-roots.ts`). Using it as a
|
|
80
|
+
* QQ Bot payload root lets the plugin trust framework-produced files that live
|
|
81
|
+
* in sibling subdirectories such as `outbound/` (written by
|
|
82
|
+
* `saveMediaBuffer(..., "outbound", ...)`) or `inbound/`, while still keeping
|
|
83
|
+
* the check anchored to a single, well-known directory.
|
|
84
|
+
*/
|
|
85
|
+
function getAutoBotMediaDir(): string {
|
|
86
|
+
return path.join(getHomeDir(), ".autobot", "media");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- Basic platform information ----
|
|
90
|
+
|
|
91
|
+
type PlatformType = "darwin" | "linux" | "win32" | "other";
|
|
92
|
+
|
|
93
|
+
export function getPlatform(): PlatformType {
|
|
94
|
+
const p = process.platform;
|
|
95
|
+
if (p === "darwin" || p === "linux" || p === "win32") {
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
return "other";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isWindows(): boolean {
|
|
102
|
+
return process.platform === "win32";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Return the preferred temporary directory. */
|
|
106
|
+
export function getTempDir(): string {
|
|
107
|
+
return getPlatformAdapter().getTempDir();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- silk-wasm detection ----
|
|
111
|
+
|
|
112
|
+
let silkWasmAvailable: boolean | null = null;
|
|
113
|
+
|
|
114
|
+
/** Check whether silk-wasm can run in the current environment. */
|
|
115
|
+
export async function checkSilkWasmAvailable(): Promise<boolean> {
|
|
116
|
+
if (silkWasmAvailable !== null) {
|
|
117
|
+
return silkWasmAvailable;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const { isSilk } = await import("silk-wasm");
|
|
121
|
+
isSilk(new Uint8Array(0));
|
|
122
|
+
silkWasmAvailable = true;
|
|
123
|
+
debugLog("[platform] silk-wasm: available");
|
|
124
|
+
} catch (err) {
|
|
125
|
+
silkWasmAvailable = false;
|
|
126
|
+
debugWarn(`[platform] silk-wasm: NOT available (${formatErrorMessage(err)})`);
|
|
127
|
+
}
|
|
128
|
+
return silkWasmAvailable;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---- Tilde expansion and path normalization ----
|
|
132
|
+
|
|
133
|
+
/** Expand `~` to the current user's home directory. */
|
|
134
|
+
function expandTilde(p: string): string {
|
|
135
|
+
if (!p) {
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
if (p === "~") {
|
|
139
|
+
return getHomeDir();
|
|
140
|
+
}
|
|
141
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
|
142
|
+
return path.join(getHomeDir(), p.slice(2));
|
|
143
|
+
}
|
|
144
|
+
return p;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Normalize a user-provided path by trimming, stripping `file://`, and expanding `~`. */
|
|
148
|
+
export function normalizePath(p: string): string {
|
|
149
|
+
let result = p.trim();
|
|
150
|
+
if (result.startsWith("file://")) {
|
|
151
|
+
result = result.slice("file://".length);
|
|
152
|
+
try {
|
|
153
|
+
result = decodeURIComponent(result);
|
|
154
|
+
} catch {
|
|
155
|
+
// Keep the raw string if decoding fails.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return expandTilde(result);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- Local path detection ----
|
|
162
|
+
|
|
163
|
+
/** Return true when the string looks like a local filesystem path rather than a URL. */
|
|
164
|
+
export function isLocalPath(p: string): boolean {
|
|
165
|
+
if (!p) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (p.startsWith("file://")) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
if (p.startsWith("/")) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (/^[a-zA-Z]:[\\/]/.test(p)) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (p.startsWith("\\\\")) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (p.startsWith("./") || p.startsWith("../")) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (p.startsWith(".\\") || p.startsWith("..\\")) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- QQBot media path resolution ----
|
|
193
|
+
|
|
194
|
+
function isPathWithinRoot(candidate: string, root: string): boolean {
|
|
195
|
+
const relative = path.relative(root, candidate);
|
|
196
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Remap legacy or hallucinated QQ Bot local media paths to real files when possible. */
|
|
200
|
+
export function resolveQQBotLocalMediaPath(p: string): string {
|
|
201
|
+
const normalized = normalizePath(p);
|
|
202
|
+
if (!isLocalPath(normalized) || fs.existsSync(normalized)) {
|
|
203
|
+
return normalized;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const homeDir = getHomeDir();
|
|
207
|
+
const mediaRoot = getQQBotMediaPath();
|
|
208
|
+
const dataRoot = getQQBotDataPath();
|
|
209
|
+
const workspaceRoot = path.join(homeDir, ".autobot", "workspace", "qqbot");
|
|
210
|
+
const candidateRoots = [
|
|
211
|
+
{ from: workspaceRoot, to: mediaRoot },
|
|
212
|
+
{ from: dataRoot, to: mediaRoot },
|
|
213
|
+
{ from: mediaRoot, to: dataRoot },
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
for (const { from, to } of candidateRoots) {
|
|
217
|
+
if (!isPathWithinRoot(normalized, from)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const relative = path.relative(from, normalized);
|
|
221
|
+
const candidate = path.join(to, relative);
|
|
222
|
+
if (fs.existsSync(candidate)) {
|
|
223
|
+
debugWarn(`[platform] Remapped missing QQBot media path ${normalized} -> ${candidate}`);
|
|
224
|
+
return candidate;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return normalized;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve a structured-payload local file path and enforce that it stays within
|
|
233
|
+
* QQ Bot-owned storage roots.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
|
|
236
|
+
const candidate = resolveQQBotLocalMediaPath(p);
|
|
237
|
+
if (!candidate.trim()) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
242
|
+
if (!fs.existsSync(resolvedCandidate)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const canonicalCandidate = fs.realpathSync(resolvedCandidate);
|
|
247
|
+
// Trust both the QQ Bot-owned subdirectory and AutoBot's shared `~/.autobot/media`
|
|
248
|
+
// root. Core helpers like `saveMediaBuffer(..., "outbound", ...)` place framework
|
|
249
|
+
// attachments under sibling directories (e.g. `media/outbound/`) that are already
|
|
250
|
+
// part of the core media allowlist; we mirror that so auto-routed sends work
|
|
251
|
+
// without leaving the plugin's trust boundary.
|
|
252
|
+
const allowedRoots = [getAutoBotMediaDir(), getQQBotMediaPath()];
|
|
253
|
+
|
|
254
|
+
for (const root of allowedRoots) {
|
|
255
|
+
const resolvedRoot = path.resolve(root);
|
|
256
|
+
const canonicalRoot = fs.existsSync(resolvedRoot)
|
|
257
|
+
? fs.realpathSync(resolvedRoot)
|
|
258
|
+
: resolvedRoot;
|
|
259
|
+
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
|
|
260
|
+
return canonicalCandidate;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|