@lambdot/protocol-qq 0.1.0 → 0.1.1
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 +9 -0
- package/README.md +166 -0
- package/package.json +20 -15
- package/src/api.ts +21 -40
- package/src/credentials.ts +0 -10
- package/src/events.ts +12 -20
- package/src/gateway.ts +46 -76
- package/src/index.ts +76 -121
- package/src/output.ts +23 -25
- package/src/webhook.ts +34 -42
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#10](https://github.com/Embers-of-the-Fire/lambdot/pull/10) [`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Switch inter-package dependency pins from exact versions to `workspace:*` so workspace members always resolve against local sources during development; pnpm rewrites the protocol to exact versions at pack/publish time.
|
|
8
|
+
- Updated dependencies [[`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355)]:
|
|
9
|
+
- @lambdot/core@0.1.1
|
|
10
|
+
- @lambdot/websocket@0.1.1
|
|
11
|
+
|
|
3
12
|
## [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
13
|
|
|
5
14
|
|
package/README.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# @lambdot/protocol-qq
|
|
2
|
+
|
|
3
|
+
The QQ wire protocol for lambdot: address and stream contracts, frame
|
|
4
|
+
codec, and plugin factories for both QQ bot infrastructures — the websocket
|
|
5
|
+
gateway and the webhook (reversed post) — sharing one REST client. Both
|
|
6
|
+
transports deliver messages only; sending a message is always an HTTPS call
|
|
7
|
+
against the open platform, so the output half is transport-independent.
|
|
8
|
+
|
|
9
|
+
## Two infras, one platform tag
|
|
10
|
+
|
|
11
|
+
Both infras emit the same stream contract (`QqMessageStream` —
|
|
12
|
+
`Stream<Message<QqMessage, QqAddress>>`) and share the output
|
|
13
|
+
(`QqCommandStream` — `Stream<Command<QqAddress, string>>`), so a feature
|
|
14
|
+
plugin written against those two types runs unchanged on either infra: the
|
|
15
|
+
wiring `mapping` is the platform adapter. What differs is how messages
|
|
16
|
+
arrive:
|
|
17
|
+
|
|
18
|
+
- **Gateway** — the bot holds a websocket to QQ. The socket URL is not
|
|
19
|
+
static configuration: it is discovered through the REST client
|
|
20
|
+
(`GET /gateway` with the access token), so `qqGatewayTransport` consumes
|
|
21
|
+
`{ api: QqApi }`, resolves the URL at activation, owns the socket, and
|
|
22
|
+
emits the `WsConnection` (from `@lambdot/websocket`) as its namespace
|
|
23
|
+
value. `qqGatewayInput` then runs the basic gateway algorithm on it —
|
|
24
|
+
identify on hello (op 10), heartbeat on the advertised interval (op 1),
|
|
25
|
+
decode dispatches (op 0) into the emitted message stream. Resume (op 6)
|
|
26
|
+
is deliberately not implemented: a dropped connection is a fresh
|
|
27
|
+
identify.
|
|
28
|
+
- **Webhook** — QQ pushes events to an HTTPS callback address owned by your
|
|
29
|
+
HTTP surface (a hono route, a worker's fetch). `qqWebhook` implements the
|
|
30
|
+
callback algorithm and emits it as a `QqWebhook` — `{ handle, messages }`:
|
|
31
|
+
op 13 callback-address validation (sign `event_ts + plain_token`), and
|
|
32
|
+
ed25519 verification of `X-Signature-Ed25519` over `timestamp + body` for
|
|
33
|
+
every other request. Decoded message dispatches join the `messages`
|
|
34
|
+
stream. The bot secret seeds the ed25519 keypair (repeated to 32 bytes);
|
|
35
|
+
a forged signature gets a 401.
|
|
36
|
+
|
|
37
|
+
Both infras read credentials from an env namespace (see `@lambdot/env`) via
|
|
38
|
+
`readQqCredentials` — `QQ_BOT_APP_ID` and `QQ_BOT_APP_SECRET` by default,
|
|
39
|
+
overridable through `QqCredentialKeys` in each plugin's config.
|
|
40
|
+
|
|
41
|
+
## Assembling a platform
|
|
42
|
+
|
|
43
|
+
Prefer the bundles: `qqGatewayPlatform` / `qqWebhookPlatform` build a whole
|
|
44
|
+
platform under one name. The pieces stay separate plugins (rather than one
|
|
45
|
+
fused plugin) so features compose between the input (whose message stream
|
|
46
|
+
is `use`d under the platform name) and the terminal output, with the api
|
|
47
|
+
and transport `bind`ed as internal wiring. Wiring a consumer before its
|
|
48
|
+
dependency is a compile error — the `mapping` parameter is typed as the
|
|
49
|
+
namespaces visible so far.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { createKernel, definePlugin, mapStream } from "@lambdot/core";
|
|
53
|
+
import { envVars } from "@lambdot/env";
|
|
54
|
+
import type { QqMessageStream } from "@lambdot/protocol-qq";
|
|
55
|
+
import { qqGatewayPlatform } from "@lambdot/protocol-qq";
|
|
56
|
+
|
|
57
|
+
const reply = definePlugin({
|
|
58
|
+
name: "reply",
|
|
59
|
+
apply(input: { messages: QqMessageStream }) {
|
|
60
|
+
return mapStream(input.messages, (event) => ({
|
|
61
|
+
address: event.address,
|
|
62
|
+
content: `echo: ${event.payload.content}`,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const qq = qqGatewayPlatform("qq");
|
|
68
|
+
|
|
69
|
+
const kernel = createKernel()
|
|
70
|
+
.use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
71
|
+
.bind(qq.api, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
72
|
+
.bind(qq.transport, { mapping: (ctx) => ({ api: ctx["qq/api"] }) })
|
|
73
|
+
.use(qq.input, {
|
|
74
|
+
option: {},
|
|
75
|
+
mapping: (ctx) => ({ connection: ctx["qq/transport"], api: ctx["qq/api"] }),
|
|
76
|
+
})
|
|
77
|
+
.use(reply, { mapping: (ctx) => ({ messages: ctx.qq }) })
|
|
78
|
+
.bind(qq.output, { mapping: (ctx) => ({ api: ctx["qq/api"], commands: ctx.reply }) });
|
|
79
|
+
|
|
80
|
+
await kernel.start();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The webhook bundle swaps the socket for a request bridge — the HTTP route
|
|
84
|
+
lives outside the composition and hands each callback to the emitted
|
|
85
|
+
`handle`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const qq = qqWebhookPlatform("qq");
|
|
89
|
+
|
|
90
|
+
const kernel = createKernel()
|
|
91
|
+
.use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
92
|
+
.use(qq.webhook, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
93
|
+
.bind(qq.api, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
94
|
+
.use(reply, { mapping: (ctx) => ({ messages: ctx.qq.messages }) })
|
|
95
|
+
.bind(qq.output, { mapping: (ctx) => ({ api: ctx["qq/api"], commands: ctx.reply }) });
|
|
96
|
+
// in the hono route: return kernel.ctx.qq.handle(c.req.raw);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`option` is required (even as `{}`) wherever the plugin's config type is
|
|
100
|
+
non-void: `qq.api` accepts `{ apiBase }` to point the REST client at a mock
|
|
101
|
+
in tests, and `qq.input` accepts `{ intents }` (defaults to
|
|
102
|
+
`QQ_INTENT_GROUP_AND_C2C`).
|
|
103
|
+
|
|
104
|
+
## API overview
|
|
105
|
+
|
|
106
|
+
Shared by both infras:
|
|
107
|
+
|
|
108
|
+
- `qqApi(name)` — emits the REST client as `QqApi` under `name`; consumes
|
|
109
|
+
`{ env: Readonly<Record<string, string>> }`. `QqApi` owns the
|
|
110
|
+
access-token lifecycle (cached, refreshed ahead of expiry) and exposes
|
|
111
|
+
`appId`, `accessToken()`, `gatewayUrl()`, and `sendMessage(to, content)`
|
|
112
|
+
— plain text (`msg_type` 0) to `/v2/groups/:openid/messages` or
|
|
113
|
+
`/v2/users/:openid/messages`. When the address carries a `msgId` the send
|
|
114
|
+
is a passive reply: `msg_seq` is taken from the address or
|
|
115
|
+
auto-incremented per `msgId` when omitted.
|
|
116
|
+
- `qqOutput(name)` — the output half; consumes `{ api, commands }` and
|
|
117
|
+
sends each command's content (`string`) through the api. Terminal: wire
|
|
118
|
+
it last.
|
|
119
|
+
- `QqAddress` — `scope: "group" | "c2c"`, `openid` (`group_openid` or the
|
|
120
|
+
user's openid), optional `msgId`/`msgSeq` passive-reply reference.
|
|
121
|
+
- `QqMessage`, `QqMessageStream`, `QqCommandStream` — the plain-text
|
|
122
|
+
payload and the two stream contracts features are written against.
|
|
123
|
+
- `decodeMessageEvent(t, d)` — decode a dispatch pair into a message
|
|
124
|
+
(`GROUP_AT_MESSAGE_CREATE`, `C2C_MESSAGE_CREATE`), or null to ignore.
|
|
125
|
+
Both inputs share it: the transports deliver the same `{op, t, d}`
|
|
126
|
+
envelope.
|
|
127
|
+
- `readQqCredentials`, `QqCredentials`, `QqCredentialKeys`,
|
|
128
|
+
`DEFAULT_QQ_CREDENTIAL_KEYS` — the credentials half.
|
|
129
|
+
|
|
130
|
+
Gateway infra:
|
|
131
|
+
|
|
132
|
+
- `qqGatewayPlatform(name)` — the bundle (`api`, `transport`, `input`,
|
|
133
|
+
`output`), typed as `QqGatewayPlatform`. The api/transport/output are
|
|
134
|
+
named `${name}/api`, `${name}/transport`, `${name}/output`; the input is
|
|
135
|
+
named `name`.
|
|
136
|
+
- `qqGatewayTransport(name)` — resolve `GET /gateway`, own the socket, emit
|
|
137
|
+
the `WsConnection`.
|
|
138
|
+
- `qqGatewayInput(name)` — the receiving half; `QqGatewayInputConfig` for
|
|
139
|
+
the intents bitmask, `QQ_INTENT_GROUP_AND_C2C` for the default.
|
|
140
|
+
|
|
141
|
+
Webhook infra:
|
|
142
|
+
|
|
143
|
+
- `qqWebhookPlatform(name)` — the bundle (`webhook`, `api`, `output`),
|
|
144
|
+
typed as `QqWebhookPlatform`. The webhook is named `name`; api and output
|
|
145
|
+
are `${name}/api` and `${name}/output`.
|
|
146
|
+
- `qqWebhook(name)` — the callback algorithm, emitted as `QqWebhook`;
|
|
147
|
+
`QqWebhook.handle(request)` returns the `Response` to send back, and
|
|
148
|
+
`QqWebhook.messages` is the decoded message stream. `QqWebhookConfig`
|
|
149
|
+
carries the credential keys.
|
|
150
|
+
|
|
151
|
+
## Examples
|
|
152
|
+
|
|
153
|
+
- [qq-gateway-bot](../../../examples/qq-gateway-bot) — the full gateway round
|
|
154
|
+
trip against a fake platform: token endpoint, gateway discovery, and a
|
|
155
|
+
websocket speaking the op-code flow.
|
|
156
|
+
- [qq-webhook-bot](../../../examples/qq-webhook-bot) — a hono-served callback
|
|
157
|
+
against a fake platform, exercising op-13 validation, a signed dispatch,
|
|
158
|
+
and a rejected forged signature.
|
|
159
|
+
|
|
160
|
+
The gateway transport mirrors the generic machinery of
|
|
161
|
+
[`@lambdot/websocket`](../../core/websocket) — the `WsConnection` shape is
|
|
162
|
+
documented there.
|
|
163
|
+
|
|
164
|
+
## License
|
|
165
|
+
|
|
166
|
+
Dual-licensed under [Apache-2.0](../../../LICENSE-APACHE) and [MIT](../../../LICENSE-MIT).
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2
|
+
"name": "@lambdot/protocol-qq",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/Embers-of-the-Fire/lambdot",
|
|
7
|
+
"directory": "packages/protocol/qq"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"tweetnacl": "^1.0.3",
|
|
18
|
+
"@lambdot/core": "0.1.1",
|
|
19
|
+
"@lambdot/websocket": "0.1.1"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Plugin } from "@lambdot/core";
|
|
2
|
+
import { definePlugin } from "@lambdot/core";
|
|
2
3
|
|
|
3
|
-
import { readQqCredentials, type QqCredentialKeys
|
|
4
|
+
import { readQqCredentials, type QqCredentialKeys } from "./credentials.ts";
|
|
4
5
|
import type { QqAddress } from "./events.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The REST half of the qq protocol,
|
|
8
|
-
* receiving transports (gateway, webhook) deliver
|
|
9
|
-
* message is always an HTTPS call against the open platform. Owns
|
|
10
|
-
* access-token lifecycle: tokens are cached and refreshed ahead of
|
|
11
|
-
* (the platform hands out ~7200s tokens and keeps the old one valid
|
|
12
|
-
* 60s overlap).
|
|
8
|
+
* The REST half of the qq protocol, emitted as the plugin's namespace value.
|
|
9
|
+
* Both receiving transports (gateway, webhook) deliver messages only;
|
|
10
|
+
* sending a message is always an HTTPS call against the open platform. Owns
|
|
11
|
+
* the access-token lifecycle: tokens are cached and refreshed ahead of
|
|
12
|
+
* expiry (the platform hands out ~7200s tokens and keeps the old one valid
|
|
13
|
+
* for a 60s overlap).
|
|
13
14
|
*/
|
|
14
15
|
export interface QqApi {
|
|
15
16
|
readonly appId: string;
|
|
@@ -26,8 +27,6 @@ export interface QqApi {
|
|
|
26
27
|
sendMessage(to: QqAddress, content: string): Promise<void>;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export type QqCapability<TCap extends string> = { readonly [K in TCap]: QqApi };
|
|
30
|
-
|
|
31
30
|
export interface QqApiConfig {
|
|
32
31
|
/** Open-platform base URL; override to point at a mock in tests. */
|
|
33
32
|
readonly apiBase?: string;
|
|
@@ -40,33 +39,22 @@ const DEFAULT_API_BASE = "https://api.bot.qq.com";
|
|
|
40
39
|
const EXPIRY_MARGIN_MS = 60_000;
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* The qq REST client as a plugin, reading credentials from an env snapshot
|
|
43
|
+
* (see `@lambdot/env`). Wire the env namespace through the mapping:
|
|
45
44
|
*
|
|
46
45
|
* ```ts
|
|
47
46
|
* createKernel()
|
|
48
47
|
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
49
|
-
* .
|
|
50
|
-
* // ctx["qq-api"]: QqApi
|
|
48
|
+
* .bind(qqApi("qq/api"), { mapping: (ctx) => ({ env: ctx["qq-env"] }) });
|
|
51
49
|
* ```
|
|
52
50
|
*/
|
|
53
|
-
export function qqApi<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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);
|
|
51
|
+
export function qqApi<const TName extends string>(
|
|
52
|
+
name: TName,
|
|
53
|
+
): Plugin<{ env: Readonly<Record<string, string>> }, QqApi, QqApiConfig, TName> {
|
|
54
|
+
return definePlugin({
|
|
55
|
+
name,
|
|
56
|
+
apply(input, _scope, config) {
|
|
57
|
+
const credentials = readQqCredentials(input.env, config.keys);
|
|
70
58
|
const apiBase = config.apiBase ?? DEFAULT_API_BASE;
|
|
71
59
|
|
|
72
60
|
let cached: { token: string; expiresAt: number } | undefined;
|
|
@@ -128,7 +116,7 @@ export function qqApi<TCap extends string, TEnvCap extends string>(
|
|
|
128
116
|
// Passive replies to one msg_id must increment msg_seq.
|
|
129
117
|
const msgSeqs = new Map<string, number>();
|
|
130
118
|
|
|
131
|
-
|
|
119
|
+
return {
|
|
132
120
|
appId: credentials.appId,
|
|
133
121
|
accessToken,
|
|
134
122
|
async gatewayUrl() {
|
|
@@ -153,13 +141,6 @@ export function qqApi<TCap extends string, TEnvCap extends string>(
|
|
|
153
141
|
await authed(path, { method: "POST", body: JSON.stringify(body) });
|
|
154
142
|
},
|
|
155
143
|
};
|
|
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
144
|
},
|
|
164
|
-
};
|
|
145
|
+
});
|
|
165
146
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
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
1
|
/** The bot credentials issued by the QQ open platform. */
|
|
12
2
|
export interface QqCredentials {
|
|
13
3
|
readonly appId: string;
|
package/src/events.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Address,
|
|
1
|
+
import type { Address, Command, Message, Stream } from "@lambdot/core";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Where a qq message goes. Owned by the output half of the qq platform —
|
|
@@ -17,7 +17,7 @@ export interface QqAddress extends Address<"qq"> {
|
|
|
17
17
|
readonly msgSeq?: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
/** The payload produced by qq message
|
|
20
|
+
/** The payload produced by qq message inputs: plain-text content only. */
|
|
21
21
|
export interface QqMessage {
|
|
22
22
|
/** Message id (`msg_id`), reused as the passive-reply reference. */
|
|
23
23
|
readonly id: string;
|
|
@@ -27,19 +27,13 @@ export interface QqMessage {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* The qq platform's stream contracts: inputs emit message streams, features
|
|
31
|
+
* emit command streams, outputs consume them. Features written against these
|
|
32
|
+
* two types run on either infra (gateway or webhook) — the wiring `mapping`
|
|
33
|
+
* is the platform adapter.
|
|
33
34
|
*/
|
|
34
|
-
export type
|
|
35
|
-
|
|
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
|
-
};
|
|
35
|
+
export type QqMessageStream = Stream<Message<QqMessage, QqAddress>>;
|
|
36
|
+
export type QqCommandStream = Stream<Command<QqAddress, string>>;
|
|
43
37
|
|
|
44
38
|
/** The wire shape of a `GROUP_AT_MESSAGE_CREATE` dispatch body. */
|
|
45
39
|
interface QqRawGroupMessage {
|
|
@@ -59,14 +53,14 @@ interface QqRawC2cMessage {
|
|
|
59
53
|
}
|
|
60
54
|
|
|
61
55
|
/**
|
|
62
|
-
* Decode a dispatch (`t`, `d`) pair into
|
|
63
|
-
*
|
|
64
|
-
*
|
|
56
|
+
* Decode a dispatch (`t`, `d`) pair into a message, or null to ignore it.
|
|
57
|
+
* Shared by the gateway and webhook inputs — both transports deliver the
|
|
58
|
+
* same `{op, t, d}` envelope.
|
|
65
59
|
*/
|
|
66
60
|
export function decodeMessageEvent(
|
|
67
61
|
t: string,
|
|
68
62
|
d: unknown,
|
|
69
|
-
): {
|
|
63
|
+
): { payload: QqMessage; address: QqAddress } | null {
|
|
70
64
|
if (typeof d !== "object" || d === null) return null;
|
|
71
65
|
|
|
72
66
|
if (t === "GROUP_AT_MESSAGE_CREATE") {
|
|
@@ -79,7 +73,6 @@ export function decodeMessageEvent(
|
|
|
79
73
|
)
|
|
80
74
|
return null;
|
|
81
75
|
return {
|
|
82
|
-
kind: "qq.group-message",
|
|
83
76
|
payload: {
|
|
84
77
|
id: raw.id,
|
|
85
78
|
// The platform strips the @bot prefix but leaves padding.
|
|
@@ -105,7 +98,6 @@ export function decodeMessageEvent(
|
|
|
105
98
|
)
|
|
106
99
|
return null;
|
|
107
100
|
return {
|
|
108
|
-
kind: "qq.c2c-message",
|
|
109
101
|
payload: {
|
|
110
102
|
id: raw.id,
|
|
111
103
|
content: raw.content.trim(),
|
package/src/gateway.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
1
|
+
import type { Message, Plugin } from "@lambdot/core";
|
|
2
|
+
import { channel, definePlugin, message, shareStream } from "@lambdot/core";
|
|
3
|
+
import type { WsConnection } from "@lambdot/websocket";
|
|
3
4
|
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
5
|
+
import type { QqApi } from "./api.ts";
|
|
6
|
+
import type { QqAddress, QqMessage, QqMessageStream } from "./events.ts";
|
|
7
|
+
import { decodeMessageEvent } from "./events.ts";
|
|
6
8
|
|
|
7
9
|
/** `GROUP_AND_C2C_EVENT` (1 << 25): group at-messages and C2C messages. */
|
|
8
10
|
export const QQ_INTENT_GROUP_AND_C2C = 1 << 25;
|
|
@@ -11,31 +13,21 @@ export const QQ_INTENT_GROUP_AND_C2C = 1 << 25;
|
|
|
11
13
|
* The qq-specific half of the transport: unlike the generic `wsTransport`,
|
|
12
14
|
* the gateway URL is not static configuration — it is discovered through the
|
|
13
15
|
* REST client (`GET /gateway` with the access token). Resolves the URL at
|
|
14
|
-
* activation, owns the socket, and
|
|
15
|
-
*
|
|
16
|
-
|
|
16
|
+
* activation, owns the socket, and emits the connection so the input can
|
|
17
|
+
* ride it. Wire the api through the mapping:
|
|
18
|
+
|
|
17
19
|
* ```ts
|
|
18
|
-
* .
|
|
19
|
-
* .
|
|
20
|
+
* .bind(qqApi("qq/api"), ...)
|
|
21
|
+
* .bind(qqGatewayTransport("qq/transport"), { mapping: (ctx) => ({ api: ctx["qq/api"] }) })
|
|
20
22
|
* ```
|
|
21
23
|
*/
|
|
22
|
-
export function qqGatewayTransport<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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();
|
|
24
|
+
export function qqGatewayTransport<const TName extends string>(
|
|
25
|
+
name: TName,
|
|
26
|
+
): Plugin<{ api: QqApi }, WsConnection, void, TName> {
|
|
27
|
+
return definePlugin({
|
|
28
|
+
name,
|
|
29
|
+
async apply(input, scope) {
|
|
30
|
+
const url = await input.api.gatewayUrl();
|
|
39
31
|
const socket = new WebSocket(url);
|
|
40
32
|
await new Promise<void>((resolve, reject) => {
|
|
41
33
|
socket.addEventListener("open", () => resolve(), { once: true });
|
|
@@ -45,36 +37,21 @@ export function qqGatewayTransport<TWsCap extends string, TApiCap extends string
|
|
|
45
37
|
{ once: true },
|
|
46
38
|
);
|
|
47
39
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
socket.addEventListener("message", (event) => {
|
|
51
|
-
if (typeof event.data !== "string") return;
|
|
52
|
-
for (const listener of listeners) listener(event.data);
|
|
40
|
+
scope.onDispose(() => {
|
|
41
|
+
socket.close();
|
|
53
42
|
});
|
|
54
43
|
|
|
55
|
-
|
|
44
|
+
return {
|
|
56
45
|
url,
|
|
57
46
|
send: (data) => socket.send(data),
|
|
58
47
|
onMessage(listener) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
};
|
|
48
|
+
socket.addEventListener("message", (event) => {
|
|
49
|
+
if (typeof event.data === "string") listener(event.data);
|
|
50
|
+
});
|
|
63
51
|
},
|
|
64
52
|
};
|
|
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
53
|
},
|
|
77
|
-
};
|
|
54
|
+
});
|
|
78
55
|
}
|
|
79
56
|
|
|
80
57
|
export interface QqGatewayInputConfig {
|
|
@@ -83,33 +60,25 @@ export interface QqGatewayInputConfig {
|
|
|
83
60
|
}
|
|
84
61
|
|
|
85
62
|
/**
|
|
86
|
-
* The receiving half of the gateway infra: consumes the connection
|
|
63
|
+
* The receiving half of the gateway infra: consumes the connection emitted
|
|
87
64
|
* by {@link qqGatewayTransport} and runs the basic gateway algorithm —
|
|
88
65
|
* identify on hello (op 10), heartbeat on the advertised interval (op 1),
|
|
89
|
-
* decode dispatches (op 0) into message
|
|
90
|
-
* not implemented: a dropped connection is a fresh identify.
|
|
66
|
+
* decode dispatches (op 0) into the emitted message stream. Resume (op 6) is
|
|
67
|
+
* deliberately not implemented: a dropped connection is a fresh identify.
|
|
91
68
|
*/
|
|
92
|
-
export function qqGatewayInput<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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];
|
|
69
|
+
export function qqGatewayInput<const TName extends string>(
|
|
70
|
+
name: TName,
|
|
71
|
+
): Plugin<{ connection: WsConnection; api: QqApi }, QqMessageStream, QqGatewayInputConfig, TName> {
|
|
72
|
+
return definePlugin({
|
|
73
|
+
name,
|
|
74
|
+
apply(input, scope, config) {
|
|
75
|
+
const { connection, api } = input;
|
|
108
76
|
const intents = config.intents ?? QQ_INTENT_GROUP_AND_C2C;
|
|
77
|
+
const out = channel<Message<QqMessage, QqAddress>>();
|
|
109
78
|
let lastSeq: number | null = null;
|
|
110
79
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
111
80
|
|
|
112
|
-
|
|
81
|
+
connection.onMessage((data) => {
|
|
113
82
|
let frame: { op?: unknown; d?: unknown; s?: unknown; t?: unknown };
|
|
114
83
|
try {
|
|
115
84
|
frame = JSON.parse(data) as typeof frame;
|
|
@@ -126,7 +95,7 @@ export function qqGatewayInput<TWsCap extends string, TApiCap extends string>(
|
|
|
126
95
|
typeof d?.heartbeat_interval === "number"
|
|
127
96
|
? d.heartbeat_interval
|
|
128
97
|
: 45_000;
|
|
129
|
-
void
|
|
98
|
+
void api.accessToken().then((token) => {
|
|
130
99
|
connection.send(
|
|
131
100
|
JSON.stringify({
|
|
132
101
|
op: 2,
|
|
@@ -145,21 +114,22 @@ export function qqGatewayInput<TWsCap extends string, TApiCap extends string>(
|
|
|
145
114
|
break;
|
|
146
115
|
}
|
|
147
116
|
case 0: {
|
|
148
|
-
// Dispatch: message
|
|
117
|
+
// Dispatch: message frames join the emitted stream.
|
|
149
118
|
if (typeof frame.t !== "string") break;
|
|
150
119
|
const decoded = decodeMessageEvent(frame.t, frame.d);
|
|
151
|
-
if (decoded)
|
|
152
|
-
void ctx.ingest(decoded.kind, decoded.payload, decoded.address);
|
|
120
|
+
if (decoded) out.push(message(decoded.payload, decoded.address));
|
|
153
121
|
break;
|
|
154
122
|
}
|
|
155
123
|
// 11 is a heartbeat ack; every other opcode needs no handling.
|
|
156
124
|
}
|
|
157
125
|
});
|
|
158
126
|
|
|
159
|
-
|
|
127
|
+
scope.onDispose(() => {
|
|
160
128
|
if (heartbeat !== undefined) clearInterval(heartbeat);
|
|
161
|
-
|
|
162
|
-
};
|
|
129
|
+
out.close();
|
|
130
|
+
});
|
|
131
|
+
// Shared: several consumers may subscribe to the message stream.
|
|
132
|
+
return shareStream(out.stream);
|
|
163
133
|
},
|
|
164
|
-
};
|
|
134
|
+
});
|
|
165
135
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Plugin } from "@lambdot/core";
|
|
2
|
+
import type { WsConnection } from "@lambdot/websocket";
|
|
3
3
|
|
|
4
|
-
import { qqApi, type
|
|
5
|
-
import type {
|
|
6
|
-
import type { QqAddress, QqEvents } from "./events.ts";
|
|
4
|
+
import { qqApi, type QqApi, type QqApiConfig } from "./api.ts";
|
|
5
|
+
import type { QqCommandStream, QqMessageStream } from "./events.ts";
|
|
7
6
|
import { qqGatewayInput, qqGatewayTransport, type QqGatewayInputConfig } from "./gateway.ts";
|
|
8
7
|
import { qqOutput } from "./output.ts";
|
|
9
|
-
import {
|
|
8
|
+
import { qqWebhook, type QqWebhook, type QqWebhookConfig } from "./webhook.ts";
|
|
10
9
|
|
|
11
|
-
export { qqApi, type QqApi, type QqApiConfig
|
|
10
|
+
export { qqApi, type QqApi, type QqApiConfig } from "./api.ts";
|
|
12
11
|
export {
|
|
13
12
|
DEFAULT_QQ_CREDENTIAL_KEYS,
|
|
14
13
|
readQqCredentials,
|
|
15
14
|
type QqCredentialKeys,
|
|
16
15
|
type QqCredentials,
|
|
17
|
-
type QqEnvNeeds,
|
|
18
16
|
} from "./credentials.ts";
|
|
19
17
|
export {
|
|
20
18
|
decodeMessageEvent,
|
|
21
19
|
type QqAddress,
|
|
22
|
-
type
|
|
20
|
+
type QqCommandStream,
|
|
23
21
|
type QqMessage,
|
|
24
|
-
type
|
|
22
|
+
type QqMessageStream,
|
|
25
23
|
} from "./events.ts";
|
|
26
24
|
export {
|
|
27
25
|
QQ_INTENT_GROUP_AND_C2C,
|
|
@@ -30,150 +28,107 @@ export {
|
|
|
30
28
|
type QqGatewayInputConfig,
|
|
31
29
|
} from "./gateway.ts";
|
|
32
30
|
export { qqOutput } from "./output.ts";
|
|
33
|
-
export {
|
|
34
|
-
qqWebhookInput,
|
|
35
|
-
type QqWebhook,
|
|
36
|
-
type QqWebhookCapability,
|
|
37
|
-
type QqWebhookConfig,
|
|
38
|
-
} from "./webhook.ts";
|
|
31
|
+
export { qqWebhook, type QqWebhook, type QqWebhookConfig } from "./webhook.ts";
|
|
39
32
|
|
|
40
33
|
/**
|
|
41
|
-
* One qq platform over the websocket gateway, bundled
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
*
|
|
34
|
+
* One qq platform over the websocket gateway, bundled as leaves. The api and
|
|
35
|
+
* transport are internal wiring (compose them with `bind`); the input's
|
|
36
|
+
* message stream is exposed under the platform name (compose with `use`).
|
|
37
|
+
* The output is terminal, so it is always wired last:
|
|
38
|
+
|
|
47
39
|
* ```ts
|
|
48
|
-
* const qq = qqGatewayPlatform(
|
|
40
|
+
* const qq = qqGatewayPlatform("qq");
|
|
49
41
|
* createKernel()
|
|
50
42
|
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
51
|
-
* .
|
|
52
|
-
* .
|
|
53
|
-
* .use(qq.input, {
|
|
54
|
-
*
|
|
43
|
+
* .bind(qq.api, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
44
|
+
* .bind(qq.transport, { mapping: (ctx) => ({ api: ctx["qq/api"] }) })
|
|
45
|
+
* .use(qq.input, {
|
|
46
|
+
* option: {},
|
|
47
|
+
* mapping: (ctx) => ({ connection: ctx["qq/transport"], api: ctx["qq/api"] }),
|
|
48
|
+
* })
|
|
49
|
+
* .use(reply, { mapping: (ctx) => ({ messages: ctx.qq }) })
|
|
50
|
+
* .bind(qq.output, { mapping: (ctx) => ({ api: ctx["qq/api"], commands: ctx.reply }) });
|
|
55
51
|
* ```
|
|
56
52
|
*/
|
|
57
|
-
export interface QqGatewayPlatform<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
> {
|
|
62
|
-
readonly api: FeaturePlugin<
|
|
63
|
-
{},
|
|
64
|
-
{},
|
|
65
|
-
undefined,
|
|
53
|
+
export interface QqGatewayPlatform<TName extends string> {
|
|
54
|
+
readonly api: Plugin<
|
|
55
|
+
{ env: Readonly<Record<string, string>> },
|
|
56
|
+
QqApi,
|
|
66
57
|
QqApiConfig,
|
|
67
|
-
|
|
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>
|
|
58
|
+
`${TName}/api`
|
|
79
59
|
>;
|
|
80
|
-
readonly
|
|
81
|
-
|
|
60
|
+
readonly transport: Plugin<{ api: QqApi }, WsConnection, void, `${TName}/transport`>;
|
|
61
|
+
readonly input: Plugin<
|
|
62
|
+
{ connection: WsConnection; api: QqApi },
|
|
63
|
+
QqMessageStream,
|
|
82
64
|
QqGatewayInputConfig,
|
|
83
|
-
|
|
84
|
-
{},
|
|
85
|
-
WsCapability<TWsCap> & QqCapability<TApiCap>
|
|
65
|
+
TName
|
|
86
66
|
>;
|
|
87
|
-
readonly output:
|
|
88
|
-
|
|
89
|
-
QqAddress,
|
|
90
|
-
string,
|
|
67
|
+
readonly output: Plugin<
|
|
68
|
+
{ api: QqApi; commands: QqCommandStream },
|
|
91
69
|
void,
|
|
92
|
-
|
|
93
|
-
{}
|
|
94
|
-
QqCapability<TApiCap>
|
|
70
|
+
void,
|
|
71
|
+
`${TName}/output`
|
|
95
72
|
>;
|
|
96
73
|
}
|
|
97
74
|
|
|
98
|
-
/** Build a whole gateway-backed qq platform
|
|
99
|
-
export function qqGatewayPlatform<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
TEnvCap extends string,
|
|
103
|
-
>(capabilities: {
|
|
104
|
-
readonly ws: TWsCap;
|
|
105
|
-
readonly api: TApiCap;
|
|
106
|
-
readonly env: TEnvCap;
|
|
107
|
-
}): QqGatewayPlatform<TWsCap, TApiCap, TEnvCap> {
|
|
75
|
+
/** Build a whole gateway-backed qq platform under one name. */
|
|
76
|
+
export function qqGatewayPlatform<const TName extends string>(
|
|
77
|
+
name: TName,
|
|
78
|
+
): QqGatewayPlatform<TName> {
|
|
108
79
|
return {
|
|
109
|
-
api: qqApi(
|
|
110
|
-
transport: qqGatewayTransport(
|
|
111
|
-
input: qqGatewayInput(
|
|
112
|
-
output: qqOutput(
|
|
80
|
+
api: qqApi(`${name}/api`),
|
|
81
|
+
transport: qqGatewayTransport(`${name}/transport`),
|
|
82
|
+
input: qqGatewayInput(name),
|
|
83
|
+
output: qqOutput(`${name}/output`),
|
|
113
84
|
};
|
|
114
85
|
}
|
|
115
86
|
|
|
116
87
|
/**
|
|
117
|
-
* One qq platform over the webhook (reversed-post) infra, bundled
|
|
118
|
-
* webhook
|
|
119
|
-
*
|
|
120
|
-
* output
|
|
121
|
-
|
|
88
|
+
* One qq platform over the webhook (reversed-post) infra, bundled as leaves.
|
|
89
|
+
* The webhook is exposed under the platform name — its `handle` serves the
|
|
90
|
+
* HTTP callback route, its `messages` stream feeds the features. The api is
|
|
91
|
+
* internal wiring; the output is terminal:
|
|
92
|
+
|
|
122
93
|
* ```ts
|
|
123
|
-
* const qq = qqWebhookPlatform(
|
|
94
|
+
* const qq = qqWebhookPlatform("qq");
|
|
124
95
|
* createKernel()
|
|
125
96
|
* .use(envVars("qq-env", ["QQ_BOT_APP_ID", "QQ_BOT_APP_SECRET"]))
|
|
126
|
-
* .use(qq.webhook, {})
|
|
127
|
-
* .
|
|
128
|
-
* .use(qq.
|
|
129
|
-
*
|
|
97
|
+
* .use(qq.webhook, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
98
|
+
* .bind(qq.api, { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) })
|
|
99
|
+
* .use(reply, { mapping: (ctx) => ({ messages: ctx.qq.messages }) })
|
|
100
|
+
* .bind(qq.output, { mapping: (ctx) => ({ api: ctx["qq/api"], commands: ctx.reply }) });
|
|
101
|
+
* // in the hono route: return kernel.ctx.qq.handle(c.req.raw);
|
|
130
102
|
* ```
|
|
131
103
|
*/
|
|
132
|
-
export interface QqWebhookPlatform<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
> {
|
|
137
|
-
readonly webhook: InputPlugin<
|
|
138
|
-
QqEvents,
|
|
104
|
+
export interface QqWebhookPlatform<TName extends string> {
|
|
105
|
+
readonly webhook: Plugin<
|
|
106
|
+
{ env: Readonly<Record<string, string>> },
|
|
107
|
+
QqWebhook,
|
|
139
108
|
QqWebhookConfig,
|
|
140
|
-
|
|
141
|
-
QqWebhookCapability<TCap>,
|
|
142
|
-
QqEnvNeeds<TEnvCap>
|
|
109
|
+
TName
|
|
143
110
|
>;
|
|
144
|
-
readonly api:
|
|
145
|
-
{},
|
|
146
|
-
|
|
147
|
-
undefined,
|
|
111
|
+
readonly api: Plugin<
|
|
112
|
+
{ env: Readonly<Record<string, string>> },
|
|
113
|
+
QqApi,
|
|
148
114
|
QqApiConfig,
|
|
149
|
-
|
|
150
|
-
QqCapability<TApiCap>,
|
|
151
|
-
QqEnvNeeds<TEnvCap>
|
|
115
|
+
`${TName}/api`
|
|
152
116
|
>;
|
|
153
|
-
readonly output:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
string,
|
|
117
|
+
readonly output: Plugin<
|
|
118
|
+
{ api: QqApi; commands: QqCommandStream },
|
|
119
|
+
void,
|
|
157
120
|
void,
|
|
158
|
-
|
|
159
|
-
{},
|
|
160
|
-
QqCapability<TApiCap>
|
|
121
|
+
`${TName}/output`
|
|
161
122
|
>;
|
|
162
123
|
}
|
|
163
124
|
|
|
164
|
-
/** Build a whole webhook-backed qq platform
|
|
165
|
-
export function qqWebhookPlatform<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
TEnvCap extends string,
|
|
169
|
-
>(capabilities: {
|
|
170
|
-
readonly webhook: TCap;
|
|
171
|
-
readonly api: TApiCap;
|
|
172
|
-
readonly env: TEnvCap;
|
|
173
|
-
}): QqWebhookPlatform<TCap, TApiCap, TEnvCap> {
|
|
125
|
+
/** Build a whole webhook-backed qq platform under one name. */
|
|
126
|
+
export function qqWebhookPlatform<const TName extends string>(
|
|
127
|
+
name: TName,
|
|
128
|
+
): QqWebhookPlatform<TName> {
|
|
174
129
|
return {
|
|
175
|
-
webhook:
|
|
176
|
-
api: qqApi(
|
|
177
|
-
output: qqOutput(
|
|
130
|
+
webhook: qqWebhook(name),
|
|
131
|
+
api: qqApi(`${name}/api`),
|
|
132
|
+
output: qqOutput(`${name}/output`),
|
|
178
133
|
};
|
|
179
134
|
}
|
package/src/output.ts
CHANGED
|
@@ -1,31 +1,29 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Plugin } from "@lambdot/core";
|
|
2
|
+
import { definePlugin, pumpStream } from "@lambdot/core";
|
|
2
3
|
|
|
3
|
-
import type { QqApi
|
|
4
|
-
import type {
|
|
4
|
+
import type { QqApi } from "./api.ts";
|
|
5
|
+
import type { QqCommandStream } from "./events.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* The output half of the qq platform:
|
|
8
|
-
*
|
|
9
|
-
* gateway and webhook infras share this
|
|
8
|
+
* The output half of the qq platform: consumes a command stream and sends
|
|
9
|
+
* each command as plain text (msg_type 0) through the REST client. Sending
|
|
10
|
+
* is transport-independent — the gateway and webhook infras share this
|
|
11
|
+
* output. Terminal: wire it last, after the features it consumes.
|
|
10
12
|
*/
|
|
11
|
-
export function qqOutput<
|
|
12
|
-
|
|
13
|
-
):
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
export function qqOutput<const TName extends string>(
|
|
14
|
+
name: TName,
|
|
15
|
+
): Plugin<{ api: QqApi; commands: QqCommandStream }, void, void, TName> {
|
|
16
|
+
return definePlugin({
|
|
17
|
+
name,
|
|
18
|
+
apply(input, scope) {
|
|
19
|
+
const { api } = input;
|
|
20
|
+
scope.onDispose(
|
|
21
|
+
pumpStream(
|
|
22
|
+
input.commands,
|
|
23
|
+
(cmd) => api.sendMessage(cmd.address, cmd.content),
|
|
24
|
+
(error) => scope.onError(error),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
23
27
|
},
|
|
24
|
-
|
|
25
|
-
client = ctx[api];
|
|
26
|
-
return () => {
|
|
27
|
-
client = undefined;
|
|
28
|
-
};
|
|
29
|
-
},
|
|
30
|
-
};
|
|
28
|
+
});
|
|
31
29
|
}
|
package/src/webhook.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Message, Plugin } from "@lambdot/core";
|
|
2
|
+
import { channel, definePlugin, message, shareStream } from "@lambdot/core";
|
|
2
3
|
import nacl from "tweetnacl";
|
|
3
4
|
|
|
4
|
-
import { readQqCredentials, type QqCredentialKeys
|
|
5
|
-
import {
|
|
5
|
+
import { readQqCredentials, type QqCredentialKeys } from "./credentials.ts";
|
|
6
|
+
import type { QqAddress, QqMessage, QqMessageStream } from "./events.ts";
|
|
7
|
+
import { decodeMessageEvent } from "./events.ts";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* The request bridge,
|
|
9
|
-
* outside the
|
|
10
|
-
* each callback request to `handle` and sends back the returned
|
|
11
|
-
*
|
|
10
|
+
* The request bridge, emitted as the plugin's namespace value: the HTTP
|
|
11
|
+
* route lives outside the composition (a hono handler, a worker's fetch), so
|
|
12
|
+
* it hands each callback request to `handle` and sends back the returned
|
|
13
|
+
* response. Decoded message dispatches join the `messages` stream.
|
|
12
14
|
*/
|
|
13
15
|
export interface QqWebhook {
|
|
14
16
|
handle(request: Request): Promise<Response>;
|
|
17
|
+
readonly messages: QqMessageStream;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
export type QqWebhookCapability<TCap extends string> = { readonly [K in TCap]: QqWebhook };
|
|
18
|
-
|
|
19
20
|
export interface QqWebhookConfig {
|
|
20
21
|
/** Which env variables carry the credentials. */
|
|
21
22
|
readonly keys?: QqCredentialKeys;
|
|
@@ -23,40 +24,37 @@ export interface QqWebhookConfig {
|
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The webhook (reversed-post) input: QQ pushes events to an HTTPS callback
|
|
26
|
-
* address.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* (repeated to 32 bytes).
|
|
27
|
+
* address. Emits a {@link QqWebhook} that implements the callback algorithm —
|
|
28
|
+
* op 13 address validation (sign `event_ts + plain_token`), ed25519
|
|
29
|
+
* verification of `X-Signature-Ed25519` over `timestamp + body` for
|
|
30
|
+
* everything else — then pushes message dispatches to the stream. The bot
|
|
31
|
+
* secret seeds the ed25519 keypair (repeated to 32 bytes).
|
|
32
32
|
*
|
|
33
33
|
* ```ts
|
|
34
|
-
* .use(
|
|
35
|
-
* // in the hono route: return kernel.ctx
|
|
34
|
+
* .use(qqWebhook("qq"), { option: {}, mapping: (ctx) => ({ env: ctx["qq-env"] }) });
|
|
35
|
+
* // in the hono route: return kernel.ctx.qq.handle(c.req.raw);
|
|
36
36
|
* ```
|
|
37
37
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
38
|
+
export function qqWebhook<const TName extends string>(
|
|
39
|
+
name: TName,
|
|
40
|
+
): Plugin<{ env: Readonly<Record<string, string>> }, QqWebhook, QqWebhookConfig, TName> {
|
|
41
|
+
return definePlugin({
|
|
42
|
+
name,
|
|
43
|
+
apply(input, scope, config) {
|
|
44
|
+
const credentials = readQqCredentials(input.env, config.keys);
|
|
54
45
|
// The bot secret seeds the ed25519 keypair: repeat to 32 bytes.
|
|
55
46
|
let seed = credentials.clientSecret;
|
|
56
47
|
while (seed.length < 32) seed += seed;
|
|
57
48
|
const keyPair = nacl.sign.keyPair.fromSeed(new TextEncoder().encode(seed.slice(0, 32)));
|
|
58
49
|
|
|
50
|
+
const messages = channel<Message<QqMessage, QqAddress>>();
|
|
51
|
+
scope.onDispose(() => {
|
|
52
|
+
messages.close();
|
|
53
|
+
});
|
|
54
|
+
|
|
59
55
|
const webhook: QqWebhook = {
|
|
56
|
+
// Shared: several consumers may subscribe to the stream.
|
|
57
|
+
messages: shareStream(messages.stream),
|
|
60
58
|
async handle(request) {
|
|
61
59
|
if (request.method !== "POST")
|
|
62
60
|
return new Response("method not allowed", { status: 405 });
|
|
@@ -98,21 +96,15 @@ export function qqWebhookInput<TCap extends string, TEnvCap extends string>(
|
|
|
98
96
|
|
|
99
97
|
if (frame.op === 0 && typeof frame.t === "string") {
|
|
100
98
|
const decoded = decodeMessageEvent(frame.t, frame.d);
|
|
101
|
-
if (decoded)
|
|
102
|
-
await ctx.ingest(decoded.kind, decoded.payload, decoded.address);
|
|
99
|
+
if (decoded) messages.push(message(decoded.payload, decoded.address));
|
|
103
100
|
}
|
|
104
101
|
return Response.json({});
|
|
105
102
|
},
|
|
106
103
|
};
|
|
107
104
|
|
|
108
|
-
|
|
109
|
-
return (ctx.provide as (name: TCap, value: QqWebhook) => Disposer).call(
|
|
110
|
-
ctx,
|
|
111
|
-
capability,
|
|
112
|
-
webhook,
|
|
113
|
-
);
|
|
105
|
+
return webhook;
|
|
114
106
|
},
|
|
115
|
-
};
|
|
107
|
+
});
|
|
116
108
|
}
|
|
117
109
|
|
|
118
110
|
function verify(publicKey: Uint8Array, message: string, signatureHex: string): boolean {
|