@openclaw/qqbot 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -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 +237 -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 +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -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 +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -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.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -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 +139 -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.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -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 +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -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 +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -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 +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -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 +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -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 +702 -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 +45 -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 +729 -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.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -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 +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
formatAttachmentTags,
|
|
4
|
+
renderAttachmentTags,
|
|
5
|
+
TRANSCRIPT_SOURCE_LABELS,
|
|
6
|
+
type AttachmentSummary,
|
|
7
|
+
} from "./attachment-tags.js";
|
|
8
|
+
|
|
9
|
+
describe("engine/utils/attachment-tags", () => {
|
|
10
|
+
// ────────────────────────── shared body (mode-agnostic) ──────────────────────────
|
|
11
|
+
|
|
12
|
+
describe("shared tag body", () => {
|
|
13
|
+
it("returns empty string for missing/empty input", () => {
|
|
14
|
+
expect(formatAttachmentTags()).toBe("");
|
|
15
|
+
expect(formatAttachmentTags([])).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("collapses to MEDIA:{source} when a path/url is present", () => {
|
|
19
|
+
expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe(
|
|
20
|
+
"MEDIA:/tmp/a.png",
|
|
21
|
+
);
|
|
22
|
+
expect(formatAttachmentTags([{ type: "file", url: "https://x/y.pdf" }])).toBe(
|
|
23
|
+
"MEDIA:https://x/y.pdf",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("inlines voice transcript only for voice attachments", () => {
|
|
28
|
+
expect(
|
|
29
|
+
formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]),
|
|
30
|
+
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
|
|
31
|
+
// Non-voice attachments never get the transcript suffix even if one
|
|
32
|
+
// is present on the summary.
|
|
33
|
+
expect(
|
|
34
|
+
formatAttachmentTags([
|
|
35
|
+
{ type: "image", localPath: "/tmp/i.png", transcript: "unused" } as AttachmentSummary,
|
|
36
|
+
]),
|
|
37
|
+
).toBe("MEDIA:/tmp/i.png");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to bracketed tags when no source is available", () => {
|
|
41
|
+
expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]");
|
|
42
|
+
expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]");
|
|
43
|
+
expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]");
|
|
44
|
+
expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe(
|
|
45
|
+
'[voice (transcript: "t")]',
|
|
46
|
+
);
|
|
47
|
+
expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]");
|
|
48
|
+
expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]");
|
|
49
|
+
expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("joins multiple entries with newline in inline mode", () => {
|
|
53
|
+
expect(
|
|
54
|
+
formatAttachmentTags([
|
|
55
|
+
{ type: "image", localPath: "/tmp/a.png" },
|
|
56
|
+
{ type: "voice", transcript: "hi" },
|
|
57
|
+
]),
|
|
58
|
+
).toBe('MEDIA:/tmp/a.png\n[voice (transcript: "hi")]');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ────────────────────────── ref mode = body + source suffix ──────────────────────────
|
|
63
|
+
|
|
64
|
+
describe("ref mode consistency with inline", () => {
|
|
65
|
+
it("produces the same body as inline for non-voice attachments", () => {
|
|
66
|
+
const att: AttachmentSummary[] = [
|
|
67
|
+
{ type: "image", localPath: "/tmp/a.png" },
|
|
68
|
+
{ type: "file", filename: "b.pdf" },
|
|
69
|
+
];
|
|
70
|
+
// Rendered one at a time so separator differences don't matter.
|
|
71
|
+
for (const a of att) {
|
|
72
|
+
expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
|
|
73
|
+
renderAttachmentTags([a], { mode: "ref" }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("produces the same body as inline for voice without transcriptSource", () => {
|
|
79
|
+
const cases: AttachmentSummary[] = [
|
|
80
|
+
{ type: "voice" },
|
|
81
|
+
{ type: "voice", transcript: "hi" },
|
|
82
|
+
{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" },
|
|
83
|
+
];
|
|
84
|
+
for (const a of cases) {
|
|
85
|
+
expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
|
|
86
|
+
renderAttachmentTags([a], { mode: "ref" }),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("appends ' [source: …]' ONLY for voice + transcript + transcriptSource in ref mode", () => {
|
|
92
|
+
// ref mode: suffix appears.
|
|
93
|
+
expect(
|
|
94
|
+
renderAttachmentTags(
|
|
95
|
+
[{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
|
|
96
|
+
{ mode: "ref" },
|
|
97
|
+
),
|
|
98
|
+
).toBe('MEDIA:/tmp/v.wav (transcript: "hi") [source: local STT]');
|
|
99
|
+
|
|
100
|
+
// inline mode: suffix NEVER appears, even with transcriptSource set.
|
|
101
|
+
expect(
|
|
102
|
+
renderAttachmentTags(
|
|
103
|
+
[{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
|
|
104
|
+
{ mode: "inline" },
|
|
105
|
+
),
|
|
106
|
+
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("omits the source suffix when transcriptSource is missing (both modes identical)", () => {
|
|
110
|
+
const att: AttachmentSummary = { type: "voice", transcript: "hi" };
|
|
111
|
+
expect(renderAttachmentTags([att], { mode: "ref" })).toBe(
|
|
112
|
+
renderAttachmentTags([att], { mode: "inline" }),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("joins with space in ref mode", () => {
|
|
117
|
+
expect(
|
|
118
|
+
renderAttachmentTags(
|
|
119
|
+
[
|
|
120
|
+
{ type: "image", filename: "a.png" },
|
|
121
|
+
{ type: "voice", transcript: "hi" },
|
|
122
|
+
],
|
|
123
|
+
{ mode: "ref" },
|
|
124
|
+
),
|
|
125
|
+
).toBe('[image: a.png] [voice (transcript: "hi")]');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ────────────────────────── Prompt-contract regression guards ──────────────────────────
|
|
130
|
+
|
|
131
|
+
describe("prompt contract", () => {
|
|
132
|
+
it("exposes the transcript-source labels table", () => {
|
|
133
|
+
expect(TRANSCRIPT_SOURCE_LABELS.stt).toBe("local STT");
|
|
134
|
+
expect(TRANSCRIPT_SOURCE_LABELS.asr).toBe("platform ASR");
|
|
135
|
+
expect(TRANSCRIPT_SOURCE_LABELS.tts).toBe("TTS source");
|
|
136
|
+
expect(TRANSCRIPT_SOURCE_LABELS.fallback).toBe("fallback text");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("uses the single canonical keyword 'transcript:' (never 'content:')", () => {
|
|
140
|
+
// If anyone reintroduces 'content:' the regex below will match and fail the test.
|
|
141
|
+
const samples = [
|
|
142
|
+
formatAttachmentTags([{ type: "voice", transcript: "t" }]),
|
|
143
|
+
renderAttachmentTags([{ type: "voice", transcript: "t", transcriptSource: "asr" }], {
|
|
144
|
+
mode: "ref",
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
for (const s of samples) {
|
|
148
|
+
expect(s).toMatch(/transcript:/);
|
|
149
|
+
expect(s).not.toMatch(/content:/);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses the single canonical type label 'voice' (never 'voice message')", () => {
|
|
154
|
+
const samples = [
|
|
155
|
+
renderAttachmentTags([{ type: "voice" }], { mode: "inline" }),
|
|
156
|
+
renderAttachmentTags([{ type: "voice", transcript: "hi" }], { mode: "ref" }),
|
|
157
|
+
];
|
|
158
|
+
for (const s of samples) {
|
|
159
|
+
expect(s).not.toMatch(/voice message/);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ────────────────────────── Options ──────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("options", () => {
|
|
167
|
+
it("respects a custom separator", () => {
|
|
168
|
+
expect(
|
|
169
|
+
renderAttachmentTags(
|
|
170
|
+
[
|
|
171
|
+
{ type: "image", filename: "a" },
|
|
172
|
+
{ type: "video", filename: "b" },
|
|
173
|
+
],
|
|
174
|
+
{ mode: "inline", separator: " | " },
|
|
175
|
+
),
|
|
176
|
+
).toBe("[image: a] | [video: b]");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("returns the emptyFallback when input is empty", () => {
|
|
180
|
+
expect(renderAttachmentTags(undefined, { mode: "ref", emptyFallback: "(none)" })).toBe(
|
|
181
|
+
"(none)",
|
|
182
|
+
);
|
|
183
|
+
expect(renderAttachmentTags([], { mode: "inline", emptyFallback: "" })).toBe("");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for rendering attachment summaries as
|
|
3
|
+
* human-readable tags that the LLM sees.
|
|
4
|
+
*
|
|
5
|
+
* There is exactly ONE vocabulary shared by every consumer:
|
|
6
|
+
*
|
|
7
|
+
* • Type labels: `image` / `voice` / `video` / `file` / `attachment`
|
|
8
|
+
* • Keyword for voice text: `transcript:` (never `content:`)
|
|
9
|
+
* • With source: `MEDIA:{source}` (no bracketed alias)
|
|
10
|
+
* • Without source: `[{type}]` or `[{type}: {filename}]`
|
|
11
|
+
*
|
|
12
|
+
* Both consumers (group history / current inbound turn, and the ref-index
|
|
13
|
+
* quoted-message block) call the same function with the same vocabulary.
|
|
14
|
+
* They differ only on two orthogonal dimensions:
|
|
15
|
+
*
|
|
16
|
+
* 1. `transcriptSource` — ref mode appends `[source: local STT]` (or
|
|
17
|
+
* similar) after a voice transcript so the model knows where the
|
|
18
|
+
* text came from. Inline mode omits this (the current turn knows
|
|
19
|
+
* its own STT provenance).
|
|
20
|
+
*
|
|
21
|
+
* 2. Separator — inline joins with `\n` (history replay is multi-line),
|
|
22
|
+
* ref joins with a space (quoted block is rendered inline).
|
|
23
|
+
*
|
|
24
|
+
* These are the ONLY permitted differences between modes. Any new
|
|
25
|
+
* decoration must be added in both modes or behind an explicit option
|
|
26
|
+
* documented here, otherwise the model ends up learning two dialects.
|
|
27
|
+
*
|
|
28
|
+
* Zero external dependencies — pure string formatting.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { RefAttachmentSummary } from "../ref/types.js";
|
|
32
|
+
|
|
33
|
+
// ============ Types ============
|
|
34
|
+
|
|
35
|
+
/** Canonical attachment shape shared by history entries and ref entries. */
|
|
36
|
+
export type AttachmentSummary = RefAttachmentSummary;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Rendering mode.
|
|
40
|
+
*
|
|
41
|
+
* - `"inline"`: current turn + history replay. No transcript-source tag.
|
|
42
|
+
* Tags are separated by newlines.
|
|
43
|
+
* - `"ref"`: quoted-message block. Appends `[source: …]` to voice
|
|
44
|
+
* transcripts when `transcriptSource` is present. Tags are separated
|
|
45
|
+
* by spaces so the block fits on one line.
|
|
46
|
+
*/
|
|
47
|
+
type RenderMode = "inline" | "ref";
|
|
48
|
+
|
|
49
|
+
/** Human-readable labels for transcript provenance (prompt contract). */
|
|
50
|
+
export const TRANSCRIPT_SOURCE_LABELS: Record<
|
|
51
|
+
NonNullable<RefAttachmentSummary["transcriptSource"]>,
|
|
52
|
+
string
|
|
53
|
+
> = {
|
|
54
|
+
stt: "local STT",
|
|
55
|
+
asr: "platform ASR",
|
|
56
|
+
tts: "TTS source",
|
|
57
|
+
fallback: "fallback text",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Options controlling how the tag list is rendered. */
|
|
61
|
+
interface RenderOptions {
|
|
62
|
+
mode: RenderMode;
|
|
63
|
+
/** Separator between tags. Defaults per mode: inline=`\n`, ref=` `. */
|
|
64
|
+
separator?: string;
|
|
65
|
+
/** Returned when `attachments` is empty/undefined. Defaults to `""`. */
|
|
66
|
+
emptyFallback?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============ Public API ============
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a list of attachments into an LLM-facing tag string.
|
|
73
|
+
*
|
|
74
|
+
* Shared grammar (both modes):
|
|
75
|
+
*
|
|
76
|
+
* ```
|
|
77
|
+
* attachment_with_source := "MEDIA:" SOURCE [voice_suffix]
|
|
78
|
+
* voice_suffix := ' (transcript: "' TEXT '")' [source_suffix]
|
|
79
|
+
* attachment_no_source := "[" TYPE_LABEL [": " FILENAME] [voice_suffix_bare] "]" [source_suffix_bare]
|
|
80
|
+
* voice_suffix_bare := ' (transcript: "' TEXT '")'
|
|
81
|
+
* source_suffix := " [source: " LABEL "]" ← ref mode only
|
|
82
|
+
* source_suffix_bare := " [source: " LABEL "]" ← ref mode only
|
|
83
|
+
* TYPE_LABEL := "image" | "voice" | "video" | "file" | "attachment"
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* The **only** mode-dependent decoration is the `source_suffix` (present
|
|
87
|
+
* in `ref`, absent in `inline`). Every other token is identical.
|
|
88
|
+
*/
|
|
89
|
+
export function renderAttachmentTags(
|
|
90
|
+
attachments: readonly AttachmentSummary[] | undefined,
|
|
91
|
+
options: RenderOptions,
|
|
92
|
+
): string {
|
|
93
|
+
if (!attachments?.length) {
|
|
94
|
+
return options.emptyFallback ?? "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
for (const att of attachments) {
|
|
99
|
+
parts.push(renderOne(att, options.mode));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const separator = options.separator ?? (options.mode === "ref" ? " " : "\n");
|
|
103
|
+
return parts.join(separator);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Shorthand for `renderAttachmentTags(attachments, { mode: "inline" })`.
|
|
108
|
+
*
|
|
109
|
+
* Kept as the primary entry point for group history / current-turn
|
|
110
|
+
* rendering where the terse inline form is always wanted.
|
|
111
|
+
*/
|
|
112
|
+
export function formatAttachmentTags(attachments?: readonly AttachmentSummary[]): string {
|
|
113
|
+
return renderAttachmentTags(attachments, { mode: "inline" });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ============ Internal ============
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Render a single attachment.
|
|
120
|
+
*
|
|
121
|
+
* The function is split into two orthogonal concerns:
|
|
122
|
+
* - `renderBody`: the shared "MEDIA:{source}…" or "[type…]" string.
|
|
123
|
+
* - `renderSourceSuffix`: ref-mode-only `" [source: …]"` tail.
|
|
124
|
+
*
|
|
125
|
+
* Both consumers produce the same body; only the suffix differs.
|
|
126
|
+
*/
|
|
127
|
+
function renderOne(att: AttachmentSummary, mode: RenderMode): string {
|
|
128
|
+
const body = renderBody(att);
|
|
129
|
+
const suffix = mode === "ref" ? renderSourceSuffix(att) : "";
|
|
130
|
+
return body + suffix;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Shared, mode-agnostic body of the tag. */
|
|
134
|
+
function renderBody(att: AttachmentSummary): string {
|
|
135
|
+
const source = att.localPath || att.url;
|
|
136
|
+
const voiceSuffix =
|
|
137
|
+
att.type === "voice" && att.transcript ? ` (transcript: "${att.transcript}")` : "";
|
|
138
|
+
|
|
139
|
+
if (source) {
|
|
140
|
+
return `MEDIA:${source}${voiceSuffix}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const label = labelForType(att.type);
|
|
144
|
+
const namePart = att.filename ? `: ${att.filename}` : "";
|
|
145
|
+
return `[${label}${namePart}${voiceSuffix}]`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Ref-mode-only tail that records where a voice transcript came from.
|
|
150
|
+
* Empty string when the attachment isn't a transcribed voice message.
|
|
151
|
+
*/
|
|
152
|
+
function renderSourceSuffix(att: AttachmentSummary): string {
|
|
153
|
+
if (att.type !== "voice" || !att.transcript || !att.transcriptSource) {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
const label = TRANSCRIPT_SOURCE_LABELS[att.transcriptSource] ?? att.transcriptSource;
|
|
157
|
+
return ` [source: ${label}]`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Canonical single-word label for each attachment type. */
|
|
161
|
+
function labelForType(type: AttachmentSummary["type"]): string {
|
|
162
|
+
switch (type) {
|
|
163
|
+
case "image":
|
|
164
|
+
return "image";
|
|
165
|
+
case "voice":
|
|
166
|
+
return "voice";
|
|
167
|
+
case "video":
|
|
168
|
+
return "video";
|
|
169
|
+
case "file":
|
|
170
|
+
return "file";
|
|
171
|
+
default:
|
|
172
|
+
return "attachment";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
pcmToWav,
|
|
4
|
+
stripAmrHeader,
|
|
5
|
+
isVoiceAttachment,
|
|
6
|
+
isAudioFile,
|
|
7
|
+
shouldTranscodeVoice,
|
|
8
|
+
parseWavFallback,
|
|
9
|
+
} from "./audio.js";
|
|
10
|
+
|
|
11
|
+
describe("engine/utils/audio", () => {
|
|
12
|
+
describe("pcmToWav", () => {
|
|
13
|
+
it("produces a valid WAV header", () => {
|
|
14
|
+
const pcm = new Uint8Array([0, 0, 1, 0]);
|
|
15
|
+
const wav = pcmToWav(pcm, 24000);
|
|
16
|
+
|
|
17
|
+
expect(wav.toString("ascii", 0, 4)).toBe("RIFF");
|
|
18
|
+
expect(wav.toString("ascii", 8, 12)).toBe("WAVE");
|
|
19
|
+
expect(wav.toString("ascii", 12, 16)).toBe("fmt ");
|
|
20
|
+
expect(wav.toString("ascii", 36, 40)).toBe("data");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("sets correct file size in RIFF header", () => {
|
|
24
|
+
const pcm = new Uint8Array(100);
|
|
25
|
+
const wav = pcmToWav(pcm, 24000);
|
|
26
|
+
const riffSize = wav.readUInt32LE(4);
|
|
27
|
+
expect(riffSize).toBe(wav.length - 8);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("sets correct sample rate", () => {
|
|
31
|
+
const pcm = new Uint8Array(10);
|
|
32
|
+
const wav = pcmToWav(pcm, 48000);
|
|
33
|
+
expect(wav.readUInt32LE(24)).toBe(48000);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sets correct channel count", () => {
|
|
37
|
+
const pcm = new Uint8Array(10);
|
|
38
|
+
const wav = pcmToWav(pcm, 24000, 2);
|
|
39
|
+
expect(wav.readUInt16LE(22)).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("embeds PCM data after the 44-byte header", () => {
|
|
43
|
+
const pcm = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
|
|
44
|
+
const wav = pcmToWav(pcm, 24000);
|
|
45
|
+
expect(wav[44]).toBe(0x01);
|
|
46
|
+
expect(wav[45]).toBe(0x02);
|
|
47
|
+
expect(wav[46]).toBe(0x03);
|
|
48
|
+
expect(wav[47]).toBe(0x04);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("sets data chunk size matching PCM length", () => {
|
|
52
|
+
const pcm = new Uint8Array(256);
|
|
53
|
+
const wav = pcmToWav(pcm, 24000);
|
|
54
|
+
const dataSize = wav.readUInt32LE(40);
|
|
55
|
+
expect(dataSize).toBe(256);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("stripAmrHeader", () => {
|
|
60
|
+
it("strips the #!AMR header when present", () => {
|
|
61
|
+
const amrHeader = Buffer.from("#!AMR\n");
|
|
62
|
+
const payload = Buffer.from([0x01, 0x02, 0x03]);
|
|
63
|
+
const buf = Buffer.concat([amrHeader, payload]);
|
|
64
|
+
|
|
65
|
+
const result = stripAmrHeader(buf);
|
|
66
|
+
expect(result).toEqual(payload);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns the buffer unchanged when no AMR header", () => {
|
|
70
|
+
const buf = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
|
|
71
|
+
const result = stripAmrHeader(buf);
|
|
72
|
+
expect(result).toBe(buf);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the buffer unchanged when too short", () => {
|
|
76
|
+
const buf = Buffer.from([0x01, 0x02]);
|
|
77
|
+
const result = stripAmrHeader(buf);
|
|
78
|
+
expect(result).toBe(buf);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("isVoiceAttachment", () => {
|
|
83
|
+
it("detects voice content_type", () => {
|
|
84
|
+
expect(isVoiceAttachment({ content_type: "voice" })).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("detects audio/* content_type", () => {
|
|
88
|
+
expect(isVoiceAttachment({ content_type: "audio/silk" })).toBe(true);
|
|
89
|
+
expect(isVoiceAttachment({ content_type: "audio/amr" })).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("detects voice file extensions", () => {
|
|
93
|
+
expect(isVoiceAttachment({ filename: "msg.amr" })).toBe(true);
|
|
94
|
+
expect(isVoiceAttachment({ filename: "msg.silk" })).toBe(true);
|
|
95
|
+
expect(isVoiceAttachment({ filename: "msg.slk" })).toBe(true);
|
|
96
|
+
expect(isVoiceAttachment({ filename: "msg.slac" })).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects non-voice attachments", () => {
|
|
100
|
+
expect(isVoiceAttachment({ content_type: "image/png" })).toBe(false);
|
|
101
|
+
expect(isVoiceAttachment({ filename: "photo.jpg" })).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles missing fields", () => {
|
|
105
|
+
expect(isVoiceAttachment({})).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("isAudioFile", () => {
|
|
110
|
+
it.each([
|
|
111
|
+
".silk",
|
|
112
|
+
".slk",
|
|
113
|
+
".amr",
|
|
114
|
+
".wav",
|
|
115
|
+
".mp3",
|
|
116
|
+
".ogg",
|
|
117
|
+
".opus",
|
|
118
|
+
".aac",
|
|
119
|
+
".flac",
|
|
120
|
+
".m4a",
|
|
121
|
+
".wma",
|
|
122
|
+
".pcm",
|
|
123
|
+
])("recognizes %s as audio", (ext) => {
|
|
124
|
+
expect(isAudioFile(`file${ext}`)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("recognizes audio MIME types", () => {
|
|
128
|
+
expect(isAudioFile("file.bin", "audio/mpeg")).toBe(true);
|
|
129
|
+
expect(isAudioFile("file.bin", "voice")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("rejects non-audio files", () => {
|
|
133
|
+
expect(isAudioFile("photo.jpg")).toBe(false);
|
|
134
|
+
expect(isAudioFile("doc.pdf")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("is case-insensitive on extensions", () => {
|
|
138
|
+
expect(isAudioFile("file.MP3")).toBe(true);
|
|
139
|
+
expect(isAudioFile("file.Wav")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("shouldTranscodeVoice", () => {
|
|
144
|
+
it("returns false for QQ native MIME types", () => {
|
|
145
|
+
expect(shouldTranscodeVoice("file.bin", "audio/silk")).toBe(false);
|
|
146
|
+
expect(shouldTranscodeVoice("file.bin", "audio/amr")).toBe(false);
|
|
147
|
+
expect(shouldTranscodeVoice("file.bin", "audio/wav")).toBe(false);
|
|
148
|
+
expect(shouldTranscodeVoice("file.bin", "audio/mp3")).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns false for QQ native extensions", () => {
|
|
152
|
+
expect(shouldTranscodeVoice("voice.silk")).toBe(false);
|
|
153
|
+
expect(shouldTranscodeVoice("voice.amr")).toBe(false);
|
|
154
|
+
expect(shouldTranscodeVoice("voice.wav")).toBe(false);
|
|
155
|
+
expect(shouldTranscodeVoice("voice.mp3")).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns true for non-native audio formats", () => {
|
|
159
|
+
expect(shouldTranscodeVoice("voice.ogg")).toBe(true);
|
|
160
|
+
expect(shouldTranscodeVoice("voice.opus")).toBe(true);
|
|
161
|
+
expect(shouldTranscodeVoice("voice.flac")).toBe(true);
|
|
162
|
+
expect(shouldTranscodeVoice("voice.aac")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns false for non-audio files", () => {
|
|
166
|
+
expect(shouldTranscodeVoice("photo.jpg")).toBe(false);
|
|
167
|
+
expect(shouldTranscodeVoice("doc.txt")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("parseWavFallback", () => {
|
|
172
|
+
function buildMinimalWav(pcmData: Buffer, sampleRate = 24000, channels = 1): Buffer {
|
|
173
|
+
const bitsPerSample = 16;
|
|
174
|
+
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
|
175
|
+
const blockAlign = channels * (bitsPerSample / 8);
|
|
176
|
+
const dataSize = pcmData.length;
|
|
177
|
+
const buf = Buffer.alloc(44 + dataSize);
|
|
178
|
+
|
|
179
|
+
buf.write("RIFF", 0);
|
|
180
|
+
buf.writeUInt32LE(36 + dataSize, 4);
|
|
181
|
+
buf.write("WAVE", 8);
|
|
182
|
+
buf.write("fmt ", 12);
|
|
183
|
+
buf.writeUInt32LE(16, 16);
|
|
184
|
+
buf.writeUInt16LE(1, 20);
|
|
185
|
+
buf.writeUInt16LE(channels, 22);
|
|
186
|
+
buf.writeUInt32LE(sampleRate, 24);
|
|
187
|
+
buf.writeUInt32LE(byteRate, 28);
|
|
188
|
+
buf.writeUInt16LE(blockAlign, 32);
|
|
189
|
+
buf.writeUInt16LE(bitsPerSample, 34);
|
|
190
|
+
buf.write("data", 36);
|
|
191
|
+
buf.writeUInt32LE(dataSize, 40);
|
|
192
|
+
pcmData.copy(buf, 44);
|
|
193
|
+
return buf;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it("extracts PCM from a valid mono 24kHz WAV", () => {
|
|
197
|
+
const pcm = Buffer.from([0x01, 0x00, 0x02, 0x00]);
|
|
198
|
+
const wav = buildMinimalWav(pcm, 24000, 1);
|
|
199
|
+
const result = parseWavFallback(wav);
|
|
200
|
+
expect(result).not.toBeNull();
|
|
201
|
+
expect(result!.length).toBe(4);
|
|
202
|
+
expect(result![0]).toBe(0x01);
|
|
203
|
+
expect(result![1]).toBe(0x00);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns null for buffers shorter than 44 bytes", () => {
|
|
207
|
+
expect(parseWavFallback(Buffer.alloc(20))).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns null for non-WAV data", () => {
|
|
211
|
+
const buf = Buffer.alloc(44);
|
|
212
|
+
buf.write("NOT_", 0);
|
|
213
|
+
expect(parseWavFallback(buf)).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns null for non-PCM audio formats", () => {
|
|
217
|
+
const wav = buildMinimalWav(Buffer.alloc(4), 24000, 1);
|
|
218
|
+
wav.writeUInt16LE(3, 20); // IEEE float instead of PCM
|
|
219
|
+
expect(parseWavFallback(wav)).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("downmixes stereo to mono", () => {
|
|
223
|
+
// 2 samples × 2 channels × 2 bytes = 8 bytes
|
|
224
|
+
const stereoPcm = Buffer.alloc(8);
|
|
225
|
+
const view = new DataView(stereoPcm.buffer);
|
|
226
|
+
view.setInt16(0, 100, true); // L sample 0
|
|
227
|
+
view.setInt16(2, 200, true); // R sample 0
|
|
228
|
+
view.setInt16(4, -100, true); // L sample 1
|
|
229
|
+
view.setInt16(6, -200, true); // R sample 1
|
|
230
|
+
|
|
231
|
+
const wav = buildMinimalWav(stereoPcm, 24000, 2);
|
|
232
|
+
const result = parseWavFallback(wav);
|
|
233
|
+
expect(result).not.toBeNull();
|
|
234
|
+
// mono output: 2 samples × 2 bytes = 4 bytes
|
|
235
|
+
expect(result!.length).toBe(4);
|
|
236
|
+
const outView = new DataView(result!.buffer, result!.byteOffset);
|
|
237
|
+
expect(outView.getInt16(0, true)).toBe(150); // (100+200)/2
|
|
238
|
+
expect(outView.getInt16(2, true)).toBe(-150); // (-100+-200)/2
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("resamples non-24kHz WAV to 24kHz", () => {
|
|
242
|
+
// 4 samples at 48kHz → should produce ~2 samples at 24kHz
|
|
243
|
+
const pcm48k = Buffer.alloc(8);
|
|
244
|
+
const wav = buildMinimalWav(pcm48k, 48000, 1);
|
|
245
|
+
const result = parseWavFallback(wav);
|
|
246
|
+
expect(result).not.toBeNull();
|
|
247
|
+
expect(result!.length).toBe(4); // 2 samples × 2 bytes
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|