Files
clawdbot/extensions/qq/src/channel.ts
empty 79b946584e
Some checks failed
CI / install-check (push) Has been cancelled
CI / checks (bunx tsc -p tsconfig.json, bun, build) (push) Has been cancelled
CI / checks (bunx vitest run, bun, test) (push) Has been cancelled
CI / checks (pnpm build, node, build) (push) Has been cancelled
CI / checks (pnpm format, node, format) (push) Has been cancelled
CI / checks (pnpm lint, node, lint) (push) Has been cancelled
CI / checks (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks (pnpm test, node, test) (push) Has been cancelled
CI / secrets (push) Has been cancelled
CI / checks-windows (pnpm build, node, build) (push) Has been cancelled
CI / checks-windows (pnpm lint, node, lint) (push) Has been cancelled
CI / checks-windows (pnpm protocol:check, node, protocol) (push) Has been cancelled
CI / checks-windows (pnpm test, node, test) (push) Has been cancelled
CI / checks-macos (pnpm test, test) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift build --package-path apps/macos --configuration release; then exit 0 fi echo "swift build failed (attempt $attempt/3). Retrying…" sleep $((attempt * 20)) done exit 1 , build) (push) Has been cancelled
CI / macos-app (set -euo pipefail for attempt in 1 2 3; do if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" sleep $((attempt *… (push) Has been cancelled
CI / macos-app (swiftlint --config .swiftlint.yml swiftformat --lint apps/macos/Sources --config .swiftformat , lint) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:assembleDebug, build) (push) Has been cancelled
CI / android (./gradlew --no-daemon :app:testDebugUnitTest, test) (push) Has been cancelled
Docker Release / build-amd64 (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
Install Smoke / install-smoke (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Docker Release / create-manifest (push) Has been cancelled
feat(qq): add QQ Bot channel plugin (Official Bot API)
- Implement QQ Bot API client with token caching
- Add WebSocket monitor for event handling
- Support C2C (single chat) and group messages
- Include pairing mechanism for DM authorization

Also fix memory-core peerDependencies to use workspace:*
2026-01-28 00:05:33 +08:00

417 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QQ Bot Channel Plugin
*
* Main channel plugin implementation for QQ Bot.
*/
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
MoltbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import {
listQQAccountIds,
resolveDefaultQQAccountId,
resolveQQAccount,
type ResolvedQQAccount,
} from "./accounts.js";
import { QQConfigSchema } from "./config-schema.js";
import { probeQQ } from "./probe.js";
import { sendMessageQQ } from "./send.js";
const meta = {
id: "qq",
label: "QQ",
selectionLabel: "QQ (Official Bot API)",
docsPath: "/channels/qq",
docsLabel: "qq",
blurb: "QQ 机器人官方 API支持单聊、群聊",
aliases: ["qq"],
order: 85,
quickstartAllowFrom: true,
};
function normalizeQQMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
// Remove qq: or group: prefix
return trimmed.replace(/^(qq|group):/i, "");
}
export const qqDock: ChannelDock = {
id: "qq",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(qq|group):/i, ""))
.map((entry) => entry.toLowerCase()),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
export const qqPlugin: ChannelPlugin<ResolvedQQAccount> = {
id: "qq",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.qq"] },
configSchema: buildChannelConfigSchema(QQConfigSchema),
config: {
listAccountIds: (cfg) => listQQAccountIds(cfg as MoltbotConfig),
resolveAccount: (cfg, accountId) =>
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }),
defaultAccountId: (cfg) =>
resolveDefaultQQAccountId(cfg as MoltbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as MoltbotConfig,
sectionKey: "qq",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as MoltbotConfig,
sectionKey: "qq",
accountId,
clearBaseFields: ["appId", "appSecret", "name"],
}),
isConfigured: (account) =>
Boolean(account.appId?.trim() && account.appSecret?.trim()),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.appId?.trim() && account.appSecret?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveQQAccount({ cfg: cfg as MoltbotConfig, accountId }).config
.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(qq|group):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId =
accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as MoltbotConfig).channels?.qq?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.qq.accounts.${resolvedAccountId}.`
: "channels.qq.";
return {
policy: account.config.dmPolicy ?? "open",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("qq"),
normalizeEntry: (raw) => raw.replace(/^(qq|group):/i, ""),
};
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: normalizeQQMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
// QQ OpenIDs are typically hex strings
return /^[A-F0-9]{32}$/i.test(trimmed) || /^\d{5,}$/.test(trimmed);
},
hint: "<openId>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveQQAccount({
cfg: cfg as MoltbotConfig,
accountId,
});
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
(account.config.allowFrom ?? [])
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => entry.replace(/^(qq|group):/i, "")),
),
)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as MoltbotConfig,
channelKey: "qq",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "QQ_APP_ID/QQ_APP_SECRET can only be used for the default account.";
}
if (!input.useEnv && (!input.appId || !input.appSecret)) {
return "QQ requires appId and appSecret (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as MoltbotConfig,
channelKey: "qq",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "qq",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
qq: {
...next.channels?.qq,
enabled: true,
...(input.useEnv
? {}
: {
appId: input.appId,
appSecret: input.appSecret,
}),
},
},
} as MoltbotConfig;
}
return {
...next,
channels: {
...next.channels,
qq: {
...next.channels?.qq,
enabled: true,
accounts: {
...(next.channels?.qq?.accounts ?? {}),
[accountId]: {
...(next.channels?.qq?.accounts?.[accountId] ?? {}),
enabled: true,
appId: input.appId,
appSecret: input.appSecret,
},
},
},
},
} as MoltbotConfig;
},
},
pairing: {
idLabel: "qqOpenId",
normalizeAllowEntry: (entry) => entry.replace(/^(qq|group):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveQQAccount({ cfg: cfg as MoltbotConfig });
if (!account.appId || !account.appSecret) {
throw new Error("QQ appId/appSecret not configured");
}
await sendMessageQQ("c2c", id, PAIRING_APPROVED_MESSAGE, {
appId: account.appId,
appSecret: account.appSecret,
});
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator =
breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(
remaining.length,
breakIdx + (brokeOnSeparator ? 1 : 0),
);
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
},
chunkerMode: "text",
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
// Determine chat type from target format
const isGroup = to.startsWith("group:");
const targetId = to.replace(/^(qq:|group:)/i, "");
const chatType = isGroup ? "group" : "c2c";
const result = await sendMessageQQ(chatType, targetId, text, {
accountId: accountId ?? undefined,
cfg: cfg as MoltbotConfig,
});
return {
channel: "qq",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
// TODO: Implement media sending via QQ rich media API
// For now, just send text with media URL
const isGroup = to.startsWith("group:");
const targetId = to.replace(/^(qq:|group:)/i, "");
const chatType = isGroup ? "group" : "c2c";
const messageText = mediaUrl ? `${text}\n${mediaUrl}` : text;
const result = await sendMessageQQ(chatType, targetId, messageText, {
accountId: accountId ?? undefined,
cfg: cfg as MoltbotConfig,
});
return {
channel: "qq",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: async () => [],
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: "websocket",
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
account.appId && account.appSecret
? probeQQ(account.appId, account.appSecret, timeoutMs)
: { ok: false, error: "appId/appSecret not configured" },
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(
account.appId?.trim() && account.appSecret?.trim(),
);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "websocket",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "open",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.appId?.trim() || !account.appSecret?.trim()) {
throw new Error("QQ appId and appSecret are required");
}
ctx.log?.info(`[${account.accountId}] Starting QQ provider`);
const { monitorQQProvider } = await import("./monitor.js");
return monitorQQProvider({
account,
config: ctx.cfg as MoltbotConfig,
abortSignal: ctx.abortSignal,
statusSink: (patch) =>
ctx.setStatus({ accountId: ctx.accountId, ...patch }),
log: ctx.log,
});
},
},
};