@lambdot/protocol-qq 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7), [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7)]:
8
+ - @lambdot/core@0.2.0
9
+ - @lambdot/websocket@0.2.0
10
+
11
+ ## 0.1.1
12
+
13
+ ### Patch Changes
14
+
15
+ - [#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.
16
+ - Updated dependencies [[`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355)]:
17
+ - @lambdot/core@0.1.1
18
+ - @lambdot/websocket@0.1.1
19
+
3
20
  ## [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
21
 
5
22
 
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
- "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
- }
2
+ "name": "@lambdot/protocol-qq",
3
+ "version": "0.2.0",
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.2.0",
19
+ "@lambdot/websocket": "0.2.0"
20
+ }
21
+ }
package/src/api.ts CHANGED
@@ -1,15 +1,16 @@
1
- import type { Disposer, FeaturePlugin } from "@lambdot/core";
1
+ import type { Plugin } from "@lambdot/core";
2
+ import { definePlugin } from "@lambdot/core";
2
3
 
3
- import { readQqCredentials, type QqCredentialKeys, type QqEnvNeeds } from "./credentials.ts";
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, 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).
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
- * Provide the qq REST client as a typed capability, reading credentials from
44
- * the env capability (see `@lambdot/env`). Register the env provider first:
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
- * .use(qqApi("qq-api", "qq-env"));
50
- * // ctx["qq-api"]: QqApi
48
+ * .bind(qqApi("qq/api"), { mapping: (ctx) => ({ env: ctx["qq-env"] }) });
51
49
  * ```
52
50
  */
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);
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
- const api: QqApi = {
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
  }
@@ -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, EventDef, OutputContract } from "@lambdot/core";
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 events: plain-text content only. */
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
- * 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.
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 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
- };
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 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.
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
- ): { kind: keyof QqEvents; payload: QqMessage; address: QqAddress } | null {
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 { Disposer, FeaturePlugin, InputPlugin } from "@lambdot/core";
2
- import type { WsCapability, WsConnection } from "@lambdot/websocket";
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 { QqCapability } from "./api.ts";
5
- import { decodeMessageEvent, type QqEvents } from "./events.ts";
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 provides the connection as a typed
15
- * `WsCapability` so the input can ride it. Register after the api:
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
- * .use(qqApi("qq-api", "qq-env"))
19
- * .use(qqGatewayTransport("qq-ws", "qq-api"))
20
+ * .bind(qqApi("qq/api"), ...)
21
+ * .bind(qqGatewayTransport("qq/transport"), { mapping: (ctx) => ({ api: ctx["qq/api"] }) })
20
22
  * ```
21
23
  */
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();
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
- 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);
40
+ scope.onDispose(() => {
41
+ socket.close();
53
42
  });
54
43
 
55
- const connection: WsConnection = {
44
+ return {
56
45
  url,
57
46
  send: (data) => socket.send(data),
58
47
  onMessage(listener) {
59
- listeners.add(listener);
60
- return () => {
61
- listeners.delete(listener);
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 provided
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 events. Resume (op 6) is deliberately
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<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];
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
- const unsubscribe = connection.onMessage((data) => {
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 ctx[api].accessToken().then((token) => {
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 events become lambdot events.
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
- return () => {
127
+ scope.onDispose(() => {
160
128
  if (heartbeat !== undefined) clearInterval(heartbeat);
161
- void unsubscribe();
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 { FeaturePlugin, InputPlugin, OutputPlugin } from "@lambdot/core";
2
- import type { WsCapability } from "@lambdot/websocket";
1
+ import type { Plugin } from "@lambdot/core";
2
+ import type { WsConnection } from "@lambdot/websocket";
3
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";
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 { qqWebhookInput, type QqWebhookCapability, type QqWebhookConfig } from "./webhook.ts";
8
+ import { qqWebhook, type QqWebhook, type QqWebhookConfig } from "./webhook.ts";
10
9
 
11
- export { qqApi, type QqApi, type QqApiConfig, type QqCapability } from "./api.ts";
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 QqEvents,
20
+ type QqCommandStream,
23
21
  type QqMessage,
24
- type QqOutputs,
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: 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
- *
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({ ws: "qq-ws", api: "qq-api", env: "qq-env" });
40
+ * const qq = qqGatewayPlatform("qq");
49
41
  * createKernel()
50
42
  * .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);
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
- TWsCap extends string,
59
- TApiCap extends string,
60
- TEnvCap extends string,
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
- `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>
58
+ `${TName}/api`
79
59
  >;
80
- readonly input: InputPlugin<
81
- QqEvents,
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
- "qq-gateway-input",
84
- {},
85
- WsCapability<TWsCap> & QqCapability<TApiCap>
65
+ TName
86
66
  >;
87
- readonly output: OutputPlugin<
88
- "qq",
89
- QqAddress,
90
- string,
67
+ readonly output: Plugin<
68
+ { api: QqApi; commands: QqCommandStream },
91
69
  void,
92
- "qq-output",
93
- {},
94
- QqCapability<TApiCap>
70
+ void,
71
+ `${TName}/output`
95
72
  >;
96
73
  }
97
74
 
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> {
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(capabilities.api, capabilities.env),
110
- transport: qqGatewayTransport(capabilities.ws, capabilities.api),
111
- input: qqGatewayInput(capabilities.ws, capabilities.api),
112
- output: qqOutput(capabilities.api),
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: 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
- *
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({ webhook: "qq-webhook", api: "qq-api", env: "qq-env" });
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
- * .use(qq.api, {})
128
- * .use(qq.output);
129
- * // in the hono route: return kernel.ctx["qq-webhook"].handle(c.req.raw);
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
- TCap extends string,
134
- TApiCap extends string,
135
- TEnvCap extends string,
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
- `qq-webhook:${TCap}`,
141
- QqWebhookCapability<TCap>,
142
- QqEnvNeeds<TEnvCap>
109
+ TName
143
110
  >;
144
- readonly api: FeaturePlugin<
145
- {},
146
- {},
147
- undefined,
111
+ readonly api: Plugin<
112
+ { env: Readonly<Record<string, string>> },
113
+ QqApi,
148
114
  QqApiConfig,
149
- `qq-api:${TApiCap}`,
150
- QqCapability<TApiCap>,
151
- QqEnvNeeds<TEnvCap>
115
+ `${TName}/api`
152
116
  >;
153
- readonly output: OutputPlugin<
154
- "qq",
155
- QqAddress,
156
- string,
117
+ readonly output: Plugin<
118
+ { api: QqApi; commands: QqCommandStream },
119
+ void,
157
120
  void,
158
- "qq-output",
159
- {},
160
- QqCapability<TApiCap>
121
+ `${TName}/output`
161
122
  >;
162
123
  }
163
124
 
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> {
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: qqWebhookInput(capabilities.webhook, capabilities.env),
176
- api: qqApi(capabilities.api, capabilities.env),
177
- output: qqOutput(capabilities.api),
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 { OutputPlugin } from "@lambdot/core";
1
+ import type { Plugin } from "@lambdot/core";
2
+ import { definePlugin, pumpStream } from "@lambdot/core";
2
3
 
3
- import type { QqApi, QqCapability } from "./api.ts";
4
- import type { QqAddress } from "./events.ts";
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: 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.
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<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);
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
- apply(ctx) {
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 { Disposer, InputPlugin } from "@lambdot/core";
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, type QqEnvNeeds } from "./credentials.ts";
5
- import { decodeMessageEvent, type QqEvents } from "./events.ts";
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, 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`.
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. 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).
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(qqWebhookInput("qq-webhook", "qq-env"), {});
35
- * // in the hono route: return kernel.ctx["qq-webhook"].handle(c.req.raw);
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 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);
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
- // 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
- );
105
+ return webhook;
114
106
  },
115
- };
107
+ });
116
108
  }
117
109
 
118
110
  function verify(publicKey: Uint8Array, message: string, signatureHex: string): boolean {