@lambdot/protocol-qq 0.1.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/CHANGELOG.md +8 -0
- package/package.json +16 -0
- package/src/api.ts +165 -0
- package/src/credentials.ts +40 -0
- package/src/events.ts +125 -0
- package/src/gateway.ts +165 -0
- package/src/index.ts +179 -0
- package/src/output.ts +31 -0
- package/src/webhook.ts +137 -0
- package/tsconfig.json +4 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/protocol-qq-v0.0.1...protocol-qq-v0.1.0) (2026-08-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **protocol-qq:** qq protocol with gateway and webhook infras ([d41936b](https://github.com/Embers-of-the-Fire/lambdot/commit/d41936be964e620c6da5641aecfe5f370c2e541c))
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lambdot/protocol-qq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@lambdot/core": "workspace:*",
|
|
13
|
+
"@lambdot/websocket": "workspace:*",
|
|
14
|
+
"tweetnacl": "^1.0.3"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Disposer, FeaturePlugin } from "@lambdot/core";
|
|
2
|
+
|
|
3
|
+
import { readQqCredentials, type QqCredentialKeys, type QqEnvNeeds } from "./credentials.ts";
|
|
4
|
+
import type { QqAddress } from "./events.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The REST half of the qq protocol, provided as a typed capability. Both
|
|
8
|
+
* receiving transports (gateway, webhook) deliver events only; sending a
|
|
9
|
+
* message is always an HTTPS call against the open platform. Owns the
|
|
10
|
+
* access-token lifecycle: tokens are cached and refreshed ahead of expiry
|
|
11
|
+
* (the platform hands out ~7200s tokens and keeps the old one valid for a
|
|
12
|
+
* 60s overlap).
|
|
13
|
+
*/
|
|
14
|
+
export interface QqApi {
|
|
15
|
+
readonly appId: string;
|
|
16
|
+
/** A valid access token; refreshed automatically ahead of expiry. */
|
|
17
|
+
accessToken(): Promise<string>;
|
|
18
|
+
/** The websocket gateway URL (`GET /gateway`). */
|
|
19
|
+
gatewayUrl(): Promise<string>;
|
|
20
|
+
/**
|
|
21
|
+
* Send a plain-text message (`msg_type` 0). Passive reply when the
|
|
22
|
+
* address carries a `msgId`: `msg_seq` is taken from the address, or
|
|
23
|
+
* auto-incremented per `msgId` when omitted (the platform rejects a
|
|
24
|
+
* repeated `msg_id` + `msg_seq` pair).
|
|
25
|
+
*/
|
|
26
|
+
sendMessage(to: QqAddress, content: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type QqCapability<TCap extends string> = { readonly [K in TCap]: QqApi };
|
|
30
|
+
|
|
31
|
+
export interface QqApiConfig {
|
|
32
|
+
/** Open-platform base URL; override to point at a mock in tests. */
|
|
33
|
+
readonly apiBase?: string;
|
|
34
|
+
/** Which env variables carry the credentials. */
|
|
35
|
+
readonly keys?: QqCredentialKeys;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_API_BASE = "https://api.bot.qq.com";
|
|
39
|
+
/** Refresh a token once it is within this margin of its expiry. */
|
|
40
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Provide the qq REST client as a typed capability, reading credentials from
|
|
44
|
+
* the env capability (see `@lambdot/env`). Register the env provider first:
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* createKernel()
|
|
48
|
+
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
49
|
+
* .use(qqApi("qq-api", "qq-env"));
|
|
50
|
+
* // ctx["qq-api"]: QqApi
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function qqApi<TCap extends string, TEnvCap extends string>(
|
|
54
|
+
capability: TCap,
|
|
55
|
+
env: TEnvCap,
|
|
56
|
+
): FeaturePlugin<
|
|
57
|
+
{},
|
|
58
|
+
{},
|
|
59
|
+
undefined,
|
|
60
|
+
QqApiConfig,
|
|
61
|
+
`qq-api:${TCap}`,
|
|
62
|
+
QqCapability<TCap>,
|
|
63
|
+
QqEnvNeeds<TEnvCap>
|
|
64
|
+
> {
|
|
65
|
+
return {
|
|
66
|
+
name: `qq-api:${capability}`,
|
|
67
|
+
inject: [env],
|
|
68
|
+
apply(ctx, config) {
|
|
69
|
+
const credentials = readQqCredentials(ctx[env], config.keys);
|
|
70
|
+
const apiBase = config.apiBase ?? DEFAULT_API_BASE;
|
|
71
|
+
|
|
72
|
+
let cached: { token: string; expiresAt: number } | undefined;
|
|
73
|
+
let pending: Promise<string> | undefined;
|
|
74
|
+
const accessToken = (): Promise<string> => {
|
|
75
|
+
if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached.token);
|
|
76
|
+
pending ??= fetch(`${apiBase}/app/getAppAccessToken`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json" },
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
appId: credentials.appId,
|
|
81
|
+
clientSecret: credentials.clientSecret,
|
|
82
|
+
}),
|
|
83
|
+
})
|
|
84
|
+
.then(async (res) => {
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error(
|
|
87
|
+
`qq access token request failed: ${res.status} ${await res.text()}`,
|
|
88
|
+
);
|
|
89
|
+
const body = (await res.json()) as {
|
|
90
|
+
access_token?: unknown;
|
|
91
|
+
expires_in?: unknown;
|
|
92
|
+
};
|
|
93
|
+
if (typeof body.access_token !== "string")
|
|
94
|
+
throw new Error("qq access token response is missing access_token");
|
|
95
|
+
return {
|
|
96
|
+
token: body.access_token,
|
|
97
|
+
expiresAt:
|
|
98
|
+
Date.now() +
|
|
99
|
+
// The docs' examples show expires_in as a string.
|
|
100
|
+
Number(body.expires_in ?? 7200) * 1000 -
|
|
101
|
+
EXPIRY_MARGIN_MS,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.then(
|
|
105
|
+
(next) => {
|
|
106
|
+
cached = next;
|
|
107
|
+
pending = undefined;
|
|
108
|
+
return next.token;
|
|
109
|
+
},
|
|
110
|
+
(error: unknown) => {
|
|
111
|
+
pending = undefined;
|
|
112
|
+
throw error;
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
return pending;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const authed = async (path: string, init?: RequestInit): Promise<Response> => {
|
|
119
|
+
const headers = new Headers(init?.headers);
|
|
120
|
+
headers.set("content-type", "application/json");
|
|
121
|
+
headers.set("authorization", `QQBot ${await accessToken()}`);
|
|
122
|
+
const res = await fetch(`${apiBase}${path}`, { ...init, headers });
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
throw new Error(`qq api ${path} failed: ${res.status} ${await res.text()}`);
|
|
125
|
+
return res;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Passive replies to one msg_id must increment msg_seq.
|
|
129
|
+
const msgSeqs = new Map<string, number>();
|
|
130
|
+
|
|
131
|
+
const api: QqApi = {
|
|
132
|
+
appId: credentials.appId,
|
|
133
|
+
accessToken,
|
|
134
|
+
async gatewayUrl() {
|
|
135
|
+
const res = await authed("/gateway");
|
|
136
|
+
const body = (await res.json()) as { url?: unknown };
|
|
137
|
+
if (typeof body.url !== "string")
|
|
138
|
+
throw new Error("qq gateway response is missing url");
|
|
139
|
+
return body.url;
|
|
140
|
+
},
|
|
141
|
+
async sendMessage(to, content) {
|
|
142
|
+
const path =
|
|
143
|
+
to.scope === "group"
|
|
144
|
+
? `/v2/groups/${to.openid}/messages`
|
|
145
|
+
: `/v2/users/${to.openid}/messages`;
|
|
146
|
+
const body: Record<string, unknown> = { msg_type: 0, content };
|
|
147
|
+
if (to.msgId !== undefined) {
|
|
148
|
+
body.msg_id = to.msgId;
|
|
149
|
+
const seq = (msgSeqs.get(to.msgId) ?? 0) + 1;
|
|
150
|
+
msgSeqs.set(to.msgId, seq);
|
|
151
|
+
body.msg_seq = to.msgSeq ?? seq;
|
|
152
|
+
}
|
|
153
|
+
await authed(path, { method: "POST", body: JSON.stringify(body) });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// See `wsTransport` in @lambdot/websocket for why `provide` is pinned here.
|
|
158
|
+
return (ctx.provide as (name: TCap, value: QqApi) => Disposer).call(
|
|
159
|
+
ctx,
|
|
160
|
+
capability,
|
|
161
|
+
api,
|
|
162
|
+
);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The env-capability view the qq plugins declare as `TInjects`: any
|
|
3
|
+
* string-keyed snapshot of variables. `@lambdot/env`'s
|
|
4
|
+
* `EnvCapability<TCap, TKey>` narrows the keys but stays assignable to this,
|
|
5
|
+
* so the fold's capability-type check accepts it.
|
|
6
|
+
*/
|
|
7
|
+
export type QqEnvNeeds<TEnvCap extends string> = {
|
|
8
|
+
readonly [K in TEnvCap]: Readonly<Record<string, string>>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** The bot credentials issued by the QQ open platform. */
|
|
12
|
+
export interface QqCredentials {
|
|
13
|
+
readonly appId: string;
|
|
14
|
+
readonly clientSecret: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which env variables carry the credentials (overridable per plugin config). */
|
|
18
|
+
export interface QqCredentialKeys {
|
|
19
|
+
readonly appId: string;
|
|
20
|
+
readonly clientSecret: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_QQ_CREDENTIAL_KEYS: QqCredentialKeys = {
|
|
24
|
+
appId: "QQ_BOT_APP_ID",
|
|
25
|
+
clientSecret: "QQ_BOT_APP_SECRET",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Read the credentials out of an env snapshot, failing loudly when absent. */
|
|
29
|
+
export function readQqCredentials(
|
|
30
|
+
env: Readonly<Record<string, string>>,
|
|
31
|
+
keys: QqCredentialKeys = DEFAULT_QQ_CREDENTIAL_KEYS,
|
|
32
|
+
): QqCredentials {
|
|
33
|
+
const appId = env[keys.appId];
|
|
34
|
+
const clientSecret = env[keys.clientSecret];
|
|
35
|
+
if (!appId || !clientSecret)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`qq credentials missing: "${keys.appId}" and "${keys.clientSecret}" must both be set`,
|
|
38
|
+
);
|
|
39
|
+
return { appId, clientSecret };
|
|
40
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Address, EventDef, OutputContract } from "@lambdot/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a qq message goes. Owned by the output half of the qq platform —
|
|
5
|
+
* outputs consume addresses, inputs produce them. `msgId`/`msgSeq` carry the
|
|
6
|
+
* passive-reply reference (`msg_id`/`msg_seq`) from the triggering message;
|
|
7
|
+
* omit both for an active (proactive) message.
|
|
8
|
+
*/
|
|
9
|
+
export interface QqAddress extends Address<"qq"> {
|
|
10
|
+
/** Which conversation the message belongs to. */
|
|
11
|
+
readonly scope: "group" | "c2c";
|
|
12
|
+
/** `group_openid` for groups, the user's openid for C2C. */
|
|
13
|
+
readonly openid: string;
|
|
14
|
+
/** Passive-reply reference, from the triggering event's `d.id`. */
|
|
15
|
+
readonly msgId?: string;
|
|
16
|
+
/** Passive-reply sequence; the output auto-increments it per reply. */
|
|
17
|
+
readonly msgSeq?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The payload produced by qq message events: plain-text content only. */
|
|
21
|
+
export interface QqMessage {
|
|
22
|
+
/** Message id (`msg_id`), reused as the passive-reply reference. */
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly content: string;
|
|
25
|
+
readonly authorOpenid: string;
|
|
26
|
+
readonly timestamp: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Events produced by qq inputs (gateway and webhook alike): one kind per
|
|
31
|
+
* conversation scope. A type alias (not an interface extending `EventMap`)
|
|
32
|
+
* so `keyof` stays exactly these kinds.
|
|
33
|
+
*/
|
|
34
|
+
export type QqEvents = {
|
|
35
|
+
"qq.group-message": EventDef<QqMessage, QqAddress>;
|
|
36
|
+
"qq.c2c-message": EventDef<QqMessage, QqAddress>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The qq platform's output contract: plain text (msg_type 0). */
|
|
40
|
+
export type QqOutputs = {
|
|
41
|
+
qq: OutputContract<QqAddress, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The wire shape of a `GROUP_AT_MESSAGE_CREATE` dispatch body. */
|
|
45
|
+
interface QqRawGroupMessage {
|
|
46
|
+
id?: unknown;
|
|
47
|
+
content?: unknown;
|
|
48
|
+
group_openid?: unknown;
|
|
49
|
+
timestamp?: unknown;
|
|
50
|
+
author?: { member_openid?: unknown };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The wire shape of a `C2C_MESSAGE_CREATE` dispatch body. */
|
|
54
|
+
interface QqRawC2cMessage {
|
|
55
|
+
id?: unknown;
|
|
56
|
+
content?: unknown;
|
|
57
|
+
timestamp?: unknown;
|
|
58
|
+
author?: { user_openid?: unknown };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decode a dispatch (`t`, `d`) pair into an ingestible event, or null to
|
|
63
|
+
* ignore it. Shared by the gateway and webhook inputs — both transports
|
|
64
|
+
* deliver the same `{op, t, d}` envelope.
|
|
65
|
+
*/
|
|
66
|
+
export function decodeMessageEvent(
|
|
67
|
+
t: string,
|
|
68
|
+
d: unknown,
|
|
69
|
+
): { kind: keyof QqEvents; payload: QqMessage; address: QqAddress } | null {
|
|
70
|
+
if (typeof d !== "object" || d === null) return null;
|
|
71
|
+
|
|
72
|
+
if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
73
|
+
const raw = d as QqRawGroupMessage;
|
|
74
|
+
if (
|
|
75
|
+
typeof raw.id !== "string" ||
|
|
76
|
+
typeof raw.content !== "string" ||
|
|
77
|
+
typeof raw.group_openid !== "string" ||
|
|
78
|
+
typeof raw.author?.member_openid !== "string"
|
|
79
|
+
)
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
kind: "qq.group-message",
|
|
83
|
+
payload: {
|
|
84
|
+
id: raw.id,
|
|
85
|
+
// The platform strips the @bot prefix but leaves padding.
|
|
86
|
+
content: raw.content.trim(),
|
|
87
|
+
authorOpenid: raw.author.member_openid,
|
|
88
|
+
timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
|
|
89
|
+
},
|
|
90
|
+
address: {
|
|
91
|
+
platform: "qq",
|
|
92
|
+
scope: "group",
|
|
93
|
+
openid: raw.group_openid,
|
|
94
|
+
msgId: raw.id,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (t === "C2C_MESSAGE_CREATE") {
|
|
100
|
+
const raw = d as QqRawC2cMessage;
|
|
101
|
+
if (
|
|
102
|
+
typeof raw.id !== "string" ||
|
|
103
|
+
typeof raw.content !== "string" ||
|
|
104
|
+
typeof raw.author?.user_openid !== "string"
|
|
105
|
+
)
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
kind: "qq.c2c-message",
|
|
109
|
+
payload: {
|
|
110
|
+
id: raw.id,
|
|
111
|
+
content: raw.content.trim(),
|
|
112
|
+
authorOpenid: raw.author.user_openid,
|
|
113
|
+
timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "",
|
|
114
|
+
},
|
|
115
|
+
address: {
|
|
116
|
+
platform: "qq",
|
|
117
|
+
scope: "c2c",
|
|
118
|
+
openid: raw.author.user_openid,
|
|
119
|
+
msgId: raw.id,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Disposer, FeaturePlugin, InputPlugin } from "@lambdot/core";
|
|
2
|
+
import type { WsCapability, WsConnection } from "@lambdot/websocket";
|
|
3
|
+
|
|
4
|
+
import type { QqCapability } from "./api.ts";
|
|
5
|
+
import { decodeMessageEvent, type QqEvents } from "./events.ts";
|
|
6
|
+
|
|
7
|
+
/** `GROUP_AND_C2C_EVENT` (1 << 25): group at-messages and C2C messages. */
|
|
8
|
+
export const QQ_INTENT_GROUP_AND_C2C = 1 << 25;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The qq-specific half of the transport: unlike the generic `wsTransport`,
|
|
12
|
+
* the gateway URL is not static configuration — it is discovered through the
|
|
13
|
+
* REST client (`GET /gateway` with the access token). Resolves the URL at
|
|
14
|
+
* activation, owns the socket, and provides the connection as a typed
|
|
15
|
+
* `WsCapability` so the input can ride it. Register after the api:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* .use(qqApi("qq-api", "qq-env"))
|
|
19
|
+
* .use(qqGatewayTransport("qq-ws", "qq-api"))
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function qqGatewayTransport<TWsCap extends string, TApiCap extends string>(
|
|
23
|
+
capability: TWsCap,
|
|
24
|
+
api: TApiCap,
|
|
25
|
+
): FeaturePlugin<
|
|
26
|
+
{},
|
|
27
|
+
{},
|
|
28
|
+
undefined,
|
|
29
|
+
void,
|
|
30
|
+
`qq-gateway-transport:${TWsCap}`,
|
|
31
|
+
WsCapability<TWsCap>,
|
|
32
|
+
QqCapability<TApiCap>
|
|
33
|
+
> {
|
|
34
|
+
return {
|
|
35
|
+
name: `qq-gateway-transport:${capability}`,
|
|
36
|
+
inject: [api],
|
|
37
|
+
async apply(ctx) {
|
|
38
|
+
const url = await ctx[api].gatewayUrl();
|
|
39
|
+
const socket = new WebSocket(url);
|
|
40
|
+
await new Promise<void>((resolve, reject) => {
|
|
41
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
42
|
+
socket.addEventListener(
|
|
43
|
+
"error",
|
|
44
|
+
() => reject(new Error("qq gateway failed to connect")),
|
|
45
|
+
{ once: true },
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const listeners = new Set<(data: string) => void>();
|
|
50
|
+
socket.addEventListener("message", (event) => {
|
|
51
|
+
if (typeof event.data !== "string") return;
|
|
52
|
+
for (const listener of listeners) listener(event.data);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const connection: WsConnection = {
|
|
56
|
+
url,
|
|
57
|
+
send: (data) => socket.send(data),
|
|
58
|
+
onMessage(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// See `wsTransport` in @lambdot/websocket for why `provide` is pinned here.
|
|
67
|
+
const unprovide = (ctx.provide as (name: TWsCap, value: WsConnection) => Disposer).call(
|
|
68
|
+
ctx,
|
|
69
|
+
capability,
|
|
70
|
+
connection,
|
|
71
|
+
);
|
|
72
|
+
return () => {
|
|
73
|
+
socket.close();
|
|
74
|
+
void unprovide();
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface QqGatewayInputConfig {
|
|
81
|
+
/** Event intents bitmask; defaults to `QQ_INTENT_GROUP_AND_C2C`. */
|
|
82
|
+
readonly intents?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The receiving half of the gateway infra: consumes the connection provided
|
|
87
|
+
* by {@link qqGatewayTransport} and runs the basic gateway algorithm —
|
|
88
|
+
* identify on hello (op 10), heartbeat on the advertised interval (op 1),
|
|
89
|
+
* decode dispatches (op 0) into message events. Resume (op 6) is deliberately
|
|
90
|
+
* not implemented: a dropped connection is a fresh identify.
|
|
91
|
+
*/
|
|
92
|
+
export function qqGatewayInput<TWsCap extends string, TApiCap extends string>(
|
|
93
|
+
ws: TWsCap,
|
|
94
|
+
api: TApiCap,
|
|
95
|
+
): InputPlugin<
|
|
96
|
+
QqEvents,
|
|
97
|
+
QqGatewayInputConfig,
|
|
98
|
+
"qq-gateway-input",
|
|
99
|
+
{},
|
|
100
|
+
WsCapability<TWsCap> & QqCapability<TApiCap>
|
|
101
|
+
> {
|
|
102
|
+
return {
|
|
103
|
+
role: "input",
|
|
104
|
+
name: "qq-gateway-input",
|
|
105
|
+
inject: [ws, api],
|
|
106
|
+
apply(ctx, config) {
|
|
107
|
+
const connection = ctx[ws];
|
|
108
|
+
const intents = config.intents ?? QQ_INTENT_GROUP_AND_C2C;
|
|
109
|
+
let lastSeq: number | null = null;
|
|
110
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
111
|
+
|
|
112
|
+
const unsubscribe = connection.onMessage((data) => {
|
|
113
|
+
let frame: { op?: unknown; d?: unknown; s?: unknown; t?: unknown };
|
|
114
|
+
try {
|
|
115
|
+
frame = JSON.parse(data) as typeof frame;
|
|
116
|
+
} catch {
|
|
117
|
+
return; // not JSON: not ours
|
|
118
|
+
}
|
|
119
|
+
if (typeof frame.s === "number") lastSeq = frame.s;
|
|
120
|
+
|
|
121
|
+
switch (frame.op) {
|
|
122
|
+
case 10: {
|
|
123
|
+
// Hello: identify, then heartbeat on the advertised interval.
|
|
124
|
+
const d = frame.d as { heartbeat_interval?: unknown } | null | undefined;
|
|
125
|
+
const interval =
|
|
126
|
+
typeof d?.heartbeat_interval === "number"
|
|
127
|
+
? d.heartbeat_interval
|
|
128
|
+
: 45_000;
|
|
129
|
+
void ctx[api].accessToken().then((token) => {
|
|
130
|
+
connection.send(
|
|
131
|
+
JSON.stringify({
|
|
132
|
+
op: 2,
|
|
133
|
+
d: {
|
|
134
|
+
token: `QQBot ${token}`,
|
|
135
|
+
intents,
|
|
136
|
+
shard: [0, 1],
|
|
137
|
+
properties: {},
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
heartbeat = setInterval(() => {
|
|
143
|
+
connection.send(JSON.stringify({ op: 1, d: lastSeq }));
|
|
144
|
+
}, interval);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 0: {
|
|
148
|
+
// Dispatch: message events become lambdot events.
|
|
149
|
+
if (typeof frame.t !== "string") break;
|
|
150
|
+
const decoded = decodeMessageEvent(frame.t, frame.d);
|
|
151
|
+
if (decoded)
|
|
152
|
+
void ctx.ingest(decoded.kind, decoded.payload, decoded.address);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
// 11 is a heartbeat ack; every other opcode needs no handling.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
161
|
+
void unsubscribe();
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { FeaturePlugin, InputPlugin, OutputPlugin } from "@lambdot/core";
|
|
2
|
+
import type { WsCapability } from "@lambdot/websocket";
|
|
3
|
+
|
|
4
|
+
import { qqApi, type QqApiConfig, type QqCapability } from "./api.ts";
|
|
5
|
+
import type { QqEnvNeeds } from "./credentials.ts";
|
|
6
|
+
import type { QqAddress, QqEvents } from "./events.ts";
|
|
7
|
+
import { qqGatewayInput, qqGatewayTransport, type QqGatewayInputConfig } from "./gateway.ts";
|
|
8
|
+
import { qqOutput } from "./output.ts";
|
|
9
|
+
import { qqWebhookInput, type QqWebhookCapability, type QqWebhookConfig } from "./webhook.ts";
|
|
10
|
+
|
|
11
|
+
export { qqApi, type QqApi, type QqApiConfig, type QqCapability } from "./api.ts";
|
|
12
|
+
export {
|
|
13
|
+
DEFAULT_QQ_CREDENTIAL_KEYS,
|
|
14
|
+
readQqCredentials,
|
|
15
|
+
type QqCredentialKeys,
|
|
16
|
+
type QqCredentials,
|
|
17
|
+
type QqEnvNeeds,
|
|
18
|
+
} from "./credentials.ts";
|
|
19
|
+
export {
|
|
20
|
+
decodeMessageEvent,
|
|
21
|
+
type QqAddress,
|
|
22
|
+
type QqEvents,
|
|
23
|
+
type QqMessage,
|
|
24
|
+
type QqOutputs,
|
|
25
|
+
} from "./events.ts";
|
|
26
|
+
export {
|
|
27
|
+
QQ_INTENT_GROUP_AND_C2C,
|
|
28
|
+
qqGatewayInput,
|
|
29
|
+
qqGatewayTransport,
|
|
30
|
+
type QqGatewayInputConfig,
|
|
31
|
+
} from "./gateway.ts";
|
|
32
|
+
export { qqOutput } from "./output.ts";
|
|
33
|
+
export {
|
|
34
|
+
qqWebhookInput,
|
|
35
|
+
type QqWebhook,
|
|
36
|
+
type QqWebhookCapability,
|
|
37
|
+
type QqWebhookConfig,
|
|
38
|
+
} from "./webhook.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One qq platform over the websocket gateway, bundled: the REST client, the
|
|
42
|
+
* gateway transport that discovers the socket URL through it, the receiving
|
|
43
|
+
* input, and the shared output. The pieces stay separate (rather than one
|
|
44
|
+
* fused plugin) so the type fold can keep enforcing registration order:
|
|
45
|
+
* env provider → api → transport → input → output → features.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const qq = qqGatewayPlatform({ ws: "qq-ws", api: "qq-api", env: "qq-env" });
|
|
49
|
+
* createKernel()
|
|
50
|
+
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
51
|
+
* .use(qq.api, {})
|
|
52
|
+
* .use(qq.transport)
|
|
53
|
+
* .use(qq.input, {})
|
|
54
|
+
* .use(qq.output);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export interface QqGatewayPlatform<
|
|
58
|
+
TWsCap extends string,
|
|
59
|
+
TApiCap extends string,
|
|
60
|
+
TEnvCap extends string,
|
|
61
|
+
> {
|
|
62
|
+
readonly api: FeaturePlugin<
|
|
63
|
+
{},
|
|
64
|
+
{},
|
|
65
|
+
undefined,
|
|
66
|
+
QqApiConfig,
|
|
67
|
+
`qq-api:${TApiCap}`,
|
|
68
|
+
QqCapability<TApiCap>,
|
|
69
|
+
QqEnvNeeds<TEnvCap>
|
|
70
|
+
>;
|
|
71
|
+
readonly transport: FeaturePlugin<
|
|
72
|
+
{},
|
|
73
|
+
{},
|
|
74
|
+
undefined,
|
|
75
|
+
void,
|
|
76
|
+
`qq-gateway-transport:${TWsCap}`,
|
|
77
|
+
WsCapability<TWsCap>,
|
|
78
|
+
QqCapability<TApiCap>
|
|
79
|
+
>;
|
|
80
|
+
readonly input: InputPlugin<
|
|
81
|
+
QqEvents,
|
|
82
|
+
QqGatewayInputConfig,
|
|
83
|
+
"qq-gateway-input",
|
|
84
|
+
{},
|
|
85
|
+
WsCapability<TWsCap> & QqCapability<TApiCap>
|
|
86
|
+
>;
|
|
87
|
+
readonly output: OutputPlugin<
|
|
88
|
+
"qq",
|
|
89
|
+
QqAddress,
|
|
90
|
+
string,
|
|
91
|
+
void,
|
|
92
|
+
"qq-output",
|
|
93
|
+
{},
|
|
94
|
+
QqCapability<TApiCap>
|
|
95
|
+
>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build a whole gateway-backed qq platform from its capability names. */
|
|
99
|
+
export function qqGatewayPlatform<
|
|
100
|
+
TWsCap extends string,
|
|
101
|
+
TApiCap extends string,
|
|
102
|
+
TEnvCap extends string,
|
|
103
|
+
>(capabilities: {
|
|
104
|
+
readonly ws: TWsCap;
|
|
105
|
+
readonly api: TApiCap;
|
|
106
|
+
readonly env: TEnvCap;
|
|
107
|
+
}): QqGatewayPlatform<TWsCap, TApiCap, TEnvCap> {
|
|
108
|
+
return {
|
|
109
|
+
api: qqApi(capabilities.api, capabilities.env),
|
|
110
|
+
transport: qqGatewayTransport(capabilities.ws, capabilities.api),
|
|
111
|
+
input: qqGatewayInput(capabilities.ws, capabilities.api),
|
|
112
|
+
output: qqOutput(capabilities.api),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One qq platform over the webhook (reversed-post) infra, bundled: the
|
|
118
|
+
* webhook input that provides the callback-handler capability, the REST
|
|
119
|
+
* client, and the output. Registration order: env provider → webhook → api →
|
|
120
|
+
* output → features.
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* const qq = qqWebhookPlatform({ webhook: "qq-webhook", api: "qq-api", env: "qq-env" });
|
|
124
|
+
* createKernel()
|
|
125
|
+
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
126
|
+
* .use(qq.webhook, {})
|
|
127
|
+
* .use(qq.api, {})
|
|
128
|
+
* .use(qq.output);
|
|
129
|
+
* // in the hono route: return kernel.ctx["qq-webhook"].handle(c.req.raw);
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export interface QqWebhookPlatform<
|
|
133
|
+
TCap extends string,
|
|
134
|
+
TApiCap extends string,
|
|
135
|
+
TEnvCap extends string,
|
|
136
|
+
> {
|
|
137
|
+
readonly webhook: InputPlugin<
|
|
138
|
+
QqEvents,
|
|
139
|
+
QqWebhookConfig,
|
|
140
|
+
`qq-webhook:${TCap}`,
|
|
141
|
+
QqWebhookCapability<TCap>,
|
|
142
|
+
QqEnvNeeds<TEnvCap>
|
|
143
|
+
>;
|
|
144
|
+
readonly api: FeaturePlugin<
|
|
145
|
+
{},
|
|
146
|
+
{},
|
|
147
|
+
undefined,
|
|
148
|
+
QqApiConfig,
|
|
149
|
+
`qq-api:${TApiCap}`,
|
|
150
|
+
QqCapability<TApiCap>,
|
|
151
|
+
QqEnvNeeds<TEnvCap>
|
|
152
|
+
>;
|
|
153
|
+
readonly output: OutputPlugin<
|
|
154
|
+
"qq",
|
|
155
|
+
QqAddress,
|
|
156
|
+
string,
|
|
157
|
+
void,
|
|
158
|
+
"qq-output",
|
|
159
|
+
{},
|
|
160
|
+
QqCapability<TApiCap>
|
|
161
|
+
>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Build a whole webhook-backed qq platform from its capability names. */
|
|
165
|
+
export function qqWebhookPlatform<
|
|
166
|
+
TCap extends string,
|
|
167
|
+
TApiCap extends string,
|
|
168
|
+
TEnvCap extends string,
|
|
169
|
+
>(capabilities: {
|
|
170
|
+
readonly webhook: TCap;
|
|
171
|
+
readonly api: TApiCap;
|
|
172
|
+
readonly env: TEnvCap;
|
|
173
|
+
}): QqWebhookPlatform<TCap, TApiCap, TEnvCap> {
|
|
174
|
+
return {
|
|
175
|
+
webhook: qqWebhookInput(capabilities.webhook, capabilities.env),
|
|
176
|
+
api: qqApi(capabilities.api, capabilities.env),
|
|
177
|
+
output: qqOutput(capabilities.api),
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { OutputPlugin } from "@lambdot/core";
|
|
2
|
+
|
|
3
|
+
import type { QqApi, QqCapability } from "./api.ts";
|
|
4
|
+
import type { QqAddress } from "./events.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The output half of the qq platform: plain text (msg_type 0) through the
|
|
8
|
+
* REST client provided by `qqApi`. Sending is transport-independent — the
|
|
9
|
+
* gateway and webhook infras share this output.
|
|
10
|
+
*/
|
|
11
|
+
export function qqOutput<TApiCap extends string>(
|
|
12
|
+
api: TApiCap,
|
|
13
|
+
): OutputPlugin<"qq", QqAddress, string, void, "qq-output", {}, QqCapability<TApiCap>> {
|
|
14
|
+
let client: QqApi | undefined;
|
|
15
|
+
return {
|
|
16
|
+
role: "output",
|
|
17
|
+
name: "qq-output",
|
|
18
|
+
platform: "qq",
|
|
19
|
+
inject: [api],
|
|
20
|
+
async send(to, content) {
|
|
21
|
+
if (!client) throw new Error('output "qq" is not active');
|
|
22
|
+
await client.sendMessage(to, content);
|
|
23
|
+
},
|
|
24
|
+
apply(ctx) {
|
|
25
|
+
client = ctx[api];
|
|
26
|
+
return () => {
|
|
27
|
+
client = undefined;
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Disposer, InputPlugin } from "@lambdot/core";
|
|
2
|
+
import nacl from "tweetnacl";
|
|
3
|
+
|
|
4
|
+
import { readQqCredentials, type QqCredentialKeys, type QqEnvNeeds } from "./credentials.ts";
|
|
5
|
+
import { decodeMessageEvent, type QqEvents } from "./events.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The request bridge, provided as a typed capability: the HTTP route lives
|
|
9
|
+
* outside the event pipeline (a hono handler, a worker's fetch), so it hands
|
|
10
|
+
* each callback request to `handle` and sends back the returned response —
|
|
11
|
+
* the same bridge pattern as the cloudflare example's `PingService`.
|
|
12
|
+
*/
|
|
13
|
+
export interface QqWebhook {
|
|
14
|
+
handle(request: Request): Promise<Response>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type QqWebhookCapability<TCap extends string> = { readonly [K in TCap]: QqWebhook };
|
|
18
|
+
|
|
19
|
+
export interface QqWebhookConfig {
|
|
20
|
+
/** Which env variables carry the credentials. */
|
|
21
|
+
readonly keys?: QqCredentialKeys;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The webhook (reversed-post) input: QQ pushes events to an HTTPS callback
|
|
26
|
+
* address. Registers the message event kinds and provides a {@link QqWebhook}
|
|
27
|
+
* capability that implements the callback algorithm — op 13 address
|
|
28
|
+
* validation (sign `event_ts + plain_token`), ed25519 verification of
|
|
29
|
+
* `X-Signature-Ed25519` over `timestamp + body` for everything else — then
|
|
30
|
+
* ingests message dispatches. The bot secret seeds the ed25519 keypair
|
|
31
|
+
* (repeated to 32 bytes).
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* .use(qqWebhookInput("qq-webhook", "qq-env"), {});
|
|
35
|
+
* // in the hono route: return kernel.ctx["qq-webhook"].handle(c.req.raw);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function qqWebhookInput<TCap extends string, TEnvCap extends string>(
|
|
39
|
+
capability: TCap,
|
|
40
|
+
env: TEnvCap,
|
|
41
|
+
): InputPlugin<
|
|
42
|
+
QqEvents,
|
|
43
|
+
QqWebhookConfig,
|
|
44
|
+
`qq-webhook:${TCap}`,
|
|
45
|
+
QqWebhookCapability<TCap>,
|
|
46
|
+
QqEnvNeeds<TEnvCap>
|
|
47
|
+
> {
|
|
48
|
+
return {
|
|
49
|
+
role: "input",
|
|
50
|
+
name: `qq-webhook:${capability}`,
|
|
51
|
+
inject: [env],
|
|
52
|
+
apply(ctx, config) {
|
|
53
|
+
const credentials = readQqCredentials(ctx[env], config.keys);
|
|
54
|
+
// The bot secret seeds the ed25519 keypair: repeat to 32 bytes.
|
|
55
|
+
let seed = credentials.clientSecret;
|
|
56
|
+
while (seed.length < 32) seed += seed;
|
|
57
|
+
const keyPair = nacl.sign.keyPair.fromSeed(new TextEncoder().encode(seed.slice(0, 32)));
|
|
58
|
+
|
|
59
|
+
const webhook: QqWebhook = {
|
|
60
|
+
async handle(request) {
|
|
61
|
+
if (request.method !== "POST")
|
|
62
|
+
return new Response("method not allowed", { status: 405 });
|
|
63
|
+
const body = await request.text();
|
|
64
|
+
let frame: { op?: unknown; d?: unknown; t?: unknown };
|
|
65
|
+
try {
|
|
66
|
+
frame = JSON.parse(body) as typeof frame;
|
|
67
|
+
} catch {
|
|
68
|
+
return new Response("bad request", { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// op 13: callback-address validation. Sign, no verify.
|
|
72
|
+
if (frame.op === 13) {
|
|
73
|
+
const d = frame.d as
|
|
74
|
+
| { plain_token?: unknown; event_ts?: unknown }
|
|
75
|
+
| null
|
|
76
|
+
| undefined;
|
|
77
|
+
if (typeof d?.plain_token !== "string" || typeof d.event_ts !== "string")
|
|
78
|
+
return new Response("bad request", { status: 400 });
|
|
79
|
+
const signature = toHex(
|
|
80
|
+
nacl.sign.detached(
|
|
81
|
+
new TextEncoder().encode(d.event_ts + d.plain_token),
|
|
82
|
+
keyPair.secretKey,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
return Response.json({ plain_token: d.plain_token, signature });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Everything else: verify the ed25519 signature over
|
|
89
|
+
// timestamp + body before trusting the payload.
|
|
90
|
+
const signature = request.headers.get("x-signature-ed25519");
|
|
91
|
+
const timestamp = request.headers.get("x-signature-timestamp");
|
|
92
|
+
if (
|
|
93
|
+
signature === null ||
|
|
94
|
+
timestamp === null ||
|
|
95
|
+
!verify(keyPair.publicKey, timestamp + body, signature)
|
|
96
|
+
)
|
|
97
|
+
return new Response("unauthorized", { status: 401 });
|
|
98
|
+
|
|
99
|
+
if (frame.op === 0 && typeof frame.t === "string") {
|
|
100
|
+
const decoded = decodeMessageEvent(frame.t, frame.d);
|
|
101
|
+
if (decoded)
|
|
102
|
+
await ctx.ingest(decoded.kind, decoded.payload, decoded.address);
|
|
103
|
+
}
|
|
104
|
+
return Response.json({});
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// See `wsTransport` in @lambdot/websocket for why `provide` is pinned here.
|
|
109
|
+
return (ctx.provide as (name: TCap, value: QqWebhook) => Disposer).call(
|
|
110
|
+
ctx,
|
|
111
|
+
capability,
|
|
112
|
+
webhook,
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function verify(publicKey: Uint8Array, message: string, signatureHex: string): boolean {
|
|
119
|
+
const signature = fromHex(signatureHex);
|
|
120
|
+
return (
|
|
121
|
+
signature !== null &&
|
|
122
|
+
signature.length === nacl.sign.signatureLength &&
|
|
123
|
+
nacl.sign.detached.verify(new TextEncoder().encode(message), signature, publicKey)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toHex(bytes: Uint8Array): string {
|
|
128
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fromHex(hex: string): Uint8Array | null {
|
|
132
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) return null;
|
|
133
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
134
|
+
for (let i = 0; i < bytes.length; i++)
|
|
135
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
136
|
+
return bytes;
|
|
137
|
+
}
|
package/tsconfig.json
ADDED