@youglin/adapter-qq-bot
v0.1.0
Published
QQ Bot adapter for Chat SDK
Downloads
502
Maintainers
Readme
@youglin/adapter-qq-bot
QQ Bot adapter for Chat SDK. It supports QQ Bot OpenAPI v2 webhooks, Gateway WebSocket events, text/markdown messages, card buttons, passive replies, rich media for c2c/group scenes, deletion, channel reactions, and cached message history.
Installation
pnpm add @youglin/adapter-qq-bot chat @chat-adapter/state-memoryUsage
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createQQBotAdapter } from "@youglin/adapter-qq-bot";
export const qqBot = createQQBotAdapter();
export const bot = new Chat({
userName: "mybot",
state: createMemoryState(),
adapters: {
qq: qqBot,
},
});
bot.onNewMention(async (thread, message) => {
await thread.post({ markdown: `You said: **${message.text}**` });
});Environment Variables
QQ_BOT_APP_ID=your-app-id
QQ_BOT_APP_SECRET=your-app-secret
# Optional
QQ_BOT_ACCESS_TOKEN=pre-provisioned-token
QQ_BOT_USERNAME=mybot
QQ_BOT_USER_ID=bot-user-id
QQ_BOT_SANDBOX=1
QQ_BOT_API_URL=https://api.sgroup.qq.com
QQ_BOT_TOKEN_URL=https://bots.qq.comAppID and AppSecret are used to fetch AccessToken from /app/getAppAccessToken. OpenAPI calls use Authorization: QQBot ACCESS_TOKEN. QQ_BOT_ACCESS_TOKEN should normally be the bare token value; if you paste a QQBot ... authorization value, the adapter strips the prefix before sending requests.
QQ_BOT_API_URL and QQ_BOT_TOKEN_URL must be absolute http or https base URLs. Trailing slashes and surrounding whitespace are ignored.
For compatibility with different deployment naming styles, the adapter also accepts QQBOT_* aliases for the environment variables above.
QQ_BOT_USERNAME / QQBOT_USERNAME and the userName config option are treated as explicit bot names for fallback mention detection. When supplied, they are preserved over Chat.userName and Gateway READY.user.username; when omitted, the adapter can infer the display name from Chat initialization or Gateway READY events.
When QQ_BOT_USER_ID / QQBOT_USER_ID, botUserId, or Gateway READY.user.id is available, the adapter caches the bot identity so getUser(botUserId) can return the bot's current display name without an extra QQ API call.
Inbound message authors, button interaction users, reaction users, and management-event operators are cached by id when QQ includes them, so later getUser(userId) calls can resolve recently seen users from process-local state.
Webhook Route
import { bot } from "@/lib/bot";
export async function POST(request: Request): Promise<Response> {
return bot.webhooks.qq(request);
}The adapter verifies X-Signature-Ed25519 and X-Signature-Timestamp for event callbacks when appSecret or webhookSecret is configured. QQ callback URL validation (op: 13) is answered before normal event signature enforcement because QQ's validation request only provides plain_token and event_ts; the adapter returns the required plain_token and signature.
WebSocket Mode
import { Chat } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { QQBotIntents, createQQBotAdapter } from "@youglin/adapter-qq-bot";
const qq = createQQBotAdapter({
mode: "websocket",
websocket: {
intents:
QQBotIntents.GROUP_AND_C2C_EVENT |
QQBotIntents.INTERACTION |
QQBotIntents.PUBLIC_GUILD_MESSAGES |
QQBotIntents.DIRECT_MESSAGE |
QQBotIntents.GUILD_MESSAGE_REACTIONS,
shard: [0, 1],
},
});
const bot = new Chat({
userName: "mybot",
state: createMemoryState(),
adapters: { qq },
});
await bot.initialize();Webhook and WebSocket dispatch payloads share the same parser. WebSocket mode also handles QQ heartbeat, reconnect, resume, and RESUMED lifecycle frames. After Gateway Hello, the adapter sends Identify or Resume, then starts the heartbeat cycle once QQ confirms the session with READY or RESUMED.
WebSocket intents must be a non-negative safe integer, shard must be a [shardId, shardCount] tuple with shardId < shardCount, and reconnectDelayMs must be non-negative. When useBotGateway is not disabled and shard is not configured, the adapter uses /gateway/bot's shards recommendation as [0, shards].
QQ Bot Setup Checklist
- Create or select a bot in the QQ Bot open platform and collect
AppIDandAppSecret. - Configure either a public HTTPS webhook route or Gateway WebSocket mode. QQ webhook callback ports are limited by the platform, so use a standard HTTPS deployment port.
- Subscribe to the event families your app needs. For c2c messaging and any group capability available to your bot, enable
QQBotIntents.GROUP_AND_C2C_EVENT; for button interactions, enableQQBotIntents.INTERACTION; for public guild at-messages, enableQQBotIntents.PUBLIC_GUILD_MESSAGES; for guild direct messages, enableQQBotIntents.DIRECT_MESSAGE; for channel reactions, enableQQBotIntents.GUILD_MESSAGE_REACTIONS. - For webhook mode, register the route that calls
bot.webhooks.qq(request)and keep signature verification enabled in production. - For sandbox testing, set
QQ_BOT_SANDBOX=1or passsandbox: true. - Validate with a real c2c message, button callback, and any media paths your product depends on before publishing production credentials. Group, guild/channel, and guild-DM live checks are optional and should only be run when those QQ capabilities are available to your bot account; local tests cover their adapter behavior.
Thread IDs
The adapter encodes platform targets into Chat SDK thread IDs:
qq.encodeThreadId({ scene: "c2c", openId: "USER_OPENID" });
qq.encodeThreadId({ scene: "group", groupOpenId: "GROUP_OPENID" });
qq.encodeThreadId({ scene: "channel", channelId: "CHANNEL_ID" });
qq.encodeThreadId({ scene: "guild-dm", guildId: "GUILD_ID" });Generated IDs use the qq:* prefix so they match the adapters: { qq: ... } registration key and work with bot.thread("qq:..."). Existing qq-bot:* IDs are still accepted by direct adapter methods and the live verifier for compatibility, but Chat SDK bot.thread() resolves adapters by prefix, so new stored IDs should use qq:* unless your app also registers a qq-bot adapter alias.
QQ openids are opaque and cannot be inferred by Chat SDK's generic bot.openDM(userId) resolver. To start a QQ c2c conversation outside an inbound event, call qq.openDM(openId) or qq.encodeThreadId({ scene: "c2c", openId }), then pass the returned ID to bot.thread(threadId).
Bot Share Links
QQ exposes a bot profile share-link API that can help users add or share the bot when you do not yet have a c2c or group openid. The adapter exposes it as a QQ-specific helper:
const url = await qq.generateShareUrl({ callbackData: "campaign-1" });callbackData is optional, trimmed, limited to QQ's documented 32 characters, and sent as callback_data so QQ can return it in later add-user attribution events.
Passive Replies
When the adapter receives an inbound message, it caches the latest msg_id and event id for that thread. Subsequent thread.post() calls inside the reply window automatically include msg_id, msg_seq, and event_id, so QQ treats them as passive replies where possible.
The adapter also caches one-shot event_id reply windows from QQ interaction and management events that support event-only passive replies, including INTERACTION_CREATE, FRIEND_ADD, C2C_MSG_RECEIVE, GROUP_ADD_ROBOT, and GROUP_MSG_RECEIVE.
Expired or exhausted passive reply windows are pruned from process-local state.
You can override this per message:
import type { QQBotPostableMessage } from "@youglin/adapter-qq-bot";
await thread.post({
raw: "reply",
qq: {
msgId: "incoming-message-id",
msgSeq: 2,
eventId: "event-id",
},
} as QQBotPostableMessage);Explicit QQ reply fields are authoritative. Supplying eventId/event_id without msgId/msg_id sends an event-only passive reply and suppresses cached msg_id/msg_seq; supplying isWakeup/is_wakeup suppresses all passive reply fields.
QQ Native Payload Options
Use QQBotPostableMessage.qq when you need QQ-specific send fields that are not modeled by Chat SDK primitives, such as Ark, embed, markdown templates, media file_info, keyboard templates, or message references.
await thread.post({
raw: "",
qq: {
content: "template fallback",
markdown: {
custom_template_id: "template-id",
params: [{ key: "name", values: ["Ada"] }],
},
messageReference: { message_id: "referenced-message-id" },
},
} as QQBotPostableMessage);The adapter accepts both camelCase helper names (msgType, msgId, eventId, isWakeup, messageReference) and QQ's documented snake_case field names (msg_type, msg_id, event_id, is_wakeup, message_reference). msg_type is validated against QQ message types 0, 2, 3, 4, and 7; native content, msg_id, and event_id must be strings when supplied; native media.file_info and message_reference.message_id are required when those objects are supplied and are trimmed. message_reference.ignore_get_message_error must be a boolean when supplied. ark, embed, keyboard, markdown, and message_reference must be objects; native rich payloads are mutually exclusive, and explicit msg_type values must match the final payload (markdown: 2, ark: 3, embed: 4, media: 7). Setting msgType: 0 sends the rendered plain text and strips any Chat SDK generated markdown payload; setting a non-text msg_type requires the corresponding native payload object. Native Markdown payloads require non-empty content or custom_template_id; template params[].key must be non-empty and params[].values must contain at least one string. Native ARK payloads require integer template_id and a kv array with non-empty key values and either string value or nested obj[].obj_kv. Native Embed payloads are limited to channel and guild-DM scenes; when fields or thumbnail.url are supplied, their documented string/object shapes are validated. Native keyboards require either a non-empty template id or inline content.rows; inline keyboards are validated against QQ's 1-5 rows and 1-5 buttons per row shape, including required render_data, action, and permission fields. is_wakeup must be a boolean and is treated as mutually exclusive with passive reply fields.
Chat SDK files and attachments are sent as QQ media messages (msg_type: 7). They can be combined with native content, keyboard, message_reference, and passive reply fields, but not with native QQ markdown, ark, embed, media, or non-media msg_type values in the same message.
Public Utilities
The package exports QQBotAdapter, createQQBotAdapter, QQBotIntents, QQ signature helpers, card callback helpers, and QQ payload/event TypeScript types for custom runtime wiring and tests. Native send payload types include QQBotArk, QQBotEmbed, QQBotKeyboard, QQBotMarkdown, QQBotMessageReference, and QQBotSendMessagePayload.
Features
| Feature | Support |
| --- | --- |
| Webhook events | Yes |
| Webhook signature verification | Yes |
| Callback URL validation | Yes |
| Gateway WebSocket | Yes |
| C2C messages | Yes |
| Group @ messages | Yes |
| Channel messages | Yes |
| Guild DMs | Yes |
| Text / raw messages | Yes |
| Markdown messages | Yes |
| Card buttons | QQ keyboard callback/link buttons |
| QQ native payloads | Ark, embed, markdown templates, media file_info, message references |
| Button interactions | Yes, with /interactions/{id} ack |
| Bot share links | Yes, via generateShareUrl() |
| Rich media | C2C/group image/video/audio/file, subject to QQ scene limits |
| Delete message | Yes; QQ delete events prune the process-local cache |
| Edit message | Delete plus repost fallback |
| Reactions | Channel messages only |
| Typing indicator | No-op; QQ Bot OpenAPI does not expose one |
| Chat SDK Plan / PostableObject | Fallback text via Chat SDK; no native QQ object renderer |
| Message history | Process-local cache |
| List threads | Known process-local cached QQ conversations |
Card button IDs, labels, callback payloads, and link URLs are trimmed and validated before the QQ keyboard is sent. QQ keyboard action data is capped at 1024 bytes. Incoming button interactions are acknowledged with /interactions/{id} before user action handlers run, decode Chat SDK callback data when present, and fall back to QQ button_id for template buttons that do not include button_data. In webhook mode, the adapter waits for this platform acknowledgement when no waitUntil hook is supplied. Interactions that include an id but cannot be routed to a Chat SDK thread are acknowledged with QQ failure code 1.
Notes
- QQ Bot has separate send endpoints for c2c, group, channel, and guild DM scenes.
- Rich media upload is documented for c2c and group scenes. The adapter accepts non-empty binary data, non-empty fetchable attachments, remote URLs, and non-empty data URLs with strict base64 validation. Generic file upload is not open for group scenes.
- Chat SDK message, action, and reaction handler failures are logged and passed to
waitUntilwhen provided, without preventing QQ webhook acknowledgements. IfwaitUntilitself throws, the adapter logs that registration failure; interaction acknowledgements fall back to being awaited before the webhook response is returned. - QQ recall/delete rules still apply to
deleteMessage(): c2c and group bot-sent messages can only be recalled inside QQ's documented time window, and guild channel/DM recalls depend on QQ permissions. The adapter surfaces QQ API errors through Chat SDK error classes: token validation failures map to authentication errors, missing QQ API permissions map to permission errors, andRetry-Aftertiming is preserved on rate-limit errors when QQ sends it. - QQ may retry the same inbound message or interaction event. Runtime webhook and WebSocket dispatch paths deduplicate repeated message ids before invoking Chat SDK message handlers and repeated interaction ids before invoking Chat SDK action handlers, so duplicate deliveries do not repeat business handlers or reset passive reply sequence state.
- QQ limits passive reply windows and reply counts; the adapter tracks
msg_sequp to 5 per inbound message. - The adapter intentionally does not implement Chat SDK
postObject()oreditObject(). Structured objects such asPlanuse Chat SDK fallback text, and later edits use the adapter's delete-plus-repost message fallback. - Channel visibility metadata is
privatefor c2c, group, and guild DM scenes. Guild text channels returnunknownbecause QQ channel visibility cannot be inferred safely from the thread ID alone. fetchMessages(),fetchChannelMessages(),fetchMessage(), andlistThreads()operate on messages observed or sent by the current process, retaining the newest 1000 messages per thread.fetchThread()requires an encoded QQ thread ID and returns canonicalqq:*metadata. Channel-level helpers such asfetchChannelInfo()accept raw QQ channel IDs and encoded QQ channel identities such asqq:channel:*,qq:c2c:*,qq:group:*, andqq:guild-dm:*; private identities let Chat SDKthread.channelAPIs work for c2c, group, and guild-DM conversations. Legacyqq-bot:*thread IDs are decoded for compatibility but are not emitted by new adapter calls.fetchMessage(threadId, messageId)accepts either the adapter's encoded message id or a raw QQ message id scoped to the supplied thread. QQMESSAGE_DELETE,DIRECT_MESSAGE_DELETE, andPUBLIC_MESSAGE_DELETEevents remove matching cached messages when the payload includes enough message/thread identifiers: channel deletes needchannel_id, and direct-message deletes needguild_id.- QQ
MESSAGE_AUDIT_PASSandMESSAGE_AUDIT_REJECTare lifecycle events. They are logged and rejected byparseMessage()because Chat SDK message handlers expect concrete message content. - QQ guild/channel/member lifecycle events, forum events, and audio events are typed as official dispatch names, but they are ignored by the runtime dispatcher and rejected by
parseMessage()because they do not map to Chat SDK message primitives. - Channel metadata APIs may require guild permissions.
fetchThread()andfetchChannelInfo()gracefully fall back when metadata calls fail. - Official docs: https://bot.q.qq.com/wiki/develop/api-v2/
Development
pnpm check
pnpm pack --dry-runpnpm check runs type checking, the coverage-thresholded test suite, the live verifier script syntax check, and the build. pnpm pack --dry-run verifies the public npm tarball contents.
Live QQ Validation
Local tests mock QQ OpenAPI, including group and guild/channel paths. pnpm verify:qq:live imports the built adapter package, checks public runtime exports, TypeScript declaration exports, signature helpers, callback helpers, thread ID roundtrips, and key outbound payload invariants, then validates real credentials and Gateway access. Group, guild/channel, and guild-DM live tests are not part of the required live gate unless QQ has opened those scenes for your bot:
The live verifier automatically loads .env.local and .env from the current working directory when present. Existing shell environment variables take precedence, and secret values are never printed by the verifier.
QQ_BOT_APP_ID=your-app-id \
QQ_BOT_APP_SECRET=your-app-secret \
pnpm verify:qq:liveRun qq-bot-adapter-live-check --help or node scripts/qq-live-check.mjs --help to inspect verifier options without making QQ API calls.
The same verifier is published as the qq-bot-adapter-live-check binary for installed-package validation:
QQ_BOT_APP_ID=your-app-id \
QQ_BOT_APP_SECRET=your-app-secret \
pnpm dlx --package @youglin/adapter-qq-bot qq-bot-adapter-live-checkTo verify a deployed webhook route, point the verifier at the public URL:
QQ_BOT_LIVE_WEBHOOK=1 \
QQ_BOT_TEST_WEBHOOK_URL=https://your-app.example.com/api/qq/webhook \
pnpm verify:qq:liveThe webhook check sends a QQ callback validation payload and verifies the returned plain_token signature, then sends a signed READY dispatch and expects the QQ ack { op: 12 }. Set QQ_BOT_TEST_WEBHOOK_SECRET if the deployed route uses a secret different from QQ_BOT_APP_SECRET.
To prove the adapter can complete a real Gateway WebSocket identify flow, enable the optional READY check:
QQ_BOT_LIVE_GATEWAY=1 \
QQ_BOT_LIVE_GATEWAY_TIMEOUT_MS=15000 \
pnpm verify:qq:liveQQ_BOT_LIVE_GATEWAY_INTENTS can override the adapter's default intent bitmask when you need to validate a specific subscription set. By default, the adapter requests implemented c2c, group, public guild message, guild-DM, interaction, and channel reaction events, but account-level availability is controlled by QQ. The verifier stops the WebSocket immediately after receiving READY.
To verify the bot share-link OpenAPI endpoint, enable:
QQ_BOT_LIVE_SHARE_URL=1 \
QQ_BOT_TEST_SHARE_CALLBACK_DATA=source-1 \
pnpm verify:qq:liveThe verifier calls generateShareUrl(), validates that QQ returned a non-empty URL, and prints it so you can use it to share the bot profile.
To capture real thread IDs from Gateway events, enable observe mode and then send a c2c message. If QQ has opened group access for your bot, @ the bot in a group also works:
QQ_BOT_LIVE_OBSERVE=1 \
QQ_BOT_LIVE_OBSERVE_TIMEOUT_MS=60000 \
pnpm verify:qq:liveThe observer prints canonical thread IDs such as qq:c2c:*, and qq:group:* when group events are available. It does not print message text unless QQ_BOT_LIVE_OBSERVE_PRINT_TEXT=1 is set.
To also send real messages, pass one or more encoded adapter thread IDs and opt in explicitly:
QQ_BOT_APP_ID=your-app-id \
QQ_BOT_APP_SECRET=your-app-secret \
QQ_BOT_TEST_THREAD_ID=qq:c2c:USER_OPENID \
QQ_BOT_LIVE_SEND=1 \
pnpm verify:qq:liveAdditional optional live-send targets let the same verifier cover more QQ scenes when QQ has opened them for your bot:
QQ_BOT_TEST_C2C_THREAD_ID=qq:c2c:USER_OPENID
QQ_BOT_TEST_GROUP_THREAD_ID=qq:group:GROUP_OPENID
QQ_BOT_TEST_CHANNEL_THREAD_ID=qq:channel:CHANNEL_ID
QQ_BOT_TEST_GUILD_DM_THREAD_ID=qq:guild-dm:GUILD_ID
QQ_BOT_TEST_THREAD_IDS=qq:c2c:USER_OPENID,qq:group:GROUP_OPENIDTo send a QQ keyboard card, set QQ_BOT_TEST_BUTTON_THREAD_ID. This proves the outbound card/button payload reaches QQ; verifying the click callback still requires observing a real INTERACTION_CREATE event through your webhook or Gateway setup.
To verify a real button click end-to-end through Gateway, run the interaction check and click the Verify callback button in QQ before the timeout:
QQ_BOT_LIVE_SEND=1 \
QQ_BOT_LIVE_INTERACTION=1 \
QQ_BOT_TEST_INTERACTION_THREAD_ID=qq:c2c:USER_OPENID \
QQ_BOT_LIVE_INTERACTION_TIMEOUT_MS=60000 \
pnpm verify:qq:liveThe interaction verifier sends a card with a unique callback value, waits for the matching INTERACTION_CREATE action to reach Chat SDK processAction, and confirms the adapter's /interactions/{id} acknowledgement returned successfully.
To verify rich media upload, set QQ_BOT_TEST_MEDIA_THREAD_ID to a c2c thread, or to a group thread when group access is available, QQ_BOT_TEST_MEDIA_TYPE to image, video, audio, or file, and exactly one media source:
QQ_BOT_TEST_MEDIA_THREAD_ID=qq:c2c:USER_OPENID
QQ_BOT_TEST_MEDIA_TYPE=image
QQ_BOT_TEST_MEDIA_URL=https://example.com/image.png
# or: QQ_BOT_TEST_MEDIA_FILE=./image.png
# or: QQ_BOT_TEST_MEDIA_DATA_URL=data:image/png;base64,...When group access is available, QQ group media supports image, video, and audio uploads; generic file uploads are c2c-only.
License
MIT
