@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 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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }