@starim-io/bot-sdk
v0.1.17
Published
Official Node.js SDK for StarIM Bot API and Webhooks
Readme
@starim-io/bot-sdk
StarIM 机器人开放平台的 官方 Node.js / TypeScript SDK,封装:
- Bot API:
Authorization: Bearer <bot_token>调用网关/api/v1/bots/** - Webhook:校验
X-StarIM-Signature-V2(HMAC-SHA256,带X-StarIM-Timestamp防重放)、update_id幂等、高阶事件路由
已发布到 npm:
@starim-io/bot-sdk·npm install @starim-io/bot-sdk(版本号以 npm registry 为准)
要求
- Node.js ≥ 18(依赖全局
fetch)
安装
npm install @starim-io/bot-sdk
# 或
pnpm add @starim-io/bot-sdk本仓库已将 botsdk/nodejs 加入根目录 pnpm workspace,在仓库根执行 pnpm install 即可安装依赖。单独构建/测试:
pnpm --filter @starim-io/bot-sdk run build
pnpm --filter @starim-io/bot-sdk run test对外即可在任意项目中:
npm install @starim-io/bot-sdk
# 或
pnpm add @starim-io/bot-sdk5 分钟快速上手
- 在 StarIM 开放平台开发者控制台提交机器人申请,由平台 Admin 审核通过后取得
sbot_...Token。 - 为机器人配置 HTTPS Webhook,并设置
secret_token(与下文代码中secretToken一致)。 - 编写 Webhook 服务(需保留 原始 JSON 字节 用于验签,见下文「Webhook 签名校验」)。
import { StarIMBot } from '@starim-io/bot-sdk';
const bot = new StarIMBot({
token: process.env.STARIM_BOT_TOKEN!,
secretToken: process.env.STARIM_WEBHOOK_SECRET!,
baseUrl: process.env.STARIM_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 并收到自动回复。
环境变量
| 变量 | 说明 |
|------|------|
| STARIM_API_BASE | 网关根 URL,须含 /api/v1,如 https://gw.example.com/api/v1 |
| STARIM_BOT_TOKEN | 审核通过后签发的 Bot Token(sbot_ 前缀) |
| STARIM_WEBHOOK_SECRET | setWebhook 时配置的 secret_token |
未设置 STARIM_API_BASE 时,客户端默认使用占位地址 https://api.starim.example/api/v1,请务必改为真实网关。
低阶客户端 StarIMBotClient
不跑 Webhook、仅调 Bot API 时使用:
import { StarIMBotClient } from '@starim-io/bot-sdk';
const client = new StarIMBotClient({
token: process.env.STARIM_BOT_TOKEN!,
baseUrl: process.env.STARIM_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',
// 引用回复:被引用消息的 ID(与 TG reply_to_message_id 同义,文本/媒体等发送方法均支持)
reply_to_message_id: '<messageId>',
reply_markup: {
inline_keyboard: [[{ text: '收到', callback_data: 'ack' }]]
}
});
// sendMessage 一次只创建一条消息:去除首尾空白后最多 5000 个 UTF-16 code units。
// SDK 不会自动截断或拆分,超限会收到 HTTP 400;需要分条时由调用方显式发送多次。
// 在事件处理器中可直接引用触发本次回调的消息
bot.on('message', async ctx => {
await ctx.reply('已收到你的消息', { quote: true });
});
await client.setMyCommands({
commands: [
{ command: 'start', description: '开始使用' },
{ command: 'help', description: '帮助' }
],
scope: 'all_private_chats'
});主要方法
| 方法 | 说明 |
|------|------|
| getMe() | GET /bots/me |
| sendMessage | 单条文本消息(1-5000 个 UTF-16 code units;不自动截断或拆分;可选 reply_to_message_id 引用回复、reply_markup) |
| setMyCommands / getMyCommands / deleteMyCommands | 命令菜单(按 scope、language_code 分组) |
| sendPhoto / sendDocument / sendVideo / sendAudio | file_id 为上传完成后的文件 ID;可选 caption(附件配文本,混合消息)、reply_to_message_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) |
| setMyGroupPrivacy / getMyGroupPrivacy | 配置 / 读取群隐私模式(对标 TG /setprivacy:enabled=false 时群里收全部普通消息) |
| kickChatMember / banChatMember / unbanChatMember | 群治理(chat_id 为群会话 Mongo _id;机器人须为群主或群管理员;ban 为先踢后黑,非原子) |
| setWebhook / deleteWebhook | Webhook 配置 |
| issueUploadCredentials / completeUpload | S3 直传两步;两步都必须传同一份 64 位 SHA-256 checksum |
| sendPhotoFromFile / sendDocumentFromFile | 本地路径 → 上传 → 发送 |
| verifyToken() | 公开接口校验 Token |
| listChatVirtualMembers / simulateSendMessage(增值能力) | 列群内可调度成员 / 以指定成员身份发送文本、图片、文件、音频、视频或位置;type 缺省为 text,默认不对外开放,需联系开放平台支持申请开通 |
调度群内虚拟成员(增值能力)
该能力默认不对外开放,需联系开放平台支持申请开通后使用。 支持文本、图片、文件、音频、视频和位置;
type不传时按text兼容。文本和媒体说明单条 ≤ 1000 字,所有调用均会被记录用于合规追溯。
消息类型与主要字段:
| type | 必填业务字段 | 可选字段 |
|------|------------|---------|
| 缺省或 text | text | reply_to_message_id |
| photo / image | file_id | caption、width、height、thumbnail_url、reply_to_message_id |
| document / file | file_id | caption、thumbnail_url、reply_to_message_id |
| audio | file_id | caption、duration、performer、title、reply_to_message_id |
| video | file_id | caption、duration、width、height、thumbnail_url、reply_to_message_id |
| location | latitude、longitude | name、address、reply_to_message_id |
媒体消息的 file_id 须先通过 issueUploadCredentials → S3 直传 → completeUpload 取得。位置参数范围为纬度 -90..90、经度 -180..180;duration、width、height 如填写须大于 0,reply_to_message_id 最长 128 字符。
import { StarIMBotClient } from '@starim-io/bot-sdk';
const client = new StarIMBotClient({ token: process.env.STARIM_TOKEN! });
// 1) 列出群内可被调度的成员(深页传上一页 pagination.next_cursor)
const { items, pagination } = await client.listChatVirtualMembers({
chat_id: '<conversation_id>',
limit: 50
});
console.log('display count', pagination.total, 'candidates', items.slice(0, 3));
// pagination.total 是展示人数;以 has_more 判断是否继续,不能以 items 为空结束。
// 下一页:client.listChatVirtualMembers({ chat_id, limit: 50, cursor: pagination.next_cursor! })
// offset 仅兼容 0–1000;409 表示名册版本变化或尚未完成独立关系切换。
// 列表不是发送许可;提交前再次校验,撤权、停用、禁言或踢出后返回 403。
// 2) 发送文本:type 可省略,服务端按 text 处理
if (items[0]) {
const memberId = items[0].virtual_user_id;
await client.simulateSendMessage({
chat_id: '<conversation_id>',
virtual_user_id: memberId,
text: '大家好~'
});
// 3) 发送图片:file_id 来自 completeUpload
await client.simulateSendMessage({
chat_id: '<conversation_id>',
virtual_user_id: memberId,
type: 'photo',
file_id: '<uploaded_file_id>',
caption: '现场照片',
width: 1280,
height: 720
});
// 4) 发送位置
await client.simulateSendMessage({
chat_id: '<conversation_id>',
virtual_user_id: memberId,
type: 'location',
latitude: 22.3193,
longitude: 114.1694,
name: '尖沙咀',
address: '香港九龙'
});
}常见错误:
| HTTP | 触发 |
|------|------|
| 400 | 入参缺失 / virtual_user_id 格式不合法 / 消息类型不支持 / 文本或说明超长 / 媒体缺少 file_id / 坐标或媒体元数据不合法 |
| 403 | 能力未开通 / 机器人不在该群 / 目标成员不在该群或状态不可用 |
| 404 | virtual_user_id 不存在 |
InlineKeyboard / Callback Query
bot.command('menu', async ctx => {
await ctx.reply('请选择:', {
reply_markup: {
inline_keyboard: [
[
{ text: '点赞', callback_data: 'like' },
{ text: '文档', url: 'https://open.starim.io/' }
],
[
{
text: '联系客服',
action: { type: 'user_profile', user_id: '<supportUserId>' }
},
{
text: '查看公开群',
action: { type: 'public_group', group_id: '<groupId>' }
}
]
]
}
});
});
bot.on('callback_query', async ctx => {
await ctx.answerCallback({ text: `收到: ${ctx.callbackQuery?.data}` });
});user_profile 会在 Web、App 和桌面客户端内部打开机器人或普通用户资料;public_group 仅允许打开公开群的群详情页,私有群不会跳转。user_id 和 group_id 都应使用稳定的系统 ID。
Webhook 签名校验原理
平台投递时(见 WebhookDeliveryWorker)下发的签名头:
| 头 | 算法 | 防 replay | 状态 |
| --- | --- | --- | --- |
| X-StarIM-Signature-V2 | sha256=HMAC(secret, "${ts}.${body}") | 是(结合 X-StarIM-Timestamp 判 5min 时窗) | 当前唯一推荐 |
| X-StarIM-Timestamp | unix 秒 | — | V2 必带 |
| X-StarIM-Update-Id | 透传 update.update_id | — | 用于幂等 |
SDK 的 verifyWebhookSignature / processWebhook / webhookMiddleware 默认使用 V2 校验。
服务端必须用 与计算签名时完全相同的字节 验签。若使用
express.json()默认中间件,会丢失原始字节,签名将无法对齐。推荐之一:
方式 A:express.raw
import express from 'express';
import { StarIMBot } from '@starim-io/bot-sdk';
const app = express();
const bot = new StarIMBot({ 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');
}
}多媒体:本地 / Buffer / URL
import {
StarIMBotClient,
sendPhotoFromBuffer,
sendPhotoFromUrl,
sendVideoFromUrl
} from '@starim-io/bot-sdk';
import { readFile } from 'node:fs/promises';
const client = new StarIMBotClient({ 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
});URL 一步法只接受 HTTP(S),默认最多下载 100MB、最长等待 120 秒,读取响应体过程中也会持续检查
实际字节数,避免无 Content-Length 的异常响应撑爆进程内存。可通过 maxBytes、timeoutMs 和
signal 收紧限制或主动取消;远程 URL 应来自可信来源,SDK 不替业务方开放任意内网抓取能力。
一步法会先计算完整文件 SHA-256,并在凭证请求中显式发送 checksum、reuseExisting: true、
reuseRequestId 与 isPublic: false。
服务端命中同 Bot、同类别、文件名、大小和访问级别的可信内容后,会复用物理对象但为本次操作
创建新的逻辑 File,再返回 uploadRequired: false;SDK 会跳过 PUT 与 complete。直接调用
issueUploadCredentials / completeUpload 时,checksum 同样必填且必须是完整文件的 64 位
SHA-256 十六进制摘要;缺失或格式错误会在网络请求前失败。低层调用默认不开启秒传;调用方启用时
必须为同一次重试保持相同 reuseRequestId,不同逻辑上传必须使用不同值。SDK 不会跨消息缓存或复用旧 file_id:即使字节
完全相同,每次业务发送也会取得独立逻辑 File,只有底层 FileBlob/S3 物理对象被共享。
错误码说明(常见)
平台与网关可能返回 success: false 或 HTTP 4xx/5xx,SDK 统一抛出 StarIMApiError(含 statusCode、message、code 若存在)。文档与产品侧常见语义如下(具体以网关响应为准):
| 语义 | 可能场景 |
|------|----------|
| invalid_token / 401 | Token 错误、吊销、格式不对 |
| bot_disabled / 403 | 机器人未审核通过、已停用 |
| chat_forbidden / 4xx | 无权限向目标会话发消息 |
| rate_limited / 429 | 触发发送配额 |
| webhook_invalid | Webhook URL 非 HTTPS 或配置无效(配置接口返回消息) |
幂等与重复投递
平台重试 Webhook 时 保持同一 update_id。StarIMBot 内置 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 © StarIM
