Files
openclaw/extensions/qqbot/index.ts
2026-04-05 18:18:59 +01:00

197 lines
6.3 KiB
TypeScript

import {
defineBundledChannelEntry,
loadBundledEntryExportSync,
type OpenClawPluginApi,
type PluginCommandContext,
} from "openclaw/plugin-sdk/channel-entry-contract";
type QQBotAccount = {
accountId: string;
appId: string;
config: unknown;
};
type MediaTargetContext = {
targetType: "c2c" | "group" | "channel" | "dm";
targetId: string;
account: QQBotAccount;
logPrefix: string;
};
type QQBotFrameworkCommandResult =
| string
| {
text: string;
filePath?: string;
}
| null
| undefined;
type QQBotFrameworkCommand = {
name: string;
description: string;
handler: (ctx: Record<string, unknown>) => Promise<QQBotFrameworkCommandResult>;
};
function resolveQQBotAccount(config: unknown, accountId?: string): QQBotAccount {
const resolve = loadBundledEntryExportSync<(config: unknown, accountId?: string) => QQBotAccount>(
import.meta.url,
{
specifier: "./api.js",
exportName: "resolveQQBotAccount",
},
);
return resolve(config, accountId);
}
function sendDocument(context: MediaTargetContext, filePath: string) {
const send = loadBundledEntryExportSync<
(context: MediaTargetContext, filePath: string) => Promise<unknown>
>(import.meta.url, {
specifier: "./api.js",
exportName: "sendDocument",
});
return send(context, filePath);
}
function getFrameworkCommands(): QQBotFrameworkCommand[] {
const getCommands = loadBundledEntryExportSync<() => QQBotFrameworkCommand[]>(import.meta.url, {
specifier: "./api.js",
exportName: "getFrameworkCommands",
});
return getCommands();
}
function registerChannelTool(api: OpenClawPluginApi): void {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerChannelTool",
});
register(api);
}
function registerRemindTool(api: OpenClawPluginApi): void {
const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, {
specifier: "./api.js",
exportName: "registerRemindTool",
});
register(api);
}
export default defineBundledChannelEntry({
id: "qqbot",
name: "QQ Bot",
description: "QQ Bot channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "qqbotPlugin",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setQQBotRuntime",
},
registerFull(api: OpenClawPluginApi) {
registerChannelTool(api);
registerRemindTool(api);
// Register all requireAuth:true slash commands with the framework so that
// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence
// and qqbot: prefix normalization before any handler runs.
for (const cmd of getFrameworkCommands()) {
api.registerCommand({
name: cmd.name,
description: cmd.description,
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
// Derive the QQBot message type from ctx.from so that handlers that
// inspect SlashCommandContext.type get the correct value.
// ctx.from format: "qqbot:<type>:<id>" e.g. "qqbot:c2c:<senderId>"
const fromStripped = (ctx.from ?? "").replace(/^qqbot:/i, "");
const rawMsgType = fromStripped.split(":")[0] ?? "c2c";
const msgType: "c2c" | "guild" | "dm" | "group" =
rawMsgType === "group"
? "group"
: rawMsgType === "channel"
? "guild"
: rawMsgType === "dm"
? "dm"
: "c2c";
// Parse target for file sends (same from string).
const colonIdx = fromStripped.indexOf(":");
const targetId = colonIdx !== -1 ? fromStripped.slice(colonIdx + 1) : fromStripped;
const targetType: "c2c" | "group" | "channel" | "dm" =
rawMsgType === "group"
? "group"
: rawMsgType === "channel"
? "channel"
: rawMsgType === "dm"
? "dm"
: "c2c";
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
// Build a minimal SlashCommandContext from the framework PluginCommandContext.
// commandAuthorized is always true here because the framework has already
// verified the sender via resolveCommandAuthorization().
const slashCtx = {
type: msgType,
senderId: ctx.senderId ?? "",
messageId: "",
eventTimestamp: new Date().toISOString(),
receivedAt: Date.now(),
rawContent: `/${cmd.name}${ctx.args ? ` ${ctx.args}` : ""}`,
args: ctx.args ?? "",
accountId: account.accountId,
// appId is not available from PluginCommandContext directly; handlers
// that need it should call resolveQQBotAccount(ctx.config, ctx.accountId).
appId: account.appId,
accountConfig: account.config,
commandAuthorized: true,
queueSnapshot: {
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 10,
senderPending: 0,
},
};
const result = await cmd.handler(slashCtx);
// Plain-text result.
if (typeof result === "string") {
return { text: result };
}
// File result: send the file attachment via QQ API, return text summary.
if (result && typeof result === "object" && "filePath" in result) {
try {
const mediaCtx: MediaTargetContext = {
targetType,
targetId,
account,
logPrefix: `[qqbot:${account.accountId}]`,
};
await sendDocument(mediaCtx, String(result.filePath));
} catch {
// File send failed; the text summary is still returned below.
}
return { text: String(result.text) };
}
return {
text:
result &&
typeof result === "object" &&
"text" in result &&
typeof result.text === "string"
? result.text
: "⚠️ 命令返回了意外结果。",
};
},
});
}
},
});