@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/dist/gateway.d.ts +66 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +484 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +303 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +11 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +211 -0
- package/dist/normalize.js.map +1 -0
- package/dist/port.d.ts +86 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +161 -0
- package/dist/port.js.map +1 -0
- package/dist/send.d.ts +24 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/send.js +106 -0
- package/dist/send.js.map +1 -0
- package/dist/webhook.d.ts +24 -0
- package/dist/webhook.d.ts.map +1 -0
- package/dist/webhook.js +88 -0
- package/dist/webhook.js.map +1 -0
- package/package.json +14 -9
- package/src/gateway.ts +0 -546
- package/src/index.ts +0 -395
- package/src/normalize.ts +0 -207
- package/src/port.ts +0 -242
- package/src/send.ts +0 -121
- package/src/webhook.ts +0 -90
package/src/index.ts
DELETED
|
@@ -1,395 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The QQ official Bot OpenAPI v2 connector. Inbound events arrive either on the platform's
|
|
3
|
-
* WebSocket gateway (the default, owned by `gateway.ts`) or on the signed HTTP callback the host
|
|
4
|
-
* hands to `handleWebhook`; every outbound platform call goes through the injected port either way.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { readFile as nodeReadFile } from 'node:fs/promises';
|
|
8
|
-
import type { Json } from '@onlyne/lib';
|
|
9
|
-
import { OnlyneError } from '@onlyne/lib';
|
|
10
|
-
import { resolveCredential, type Connector, type ImEvent, type SendOutcome, type SendRequest } from '@onlyne/im-core';
|
|
11
|
-
import { createQqPort, messagePath, type QqMessageBody, type QqPort, type QqSendTarget } from './port.js';
|
|
12
|
-
import { normalizeEvent } from './normalize.js';
|
|
13
|
-
import { createSendState, outcomeOf, parseConversation, sendMessage } from './send.js';
|
|
14
|
-
import { challenge, verifyHex } from './webhook.js';
|
|
15
|
-
import { createQqGateway, type QqGateway, type QqGatewayOptions, type QqGatewayState } from './gateway.js';
|
|
16
|
-
|
|
17
|
-
export {
|
|
18
|
-
createQqPort, messagePath, QqPortError,
|
|
19
|
-
type QqFetch, type QqHttpRequest, type QqHttpResponse, type QqMessageBody, type QqPort,
|
|
20
|
-
type QqPortOptions, type QqPortStatus, type QqSendResult, type QqSendTarget,
|
|
21
|
-
} from './port.js';
|
|
22
|
-
export { normalizeEvent } from './normalize.js';
|
|
23
|
-
export { challenge, deriveEd25519, signHex, verifyHex, type QqEd25519Keys } from './webhook.js';
|
|
24
|
-
export { createSendState, parseConversation, sendMessage, type QqMessagePort, type SendState } from './send.js';
|
|
25
|
-
export {
|
|
26
|
-
createQqGateway, QQ_INTENTS,
|
|
27
|
-
type QqGateway, type QqGatewayHandlers, type QqGatewayOptions, type QqGatewayState, type QqGatewayStatus,
|
|
28
|
-
type QqSessionStartLimit,
|
|
29
|
-
} from './gateway.js';
|
|
30
|
-
|
|
31
|
-
export const QQ_CREDENTIAL_VARIABLES = ['QQ_BOT_APP_ID', 'QQ_BOT_APP_SECRET'] as const;
|
|
32
|
-
/** Platform operations the connector implements beyond `send`; a resource lists them. */
|
|
33
|
-
export const QQ_OPERATIONS = ['recall', 'typing'] as const;
|
|
34
|
-
|
|
35
|
-
/** Where inbound events come from: the platform's gateway socket, or its signed HTTP callback. */
|
|
36
|
-
export type QqTransport = 'websocket' | 'webhook';
|
|
37
|
-
|
|
38
|
-
/** QQ signs every dispatch over its timestamp; a callback older than this is a replay. */
|
|
39
|
-
const SIGNATURE_WINDOW_MS = 5 * 60 * 1000;
|
|
40
|
-
/** What QQ's `input_notify` shows the user; the platform caps the hint at 60 seconds. */
|
|
41
|
-
const TYPING_SECONDS = 60;
|
|
42
|
-
|
|
43
|
-
export type QqConnectionState =
|
|
44
|
-
| 'onboarding'
|
|
45
|
-
/** The webhook transport is authenticated and ready; the platform calls in. */
|
|
46
|
-
| 'connected'
|
|
47
|
-
| QqGatewayState
|
|
48
|
-
| 'error';
|
|
49
|
-
|
|
50
|
-
/** The optional platform-status surface the gateway duck-types; not part of `Connector`. */
|
|
51
|
-
export interface QqStatus {
|
|
52
|
-
state: string;
|
|
53
|
-
connected: boolean;
|
|
54
|
-
reconnects: number;
|
|
55
|
-
rate_limits: number;
|
|
56
|
-
last_event_at?: string;
|
|
57
|
-
onboarding?: string[];
|
|
58
|
-
error?: string;
|
|
59
|
-
/** The inbound transport in force. */
|
|
60
|
-
transport: QqTransport;
|
|
61
|
-
/** WebSocket gateway counters; present once the gateway transport has started. */
|
|
62
|
-
heartbeat_timeouts?: number;
|
|
63
|
-
resumes?: number;
|
|
64
|
-
resume_failures?: number;
|
|
65
|
-
shards?: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** The gateway transport's knobs; the connector supplies the port, the log and the clock. */
|
|
69
|
-
export type QqGatewayTuning = Omit<QqGatewayOptions, 'port' | 'log' | 'onState' | 'now'>;
|
|
70
|
-
|
|
71
|
-
export interface QqConnectorOptions {
|
|
72
|
-
/** Explicit `appId:appSecret`; wins over the file and the environment pair. */
|
|
73
|
-
credential?: string;
|
|
74
|
-
/** Path to a credential file; `$VAR` references resolve from the environment. */
|
|
75
|
-
credentialFile?: string;
|
|
76
|
-
env?: Record<string, string | undefined>;
|
|
77
|
-
readFile?: (path: string) => Promise<string>;
|
|
78
|
-
/** The HTTP boundary; production leaves it unset and one is built from the credential. */
|
|
79
|
-
port?: QqPort;
|
|
80
|
-
log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
81
|
-
/**
|
|
82
|
-
* One call per connection state transition, with the shard and session the transition knew;
|
|
83
|
-
* the host that owns the log renders it as `connector.state`.
|
|
84
|
-
*/
|
|
85
|
-
onState?: (to: string, fields?: Record<string, unknown>) => void;
|
|
86
|
-
/**
|
|
87
|
-
* Inbound transport. The gateway socket is QQ's own receive path and needs no public endpoint;
|
|
88
|
-
* `webhook` serves the signed HTTP callback instead. Default `websocket`.
|
|
89
|
-
*/
|
|
90
|
-
transport?: QqTransport;
|
|
91
|
-
/** Gateway transport tuning: the reconnect backoff, and the intents bitmask. */
|
|
92
|
-
gateway?: QqGatewayTuning;
|
|
93
|
-
/** The inbound path the operator registers with QQ; reported once at start. */
|
|
94
|
-
webhookPath?: string;
|
|
95
|
-
now?: () => number;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface QqConnector extends Connector {
|
|
99
|
-
readonly operations: readonly string[];
|
|
100
|
-
validate(operation: string, data: Json): void;
|
|
101
|
-
operate(operation: string, data: Json): Promise<SendOutcome>;
|
|
102
|
-
status(): QqStatus;
|
|
103
|
-
/** The platform's callback: op 13 validation and signed op 0 dispatches. */
|
|
104
|
-
handleWebhook(request: Request): Promise<Response>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
108
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
109
|
-
? value as Record<string, unknown>
|
|
110
|
-
: undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function createQqConnector(options: QqConnectorOptions = {}): QqConnector {
|
|
114
|
-
const now = options.now ?? Date.now;
|
|
115
|
-
const log = options.log ?? (() => {});
|
|
116
|
-
const transport: QqTransport = options.transport ?? 'websocket';
|
|
117
|
-
const listeners: Array<(event: ImEvent) => void | Promise<void>> = [];
|
|
118
|
-
/** One chain per conversation: the endpoint's `receive` calls land in arrival order. */
|
|
119
|
-
const queues = new Map<string, Promise<void>>();
|
|
120
|
-
const sendState = createSendState();
|
|
121
|
-
|
|
122
|
-
let port: QqPort | undefined;
|
|
123
|
-
let gateway: QqGateway | undefined;
|
|
124
|
-
let lifecycle: QqConnectionState = 'onboarding';
|
|
125
|
-
let onboarding: string[] | undefined;
|
|
126
|
-
let failure: string | undefined;
|
|
127
|
-
let lastEventAt: string | undefined;
|
|
128
|
-
let appId: string | undefined;
|
|
129
|
-
let appSecret: string | undefined;
|
|
130
|
-
/** The credential resolved and the token was proven: outbound calls can go. */
|
|
131
|
-
let ready = false;
|
|
132
|
-
|
|
133
|
-
function emit(event: ImEvent): Promise<void> {
|
|
134
|
-
const previous = queues.get(event.conversation) ?? Promise.resolve();
|
|
135
|
-
const run = previous.then(async () => {
|
|
136
|
-
for (const listener of listeners) await listener(event);
|
|
137
|
-
});
|
|
138
|
-
// The chain survives a failing listener so the next event in this conversation still runs;
|
|
139
|
-
// the caller keeps the rejection and answers the platform with a retryable status.
|
|
140
|
-
const settled = run.catch(() => {});
|
|
141
|
-
queues.set(event.conversation, settled);
|
|
142
|
-
void settled.then(() => {
|
|
143
|
-
if (queues.get(event.conversation) === settled) queues.delete(event.conversation);
|
|
144
|
-
});
|
|
145
|
-
return run;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function refuse(code: string, message: string, status: number): Response {
|
|
149
|
-
return Response.json({ error: { code, message } }, { status });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function credentialFields(problem: string | undefined): string[] {
|
|
153
|
-
const env = options.env ?? process.env;
|
|
154
|
-
// A configured file that could not be read: the resolve problem is the real fix, not the pair.
|
|
155
|
-
if (options.credentialFile !== undefined) return [problem ?? `Provide ${QQ_CREDENTIAL_VARIABLES.join(' + ')}.`];
|
|
156
|
-
const missing = QQ_CREDENTIAL_VARIABLES.filter(name => (env[name] ?? '').length === 0);
|
|
157
|
-
return missing.length > 0 ? missing : [problem ?? `Provide ${QQ_CREDENTIAL_VARIABLES.join(' + ')}.`];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
platform: 'qq',
|
|
162
|
-
operations: QQ_OPERATIONS,
|
|
163
|
-
|
|
164
|
-
async start(): Promise<void> {
|
|
165
|
-
lifecycle = 'connecting';
|
|
166
|
-
const credential = await resolveCredential(
|
|
167
|
-
{ value: options.credential, file: options.credentialFile, variables: [...QQ_CREDENTIAL_VARIABLES] },
|
|
168
|
-
options.readFile ?? (path => nodeReadFile(path, 'utf8')),
|
|
169
|
-
options.env ?? process.env,
|
|
170
|
-
);
|
|
171
|
-
if (credential.value === undefined) {
|
|
172
|
-
// A missing credential is a state, not a failure: the host renders the fields to provide.
|
|
173
|
-
lifecycle = 'onboarding';
|
|
174
|
-
onboarding = credentialFields(credential.problem);
|
|
175
|
-
failure = undefined;
|
|
176
|
-
log('qq connector is onboarding', { onboarding });
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
const separator = credential.value.indexOf(':');
|
|
180
|
-
if (separator <= 0 || separator === credential.value.length - 1) {
|
|
181
|
-
lifecycle = 'error';
|
|
182
|
-
failure = 'The QQ credential is appId:appSecret.';
|
|
183
|
-
onboarding = [...QQ_CREDENTIAL_VARIABLES];
|
|
184
|
-
log('qq connector rejected the credential shape', { error: failure });
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
187
|
-
appId = credential.value.slice(0, separator);
|
|
188
|
-
appSecret = credential.value.slice(separator + 1);
|
|
189
|
-
port = options.port ?? createQqPort({
|
|
190
|
-
appId,
|
|
191
|
-
appSecret,
|
|
192
|
-
...(options.now === undefined ? {} : { now: options.now }),
|
|
193
|
-
});
|
|
194
|
-
try {
|
|
195
|
-
await port.start();
|
|
196
|
-
// Fetching the token proves the pair: QQ answers a bad secret here and nowhere earlier.
|
|
197
|
-
await port.token();
|
|
198
|
-
ready = true;
|
|
199
|
-
onboarding = undefined;
|
|
200
|
-
failure = undefined;
|
|
201
|
-
if (transport === 'webhook') {
|
|
202
|
-
lifecycle = 'connected';
|
|
203
|
-
log('qq connector is connected', {
|
|
204
|
-
app_id: appId,
|
|
205
|
-
transport,
|
|
206
|
-
webhook_path: options.webhookPath ?? 'unset',
|
|
207
|
-
});
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
gateway = createQqGateway({
|
|
211
|
-
port,
|
|
212
|
-
log,
|
|
213
|
-
now,
|
|
214
|
-
...(options.onState === undefined ? {} : { onState: options.onState }),
|
|
215
|
-
...(options.gateway ?? {}),
|
|
216
|
-
});
|
|
217
|
-
// Not awaited to a socket: a gateway that cannot reach QQ stays a retrying state, and
|
|
218
|
-
// the host's status panel shows it, instead of a failed start.
|
|
219
|
-
await gateway.start({
|
|
220
|
-
dispatch: async payload => {
|
|
221
|
-
const event = normalizeEvent(payload, now);
|
|
222
|
-
if (event === undefined) return;
|
|
223
|
-
// A rejection leaves the sequence number where it was: the platform replays the event.
|
|
224
|
-
await emit(event);
|
|
225
|
-
log('qq.dispatch', {
|
|
226
|
-
conversation: event.conversation, source_id: event.source_id, kind: event.kind,
|
|
227
|
-
...(typeof payload.t === 'string' ? { type: payload.t } : {}),
|
|
228
|
-
...(typeof payload.s === 'number' ? { seq: payload.s } : {}),
|
|
229
|
-
});
|
|
230
|
-
lastEventAt = event.at;
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
lifecycle = 'connecting';
|
|
234
|
-
log('qq connector is dialing the gateway', { app_id: appId, transport });
|
|
235
|
-
} catch (error) {
|
|
236
|
-
ready = false;
|
|
237
|
-
lifecycle = 'error';
|
|
238
|
-
failure = error instanceof Error ? error.message : String(error);
|
|
239
|
-
log('qq connector could not authenticate', { error: failure });
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
|
-
|
|
243
|
-
onEvent(listener: (event: ImEvent) => void | Promise<void>): void {
|
|
244
|
-
listeners.push(listener);
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
async send(request: SendRequest): Promise<SendOutcome> {
|
|
248
|
-
if (port === undefined || !ready) {
|
|
249
|
-
const hint = onboarding === undefined ? '' : `; provide ${onboarding.join(', ')}`;
|
|
250
|
-
return { state: 'unknown', error: `The QQ connector is not connected${hint}.` };
|
|
251
|
-
}
|
|
252
|
-
return sendMessage(port, request, sendState);
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
validate(operation: string, data: Json): void {
|
|
256
|
-
if (!(QQ_OPERATIONS as readonly string[]).includes(operation)) {
|
|
257
|
-
throw new OnlyneError('invalid_request', `The QQ connector does not implement ${operation}.`);
|
|
258
|
-
}
|
|
259
|
-
const raw = recordOf(data) ?? {};
|
|
260
|
-
if (operation === 'recall' && (typeof raw.message_id !== 'string' || raw.message_id.length === 0)) {
|
|
261
|
-
throw new OnlyneError('invalid_request', 'recall names message_id.');
|
|
262
|
-
}
|
|
263
|
-
if (typeof raw.conversation !== 'string' || raw.conversation.length === 0) {
|
|
264
|
-
throw new OnlyneError('invalid_request', `${operation} names conversation.`);
|
|
265
|
-
}
|
|
266
|
-
},
|
|
267
|
-
|
|
268
|
-
async operate(operation: string, data: Json): Promise<SendOutcome> {
|
|
269
|
-
if (port === undefined || !ready) {
|
|
270
|
-
return { state: 'unknown', error: 'The QQ connector is not connected.' };
|
|
271
|
-
}
|
|
272
|
-
const raw = recordOf(data) ?? {};
|
|
273
|
-
const conversation = typeof raw.conversation === 'string' ? raw.conversation : '';
|
|
274
|
-
const target: QqSendTarget | undefined = parseConversation(conversation);
|
|
275
|
-
if (target === undefined) {
|
|
276
|
-
return {
|
|
277
|
-
state: 'unknown',
|
|
278
|
-
error: `The QQ connector addresses c2c:<openid> or group:<openid>, not ${conversation}.`,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
try {
|
|
282
|
-
if (operation === 'recall') {
|
|
283
|
-
await port.request('DELETE', messagePath(target, String(raw.message_id)));
|
|
284
|
-
return { state: 'committed' };
|
|
285
|
-
}
|
|
286
|
-
if (operation === 'typing') {
|
|
287
|
-
if (target.kind === 'group') return { state: 'unknown', error: 'QQ typing is c2c only.' };
|
|
288
|
-
const body: QqMessageBody = { msg_type: 6, input_notify: { input_type: 1, input_second: TYPING_SECONDS } };
|
|
289
|
-
await port.send(target, body);
|
|
290
|
-
return { state: 'committed' };
|
|
291
|
-
}
|
|
292
|
-
return { state: 'unknown', error: `The QQ connector does not implement ${operation}.` };
|
|
293
|
-
} catch (error) {
|
|
294
|
-
return outcomeOf(error, 'operation');
|
|
295
|
-
}
|
|
296
|
-
},
|
|
297
|
-
|
|
298
|
-
async handleWebhook(request: Request): Promise<Response> {
|
|
299
|
-
if (request.method !== 'POST') {
|
|
300
|
-
return refuse('method_not_allowed', 'The QQ webhook accepts POST only.', 405);
|
|
301
|
-
}
|
|
302
|
-
const rawBody = await request.text();
|
|
303
|
-
let payload: unknown;
|
|
304
|
-
try {
|
|
305
|
-
payload = JSON.parse(rawBody);
|
|
306
|
-
} catch {
|
|
307
|
-
return refuse('invalid_request', 'The QQ webhook body is not JSON.', 400);
|
|
308
|
-
}
|
|
309
|
-
const envelope = recordOf(payload);
|
|
310
|
-
if (envelope === undefined) {
|
|
311
|
-
return refuse('invalid_request', 'The QQ webhook body is not an object.', 400);
|
|
312
|
-
}
|
|
313
|
-
if (appSecret === undefined) {
|
|
314
|
-
return refuse('unavailable', 'The QQ connector has no credential, so a callback cannot be validated.', 503);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (envelope.op === 13) {
|
|
318
|
-
const claimed = request.headers.get('x-bot-appid');
|
|
319
|
-
if (claimed !== null && appId !== undefined && claimed !== appId) {
|
|
320
|
-
return refuse('unauthenticated', 'X-Bot-Appid does not match the configured app id.', 401);
|
|
321
|
-
}
|
|
322
|
-
const answer = challenge(appSecret, rawBody);
|
|
323
|
-
if (answer === undefined) {
|
|
324
|
-
return refuse('invalid_request', 'The validation callback names plain_token and event_ts.', 400);
|
|
325
|
-
}
|
|
326
|
-
return Response.json(answer);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (envelope.op === 0) {
|
|
330
|
-
const timestamp = request.headers.get('x-signature-timestamp');
|
|
331
|
-
const signature = request.headers.get('x-signature-ed25519');
|
|
332
|
-
if (timestamp === null || signature === null) {
|
|
333
|
-
return refuse('unauthenticated', 'The QQ dispatch carries no signature headers.', 401);
|
|
334
|
-
}
|
|
335
|
-
const stamp = Number(timestamp);
|
|
336
|
-
if (!Number.isFinite(stamp) || Math.abs(now() - stamp * 1000) > SIGNATURE_WINDOW_MS) {
|
|
337
|
-
return refuse('unauthenticated', 'The QQ dispatch timestamp is outside the five-minute window.', 401);
|
|
338
|
-
}
|
|
339
|
-
if (!verifyHex(appSecret, `${timestamp}${rawBody}`, signature)) {
|
|
340
|
-
return refuse('unauthenticated', 'The QQ dispatch signature does not verify.', 401);
|
|
341
|
-
}
|
|
342
|
-
const event = normalizeEvent(payload, now);
|
|
343
|
-
if (event !== undefined) {
|
|
344
|
-
try {
|
|
345
|
-
await emit(event);
|
|
346
|
-
} catch (error) {
|
|
347
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
348
|
-
// Never ack a batch whose durable receive failed: QQ retries a non-2xx answer.
|
|
349
|
-
log('qq inbound listener failed', { source_id: event.source_id, error: detail });
|
|
350
|
-
return refuse('unavailable', `The inbound event was not stored: ${detail}`, 500);
|
|
351
|
-
}
|
|
352
|
-
log('qq.dispatch', {
|
|
353
|
-
conversation: event.conversation, source_id: event.source_id, kind: event.kind,
|
|
354
|
-
...(typeof envelope.t === 'string' ? { type: envelope.t } : {}),
|
|
355
|
-
});
|
|
356
|
-
lastEventAt = event.at;
|
|
357
|
-
}
|
|
358
|
-
return Response.json({ op: 12 });
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Heartbeats and anything newer than this connector knows: acknowledged, not translated.
|
|
362
|
-
return Response.json({ op: 12 });
|
|
363
|
-
},
|
|
364
|
-
|
|
365
|
-
status(): QqStatus {
|
|
366
|
-
const observed = gateway?.status();
|
|
367
|
-
return {
|
|
368
|
-
state: observed?.state ?? lifecycle,
|
|
369
|
-
connected: observed === undefined ? lifecycle === 'connected' : observed.state === 'online',
|
|
370
|
-
reconnects: observed?.reconnects ?? 0,
|
|
371
|
-
rate_limits: port?.status().rate_limits ?? 0,
|
|
372
|
-
transport,
|
|
373
|
-
...(lastEventAt === undefined ? {} : { last_event_at: lastEventAt }),
|
|
374
|
-
...(onboarding === undefined || onboarding.length === 0 ? {} : { onboarding }),
|
|
375
|
-
...(failure === undefined ? {} : { error: failure }),
|
|
376
|
-
...(observed === undefined ? {} : {
|
|
377
|
-
heartbeat_timeouts: observed.heartbeat_timeouts,
|
|
378
|
-
resumes: observed.resumes,
|
|
379
|
-
resume_failures: observed.resume_failures,
|
|
380
|
-
shards: observed.shards,
|
|
381
|
-
}),
|
|
382
|
-
};
|
|
383
|
-
},
|
|
384
|
-
|
|
385
|
-
async stop(): Promise<void> {
|
|
386
|
-
listeners.length = 0;
|
|
387
|
-
queues.clear();
|
|
388
|
-
await gateway?.stop();
|
|
389
|
-
gateway = undefined;
|
|
390
|
-
await port?.stop();
|
|
391
|
-
ready = false;
|
|
392
|
-
lifecycle = 'stopped';
|
|
393
|
-
},
|
|
394
|
-
};
|
|
395
|
-
}
|
package/src/normalize.ts
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The QQ event envelope, translated into one `ImEvent`. Pure: no clock beyond the injected one,
|
|
3
|
-
* no network, no state — the same payload always normalizes the same way.
|
|
4
|
-
*
|
|
5
|
-
* Envelope: `{id, op, d, s, t}`. `op 0` is a dispatch and `t` names the event; anything else
|
|
6
|
-
* (heartbeat, callback validation) is not an inbound event.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { ImAttachment, ImEvent, ImReply } from '@onlyne/im-core';
|
|
10
|
-
import type { Json } from '@onlyne/lib';
|
|
11
|
-
|
|
12
|
-
const PLATFORM = 'qq';
|
|
13
|
-
|
|
14
|
-
const MESSAGE_EVENTS: Record<string, true> = {
|
|
15
|
-
C2C_MESSAGE_CREATE: true,
|
|
16
|
-
GROUP_AT_MESSAGE_CREATE: true,
|
|
17
|
-
};
|
|
18
|
-
const LIFECYCLE_EVENTS: Record<string, true> = {
|
|
19
|
-
GROUP_ADD_ROBOT: true,
|
|
20
|
-
GROUP_DEL_ROBOT: true,
|
|
21
|
-
FRIEND_ADD: true,
|
|
22
|
-
FRIEND_DEL: true,
|
|
23
|
-
C2C_MSG_RECEIVE: true,
|
|
24
|
-
C2C_MSG_REJECT: true,
|
|
25
|
-
GROUP_MSG_RECEIVE: true,
|
|
26
|
-
GROUP_MSG_REJECT: true,
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
type Raw = Record<string, unknown>;
|
|
30
|
-
|
|
31
|
-
function recordOf(value: unknown): Raw | undefined {
|
|
32
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Raw : undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function textOf(value: unknown): string | undefined {
|
|
36
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function senderOf(d: Raw): string | undefined {
|
|
40
|
-
const author = recordOf(d.author);
|
|
41
|
-
return textOf(author?.user_openid) ?? textOf(author?.id)
|
|
42
|
-
?? textOf(d.openid) ?? textOf(d.op_member_openid) ?? textOf(d.user_openid);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* `group:<group_openid>` when the event names a group, else `c2c:<openid>`. A c2c message keeps
|
|
47
|
-
* its openid under `author`, not at the top level.
|
|
48
|
-
*/
|
|
49
|
-
function conversationOf(d: Raw): string | undefined {
|
|
50
|
-
const group = textOf(d.group_openid);
|
|
51
|
-
if (group !== undefined) return `group:${group}`;
|
|
52
|
-
const user = senderOf(d);
|
|
53
|
-
return user === undefined ? undefined : `c2c:${user}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** `message_scene.ext` carries `msg_idx=REFIDX_…` for this message and `ref_msg_idx=…` for the quote. */
|
|
57
|
-
function sceneIndexes(d: Raw): { self?: string; quoted?: string } {
|
|
58
|
-
const ext = recordOf(d.message_scene)?.ext;
|
|
59
|
-
const indexes: { self?: string; quoted?: string } = {};
|
|
60
|
-
if (!Array.isArray(ext)) return indexes;
|
|
61
|
-
for (const entry of ext) {
|
|
62
|
-
if (typeof entry !== 'string') continue;
|
|
63
|
-
const separator = entry.indexOf('=');
|
|
64
|
-
if (separator <= 0) continue;
|
|
65
|
-
const name = entry.slice(0, separator);
|
|
66
|
-
const value = entry.slice(separator + 1);
|
|
67
|
-
if (value.length === 0) continue;
|
|
68
|
-
if (name === 'msg_idx') indexes.self = value;
|
|
69
|
-
else if (name === 'ref_msg_idx') indexes.quoted = value;
|
|
70
|
-
}
|
|
71
|
-
return indexes;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function attachmentsOf(d: Raw): ImAttachment[] {
|
|
75
|
-
if (!Array.isArray(d.attachments)) return [];
|
|
76
|
-
const attachments: ImAttachment[] = [];
|
|
77
|
-
for (const entry of d.attachments) {
|
|
78
|
-
const item = recordOf(entry);
|
|
79
|
-
if (item === undefined) continue;
|
|
80
|
-
const contentType = (textOf(item.content_type) ?? '').toLowerCase();
|
|
81
|
-
const kind: ImAttachment['kind'] = contentType.startsWith('image/')
|
|
82
|
-
? 'image'
|
|
83
|
-
: contentType === 'voice' || contentType.startsWith('audio/')
|
|
84
|
-
? 'audio'
|
|
85
|
-
: contentType.startsWith('video/')
|
|
86
|
-
? 'video'
|
|
87
|
-
: 'file';
|
|
88
|
-
const url = textOf(item.url);
|
|
89
|
-
// A voice attachment arrives as SILK; the WAV url is the one a player can open.
|
|
90
|
-
const handle = kind === 'audio' ? textOf(item.voice_wav_url) ?? url : url ?? textOf(item.voice_wav_url);
|
|
91
|
-
if (handle === undefined) continue;
|
|
92
|
-
const filename = textOf(item.filename);
|
|
93
|
-
attachments.push({ kind, platform_handle: handle, ...(filename === undefined ? {} : { alt: filename }) });
|
|
94
|
-
}
|
|
95
|
-
return attachments;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The first voice attachment's server-side transcript, when the platform produced one. */
|
|
99
|
-
function transcriptOf(d: Raw): string | undefined {
|
|
100
|
-
if (!Array.isArray(d.attachments)) return undefined;
|
|
101
|
-
for (const entry of d.attachments) {
|
|
102
|
-
const item = recordOf(entry);
|
|
103
|
-
if (item === undefined) continue;
|
|
104
|
-
const contentType = (textOf(item.content_type) ?? '').toLowerCase();
|
|
105
|
-
if (contentType !== 'voice' && !contentType.startsWith('audio/')) continue;
|
|
106
|
-
const transcript = textOf(item.asr_refer_text);
|
|
107
|
-
if (transcript !== undefined) return transcript;
|
|
108
|
-
}
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** `msg_elements[0].content` of a quote reply: the platform inlines the quoted body there. */
|
|
113
|
-
function quotedTextOf(d: Raw): string | undefined {
|
|
114
|
-
if (!Array.isArray(d.msg_elements)) return undefined;
|
|
115
|
-
const first = recordOf(d.msg_elements[0]);
|
|
116
|
-
return textOf(first?.content);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function messageEvent(d: Raw, envelope: Raw, now: () => number): ImEvent | undefined {
|
|
120
|
-
const conversation = conversationOf(d);
|
|
121
|
-
const messageId = textOf(d.id);
|
|
122
|
-
if (conversation === undefined || messageId === undefined) return undefined;
|
|
123
|
-
const messageType = typeof d.message_type === 'number' ? d.message_type : undefined;
|
|
124
|
-
const indexes = sceneIndexes(d);
|
|
125
|
-
const quotedText = messageType === 103 ? quotedTextOf(d) : undefined;
|
|
126
|
-
const attachments = attachmentsOf(d);
|
|
127
|
-
const content = textOf(d.content);
|
|
128
|
-
const arkPrompt = textOf(recordOf(d.ark_data)?.prompt);
|
|
129
|
-
|
|
130
|
-
const text = content
|
|
131
|
-
?? (quotedText ?? arkPrompt)
|
|
132
|
-
?? transcriptOf(d)
|
|
133
|
-
?? '';
|
|
134
|
-
|
|
135
|
-
const replyParts: ImReply = {};
|
|
136
|
-
if (indexes.quoted !== undefined) replyParts.parent = indexes.quoted;
|
|
137
|
-
if (quotedText !== undefined) replyParts.quoted_text = quotedText;
|
|
138
|
-
const sender = senderOf(d);
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
platform: PLATFORM,
|
|
142
|
-
source_id: messageId,
|
|
143
|
-
conversation,
|
|
144
|
-
kind: 'message',
|
|
145
|
-
...(sender === undefined ? {} : { from: sender }),
|
|
146
|
-
text,
|
|
147
|
-
attachments,
|
|
148
|
-
...(Object.keys(replyParts).length === 0 ? {} : { reply: replyParts }),
|
|
149
|
-
message_handle: messageId,
|
|
150
|
-
// The passive reply id and the quote index are two ids for one message; the bridge hands the
|
|
151
|
-
// composite back as `reply_to` and `send` splits it again.
|
|
152
|
-
ref_handle: indexes.self === undefined ? messageId : `${messageId}|${indexes.self}`,
|
|
153
|
-
at: textOf(d.timestamp) ?? new Date(now()).toISOString(),
|
|
154
|
-
raw: envelope as unknown as Json,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function interactionEvent(d: Raw, envelope: Raw, now: () => number): ImEvent | undefined {
|
|
159
|
-
const id = textOf(d.id);
|
|
160
|
-
const conversation = conversationOf(d);
|
|
161
|
-
if (id === undefined || conversation === undefined) return undefined;
|
|
162
|
-
const sender = senderOf(d);
|
|
163
|
-
return {
|
|
164
|
-
platform: PLATFORM,
|
|
165
|
-
source_id: id,
|
|
166
|
-
conversation,
|
|
167
|
-
kind: 'action',
|
|
168
|
-
...(sender === undefined ? {} : { from: sender }),
|
|
169
|
-
text: '',
|
|
170
|
-
attachments: [],
|
|
171
|
-
action: { id, ...(d.data === undefined ? {} : { value: d.data as Json }) },
|
|
172
|
-
at: textOf(d.timestamp) ?? new Date(now()).toISOString(),
|
|
173
|
-
raw: envelope as unknown as Json,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function lifecycleEvent(type: string, d: Raw, envelope: Raw, now: () => number): ImEvent | undefined {
|
|
178
|
-
const sourceId = textOf(envelope.id) ?? textOf(d.id);
|
|
179
|
-
const conversation = conversationOf(d);
|
|
180
|
-
if (sourceId === undefined || conversation === undefined) return undefined;
|
|
181
|
-
const sender = senderOf(d);
|
|
182
|
-
return {
|
|
183
|
-
platform: PLATFORM,
|
|
184
|
-
source_id: sourceId,
|
|
185
|
-
conversation,
|
|
186
|
-
kind: 'event',
|
|
187
|
-
...(sender === undefined ? {} : { from: sender }),
|
|
188
|
-
text: '',
|
|
189
|
-
attachments: [],
|
|
190
|
-
event: { type },
|
|
191
|
-
at: textOf(d.timestamp) ?? new Date(now()).toISOString(),
|
|
192
|
-
raw: envelope as unknown as Json,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** One `op 0` dispatch; `undefined` for an event this connector does not translate. */
|
|
197
|
-
export function normalizeEvent(payload: unknown, now: () => number = Date.now): ImEvent | undefined {
|
|
198
|
-
const envelope = recordOf(payload);
|
|
199
|
-
if (envelope === undefined || envelope.op !== 0) return undefined;
|
|
200
|
-
const type = textOf(envelope.t);
|
|
201
|
-
const d = recordOf(envelope.d);
|
|
202
|
-
if (type === undefined || d === undefined) return undefined;
|
|
203
|
-
if (MESSAGE_EVENTS[type] === true) return messageEvent(d, envelope, now);
|
|
204
|
-
if (type === 'INTERACTION_CREATE') return interactionEvent(d, envelope, now);
|
|
205
|
-
if (LIFECYCLE_EVENTS[type] === true) return lifecycleEvent(type, d, envelope, now);
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|