@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.15
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 +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +32 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/nextclaw-sdk/account-id.ts +31 -0
- package/src/nextclaw-sdk/compat.ts +8 -0
- package/src/nextclaw-sdk/core-channel.ts +296 -0
- package/src/nextclaw-sdk/core-pairing.ts +224 -0
- package/src/nextclaw-sdk/core.ts +26 -0
- package/src/nextclaw-sdk/dedupe.ts +246 -0
- package/src/nextclaw-sdk/feishu.ts +77 -0
- package/src/nextclaw-sdk/history.ts +127 -0
- package/src/nextclaw-sdk/network-body.ts +245 -0
- package/src/nextclaw-sdk/network-fetch.ts +129 -0
- package/src/nextclaw-sdk/network-webhook.ts +182 -0
- package/src/nextclaw-sdk/network.ts +13 -0
- package/src/nextclaw-sdk/runtime-store.ts +26 -0
- package/src/nextclaw-sdk/secrets-config.ts +109 -0
- package/src/nextclaw-sdk/secrets-core.ts +170 -0
- package/src/nextclaw-sdk/secrets-prompt.ts +305 -0
- package/src/nextclaw-sdk/secrets.ts +18 -0
- package/src/nextclaw-sdk/types.ts +300 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
|
|
10
|
+
vi.mock("./media.js", () => ({
|
|
11
|
+
sendMediaFeishu: sendMediaFeishuMock,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("./send.js", () => ({
|
|
15
|
+
sendMessageFeishu: sendMessageFeishuMock,
|
|
16
|
+
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("./runtime.js", () => ({
|
|
20
|
+
getFeishuRuntime: () => ({
|
|
21
|
+
channel: {
|
|
22
|
+
text: {
|
|
23
|
+
chunkMarkdownText: (text: string) => [text],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import { feishuOutbound } from "./outbound.js";
|
|
30
|
+
const sendText = feishuOutbound.sendText!;
|
|
31
|
+
|
|
32
|
+
function resetOutboundMocks() {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
|
35
|
+
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
|
36
|
+
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("feishuOutbound.sendText local-image auto-convert", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
resetOutboundMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
|
|
45
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-"));
|
|
46
|
+
const file = path.join(dir, `sample${ext}`);
|
|
47
|
+
await fs.writeFile(file, "image-data");
|
|
48
|
+
return { dir, file };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it("sends an absolute existing local image path as media", async () => {
|
|
52
|
+
const { dir, file } = await createTmpImage();
|
|
53
|
+
try {
|
|
54
|
+
const result = await sendText({
|
|
55
|
+
cfg: {} as any,
|
|
56
|
+
to: "chat_1",
|
|
57
|
+
text: file,
|
|
58
|
+
accountId: "main",
|
|
59
|
+
mediaLocalRoots: [dir],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
to: "chat_1",
|
|
65
|
+
mediaUrl: file,
|
|
66
|
+
accountId: "main",
|
|
67
|
+
mediaLocalRoots: [dir],
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
71
|
+
expect(result).toEqual(
|
|
72
|
+
expect.objectContaining({ channel: "feishu", messageId: "media_msg" }),
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps non-path text on the text-send path", async () => {
|
|
80
|
+
await sendText({
|
|
81
|
+
cfg: {} as any,
|
|
82
|
+
to: "chat_1",
|
|
83
|
+
text: "please upload /tmp/example.png",
|
|
84
|
+
accountId: "main",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
|
88
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
to: "chat_1",
|
|
91
|
+
text: "please upload /tmp/example.png",
|
|
92
|
+
accountId: "main",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back to plain text if local-image media send fails", async () => {
|
|
98
|
+
const { dir, file } = await createTmpImage();
|
|
99
|
+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
|
|
100
|
+
try {
|
|
101
|
+
await sendText({
|
|
102
|
+
cfg: {} as any,
|
|
103
|
+
to: "chat_1",
|
|
104
|
+
text: file,
|
|
105
|
+
accountId: "main",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
to: "chat_1",
|
|
112
|
+
text: file,
|
|
113
|
+
accountId: "main",
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("uses markdown cards when renderMode=card", async () => {
|
|
122
|
+
const result = await sendText({
|
|
123
|
+
cfg: {
|
|
124
|
+
channels: {
|
|
125
|
+
feishu: {
|
|
126
|
+
renderMode: "card",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
} as any,
|
|
130
|
+
to: "chat_1",
|
|
131
|
+
text: "| a | b |\n| - | - |",
|
|
132
|
+
accountId: "main",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
to: "chat_1",
|
|
138
|
+
text: "| a | b |\n| - | - |",
|
|
139
|
+
accountId: "main",
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
143
|
+
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("forwards replyToId as replyToMessageId on sendText", async () => {
|
|
147
|
+
await sendText({
|
|
148
|
+
cfg: {} as any,
|
|
149
|
+
to: "chat_1",
|
|
150
|
+
text: "hello",
|
|
151
|
+
replyToId: "om_reply_1",
|
|
152
|
+
accountId: "main",
|
|
153
|
+
} as any);
|
|
154
|
+
|
|
155
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
156
|
+
expect.objectContaining({
|
|
157
|
+
to: "chat_1",
|
|
158
|
+
text: "hello",
|
|
159
|
+
replyToMessageId: "om_reply_1",
|
|
160
|
+
accountId: "main",
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("falls back to threadId when replyToId is empty on sendText", async () => {
|
|
166
|
+
await sendText({
|
|
167
|
+
cfg: {} as any,
|
|
168
|
+
to: "chat_1",
|
|
169
|
+
text: "hello",
|
|
170
|
+
replyToId: " ",
|
|
171
|
+
threadId: "om_thread_2",
|
|
172
|
+
accountId: "main",
|
|
173
|
+
} as any);
|
|
174
|
+
|
|
175
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
to: "chat_1",
|
|
178
|
+
text: "hello",
|
|
179
|
+
replyToMessageId: "om_thread_2",
|
|
180
|
+
accountId: "main",
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("feishuOutbound.sendText replyToId forwarding", () => {
|
|
187
|
+
beforeEach(() => {
|
|
188
|
+
resetOutboundMocks();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
|
|
192
|
+
await sendText({
|
|
193
|
+
cfg: {} as any,
|
|
194
|
+
to: "chat_1",
|
|
195
|
+
text: "hello",
|
|
196
|
+
replyToId: "om_reply_target",
|
|
197
|
+
accountId: "main",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
to: "chat_1",
|
|
203
|
+
text: "hello",
|
|
204
|
+
replyToMessageId: "om_reply_target",
|
|
205
|
+
accountId: "main",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
|
|
211
|
+
await sendText({
|
|
212
|
+
cfg: {
|
|
213
|
+
channels: {
|
|
214
|
+
feishu: {
|
|
215
|
+
renderMode: "card",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
} as any,
|
|
219
|
+
to: "chat_1",
|
|
220
|
+
text: "```code```",
|
|
221
|
+
replyToId: "om_reply_target",
|
|
222
|
+
accountId: "main",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
226
|
+
expect.objectContaining({
|
|
227
|
+
replyToMessageId: "om_reply_target",
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not pass replyToMessageId when replyToId is absent", async () => {
|
|
233
|
+
await sendText({
|
|
234
|
+
cfg: {} as any,
|
|
235
|
+
to: "chat_1",
|
|
236
|
+
text: "hello",
|
|
237
|
+
accountId: "main",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
241
|
+
expect.objectContaining({
|
|
242
|
+
to: "chat_1",
|
|
243
|
+
text: "hello",
|
|
244
|
+
accountId: "main",
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
expect(sendMessageFeishuMock.mock.calls[0][0].replyToMessageId).toBeUndefined();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("feishuOutbound.sendMedia replyToId forwarding", () => {
|
|
252
|
+
beforeEach(() => {
|
|
253
|
+
resetOutboundMocks();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("forwards replyToId to sendMediaFeishu", async () => {
|
|
257
|
+
await feishuOutbound.sendMedia?.({
|
|
258
|
+
cfg: {} as any,
|
|
259
|
+
to: "chat_1",
|
|
260
|
+
text: "",
|
|
261
|
+
mediaUrl: "https://example.com/image.png",
|
|
262
|
+
replyToId: "om_reply_target",
|
|
263
|
+
accountId: "main",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
267
|
+
expect.objectContaining({
|
|
268
|
+
replyToMessageId: "om_reply_target",
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("forwards replyToId to text caption send", async () => {
|
|
274
|
+
await feishuOutbound.sendMedia?.({
|
|
275
|
+
cfg: {} as any,
|
|
276
|
+
to: "chat_1",
|
|
277
|
+
text: "caption text",
|
|
278
|
+
mediaUrl: "https://example.com/image.png",
|
|
279
|
+
replyToId: "om_reply_target",
|
|
280
|
+
accountId: "main",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
284
|
+
expect.objectContaining({
|
|
285
|
+
replyToMessageId: "om_reply_target",
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("feishuOutbound.sendMedia renderMode", () => {
|
|
292
|
+
beforeEach(() => {
|
|
293
|
+
resetOutboundMocks();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("uses markdown cards for captions when renderMode=card", async () => {
|
|
297
|
+
const result = await feishuOutbound.sendMedia?.({
|
|
298
|
+
cfg: {
|
|
299
|
+
channels: {
|
|
300
|
+
feishu: {
|
|
301
|
+
renderMode: "card",
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
} as any,
|
|
305
|
+
to: "chat_1",
|
|
306
|
+
text: "| a | b |\n| - | - |",
|
|
307
|
+
mediaUrl: "https://example.com/image.png",
|
|
308
|
+
accountId: "main",
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
312
|
+
expect.objectContaining({
|
|
313
|
+
to: "chat_1",
|
|
314
|
+
text: "| a | b |\n| - | - |",
|
|
315
|
+
accountId: "main",
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
319
|
+
expect.objectContaining({
|
|
320
|
+
to: "chat_1",
|
|
321
|
+
mediaUrl: "https://example.com/image.png",
|
|
322
|
+
accountId: "main",
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
326
|
+
expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("uses threadId fallback as replyToMessageId on sendMedia", async () => {
|
|
330
|
+
await feishuOutbound.sendMedia?.({
|
|
331
|
+
cfg: {} as any,
|
|
332
|
+
to: "chat_1",
|
|
333
|
+
text: "caption",
|
|
334
|
+
mediaUrl: "https://example.com/image.png",
|
|
335
|
+
threadId: "om_thread_1",
|
|
336
|
+
accountId: "main",
|
|
337
|
+
} as any);
|
|
338
|
+
|
|
339
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
340
|
+
expect.objectContaining({
|
|
341
|
+
to: "chat_1",
|
|
342
|
+
mediaUrl: "https://example.com/image.png",
|
|
343
|
+
replyToMessageId: "om_thread_1",
|
|
344
|
+
accountId: "main",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
348
|
+
expect.objectContaining({
|
|
349
|
+
to: "chat_1",
|
|
350
|
+
text: "caption",
|
|
351
|
+
replyToMessageId: "om_thread_1",
|
|
352
|
+
accountId: "main",
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import type { ChannelOutboundAdapter } from "./nextclaw-sdk/feishu.js";
|
|
4
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
5
|
+
import { sendMediaFeishu } from "./media.js";
|
|
6
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
7
|
+
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
|
8
|
+
|
|
9
|
+
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
|
10
|
+
const raw = text?.trim();
|
|
11
|
+
if (!raw) return null;
|
|
12
|
+
|
|
13
|
+
// Only auto-convert when the message is a pure path-like payload.
|
|
14
|
+
// Avoid converting regular sentences that merely contain a path.
|
|
15
|
+
const hasWhitespace = /\s/.test(raw);
|
|
16
|
+
if (hasWhitespace) return null;
|
|
17
|
+
|
|
18
|
+
// Ignore links/data URLs; those should stay in normal mediaUrl/text paths.
|
|
19
|
+
if (/^(https?:\/\/|data:|file:\/\/)/i.test(raw)) return null;
|
|
20
|
+
|
|
21
|
+
const ext = path.extname(raw).toLowerCase();
|
|
22
|
+
const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
|
|
23
|
+
ext,
|
|
24
|
+
);
|
|
25
|
+
if (!isImageExt) return null;
|
|
26
|
+
|
|
27
|
+
if (!path.isAbsolute(raw)) return null;
|
|
28
|
+
if (!fs.existsSync(raw)) return null;
|
|
29
|
+
|
|
30
|
+
// Fix race condition: wrap statSync in try-catch to handle file deletion
|
|
31
|
+
// between existsSync and statSync
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.statSync(raw).isFile()) return null;
|
|
34
|
+
} catch {
|
|
35
|
+
// File may have been deleted or became inaccessible between checks
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return raw;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shouldUseCard(text: string): boolean {
|
|
43
|
+
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveReplyToMessageId(params: {
|
|
47
|
+
replyToId?: string | null;
|
|
48
|
+
threadId?: string | number | null;
|
|
49
|
+
}): string | undefined {
|
|
50
|
+
const replyToId = params.replyToId?.trim();
|
|
51
|
+
if (replyToId) {
|
|
52
|
+
return replyToId;
|
|
53
|
+
}
|
|
54
|
+
if (params.threadId == null) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const trimmed = String(params.threadId).trim();
|
|
58
|
+
return trimmed || undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function sendOutboundText(params: {
|
|
62
|
+
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
|
|
63
|
+
to: string;
|
|
64
|
+
text: string;
|
|
65
|
+
replyToMessageId?: string;
|
|
66
|
+
accountId?: string;
|
|
67
|
+
}) {
|
|
68
|
+
const { cfg, to, text, accountId, replyToMessageId } = params;
|
|
69
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
70
|
+
const renderMode = account.config?.renderMode ?? "auto";
|
|
71
|
+
|
|
72
|
+
if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) {
|
|
73
|
+
return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const feishuOutbound: ChannelOutboundAdapter = {
|
|
80
|
+
deliveryMode: "direct",
|
|
81
|
+
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
82
|
+
chunkerMode: "markdown",
|
|
83
|
+
textChunkLimit: 4000,
|
|
84
|
+
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
|
|
85
|
+
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
|
86
|
+
// Scheme A compatibility shim:
|
|
87
|
+
// when upstream accidentally returns a local image path as plain text,
|
|
88
|
+
// auto-upload and send as Feishu image message instead of leaking path text.
|
|
89
|
+
const localImagePath = normalizePossibleLocalImagePath(text);
|
|
90
|
+
if (localImagePath) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await sendMediaFeishu({
|
|
93
|
+
cfg,
|
|
94
|
+
to,
|
|
95
|
+
mediaUrl: localImagePath,
|
|
96
|
+
accountId: accountId ?? undefined,
|
|
97
|
+
replyToMessageId,
|
|
98
|
+
mediaLocalRoots,
|
|
99
|
+
});
|
|
100
|
+
return { channel: "feishu", ...result };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(`[feishu] local image path auto-send failed:`, err);
|
|
103
|
+
// fall through to plain text as last resort
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await sendOutboundText({
|
|
108
|
+
cfg,
|
|
109
|
+
to,
|
|
110
|
+
text,
|
|
111
|
+
accountId: accountId ?? undefined,
|
|
112
|
+
replyToMessageId,
|
|
113
|
+
});
|
|
114
|
+
return { channel: "feishu", ...result };
|
|
115
|
+
},
|
|
116
|
+
sendMedia: async ({
|
|
117
|
+
cfg,
|
|
118
|
+
to,
|
|
119
|
+
text,
|
|
120
|
+
mediaUrl,
|
|
121
|
+
accountId,
|
|
122
|
+
mediaLocalRoots,
|
|
123
|
+
replyToId,
|
|
124
|
+
threadId,
|
|
125
|
+
}) => {
|
|
126
|
+
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
|
127
|
+
// Send text first if provided
|
|
128
|
+
if (text?.trim()) {
|
|
129
|
+
await sendOutboundText({
|
|
130
|
+
cfg,
|
|
131
|
+
to,
|
|
132
|
+
text,
|
|
133
|
+
accountId: accountId ?? undefined,
|
|
134
|
+
replyToMessageId,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Upload and send media if URL or local path provided
|
|
139
|
+
if (mediaUrl) {
|
|
140
|
+
try {
|
|
141
|
+
const result = await sendMediaFeishu({
|
|
142
|
+
cfg,
|
|
143
|
+
to,
|
|
144
|
+
mediaUrl,
|
|
145
|
+
accountId: accountId ?? undefined,
|
|
146
|
+
mediaLocalRoots,
|
|
147
|
+
replyToMessageId,
|
|
148
|
+
});
|
|
149
|
+
return { channel: "feishu", ...result };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// Log the error for debugging
|
|
152
|
+
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
|
153
|
+
// Fallback to URL link if upload fails
|
|
154
|
+
const fallbackText = `📎 ${mediaUrl}`;
|
|
155
|
+
const result = await sendOutboundText({
|
|
156
|
+
cfg,
|
|
157
|
+
to,
|
|
158
|
+
text: fallbackText,
|
|
159
|
+
accountId: accountId ?? undefined,
|
|
160
|
+
replyToMessageId,
|
|
161
|
+
});
|
|
162
|
+
return { channel: "feishu", ...result };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// No media URL, just return text result
|
|
167
|
+
const result = await sendOutboundText({
|
|
168
|
+
cfg,
|
|
169
|
+
to,
|
|
170
|
+
text: text ?? "",
|
|
171
|
+
accountId: accountId ?? undefined,
|
|
172
|
+
replyToMessageId,
|
|
173
|
+
});
|
|
174
|
+
return { channel: "feishu", ...result };
|
|
175
|
+
},
|
|
176
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
const TokenType = Type.Union([
|
|
4
|
+
Type.Literal("doc"),
|
|
5
|
+
Type.Literal("docx"),
|
|
6
|
+
Type.Literal("sheet"),
|
|
7
|
+
Type.Literal("bitable"),
|
|
8
|
+
Type.Literal("folder"),
|
|
9
|
+
Type.Literal("file"),
|
|
10
|
+
Type.Literal("wiki"),
|
|
11
|
+
Type.Literal("mindnote"),
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const MemberType = Type.Union([
|
|
15
|
+
Type.Literal("email"),
|
|
16
|
+
Type.Literal("openid"),
|
|
17
|
+
Type.Literal("userid"),
|
|
18
|
+
Type.Literal("unionid"),
|
|
19
|
+
Type.Literal("openchat"),
|
|
20
|
+
Type.Literal("opendepartmentid"),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const Permission = Type.Union([
|
|
24
|
+
Type.Literal("view"),
|
|
25
|
+
Type.Literal("edit"),
|
|
26
|
+
Type.Literal("full_access"),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const FeishuPermSchema = Type.Union([
|
|
30
|
+
Type.Object({
|
|
31
|
+
action: Type.Literal("list"),
|
|
32
|
+
token: Type.String({ description: "File token" }),
|
|
33
|
+
type: TokenType,
|
|
34
|
+
}),
|
|
35
|
+
Type.Object({
|
|
36
|
+
action: Type.Literal("add"),
|
|
37
|
+
token: Type.String({ description: "File token" }),
|
|
38
|
+
type: TokenType,
|
|
39
|
+
member_type: MemberType,
|
|
40
|
+
member_id: Type.String({ description: "Member ID (email, open_id, user_id, etc.)" }),
|
|
41
|
+
perm: Permission,
|
|
42
|
+
}),
|
|
43
|
+
Type.Object({
|
|
44
|
+
action: Type.Literal("remove"),
|
|
45
|
+
token: Type.String({ description: "File token" }),
|
|
46
|
+
type: TokenType,
|
|
47
|
+
member_type: MemberType,
|
|
48
|
+
member_id: Type.String({ description: "Member ID to remove" }),
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export type FeishuPermParams = Static<typeof FeishuPermSchema>;
|