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,166 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createFeishuChannel, handleFeishuWebhook } from "./feishu-channel.js";
3
+ const MOCK_CONFIG = {
4
+ appId: "test-app-id",
5
+ appSecret: "test-app-secret",
6
+ webhookPort: 9222,
7
+ webhookPath: "/webhook/feishu",
8
+ };
9
+ const MOCK_CLIENT = {
10
+ isConfigured: () => true,
11
+ getTenantAccessToken: vi.fn().mockResolvedValue("t-valid"),
12
+ sendMessage: vi.fn().mockResolvedValue({ success: true, messageId: "msg-123" }),
13
+ };
14
+ describe("createFeishuChannel", () => {
15
+ it("should return a ChannelPlugin with correct base properties", () => {
16
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
17
+ expect(channel.base.id).toBe("feishu");
18
+ expect(channel.base.meta.label).toBe("Feishu");
19
+ expect(channel.base.capabilities.chatTypes).toEqual(["direct"]);
20
+ });
21
+ it("should set targetResolver hint to openId", () => {
22
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
23
+ const messaging = channel.base.messaging;
24
+ const resolver = messaging.targetResolver;
25
+ expect(resolver.hint).toBe("openId");
26
+ });
27
+ it("should set deliveryMode to gateway", () => {
28
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
29
+ const outbound = channel.base.outbound;
30
+ expect(outbound?.deliveryMode).toBe("gateway");
31
+ });
32
+ it("should delegate sendText to feishuClient", async () => {
33
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
34
+ const outbound = channel.base.outbound;
35
+ const result = await outbound.sendText({ to: "ou-user", text: "hello" });
36
+ expect(result.channel).toBe("feishu");
37
+ expect(result.messageId).toBe("msg-123");
38
+ expect(MOCK_CLIENT.sendMessage).toHaveBeenCalledWith("ou-user", "hello");
39
+ });
40
+ it("should return error meta when sendMessage fails", async () => {
41
+ const failClient = {
42
+ ...MOCK_CLIENT,
43
+ sendMessage: vi.fn().mockResolvedValue({ success: false, error: "API error" }),
44
+ };
45
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, failClient);
46
+ const outbound = channel.base.outbound;
47
+ const result = await outbound.sendText({ to: "ou-user", text: "hello" });
48
+ expect("meta" in result && result.meta?.error).toBe("API error");
49
+ });
50
+ it("should reflect configured status from feishuClient", () => {
51
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
52
+ const config = channel.base.config;
53
+ expect(config.isConfigured()).toBe(true);
54
+ });
55
+ });
56
+ describe("handleFeishuWebhook", () => {
57
+ it("should respond to challenge verification", async () => {
58
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
59
+ const req = new Request("http://localhost:9222/webhook/feishu?challenge=abc123");
60
+ const resp = await handler(req);
61
+ const body = (await resp.json());
62
+ expect(body.challenge).toBe("abc123");
63
+ });
64
+ it("should reject request with invalid signature", async () => {
65
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
66
+ const req = new Request("http://localhost:9222/webhook/feishu", {
67
+ method: "POST",
68
+ headers: { "x-lark-signature": "invalid-sig" },
69
+ body: JSON.stringify({ type: "url_verification" }),
70
+ });
71
+ const resp = await handler(req);
72
+ expect(resp.status).toBe(400);
73
+ });
74
+ it("should extract openId and message from im.message.receive_v1 event", async () => {
75
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
76
+ const eventBody = {
77
+ schema: "2.0",
78
+ header: {
79
+ eventId: "evt-1",
80
+ eventType: "im.message.receive_v1",
81
+ createTime: "1234567890000",
82
+ token: "v-token",
83
+ appId: "app-123",
84
+ tenantKey: "tk-123",
85
+ },
86
+ event: {
87
+ sender: { senderId: { openId: "ou-sender-1" } },
88
+ message: {
89
+ messageId: "msg-1",
90
+ msgType: "text",
91
+ createTime: "1234567890000",
92
+ chatId: "chat-1",
93
+ chatType: "p2p",
94
+ content: '{"text":"hello from feishu"}',
95
+ },
96
+ },
97
+ };
98
+ const body = JSON.stringify(eventBody);
99
+ const sig = 'Tk4MriFS/RRZ9zfM0EHVMKk45vyHMPqTNwc6stjTOxo=';
100
+ const req = new Request("http://localhost:9222/webhook/feishu", {
101
+ method: "POST",
102
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
103
+ body,
104
+ });
105
+ const resp = await handler(req);
106
+ expect(resp.status).toBe(200);
107
+ });
108
+ it("should ignore non-im.message.receive_v1 events", async () => {
109
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
110
+ const eventBody = {
111
+ schema: "2.0",
112
+ header: {
113
+ eventId: "evt-2",
114
+ eventType: "im.chat.member.bot.added_v1",
115
+ createTime: "1234567890000",
116
+ token: "v-token",
117
+ appId: "app-123",
118
+ tenantKey: "tk-123",
119
+ },
120
+ event: {},
121
+ };
122
+ const body = JSON.stringify(eventBody);
123
+ const sig = 'fS/7P4Dm1/UjN5vIolDq4Vgc8welngorAiRmo6tk4H8=';
124
+ const req = new Request("http://localhost:9222/webhook/feishu", {
125
+ method: "POST",
126
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
127
+ body,
128
+ });
129
+ const resp = await handler(req);
130
+ expect(resp.status).toBe(200);
131
+ });
132
+ it("should extract plain text from JSON content field", async () => {
133
+ const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
134
+ const eventBody = {
135
+ schema: "2.0",
136
+ header: {
137
+ eventId: "evt-3",
138
+ eventType: "im.message.receive_v1",
139
+ createTime: "1234567890000",
140
+ token: "v-token",
141
+ appId: "app-123",
142
+ tenantKey: "tk-123",
143
+ },
144
+ event: {
145
+ sender: { senderId: { openId: "ou-sender" } },
146
+ message: {
147
+ messageId: "msg-3",
148
+ msgType: "text",
149
+ createTime: "1234567890000",
150
+ chatId: "chat-1",
151
+ chatType: "p2p",
152
+ content: '{"text":"邀请用户B吃饭"}',
153
+ },
154
+ },
155
+ };
156
+ const body = JSON.stringify(eventBody);
157
+ const sig = 'cV4wR/Bd4TkU7oaZJis9LNpBTFWEjRRkGdpkNeljEi8=';
158
+ const req = new Request("http://localhost:9222/webhook/feishu", {
159
+ method: "POST",
160
+ headers: { "content-type": "application/json", "x-lark-signature": sig },
161
+ body,
162
+ });
163
+ const resp = await handler(req);
164
+ expect(resp.status).toBe(200);
165
+ });
166
+ });
@@ -0,0 +1,27 @@
1
+ import type { FeishuChannelConfig } from "./feishu-types.js";
2
+ export interface FeishuApiClientDeps {
3
+ logger?: {
4
+ info?: (msg: string) => void;
5
+ warn?: (msg: string) => void;
6
+ error?: (msg: string) => void;
7
+ };
8
+ }
9
+ export interface SendResult {
10
+ success: boolean;
11
+ messageId?: string;
12
+ error?: string;
13
+ }
14
+ export declare class FeishuApiClient {
15
+ private config;
16
+ private logger?;
17
+ private cachedToken;
18
+ private cachedExpiresAt;
19
+ private tokenPromise;
20
+ constructor(config: FeishuChannelConfig, deps?: FeishuApiClientDeps);
21
+ isConfigured(): boolean;
22
+ getTenantAccessToken(): Promise<string>;
23
+ sendMessage(openId: string, text: string): Promise<SendResult>;
24
+ private fetchTenantAccessToken;
25
+ private doSendMessage;
26
+ private callSendApi;
27
+ }
@@ -0,0 +1,141 @@
1
+ const TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal";
2
+ const SEND_URL = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=openId";
3
+ const REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes
4
+ const REQUEST_TIMEOUT_MS = 10_000;
5
+ export class FeishuApiClient {
6
+ config;
7
+ logger;
8
+ cachedToken = null;
9
+ cachedExpiresAt = 0;
10
+ tokenPromise = null;
11
+ constructor(config, deps) {
12
+ this.config = config;
13
+ this.logger = deps?.logger;
14
+ }
15
+ isConfigured() {
16
+ return Boolean(this.config.appId && this.config.appSecret);
17
+ }
18
+ async getTenantAccessToken() {
19
+ if (!this.isConfigured()) {
20
+ throw new Error("Feishu client not configured: appId and appSecret required");
21
+ }
22
+ // Return cached token if still valid (with 5-minute margin)
23
+ if (this.cachedToken && Date.now() < this.cachedExpiresAt - REFRESH_MARGIN_MS) {
24
+ return this.cachedToken;
25
+ }
26
+ // Dedup concurrent requests
27
+ if (this.tokenPromise) {
28
+ return this.tokenPromise;
29
+ }
30
+ this.tokenPromise = this.fetchTenantAccessToken();
31
+ try {
32
+ return await this.tokenPromise;
33
+ }
34
+ finally {
35
+ this.tokenPromise = null;
36
+ }
37
+ }
38
+ async sendMessage(openId, text) {
39
+ if (!this.isConfigured()) {
40
+ return { success: false, error: "Feishu client not configured" };
41
+ }
42
+ try {
43
+ const token = await this.getTenantAccessToken();
44
+ return await this.doSendMessage(token, openId, text);
45
+ }
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ this.logger?.error?.(`sendMessage error: ${message}`);
49
+ return { success: false, error: message };
50
+ }
51
+ }
52
+ async fetchTenantAccessToken() {
53
+ try {
54
+ const response = await fetch(TOKEN_URL, {
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json" },
57
+ body: JSON.stringify({
58
+ app_id: this.config.appId,
59
+ app_secret: this.config.appSecret,
60
+ }),
61
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
62
+ });
63
+ if (!response.ok) {
64
+ throw new Error(`Token API returned status ${response.status}`);
65
+ }
66
+ const data = (await response.json());
67
+ if (data.code !== 0 || !data.tenant_access_token) {
68
+ throw new Error(`Token API error: code=${data.code}, msg=${data.msg}`);
69
+ }
70
+ this.cachedToken = data.tenant_access_token;
71
+ // expire is in seconds; convert to ms and set absolute expiry time
72
+ this.cachedExpiresAt = Date.now() + data.expire * 1000;
73
+ this.logger?.info?.(`Fetched new tenant_access_token, expires in ${data.expire}s`);
74
+ return this.cachedToken;
75
+ }
76
+ catch (err) {
77
+ // Clear stale cache on failure
78
+ this.cachedToken = null;
79
+ this.cachedExpiresAt = 0;
80
+ throw err;
81
+ }
82
+ }
83
+ async doSendMessage(token, openId, text) {
84
+ try {
85
+ const result = await this.callSendApi(token, openId, text);
86
+ // 99991663 = token invalid/expired — refresh and retry once
87
+ if (result.code === 99991663) {
88
+ this.logger?.warn?.("sendMessage got 99991663, refreshing token and retrying");
89
+ // Force token refresh by clearing cache
90
+ this.cachedToken = null;
91
+ this.cachedExpiresAt = 0;
92
+ const newToken = await this.getTenantAccessToken();
93
+ const retryResult = await this.callSendApi(newToken, openId, text);
94
+ if (retryResult.code !== 0) {
95
+ return {
96
+ success: false,
97
+ error: `Feishu API error after retry: code=${retryResult.code}, msg=${retryResult.msg}`,
98
+ };
99
+ }
100
+ return {
101
+ success: true,
102
+ messageId: retryResult.data?.messageId,
103
+ };
104
+ }
105
+ if (result.code !== 0) {
106
+ return {
107
+ success: false,
108
+ error: `Feishu API error: code=${result.code}, msg=${result.msg}`,
109
+ };
110
+ }
111
+ return {
112
+ success: true,
113
+ messageId: result.data?.messageId,
114
+ };
115
+ }
116
+ catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ this.logger?.error?.(`sendMessage API call failed: ${message}`);
119
+ return { success: false, error: message };
120
+ }
121
+ }
122
+ async callSendApi(token, openId, text) {
123
+ const response = await fetch(SEND_URL, {
124
+ method: "POST",
125
+ headers: {
126
+ "Content-Type": "application/json",
127
+ Authorization: `Bearer ${token}`,
128
+ },
129
+ body: JSON.stringify({
130
+ receive_id: openId,
131
+ msg_type: "text",
132
+ content: JSON.stringify({ text }),
133
+ }),
134
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
135
+ });
136
+ if (!response.ok) {
137
+ throw new Error(`Send message API returned status ${response.status}`);
138
+ }
139
+ return (await response.json());
140
+ }
141
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { FeishuApiClient } from "./feishu-client.js";
3
+ const MOCK_CONFIG = {
4
+ appId: "test-app-id",
5
+ appSecret: "test-app-secret",
6
+ webhookPort: 9222,
7
+ webhookPath: "/webhook/feishu",
8
+ };
9
+ describe("FeishuApiClient", () => {
10
+ let client;
11
+ beforeEach(() => {
12
+ client = new FeishuApiClient(MOCK_CONFIG);
13
+ });
14
+ describe("isConfigured", () => {
15
+ it("should return true when appId and appSecret are set", () => {
16
+ expect(client.isConfigured()).toBe(true);
17
+ });
18
+ it("should return false when appId is empty", () => {
19
+ const unconfigured = new FeishuApiClient({
20
+ ...MOCK_CONFIG,
21
+ appId: "",
22
+ });
23
+ expect(unconfigured.isConfigured()).toBe(false);
24
+ });
25
+ it("should return false when appSecret is empty", () => {
26
+ const unconfigured = new FeishuApiClient({
27
+ ...MOCK_CONFIG,
28
+ appSecret: "",
29
+ });
30
+ expect(unconfigured.isConfigured()).toBe(false);
31
+ });
32
+ });
33
+ describe("getTenantAccessToken", () => {
34
+ it("should fetch and cache tenant_access_token", async () => {
35
+ global.fetch = vi.fn().mockResolvedValue({
36
+ ok: true,
37
+ json: () => Promise.resolve({
38
+ code: 0,
39
+ tenant_access_token: "t-cached-token",
40
+ expire: 7200,
41
+ }),
42
+ });
43
+ const token = await client.getTenantAccessToken();
44
+ expect(token).toBe("t-cached-token");
45
+ expect(global.fetch).toHaveBeenCalledTimes(1);
46
+ });
47
+ it("should return cached token on second call without new API request", async () => {
48
+ global.fetch = vi.fn().mockResolvedValue({
49
+ ok: true,
50
+ json: () => Promise.resolve({
51
+ code: 0,
52
+ tenant_access_token: "t-cached-token",
53
+ expire: 7200,
54
+ }),
55
+ });
56
+ await client.getTenantAccessToken();
57
+ await client.getTenantAccessToken();
58
+ expect(global.fetch).toHaveBeenCalledTimes(1);
59
+ });
60
+ it("should refresh token when expired", async () => {
61
+ global.fetch = vi.fn()
62
+ .mockResolvedValueOnce({
63
+ ok: true,
64
+ json: () => Promise.resolve({
65
+ code: 0,
66
+ tenant_access_token: "t-first-token",
67
+ expire: 1,
68
+ }),
69
+ })
70
+ .mockResolvedValueOnce({
71
+ ok: true,
72
+ json: () => Promise.resolve({
73
+ code: 0,
74
+ tenant_access_token: "t-refreshed-token",
75
+ expire: 7200,
76
+ }),
77
+ });
78
+ const first = await client.getTenantAccessToken();
79
+ await new Promise((r) => setTimeout(r, 1100));
80
+ const second = await client.getTenantAccessToken();
81
+ expect(first).toBe("t-first-token");
82
+ expect(second).toBe("t-refreshed-token");
83
+ expect(global.fetch).toHaveBeenCalledTimes(2);
84
+ });
85
+ it("should refresh token when expiring within 5 minutes", async () => {
86
+ const now = Date.now();
87
+ global.fetch = vi.fn()
88
+ .mockResolvedValueOnce({
89
+ ok: true,
90
+ json: () => Promise.resolve({
91
+ code: 0,
92
+ tenant_access_token: "t-near-expiry",
93
+ expire: 300,
94
+ }),
95
+ })
96
+ .mockResolvedValueOnce({
97
+ ok: true,
98
+ json: () => Promise.resolve({
99
+ code: 0,
100
+ tenant_access_token: "t-refreshed-early",
101
+ expire: 7200,
102
+ }),
103
+ });
104
+ const first = await client.getTenantAccessToken();
105
+ // expire=300 means expiresAt = now + 300000ms.
106
+ // Since expiresAt - now = 300000 < 5*60*1000, next call should refresh.
107
+ const second = await client.getTenantAccessToken();
108
+ expect(first).toBe("t-near-expiry");
109
+ expect(second).toBe("t-refreshed-early");
110
+ expect(global.fetch).toHaveBeenCalledTimes(2);
111
+ });
112
+ it("should handle token API failure gracefully", async () => {
113
+ global.fetch = vi.fn().mockResolvedValue({
114
+ ok: false,
115
+ status: 500,
116
+ });
117
+ await expect(client.getTenantAccessToken()).rejects.toThrow();
118
+ });
119
+ it("should deduplicate concurrent token requests", async () => {
120
+ global.fetch = vi.fn().mockResolvedValue({
121
+ ok: true,
122
+ json: () => Promise.resolve({
123
+ code: 0,
124
+ tenant_access_token: "t-dedup-token",
125
+ expire: 7200,
126
+ }),
127
+ });
128
+ const [t1, t2] = await Promise.all([
129
+ client.getTenantAccessToken(),
130
+ client.getTenantAccessToken(),
131
+ ]);
132
+ expect(t1).toBe("t-dedup-token");
133
+ expect(t2).toBe("t-dedup-token");
134
+ expect(global.fetch).toHaveBeenCalledTimes(1);
135
+ });
136
+ });
137
+ describe("sendMessage", () => {
138
+ it("should send message and return messageId", async () => {
139
+ global.fetch = vi.fn()
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ json: () => Promise.resolve({
143
+ code: 0,
144
+ tenant_access_token: "t-valid",
145
+ expire: 7200,
146
+ }),
147
+ })
148
+ .mockResolvedValueOnce({
149
+ ok: true,
150
+ json: () => Promise.resolve({
151
+ code: 0,
152
+ data: { messageId: "msg-sent-456" },
153
+ }),
154
+ });
155
+ const result = await client.sendMessage("ou-recipient", "hello world");
156
+ expect(result.success).toBe(true);
157
+ expect(result.messageId).toBe("msg-sent-456");
158
+ });
159
+ it("should return error result on API failure", async () => {
160
+ global.fetch = vi.fn().mockResolvedValue({
161
+ ok: true,
162
+ json: () => Promise.resolve({
163
+ code: 99991663,
164
+ msg: "token invalid",
165
+ }),
166
+ });
167
+ const result = await client.sendMessage("ou-recipient", "hello");
168
+ expect(result.success).toBe(false);
169
+ expect(result.error).toBeDefined();
170
+ });
171
+ it("should retry once after token refresh on 99991663 error", async () => {
172
+ global.fetch = vi.fn()
173
+ .mockResolvedValueOnce({
174
+ ok: true,
175
+ json: () => Promise.resolve({
176
+ code: 0,
177
+ tenant_access_token: "t-expired",
178
+ expire: 7200,
179
+ }),
180
+ })
181
+ .mockResolvedValueOnce({
182
+ ok: true,
183
+ json: () => Promise.resolve({
184
+ code: 99991663,
185
+ msg: "token invalid",
186
+ data: { messageId: "" },
187
+ }),
188
+ })
189
+ .mockResolvedValueOnce({
190
+ ok: true,
191
+ json: () => Promise.resolve({
192
+ code: 0,
193
+ tenant_access_token: "t-new-token",
194
+ expire: 7200,
195
+ }),
196
+ })
197
+ .mockResolvedValueOnce({
198
+ ok: true,
199
+ json: () => Promise.resolve({
200
+ code: 0,
201
+ data: { messageId: "msg-retry-success" },
202
+ }),
203
+ });
204
+ const result = await client.sendMessage("ou-recipient", "hello");
205
+ expect(result.success).toBe(true);
206
+ expect(result.messageId).toBe("msg-retry-success");
207
+ });
208
+ it("should handle network errors gracefully", async () => {
209
+ global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
210
+ const result = await client.sendMessage("ou-recipient", "hello");
211
+ expect(result.success).toBe(false);
212
+ expect(result.error).toContain("ECONNREFUSED");
213
+ });
214
+ it("should serialize content as JSON string", async () => {
215
+ global.fetch = vi.fn()
216
+ .mockResolvedValueOnce({
217
+ ok: true,
218
+ json: () => Promise.resolve({
219
+ code: 0,
220
+ tenant_access_token: "t-valid",
221
+ expire: 7200,
222
+ }),
223
+ })
224
+ .mockResolvedValueOnce({
225
+ ok: true,
226
+ json: () => Promise.resolve({
227
+ code: 0,
228
+ data: { message_id: "msg-json-test" },
229
+ }),
230
+ });
231
+ await client.sendMessage("ou-recipient", "plain text");
232
+ const secondCall = global.fetch.mock.calls[1];
233
+ const body = JSON.parse(secondCall[1].body);
234
+ expect(body.content).toBe(JSON.stringify({ text: "plain text" }));
235
+ });
236
+ it("should use Authorization Bearer header with token", async () => {
237
+ global.fetch = vi.fn()
238
+ .mockResolvedValueOnce({
239
+ ok: true,
240
+ json: () => Promise.resolve({
241
+ code: 0,
242
+ tenant_access_token: "t-header-test",
243
+ expire: 7200,
244
+ }),
245
+ })
246
+ .mockResolvedValueOnce({
247
+ ok: true,
248
+ json: () => Promise.resolve({
249
+ code: 0,
250
+ data: { message_id: "msg-header-test" },
251
+ }),
252
+ });
253
+ await client.sendMessage("ou-recipient", "hello");
254
+ const secondCall = global.fetch.mock.calls[1];
255
+ expect(secondCall[1].headers.Authorization).toBe("Bearer t-header-test");
256
+ });
257
+ it("should not retry on non-99991663 API error", async () => {
258
+ global.fetch = vi.fn().mockResolvedValue({
259
+ ok: true,
260
+ json: () => Promise.resolve({
261
+ code: 99991400,
262
+ msg: "invalid parameter",
263
+ }),
264
+ });
265
+ const result = await client.sendMessage("ou-recipient", "hello");
266
+ expect(result.success).toBe(false);
267
+ expect(result.error).toContain("invalid parameter");
268
+ expect(global.fetch).toHaveBeenCalledTimes(1);
269
+ });
270
+ });
271
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createFeishuChannel } from "./feishu-channel.js";
3
+ import { buildFeishuTools } from "./agent-tools.js";
4
+ import { handleP2PInbound } from "./inbound.js";
5
+ const MOCK_CONFIG = {
6
+ appId: "cli-e2e-test",
7
+ appSecret: "secret-e2e",
8
+ webhookPort: 9222,
9
+ webhookPath: "/webhook/feishu",
10
+ };
11
+ describe("Feishu E2E integration", () => {
12
+ let mockClient;
13
+ beforeEach(() => {
14
+ mockClient = {
15
+ isConfigured: () => true,
16
+ getTenantAccessToken: vi.fn().mockResolvedValue("t-e2e"),
17
+ sendMessage: vi.fn(),
18
+ };
19
+ });
20
+ it("should handle full flow: channel -> agent tool -> Feishu API", async () => {
21
+ mockClient.sendMessage.mockResolvedValueOnce({
22
+ success: true,
23
+ messageId: "msg-agent-sent",
24
+ });
25
+ // 1. Create channel and verify it is configured
26
+ const channel = createFeishuChannel(MOCK_CONFIG, {}, mockClient);
27
+ expect(channel.base.config.isConfigured()).toBe(true);
28
+ // 2. Agent tool is available
29
+ const tools = buildFeishuTools(mockClient);
30
+ expect(tools).toHaveLength(1);
31
+ // 3. Agent sends message via tool
32
+ const toolResult = await tools[0].execute("call-1", {
33
+ openId: "ou-e2e-recipient",
34
+ message: "hello from agent",
35
+ });
36
+ expect(toolResult.isError).toBeUndefined();
37
+ expect(toolResult.details.sent).toBe(true);
38
+ expect(mockClient.sendMessage).toHaveBeenCalledWith("ou-e2e-recipient", "hello from agent");
39
+ });
40
+ it("should forward P2P message to Feishu via inbound handler", async () => {
41
+ mockClient.sendMessage.mockResolvedValue({ success: true });
42
+ const deps = {
43
+ feishuClient: mockClient,
44
+ };
45
+ const p2pMsg = {
46
+ id: "p2p-msg-1",
47
+ type: "direct",
48
+ from: "peer-x",
49
+ payload: "用户A邀请你吃饭",
50
+ timestamp: Date.now(),
51
+ };
52
+ handleP2PInbound(p2pMsg, deps);
53
+ await vi.waitFor(() => expect(mockClient.sendMessage).toHaveBeenCalledTimes(1));
54
+ expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.any(String), "用户A邀请你吃饭");
55
+ });
56
+ it("should handle broadcast messages in inbound forwarding", async () => {
57
+ mockClient.sendMessage.mockResolvedValue({ success: true });
58
+ handleP2PInbound({
59
+ id: "bc-1",
60
+ type: "broadcast",
61
+ from: "peer-y",
62
+ topic: "announcements",
63
+ payload: "系统维护通知",
64
+ timestamp: Date.now(),
65
+ }, { feishuClient: mockClient });
66
+ await vi.waitFor(() => expect(mockClient.sendMessage).toHaveBeenCalledTimes(1));
67
+ expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.any(String), "系统维护通知");
68
+ });
69
+ });