@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +34 -2
- package/dist/containers.d.ts +102 -0
- package/dist/containers.js +314 -0
- package/dist/dual-replica.d.ts +121 -0
- package/dist/dual-replica.js +247 -0
- package/dist/extension-config.d.ts +9 -0
- package/dist/extension-config.js +16 -0
- package/dist/fake-channel.d.ts +162 -0
- package/dist/fake-channel.js +325 -0
- package/dist/fake-e2b.d.ts +104 -0
- package/dist/fake-e2b.js +500 -0
- package/dist/fake-git-server.d.ts +57 -0
- package/dist/fake-git-server.js +323 -0
- package/dist/fake-lark.d.ts +234 -0
- package/dist/fake-lark.js +837 -0
- package/dist/fake-marketplace.d.ts +25 -0
- package/dist/fake-marketplace.js +58 -0
- package/dist/fake-mcp-server.d.ts +134 -0
- package/dist/fake-mcp-server.js +308 -0
- package/dist/fake-model-server.d.ts +28 -0
- package/dist/fake-model-server.js +67 -0
- package/dist/fake-npm-registry.d.ts +59 -0
- package/dist/fake-npm-registry.js +174 -0
- package/dist/fake-oauth-server.d.ts +63 -0
- package/dist/fake-oauth-server.js +284 -0
- package/dist/fake-openapi-server.d.ts +50 -0
- package/dist/fake-openapi-server.js +278 -0
- package/dist/fake-slack.d.ts +258 -0
- package/dist/fake-slack.js +654 -0
- package/dist/fixtures.d.ts +47 -0
- package/dist/fixtures.js +90 -0
- package/dist/global-setup.d.ts +15 -0
- package/dist/global-setup.js +58 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/kernel-helpers.d.ts +125 -0
- package/dist/kernel-helpers.js +336 -0
- package/dist/media-samples.d.ts +10 -0
- package/dist/media-samples.js +76 -0
- package/dist/model-conformance.d.ts +61 -0
- package/dist/model-conformance.js +287 -0
- package/dist/replica-runner.d.ts +1 -0
- package/dist/replica-runner.js +59 -0
- package/dist/sse.d.ts +20 -0
- package/dist/sse.js +87 -0
- package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
- package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
- package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
- package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
- package/fixtures/extensions/fake-connector/package.json +13 -0
- package/fixtures/extensions/fake-connector/src/index.ts +477 -0
- package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-dep-provider/package.json +12 -0
- package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
- package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
- package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
- package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
- package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-embed/package.json +12 -0
- package/fixtures/extensions/fake-embed/src/index.ts +207 -0
- package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-im/package.json +12 -0
- package/fixtures/extensions/fake-im/src/channel.ts +224 -0
- package/fixtures/extensions/fake-im/src/directory.ts +25 -0
- package/fixtures/extensions/fake-im/src/identity.ts +66 -0
- package/fixtures/extensions/fake-im/src/index.ts +91 -0
- package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-object-store/package.json +15 -0
- package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
- package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-openapi/package.json +12 -0
- package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
- package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-storage-user/package.json +12 -0
- package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
- package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
- package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
- package/fixtures/extensions/fake-tools-ui/package.json +12 -0
- package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
- package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
- package/package.json +66 -3
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-dep-requirer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: requires counter@1 and depends on the plugin fake-dep-provider",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineExtension } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test fake: the requirer side of a dependency pair. Requires `counter@1` (provided by
|
|
5
|
+
* `fake-dep-provider`, which its plugin.json also lists as a plugin dependency); the route
|
|
6
|
+
* `GET /ping` answers with what the provider's `ping()` returns.
|
|
7
|
+
*/
|
|
8
|
+
export default defineExtension({
|
|
9
|
+
name: "fake-dep-requirer",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
sdk: "^0.1.0-rc.0",
|
|
12
|
+
provides: [],
|
|
13
|
+
requires: ["counter@1"],
|
|
14
|
+
permissions: { http: [{ method: "GET", path: "/ping" }] },
|
|
15
|
+
setup(ctx) {
|
|
16
|
+
ctx.http.route("GET", "/ping", async () => {
|
|
17
|
+
const impl = ctx.extensionPoints.list<{ ping(): string }>("counter@1")[0]?.impl;
|
|
18
|
+
return new Response(impl ? impl.ping() : "no provider", { status: impl ? 200 : 503 });
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-embed",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: two embed contributions (org page and conversation tab) with token-checked routes and SSE over ctx.bus",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { defineExtension, EMBED_PROTOCOL, type EmbedClaims } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Test fake of an embed extension (`ui.embeds`): the kernel side of embedded apps (token issue and
|
|
6
|
+
* verification, the `/plugin-ui` proxy, `ctx.bus` across replicas) without the official board.
|
|
7
|
+
* - embeds `panel` (org page, `/ui/panel`) and `panel-conv` (conversation tab, `/ui/panel-conv`);
|
|
8
|
+
* both pages are static HTML loading the static script `/ui/panel.js`; none of them needs a
|
|
9
|
+
* token. The script speaks the host's message bridge (`capybara-embed/1`): on `init` it fetches
|
|
10
|
+
* `claims` with the token it was given, shows `<contribution> <orgId>` in `#status` and posts a
|
|
11
|
+
* `toast` "fake-embed ready" to the host (a failed fetch shows `error <status>`), then reads the
|
|
12
|
+
* `/ui/events` stream with the token: `ready` sets `data-live="1"` on the body, each `event`
|
|
13
|
+
* shows its text in `#event`;
|
|
14
|
+
* - every data route checks `Authorization: Bearer <embed token>` with `ctx.ui.verifyEmbedToken`
|
|
15
|
+
* (401 otherwise); identity comes only from the claims;
|
|
16
|
+
* - `GET /ui/claims` returns the verified claims;
|
|
17
|
+
* - `POST /ui/events` `{ text }` publishes `{ conversationId, text }` on the bus topic
|
|
18
|
+
* `events.<orgId>` (202);
|
|
19
|
+
* - `GET /ui/events` is an SSE stream: `ready` first, then an `event` for each message in the
|
|
20
|
+
* token's scope (its conversation, or org-wide messages for a page token); it ends when the
|
|
21
|
+
* client disconnects or the token expires.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const BEARER_RE = /^Bearer ([A-Za-z0-9._-]{1,4096})$/;
|
|
25
|
+
const EventBody = z.object({ text: z.string().min(1).max(200) });
|
|
26
|
+
|
|
27
|
+
const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Fake embed</title><script src="./panel.js"></script></head><body><p>fake-embed</p><p id="status">waiting</p><p id="event"></p></body></html>`;
|
|
28
|
+
|
|
29
|
+
const SCRIPT = `(function () {
|
|
30
|
+
var PROTOCOL = "${EMBED_PROTOCOL}";
|
|
31
|
+
var started = false;
|
|
32
|
+
document.addEventListener("DOMContentLoaded", function () { document.body.dataset.ready = "1"; });
|
|
33
|
+
function show(text) { document.getElementById("status").textContent = text; }
|
|
34
|
+
window.addEventListener("message", function (e) {
|
|
35
|
+
if (e.source !== window.parent) return;
|
|
36
|
+
var m = e.data;
|
|
37
|
+
if (!m || typeof m !== "object" || m.protocol !== PROTOCOL || m.type !== "init") return;
|
|
38
|
+
if (started || typeof m.token !== "string") return;
|
|
39
|
+
started = true;
|
|
40
|
+
fetch("claims", { headers: { authorization: "Bearer " + m.token }, credentials: "omit" })
|
|
41
|
+
.then(function (res) {
|
|
42
|
+
if (!res.ok) throw new Error("error " + res.status);
|
|
43
|
+
return res.json();
|
|
44
|
+
})
|
|
45
|
+
.then(function (c) {
|
|
46
|
+
show(c.contribution + " " + c.orgId);
|
|
47
|
+
window.parent.postMessage({ protocol: PROTOCOL, type: "toast", text: "fake-embed ready" }, "*");
|
|
48
|
+
listen(m.token);
|
|
49
|
+
})
|
|
50
|
+
.catch(function (err) { show(String(err.message)); });
|
|
51
|
+
});
|
|
52
|
+
// The SSE stream of /ui/events, read with fetch (EventSource cannot send the token): "ready"
|
|
53
|
+
// marks the body data-live="1", each "event" shows its text in #event.
|
|
54
|
+
function listen(token) {
|
|
55
|
+
fetch("events", { headers: { authorization: "Bearer " + token }, credentials: "omit" })
|
|
56
|
+
.then(function (res) {
|
|
57
|
+
if (!res.ok || !res.body) throw new Error("events " + res.status);
|
|
58
|
+
var reader = res.body.getReader();
|
|
59
|
+
var decoder = new TextDecoder();
|
|
60
|
+
var buf = "";
|
|
61
|
+
function pump() {
|
|
62
|
+
return reader.read().then(function (r) {
|
|
63
|
+
if (r.done) return;
|
|
64
|
+
buf += decoder.decode(r.value, { stream: true });
|
|
65
|
+
var at;
|
|
66
|
+
while ((at = buf.indexOf("\\n\\n")) >= 0) {
|
|
67
|
+
var block = buf.slice(0, at);
|
|
68
|
+
buf = buf.slice(at + 2);
|
|
69
|
+
var type = (/^event: (.*)$/m.exec(block) || [])[1];
|
|
70
|
+
var data = (/^data: (.*)$/m.exec(block) || [])[1];
|
|
71
|
+
if (type === "ready") document.body.dataset.live = "1";
|
|
72
|
+
if (type === "event") document.getElementById("event").textContent = JSON.parse(data).text;
|
|
73
|
+
}
|
|
74
|
+
return pump();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return pump();
|
|
78
|
+
})
|
|
79
|
+
.catch(function (err) { document.getElementById("event").textContent = String(err.message); });
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const topic = (orgId: string) => `events.${orgId}`;
|
|
85
|
+
|
|
86
|
+
const json = (status: number, body: unknown) => Response.json(body, { status });
|
|
87
|
+
const unauthorized = () =>
|
|
88
|
+
json(401, { error: { code: "unauthorized", message: "A valid embed token is required" } });
|
|
89
|
+
|
|
90
|
+
export default defineExtension({
|
|
91
|
+
name: "fake-embed",
|
|
92
|
+
version: "0.1.0",
|
|
93
|
+
sdk: "^0.1.0-rc.0",
|
|
94
|
+
provides: [],
|
|
95
|
+
ui: [
|
|
96
|
+
{
|
|
97
|
+
id: "panel",
|
|
98
|
+
title: { zh: "面板", en: "Panel" },
|
|
99
|
+
place: "page",
|
|
100
|
+
kind: "embed",
|
|
101
|
+
path: "/ui/panel",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "panel-conv",
|
|
105
|
+
title: { zh: "面板", en: "Panel" },
|
|
106
|
+
place: "conversation",
|
|
107
|
+
kind: "embed",
|
|
108
|
+
path: "/ui/panel-conv",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
permissions: {
|
|
112
|
+
http: [
|
|
113
|
+
{ method: "GET", path: "/ui/panel" },
|
|
114
|
+
{ method: "GET", path: "/ui/panel-conv" },
|
|
115
|
+
{ method: "GET", path: "/ui/panel.js" },
|
|
116
|
+
{ method: "GET", path: "/ui/claims" },
|
|
117
|
+
{ method: "POST", path: "/ui/events" },
|
|
118
|
+
{ method: "GET", path: "/ui/events" },
|
|
119
|
+
],
|
|
120
|
+
ui: { embeds: ["panel", "panel-conv"] },
|
|
121
|
+
},
|
|
122
|
+
setup(ctx) {
|
|
123
|
+
const auth = async (req: Request): Promise<EmbedClaims | null> => {
|
|
124
|
+
const m = BEARER_RE.exec(req.headers.get("authorization") ?? "");
|
|
125
|
+
return m?.[1] === undefined ? null : ctx.ui.verifyEmbedToken(m[1]);
|
|
126
|
+
};
|
|
127
|
+
const page = async () =>
|
|
128
|
+
new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
129
|
+
ctx.http.route("GET", "/ui/panel", page);
|
|
130
|
+
ctx.http.route("GET", "/ui/panel-conv", page);
|
|
131
|
+
ctx.http.route(
|
|
132
|
+
"GET",
|
|
133
|
+
"/ui/panel.js",
|
|
134
|
+
async () =>
|
|
135
|
+
new Response(SCRIPT, { headers: { "content-type": "text/javascript; charset=utf-8" } }),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
ctx.http.route("GET", "/ui/claims", async (req) => {
|
|
139
|
+
const claims = await auth(req);
|
|
140
|
+
return claims ? json(200, claims) : unauthorized();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ctx.http.route("POST", "/ui/events", async (req) => {
|
|
144
|
+
const claims = await auth(req);
|
|
145
|
+
if (!claims) return unauthorized();
|
|
146
|
+
const body = EventBody.safeParse(await req.json().catch(() => undefined));
|
|
147
|
+
if (!body.success) {
|
|
148
|
+
return json(400, { error: { code: "validation_failed", message: "text is required" } });
|
|
149
|
+
}
|
|
150
|
+
await ctx.bus.publish(topic(claims.orgId), {
|
|
151
|
+
conversationId: claims.conversationId ?? null,
|
|
152
|
+
text: body.data.text,
|
|
153
|
+
});
|
|
154
|
+
return json(202, { published: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
ctx.http.route("GET", "/ui/events", async (req) => {
|
|
158
|
+
const claims = await auth(req);
|
|
159
|
+
if (!claims) return unauthorized();
|
|
160
|
+
const done = AbortSignal.any([
|
|
161
|
+
req.signal,
|
|
162
|
+
AbortSignal.timeout(Math.max(1, claims.exp * 1000 - Date.now())),
|
|
163
|
+
]);
|
|
164
|
+
const enc = new TextEncoder();
|
|
165
|
+
let closed = false;
|
|
166
|
+
let unsubscribe: (() => Promise<void>) | undefined;
|
|
167
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
168
|
+
async start(controller) {
|
|
169
|
+
const send = (s: string) => {
|
|
170
|
+
if (!closed) controller.enqueue(enc.encode(s));
|
|
171
|
+
};
|
|
172
|
+
const finish = () => {
|
|
173
|
+
if (closed) return;
|
|
174
|
+
closed = true;
|
|
175
|
+
void unsubscribe?.().catch(() => {});
|
|
176
|
+
try {
|
|
177
|
+
controller.close();
|
|
178
|
+
} catch {
|
|
179
|
+
// already cancelled
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
unsubscribe = await ctx.bus.subscribe(topic(claims.orgId), (m) => {
|
|
183
|
+
const msg = m as { conversationId?: unknown; text?: unknown } | null;
|
|
184
|
+
if ((msg?.conversationId ?? null) !== (claims.conversationId ?? null)) return;
|
|
185
|
+
send(`event: event\ndata: ${JSON.stringify({ text: String(msg?.text ?? "") })}\n\n`);
|
|
186
|
+
});
|
|
187
|
+
if (done.aborted) {
|
|
188
|
+
finish();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
done.addEventListener("abort", finish, { once: true });
|
|
192
|
+
send("event: ready\ndata: {}\n\n");
|
|
193
|
+
},
|
|
194
|
+
cancel() {
|
|
195
|
+
closed = true;
|
|
196
|
+
void unsubscribe?.().catch(() => {});
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
return new Response(stream, {
|
|
200
|
+
headers: {
|
|
201
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
202
|
+
"cache-control": "no-cache",
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-im",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake: a whole IM platform for startFakeChannelServer (channel@1, identity-source@1, directory@1) and a write tool that asks",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
type Channel,
|
|
4
|
+
ChannelSendError,
|
|
5
|
+
type ChannelSendFailure,
|
|
6
|
+
type ChannelWebhook,
|
|
7
|
+
type InboundEvent,
|
|
8
|
+
secret,
|
|
9
|
+
} from "@capybara-hq/plugin-sdk";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 假 IM 平台的 `channel@1`(连 `@capybara-hq/testing` 的 startFakeChannelServer)。真实渠道插件(Slack / 飞书)照这个形状写:
|
|
14
|
+
* - verify:`POST <platformUrl>/bot/verify` 校验应用凭据,拿到机器人 id 与租户;
|
|
15
|
+
* - connect:`GET <platformUrl>/connect` 拿一条 NDJSON 长连接,逐行交给 `onEvent`;平台关闭连接后自行重连,
|
|
16
|
+
* 连接请求失败(非 200)时抛错(内核据此记录 last_error 并退避重试);signal 中止时返回;
|
|
17
|
+
* - send / update:POST 给平台并透传 `idempotencyKey`(平台据此去重)与 `locale`;429 / 5xx 抛可重试的
|
|
18
|
+
* `ChannelSendError`(带平台给的等待时长),其他失败抛不可重试的(带平台给的失败原因 `reason`)。
|
|
19
|
+
* 所有请求带 `x-pid`,让平台知道是哪个进程(副本)发起的。
|
|
20
|
+
*/
|
|
21
|
+
export const FakeImConnection = z.object({
|
|
22
|
+
platformUrl: z.string().url(),
|
|
23
|
+
appId: z.string().min(1),
|
|
24
|
+
tenant: z.string().min(1).default("t1"),
|
|
25
|
+
appSecret: secret(z.string().min(1)),
|
|
26
|
+
/**
|
|
27
|
+
* Optional secret (also tests PATCH secret semantics); the webhook signing secret: webhook
|
|
28
|
+
* requests are signed `x-fake-signature: sha256=<hex HMAC-SHA256(webhookToken, raw body)>`.
|
|
29
|
+
*/
|
|
30
|
+
webhookToken: secret(z.string().min(1).optional()),
|
|
31
|
+
/** 被中止后拖这么久才返回(模拟不及时退出的插件)。 */
|
|
32
|
+
lingerMs: z.number().int().nonnegative().default(0),
|
|
33
|
+
});
|
|
34
|
+
type ConnConfig = z.infer<typeof FakeImConnection>;
|
|
35
|
+
|
|
36
|
+
const headers = (extra: Record<string, string> = {}) => ({
|
|
37
|
+
"content-type": "application/json",
|
|
38
|
+
"x-pid": String(process.pid),
|
|
39
|
+
...extra,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// capybara:allow-module-state a read-only constant set
|
|
43
|
+
const REASONS = new Set<ChannelSendFailure>(["auth", "target_gone", "rejected", "too_large"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Waits `ms` or until `signal` aborts. Extensions may not use timers (plugin-timer rule), so
|
|
47
|
+
* `AbortSignal.timeout` does the timing, as in the real channel extensions.
|
|
48
|
+
*/
|
|
49
|
+
const pause = (ms: number, signal?: AbortSignal) =>
|
|
50
|
+
new Promise<void>((resolve) => {
|
|
51
|
+
if (signal?.aborted) return resolve();
|
|
52
|
+
const timer = AbortSignal.timeout(ms);
|
|
53
|
+
const done = () => {
|
|
54
|
+
signal?.removeEventListener("abort", done);
|
|
55
|
+
timer.removeEventListener("abort", done);
|
|
56
|
+
resolve();
|
|
57
|
+
};
|
|
58
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
59
|
+
timer.addEventListener("abort", done, { once: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST 给平台:429 / 5xx → 可重试的 ChannelSendError(带平台给的等待时长),其他非 200 → 不可重试
|
|
64
|
+
* (带平台给的失败原因)。
|
|
65
|
+
*/
|
|
66
|
+
async function post(c: ConnConfig, path: string, body: unknown, signal: AbortSignal) {
|
|
67
|
+
const res = await fetch(`${c.platformUrl}${path}`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: headers(),
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
if (res.status === 200) return res;
|
|
74
|
+
const b = (await res.json().catch(() => ({}))) as {
|
|
75
|
+
retryAfterMs?: number | null;
|
|
76
|
+
reason?: string | null;
|
|
77
|
+
};
|
|
78
|
+
const retryable = res.status === 429 || res.status >= 500;
|
|
79
|
+
const reason = REASONS.has(b.reason as ChannelSendFailure)
|
|
80
|
+
? (b.reason as ChannelSendFailure)
|
|
81
|
+
: undefined;
|
|
82
|
+
throw new ChannelSendError(`platform returned ${res.status}`, {
|
|
83
|
+
retryable,
|
|
84
|
+
...(typeof b.retryAfterMs === "number" && { retryAfterMs: b.retryAfterMs }),
|
|
85
|
+
...(!retryable && reason !== undefined && { reason }),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 读一条 NDJSON 长连接直到平台关闭;每行一个入站事件。 */
|
|
90
|
+
async function pump(body: ReadableStream<Uint8Array>, onEvent: (e: InboundEvent) => Promise<void>) {
|
|
91
|
+
const reader = body.getReader();
|
|
92
|
+
const dec = new TextDecoder();
|
|
93
|
+
let buf = "";
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { value, done } = await reader.read();
|
|
96
|
+
if (done) return;
|
|
97
|
+
buf += dec.decode(value, { stream: true });
|
|
98
|
+
let nl = buf.indexOf("\n");
|
|
99
|
+
while (nl >= 0) {
|
|
100
|
+
const line = buf.slice(0, nl).trim();
|
|
101
|
+
buf = buf.slice(nl + 1);
|
|
102
|
+
if (line !== "") {
|
|
103
|
+
try {
|
|
104
|
+
await onEvent(JSON.parse(line) as InboundEvent);
|
|
105
|
+
} catch {
|
|
106
|
+
// 处理失败:真实平台会因未确认而重投;测试里由平台手动重推
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
nl = buf.indexOf("\n");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Constant-time check of `x-fake-signature` against HMAC-SHA256(secret, body). */
|
|
115
|
+
function signatureOk(header: string | undefined, secret: string, body: Uint8Array): boolean {
|
|
116
|
+
if (header === undefined) return false;
|
|
117
|
+
const want = Buffer.from(`sha256=${createHmac("sha256", secret).update(body).digest("hex")}`);
|
|
118
|
+
const got = Buffer.from(header);
|
|
119
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Webhook inbound of the fake platform (see `signFakeWebhook` in `@capybara-hq/testing`): every
|
|
124
|
+
* request must be signed with `webhookToken` (missing secret or bad signature → reject 401);
|
|
125
|
+
* `{ type: "challenge", challenge }` echoes `{ challenge }`; `{ type: "events", events }` hands the
|
|
126
|
+
* events to the kernel with the ack body `ok`; anything else → reject 400.
|
|
127
|
+
*/
|
|
128
|
+
export const fakeImWebhook: ChannelWebhook = {
|
|
129
|
+
async handle({ config, request }) {
|
|
130
|
+
const c = config as ConnConfig;
|
|
131
|
+
if (c.webhookToken === undefined) return { kind: "reject", status: 401 };
|
|
132
|
+
if (!signatureOk(request.headers["x-fake-signature"], c.webhookToken, request.body)) {
|
|
133
|
+
return { kind: "reject", status: 401 };
|
|
134
|
+
}
|
|
135
|
+
let msg: { type?: unknown; challenge?: unknown; events?: unknown };
|
|
136
|
+
try {
|
|
137
|
+
msg = JSON.parse(new TextDecoder().decode(request.body)) as typeof msg;
|
|
138
|
+
} catch {
|
|
139
|
+
return { kind: "reject", status: 400 };
|
|
140
|
+
}
|
|
141
|
+
if (msg.type === "challenge" && typeof msg.challenge === "string") {
|
|
142
|
+
return {
|
|
143
|
+
kind: "respond",
|
|
144
|
+
response: {
|
|
145
|
+
status: 200,
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
body: JSON.stringify({ challenge: msg.challenge }),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (msg.type === "events" && Array.isArray(msg.events)) {
|
|
152
|
+
return {
|
|
153
|
+
kind: "events",
|
|
154
|
+
events: msg.events as InboundEvent[],
|
|
155
|
+
ack: { status: 200, headers: { "content-type": "text/plain" }, body: "ok" },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { kind: "reject", status: 400 };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 按能力开关造一个假渠道(chan-mini / chan-noedit 复用,只是插件名与 features 不同)。
|
|
164
|
+
* `opts.webhook` adds {@link fakeImWebhook} (only `fake-im` has it; the others test "unsupported").
|
|
165
|
+
*/
|
|
166
|
+
export const makeFakeImChannel = (
|
|
167
|
+
features: Channel["features"],
|
|
168
|
+
opts: { webhook?: boolean } = {},
|
|
169
|
+
): Channel => ({
|
|
170
|
+
...(opts.webhook === true && { webhook: fakeImWebhook }),
|
|
171
|
+
connectionSchema: FakeImConnection,
|
|
172
|
+
features,
|
|
173
|
+
async verify({ config, signal }) {
|
|
174
|
+
const c = config as ConnConfig;
|
|
175
|
+
const res = await fetch(`${c.platformUrl}/bot/verify`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: headers(),
|
|
178
|
+
body: JSON.stringify({ appId: c.appId, appSecret: c.appSecret, tenant: c.tenant }),
|
|
179
|
+
signal,
|
|
180
|
+
});
|
|
181
|
+
if (res.status !== 200) {
|
|
182
|
+
await res.body?.cancel();
|
|
183
|
+
throw new Error(`invalid app credentials (platform returned ${res.status})`);
|
|
184
|
+
}
|
|
185
|
+
return (await res.json()) as { botUserId: string; botName: string; tenantId: string };
|
|
186
|
+
},
|
|
187
|
+
async connect({ connectionId, config, onEvent, signal }) {
|
|
188
|
+
const c = config as ConnConfig;
|
|
189
|
+
while (!signal.aborted) {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(
|
|
192
|
+
`${c.platformUrl}/connect?connection=${encodeURIComponent(connectionId)}`,
|
|
193
|
+
{ signal, headers: headers({ "x-app-id": c.appId }) },
|
|
194
|
+
);
|
|
195
|
+
if (res.status !== 200 || !res.body) {
|
|
196
|
+
await res.body?.cancel();
|
|
197
|
+
throw new Error(`connect failed with status ${res.status}`);
|
|
198
|
+
}
|
|
199
|
+
await pump(res.body, onEvent);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (signal.aborted) {
|
|
202
|
+
// the signal is already aborted: linger without it
|
|
203
|
+
if (c.lingerMs > 0) await pause(c.lingerMs);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
// The platform closed the connection: reconnect shortly
|
|
209
|
+
await pause(50, signal);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
async send({ config, target, message, signal, idempotencyKey, locale }) {
|
|
213
|
+
const c = config as ConnConfig;
|
|
214
|
+
const body = { appId: c.appId, target, message, idempotencyKey, locale };
|
|
215
|
+
const res = await post(c, "/send", body, signal);
|
|
216
|
+
return (await res.json()) as { messageId: string };
|
|
217
|
+
},
|
|
218
|
+
async update({ config, target, message, messageId, signal, idempotencyKey, locale }) {
|
|
219
|
+
const c = config as ConnConfig;
|
|
220
|
+
const body = { appId: c.appId, target, message, messageId, idempotencyKey, locale };
|
|
221
|
+
const res = await post(c, "/update", body, signal);
|
|
222
|
+
await res.body?.cancel();
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Directory, DirectoryMember } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** 组织级配置:假平台上一份通讯录的地址(startFakeChannelServer().book(key).url)。 */
|
|
5
|
+
export const FakeImOrgConfig = z.object({ url: z.string().url() });
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 假 IM 平台的 `directory@1`:`listMembers` 按游标 GET `<url>/members?cursor=<c>`(请求头 `x-pid`),
|
|
9
|
+
* 非 200 抛错(内核把整轮当作不完整,不移除任何人)。dir-mini 复用它。
|
|
10
|
+
*/
|
|
11
|
+
export const fakeImDirectory: Directory = {
|
|
12
|
+
async listMembers({ orgConfig, cursor, signal }) {
|
|
13
|
+
const c = FakeImOrgConfig.parse(orgConfig);
|
|
14
|
+
const q = cursor === undefined ? "" : `?cursor=${encodeURIComponent(cursor)}`;
|
|
15
|
+
const res = await fetch(`${c.url}/members${q}`, {
|
|
16
|
+
signal,
|
|
17
|
+
headers: { "x-pid": String(process.pid) },
|
|
18
|
+
});
|
|
19
|
+
if (res.status !== 200) {
|
|
20
|
+
await res.body?.cancel();
|
|
21
|
+
throw new Error(`directory returned ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
return (await res.json()) as { members: DirectoryMember[]; next?: string };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExternalIdentity, IdentitySource } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import type { FakeImDeployment } from "./index.js";
|
|
3
|
+
|
|
4
|
+
interface TokenResponse {
|
|
5
|
+
user: {
|
|
6
|
+
externalId: string;
|
|
7
|
+
tenantId: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
emailVerified?: boolean;
|
|
10
|
+
displayName: string;
|
|
11
|
+
};
|
|
12
|
+
nonce: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 假 IM 平台的 `identity-source@1`(登录):授权地址指向平台的 `/oauth/authorize`;回调用授权码在
|
|
17
|
+
* `/oauth/token` 换用户资料,核对 nonce 与租户。身份源键 `fake-im:<租户>` 与渠道入站、通讯录相同,
|
|
18
|
+
* 同一个人只对应一行身份。授权地址带内核给的 PKCE challenge,换 token 时带 codeVerifier(平台核对)。
|
|
19
|
+
*/
|
|
20
|
+
export const fakeImIdentitySource: IdentitySource = {
|
|
21
|
+
label: { zh: "假 IM", en: "Fake IM" },
|
|
22
|
+
authorizeUrl({ config, redirectUri, state, nonce, codeChallenge, codeChallengeMethod }) {
|
|
23
|
+
const c = config as FakeImDeployment;
|
|
24
|
+
const u = new URL(`${c.platformUrl}/oauth/authorize`);
|
|
25
|
+
u.searchParams.set("client_id", c.clientId);
|
|
26
|
+
u.searchParams.set("redirect_uri", redirectUri);
|
|
27
|
+
u.searchParams.set("state", state);
|
|
28
|
+
u.searchParams.set("nonce", nonce);
|
|
29
|
+
if (codeChallenge !== undefined) {
|
|
30
|
+
u.searchParams.set("code_challenge", codeChallenge);
|
|
31
|
+
u.searchParams.set("code_challenge_method", codeChallengeMethod ?? "S256");
|
|
32
|
+
}
|
|
33
|
+
return u.toString();
|
|
34
|
+
},
|
|
35
|
+
async callback({ config, query, nonce, signal, codeVerifier }): Promise<ExternalIdentity> {
|
|
36
|
+
const c = config as FakeImDeployment;
|
|
37
|
+
if (query.error !== undefined) throw new Error(`authorization failed: ${query.error}`);
|
|
38
|
+
if (query.code === undefined) throw new Error("authorization code missing");
|
|
39
|
+
const res = await fetch(`${c.platformUrl}/oauth/token`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json", "x-pid": String(process.pid) },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
code: query.code,
|
|
44
|
+
clientId: c.clientId,
|
|
45
|
+
clientSecret: c.clientSecret,
|
|
46
|
+
...(codeVerifier !== undefined && { codeVerifier }),
|
|
47
|
+
}),
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (res.status !== 200) {
|
|
51
|
+
await res.body?.cancel();
|
|
52
|
+
throw new Error(`token exchange failed with status ${res.status}`);
|
|
53
|
+
}
|
|
54
|
+
const t = (await res.json()) as TokenResponse;
|
|
55
|
+
if (t.nonce !== nonce) throw new Error("nonce mismatch");
|
|
56
|
+
if (t.user.tenantId !== c.tenant) throw new Error("user belongs to another tenant");
|
|
57
|
+
return {
|
|
58
|
+
source: `fake-im:${t.user.tenantId}`,
|
|
59
|
+
externalId: t.user.externalId,
|
|
60
|
+
tenantId: t.user.tenantId,
|
|
61
|
+
...(t.user.email !== undefined && { email: t.user.email }),
|
|
62
|
+
emailVerified: t.user.emailVerified ?? false,
|
|
63
|
+
displayName: t.user.displayName,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|