@sochatlive/bot-sdk
v0.2.4
Published
Official Node.js SDK for SoChat Bot API and Webhooks
Readme
@sochatlive/bot-sdk
SoChat 机器人开放平台的 官方 Node.js / TypeScript SDK,封装:
- Bot API:
Authorization: Bearer <bot_token>调用网关/api/v1/bots/** - Webhook:校验
X-SoChat-Signature-V2(HMAC-SHA256,带X-SoChat-Timestamp防重放)、update_id幂等、高阶事件路由(V1 头并行下发以兼容旧实现)
已发布到 npm:
@sochatlive/bot-sdk· 当前版本0.1.2·npm install @sochatlive/bot-sdk
设计说明见仓库 docs/机器人开放平台规划.md。
要求
- Node.js ≥ 18(依赖全局
fetch)
安装
npm install @sochatlive/bot-sdk
# 或
pnpm add @sochatlive/bot-sdk本仓库已将 botsdk/nodejs 加入根目录 pnpm workspace,在仓库根执行 pnpm install 即可安装依赖。单独构建/测试:
pnpm --filter @sochatlive/bot-sdk run build
pnpm --filter @sochatlive/bot-sdk run test对外即可在任意项目中:
npm install @sochatlive/bot-sdk
# 或
pnpm add @sochatlive/bot-sdk5 分钟快速上手
- 在 SoChat 开放平台 / App 提交机器人申请,由平台 Admin 审核通过后取得
sbot_...Token。 - 为机器人配置 HTTPS Webhook,并设置
secret_token(与下文代码中secretToken一致)。 - 编写 Webhook 服务(需保留 原始 JSON 字节 用于验签,见下文「Webhook 签名校验」)。
import { SoChatBot } from '@sochatlive/bot-sdk';
const bot = new SoChatBot({
token: process.env.SOCHAT_BOT_TOKEN!,
secretToken: process.env.SOCHAT_WEBHOOK_SECRET!,
baseUrl: process.env.SOCHAT_API_BASE // 例如 https://gateway.example/api/v1
});
bot.on('message', async ctx => {
if (ctx.message.text) {
await ctx.reply(`你说: ${ctx.message.text}`);
}
});
await bot.start({ port: 8787, path: '/webhook' });将公网 HTTPS 反代到 http://<host>:8787/webhook,在开放平台把 Webhook URL 设为 https://你的域名/webhook。在会话中 @机器人 或发 /command,即可在控制台看到 Update 并收到自动回复。
环境变量
| 变量 | 说明 |
|------|------|
| SOCHAT_API_BASE | 网关根 URL,须含 /api/v1,如 https://gw.example.com/api/v1 |
| SOCHAT_BOT_TOKEN | 审核通过后签发的 Bot Token(sbot_ 前缀) |
| SOCHAT_WEBHOOK_SECRET | setWebhook 时配置的 secret_token |
未设置 SOCHAT_API_BASE 时,客户端默认使用占位地址 https://api.sochat.example/api/v1,请务必改为真实网关。
低阶客户端 SoChatBotClient
不跑 Webhook、仅调 Bot API 时使用:
import { SoChatBotClient } from '@sochatlive/bot-sdk';
const client = new SoChatBotClient({
token: process.env.SOCHAT_BOT_TOKEN!,
baseUrl: process.env.SOCHAT_API_BASE
});
await client.setWebhook({
url: 'https://example.com/webhook',
secret_token: 'your-secret',
allowed_updates: ['message']
});
const me = await client.getMe();
await client.sendMessage({
chat_id: '<conversationId>',
text: 'hello',
reply_markup: {
inline_keyboard: [[{ text: '收到', callback_data: 'ack' }]]
}
});
await client.setMyCommands({
commands: [
{ command: 'start', description: '开始使用' },
{ command: 'help', description: '帮助' }
],
scope: 'all_private_chats'
});主要方法
| 方法 | 说明 |
|------|------|
| getMe() | GET /bots/me |
| sendMessage | 文本消息(可选 reply_markup:InlineKeyboard、ReplyKeyboard、remove_keyboard) |
| setMyCommands / getMyCommands / deleteMyCommands | 命令菜单(按 scope、language_code 分组,与 Telegram 语义对齐) |
| sendPhoto / sendDocument / sendVideo / sendAudio | file_id 为上传完成后的文件 ID |
| sendLocation | 经纬度 |
| editMessage / editMessageReplyMarkup / deleteMessage | 仅允许操作本机器人发送的消息;editMessageReplyMarkup 可更新或清空 InlineKeyboard |
| answerCallbackQuery | 回应 callback_query 按钮点击(toast / alert / url) |
| answerInlineQuery | 应答 Inline Mode 查询结果 |
| answerFriendRequest | 处理用户发起的 pending 好友申请(friend_request_mode = manual;friendship_id + accept/reject) |
| getFriendRequests | 列出待处理好友申请(GET /bots/getFriendRequests,与 DB pending 同源) |
| setMyFriendRequestMode / getMyFriendRequestMode | 配置 / 读取好友申请策略(auto_accept / auto_reject / manual) |
| kickChatMember / banChatMember / unbanChatMember | 群治理(chat_id 为群会话 Mongo _id;机器人须为群主或群管理员;ban 为先踢后黑,非原子) |
| setWebhook / deleteWebhook | Webhook 配置 |
| issueUploadCredentials / completeUpload | S3 直传两步 |
| sendPhotoFromFile / sendDocumentFromFile | 本地路径 → 上传 → 发送 |
| verifyToken() | 公开接口校验 Token |
InlineKeyboard / Callback Query
bot.command('menu', async ctx => {
await ctx.reply('请选择:', {
reply_markup: {
inline_keyboard: [
[
{ text: '点赞', callback_data: 'like' },
{ text: '文档', url: 'https://open.sochatlive.com/' }
]
]
}
});
});
bot.on('callback_query', async ctx => {
await ctx.answerCallback({ text: `收到: ${ctx.callbackQuery?.data}` });
});Webhook 签名校验原理
平台投递时(见 WebhookDeliveryWorker)会同时下发 V1 与 V2 两份签名头,便于平滑升级:
| 头 | 算法 | 防 replay | 状态 |
| --- | --- | --- | --- |
| X-SoChat-Signature | sha256=HMAC(secret, body) | 否 | V1,兼容期保留,deprecated |
| X-SoChat-Signature-V2 | sha256=HMAC(secret, "${ts}.${body}") | 是(结合 X-SoChat-Timestamp 判 5min 时窗) | 推荐使用 |
| X-SoChat-Timestamp | unix 秒 | — | V2 必带 |
| X-SoChat-Update-Id | 透传 update.update_id | — | 用于幂等 |
SDK 的 verifyWebhookSignature / processWebhook / webhookMiddleware 默认优先 V2,缺失 V2 时回退 V1。完成全网升级后可在 verify: { requireV2: true } 强制只接受 V2,彻底闭合 replay 风险。
服务端必须用 与计算签名时完全相同的字节 验签。若使用
express.json()默认中间件,会丢失原始字节,签名将无法对齐。推荐之一:
方式 A:express.raw
import express from 'express';
import { SoChatBot } from '@sochatlive/bot-sdk';
const app = express();
const bot = new SoChatBot({ token: '...', secretToken: '...' });
app.post('/webhook', express.raw({ type: 'application/json' }), bot.webhookCallback());方式 B:express.json 的 verify 保存 rawBody
app.use(
express.json({
verify: (req: express.Request & { rawBody?: Buffer }, _res, buf) => {
req.rawBody = buf;
}
})
);
// 再挂载 bot.webhookCallback(),SDK 会优先读取 req.rawBody手写验签示例(V2,推荐):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyV2(
secret: string,
rawBody: Buffer,
sigHeader: string | undefined,
tsHeader: string | undefined
) {
if (!sigHeader?.startsWith('sha256=') || !tsHeader) throw new Error('bad sig');
const ts = Number(tsHeader);
if (!Number.isFinite(ts) || Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) {
throw new Error('replay window');
}
const expected = Buffer.from(sigHeader.slice(7), 'hex');
const actual = createHmac('sha256', secret)
.update(`${ts}.`)
.update(rawBody)
.digest();
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error('bad sig');
}
}V1 验签(仅在无 V2 头时回退):
function verifyV1(secret: string, rawBody: Buffer, header: string | undefined) {
if (!header?.startsWith('sha256=')) throw new Error('bad sig');
const expected = Buffer.from(header.slice(7), 'hex');
const actual = createHmac('sha256', secret).update(rawBody).digest();
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error('bad sig');
}
}多媒体:本地 / Buffer / URL
import {
SoChatBotClient,
sendPhotoFromBuffer,
sendPhotoFromUrl,
sendVideoFromUrl
} from '@sochatlive/bot-sdk';
import { readFile } from 'node:fs/promises';
const client = new SoChatBotClient({ token: '...', baseUrl: '...' });
await client.sendPhotoFromFile(chatId, './a.png', { caption: '本地文件' });
const buf = await readFile('./a.png');
await sendPhotoFromBuffer(client, chatId, buf, { fileName: 'a.png', fileType: 'image/png' });
await sendPhotoFromUrl(client, chatId, 'https://example.com/p.png', { fileName: 'p.png' });
await sendVideoFromUrl(client, chatId, 'https://example.com/v.mp4', {
fileName: 'v.mp4',
fileType: 'video/mp4',
caption: '可选说明',
duration: 10
});错误码说明(常见)
平台与网关可能返回 success: false 或 HTTP 4xx/5xx,SDK 统一抛出 SoChatApiError(含 statusCode、message、code 若存在)。文档与产品侧常见语义如下(具体以网关响应为准):
| 语义 | 可能场景 |
|------|----------|
| invalid_token / 401 | Token 错误、吊销、格式不对 |
| bot_disabled / 403 | 机器人未审核通过、已停用 |
| chat_forbidden / 4xx | 无权限向目标会话发消息 |
| rate_limited / 429 | 触发发送配额 |
| webhook_invalid | Webhook URL 非 HTTPS 或配置无效(配置接口返回消息) |
幂等与重复投递
平台重试 Webhook 时 保持同一 update_id。SoChatBot 内置 LRU(默认 1024 条)去重:重复 update_id 返回 200 ok 且不再执行业务 handler。可传入自定义 DedupeStore(见 processWebhook / 源码 webhook.ts)。
示例脚本
在 examples/ 目录:
echo-bot.ts— 回声slash-command.ts—/start、/helpsend-photo.ts— 本地图片上传并发送
cd botsdk/nodejs
pnpm install
npx tsx examples/echo-bot.ts构建
pnpm run build # 输出 dist/(ESM + CJS + 类型声明)
pnpm run test # VitestLicense
MIT
