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
- 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:*
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
},
|
||
},
|
||
};
|