@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,91 @@
|
|
|
1
|
+
import { callGatewayTool } from "autobot/plugin-sdk/agent-harness-runtime";
|
|
2
|
+
import type {
|
|
3
|
+
AnyAgentTool,
|
|
4
|
+
AutoBotPluginApi,
|
|
5
|
+
AutoBotPluginToolContext,
|
|
6
|
+
} from "autobot/plugin-sdk/core";
|
|
7
|
+
import { RemindSchema, executeScheduledRemind } from "../../engine/tools/remind-logic.js";
|
|
8
|
+
import type { RemindCronAction, RemindParams } from "../../engine/tools/remind-logic.js";
|
|
9
|
+
import { getRequestContext } from "../../engine/utils/request-context.js";
|
|
10
|
+
|
|
11
|
+
type CronGatewayCaller = (params: RemindCronAction) => Promise<unknown>;
|
|
12
|
+
|
|
13
|
+
type RemindToolDeps = {
|
|
14
|
+
callCron: CronGatewayCaller;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEFAULT_GATEWAY_TIMEOUT_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
function unexpectedCronParams(params: never): never {
|
|
20
|
+
throw new Error(`Unsupported reminder cron action: ${JSON.stringify(params)}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultDeps: RemindToolDeps = {
|
|
24
|
+
callCron: async (params) => {
|
|
25
|
+
switch (params.action) {
|
|
26
|
+
case "list":
|
|
27
|
+
return await callGatewayTool("cron.list", { timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS }, {});
|
|
28
|
+
case "remove":
|
|
29
|
+
return await callGatewayTool(
|
|
30
|
+
"cron.remove",
|
|
31
|
+
{ timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS },
|
|
32
|
+
{ jobId: params.jobId },
|
|
33
|
+
);
|
|
34
|
+
case "add":
|
|
35
|
+
return await callGatewayTool(
|
|
36
|
+
"cron.add",
|
|
37
|
+
{ timeoutMs: DEFAULT_GATEWAY_TIMEOUT_MS },
|
|
38
|
+
{ job: params.job },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return unexpectedCronParams(params);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function createRemindTool(
|
|
46
|
+
toolContext: AutoBotPluginToolContext = {},
|
|
47
|
+
deps: RemindToolDeps = defaultDeps,
|
|
48
|
+
): AnyAgentTool {
|
|
49
|
+
return {
|
|
50
|
+
name: "qqbot_remind",
|
|
51
|
+
label: "QQBot Reminder",
|
|
52
|
+
ownerOnly: true,
|
|
53
|
+
description:
|
|
54
|
+
"Create, list, and remove QQ reminders. " +
|
|
55
|
+
"This tool schedules Gateway cron jobs directly; do not call the cron tool after it succeeds.\n" +
|
|
56
|
+
"Create: action=add, content=message, time=schedule (to is optional, " +
|
|
57
|
+
"resolved automatically from the current conversation)\n" +
|
|
58
|
+
"List: action=list\n" +
|
|
59
|
+
"Remove: action=remove, jobId=job id from list\n" +
|
|
60
|
+
'Time examples: "5m", "1h", "0 8 * * *"',
|
|
61
|
+
parameters: RemindSchema,
|
|
62
|
+
async execute(_toolCallId, params) {
|
|
63
|
+
if (toolContext.senderIsOwner !== true) {
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text" as const,
|
|
68
|
+
text: JSON.stringify({
|
|
69
|
+
error: "QQ reminders require an owner-authorized sender.",
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
details: { error: "QQ reminders require an owner-authorized sender." },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const ctx = getRequestContext();
|
|
77
|
+
return await executeScheduledRemind(
|
|
78
|
+
params as RemindParams,
|
|
79
|
+
{
|
|
80
|
+
fallbackTo: ctx?.target ?? toolContext.deliveryContext?.to,
|
|
81
|
+
fallbackAccountId: ctx?.accountId ?? toolContext.deliveryContext?.accountId,
|
|
82
|
+
},
|
|
83
|
+
deps.callCron,
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerRemindTool(api: AutoBotPluginApi): void {
|
|
90
|
+
api.registerTool((ctx) => createRemindTool(ctx), { name: "qqbot_remind" });
|
|
91
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "autobot/plugin-sdk/core";
|
|
2
|
+
import "./bridge/bootstrap.js";
|
|
3
|
+
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
|
|
4
|
+
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
|
|
5
|
+
import { qqbotChannelConfigSchema } from "./config-schema.js";
|
|
6
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Setup-only QQBot plugin — lightweight subset used during `autobot onboard`
|
|
10
|
+
* and `autobot configure` without pulling the full runtime dependencies.
|
|
11
|
+
*/
|
|
12
|
+
export const qqbotSetupPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
13
|
+
id: "qqbot",
|
|
14
|
+
setupWizard: qqbotSetupWizard,
|
|
15
|
+
meta: {
|
|
16
|
+
...qqbotMeta,
|
|
17
|
+
},
|
|
18
|
+
capabilities: {
|
|
19
|
+
chatTypes: ["direct", "group"],
|
|
20
|
+
media: true,
|
|
21
|
+
reactions: false,
|
|
22
|
+
threads: false,
|
|
23
|
+
blockStreaming: true,
|
|
24
|
+
},
|
|
25
|
+
reload: { configPrefixes: ["channels.qqbot"] },
|
|
26
|
+
configSchema: qqbotChannelConfigSchema,
|
|
27
|
+
config: {
|
|
28
|
+
...qqbotConfigAdapter,
|
|
29
|
+
},
|
|
30
|
+
setup: {
|
|
31
|
+
...qqbotSetupAdapterShared,
|
|
32
|
+
},
|
|
33
|
+
};
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { getExecApprovalReplyMetadata } from "autobot/plugin-sdk/approval-runtime";
|
|
2
|
+
import {
|
|
3
|
+
createMessageReceiptFromOutboundResults,
|
|
4
|
+
defineChannelMessageAdapter,
|
|
5
|
+
type ChannelMessageSendResult,
|
|
6
|
+
type MessageReceiptPartKind,
|
|
7
|
+
} from "autobot/plugin-sdk/channel-message";
|
|
8
|
+
import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
|
|
9
|
+
import type { ChannelPlugin } from "autobot/plugin-sdk/core";
|
|
10
|
+
// Register the PlatformAdapter before any core/ module is used.
|
|
11
|
+
import "./bridge/bootstrap.js";
|
|
12
|
+
import { getQQBotApprovalCapability } from "./bridge/approval/capability.js";
|
|
13
|
+
import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js";
|
|
14
|
+
import {
|
|
15
|
+
applyQQBotAccountConfig,
|
|
16
|
+
DEFAULT_ACCOUNT_ID,
|
|
17
|
+
resolveQQBotAccount,
|
|
18
|
+
} from "./bridge/config.js";
|
|
19
|
+
import type { GatewayContext } from "./bridge/gateway.js";
|
|
20
|
+
import { toGatewayAccount, writeAutoBotConfigThroughRuntime } from "./bridge/narrowing.js";
|
|
21
|
+
import { getQQBotRuntime } from "./bridge/runtime.js";
|
|
22
|
+
import { qqbotSetupWizard } from "./bridge/setup/surface.js";
|
|
23
|
+
import { qqbotChannelConfigSchema } from "./config-schema.js";
|
|
24
|
+
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
|
|
25
|
+
import { clearAccountCredentials } from "./engine/config/credentials.js";
|
|
26
|
+
import {
|
|
27
|
+
normalizeTarget as coreNormalizeTarget,
|
|
28
|
+
looksLikeQQBotTarget,
|
|
29
|
+
} from "./engine/messaging/target-parser.js";
|
|
30
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
31
|
+
|
|
32
|
+
// Shared promise so concurrent multi-account startups serialize the dynamic
|
|
33
|
+
// import of the gateway module, avoiding an ESM circular-dependency race.
|
|
34
|
+
let gatewayModulePromise: Promise<typeof import("./bridge/gateway.js")> | undefined;
|
|
35
|
+
function loadGatewayModule(): Promise<typeof import("./bridge/gateway.js")> {
|
|
36
|
+
gatewayModulePromise ??= import("./bridge/gateway.js");
|
|
37
|
+
return gatewayModulePromise;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createQQBotSendReceipt(params: {
|
|
41
|
+
messageId?: string;
|
|
42
|
+
target: string;
|
|
43
|
+
kind: MessageReceiptPartKind;
|
|
44
|
+
}) {
|
|
45
|
+
const messageId = params.messageId?.trim();
|
|
46
|
+
return createMessageReceiptFromOutboundResults({
|
|
47
|
+
results: messageId
|
|
48
|
+
? [
|
|
49
|
+
{
|
|
50
|
+
channel: "qqbot",
|
|
51
|
+
messageId,
|
|
52
|
+
conversationId: params.target,
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
: [],
|
|
56
|
+
threadId: params.target,
|
|
57
|
+
kind: params.kind,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendQQBotText(params: {
|
|
62
|
+
cfg: AutoBotConfig;
|
|
63
|
+
to: string;
|
|
64
|
+
text: string;
|
|
65
|
+
accountId?: string | null;
|
|
66
|
+
replyToId?: string | null;
|
|
67
|
+
}) {
|
|
68
|
+
// Ensure bridge/gateway.ts module-level registrations (audio adapter factory,
|
|
69
|
+
// platform adapter, etc.) have executed before engine code runs.
|
|
70
|
+
await loadGatewayModule();
|
|
71
|
+
const account = resolveQQBotAccount(params.cfg, params.accountId);
|
|
72
|
+
const { sendText } = await import("./engine/messaging/outbound.js");
|
|
73
|
+
const result = await sendText({
|
|
74
|
+
to: params.to,
|
|
75
|
+
text: params.text,
|
|
76
|
+
accountId: params.accountId,
|
|
77
|
+
replyToId: params.replyToId,
|
|
78
|
+
account: toGatewayAccount(account),
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
channel: "qqbot" as const,
|
|
82
|
+
messageId: result.messageId ?? "",
|
|
83
|
+
receipt: createQQBotSendReceipt({
|
|
84
|
+
messageId: result.messageId,
|
|
85
|
+
target: params.to,
|
|
86
|
+
kind: "text",
|
|
87
|
+
}),
|
|
88
|
+
meta: result.error ? { error: result.error } : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function sendQQBotMedia(params: {
|
|
93
|
+
cfg: AutoBotConfig;
|
|
94
|
+
to: string;
|
|
95
|
+
text?: string | null;
|
|
96
|
+
mediaUrl?: string | null;
|
|
97
|
+
accountId?: string | null;
|
|
98
|
+
replyToId?: string | null;
|
|
99
|
+
}) {
|
|
100
|
+
// Same guard as sendText — ensure adapters are registered.
|
|
101
|
+
await loadGatewayModule();
|
|
102
|
+
const account = resolveQQBotAccount(params.cfg, params.accountId);
|
|
103
|
+
const { sendMedia } = await import("./engine/messaging/outbound.js");
|
|
104
|
+
const result = await sendMedia({
|
|
105
|
+
to: params.to,
|
|
106
|
+
text: params.text ?? "",
|
|
107
|
+
mediaUrl: params.mediaUrl ?? "",
|
|
108
|
+
accountId: params.accountId,
|
|
109
|
+
replyToId: params.replyToId,
|
|
110
|
+
account: toGatewayAccount(account),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
channel: "qqbot" as const,
|
|
114
|
+
messageId: result.messageId ?? "",
|
|
115
|
+
receipt: createQQBotSendReceipt({
|
|
116
|
+
messageId: result.messageId,
|
|
117
|
+
target: params.to,
|
|
118
|
+
kind: "media",
|
|
119
|
+
}),
|
|
120
|
+
meta: result.error ? { error: result.error } : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toQQBotMessageSendResult(result: Awaited<ReturnType<typeof sendQQBotText>>) {
|
|
125
|
+
return {
|
|
126
|
+
messageId: result.messageId,
|
|
127
|
+
receipt: result.receipt,
|
|
128
|
+
} satisfies ChannelMessageSendResult;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const qqbotMessageAdapter = defineChannelMessageAdapter({
|
|
132
|
+
id: "qqbot",
|
|
133
|
+
durableFinal: {
|
|
134
|
+
capabilities: {
|
|
135
|
+
text: true,
|
|
136
|
+
media: true,
|
|
137
|
+
replyTo: true,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
send: {
|
|
141
|
+
text: async (ctx) =>
|
|
142
|
+
toQQBotMessageSendResult(
|
|
143
|
+
await sendQQBotText({
|
|
144
|
+
cfg: ctx.cfg,
|
|
145
|
+
to: ctx.to,
|
|
146
|
+
text: ctx.text,
|
|
147
|
+
accountId: ctx.accountId,
|
|
148
|
+
replyToId: ctx.replyToId,
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
media: async (ctx) =>
|
|
152
|
+
toQQBotMessageSendResult(
|
|
153
|
+
await sendQQBotMedia({
|
|
154
|
+
cfg: ctx.cfg,
|
|
155
|
+
to: ctx.to,
|
|
156
|
+
text: ctx.text,
|
|
157
|
+
mediaUrl: ctx.mediaUrl,
|
|
158
|
+
accountId: ctx.accountId,
|
|
159
|
+
replyToId: ctx.replyToId,
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const EXEC_APPROVAL_COMMAND_RE =
|
|
166
|
+
/\/approve(?:@[^\s]+)?\s+[A-Za-z0-9][A-Za-z0-9._:-]*\s+(?:allow-once|allow-always|always|deny)\b/i;
|
|
167
|
+
|
|
168
|
+
function persistAccountCredentialSnapshot(account: ResolvedQQBotAccount): void {
|
|
169
|
+
if (account.appId && account.clientSecret) {
|
|
170
|
+
saveCredentialBackup(account.accountId, account.appId, account.clientSecret);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shouldSuppressLocalQQBotApprovalPrompt(params: {
|
|
175
|
+
cfg: AutoBotConfig;
|
|
176
|
+
accountId?: string | null;
|
|
177
|
+
payload: { text?: string; channelData?: unknown };
|
|
178
|
+
hint?: { kind: "approval-pending" | "approval-resolved"; approvalKind: "exec" | "plugin" };
|
|
179
|
+
}): boolean {
|
|
180
|
+
if (params.hint?.kind !== "approval-pending" || params.hint.approvalKind !== "exec") {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const account = resolveQQBotAccount(params.cfg, params.accountId);
|
|
184
|
+
if (!account.enabled || account.secretSource === "none") {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (getExecApprovalReplyMetadata(params.payload as never)) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const text = typeof params.payload.text === "string" ? params.payload.text : "";
|
|
191
|
+
return EXEC_APPROVAL_COMMAND_RE.test(text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
195
|
+
id: "qqbot",
|
|
196
|
+
setupWizard: qqbotSetupWizard,
|
|
197
|
+
meta: {
|
|
198
|
+
...qqbotMeta,
|
|
199
|
+
},
|
|
200
|
+
capabilities: {
|
|
201
|
+
chatTypes: ["direct", "group"],
|
|
202
|
+
media: true,
|
|
203
|
+
reactions: false,
|
|
204
|
+
threads: false,
|
|
205
|
+
blockStreaming: true,
|
|
206
|
+
},
|
|
207
|
+
reload: { configPrefixes: ["channels.qqbot"] },
|
|
208
|
+
configSchema: qqbotChannelConfigSchema,
|
|
209
|
+
config: {
|
|
210
|
+
...qqbotConfigAdapter,
|
|
211
|
+
/**
|
|
212
|
+
* Treat an account as configured when either the live config has
|
|
213
|
+
* credentials OR a recoverable credential backup exists. This mirrors
|
|
214
|
+
* the standalone plugin and lets the gateway survive a hot upgrade
|
|
215
|
+
* that wiped autobot.json mid-flight.
|
|
216
|
+
*/
|
|
217
|
+
isConfigured: (account: ResolvedQQBotAccount | undefined) => {
|
|
218
|
+
if (qqbotConfigAdapter.isConfigured(account)) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (!account) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
225
|
+
return Boolean(backup?.appId && backup?.clientSecret);
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
setup: {
|
|
229
|
+
...qqbotSetupAdapterShared,
|
|
230
|
+
},
|
|
231
|
+
approvalCapability: getQQBotApprovalCapability(),
|
|
232
|
+
message: qqbotMessageAdapter,
|
|
233
|
+
messaging: {
|
|
234
|
+
targetPrefixes: ["qqbot"],
|
|
235
|
+
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
|
|
236
|
+
normalizeTarget: coreNormalizeTarget,
|
|
237
|
+
targetResolver: {
|
|
238
|
+
/** Return true when the id looks like a QQ Bot target. */
|
|
239
|
+
looksLikeId: looksLikeQQBotTarget,
|
|
240
|
+
hint: "QQ Bot target format: qqbot:c2c:openid (direct) or qqbot:group:groupid (group)",
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
outbound: {
|
|
244
|
+
deliveryMode: "direct",
|
|
245
|
+
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
246
|
+
chunkerMode: "markdown",
|
|
247
|
+
textChunkLimit: 5000,
|
|
248
|
+
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) =>
|
|
249
|
+
shouldSuppressLocalQQBotApprovalPrompt({
|
|
250
|
+
cfg,
|
|
251
|
+
accountId,
|
|
252
|
+
payload,
|
|
253
|
+
hint,
|
|
254
|
+
}),
|
|
255
|
+
sendText: async ({ to, text, accountId, replyToId, cfg }) =>
|
|
256
|
+
await sendQQBotText({
|
|
257
|
+
cfg,
|
|
258
|
+
to,
|
|
259
|
+
text,
|
|
260
|
+
accountId,
|
|
261
|
+
replyToId,
|
|
262
|
+
}),
|
|
263
|
+
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) =>
|
|
264
|
+
await sendQQBotMedia({
|
|
265
|
+
cfg,
|
|
266
|
+
to,
|
|
267
|
+
text,
|
|
268
|
+
mediaUrl,
|
|
269
|
+
accountId,
|
|
270
|
+
replyToId,
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
gateway: {
|
|
274
|
+
startAccount: async (ctx) => {
|
|
275
|
+
let { account, cfg } = ctx;
|
|
276
|
+
const { abortSignal, log } = ctx;
|
|
277
|
+
|
|
278
|
+
// Recover credentials from the per-account backup if the live
|
|
279
|
+
// config is missing appId/secret (e.g. a hot-upgrade wiped
|
|
280
|
+
// autobot.json). We only restore when both fields are empty so a
|
|
281
|
+
// user's intentional clear isn't silently undone.
|
|
282
|
+
if (!account.appId || !account.clientSecret) {
|
|
283
|
+
const backup = loadCredentialBackup(account.accountId);
|
|
284
|
+
if (backup?.appId && backup?.clientSecret) {
|
|
285
|
+
try {
|
|
286
|
+
const nextCfg = applyQQBotAccountConfig(cfg, account.accountId, {
|
|
287
|
+
appId: backup.appId,
|
|
288
|
+
clientSecret: backup.clientSecret,
|
|
289
|
+
});
|
|
290
|
+
await writeAutoBotConfigThroughRuntime(getQQBotRuntime(), nextCfg);
|
|
291
|
+
cfg = nextCfg;
|
|
292
|
+
account = resolveQQBotAccount(nextCfg, account.accountId);
|
|
293
|
+
log?.info(
|
|
294
|
+
`[qqbot:${account.accountId}] Restored credentials from backup (appId=${account.appId})`,
|
|
295
|
+
);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
log?.error(
|
|
298
|
+
`[qqbot:${account.accountId}] Failed to restore credentials from backup: ${err instanceof Error ? err.message : String(err)}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Serialize the dynamic import so concurrent multi-account startups
|
|
305
|
+
// do not hit an ESM circular-dependency race where the gateway chunk's
|
|
306
|
+
// transitive imports have not finished evaluating yet.
|
|
307
|
+
const { startGateway } = await loadGatewayModule();
|
|
308
|
+
|
|
309
|
+
log?.info(
|
|
310
|
+
`[qqbot:${account.accountId}] Starting gateway — appId=${account.appId}, enabled=${account.enabled}, name=${account.name ?? "unnamed"}`,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
await startGateway({
|
|
314
|
+
account,
|
|
315
|
+
abortSignal,
|
|
316
|
+
cfg,
|
|
317
|
+
log,
|
|
318
|
+
channelRuntime: ctx.channelRuntime as GatewayContext["channelRuntime"],
|
|
319
|
+
onReady: () => {
|
|
320
|
+
log?.info(`[qqbot:${account.accountId}] Gateway ready`);
|
|
321
|
+
ctx.setStatus({
|
|
322
|
+
...ctx.getStatus(),
|
|
323
|
+
running: true,
|
|
324
|
+
connected: true,
|
|
325
|
+
lastConnectedAt: Date.now(),
|
|
326
|
+
});
|
|
327
|
+
// Snapshot credentials so we can recover from the next hot
|
|
328
|
+
// upgrade that might wipe autobot.json mid-flight.
|
|
329
|
+
persistAccountCredentialSnapshot(account);
|
|
330
|
+
},
|
|
331
|
+
onResumed: () => {
|
|
332
|
+
log?.info(`[qqbot:${account.accountId}] Gateway resumed`);
|
|
333
|
+
ctx.setStatus({
|
|
334
|
+
...ctx.getStatus(),
|
|
335
|
+
running: true,
|
|
336
|
+
connected: true,
|
|
337
|
+
lastConnectedAt: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
persistAccountCredentialSnapshot(account);
|
|
340
|
+
},
|
|
341
|
+
onError: (error) => {
|
|
342
|
+
log?.error(`[qqbot:${account.accountId}] Gateway error: ${error.message}`);
|
|
343
|
+
ctx.setStatus({
|
|
344
|
+
...ctx.getStatus(),
|
|
345
|
+
lastError: error.message,
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
logoutAccount: async ({ accountId, cfg }) => {
|
|
351
|
+
const { nextCfg, cleared, changed } = clearAccountCredentials(
|
|
352
|
+
cfg as unknown as Record<string, unknown>,
|
|
353
|
+
accountId,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (changed) {
|
|
357
|
+
await writeAutoBotConfigThroughRuntime(getQQBotRuntime(), nextCfg as AutoBotConfig);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const resolved = resolveQQBotAccount((changed ? nextCfg : cfg) as AutoBotConfig, accountId);
|
|
361
|
+
const loggedOut = resolved.secretSource === "none";
|
|
362
|
+
const envToken = Boolean(process.env.QQBOT_CLIENT_SECRET);
|
|
363
|
+
|
|
364
|
+
return { ok: true, cleared, envToken, loggedOut };
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
status: {
|
|
368
|
+
defaultRuntime: {
|
|
369
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
370
|
+
running: false,
|
|
371
|
+
connected: false,
|
|
372
|
+
lastConnectedAt: null,
|
|
373
|
+
lastError: null,
|
|
374
|
+
lastInboundAt: null,
|
|
375
|
+
lastOutboundAt: null,
|
|
376
|
+
},
|
|
377
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
378
|
+
configured: snapshot.configured ?? false,
|
|
379
|
+
tokenSource: snapshot.tokenSource ?? "none",
|
|
380
|
+
running: snapshot.running ?? false,
|
|
381
|
+
connected: snapshot.connected ?? false,
|
|
382
|
+
lastConnectedAt: snapshot.lastConnectedAt ?? null,
|
|
383
|
+
lastError: snapshot.lastError ?? null,
|
|
384
|
+
}),
|
|
385
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
386
|
+
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
387
|
+
name: account?.name,
|
|
388
|
+
enabled: account?.enabled ?? false,
|
|
389
|
+
configured: Boolean(account?.appId && account?.clientSecret),
|
|
390
|
+
tokenSource: account?.secretSource,
|
|
391
|
+
running: runtime?.running ?? false,
|
|
392
|
+
connected: runtime?.connected ?? false,
|
|
393
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
394
|
+
lastError: runtime?.lastError ?? null,
|
|
395
|
+
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
396
|
+
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
397
|
+
}),
|
|
398
|
+
},
|
|
399
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AllowFromListSchema,
|
|
3
|
+
buildChannelConfigSchema,
|
|
4
|
+
} from "autobot/plugin-sdk/channel-config-schema";
|
|
5
|
+
import { buildSecretInputSchema } from "autobot/plugin-sdk/secret-input";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
const AudioFormatPolicySchema = z
|
|
9
|
+
.object({
|
|
10
|
+
sttDirectFormats: z.array(z.string()).optional(),
|
|
11
|
+
uploadDirectFormats: z.array(z.string()).optional(),
|
|
12
|
+
transcodeEnabled: z.boolean().optional(),
|
|
13
|
+
})
|
|
14
|
+
.optional();
|
|
15
|
+
|
|
16
|
+
const QQBotSttSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
enabled: z.boolean().optional(),
|
|
19
|
+
provider: z.string().optional(),
|
|
20
|
+
baseUrl: z.string().optional(),
|
|
21
|
+
apiKey: z.string().optional(),
|
|
22
|
+
model: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.strict()
|
|
25
|
+
.optional();
|
|
26
|
+
|
|
27
|
+
/** When `true`, same as `mode: "partial"` and `c2cStreamApi: true` for C2C. Object form kept for legacy configs. */
|
|
28
|
+
const QQBotStreamingSchema = z
|
|
29
|
+
.union([
|
|
30
|
+
z.boolean(),
|
|
31
|
+
z
|
|
32
|
+
.object({
|
|
33
|
+
/** "partial" (default) enables block streaming; "off" disables it. */
|
|
34
|
+
mode: z.enum(["off", "partial"]).default("partial"),
|
|
35
|
+
/** @deprecated Prefer `streaming: true`. */
|
|
36
|
+
c2cStreamApi: z.boolean().optional(),
|
|
37
|
+
})
|
|
38
|
+
.passthrough(),
|
|
39
|
+
])
|
|
40
|
+
.optional();
|
|
41
|
+
|
|
42
|
+
const QQBotExecApprovalsSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
enabled: z.union([z.boolean(), z.literal("auto")]).optional(),
|
|
45
|
+
approvers: z.array(z.string()).optional(),
|
|
46
|
+
agentFilter: z.array(z.string()).optional(),
|
|
47
|
+
sessionFilter: z.array(z.string()).optional(),
|
|
48
|
+
target: z.enum(["dm", "channel", "both"]).optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.optional();
|
|
52
|
+
|
|
53
|
+
const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
|
|
54
|
+
const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
|
|
55
|
+
|
|
56
|
+
const QQBotAccountSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
enabled: z.boolean().optional(),
|
|
59
|
+
name: z.string().optional(),
|
|
60
|
+
appId: z.string().optional(),
|
|
61
|
+
clientSecret: buildSecretInputSchema().optional(),
|
|
62
|
+
clientSecretFile: z.string().optional(),
|
|
63
|
+
allowFrom: AllowFromListSchema,
|
|
64
|
+
groupAllowFrom: AllowFromListSchema,
|
|
65
|
+
dmPolicy: QQBotDmPolicySchema,
|
|
66
|
+
groupPolicy: QQBotGroupPolicySchema,
|
|
67
|
+
systemPrompt: z.string().optional(),
|
|
68
|
+
markdownSupport: z.boolean().optional(),
|
|
69
|
+
voiceDirectUploadFormats: z.array(z.string()).optional(),
|
|
70
|
+
audioFormatPolicy: AudioFormatPolicySchema,
|
|
71
|
+
urlDirectUpload: z.boolean().optional(),
|
|
72
|
+
upgradeUrl: z.string().optional(),
|
|
73
|
+
upgradeMode: z.enum(["doc", "hot-reload"]).optional(),
|
|
74
|
+
streaming: QQBotStreamingSchema,
|
|
75
|
+
execApprovals: QQBotExecApprovalsSchema,
|
|
76
|
+
})
|
|
77
|
+
.passthrough();
|
|
78
|
+
|
|
79
|
+
export const QQBotConfigSchema = QQBotAccountSchema.extend({
|
|
80
|
+
stt: QQBotSttSchema,
|
|
81
|
+
accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(),
|
|
82
|
+
defaultAccount: z.string().optional(),
|
|
83
|
+
}).passthrough();
|
|
84
|
+
export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface EffectivePolicyInput {
|
|
4
|
+
allowFrom?: Array<string | number> | null;
|
|
5
|
+
groupAllowFrom?: Array<string | number> | null;
|
|
6
|
+
dmPolicy?: QQBotDmPolicy | null;
|
|
7
|
+
groupPolicy?: QQBotGroupPolicy | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function hasRealRestriction(list: Array<string | number> | null | undefined): boolean {
|
|
11
|
+
if (!list || list.length === 0) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return !list.every((entry) => String(entry).trim() === "*");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveQQBotEffectivePolicies(input: EffectivePolicyInput): {
|
|
18
|
+
dmPolicy: QQBotDmPolicy;
|
|
19
|
+
groupPolicy: QQBotGroupPolicy;
|
|
20
|
+
} {
|
|
21
|
+
const allowFromRestricted = hasRealRestriction(input.allowFrom);
|
|
22
|
+
const groupAllowFromRestricted = hasRealRestriction(input.groupAllowFrom);
|
|
23
|
+
|
|
24
|
+
const dmPolicy: QQBotDmPolicy = input.dmPolicy ?? (allowFromRestricted ? "allowlist" : "open");
|
|
25
|
+
|
|
26
|
+
const groupPolicy: QQBotGroupPolicy =
|
|
27
|
+
input.groupPolicy ?? (groupAllowFromRestricted || allowFromRestricted ? "allowlist" : "open");
|
|
28
|
+
|
|
29
|
+
return { dmPolicy, groupPolicy };
|
|
30
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQBot sender normalization and allowlist matching.
|
|
3
|
+
*
|
|
4
|
+
* Keeps QQ-specific quirks (the `qqbot:` prefix, uppercase-insensitive
|
|
5
|
+
* comparison) localized to this module so the policy engine itself can
|
|
6
|
+
* stay channel-agnostic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Normalize a single entry (openid): strip `qqbot:` prefix, uppercase, trim. */
|
|
10
|
+
export function normalizeQQBotSenderId(raw: unknown): string {
|
|
11
|
+
if (typeof raw !== "string" && typeof raw !== "number") {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
return String(raw)
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/^qqbot:/i, "")
|
|
17
|
+
.toUpperCase();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Normalize an entire allowFrom list, dropping empty entries. */
|
|
21
|
+
export function normalizeQQBotAllowFrom(list: Array<string | number> | undefined | null): string[] {
|
|
22
|
+
if (!list || list.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const out: string[] = [];
|
|
26
|
+
for (const entry of list) {
|
|
27
|
+
const normalized = normalizeQQBotSenderId(entry);
|
|
28
|
+
if (normalized) {
|
|
29
|
+
out.push(normalized);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a matcher closure suitable for passing to the policy engine's
|
|
37
|
+
* `isSenderAllowed` callback. The caller supplies the sender once, and
|
|
38
|
+
* the returned function can be invoked against different allowlists
|
|
39
|
+
* (DM allowlist vs group allowlist) without repeating normalization.
|
|
40
|
+
*/
|
|
41
|
+
export function createQQBotSenderMatcher(senderId: string): (allowFrom: string[]) => boolean {
|
|
42
|
+
const normalizedSender = normalizeQQBotSenderId(senderId);
|
|
43
|
+
return (allowFrom: string[]) => {
|
|
44
|
+
if (allowFrom.length === 0) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (allowFrom.includes("*")) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
if (!normalizedSender) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return allowFrom.some((entry) => normalizeQQBotSenderId(entry) === normalizedSender);
|
|
54
|
+
};
|
|
55
|
+
}
|