@onlyne/im-qq 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/port.ts DELETED
@@ -1,242 +0,0 @@
1
- /**
2
- * The HTTP boundary: every platform call the connector makes goes through this port, so the
3
- * translation logic runs against a fake in tests and the real QQ OpenAPI in production. The only
4
- * injected seams are `fetch` and `now`.
5
- */
6
-
7
- export const QQ_API_BASE = 'https://api.bot.qq.com';
8
-
9
- /** The token is refreshed this long before the platform's own expiry. */
10
- const TOKEN_REFRESH_WINDOW_MS = 60_000;
11
- /** What the platform answers when it omits `expires_in`; QQ documents 7200 seconds. */
12
- const DEFAULT_TOKEN_LIFETIME_SECONDS = 7200;
13
-
14
- export interface QqHttpRequest {
15
- method: string;
16
- headers: Record<string, string>;
17
- body?: string;
18
- }
19
-
20
- export interface QqHttpResponse {
21
- readonly status: number;
22
- text(): Promise<string>;
23
- }
24
-
25
- export type QqFetch = (url: string, init: QqHttpRequest) => Promise<QqHttpResponse>;
26
-
27
- export interface QqPortErrorInit {
28
- /** The HTTP status; `0` when the request never reached the platform. */
29
- status: number;
30
- /** The platform's own error code, when its body carried one. */
31
- code?: string | number;
32
- /** The platform's own error text, verbatim. */
33
- detail?: string;
34
- }
35
-
36
- /** A platform refusal or a transport failure, with what the platform said kept apart from the summary. */
37
- export class QqPortError extends Error {
38
- readonly status: number;
39
- readonly code?: string | number;
40
- readonly detail?: string;
41
-
42
- constructor(message: string, init: QqPortErrorInit) {
43
- super(message);
44
- this.name = 'QqPortError';
45
- this.status = init.status;
46
- if (init.code !== undefined) this.code = init.code;
47
- if (init.detail !== undefined) this.detail = init.detail;
48
- }
49
- }
50
-
51
- /** A QQ conversation coordinate as the platform addresses it. */
52
- export interface QqSendTarget {
53
- kind: 'c2c' | 'group';
54
- openid: string;
55
- }
56
-
57
- /** The message body QQ v2 accepts; the connector fills only what it sends. */
58
- export interface QqMessageBody {
59
- msg_type: number;
60
- content?: string;
61
- markdown?: { content: string };
62
- /** The passive reply id from the inbound event. */
63
- msg_id?: string;
64
- msg_seq?: number;
65
- message_reference?: { message_id: string };
66
- input_notify?: { input_type: 1; input_second: number };
67
- }
68
-
69
- export interface QqSendResult {
70
- id?: string;
71
- timestamp?: string;
72
- ext_info?: { ref_idx?: string };
73
- }
74
-
75
- export interface QqPortStatus {
76
- state: string;
77
- connected: boolean;
78
- rate_limits: number;
79
- }
80
-
81
- export interface QqPort {
82
- start(): Promise<void>;
83
- stop(): Promise<void>;
84
- /** The access token, fetched on demand and cached until shortly before it expires. */
85
- token(): Promise<string>;
86
- request<T>(method: string, path: string, body?: unknown): Promise<T>;
87
- send(target: QqSendTarget, body: QqMessageBody): Promise<QqSendResult>;
88
- status(): QqPortStatus;
89
- }
90
-
91
- export interface QqPortOptions {
92
- appId: string;
93
- appSecret: string;
94
- baseUrl?: string;
95
- fetch?: QqFetch;
96
- now?: () => number;
97
- }
98
-
99
- /** The message endpoint for one conversation; `messageId` narrows it to that message (retract). */
100
- export function messagePath(target: QqSendTarget, messageId?: string): string {
101
- const scope = target.kind === 'group' ? 'groups' : 'users';
102
- const base = `/v2/${scope}/${encodeURIComponent(target.openid)}/messages`;
103
- return messageId === undefined ? base : `${base}/${encodeURIComponent(messageId)}`;
104
- }
105
-
106
- function recordOf(value: unknown): Record<string, unknown> | undefined {
107
- return value !== null && typeof value === 'object' && !Array.isArray(value)
108
- ? value as Record<string, unknown>
109
- : undefined;
110
- }
111
-
112
- function codeOf(body: unknown): string | number | undefined {
113
- const code = recordOf(body)?.code;
114
- if (typeof code === 'number') return code;
115
- if (typeof code === 'string' && /^\d+$/.test(code)) return Number(code);
116
- return undefined;
117
- }
118
-
119
- function messageOf(body: unknown): string | undefined {
120
- const record = recordOf(body);
121
- const message = record?.message ?? record?.errmsg;
122
- return typeof message === 'string' && message.length > 0 ? message : undefined;
123
- }
124
-
125
- function detailOf(error: unknown): string {
126
- return error instanceof Error ? error.message : String(error);
127
- }
128
-
129
- export function createQqPort(options: QqPortOptions): QqPort {
130
- const baseUrl = (options.baseUrl ?? QQ_API_BASE).replace(/\/+$/, '');
131
- const fetchImpl: QqFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
132
- const now = options.now ?? Date.now;
133
-
134
- let cached: { token: string; expiresAt: number } | undefined;
135
- let inflight: Promise<string> | undefined;
136
- let state = 'idle';
137
- let rateLimits = 0;
138
-
139
- /**
140
- * A 2xx body can still be a refusal: the QQ error envelope is `{code, message}` and some
141
- * endpoints answer it with HTTP 200, so a non-zero `code` is read as a rejection.
142
- */
143
- function rejection(method: string, path: string, status: number, body: unknown, raw: string): QqPortError {
144
- const code = codeOf(body);
145
- const detail = messageOf(body) ?? (raw.trim().length > 0 ? raw.trim() : 'no body');
146
- const label = code === undefined ? detail : `${code} ${detail}`;
147
- const summary = status >= 500
148
- ? `The QQ platform failed ${method} ${path} with HTTP ${status}: ${label}`
149
- : `The platform rejected ${method} ${path}: ${label}`;
150
- return new QqPortError(summary, { status, ...(code === undefined ? {} : { code }), detail });
151
- }
152
-
153
- async function call(method: string, path: string, body: unknown, authorized: boolean): Promise<unknown> {
154
- const headers: Record<string, string> = { 'content-type': 'application/json' };
155
- if (authorized) headers.authorization = `QQBot ${await token()}`;
156
- const init: QqHttpRequest = { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) };
157
- let response: QqHttpResponse;
158
- try {
159
- response = await fetchImpl(`${baseUrl}${path}`, init);
160
- } catch (error) {
161
- throw new QqPortError(`The QQ request ${method} ${path} did not complete: ${detailOf(error)}`, { status: 0 });
162
- }
163
- const raw = await response.text();
164
- if (response.status === 429) rateLimits += 1;
165
- let parsed: unknown;
166
- try {
167
- parsed = raw.length === 0 ? undefined : JSON.parse(raw);
168
- } catch {
169
- parsed = undefined;
170
- }
171
- if (response.status < 200 || response.status >= 300) {
172
- throw rejection(method, path, response.status, parsed, raw);
173
- }
174
- const code = codeOf(parsed);
175
- if (code !== undefined && code !== 0) throw rejection(method, path, response.status, parsed, raw);
176
- return parsed ?? {};
177
- }
178
-
179
- async function fetchToken(): Promise<string> {
180
- try {
181
- const body = await call(
182
- 'POST',
183
- '/app/getAppAccessToken',
184
- { appId: options.appId, clientSecret: options.appSecret },
185
- false,
186
- );
187
- const record = recordOf(body);
188
- const token = record?.access_token;
189
- if (typeof token !== 'string' || token.length === 0) {
190
- throw new QqPortError('The QQ token endpoint answered without an access_token.', { status: 200 });
191
- }
192
- const lifetime = Number(record?.expires_in);
193
- const seconds = Number.isFinite(lifetime) && lifetime > 0 ? lifetime : DEFAULT_TOKEN_LIFETIME_SECONDS;
194
- cached = { token, expiresAt: now() + seconds * 1000 };
195
- state = 'connected';
196
- return token;
197
- } catch (error) {
198
- state = 'error';
199
- throw error;
200
- }
201
- }
202
-
203
- async function token(): Promise<string> {
204
- if (cached !== undefined && now() < cached.expiresAt - TOKEN_REFRESH_WINDOW_MS) return cached.token;
205
- if (inflight !== undefined) return inflight;
206
- const pending = fetchToken();
207
- inflight = pending;
208
- try {
209
- return await pending;
210
- } finally {
211
- if (inflight === pending) inflight = undefined;
212
- }
213
- }
214
-
215
- return {
216
- async start(): Promise<void> {
217
- // No long connection to open: QQ delivers events to the webhook, and the token is fetched
218
- // on the first authorized call.
219
- state = cached === undefined ? 'idle' : 'connected';
220
- },
221
-
222
- async stop(): Promise<void> {
223
- cached = undefined;
224
- inflight = undefined;
225
- state = 'stopped';
226
- },
227
-
228
- token,
229
-
230
- async request<T>(method: string, path: string, body?: unknown): Promise<T> {
231
- return await call(method, path, body, true) as T;
232
- },
233
-
234
- async send(target: QqSendTarget, body: QqMessageBody): Promise<QqSendResult> {
235
- return await call('POST', messagePath(target), body, true) as QqSendResult;
236
- },
237
-
238
- status(): QqPortStatus {
239
- return { state, connected: state === 'connected', rate_limits: rateLimits };
240
- },
241
- };
242
- }
package/src/send.ts DELETED
@@ -1,121 +0,0 @@
1
- /**
2
- * One outbound message, translated into a QQ v2 message body. QQ carries no attachments and no
3
- * cards through this connector, so a request that has them is settled locally instead of being
4
- * half-sent.
5
- */
6
-
7
- import type { SendOutcome, SendRequest } from '@onlyne/im-core';
8
- import { QqPortError, type QqMessageBody, type QqSendResult, type QqSendTarget } from './port.js';
9
-
10
- const UNSUPPORTED = 'unsupported by @onlyne/im-qq: the connector sends text and markdown only, so attachments/cards were not sent.';
11
-
12
- /** How many passive ids keep their `msg_seq`; beyond this the oldest is dropped. */
13
- const SEQUENCE_LIMIT = 1024;
14
-
15
- /** The port slice `send` needs; `QqPort` satisfies it as it stands. */
16
- export interface QqMessagePort {
17
- send(target: QqSendTarget, body: QqMessageBody): Promise<QqSendResult>;
18
- }
19
-
20
- /**
21
- * `msg_seq` is per passive message id: QQ rejects a second message with the same `msg_id` and
22
- * `msg_seq`, so the connector counts how many answers it already sent into one event.
23
- */
24
- export interface SendState {
25
- readonly sequences: Map<string, number>;
26
- }
27
-
28
- export function createSendState(): SendState {
29
- return { sequences: new Map() };
30
- }
31
-
32
- export function parseConversation(conversation: string): QqSendTarget | undefined {
33
- const separator = conversation.indexOf(':');
34
- if (separator <= 0) return undefined;
35
- const scope = conversation.slice(0, separator);
36
- const openid = conversation.slice(separator + 1);
37
- if (openid.length === 0) return undefined;
38
- if (scope === 'c2c') return { kind: 'c2c', openid };
39
- if (scope === 'group') return { kind: 'group', openid };
40
- return undefined;
41
- }
42
-
43
- /** The platform's answer as the bridge needs it: a refusal is settled, an unclear transport is not. */
44
- export function outcomeOf(error: unknown, subject: 'send' | 'operation'): SendOutcome {
45
- if (error instanceof QqPortError) {
46
- // QQ answers some refusals with HTTP 200 and a non-zero `code`; either form is settled.
47
- const refused = (error.status >= 400 && error.status < 500)
48
- || (error.status >= 200 && error.status < 300 && error.code !== undefined);
49
- if (refused) {
50
- const label = [error.code === undefined ? '' : String(error.code), error.detail ?? '']
51
- .filter(part => part.length > 0)
52
- .join(' ');
53
- return { state: 'committed', error: `The platform rejected the ${subject}: ${label.length > 0 ? label : error.message}` };
54
- }
55
- return { state: 'unknown', error: error.message };
56
- }
57
- return { state: 'unknown', error: error instanceof Error ? error.message : String(error) };
58
- }
59
-
60
- export async function sendMessage(port: QqMessagePort, request: SendRequest, state: SendState): Promise<SendOutcome> {
61
- if (request.attachments.length > 0 || request.card !== undefined) {
62
- return { state: 'committed', error: UNSUPPORTED };
63
- }
64
- const target = parseConversation(request.conversation);
65
- if (target === undefined) {
66
- return {
67
- state: 'unknown',
68
- error: `The QQ connector addresses c2c:<openid> or group:<openid>, not ${request.conversation}.`,
69
- };
70
- }
71
- if (request.text.trim().length === 0) {
72
- return { state: 'unknown', error: 'nothing to send: the QQ connector sends text or markdown only.' };
73
- }
74
- try {
75
- const result = await port.send(target, messageBody(request, state));
76
- const id = typeof result.id === 'string' && result.id.length > 0 ? result.id : undefined;
77
- const ref = result.ext_info?.ref_idx;
78
- return {
79
- state: 'committed',
80
- ...(id === undefined ? {} : { message_id: id }),
81
- ...(typeof ref === 'string' && ref.length > 0 ? { ref_handle: ref } : {}),
82
- };
83
- } catch (error) {
84
- return outcomeOf(error, 'send');
85
- }
86
- }
87
-
88
- function messageBody(request: SendRequest, state: SendState): QqMessageBody {
89
- // msg_type 2 carries `markdown` and no `content`: QQ renders whichever field matches the type.
90
- const body: QqMessageBody = request.format === 'markdown'
91
- ? { msg_type: 2, markdown: { content: request.text } }
92
- : { msg_type: 0, content: request.text };
93
- return { ...body, ...passiveReply(request.reply_to, state) };
94
- }
95
-
96
- /**
97
- * `reply_to` is `msg_id|REFIDX`: the passive reply id, and the quote reference when the inbound
98
- * event carried one. A single part answers passively without quoting anything.
99
- */
100
- function passiveReply(replyTo: string | undefined, state: SendState): Partial<QqMessageBody> {
101
- if (replyTo === undefined || replyTo.length === 0) return {};
102
- const separator = replyTo.indexOf('|');
103
- const messageId = separator < 0 ? replyTo : replyTo.slice(0, separator);
104
- const quote = separator < 0 ? '' : replyTo.slice(separator + 1);
105
- if (messageId.length === 0) return {};
106
- const sequence = (state.sequences.get(messageId) ?? 0) + 1;
107
- state.sequences.set(messageId, sequence);
108
- if (state.sequences.size > SEQUENCE_LIMIT) {
109
- for (const key of state.sequences.keys()) {
110
- if (key === messageId) continue;
111
- state.sequences.delete(key);
112
- break;
113
- }
114
- }
115
- return {
116
- msg_id: messageId,
117
- msg_seq: sequence,
118
- ...(quote.length === 0 ? {} : { message_reference: { message_id: quote } }),
119
- };
120
- }
121
-
package/src/webhook.ts DELETED
@@ -1,90 +0,0 @@
1
- /**
2
- * QQ's callback authentication: Ed25519 over an app-secret-derived seed. The platform derives the
3
- * same key pair from the AppSecret, which is why the secret alone both signs the `op 13` challenge
4
- * and verifies every `op 0` dispatch.
5
- */
6
-
7
- import { createPrivateKey, createPublicKey, sign, verify, type KeyObject } from 'node:crypto';
8
-
9
- /** PKCS#8 header for an Ed25519 private key: the 32-byte seed follows it. */
10
- const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
11
- const SEED_LENGTH = 32;
12
- /** An Ed25519 SPKI ends with the 32-byte public key after this many header bytes. */
13
- const SPKI_HEADER_LENGTH = 12;
14
- const SIGNATURE_LENGTH = 64;
15
- const CACHE_LIMIT = 8;
16
-
17
- export interface QqEd25519Keys {
18
- privateKey: KeyObject;
19
- publicKey: KeyObject;
20
- /** The raw 32-byte public key, hex: what QQ's console shows for the app. */
21
- publicHex: string;
22
- }
23
-
24
- /** Key derivation costs a scalar multiplication, so one secret's pair is derived once. */
25
- const cache = new Map<string, QqEd25519Keys>();
26
-
27
- export function deriveEd25519(secret: string): QqEd25519Keys {
28
- const cached = cache.get(secret);
29
- if (cached !== undefined) return cached;
30
- if (secret.length === 0) throw new Error('The QQ app secret is empty; no Ed25519 key can be derived.');
31
- const seed = Buffer.from(secret.repeat(Math.ceil(SEED_LENGTH / secret.length)), 'utf8').subarray(0, SEED_LENGTH);
32
- const privateKey = createPrivateKey({
33
- key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
34
- format: 'der',
35
- type: 'pkcs8',
36
- });
37
- const publicKey = createPublicKey(privateKey);
38
- const publicHex = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI_HEADER_LENGTH).toString('hex');
39
- const keys: QqEd25519Keys = { privateKey, publicKey, publicHex };
40
- if (cache.size >= CACHE_LIMIT) {
41
- const oldest = cache.keys().next();
42
- if (!oldest.done) cache.delete(oldest.value);
43
- }
44
- cache.set(secret, keys);
45
- return keys;
46
- }
47
-
48
- export function signHex(secret: string, message: string | Buffer): string {
49
- // The platform signs the raw body bytes; QQ messages are UTF-8, so the string form must encode
50
- // the same way. For the ASCII-only challenge the two encodings agree byte for byte.
51
- const data = typeof message === 'string' ? Buffer.from(message, 'utf8') : message;
52
- return sign(null, data, deriveEd25519(secret).privateKey).toString('hex');
53
- }
54
-
55
- export function verifyHex(secret: string, message: string | Buffer, signatureHex: string): boolean {
56
- const signature = Buffer.from(signatureHex, 'hex');
57
- // A malformed hex string decodes short rather than throwing; the length is the real check.
58
- if (signature.length !== SIGNATURE_LENGTH) return false;
59
- const data = typeof message === 'string' ? Buffer.from(message, 'utf8') : message;
60
- try {
61
- return verify(null, data, deriveEd25519(secret).publicKey, signature);
62
- } catch {
63
- return false;
64
- }
65
- }
66
-
67
- /**
68
- * The `op 13` callback validation: QQ asks the bot to prove it holds the AppSecret by signing
69
- * `event_ts + plain_token`. The answer echoes the token with the signature.
70
- */
71
- export function challenge(secret: string, bodyJson: string): { plain_token: string; signature: string } | undefined {
72
- let payload: unknown;
73
- try {
74
- payload = JSON.parse(bodyJson);
75
- } catch {
76
- return undefined;
77
- }
78
- if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return undefined;
79
- const envelope = payload as Record<string, unknown>;
80
- if (envelope.op !== 13) return undefined;
81
- const d = envelope.d;
82
- if (d === null || typeof d !== 'object' || Array.isArray(d)) return undefined;
83
- const fields = d as Record<string, unknown>;
84
- const plainToken = fields.plain_token;
85
- const eventTs = fields.event_ts;
86
- if (typeof plainToken !== 'string' || plainToken.length === 0) return undefined;
87
- const timestamp = typeof eventTs === 'string' ? eventTs : typeof eventTs === 'number' ? String(eventTs) : undefined;
88
- if (timestamp === undefined || timestamp.length === 0) return undefined;
89
- return { plain_token: plainToken, signature: signHex(secret, `${timestamp}${plainToken}`) };
90
- }