一个极简 TypeScript QQ Bot 私聊工具类:基于 QQ Bot WebSocket Gateway 接收 C2C 私聊消息,并提供私聊文本、流式 Markdown、图片发送 API。
特点:
- 只支持 WebSocket 模式,可接收 QQ 私聊消息,不需要公网 Webhook。
- 只封装 C2C 私聊能力,避免群聊、频道、DM 等额外复杂度。
- 不依赖 OpenClaw 仓库代码。
- 核心文件不 import
node:*,浏览器和 Node 都能使用。 - 对外 API 简洁:
start、stop、onMessage、reply、replyImage、replyStream、createReplyStream、sendPrivate、sendPrivateImage、sendPrivateStream、createPrivateStream。
- Node.js 24+ 或现代浏览器
- QQ Bot 的
appId和clientSecret,或后端提供的accessToken/tokenProvider - QQ Bot 已开通 C2C 私聊消息事件权限
npm install本项目只把 tsx、typescript 和 Node 类型作为开发依赖,核心文件 qqbot-api-tool.ts 本身不依赖第三方运行时包。
复制环境变量示例:
cp .env.example .env填写:
QQBOT_APP_ID=你的 appId
QQBOT_CLIENT_SECRET=你的 appSecret
QQBOT_USER_OPENID=可选,默认私聊用户 openidQQBOT_USER_OPENID 不是由 appId / clientSecret 计算出来的。它来自 QQ 私聊消息事件里的 author.user_openid。如果要主动私信,必须先知道目标用户 openid,或者在收到消息后自己保存。
Node 里可以直接用 appId / clientSecret:
import { QQBotApiTool } from "./qqbot-api-tool.ts";
const bot = new QQBotApiTool({
appId: process.env.QQBOT_APP_ID!,
clientSecret: process.env.QQBOT_CLIENT_SECRET!,
});
bot.onMessage(async (event, api) => {
console.log("收到私聊", event.openid, event.text);
if (event.text.trim() === "/ping") {
await api.reply(event, "pong");
}
});
await bot.start();浏览器里不要放 clientSecret,应该让你的后端返回 token:
const bot = new QQBotApiTool({
tokenProvider: async () => {
const response = await fetch("/api/qqbot-token");
const data = await response.json();
return data.accessToken;
},
});运行后,程序会主动连接 QQ WebSocket Gateway。QQ 平台会沿这条连接推送私聊消息,所以不需要把本机 localhost 暴露给公网。
replyImage 支持公网图片 URL、Blob、ArrayBuffer、Uint8Array 或 base64:
bot.onMessage(async (event, api) => {
if (event.text.trim() === "/image") {
await api.replyImage(event, "https://example.com/demo.png");
}
});浏览器文件输入示例:
const file = fileInput.files?.[0];
if (file) {
await bot.sendPrivateImage(file);
}base64 示例:
await bot.sendPrivateImage({
base64: "iVBORw0KGgo...",
filename: "demo.png",
});如果构造时传了默认用户 openid:
const bot = new QQBotApiTool({
appId: process.env.QQBOT_APP_ID!,
clientSecret: process.env.QQBOT_CLIENT_SECRET!,
userOpenid: process.env.QQBOT_USER_OPENID,
});
await bot.sendPrivate("你好");也可以临时指定 openid:
await bot.sendPrivate("你好", "USER_OPENID");发送公网图片 URL:
await bot.sendPrivateImage("https://example.com/demo.png");发送浏览器 File / Blob:
await bot.sendPrivateImage(file, "USER_OPENID");发送 base64:
await bot.sendPrivateImage({ base64: "iVBORw0KGgo...", filename: "demo.png" });参考 QQ Bot markdown stream 消息接口实现。
回复当前消息:
bot.onMessage(async (event, api) => {
if (event.text.trim() === "/stream") {
await api.replyStream(event, `# 流式回复
你好,这是一条流式 Markdown 回复。`);
}
});主动发送:
await bot.sendPrivateStream(`# 流式消息
你好,这是一条流式 Markdown 私信。`);接 LLM 流式输出时,用 writer API:
bot.onMessage(async (event, api) => {
const reply = api.createReplyStream(event);
for await (const chunk of llmStream) {
await reply.write(chunk);
}
await reply.close();
});主动私信也可以用 writer API:
const stream = bot.createPrivateStream("USER_OPENID");
await stream.write("第一段");
await stream.write("第二段");
await stream.close();自定义分片大小和间隔:
await bot.sendPrivateStream(markdownText, {
chunkSize: 50,
intervalMs: 100,
});临时指定 openid:
await bot.sendPrivateStream(markdownText, {
openid: "USER_OPENID",
});流式发送流程:
- 按行切分文本。
- 多次发送
state: 1的 markdown stream 分片。 - 最后发送
state: 10、reset: true的完整文本收尾。
onMessage 只会收到 C2C 私聊事件:
interface QQBotMessageEvent {
messageId: string;
text: string;
openid: string;
timestamp?: string;
attachments: QQBotAttachment[];
raw: unknown;
}常用字段:
event.text:消息文本。event.openid:私聊用户 openid。event.messageId:回复当前消息时使用。event.attachments:QQ 事件里携带的附件信息。event.raw:QQ 原始事件数据。
const bot = new QQBotApiTool(options);
bot.onMessage(handler);
await bot.start();
bot.stop();
await bot.reply(event, "你好");
await bot.replyImage(event, "https://example.com/demo.png");
await bot.replyStream(event, markdownText);
const replyStream = bot.createReplyStream(event);
await replyStream.write("第一段");
await replyStream.close();
await bot.sendPrivate("你好");
await bot.sendPrivateImage(fileOrBlobOrBase64);
await bot.sendPrivateStream(markdownText);
const privateStream = bot.createPrivateStream("USER_OPENID");
await privateStream.write("第一段");
await privateStream.close();npm run typecheckopenid不能通过appId和clientSecret拼接或计算得到。- 主动私信需要目标用户 openid。
- 浏览器里不要暴露
clientSecret,请用后端提供accessToken或tokenProvider。 - QQ 平台 API 是否允许浏览器直连取决于 CORS;如果被拦截,需要经由你自己的后端代理发送。
- QQ 平台可能限制主动消息发送频率和场景。
- 流式消息当前封装的是 C2C 私信 markdown stream。
replyStream会在 stream 消息体里带上当前事件的msg_id。- 图片发送会先调用
/v2/users/{openid}/files获取file_info,再发送msg_type: 7消息。 - 本工具类只保留 C2C 私聊能力;群聊、频道、频道私信不在当前封装范围内。