@onlyne/im-qq 0.0.0-stage → 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/dist/port.d.ts ADDED
@@ -0,0 +1,86 @@
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
+ export declare const QQ_API_BASE = "https://api.bot.qq.com";
7
+ export interface QqHttpRequest {
8
+ method: string;
9
+ headers: Record<string, string>;
10
+ body?: string;
11
+ }
12
+ export interface QqHttpResponse {
13
+ readonly status: number;
14
+ text(): Promise<string>;
15
+ }
16
+ export type QqFetch = (url: string, init: QqHttpRequest) => Promise<QqHttpResponse>;
17
+ export interface QqPortErrorInit {
18
+ /** The HTTP status; `0` when the request never reached the platform. */
19
+ status: number;
20
+ /** The platform's own error code, when its body carried one. */
21
+ code?: string | number;
22
+ /** The platform's own error text, verbatim. */
23
+ detail?: string;
24
+ }
25
+ /** A platform refusal or a transport failure, with what the platform said kept apart from the summary. */
26
+ export declare class QqPortError extends Error {
27
+ readonly status: number;
28
+ readonly code?: string | number;
29
+ readonly detail?: string;
30
+ constructor(message: string, init: QqPortErrorInit);
31
+ }
32
+ /** A QQ conversation coordinate as the platform addresses it. */
33
+ export interface QqSendTarget {
34
+ kind: 'c2c' | 'group';
35
+ openid: string;
36
+ }
37
+ /** The message body QQ v2 accepts; the connector fills only what it sends. */
38
+ export interface QqMessageBody {
39
+ msg_type: number;
40
+ content?: string;
41
+ markdown?: {
42
+ content: string;
43
+ };
44
+ /** The passive reply id from the inbound event. */
45
+ msg_id?: string;
46
+ msg_seq?: number;
47
+ message_reference?: {
48
+ message_id: string;
49
+ };
50
+ input_notify?: {
51
+ input_type: 1;
52
+ input_second: number;
53
+ };
54
+ }
55
+ export interface QqSendResult {
56
+ id?: string;
57
+ timestamp?: string;
58
+ ext_info?: {
59
+ ref_idx?: string;
60
+ };
61
+ }
62
+ export interface QqPortStatus {
63
+ state: string;
64
+ connected: boolean;
65
+ rate_limits: number;
66
+ }
67
+ export interface QqPort {
68
+ start(): Promise<void>;
69
+ stop(): Promise<void>;
70
+ /** The access token, fetched on demand and cached until shortly before it expires. */
71
+ token(): Promise<string>;
72
+ request<T>(method: string, path: string, body?: unknown): Promise<T>;
73
+ send(target: QqSendTarget, body: QqMessageBody): Promise<QqSendResult>;
74
+ status(): QqPortStatus;
75
+ }
76
+ export interface QqPortOptions {
77
+ appId: string;
78
+ appSecret: string;
79
+ baseUrl?: string;
80
+ fetch?: QqFetch;
81
+ now?: () => number;
82
+ }
83
+ /** The message endpoint for one conversation; `messageId` narrows it to that message (retract). */
84
+ export declare function messagePath(target: QqSendTarget, messageId?: string): string;
85
+ export declare function createQqPort(options: QqPortOptions): QqPort;
86
+ //# sourceMappingURL=port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.d.ts","sourceRoot":"","sources":["../src/port.ts"],"names":[],"mappings":"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"}
package/dist/port.js ADDED
@@ -0,0 +1,161 @@
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
+ export const QQ_API_BASE = 'https://api.bot.qq.com';
7
+ /** The token is refreshed this long before the platform's own expiry. */
8
+ const TOKEN_REFRESH_WINDOW_MS = 60_000;
9
+ /** What the platform answers when it omits `expires_in`; QQ documents 7200 seconds. */
10
+ const DEFAULT_TOKEN_LIFETIME_SECONDS = 7200;
11
+ /** A platform refusal or a transport failure, with what the platform said kept apart from the summary. */
12
+ export class QqPortError extends Error {
13
+ status;
14
+ code;
15
+ detail;
16
+ constructor(message, init) {
17
+ super(message);
18
+ this.name = 'QqPortError';
19
+ this.status = init.status;
20
+ if (init.code !== undefined)
21
+ this.code = init.code;
22
+ if (init.detail !== undefined)
23
+ this.detail = init.detail;
24
+ }
25
+ }
26
+ /** The message endpoint for one conversation; `messageId` narrows it to that message (retract). */
27
+ export function messagePath(target, messageId) {
28
+ const scope = target.kind === 'group' ? 'groups' : 'users';
29
+ const base = `/v2/${scope}/${encodeURIComponent(target.openid)}/messages`;
30
+ return messageId === undefined ? base : `${base}/${encodeURIComponent(messageId)}`;
31
+ }
32
+ function recordOf(value) {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
34
+ ? value
35
+ : undefined;
36
+ }
37
+ function codeOf(body) {
38
+ const code = recordOf(body)?.code;
39
+ if (typeof code === 'number')
40
+ return code;
41
+ if (typeof code === 'string' && /^\d+$/.test(code))
42
+ return Number(code);
43
+ return undefined;
44
+ }
45
+ function messageOf(body) {
46
+ const record = recordOf(body);
47
+ const message = record?.message ?? record?.errmsg;
48
+ return typeof message === 'string' && message.length > 0 ? message : undefined;
49
+ }
50
+ function detailOf(error) {
51
+ return error instanceof Error ? error.message : String(error);
52
+ }
53
+ export function createQqPort(options) {
54
+ const baseUrl = (options.baseUrl ?? QQ_API_BASE).replace(/\/+$/, '');
55
+ const fetchImpl = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
56
+ const now = options.now ?? Date.now;
57
+ let cached;
58
+ let inflight;
59
+ let state = 'idle';
60
+ let rateLimits = 0;
61
+ /**
62
+ * A 2xx body can still be a refusal: the QQ error envelope is `{code, message}` and some
63
+ * endpoints answer it with HTTP 200, so a non-zero `code` is read as a rejection.
64
+ */
65
+ function rejection(method, path, status, body, raw) {
66
+ const code = codeOf(body);
67
+ const detail = messageOf(body) ?? (raw.trim().length > 0 ? raw.trim() : 'no body');
68
+ const label = code === undefined ? detail : `${code} ${detail}`;
69
+ const summary = status >= 500
70
+ ? `The QQ platform failed ${method} ${path} with HTTP ${status}: ${label}`
71
+ : `The platform rejected ${method} ${path}: ${label}`;
72
+ return new QqPortError(summary, { status, ...(code === undefined ? {} : { code }), detail });
73
+ }
74
+ async function call(method, path, body, authorized) {
75
+ const headers = { 'content-type': 'application/json' };
76
+ if (authorized)
77
+ headers.authorization = `QQBot ${await token()}`;
78
+ const init = { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) };
79
+ let response;
80
+ try {
81
+ response = await fetchImpl(`${baseUrl}${path}`, init);
82
+ }
83
+ catch (error) {
84
+ throw new QqPortError(`The QQ request ${method} ${path} did not complete: ${detailOf(error)}`, { status: 0 });
85
+ }
86
+ const raw = await response.text();
87
+ if (response.status === 429)
88
+ rateLimits += 1;
89
+ let parsed;
90
+ try {
91
+ parsed = raw.length === 0 ? undefined : JSON.parse(raw);
92
+ }
93
+ catch {
94
+ parsed = undefined;
95
+ }
96
+ if (response.status < 200 || response.status >= 300) {
97
+ throw rejection(method, path, response.status, parsed, raw);
98
+ }
99
+ const code = codeOf(parsed);
100
+ if (code !== undefined && code !== 0)
101
+ throw rejection(method, path, response.status, parsed, raw);
102
+ return parsed ?? {};
103
+ }
104
+ async function fetchToken() {
105
+ try {
106
+ const body = await call('POST', '/app/getAppAccessToken', { appId: options.appId, clientSecret: options.appSecret }, false);
107
+ const record = recordOf(body);
108
+ const token = record?.access_token;
109
+ if (typeof token !== 'string' || token.length === 0) {
110
+ throw new QqPortError('The QQ token endpoint answered without an access_token.', { status: 200 });
111
+ }
112
+ const lifetime = Number(record?.expires_in);
113
+ const seconds = Number.isFinite(lifetime) && lifetime > 0 ? lifetime : DEFAULT_TOKEN_LIFETIME_SECONDS;
114
+ cached = { token, expiresAt: now() + seconds * 1000 };
115
+ state = 'connected';
116
+ return token;
117
+ }
118
+ catch (error) {
119
+ state = 'error';
120
+ throw error;
121
+ }
122
+ }
123
+ async function token() {
124
+ if (cached !== undefined && now() < cached.expiresAt - TOKEN_REFRESH_WINDOW_MS)
125
+ return cached.token;
126
+ if (inflight !== undefined)
127
+ return inflight;
128
+ const pending = fetchToken();
129
+ inflight = pending;
130
+ try {
131
+ return await pending;
132
+ }
133
+ finally {
134
+ if (inflight === pending)
135
+ inflight = undefined;
136
+ }
137
+ }
138
+ return {
139
+ async start() {
140
+ // No long connection to open: QQ delivers events to the webhook, and the token is fetched
141
+ // on the first authorized call.
142
+ state = cached === undefined ? 'idle' : 'connected';
143
+ },
144
+ async stop() {
145
+ cached = undefined;
146
+ inflight = undefined;
147
+ state = 'stopped';
148
+ },
149
+ token,
150
+ async request(method, path, body) {
151
+ return await call(method, path, body, true);
152
+ },
153
+ async send(target, body) {
154
+ return await call('POST', messagePath(target), body, true);
155
+ },
156
+ status() {
157
+ return { state, connected: state === 'connected', rate_limits: rateLimits };
158
+ },
159
+ };
160
+ }
161
+ //# sourceMappingURL=port.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"port.js","sourceRoot":"","sources":["../src/port.ts"],"names":[],"mappings":"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"}
package/dist/send.d.ts ADDED
@@ -0,0 +1,24 @@
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
+ import type { SendOutcome, SendRequest } from '@onlyne/im-core';
7
+ import { type QqMessageBody, type QqSendResult, type QqSendTarget } from './port.js';
8
+ /** The port slice `send` needs; `QqPort` satisfies it as it stands. */
9
+ export interface QqMessagePort {
10
+ send(target: QqSendTarget, body: QqMessageBody): Promise<QqSendResult>;
11
+ }
12
+ /**
13
+ * `msg_seq` is per passive message id: QQ rejects a second message with the same `msg_id` and
14
+ * `msg_seq`, so the connector counts how many answers it already sent into one event.
15
+ */
16
+ export interface SendState {
17
+ readonly sequences: Map<string, number>;
18
+ }
19
+ export declare function createSendState(): SendState;
20
+ export declare function parseConversation(conversation: string): QqSendTarget | undefined;
21
+ /** The platform's answer as the bridge needs it: a refusal is settled, an unclear transport is not. */
22
+ export declare function outcomeOf(error: unknown, subject: 'send' | 'operation'): SendOutcome;
23
+ export declare function sendMessage(port: QqMessagePort, request: SendRequest, state: SendState): Promise<SendOutcome>;
24
+ //# sourceMappingURL=send.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send.d.ts","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAChE,OAAO,EAAe,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AAOlG,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CACxE;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzC;AAED,wBAAgB,eAAe,IAAI,SAAS,CAE3C;AAED,wBAAgB,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAShF;AAED,uGAAuG;AACvG,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,WAAW,CAcpF;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CA0BnH"}
package/dist/send.js ADDED
@@ -0,0 +1,106 @@
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
+ import { QqPortError } from './port.js';
7
+ const UNSUPPORTED = 'unsupported by @onlyne/im-qq: the connector sends text and markdown only, so attachments/cards were not sent.';
8
+ /** How many passive ids keep their `msg_seq`; beyond this the oldest is dropped. */
9
+ const SEQUENCE_LIMIT = 1024;
10
+ export function createSendState() {
11
+ return { sequences: new Map() };
12
+ }
13
+ export function parseConversation(conversation) {
14
+ const separator = conversation.indexOf(':');
15
+ if (separator <= 0)
16
+ return undefined;
17
+ const scope = conversation.slice(0, separator);
18
+ const openid = conversation.slice(separator + 1);
19
+ if (openid.length === 0)
20
+ return undefined;
21
+ if (scope === 'c2c')
22
+ return { kind: 'c2c', openid };
23
+ if (scope === 'group')
24
+ return { kind: 'group', openid };
25
+ return undefined;
26
+ }
27
+ /** The platform's answer as the bridge needs it: a refusal is settled, an unclear transport is not. */
28
+ export function outcomeOf(error, subject) {
29
+ if (error instanceof QqPortError) {
30
+ // QQ answers some refusals with HTTP 200 and a non-zero `code`; either form is settled.
31
+ const refused = (error.status >= 400 && error.status < 500)
32
+ || (error.status >= 200 && error.status < 300 && error.code !== undefined);
33
+ if (refused) {
34
+ const label = [error.code === undefined ? '' : String(error.code), error.detail ?? '']
35
+ .filter(part => part.length > 0)
36
+ .join(' ');
37
+ return { state: 'committed', error: `The platform rejected the ${subject}: ${label.length > 0 ? label : error.message}` };
38
+ }
39
+ return { state: 'unknown', error: error.message };
40
+ }
41
+ return { state: 'unknown', error: error instanceof Error ? error.message : String(error) };
42
+ }
43
+ export async function sendMessage(port, request, state) {
44
+ if (request.attachments.length > 0 || request.card !== undefined) {
45
+ return { state: 'committed', error: UNSUPPORTED };
46
+ }
47
+ const target = parseConversation(request.conversation);
48
+ if (target === undefined) {
49
+ return {
50
+ state: 'unknown',
51
+ error: `The QQ connector addresses c2c:<openid> or group:<openid>, not ${request.conversation}.`,
52
+ };
53
+ }
54
+ if (request.text.trim().length === 0) {
55
+ return { state: 'unknown', error: 'nothing to send: the QQ connector sends text or markdown only.' };
56
+ }
57
+ try {
58
+ const result = await port.send(target, messageBody(request, state));
59
+ const id = typeof result.id === 'string' && result.id.length > 0 ? result.id : undefined;
60
+ const ref = result.ext_info?.ref_idx;
61
+ return {
62
+ state: 'committed',
63
+ ...(id === undefined ? {} : { message_id: id }),
64
+ ...(typeof ref === 'string' && ref.length > 0 ? { ref_handle: ref } : {}),
65
+ };
66
+ }
67
+ catch (error) {
68
+ return outcomeOf(error, 'send');
69
+ }
70
+ }
71
+ function messageBody(request, state) {
72
+ // msg_type 2 carries `markdown` and no `content`: QQ renders whichever field matches the type.
73
+ const body = request.format === 'markdown'
74
+ ? { msg_type: 2, markdown: { content: request.text } }
75
+ : { msg_type: 0, content: request.text };
76
+ return { ...body, ...passiveReply(request.reply_to, state) };
77
+ }
78
+ /**
79
+ * `reply_to` is `msg_id|REFIDX`: the passive reply id, and the quote reference when the inbound
80
+ * event carried one. A single part answers passively without quoting anything.
81
+ */
82
+ function passiveReply(replyTo, state) {
83
+ if (replyTo === undefined || replyTo.length === 0)
84
+ return {};
85
+ const separator = replyTo.indexOf('|');
86
+ const messageId = separator < 0 ? replyTo : replyTo.slice(0, separator);
87
+ const quote = separator < 0 ? '' : replyTo.slice(separator + 1);
88
+ if (messageId.length === 0)
89
+ return {};
90
+ const sequence = (state.sequences.get(messageId) ?? 0) + 1;
91
+ state.sequences.set(messageId, sequence);
92
+ if (state.sequences.size > SEQUENCE_LIMIT) {
93
+ for (const key of state.sequences.keys()) {
94
+ if (key === messageId)
95
+ continue;
96
+ state.sequences.delete(key);
97
+ break;
98
+ }
99
+ }
100
+ return {
101
+ msg_id: messageId,
102
+ msg_seq: sequence,
103
+ ...(quote.length === 0 ? {} : { message_reference: { message_id: quote } }),
104
+ };
105
+ }
106
+ //# sourceMappingURL=send.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send.js","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
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
+ import { type KeyObject } from 'node:crypto';
7
+ export interface QqEd25519Keys {
8
+ privateKey: KeyObject;
9
+ publicKey: KeyObject;
10
+ /** The raw 32-byte public key, hex: what QQ's console shows for the app. */
11
+ publicHex: string;
12
+ }
13
+ export declare function deriveEd25519(secret: string): QqEd25519Keys;
14
+ export declare function signHex(secret: string, message: string | Buffer): string;
15
+ export declare function verifyHex(secret: string, message: string | Buffer, signatureHex: string): boolean;
16
+ /**
17
+ * The `op 13` callback validation: QQ asks the bot to prove it holds the AppSecret by signing
18
+ * `event_ts + plain_token`. The answer echoes the token with the signature.
19
+ */
20
+ export declare function challenge(secret: string, bodyJson: string): {
21
+ plain_token: string;
22
+ signature: string;
23
+ } | undefined;
24
+ //# sourceMappingURL=webhook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAmD,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAU9F,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,SAAS,CAAC;IACtB,SAAS,EAAE,SAAS,CAAC;IACrB,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;CACnB;AAKD,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAmB3D;AAED,wBAAgB,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAKxE;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAUjG;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAmBlH"}
@@ -0,0 +1,88 @@
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
+ import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
7
+ /** PKCS#8 header for an Ed25519 private key: the 32-byte seed follows it. */
8
+ const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
9
+ const SEED_LENGTH = 32;
10
+ /** An Ed25519 SPKI ends with the 32-byte public key after this many header bytes. */
11
+ const SPKI_HEADER_LENGTH = 12;
12
+ const SIGNATURE_LENGTH = 64;
13
+ const CACHE_LIMIT = 8;
14
+ /** Key derivation costs a scalar multiplication, so one secret's pair is derived once. */
15
+ const cache = new Map();
16
+ export function deriveEd25519(secret) {
17
+ const cached = cache.get(secret);
18
+ if (cached !== undefined)
19
+ return cached;
20
+ if (secret.length === 0)
21
+ throw new Error('The QQ app secret is empty; no Ed25519 key can be derived.');
22
+ const seed = Buffer.from(secret.repeat(Math.ceil(SEED_LENGTH / secret.length)), 'utf8').subarray(0, SEED_LENGTH);
23
+ const privateKey = createPrivateKey({
24
+ key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]),
25
+ format: 'der',
26
+ type: 'pkcs8',
27
+ });
28
+ const publicKey = createPublicKey(privateKey);
29
+ const publicHex = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI_HEADER_LENGTH).toString('hex');
30
+ const keys = { privateKey, publicKey, publicHex };
31
+ if (cache.size >= CACHE_LIMIT) {
32
+ const oldest = cache.keys().next();
33
+ if (!oldest.done)
34
+ cache.delete(oldest.value);
35
+ }
36
+ cache.set(secret, keys);
37
+ return keys;
38
+ }
39
+ export function signHex(secret, message) {
40
+ // The platform signs the raw body bytes; QQ messages are UTF-8, so the string form must encode
41
+ // the same way. For the ASCII-only challenge the two encodings agree byte for byte.
42
+ const data = typeof message === 'string' ? Buffer.from(message, 'utf8') : message;
43
+ return sign(null, data, deriveEd25519(secret).privateKey).toString('hex');
44
+ }
45
+ export function verifyHex(secret, message, signatureHex) {
46
+ const signature = Buffer.from(signatureHex, 'hex');
47
+ // A malformed hex string decodes short rather than throwing; the length is the real check.
48
+ if (signature.length !== SIGNATURE_LENGTH)
49
+ return false;
50
+ const data = typeof message === 'string' ? Buffer.from(message, 'utf8') : message;
51
+ try {
52
+ return verify(null, data, deriveEd25519(secret).publicKey, signature);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * The `op 13` callback validation: QQ asks the bot to prove it holds the AppSecret by signing
60
+ * `event_ts + plain_token`. The answer echoes the token with the signature.
61
+ */
62
+ export function challenge(secret, bodyJson) {
63
+ let payload;
64
+ try {
65
+ payload = JSON.parse(bodyJson);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
71
+ return undefined;
72
+ const envelope = payload;
73
+ if (envelope.op !== 13)
74
+ return undefined;
75
+ const d = envelope.d;
76
+ if (d === null || typeof d !== 'object' || Array.isArray(d))
77
+ return undefined;
78
+ const fields = d;
79
+ const plainToken = fields.plain_token;
80
+ const eventTs = fields.event_ts;
81
+ if (typeof plainToken !== 'string' || plainToken.length === 0)
82
+ return undefined;
83
+ const timestamp = typeof eventTs === 'string' ? eventTs : typeof eventTs === 'number' ? String(eventTs) : undefined;
84
+ if (timestamp === undefined || timestamp.length === 0)
85
+ return undefined;
86
+ return { plain_token: plainToken, signature: signHex(secret, `${timestamp}${plainToken}`) };
87
+ }
88
+ //# sourceMappingURL=webhook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook.js","sourceRoot":"","sources":["../src/webhook.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@onlyne/im-qq",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.2.0",
4
+ "description": "QQ official Bot OpenAPI v2 connector for the Onlyne IM gateway: gateway WebSocket or webhook events, text and markdown messages, recall and typing",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/dbydd/onlyne.git",
12
+ "directory": "packages/im-qq"
13
+ },
14
+ "homepage": "https://github.com/dbydd/onlyne#readme",
15
+ "bugs": "https://github.com/dbydd/onlyne/issues",
16
+ "keywords": ["onlyne", "qq", "bot", "im", "connector"],
17
+ "publishConfig": { "access": "public" },
18
+ "engines": { "bun": ">=1.4.3" },
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": ["dist", "LICENSE"],
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "check": "tsc && tsc -p tsconfig.tests.json",
29
+ "test": "bun tests/verify.ts && bun tests/send.ts && bun tests/webhook.ts && bun tests/gateway.ts",
30
+ "prepack": "bun run build"
31
+ },
32
+ "dependencies": {
33
+ "@onlyne/im-core": "^3.2.0",
34
+ "@onlyne/lib": "^3.2.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/bun": "^1.3.0",
38
+ "typescript": "^5.9.0"
39
+ }
40
+ }