@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register slash commands that are allowed on the framework surface via
|
|
3
|
+
* `api.registerCommand`.
|
|
4
|
+
*
|
|
5
|
+
* Routing through the framework lets `resolveCommandAuthorization()` apply
|
|
6
|
+
* `commands.allowFrom.qqbot` precedence and the `qqbot:` prefix normalization
|
|
7
|
+
* before any QQBot command handler runs.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally thin: it wires the engine-side command registry
|
|
10
|
+
* (`getFrameworkCommands`) to the framework registration surface via the three
|
|
11
|
+
* single-responsibility helpers in this directory.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AutoBotPluginApi, PluginCommandContext } from "autobot/plugin-sdk/plugin-entry";
|
|
15
|
+
import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js";
|
|
16
|
+
import { resolveQQBotAccount } from "../config.js";
|
|
17
|
+
import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
|
|
18
|
+
import { parseQQBotFrom } from "./from-parser.js";
|
|
19
|
+
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
|
|
20
|
+
|
|
21
|
+
const PRIVATE_CHAT_ONLY_TEXT = "💡 请在私聊中使用此指令";
|
|
22
|
+
|
|
23
|
+
function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean {
|
|
24
|
+
const raw = (from ?? "").trim();
|
|
25
|
+
const stripped = raw.replace(/^qqbot:/iu, "");
|
|
26
|
+
const colonIdx = stripped.indexOf(":");
|
|
27
|
+
if (colonIdx === -1) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const kind = stripped.slice(0, colonIdx).toLowerCase();
|
|
31
|
+
const targetId = stripped.slice(colonIdx + 1).trim();
|
|
32
|
+
return /^qqbot:/iu.test(raw) && kind === "c2c" && targetId.length > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerQQBotFrameworkCommands(api: AutoBotPluginApi): void {
|
|
36
|
+
for (const cmd of getFrameworkCommands()) {
|
|
37
|
+
api.registerCommand({
|
|
38
|
+
name: cmd.name,
|
|
39
|
+
description: cmd.description,
|
|
40
|
+
channels: ["qqbot"],
|
|
41
|
+
requireAuth: true,
|
|
42
|
+
acceptsArgs: true,
|
|
43
|
+
handler: async (ctx: PluginCommandContext) => {
|
|
44
|
+
if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) {
|
|
45
|
+
return { text: PRIVATE_CHAT_ONLY_TEXT };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const from = parseQQBotFrom(ctx.from);
|
|
49
|
+
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
|
|
50
|
+
const slashCtx = buildFrameworkSlashContext({
|
|
51
|
+
ctx,
|
|
52
|
+
account,
|
|
53
|
+
from,
|
|
54
|
+
commandName: cmd.name,
|
|
55
|
+
});
|
|
56
|
+
const result = await cmd.handler(slashCtx);
|
|
57
|
+
return await dispatchFrameworkSlashResult({
|
|
58
|
+
result,
|
|
59
|
+
account,
|
|
60
|
+
from,
|
|
61
|
+
logger: api.logger,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the framework `PluginCommandContext.from` string into the QQBot
|
|
3
|
+
* message type and send target.
|
|
4
|
+
*
|
|
5
|
+
* The framework passes `from` in the form `qqbot:<kind>:<id>` (case-insensitive
|
|
6
|
+
* prefix). We split that string once and map `<kind>` into the engine-side
|
|
7
|
+
* `SlashCommandContext.type` enum and the outbound `MediaTargetContext.targetType`
|
|
8
|
+
* enum. Both enums diverge only for guild/channel, so we keep two lookup
|
|
9
|
+
* tables to avoid the nested ternary chain the previous implementation used.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface QQBotFromParseResult {
|
|
13
|
+
/** Message type consumed by SlashCommandContext.type. */
|
|
14
|
+
msgType: "c2c" | "guild" | "dm" | "group";
|
|
15
|
+
/** Target type consumed by MediaTargetContext.targetType. */
|
|
16
|
+
targetType: "c2c" | "group" | "channel" | "dm";
|
|
17
|
+
/** Raw target id (everything after the first `:`). */
|
|
18
|
+
targetId: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type FromKind = "c2c" | "group" | "channel" | "dm";
|
|
22
|
+
|
|
23
|
+
const MSG_TYPE_MAP: Record<FromKind, QQBotFromParseResult["msgType"]> = {
|
|
24
|
+
c2c: "c2c",
|
|
25
|
+
dm: "dm",
|
|
26
|
+
group: "group",
|
|
27
|
+
channel: "guild",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TARGET_TYPE_MAP: Record<FromKind, QQBotFromParseResult["targetType"]> = {
|
|
31
|
+
c2c: "c2c",
|
|
32
|
+
dm: "dm",
|
|
33
|
+
group: "group",
|
|
34
|
+
channel: "channel",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function isFromKind(value: string): value is FromKind {
|
|
38
|
+
return value === "c2c" || value === "dm" || value === "group" || value === "channel";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse `ctx.from` into the structured fields the QQBot bridge expects.
|
|
43
|
+
*
|
|
44
|
+
* Unknown or missing prefixes fall back to c2c. The remainder after the first
|
|
45
|
+
* `:` is returned verbatim as the target id, matching what the previous inline
|
|
46
|
+
* implementation did.
|
|
47
|
+
*/
|
|
48
|
+
export function parseQQBotFrom(from: string | undefined | null): QQBotFromParseResult {
|
|
49
|
+
const stripped = (from ?? "").replace(/^qqbot:/iu, "");
|
|
50
|
+
const colonIdx = stripped.indexOf(":");
|
|
51
|
+
const rawPrefix = colonIdx === -1 ? stripped : stripped.slice(0, colonIdx);
|
|
52
|
+
const targetId = colonIdx === -1 ? stripped : stripped.slice(colonIdx + 1);
|
|
53
|
+
const kind: FromKind = isFromKind(rawPrefix) ? rawPrefix : "c2c";
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
msgType: MSG_TYPE_MAP[kind],
|
|
57
|
+
targetType: TARGET_TYPE_MAP[kind],
|
|
58
|
+
targetId,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch a slash command result produced on the framework command surface.
|
|
3
|
+
*
|
|
4
|
+
* Slash command handlers return one of:
|
|
5
|
+
* 1. a plain string (text reply),
|
|
6
|
+
* 2. a `SlashCommandFileResult` (text plus a local file to upload), or
|
|
7
|
+
* 3. null / unexpected value (we surface a generic warning).
|
|
8
|
+
*
|
|
9
|
+
* This module isolates the text/file branching so the framework registration
|
|
10
|
+
* layer stays declarative and so the file-send side effect has a single
|
|
11
|
+
* location where logging and error handling live.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { PluginLogger } from "autobot/plugin-sdk/plugin-entry";
|
|
15
|
+
import type { SlashCommandResult } from "../../engine/commands/slash-commands.js";
|
|
16
|
+
import { sendDocument, type MediaTargetContext } from "../../engine/messaging/outbound.js";
|
|
17
|
+
import type { ResolvedQQBotAccount } from "../../types.js";
|
|
18
|
+
import type { QQBotFromParseResult } from "./from-parser.js";
|
|
19
|
+
|
|
20
|
+
const UNEXPECTED_RESULT_TEXT = "⚠️ 命令返回了意外结果。";
|
|
21
|
+
|
|
22
|
+
interface FrameworkSlashReply {
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DispatchFrameworkSlashResultInput {
|
|
27
|
+
result: SlashCommandResult;
|
|
28
|
+
account: ResolvedQQBotAccount;
|
|
29
|
+
from: QQBotFromParseResult;
|
|
30
|
+
logger?: PluginLogger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasFilePath(value: unknown): value is { text: string; filePath: string } {
|
|
34
|
+
return (
|
|
35
|
+
typeof value === "object" &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
"filePath" in value &&
|
|
38
|
+
typeof (value as { filePath: unknown }).filePath === "string"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildMediaTarget(
|
|
43
|
+
account: ResolvedQQBotAccount,
|
|
44
|
+
from: QQBotFromParseResult,
|
|
45
|
+
): MediaTargetContext {
|
|
46
|
+
return {
|
|
47
|
+
targetType: from.targetType,
|
|
48
|
+
targetId: from.targetId,
|
|
49
|
+
account: account as unknown as MediaTargetContext["account"],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function dispatchFrameworkSlashResult({
|
|
54
|
+
result,
|
|
55
|
+
account,
|
|
56
|
+
from,
|
|
57
|
+
logger,
|
|
58
|
+
}: DispatchFrameworkSlashResultInput): Promise<FrameworkSlashReply> {
|
|
59
|
+
if (typeof result === "string") {
|
|
60
|
+
return { text: result };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (hasFilePath(result)) {
|
|
64
|
+
const mediaCtx = buildMediaTarget(account, from);
|
|
65
|
+
try {
|
|
66
|
+
await sendDocument(mediaCtx, result.filePath, {
|
|
67
|
+
allowQQBotDataDownloads: true,
|
|
68
|
+
});
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger?.warn(`framework slash file send failed: ${String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
return { text: result.text };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { text: UNEXPECTED_RESULT_TEXT };
|
|
76
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
2
|
+
import {
|
|
3
|
+
applyAccountNameToChannelSection,
|
|
4
|
+
deleteAccountFromConfigSection,
|
|
5
|
+
setAccountEnabledInConfigSection,
|
|
6
|
+
} from "autobot/plugin-sdk/core";
|
|
7
|
+
import type { ChannelSetupInput } from "autobot/plugin-sdk/setup";
|
|
8
|
+
import {
|
|
9
|
+
describeAccount as engineDescribeAccount,
|
|
10
|
+
formatAllowFrom as engineFormatAllowFrom,
|
|
11
|
+
isAccountConfigured as engineIsAccountConfigured,
|
|
12
|
+
} from "../engine/config/resolve.js";
|
|
13
|
+
import {
|
|
14
|
+
applySetupAccountConfig as engineApplySetupAccountConfig,
|
|
15
|
+
validateSetupInput as engineValidateSetupInput,
|
|
16
|
+
} from "../engine/config/setup-logic.js";
|
|
17
|
+
import { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.js";
|
|
18
|
+
import type { ResolvedQQBotAccount } from "../types.js";
|
|
19
|
+
import {
|
|
20
|
+
listQQBotAccountIds,
|
|
21
|
+
resolveDefaultQQBotAccountId,
|
|
22
|
+
resolveQQBotAccount,
|
|
23
|
+
} from "./config.js";
|
|
24
|
+
|
|
25
|
+
export const qqbotMeta = {
|
|
26
|
+
id: "qqbot",
|
|
27
|
+
label: "QQ Bot",
|
|
28
|
+
selectionLabel: "QQ Bot (Bot API)",
|
|
29
|
+
docsPath: "/channels/qqbot",
|
|
30
|
+
blurb: "Connect to QQ via official QQ Bot API",
|
|
31
|
+
order: 50,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
function validateQQBotSetupInput(params: {
|
|
35
|
+
accountId: string;
|
|
36
|
+
input: ChannelSetupInput;
|
|
37
|
+
}): string | null {
|
|
38
|
+
return engineValidateSetupInput(params.accountId, params.input);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function applyQQBotSetupAccountConfig(params: {
|
|
42
|
+
cfg: AutoBotConfig;
|
|
43
|
+
accountId: string;
|
|
44
|
+
input: ChannelSetupInput;
|
|
45
|
+
}): AutoBotConfig {
|
|
46
|
+
return engineApplySetupAccountConfig(
|
|
47
|
+
params.cfg as unknown as Record<string, unknown>,
|
|
48
|
+
params.accountId,
|
|
49
|
+
params.input,
|
|
50
|
+
) as AutoBotConfig;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isQQBotConfigured(account: ResolvedQQBotAccount | undefined): boolean {
|
|
54
|
+
return engineIsAccountConfigured(account as never);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describeQQBotAccount(account: ResolvedQQBotAccount | undefined) {
|
|
58
|
+
return engineDescribeAccount(account as never);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatQQBotAllowFrom(params: {
|
|
62
|
+
allowFrom: Array<string | number> | undefined | null;
|
|
63
|
+
}): string[] {
|
|
64
|
+
return engineFormatAllowFrom(params.allowFrom);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const qqbotConfigAdapter = {
|
|
68
|
+
listAccountIds: (cfg: AutoBotConfig) => listQQBotAccountIds(cfg),
|
|
69
|
+
resolveAccount: (cfg: AutoBotConfig, accountId?: string | null) =>
|
|
70
|
+
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }),
|
|
71
|
+
defaultAccountId: (cfg: AutoBotConfig) => resolveDefaultQQBotAccountId(cfg),
|
|
72
|
+
setAccountEnabled: ({
|
|
73
|
+
cfg,
|
|
74
|
+
accountId,
|
|
75
|
+
enabled,
|
|
76
|
+
}: {
|
|
77
|
+
cfg: AutoBotConfig;
|
|
78
|
+
accountId: string;
|
|
79
|
+
enabled: boolean;
|
|
80
|
+
}) =>
|
|
81
|
+
setAccountEnabledInConfigSection({
|
|
82
|
+
cfg,
|
|
83
|
+
sectionKey: "qqbot",
|
|
84
|
+
accountId,
|
|
85
|
+
enabled,
|
|
86
|
+
allowTopLevel: true,
|
|
87
|
+
}),
|
|
88
|
+
deleteAccount: ({ cfg, accountId }: { cfg: AutoBotConfig; accountId: string }) =>
|
|
89
|
+
deleteAccountFromConfigSection({
|
|
90
|
+
cfg,
|
|
91
|
+
sectionKey: "qqbot",
|
|
92
|
+
accountId,
|
|
93
|
+
clearBaseFields: ["appId", "clientSecret", "clientSecretFile", "name"],
|
|
94
|
+
}),
|
|
95
|
+
isConfigured: isQQBotConfigured,
|
|
96
|
+
describeAccount: describeQQBotAccount,
|
|
97
|
+
resolveAllowFrom: ({ cfg, accountId }: { cfg: AutoBotConfig; accountId?: string | null }) =>
|
|
98
|
+
resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }).config?.allowFrom,
|
|
99
|
+
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> | undefined | null }) =>
|
|
100
|
+
formatQQBotAllowFrom({ allowFrom }),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const qqbotSetupAdapterShared = {
|
|
104
|
+
resolveAccountId: ({ cfg, accountId }: { cfg: AutoBotConfig; accountId?: string | null }) =>
|
|
105
|
+
normalizeLowercaseStringOrEmpty(accountId) || resolveDefaultQQBotAccountId(cfg),
|
|
106
|
+
applyAccountName: ({
|
|
107
|
+
cfg,
|
|
108
|
+
accountId,
|
|
109
|
+
name,
|
|
110
|
+
}: {
|
|
111
|
+
cfg: AutoBotConfig;
|
|
112
|
+
accountId: string;
|
|
113
|
+
name?: string;
|
|
114
|
+
}) =>
|
|
115
|
+
applyAccountNameToChannelSection({
|
|
116
|
+
cfg,
|
|
117
|
+
channelKey: "qqbot",
|
|
118
|
+
accountId,
|
|
119
|
+
name,
|
|
120
|
+
}),
|
|
121
|
+
validateInput: ({ accountId, input }: { accountId: string; input: ChannelSetupInput }) =>
|
|
122
|
+
validateQQBotSetupInput({ accountId, input }),
|
|
123
|
+
applyAccountConfig: ({
|
|
124
|
+
cfg,
|
|
125
|
+
accountId,
|
|
126
|
+
input,
|
|
127
|
+
}: {
|
|
128
|
+
cfg: AutoBotConfig;
|
|
129
|
+
accountId: string;
|
|
130
|
+
input: ChannelSetupInput;
|
|
131
|
+
}) => applyQQBotSetupAccountConfig({ cfg, accountId, input }),
|
|
132
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
3
|
+
import { resolveDefaultSecretProviderAlias } from "autobot/plugin-sdk/provider-auth";
|
|
4
|
+
import { coerceSecretRef, normalizeSecretInputString } from "autobot/plugin-sdk/secret-input";
|
|
5
|
+
import { getPlatformAdapter } from "../engine/adapter/index.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID,
|
|
8
|
+
applyAccountConfig,
|
|
9
|
+
listAccountIds,
|
|
10
|
+
resolveAccountBase,
|
|
11
|
+
resolveDefaultAccountId,
|
|
12
|
+
} from "../engine/config/resolve.js";
|
|
13
|
+
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID;
|
|
16
|
+
|
|
17
|
+
interface QQBotChannelConfig extends QQBotAccountConfig {
|
|
18
|
+
accounts?: Record<string, QQBotAccountConfig>;
|
|
19
|
+
defaultAccount?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertNotLegacySecretRefMarker(value: unknown, path: string): void {
|
|
23
|
+
const normalized = normalizeSecretInputString(value);
|
|
24
|
+
if (!normalized || !/^secretref(?:-env)?:/i.test(normalized)) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${path}: legacy SecretRef marker strings are not valid QQ Bot clientSecret values; use a structured SecretRef object instead.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveEnvSecretRefValue(params: {
|
|
33
|
+
cfg: AutoBotConfig;
|
|
34
|
+
value: unknown;
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
}): string | undefined {
|
|
37
|
+
const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults);
|
|
38
|
+
if (!ref || ref.source !== "env") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const providerConfig = params.cfg.secrets?.providers?.[ref.provider];
|
|
43
|
+
if (providerConfig) {
|
|
44
|
+
if (providerConfig.source !== "env") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "env".`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (providerConfig.allowlist && !providerConfig.allowlist.includes(ref.id)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${ref.provider}.allowlist.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} else if (ref.provider !== resolveDefaultSecretProviderAlias(params.cfg, "env")) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Secret provider "${ref.provider}" is not configured (ref: env:${ref.provider}:${ref.id}).`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalizeSecretInputString((params.env ?? process.env)[ref.id]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveQQBotClientSecretInput(params: {
|
|
64
|
+
cfg: AutoBotConfig;
|
|
65
|
+
value: unknown;
|
|
66
|
+
path: string;
|
|
67
|
+
}): string | undefined {
|
|
68
|
+
assertNotLegacySecretRefMarker(params.value, params.path);
|
|
69
|
+
|
|
70
|
+
const envSecret = resolveEnvSecretRefValue({
|
|
71
|
+
cfg: params.cfg,
|
|
72
|
+
value: params.value,
|
|
73
|
+
});
|
|
74
|
+
if (envSecret) {
|
|
75
|
+
return envSecret;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return getPlatformAdapter().resolveSecretInputString({
|
|
79
|
+
value: params.value,
|
|
80
|
+
path: params.path,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** List all configured QQBot account IDs. */
|
|
85
|
+
export function listQQBotAccountIds(cfg: AutoBotConfig): string[] {
|
|
86
|
+
return listAccountIds(cfg as unknown as Record<string, unknown>);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve the default QQBot account ID. */
|
|
90
|
+
export function resolveDefaultQQBotAccountId(cfg: AutoBotConfig): string {
|
|
91
|
+
return resolveDefaultAccountId(cfg as unknown as Record<string, unknown>);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolve QQBot account config for runtime or setup flows. */
|
|
95
|
+
export function resolveQQBotAccount(
|
|
96
|
+
cfg: AutoBotConfig,
|
|
97
|
+
accountId?: string | null,
|
|
98
|
+
opts?: { allowUnresolvedSecretRef?: boolean },
|
|
99
|
+
): ResolvedQQBotAccount {
|
|
100
|
+
const raw = cfg as unknown as Record<string, unknown>;
|
|
101
|
+
const base = resolveAccountBase(raw, accountId);
|
|
102
|
+
|
|
103
|
+
const qqbot = cfg.channels?.qqbot as QQBotChannelConfig | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Legacy top-level account uses `channels.qqbot` as the base, but per-account
|
|
106
|
+
* fields (allowFrom, streaming, …) often live under `accounts.default`.
|
|
107
|
+
* Merge that slice so runtime sees `config.streaming` etc.
|
|
108
|
+
*/
|
|
109
|
+
const accountConfig: QQBotAccountConfig =
|
|
110
|
+
base.accountId === DEFAULT_ACCOUNT_ID
|
|
111
|
+
? {
|
|
112
|
+
...qqbot,
|
|
113
|
+
...qqbot?.accounts?.[DEFAULT_ACCOUNT_ID],
|
|
114
|
+
}
|
|
115
|
+
: (qqbot?.accounts?.[base.accountId] ?? {});
|
|
116
|
+
|
|
117
|
+
let clientSecret = "";
|
|
118
|
+
let secretSource: "config" | "file" | "env" | "none" = "none";
|
|
119
|
+
|
|
120
|
+
const clientSecretPath =
|
|
121
|
+
base.accountId === DEFAULT_ACCOUNT_ID
|
|
122
|
+
? "channels.qqbot.clientSecret"
|
|
123
|
+
: `channels.qqbot.accounts.${base.accountId}.clientSecret`;
|
|
124
|
+
|
|
125
|
+
const adapter = getPlatformAdapter();
|
|
126
|
+
if (adapter.hasConfiguredSecret(accountConfig.clientSecret)) {
|
|
127
|
+
clientSecret = opts?.allowUnresolvedSecretRef
|
|
128
|
+
? (adapter.normalizeSecretInputString(accountConfig.clientSecret) ?? "")
|
|
129
|
+
: (resolveQQBotClientSecretInput({
|
|
130
|
+
cfg,
|
|
131
|
+
value: accountConfig.clientSecret,
|
|
132
|
+
path: clientSecretPath,
|
|
133
|
+
}) ?? "");
|
|
134
|
+
secretSource = "config";
|
|
135
|
+
} else if (accountConfig.clientSecretFile) {
|
|
136
|
+
try {
|
|
137
|
+
clientSecret = fs.readFileSync(accountConfig.clientSecretFile, "utf8").trim();
|
|
138
|
+
secretSource = "file";
|
|
139
|
+
} catch {
|
|
140
|
+
secretSource = "none";
|
|
141
|
+
}
|
|
142
|
+
} else if (process.env.QQBOT_CLIENT_SECRET && base.accountId === DEFAULT_ACCOUNT_ID) {
|
|
143
|
+
clientSecret = process.env.QQBOT_CLIENT_SECRET;
|
|
144
|
+
secretSource = "env";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
accountId: base.accountId,
|
|
149
|
+
name: accountConfig.name,
|
|
150
|
+
enabled: base.enabled,
|
|
151
|
+
appId: base.appId,
|
|
152
|
+
clientSecret,
|
|
153
|
+
secretSource,
|
|
154
|
+
systemPrompt: base.systemPrompt,
|
|
155
|
+
markdownSupport: base.markdownSupport,
|
|
156
|
+
config: accountConfig,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Apply account config updates back into the AutoBot config object. */
|
|
161
|
+
export function applyQQBotAccountConfig(
|
|
162
|
+
cfg: AutoBotConfig,
|
|
163
|
+
accountId: string,
|
|
164
|
+
input: {
|
|
165
|
+
appId?: string;
|
|
166
|
+
clientSecret?: string;
|
|
167
|
+
clientSecretFile?: string;
|
|
168
|
+
name?: string;
|
|
169
|
+
},
|
|
170
|
+
): AutoBotConfig {
|
|
171
|
+
return applyAccountConfig(
|
|
172
|
+
cfg as unknown as Record<string, unknown>,
|
|
173
|
+
accountId,
|
|
174
|
+
input,
|
|
175
|
+
) as AutoBotConfig;
|
|
176
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway entry point — thin bridge shell that constructs
|
|
3
|
+
* {@link EngineAdapters} and passes them to the engine's
|
|
4
|
+
* `startGateway`.
|
|
5
|
+
*
|
|
6
|
+
* All adapter dependencies are assembled here in one place.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveRuntimeServiceVersion } from "autobot/plugin-sdk/cli-runtime";
|
|
10
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
11
|
+
import type { EngineAdapters } from "../engine/adapter/index.js";
|
|
12
|
+
import {
|
|
13
|
+
startGateway as coreStartGateway,
|
|
14
|
+
type CoreGatewayContext,
|
|
15
|
+
} from "../engine/gateway/gateway.js";
|
|
16
|
+
import { initSender, registerAccount } from "../engine/messaging/sender.js";
|
|
17
|
+
import type { EngineLogger } from "../engine/types.js";
|
|
18
|
+
import * as audioModule from "../engine/utils/audio.js";
|
|
19
|
+
import { formatDuration } from "../engine/utils/format.js";
|
|
20
|
+
import { debugLog, debugError } from "../engine/utils/log.js";
|
|
21
|
+
import type { ResolvedQQBotAccount } from "../types.js";
|
|
22
|
+
import { ensurePlatformAdapter } from "./bootstrap.js";
|
|
23
|
+
import { setBridgeLogger } from "./logger.js";
|
|
24
|
+
import { toGatewayAccount } from "./narrowing.js";
|
|
25
|
+
import { resolveQQBotPluginVersion } from "./plugin-version.js";
|
|
26
|
+
import { getQQBotRuntime, getQQBotRuntimeForEngine } from "./runtime.js";
|
|
27
|
+
import {
|
|
28
|
+
createSdkAccessAdapter,
|
|
29
|
+
createSdkHistoryAdapter,
|
|
30
|
+
createSdkMentionGateAdapter,
|
|
31
|
+
} from "./sdk-adapter.js";
|
|
32
|
+
|
|
33
|
+
// ---- One-time startup initialization (module-level) ----
|
|
34
|
+
|
|
35
|
+
const pluginVersion = resolveQQBotPluginVersion(import.meta.url);
|
|
36
|
+
initSender({
|
|
37
|
+
pluginVersion,
|
|
38
|
+
autobotVersion: resolveRuntimeServiceVersion(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ============ Public types ============
|
|
42
|
+
|
|
43
|
+
export interface GatewayContext {
|
|
44
|
+
account: ResolvedQQBotAccount;
|
|
45
|
+
abortSignal: AbortSignal;
|
|
46
|
+
cfg: AutoBotConfig;
|
|
47
|
+
onReady?: (data: unknown) => void;
|
|
48
|
+
onResumed?: (data: unknown) => void;
|
|
49
|
+
onError?: (error: Error) => void;
|
|
50
|
+
log?: {
|
|
51
|
+
info: (msg: string) => void;
|
|
52
|
+
error: (msg: string) => void;
|
|
53
|
+
debug?: (msg: string) => void;
|
|
54
|
+
};
|
|
55
|
+
channelRuntime?: {
|
|
56
|
+
runtimeContexts: {
|
|
57
|
+
register: (params: {
|
|
58
|
+
channelId: string;
|
|
59
|
+
accountId: string;
|
|
60
|
+
capability: string;
|
|
61
|
+
context: unknown;
|
|
62
|
+
abortSignal?: AbortSignal;
|
|
63
|
+
}) => { dispose: () => void };
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============ Adapter factory ============
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create the full set of engine adapters from the bridge layer.
|
|
72
|
+
*
|
|
73
|
+
* This is the **single assembly point** — all SDK → engine binding
|
|
74
|
+
* happens here. The engine receives a fully-populated
|
|
75
|
+
* {@link EngineAdapters} object with zero global singletons.
|
|
76
|
+
*/
|
|
77
|
+
function createEngineAdapters(): EngineAdapters {
|
|
78
|
+
return {
|
|
79
|
+
history: createSdkHistoryAdapter(),
|
|
80
|
+
mentionGate: createSdkMentionGateAdapter(),
|
|
81
|
+
access: createSdkAccessAdapter(),
|
|
82
|
+
audioConvert: {
|
|
83
|
+
convertSilkToWav: audioModule.convertSilkToWav,
|
|
84
|
+
isVoiceAttachment: audioModule.isVoiceAttachment,
|
|
85
|
+
formatDuration,
|
|
86
|
+
},
|
|
87
|
+
outboundAudio: {
|
|
88
|
+
audioFileToSilkBase64: async (p: string, f?: string[]) =>
|
|
89
|
+
(await audioModule.audioFileToSilkBase64(p, f)) ?? undefined,
|
|
90
|
+
isAudioFile: (p: string, m?: string) => audioModule.isAudioFile(p, m),
|
|
91
|
+
shouldTranscodeVoice: (p: string) => audioModule.shouldTranscodeVoice(p),
|
|
92
|
+
waitForFile: (p: string, ms?: number) => audioModule.waitForFile(p, ms),
|
|
93
|
+
},
|
|
94
|
+
commands: {
|
|
95
|
+
resolveVersion: resolveRuntimeServiceVersion,
|
|
96
|
+
pluginVersion,
|
|
97
|
+
approveRuntimeGetter: () => {
|
|
98
|
+
const rt = getQQBotRuntime();
|
|
99
|
+
return { config: rt.config };
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============ startGateway ============
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Start the Gateway WebSocket connection.
|
|
109
|
+
*
|
|
110
|
+
* Assembles all adapters and passes them to the engine's core gateway.
|
|
111
|
+
*/
|
|
112
|
+
export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
113
|
+
ensurePlatformAdapter();
|
|
114
|
+
|
|
115
|
+
const runtime = getQQBotRuntimeForEngine();
|
|
116
|
+
const accountLogger = createAccountLogger(ctx.log, ctx.account.accountId);
|
|
117
|
+
|
|
118
|
+
// Per-account registration (still global — sender is a leaf utility).
|
|
119
|
+
registerAccount(ctx.account.appId, {
|
|
120
|
+
logger: accountLogger,
|
|
121
|
+
markdownSupport: ctx.account.markdownSupport,
|
|
122
|
+
});
|
|
123
|
+
setBridgeLogger(accountLogger);
|
|
124
|
+
|
|
125
|
+
if (ctx.channelRuntime) {
|
|
126
|
+
accountLogger.info("Registering approval.native runtime context");
|
|
127
|
+
const lease = ctx.channelRuntime.runtimeContexts.register({
|
|
128
|
+
channelId: "qqbot",
|
|
129
|
+
accountId: ctx.account.accountId,
|
|
130
|
+
capability: "approval.native",
|
|
131
|
+
context: { account: ctx.account },
|
|
132
|
+
abortSignal: ctx.abortSignal,
|
|
133
|
+
});
|
|
134
|
+
accountLogger.info(`approval.native context registered (lease=${!!lease})`);
|
|
135
|
+
} else {
|
|
136
|
+
accountLogger.info("No channelRuntime — skipping approval.native registration");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const coreCtx: CoreGatewayContext = {
|
|
140
|
+
account: toGatewayAccount(ctx.account),
|
|
141
|
+
abortSignal: ctx.abortSignal,
|
|
142
|
+
cfg: ctx.cfg,
|
|
143
|
+
onReady: ctx.onReady,
|
|
144
|
+
onResumed: ctx.onResumed,
|
|
145
|
+
onError: ctx.onError,
|
|
146
|
+
log: accountLogger,
|
|
147
|
+
runtime,
|
|
148
|
+
adapters: createEngineAdapters(),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return coreStartGateway(coreCtx);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ============ Per-account logger factory ============
|
|
155
|
+
|
|
156
|
+
function createAccountLogger(
|
|
157
|
+
raw: GatewayContext["log"] | undefined,
|
|
158
|
+
accountId: string,
|
|
159
|
+
): EngineLogger {
|
|
160
|
+
const prefix = `[${accountId}]`;
|
|
161
|
+
const withMeta = (msg: string, meta?: Record<string, unknown>) =>
|
|
162
|
+
meta && Object.keys(meta).length > 0 ? `${msg} ${JSON.stringify(meta)}` : msg;
|
|
163
|
+
|
|
164
|
+
if (!raw) {
|
|
165
|
+
return {
|
|
166
|
+
info: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`),
|
|
167
|
+
error: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`),
|
|
168
|
+
warn: (msg, meta) => debugError(`${prefix} ${withMeta(msg, meta)}`),
|
|
169
|
+
debug: (msg, meta) => debugLog(`${prefix} ${withMeta(msg, meta)}`),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
info: (msg, meta) => raw.info(`${prefix} ${withMeta(msg, meta)}`),
|
|
174
|
+
error: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`),
|
|
175
|
+
warn: (msg, meta) => raw.error(`${prefix} ${withMeta(msg, meta)}`),
|
|
176
|
+
debug: (msg, meta) => raw.debug?.(`${prefix} ${withMeta(msg, meta)}`),
|
|
177
|
+
};
|
|
178
|
+
}
|