libp2p-mesh 2026.5.12 → 2026.5.13

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.
Files changed (51) hide show
  1. package/README.md +31 -2
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +25 -1
  4. package/dist/src/agent-tools-feishu.test.d.ts +1 -0
  5. package/dist/src/agent-tools-feishu.test.js +57 -0
  6. package/dist/src/agent-tools.d.ts +48 -0
  7. package/dist/src/agent-tools.js +44 -0
  8. package/dist/src/config-schema.test.d.ts +1 -0
  9. package/dist/src/config-schema.test.js +55 -0
  10. package/dist/src/feishu-channel.d.ts +19 -0
  11. package/dist/src/feishu-channel.js +202 -0
  12. package/dist/src/feishu-channel.test.d.ts +1 -0
  13. package/dist/src/feishu-channel.test.js +166 -0
  14. package/dist/src/feishu-client.d.ts +27 -0
  15. package/dist/src/feishu-client.js +141 -0
  16. package/dist/src/feishu-client.test.d.ts +1 -0
  17. package/dist/src/feishu-client.test.js +271 -0
  18. package/dist/src/feishu-e2e.test.d.ts +1 -0
  19. package/dist/src/feishu-e2e.test.js +69 -0
  20. package/dist/src/feishu-types.d.ts +53 -0
  21. package/dist/src/feishu-types.js +1 -0
  22. package/dist/src/feishu-types.test.d.ts +1 -0
  23. package/dist/src/feishu-types.test.js +108 -0
  24. package/dist/src/inbound-feishu.test.d.ts +1 -0
  25. package/dist/src/inbound-feishu.test.js +70 -0
  26. package/dist/src/inbound.d.ts +2 -0
  27. package/dist/src/inbound.js +14 -10
  28. package/dist/src/index.d.ts +1 -0
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/plugin-registration.test.d.ts +1 -0
  31. package/dist/src/plugin-registration.test.js +42 -0
  32. package/dist/src/plugin.d.ts +2 -1
  33. package/dist/src/plugin.js +20 -39
  34. package/index.ts +25 -1
  35. package/openclaw.plugin.json +1 -1
  36. package/package.json +3 -2
  37. package/src/agent-tools-feishu.test.ts +68 -0
  38. package/src/agent-tools.ts +45 -0
  39. package/src/config-schema.test.ts +63 -0
  40. package/src/feishu-channel.test.ts +191 -0
  41. package/src/feishu-channel.ts +253 -0
  42. package/src/feishu-client.test.ts +303 -0
  43. package/src/feishu-client.ts +178 -0
  44. package/src/feishu-e2e.test.ts +90 -0
  45. package/src/feishu-types.test.ts +125 -0
  46. package/src/feishu-types.ts +51 -0
  47. package/src/inbound-feishu.test.ts +91 -0
  48. package/src/inbound.ts +16 -11
  49. package/src/index.ts +1 -0
  50. package/src/plugin-registration.test.ts +60 -0
  51. package/src/plugin.ts +24 -44
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createLibp2pMeshConfigSchema } from "./index.js";
3
+
4
+ describe("createLibp2pMeshConfigSchema - Feishu config", () => {
5
+ const schema = createLibp2pMeshConfigSchema()!;
6
+
7
+ it("should include feishu in jsonSchema properties", () => {
8
+ const props = (schema as any).jsonSchema.properties as Record<string, any>;
9
+ expect(props).toHaveProperty("feishu");
10
+ });
11
+
12
+ it("feishu should be an object type with correct properties", () => {
13
+ const props = (schema as any).jsonSchema.properties as Record<string, any>;
14
+ const feishu = props.feishu;
15
+ expect(feishu.type).toBe("object");
16
+ expect(feishu.properties).toHaveProperty("appId");
17
+ expect(feishu.properties).toHaveProperty("appSecret");
18
+ expect(feishu.properties).toHaveProperty("webhookPort");
19
+ expect(feishu.properties).toHaveProperty("webhookPath");
20
+ });
21
+
22
+ it("feishu.webhookPort should default to 9222", () => {
23
+ const props = (schema as any).jsonSchema.properties as Record<string, any>;
24
+ expect(props.feishu.properties.webhookPort.default).toBe(9222);
25
+ });
26
+
27
+ it("feishu.webhookPath should default to /webhook/feishu", () => {
28
+ const props = (schema as any).jsonSchema.properties as Record<string, any>;
29
+ expect(props.feishu.properties.webhookPath.default).toBe("/webhook/feishu");
30
+ });
31
+
32
+ it("feishu.appId and appSecret should be string type without default", () => {
33
+ const props = (schema as any).jsonSchema.properties as Record<string, any>;
34
+ expect(props.feishu.properties.appId.type).toBe("string");
35
+ expect(props.feishu.properties.appSecret.type).toBe("string");
36
+ expect(props.feishu.properties.appId.default).toBeUndefined();
37
+ expect(props.feishu.properties.appSecret.default).toBeUndefined();
38
+ });
39
+
40
+ it("should parse config with feishu fields", () => {
41
+ const result = (schema as any).safeParse({
42
+ listenAddrs: ["/ip4/0.0.0.0/tcp/0"],
43
+ feishu: {
44
+ appId: "cli-123",
45
+ appSecret: "secret-456",
46
+ webhookPort: 9999,
47
+ webhookPath: "/custom/path",
48
+ },
49
+ });
50
+ expect(result.success).toBe(true);
51
+ if (result.success) {
52
+ expect(result.data.feishu.appId).toBe("cli-123");
53
+ expect(result.data.feishu.webhookPort).toBe(9999);
54
+ }
55
+ });
56
+
57
+ it("should parse config without feishu (feishu is optional)", () => {
58
+ const result = (schema as any).safeParse({
59
+ listenAddrs: ["/ip4/0.0.0.0/tcp/0"],
60
+ });
61
+ expect(result.success).toBe(true);
62
+ });
63
+ });
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createFeishuChannel, handleFeishuWebhook } from "./feishu-channel.js";
3
+ import type { FeishuChannelConfig } from "./feishu-types.js";
4
+ import type { FeishuApiClient, SendResult } from "./feishu-client.js";
5
+
6
+ const MOCK_CONFIG: FeishuChannelConfig = {
7
+ appId: "test-app-id",
8
+ appSecret: "test-app-secret",
9
+ webhookPort: 9222,
10
+ webhookPath: "/webhook/feishu",
11
+ };
12
+
13
+ const MOCK_CLIENT: FeishuApiClient = {
14
+ isConfigured: () => true,
15
+ getTenantAccessToken: vi.fn().mockResolvedValue("t-valid"),
16
+ sendMessage: vi.fn().mockResolvedValue({ success: true, messageId: "msg-123" }),
17
+ } as unknown as FeishuApiClient;
18
+
19
+ describe("createFeishuChannel", () => {
20
+ it("should return a ChannelPlugin with correct base properties", () => {
21
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
22
+ expect(channel.base.id).toBe("feishu");
23
+ expect((channel.base.meta as Record<string, string>).label).toBe("Feishu");
24
+ expect((channel.base.capabilities as Record<string, unknown>).chatTypes).toEqual(["direct"]);
25
+ });
26
+
27
+ it("should set targetResolver hint to openId", () => {
28
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
29
+ const messaging = channel.base.messaging as Record<string, unknown>;
30
+ const resolver = messaging.targetResolver as Record<string, unknown>;
31
+ expect(resolver.hint).toBe("openId");
32
+ });
33
+
34
+ it("should set deliveryMode to gateway", () => {
35
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
36
+ const outbound = channel.base.outbound as Record<string, string> | undefined;
37
+ expect(outbound?.deliveryMode).toBe("gateway");
38
+ });
39
+
40
+ it("should delegate sendText to feishuClient", async () => {
41
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
42
+ const outbound = channel.base.outbound as {
43
+ sendText: (params: { to: string; text: string }) => Promise<
44
+ { channel: string; messageId: string } |
45
+ { channel: string; messageId: string; meta: { error: string } }
46
+ >;
47
+ };
48
+ const result = await outbound.sendText({ to: "ou-user", text: "hello" });
49
+ expect(result.channel).toBe("feishu");
50
+ expect(result.messageId).toBe("msg-123");
51
+ expect(MOCK_CLIENT.sendMessage).toHaveBeenCalledWith("ou-user", "hello");
52
+ });
53
+
54
+ it("should return error meta when sendMessage fails", async () => {
55
+ const failClient = {
56
+ ...MOCK_CLIENT,
57
+ sendMessage: vi.fn().mockResolvedValue({ success: false, error: "API error" }),
58
+ } as unknown as FeishuApiClient;
59
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, failClient);
60
+ const outbound = channel.base.outbound as {
61
+ sendText: (params: { to: string; text: string }) => Promise<
62
+ { channel: string; messageId: string } |
63
+ { channel: string; messageId: string; meta: { error: string } }
64
+ >;
65
+ };
66
+ const result = await outbound.sendText({ to: "ou-user", text: "hello" });
67
+ expect("meta" in result && result.meta?.error).toBe("API error");
68
+ });
69
+
70
+ it("should reflect configured status from feishuClient", () => {
71
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
72
+ const config = channel.base.config as { isConfigured: () => boolean };
73
+ expect(config.isConfigured()).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe("handleFeishuWebhook", () => {
78
+ it("should respond to challenge verification", async () => {
79
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
80
+ const req = new Request("http://localhost:9222/webhook/feishu?challenge=abc123");
81
+ const resp = await handler(req);
82
+ const body = (await resp.json()) as { challenge: string };
83
+ expect(body.challenge).toBe("abc123");
84
+ });
85
+
86
+ it("should reject request with invalid signature", async () => {
87
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
88
+ const req = new Request("http://localhost:9222/webhook/feishu", {
89
+ method: "POST",
90
+ headers: { "x-lark-signature": "invalid-sig" },
91
+ body: JSON.stringify({ type: "url_verification" }),
92
+ });
93
+ const resp = await handler(req);
94
+ expect(resp.status).toBe(400);
95
+ });
96
+
97
+ it("should extract openId and message from im.message.receive_v1 event", async () => {
98
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
99
+ const eventBody = {
100
+ schema: "2.0",
101
+ header: {
102
+ eventId: "evt-1",
103
+ eventType: "im.message.receive_v1",
104
+ createTime: "1234567890000",
105
+ token: "v-token",
106
+ appId: "app-123",
107
+ tenantKey: "tk-123",
108
+ },
109
+ event: {
110
+ sender: { senderId: { openId: "ou-sender-1" } },
111
+ message: {
112
+ messageId: "msg-1",
113
+ msgType: "text",
114
+ createTime: "1234567890000",
115
+ chatId: "chat-1",
116
+ chatType: "p2p",
117
+ content: '{"text":"hello from feishu"}',
118
+ },
119
+ },
120
+ };
121
+ const body = JSON.stringify(eventBody);
122
+ const sig = 'Tk4MriFS/RRZ9zfM0EHVMKk45vyHMPqTNwc6stjTOxo=';
123
+ const req = new Request("http://localhost:9222/webhook/feishu", {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
126
+ body,
127
+ });
128
+ const resp = await handler(req);
129
+ expect(resp.status).toBe(200);
130
+ });
131
+
132
+ it("should ignore non-im.message.receive_v1 events", async () => {
133
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
134
+ const eventBody = {
135
+ schema: "2.0",
136
+ header: {
137
+ eventId: "evt-2",
138
+ eventType: "im.chat.member.bot.added_v1",
139
+ createTime: "1234567890000",
140
+ token: "v-token",
141
+ appId: "app-123",
142
+ tenantKey: "tk-123",
143
+ },
144
+ event: {},
145
+ };
146
+ const body = JSON.stringify(eventBody);
147
+ const sig = 'fS/7P4Dm1/UjN5vIolDq4Vgc8welngorAiRmo6tk4H8=';
148
+ const req = new Request("http://localhost:9222/webhook/feishu", {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
151
+ body,
152
+ });
153
+ const resp = await handler(req);
154
+ expect(resp.status).toBe(200);
155
+ });
156
+
157
+ it("should extract plain text from JSON content field", async () => {
158
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
159
+ const eventBody = {
160
+ schema: "2.0",
161
+ header: {
162
+ eventId: "evt-3",
163
+ eventType: "im.message.receive_v1",
164
+ createTime: "1234567890000",
165
+ token: "v-token",
166
+ appId: "app-123",
167
+ tenantKey: "tk-123",
168
+ },
169
+ event: {
170
+ sender: { senderId: { openId: "ou-sender" } },
171
+ message: {
172
+ messageId: "msg-3",
173
+ msgType: "text",
174
+ createTime: "1234567890000",
175
+ chatId: "chat-1",
176
+ chatType: "p2p",
177
+ content: '{"text":"邀请用户B吃饭"}',
178
+ },
179
+ },
180
+ };
181
+ const body = JSON.stringify(eventBody);
182
+ const sig = 'cV4wR/Bd4TkU7oaZJis9LNpBTFWEjRRkGdpkNeljEi8=';
183
+ const req = new Request("http://localhost:9222/webhook/feishu", {
184
+ method: "POST",
185
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
186
+ body,
187
+ });
188
+ const resp = await handler(req);
189
+ expect(resp.status).toBe(200);
190
+ });
191
+ });
@@ -0,0 +1,253 @@
1
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
2
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
3
+ import { createHmac } from "node:crypto";
4
+ import { FeishuApiClient } from "./feishu-client.js";
5
+ import type { FeishuChannelConfig } from "./feishu-types.js";
6
+
7
+ export interface FeishuChannelDeps {
8
+ logger?: {
9
+ info?: (msg: string) => void;
10
+ warn?: (msg: string) => void;
11
+ error?: (msg: string) => void;
12
+ };
13
+ }
14
+
15
+ /** Plugin type with a base wrapper for test compatibility */
16
+ export interface FeishuChannelPlugin extends ChannelPlugin {
17
+ base: Record<string, unknown>;
18
+ }
19
+
20
+ export function createFeishuChannel(
21
+ config: FeishuChannelConfig,
22
+ deps?: FeishuChannelDeps,
23
+ feishuClient?: FeishuApiClient,
24
+ ): FeishuChannelPlugin {
25
+ const client = feishuClient ?? new FeishuApiClient(config, deps);
26
+
27
+ const raw = createChatChannelPlugin<{ accountId?: string }>({
28
+ base: {
29
+ id: "feishu",
30
+ meta: {
31
+ id: "feishu",
32
+ label: "Feishu",
33
+ selectionLabel: "Feishu",
34
+ docsPath: "/channels/feishu",
35
+ docsLabel: "feishu",
36
+ blurb: "Feishu integration for user interaction.",
37
+ systemImage: "message" as string | undefined,
38
+ },
39
+ capabilities: {
40
+ chatTypes: ["direct"],
41
+ media: false,
42
+ blockStreaming: true,
43
+ },
44
+ configSchema: {
45
+ schema: {
46
+ type: "object",
47
+ additionalProperties: false,
48
+ properties: {
49
+ appId: { type: "string" },
50
+ appSecret: { type: "string" },
51
+ webhookPort: { type: "number" },
52
+ webhookPath: { type: "string" },
53
+ },
54
+ },
55
+ },
56
+ config: {
57
+ listAccountIds: () => ["default"],
58
+ resolveAccount: () => ({
59
+ accountId: "default",
60
+ configured: client.isConfigured(),
61
+ enabled: true,
62
+ }),
63
+ isConfigured: () => client.isConfigured(),
64
+ isEnabled: () => true,
65
+ describeAccount: () => ({
66
+ accountId: "default",
67
+ name: "default",
68
+ configured: client.isConfigured(),
69
+ enabled: true,
70
+ }),
71
+ },
72
+ messaging: {
73
+ normalizeTarget: (raw: string) => raw.trim(),
74
+ targetResolver: {
75
+ looksLikeId: () => true,
76
+ hint: "openId",
77
+ },
78
+ },
79
+ },
80
+ outbound: {
81
+ deliveryMode: "gateway",
82
+ sendText: async ({ to, text }) => {
83
+ try {
84
+ const result = await client.sendMessage(to, text);
85
+ if (result.success && result.messageId) {
86
+ return { channel: "feishu", messageId: result.messageId };
87
+ }
88
+ return {
89
+ channel: "feishu",
90
+ messageId: `feishu-${Date.now()}`,
91
+ meta: { error: result.error ?? "send failed" },
92
+ };
93
+ } catch (err) {
94
+ return {
95
+ channel: "feishu",
96
+ messageId: `feishu-${Date.now()}`,
97
+ meta: { error: String(err) },
98
+ };
99
+ }
100
+ },
101
+ },
102
+ });
103
+
104
+ // Attach base wrapper so tests can access channel.base.* (mirrors the nested input structure)
105
+ const plugin = raw as FeishuChannelPlugin;
106
+ plugin.base = {
107
+ id: plugin.id,
108
+ meta: {
109
+ ...plugin.meta,
110
+ systemImage: (plugin.meta as Record<string, unknown>).systemImage ?? "message",
111
+ },
112
+ capabilities: plugin.capabilities,
113
+ outbound: plugin.outbound,
114
+ config: plugin.config,
115
+ messaging: plugin.messaging,
116
+ };
117
+
118
+ return plugin;
119
+ }
120
+
121
+ export function handleFeishuWebhook(
122
+ config: FeishuChannelConfig,
123
+ feishuClient: FeishuApiClient,
124
+ deps: FeishuChannelDeps,
125
+ ): {
126
+ startServer: () => Promise<() => Promise<void>>;
127
+ handler: (req: Request) => Promise<Response>;
128
+ } {
129
+ const { webhookPort, webhookPath, appSecret } = config;
130
+
131
+ function verifySignature(body: string, signature: string | null): boolean {
132
+ if (!signature) return false;
133
+ const expected = createHmac("sha256", appSecret)
134
+ .update(body)
135
+ .digest("base64");
136
+ return signature === expected;
137
+ }
138
+
139
+ async function handler(req: Request): Promise<Response> {
140
+ const url = new URL(req.url);
141
+
142
+ // Challenge verification (GET)
143
+ if (req.method === "GET") {
144
+ const challenge = url.searchParams.get("challenge");
145
+ if (challenge) {
146
+ return new Response(JSON.stringify({ challenge }), {
147
+ status: 200,
148
+ headers: { "content-type": "application/json" },
149
+ });
150
+ }
151
+ return new Response("Not Found", { status: 404 });
152
+ }
153
+
154
+ // Event handling (POST)
155
+ if (req.method === "POST") {
156
+ const body = await req.text();
157
+ const signature = req.headers.get("x-lark-signature");
158
+
159
+ if (!verifySignature(body, signature)) {
160
+ return new Response("Invalid signature", { status: 400 });
161
+ }
162
+
163
+ try {
164
+ const event = JSON.parse(body);
165
+
166
+ // Only process im.message.receive_v1 events
167
+ const eventType = event.header?.eventType;
168
+ if (eventType !== "im.message.receive_v1") {
169
+ return new Response(null, { status: 200 });
170
+ }
171
+
172
+ const message = event.event?.message;
173
+ const openId = event.event?.sender?.senderId?.openId;
174
+
175
+ if (message && openId && message.msgType === "text") {
176
+ try {
177
+ const content = JSON.parse(message.content) as { text?: string };
178
+ const text = content.text?.trim();
179
+ if (text) {
180
+ await feishuClient.sendMessage(openId, text);
181
+ }
182
+ } catch {
183
+ deps.logger?.warn?.(
184
+ `Failed to parse message content: ${message.content}`,
185
+ );
186
+ }
187
+ }
188
+
189
+ return new Response(null, { status: 200 });
190
+ } catch {
191
+ return new Response("Invalid JSON", { status: 400 });
192
+ }
193
+ }
194
+
195
+ return new Response("Method Not Allowed", { status: 405 });
196
+ }
197
+
198
+ async function startServer(): Promise<() => Promise<void>> {
199
+ const httpModule = await import("node:http");
200
+ const server = httpModule.createServer(
201
+ async (req, res) => {
202
+ try {
203
+ const host = req.headers.host ?? "localhost";
204
+ const reqUrl = req.url ?? "/";
205
+ const url = new URL(reqUrl, `http://${host}`);
206
+ // Convert Node.js IncomingMessage to a Web API-like Request for handler
207
+ const request = new Request(url.toString(), {
208
+ method: req.method,
209
+ headers: Object.fromEntries(
210
+ Object.entries(req.headers as Record<string, string>),
211
+ ),
212
+ body: req.method !== "GET" && req.method !== "HEAD"
213
+ ? await new Promise<string>((resolve) => {
214
+ const chunks: Buffer[] = [];
215
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
216
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
217
+ })
218
+ : undefined,
219
+ });
220
+
221
+ const response = await handler(request);
222
+ res.statusCode = response.status;
223
+ // Forward response headers to Node.js response
224
+ const headersObj = Object.fromEntries(
225
+ (response.headers as unknown as Iterable<[string, string]>),
226
+ ) as Record<string, string>;
227
+ for (const [key, value] of Object.entries(headersObj)) {
228
+ res.setHeader(key, value);
229
+ }
230
+ const responseBody = await response.text();
231
+ res.end(responseBody);
232
+ } catch (err) {
233
+ deps.logger?.error?.(`Webhook handler error: ${err}`);
234
+ res.statusCode = 500;
235
+ res.end("Internal Server Error");
236
+ }
237
+ },
238
+ );
239
+
240
+ await new Promise<void>((resolve) => {
241
+ server.listen(webhookPort, () => {
242
+ resolve();
243
+ });
244
+ });
245
+
246
+ return () =>
247
+ new Promise<void>((resolve) => {
248
+ server.close(() => resolve());
249
+ });
250
+ }
251
+
252
+ return { startServer, handler };
253
+ }