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