Channels: finish Feishu/Lark integration
parent
2483f26c23
commit
0223416c61
|
|
@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||||
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
|
||||||
- Docs: zh-CN translation polish + pipeline guidance. (#8202, #6995) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan.
|
- Docs: zh-CN translation polish + pipeline guidance. (#8202, #6995) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan.
|
||||||
- Docs: zh-CN translations seed + nav polish + landing notice + typo fix. (#6619, #7242, #7303, #7415) Thanks @joshp123, @lailoo.
|
- Docs: zh-CN translations seed + nav polish + landing notice + typo fix. (#6619, #7242, #7303, #7415) Thanks @joshp123, @lailoo.
|
||||||
|
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 224 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 404 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||||
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||||
|
import { feishuPlugin } from "./src/channel.js";
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
id: "feishu",
|
||||||
|
name: "Feishu",
|
||||||
|
description: "Feishu (Lark) channel plugin",
|
||||||
|
configSchema: emptyPluginConfigSchema(),
|
||||||
|
register(api: OpenClawPluginApi) {
|
||||||
|
api.registerChannel({ plugin: feishuPlugin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default plugin;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "feishu",
|
||||||
|
"channels": ["feishu"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,9 @@ importers:
|
||||||
'@homebridge/ciao':
|
'@homebridge/ciao':
|
||||||
specifier: ^1.3.4
|
specifier: ^1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@larksuiteoapi/node-sdk':
|
||||||
|
specifier: ^1.42.0
|
||||||
|
version: 1.58.0
|
||||||
'@line/bot-sdk':
|
'@line/bot-sdk':
|
||||||
specifier: ^10.6.0
|
specifier: ^10.6.0
|
||||||
version: 10.6.0
|
version: 10.6.0
|
||||||
|
|
@ -300,6 +303,12 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
|
extensions/feishu:
|
||||||
|
devDependencies:
|
||||||
|
openclaw:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/google-antigravity-auth:
|
extensions/google-antigravity-auth:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
openclaw:
|
||||||
|
|
@ -1329,6 +1338,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
apache-arrow: '>=15.0.0 <=18.1.0'
|
apache-arrow: '>=15.0.0 <=18.1.0'
|
||||||
|
|
||||||
|
'@larksuiteoapi/node-sdk@1.58.0':
|
||||||
|
resolution: {integrity: sha512-NcQNHdGuHOxOWY3bRGS9WldwpbR6+k7Fi0H1IJXDNNmbSrEB/8rLwqHRC8tAbbj/Mp8TWH/v1O+p487m6xskxw==}
|
||||||
|
|
||||||
'@line/bot-sdk@10.6.0':
|
'@line/bot-sdk@10.6.0':
|
||||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
@ -4177,6 +4189,9 @@ packages:
|
||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.identity@3.0.0:
|
||||||
|
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
|
||||||
|
|
||||||
lodash.includes@4.3.0:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
|
@ -4195,9 +4210,15 @@ packages:
|
||||||
lodash.isstring@4.0.1:
|
lodash.isstring@4.0.1:
|
||||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
|
lodash.merge@4.6.2:
|
||||||
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
lodash.once@4.1.1:
|
lodash.once@4.1.1:
|
||||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0:
|
||||||
|
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||||
|
|
||||||
lodash@4.17.23:
|
lodash@4.17.23:
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||||
|
|
||||||
|
|
@ -6649,6 +6670,20 @@ snapshots:
|
||||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||||
|
|
||||||
|
'@larksuiteoapi/node-sdk@1.58.0':
|
||||||
|
dependencies:
|
||||||
|
axios: 1.13.4(debug@4.4.3)
|
||||||
|
lodash.identity: 3.0.0
|
||||||
|
lodash.merge: 4.6.2
|
||||||
|
lodash.pickby: 4.6.0
|
||||||
|
protobufjs: 7.5.4
|
||||||
|
qs: 6.14.1
|
||||||
|
ws: 8.19.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- debug
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@line/bot-sdk@10.6.0':
|
'@line/bot-sdk@10.6.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.10.9
|
'@types/node': 24.10.9
|
||||||
|
|
@ -9774,6 +9809,8 @@ snapshots:
|
||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.identity@3.0.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
@ -9786,8 +9823,12 @@ snapshots:
|
||||||
|
|
||||||
lodash.isstring@4.0.1: {}
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.once@4.1.1: {}
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.pickby@4.6.0: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
|
|
||||||
log-symbols@6.0.0:
|
log-symbols@6.0.0:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function normalizeFeishuTarget(raw: string): string {
|
||||||
|
let normalized = raw.replace(/^(feishu|lark):/i, "").trim();
|
||||||
|
normalized = normalized.replace(/^(group|chat|user|dm):/i, "").trim();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { GroupPolicy } from "./types.base.js";
|
import type { GroupPolicy } from "./types.base.js";
|
||||||
import type { DiscordConfig } from "./types.discord.js";
|
import type { DiscordConfig } from "./types.discord.js";
|
||||||
|
import type { FeishuConfig } from "./types.feishu.js";
|
||||||
import type { GoogleChatConfig } from "./types.googlechat.js";
|
import type { GoogleChatConfig } from "./types.googlechat.js";
|
||||||
import type { IMessageConfig } from "./types.imessage.js";
|
import type { IMessageConfig } from "./types.imessage.js";
|
||||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||||
|
|
@ -28,6 +29,7 @@ export type ChannelsConfig = {
|
||||||
whatsapp?: WhatsAppConfig;
|
whatsapp?: WhatsAppConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
|
feishu?: FeishuConfig;
|
||||||
googlechat?: GoogleChatConfig;
|
googlechat?: GoogleChatConfig;
|
||||||
slack?: SlackConfig;
|
slack?: SlackConfig;
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export * from "./types.channels.js";
|
||||||
export * from "./types.openclaw.js";
|
export * from "./types.openclaw.js";
|
||||||
export * from "./types.cron.js";
|
export * from "./types.cron.js";
|
||||||
export * from "./types.discord.js";
|
export * from "./types.discord.js";
|
||||||
|
export * from "./types.feishu.js";
|
||||||
export * from "./types.googlechat.js";
|
export * from "./types.googlechat.js";
|
||||||
export * from "./types.gateway.js";
|
export * from "./types.gateway.js";
|
||||||
export * from "./types.hooks.js";
|
export * from "./types.hooks.js";
|
||||||
|
|
|
||||||
|
|
@ -53,166 +53,176 @@ beforeEach(() => {
|
||||||
__resetDiscordChannelInfoCacheForTest();
|
__resetDiscordChannelInfoCacheForTest();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
|
||||||
|
|
||||||
describe("discord tool result dispatch", () => {
|
describe("discord tool result dispatch", () => {
|
||||||
it("accepts guild messages when mentionPatterns match", async () => {
|
it(
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
"accepts guild messages when mentionPatterns match",
|
||||||
const cfg = {
|
async () => {
|
||||||
agents: {
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
defaults: {
|
const cfg = {
|
||||||
model: "anthropic/claude-opus-4-5",
|
agents: {
|
||||||
workspace: "/tmp/openclaw",
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: "/tmp/openclaw",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
session: { store: "/tmp/openclaw-sessions.json" },
|
||||||
session: { store: "/tmp/openclaw-sessions.json" },
|
channels: {
|
||||||
channels: {
|
discord: {
|
||||||
discord: {
|
dm: { enabled: true, policy: "open" },
|
||||||
dm: { enabled: true, policy: "open" },
|
groupPolicy: "open",
|
||||||
groupPolicy: "open",
|
guilds: { "*": { requireMention: true } },
|
||||||
guilds: { "*": { requireMention: true } },
|
},
|
||||||
},
|
},
|
||||||
},
|
messages: {
|
||||||
messages: {
|
responsePrefix: "PFX",
|
||||||
responsePrefix: "PFX",
|
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
||||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
|
||||||
},
|
|
||||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
|
||||||
|
|
||||||
const handler = createDiscordMessageHandler({
|
|
||||||
cfg,
|
|
||||||
discordConfig: cfg.channels.discord,
|
|
||||||
accountId: "default",
|
|
||||||
token: "token",
|
|
||||||
runtime: {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: (code: number): never => {
|
|
||||||
throw new Error(`exit ${code}`);
|
|
||||||
},
|
},
|
||||||
},
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
botUserId: "bot-id",
|
|
||||||
guildHistories: new Map(),
|
|
||||||
historyLimit: 0,
|
|
||||||
mediaMaxBytes: 10_000,
|
|
||||||
textLimit: 2000,
|
|
||||||
replyToMode: "off",
|
|
||||||
dmEnabled: true,
|
|
||||||
groupDmEnabled: false,
|
|
||||||
guildEntries: { "*": { requireMention: true } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = {
|
const handler = createDiscordMessageHandler({
|
||||||
fetchChannel: vi.fn().mockResolvedValue({
|
cfg,
|
||||||
type: ChannelType.GuildText,
|
discordConfig: cfg.channels.discord,
|
||||||
name: "general",
|
accountId: "default",
|
||||||
}),
|
token: "token",
|
||||||
} as unknown as Client;
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number): never => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
botUserId: "bot-id",
|
||||||
|
guildHistories: new Map(),
|
||||||
|
historyLimit: 0,
|
||||||
|
mediaMaxBytes: 10_000,
|
||||||
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
|
guildEntries: { "*": { requireMention: true } },
|
||||||
|
});
|
||||||
|
|
||||||
await handler(
|
const client = {
|
||||||
{
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
message: {
|
type: ChannelType.GuildText,
|
||||||
id: "m2",
|
name: "general",
|
||||||
content: "openclaw: hello",
|
}),
|
||||||
channelId: "c1",
|
} as unknown as Client;
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
type: MessageType.Default,
|
await handler(
|
||||||
attachments: [],
|
{
|
||||||
embeds: [],
|
message: {
|
||||||
mentionedEveryone: false,
|
id: "m2",
|
||||||
mentionedUsers: [],
|
content: "openclaw: hello",
|
||||||
mentionedRoles: [],
|
channelId: "c1",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
},
|
||||||
author: { id: "u1", bot: false, username: "Ada" },
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
member: { nickname: "Ada" },
|
||||||
|
guild: { id: "g1", name: "Guild" },
|
||||||
|
guild_id: "g1",
|
||||||
},
|
},
|
||||||
author: { id: "u1", bot: false, username: "Ada" },
|
client,
|
||||||
member: { nickname: "Ada" },
|
);
|
||||||
guild: { id: "g1", name: "Guild" },
|
|
||||||
guild_id: "g1",
|
|
||||||
},
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 20_000);
|
},
|
||||||
|
MENTION_PATTERNS_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
it("accepts guild messages when mentionPatterns match even if another user is mentioned", async () => {
|
it(
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
"accepts guild messages when mentionPatterns match even if another user is mentioned",
|
||||||
const cfg = {
|
async () => {
|
||||||
agents: {
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
defaults: {
|
const cfg = {
|
||||||
model: "anthropic/claude-opus-4-5",
|
agents: {
|
||||||
workspace: "/tmp/openclaw",
|
defaults: {
|
||||||
|
model: "anthropic/claude-opus-4-5",
|
||||||
|
workspace: "/tmp/openclaw",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
session: { store: "/tmp/openclaw-sessions.json" },
|
||||||
session: { store: "/tmp/openclaw-sessions.json" },
|
channels: {
|
||||||
channels: {
|
discord: {
|
||||||
discord: {
|
dm: { enabled: true, policy: "open" },
|
||||||
dm: { enabled: true, policy: "open" },
|
groupPolicy: "open",
|
||||||
groupPolicy: "open",
|
guilds: { "*": { requireMention: true } },
|
||||||
guilds: { "*": { requireMention: true } },
|
},
|
||||||
},
|
},
|
||||||
},
|
messages: {
|
||||||
messages: {
|
responsePrefix: "PFX",
|
||||||
responsePrefix: "PFX",
|
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
||||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
|
||||||
},
|
|
||||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
|
||||||
|
|
||||||
const handler = createDiscordMessageHandler({
|
|
||||||
cfg,
|
|
||||||
discordConfig: cfg.channels.discord,
|
|
||||||
accountId: "default",
|
|
||||||
token: "token",
|
|
||||||
runtime: {
|
|
||||||
log: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
exit: (code: number): never => {
|
|
||||||
throw new Error(`exit ${code}`);
|
|
||||||
},
|
},
|
||||||
},
|
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||||
botUserId: "bot-id",
|
|
||||||
guildHistories: new Map(),
|
|
||||||
historyLimit: 0,
|
|
||||||
mediaMaxBytes: 10_000,
|
|
||||||
textLimit: 2000,
|
|
||||||
replyToMode: "off",
|
|
||||||
dmEnabled: true,
|
|
||||||
groupDmEnabled: false,
|
|
||||||
guildEntries: { "*": { requireMention: true } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = {
|
const handler = createDiscordMessageHandler({
|
||||||
fetchChannel: vi.fn().mockResolvedValue({
|
cfg,
|
||||||
type: ChannelType.GuildText,
|
discordConfig: cfg.channels.discord,
|
||||||
name: "general",
|
accountId: "default",
|
||||||
}),
|
token: "token",
|
||||||
} as unknown as Client;
|
runtime: {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: (code: number): never => {
|
||||||
|
throw new Error(`exit ${code}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
botUserId: "bot-id",
|
||||||
|
guildHistories: new Map(),
|
||||||
|
historyLimit: 0,
|
||||||
|
mediaMaxBytes: 10_000,
|
||||||
|
textLimit: 2000,
|
||||||
|
replyToMode: "off",
|
||||||
|
dmEnabled: true,
|
||||||
|
groupDmEnabled: false,
|
||||||
|
guildEntries: { "*": { requireMention: true } },
|
||||||
|
});
|
||||||
|
|
||||||
await handler(
|
const client = {
|
||||||
{
|
fetchChannel: vi.fn().mockResolvedValue({
|
||||||
message: {
|
type: ChannelType.GuildText,
|
||||||
id: "m2",
|
name: "general",
|
||||||
content: "openclaw: hello",
|
}),
|
||||||
channelId: "c1",
|
} as unknown as Client;
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
type: MessageType.Default,
|
await handler(
|
||||||
attachments: [],
|
{
|
||||||
embeds: [],
|
message: {
|
||||||
mentionedEveryone: false,
|
id: "m2",
|
||||||
mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }],
|
content: "openclaw: hello",
|
||||||
mentionedRoles: [],
|
channelId: "c1",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type: MessageType.Default,
|
||||||
|
attachments: [],
|
||||||
|
embeds: [],
|
||||||
|
mentionedEveryone: false,
|
||||||
|
mentionedUsers: [{ id: "u2", bot: false, username: "Bea" }],
|
||||||
|
mentionedRoles: [],
|
||||||
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
},
|
||||||
author: { id: "u1", bot: false, username: "Ada" },
|
author: { id: "u1", bot: false, username: "Ada" },
|
||||||
|
member: { nickname: "Ada" },
|
||||||
|
guild: { id: "g1", name: "Guild" },
|
||||||
|
guild_id: "g1",
|
||||||
},
|
},
|
||||||
author: { id: "u1", bot: false, username: "Ada" },
|
client,
|
||||||
member: { nickname: "Ada" },
|
);
|
||||||
guild: { id: "g1", name: "Guild" },
|
|
||||||
guild_id: "g1",
|
|
||||||
},
|
|
||||||
client,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
expect(dispatchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||||
}, 20_000);
|
},
|
||||||
|
MENTION_PATTERNS_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
||||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { AllowlistMatch } from "../channels/allowlist-match.js";
|
||||||
|
|
||||||
|
export type NormalizedAllowFrom = {
|
||||||
|
entries: string[];
|
||||||
|
entriesLower: string[];
|
||||||
|
hasWildcard: boolean;
|
||||||
|
hasEntries: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AllowFromMatch = AllowlistMatch<"wildcard" | "id">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an allowlist for Feishu.
|
||||||
|
* Feishu IDs are open_id (ou_xxx) or union_id (on_xxx), no usernames.
|
||||||
|
*/
|
||||||
|
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||||
|
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
|
||||||
|
const hasWildcard = entries.includes("*");
|
||||||
|
// Strip optional "feishu:" prefix
|
||||||
|
const normalized = entries
|
||||||
|
.filter((value) => value !== "*")
|
||||||
|
.map((value) => value.replace(/^(feishu|lark):/i, ""));
|
||||||
|
const normalizedLower = normalized.map((value) => value.toLowerCase());
|
||||||
|
return {
|
||||||
|
entries: normalized,
|
||||||
|
entriesLower: normalizedLower,
|
||||||
|
hasWildcard,
|
||||||
|
hasEntries: entries.length > 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeAllowFromWithStore = (params: {
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
storeAllowFrom?: string[];
|
||||||
|
}): NormalizedAllowFrom => {
|
||||||
|
const combined = [...(params.allowFrom ?? []), ...(params.storeAllowFrom ?? [])]
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return normalizeAllowFrom(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const firstDefined = <T>(...values: Array<T | undefined>) => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value !== "undefined") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a sender is allowed based on the normalized allowlist.
|
||||||
|
* Feishu uses open_id (ou_xxx) or union_id (on_xxx) - no usernames.
|
||||||
|
*/
|
||||||
|
export const isSenderAllowed = (params: { allow: NormalizedAllowFrom; senderId?: string }) => {
|
||||||
|
const { allow, senderId } = params;
|
||||||
|
if (!allow.hasEntries) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (allow.hasWildcard) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (senderId && allow.entries.includes(senderId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check case-insensitive (though Feishu IDs are typically lowercase)
|
||||||
|
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSenderAllowMatch = (params: {
|
||||||
|
allow: NormalizedAllowFrom;
|
||||||
|
senderId?: string;
|
||||||
|
}): AllowFromMatch => {
|
||||||
|
const { allow, senderId } = params;
|
||||||
|
if (allow.hasWildcard) {
|
||||||
|
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||||
|
}
|
||||||
|
if (!allow.hasEntries) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
if (senderId && allow.entries.includes(senderId)) {
|
||||||
|
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||||
|
}
|
||||||
|
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
|
||||||
|
return { allowed: true, matchKey: senderId.toLowerCase(), matchSource: "id" };
|
||||||
|
}
|
||||||
|
return { allowed: false };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import type { FeishuAccountConfig } from "../config/types.feishu.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
|
|
||||||
|
export type FeishuTokenSource = "config" | "file" | "env" | "none";
|
||||||
|
|
||||||
|
export type ResolvedFeishuAccount = {
|
||||||
|
accountId: string;
|
||||||
|
config: FeishuAccountConfig;
|
||||||
|
tokenSource: FeishuTokenSource;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readFileIfExists(filePath?: string): string | undefined {
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountConfig(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
accountId: string,
|
||||||
|
): FeishuAccountConfig | undefined {
|
||||||
|
const accounts = cfg.channels?.feishu?.accounts;
|
||||||
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const direct = accounts[accountId] as FeishuAccountConfig | undefined;
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
const normalized = normalizeAccountId(accountId);
|
||||||
|
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
||||||
|
return matchKey ? (accounts[matchKey] as FeishuAccountConfig | undefined) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFeishuAccountConfig(cfg: OpenClawConfig, accountId: string): FeishuAccountConfig {
|
||||||
|
const { accounts: _ignored, ...base } = (cfg.channels?.feishu ?? {}) as FeishuAccountConfig & {
|
||||||
|
accounts?: unknown;
|
||||||
|
};
|
||||||
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
|
return { ...base, ...account };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAppSecret(config?: { appSecret?: string; appSecretFile?: string }): {
|
||||||
|
value?: string;
|
||||||
|
source?: Exclude<FeishuTokenSource, "env" | "none">;
|
||||||
|
} {
|
||||||
|
const direct = config?.appSecret?.trim();
|
||||||
|
if (direct) {
|
||||||
|
return { value: direct, source: "config" };
|
||||||
|
}
|
||||||
|
const fromFile = readFileIfExists(config?.appSecretFile);
|
||||||
|
if (fromFile) {
|
||||||
|
return { value: fromFile, source: "file" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFeishuAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
|
const feishuCfg = cfg.channels?.feishu;
|
||||||
|
const accounts = feishuCfg?.accounts;
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
const baseConfigured = Boolean(
|
||||||
|
feishuCfg?.appId?.trim() && (feishuCfg?.appSecret?.trim() || Boolean(feishuCfg?.appSecretFile)),
|
||||||
|
);
|
||||||
|
const envConfigured = Boolean(
|
||||||
|
process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
|
||||||
|
);
|
||||||
|
if (baseConfigured || envConfigured) {
|
||||||
|
ids.add(DEFAULT_ACCOUNT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts) {
|
||||||
|
for (const id of Object.keys(accounts)) {
|
||||||
|
ids.add(normalizeAccountId(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefaultFeishuAccountId(cfg: OpenClawConfig): string {
|
||||||
|
const ids = listFeishuAccountIds(cfg);
|
||||||
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFeishuAccount(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): ResolvedFeishuAccount {
|
||||||
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
|
const baseEnabled = params.cfg.channels?.feishu?.enabled !== false;
|
||||||
|
const merged = mergeFeishuAccountConfig(params.cfg, accountId);
|
||||||
|
const accountEnabled = merged.enabled !== false;
|
||||||
|
const enabled = baseEnabled && accountEnabled;
|
||||||
|
|
||||||
|
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
|
const envAppId = allowEnv ? process.env.FEISHU_APP_ID?.trim() : undefined;
|
||||||
|
const envAppSecret = allowEnv ? process.env.FEISHU_APP_SECRET?.trim() : undefined;
|
||||||
|
|
||||||
|
const appId = merged.appId?.trim() || envAppId || "";
|
||||||
|
const secretResolution = resolveAppSecret(merged);
|
||||||
|
const appSecret = secretResolution.value ?? envAppSecret ?? "";
|
||||||
|
|
||||||
|
let tokenSource: FeishuTokenSource = "none";
|
||||||
|
if (secretResolution.value) {
|
||||||
|
tokenSource = secretResolution.source ?? "config";
|
||||||
|
} else if (envAppSecret) {
|
||||||
|
tokenSource = "env";
|
||||||
|
}
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
tokenSource = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: FeishuAccountConfig = {
|
||||||
|
...merged,
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = config.name?.trim() || config.botName?.trim() || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
config,
|
||||||
|
tokenSource,
|
||||||
|
name,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { getFeishuClient } from "./client.js";
|
||||||
|
import { processFeishuMessage } from "./message.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-bot" });
|
||||||
|
|
||||||
|
export type FeishuBotOptions = {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createFeishuBot(opts: FeishuBotOptions) {
|
||||||
|
const { appId, appSecret } = opts;
|
||||||
|
const client = getFeishuClient(appId, appSecret);
|
||||||
|
|
||||||
|
const eventDispatcher = new Lark.EventDispatcher({}).register({
|
||||||
|
"im.message.receive_v1": async (data) => {
|
||||||
|
try {
|
||||||
|
await processFeishuMessage(client, data, appId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error processing Feishu message: ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsClient = new Lark.WSClient({
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
logger: {
|
||||||
|
debug: (...args) => {
|
||||||
|
logger.debug(args.join(" "));
|
||||||
|
},
|
||||||
|
info: (...args) => {
|
||||||
|
logger.info(args.join(" "));
|
||||||
|
},
|
||||||
|
warn: (...args) => {
|
||||||
|
logger.warn(args.join(" "));
|
||||||
|
},
|
||||||
|
error: (...args) => {
|
||||||
|
logger.error(args.join(" "));
|
||||||
|
},
|
||||||
|
trace: (...args) => {
|
||||||
|
logger.silly(args.join(" "));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { client, wsClient, eventDispatcher };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startFeishuBot(bot: ReturnType<typeof createFeishuBot>) {
|
||||||
|
logger.info("Starting Feishu bot WS client...");
|
||||||
|
await bot.wsClient.start({
|
||||||
|
eventDispatcher: bot.eventDispatcher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
|
import { normalizeFeishuDomain } from "./domain.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-client" });
|
||||||
|
|
||||||
|
function readFileIfExists(filePath?: string): string | undefined {
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf-8").trim();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAppSecret(config?: {
|
||||||
|
appSecret?: string;
|
||||||
|
appSecretFile?: string;
|
||||||
|
}): string | undefined {
|
||||||
|
const direct = config?.appSecret?.trim();
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
return readFileIfExists(config?.appSecretFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFeishuClient(accountIdOrAppId?: string, explicitAppSecret?: string) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const feishuCfg = cfg.channels?.feishu;
|
||||||
|
|
||||||
|
let appId: string | undefined;
|
||||||
|
let appSecret: string | undefined = explicitAppSecret?.trim() || undefined;
|
||||||
|
let domain: string | undefined;
|
||||||
|
|
||||||
|
// Determine if we received an accountId or an appId
|
||||||
|
const isAppId = accountIdOrAppId?.startsWith("cli_");
|
||||||
|
const accountId = isAppId ? undefined : accountIdOrAppId || DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
|
if (!appSecret && feishuCfg?.accounts) {
|
||||||
|
if (isAppId) {
|
||||||
|
// When given an appId, find the account with matching appId
|
||||||
|
for (const [, acc] of Object.entries(feishuCfg.accounts)) {
|
||||||
|
if (acc.appId === accountIdOrAppId) {
|
||||||
|
appId = acc.appId;
|
||||||
|
appSecret = resolveAppSecret(acc);
|
||||||
|
domain = acc.domain ?? feishuCfg?.domain;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not found in accounts, use the appId directly (secret from first account as fallback)
|
||||||
|
if (!appSecret) {
|
||||||
|
appId = accountIdOrAppId;
|
||||||
|
const firstKey = Object.keys(feishuCfg.accounts)[0];
|
||||||
|
if (firstKey) {
|
||||||
|
const acc = feishuCfg.accounts[firstKey];
|
||||||
|
appSecret = resolveAppSecret(acc);
|
||||||
|
domain = acc.domain ?? feishuCfg?.domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (accountId && feishuCfg.accounts[accountId]) {
|
||||||
|
// Try to get from accounts config by accountId
|
||||||
|
const acc = feishuCfg.accounts[accountId];
|
||||||
|
appId = acc.appId;
|
||||||
|
appSecret = resolveAppSecret(acc);
|
||||||
|
domain = acc.domain ?? feishuCfg?.domain;
|
||||||
|
} else if (!accountId) {
|
||||||
|
// Fallback to first account if accountId is not specified
|
||||||
|
const firstKey = Object.keys(feishuCfg.accounts)[0];
|
||||||
|
if (firstKey) {
|
||||||
|
const acc = feishuCfg.accounts[firstKey];
|
||||||
|
appId = acc.appId;
|
||||||
|
appSecret = resolveAppSecret(acc);
|
||||||
|
domain = acc.domain ?? feishuCfg?.domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to top-level feishu config (for backward compatibility)
|
||||||
|
if (!appId && feishuCfg?.appId) {
|
||||||
|
appId = feishuCfg.appId.trim();
|
||||||
|
}
|
||||||
|
if (!appSecret) {
|
||||||
|
appSecret = resolveAppSecret(feishuCfg);
|
||||||
|
}
|
||||||
|
if (!domain) {
|
||||||
|
domain = feishuCfg?.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variables fallback
|
||||||
|
if (!appId) {
|
||||||
|
appId = process.env.FEISHU_APP_ID?.trim();
|
||||||
|
}
|
||||||
|
if (!appSecret) {
|
||||||
|
appSecret = process.env.FEISHU_APP_SECRET?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"Feishu app ID/secret not configured. Set channels.feishu.accounts.<id>.appId/appSecret (or appSecretFile) or FEISHU_APP_ID/FEISHU_APP_SECRET.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDomain = normalizeFeishuDomain(domain);
|
||||||
|
|
||||||
|
const client = new Lark.Client({
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
...(resolvedDomain ? { domain: resolvedDomain } : {}),
|
||||||
|
logger: {
|
||||||
|
debug: (msg) => {
|
||||||
|
logger.debug(msg);
|
||||||
|
},
|
||||||
|
info: (msg) => {
|
||||||
|
logger.info(msg);
|
||||||
|
},
|
||||||
|
warn: (msg) => {
|
||||||
|
logger.warn(msg);
|
||||||
|
},
|
||||||
|
error: (msg) => {
|
||||||
|
logger.error(msg);
|
||||||
|
},
|
||||||
|
trace: (msg) => {
|
||||||
|
logger.silly(msg);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import type { DmPolicy, GroupPolicy } from "../config/types.base.js";
|
||||||
|
import type { FeishuGroupConfig } from "../config/types.feishu.js";
|
||||||
|
import { firstDefined } from "./access.js";
|
||||||
|
|
||||||
|
export type ResolvedFeishuConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
dmPolicy: DmPolicy;
|
||||||
|
groupPolicy: GroupPolicy;
|
||||||
|
allowFrom: string[];
|
||||||
|
groupAllowFrom: string[];
|
||||||
|
historyLimit: number;
|
||||||
|
dmHistoryLimit: number;
|
||||||
|
textChunkLimit: number;
|
||||||
|
chunkMode: "length" | "newline";
|
||||||
|
blockStreaming: boolean;
|
||||||
|
streaming: boolean;
|
||||||
|
mediaMaxMb: number;
|
||||||
|
groups: Record<string, FeishuGroupConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve effective Feishu configuration for an account.
|
||||||
|
* Account-level config overrides top-level feishu config, which overrides channel defaults.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuConfig(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
}): ResolvedFeishuConfig {
|
||||||
|
const { cfg, accountId } = params;
|
||||||
|
const feishuCfg = cfg.channels?.feishu;
|
||||||
|
const accountCfg = accountId ? feishuCfg?.accounts?.[accountId] : undefined;
|
||||||
|
const defaults = cfg.channels?.defaults;
|
||||||
|
|
||||||
|
// Merge with precedence: account > feishu top-level > channel defaults > hardcoded defaults
|
||||||
|
return {
|
||||||
|
enabled: firstDefined(accountCfg?.enabled, feishuCfg?.enabled, true) ?? true,
|
||||||
|
dmPolicy: firstDefined(accountCfg?.dmPolicy, feishuCfg?.dmPolicy) ?? "pairing",
|
||||||
|
groupPolicy:
|
||||||
|
firstDefined(accountCfg?.groupPolicy, feishuCfg?.groupPolicy, defaults?.groupPolicy) ??
|
||||||
|
"open",
|
||||||
|
allowFrom: (accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? []).map(String),
|
||||||
|
groupAllowFrom: (accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? []).map(String),
|
||||||
|
historyLimit: firstDefined(accountCfg?.historyLimit, feishuCfg?.historyLimit) ?? 10,
|
||||||
|
dmHistoryLimit: firstDefined(accountCfg?.dmHistoryLimit, feishuCfg?.dmHistoryLimit) ?? 20,
|
||||||
|
textChunkLimit: firstDefined(accountCfg?.textChunkLimit, feishuCfg?.textChunkLimit) ?? 2000,
|
||||||
|
chunkMode: firstDefined(accountCfg?.chunkMode, feishuCfg?.chunkMode) ?? "length",
|
||||||
|
blockStreaming: firstDefined(accountCfg?.blockStreaming, feishuCfg?.blockStreaming) ?? true,
|
||||||
|
streaming: firstDefined(accountCfg?.streaming, feishuCfg?.streaming) ?? true,
|
||||||
|
mediaMaxMb: firstDefined(accountCfg?.mediaMaxMb, feishuCfg?.mediaMaxMb) ?? 30,
|
||||||
|
groups: { ...feishuCfg?.groups, ...accountCfg?.groups },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve group-specific configuration for a Feishu chat.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuGroupConfig(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
chatId: string;
|
||||||
|
}): { groupConfig?: FeishuGroupConfig } {
|
||||||
|
const resolved = resolveFeishuConfig({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const groupConfig = resolved.groups[params.chatId];
|
||||||
|
return { groupConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a group requires @mention for the bot to respond.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuGroupRequireMention(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
chatId: string;
|
||||||
|
}): boolean {
|
||||||
|
const { groupConfig } = resolveFeishuGroupConfig(params);
|
||||||
|
// Default: require mention in groups
|
||||||
|
return groupConfig?.requireMention ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a group is enabled.
|
||||||
|
*/
|
||||||
|
export function resolveFeishuGroupEnabled(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
chatId: string;
|
||||||
|
}): boolean {
|
||||||
|
const { groupConfig } = resolveFeishuGroupConfig(params);
|
||||||
|
return groupConfig?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export const FEISHU_DOMAIN = "https://open.feishu.cn";
|
||||||
|
export const LARK_DOMAIN = "https://open.larksuite.com";
|
||||||
|
|
||||||
|
export type FeishuDomainInput = string | null | undefined;
|
||||||
|
|
||||||
|
export function normalizeFeishuDomain(value?: FeishuDomainInput): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (lower === "feishu" || lower === "cn" || lower === "china") {
|
||||||
|
return FEISHU_DOMAIN;
|
||||||
|
}
|
||||||
|
if (lower === "lark" || lower === "global" || lower === "intl" || lower === "international") {
|
||||||
|
return LARK_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||||
|
const withoutTrailing = withScheme.replace(/\/+$/, "");
|
||||||
|
return withoutTrailing.replace(/\/open-apis$/i, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFeishuDomain(value?: FeishuDomainInput): string {
|
||||||
|
return normalizeFeishuDomain(value) ?? FEISHU_DOMAIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFeishuApiBase(value?: FeishuDomainInput): string {
|
||||||
|
const base = resolveFeishuDomain(value);
|
||||||
|
return `${base.replace(/\/+$/, "")}/open-apis`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-download" });
|
||||||
|
|
||||||
|
export type FeishuMediaRef = {
|
||||||
|
path: string;
|
||||||
|
contentType?: string;
|
||||||
|
placeholder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuMessagePayload = {
|
||||||
|
message_type?: string;
|
||||||
|
message_id?: string;
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a resource from a user message using messageResource.get
|
||||||
|
* This is the correct API for downloading resources from messages sent by users.
|
||||||
|
*
|
||||||
|
* @param type - Resource type: "image", "file", "audio", or "video"
|
||||||
|
*/
|
||||||
|
export async function downloadFeishuMessageResource(
|
||||||
|
client: Client,
|
||||||
|
messageId: string,
|
||||||
|
fileKey: string,
|
||||||
|
type: "image" | "file" | "audio" | "video",
|
||||||
|
maxBytes: number = 30 * 1024 * 1024,
|
||||||
|
): Promise<FeishuMediaRef> {
|
||||||
|
logger.debug(`Downloading Feishu ${type}: messageId=${messageId}, fileKey=${fileKey}`);
|
||||||
|
|
||||||
|
const res = await client.im.messageResource.get({
|
||||||
|
params: { type },
|
||||||
|
path: {
|
||||||
|
message_id: messageId,
|
||||||
|
file_key: fileKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
throw new Error(`Failed to get ${type} resource: no response`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = res.getReadableStream();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let totalSize = 0;
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
totalSize += chunk.length;
|
||||||
|
if (totalSize > maxBytes) {
|
||||||
|
throw new Error(`${type} resource exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Try to detect content type from headers
|
||||||
|
const contentType =
|
||||||
|
res.headers?.["content-type"] ?? res.headers?.["Content-Type"] ?? getDefaultContentType(type);
|
||||||
|
|
||||||
|
const saved = await saveMediaBuffer(buffer, contentType, "inbound", maxBytes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: saved.path,
|
||||||
|
contentType: saved.contentType,
|
||||||
|
placeholder: getPlaceholder(type),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultContentType(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return "image/jpeg";
|
||||||
|
case "audio":
|
||||||
|
return "audio/ogg";
|
||||||
|
case "video":
|
||||||
|
return "video/mp4";
|
||||||
|
default:
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaceholder(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case "image":
|
||||||
|
return "<media:image>";
|
||||||
|
case "audio":
|
||||||
|
return "<media:audio>";
|
||||||
|
case "video":
|
||||||
|
return "<media:video>";
|
||||||
|
default:
|
||||||
|
return "<media:document>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve media from a Feishu message
|
||||||
|
* Returns the downloaded media reference or null if no media
|
||||||
|
*
|
||||||
|
* Uses messageResource.get API to download resources from user messages.
|
||||||
|
*/
|
||||||
|
export async function resolveFeishuMedia(
|
||||||
|
client: Client,
|
||||||
|
message: FeishuMessagePayload,
|
||||||
|
maxBytes: number = 30 * 1024 * 1024,
|
||||||
|
): Promise<FeishuMediaRef | null> {
|
||||||
|
const msgType = message.message_type;
|
||||||
|
const messageId = message.message_id;
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
logger.warn(`Cannot download media: message_id is missing`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawContent = message.content;
|
||||||
|
if (!rawContent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === "image") {
|
||||||
|
// Image message: content = { image_key: "..." }
|
||||||
|
const content = JSON.parse(rawContent);
|
||||||
|
if (content.image_key) {
|
||||||
|
return await downloadFeishuMessageResource(
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
content.image_key,
|
||||||
|
"image",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msgType === "file") {
|
||||||
|
// File message: content = { file_key: "...", file_name: "..." }
|
||||||
|
const content = JSON.parse(rawContent);
|
||||||
|
if (content.file_key) {
|
||||||
|
return await downloadFeishuMessageResource(
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
content.file_key,
|
||||||
|
"file",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msgType === "audio") {
|
||||||
|
// Audio message: content = { file_key: "..." }
|
||||||
|
const content = JSON.parse(rawContent);
|
||||||
|
if (content.file_key) {
|
||||||
|
return await downloadFeishuMessageResource(
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
content.file_key,
|
||||||
|
"audio",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msgType === "media") {
|
||||||
|
// Video message: content = { file_key: "...", image_key: "..." (thumbnail) }
|
||||||
|
const content = JSON.parse(rawContent);
|
||||||
|
if (content.file_key) {
|
||||||
|
return await downloadFeishuMessageResource(
|
||||||
|
client,
|
||||||
|
messageId,
|
||||||
|
content.file_key,
|
||||||
|
"video",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msgType === "sticker") {
|
||||||
|
// Sticker - not supported for download via messageResource API
|
||||||
|
logger.debug(`Sticker messages are not supported for download`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to resolve Feishu media (${msgType}): ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { containsMarkdown, markdownToFeishuPost } from "./format.js";
|
||||||
|
|
||||||
|
describe("containsMarkdown", () => {
|
||||||
|
it("detects bold text", () => {
|
||||||
|
expect(containsMarkdown("Hello **world**")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects italic text", () => {
|
||||||
|
expect(containsMarkdown("Hello *world*")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects inline code", () => {
|
||||||
|
expect(containsMarkdown("Run `npm install`")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects code blocks", () => {
|
||||||
|
expect(containsMarkdown("```js\nconsole.log('hi')\n```")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects links", () => {
|
||||||
|
expect(containsMarkdown("Visit [Google](https://google.com)")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects headings", () => {
|
||||||
|
expect(containsMarkdown("# Title")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for plain text", () => {
|
||||||
|
expect(containsMarkdown("Hello world")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty string", () => {
|
||||||
|
expect(containsMarkdown("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markdownToFeishuPost", () => {
|
||||||
|
it("converts plain text", () => {
|
||||||
|
const result = markdownToFeishuPost("Hello world");
|
||||||
|
expect(result.zh_cn?.content).toBeDefined();
|
||||||
|
expect(result.zh_cn?.content[0]).toContainEqual({
|
||||||
|
tag: "text",
|
||||||
|
text: "Hello world",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts bold text", () => {
|
||||||
|
const result = markdownToFeishuPost("Hello **bold** text");
|
||||||
|
const content = result.zh_cn?.content[0];
|
||||||
|
expect(content).toBeDefined();
|
||||||
|
// Should have at least one element with bold style
|
||||||
|
const boldElement = content?.find((el) => el.tag === "text" && el.style?.includes("bold"));
|
||||||
|
expect(boldElement).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts italic text", () => {
|
||||||
|
const result = markdownToFeishuPost("Hello *italic* text");
|
||||||
|
const content = result.zh_cn?.content[0];
|
||||||
|
expect(content).toBeDefined();
|
||||||
|
const italicElement = content?.find((el) => el.tag === "text" && el.style?.includes("italic"));
|
||||||
|
expect(italicElement).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts links", () => {
|
||||||
|
const result = markdownToFeishuPost("Visit [Google](https://google.com)");
|
||||||
|
const content = result.zh_cn?.content[0];
|
||||||
|
expect(content).toBeDefined();
|
||||||
|
const linkElement = content?.find((el) => el.tag === "a");
|
||||||
|
expect(linkElement).toBeDefined();
|
||||||
|
if (linkElement && linkElement.tag === "a") {
|
||||||
|
expect(linkElement.href).toBe("https://google.com");
|
||||||
|
expect(linkElement.text).toBe("Google");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multi-line text", () => {
|
||||||
|
const result = markdownToFeishuPost("Line 1\nLine 2\nLine 3");
|
||||||
|
expect(result.zh_cn?.content.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts code to code style", () => {
|
||||||
|
const result = markdownToFeishuPost("Run `npm install`");
|
||||||
|
const content = result.zh_cn?.content[0];
|
||||||
|
expect(content).toBeDefined();
|
||||||
|
const codeElement = content?.find((el) => el.tag === "text" && el.style?.includes("code"));
|
||||||
|
expect(codeElement).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty input", () => {
|
||||||
|
const result = markdownToFeishuPost("");
|
||||||
|
expect(result.zh_cn?.content).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
import type { MarkdownTableMode } from "../config/types.base.js";
|
||||||
|
import {
|
||||||
|
chunkMarkdownIR,
|
||||||
|
markdownToIR,
|
||||||
|
type MarkdownIR,
|
||||||
|
type MarkdownLinkSpan,
|
||||||
|
type MarkdownStyleSpan,
|
||||||
|
} from "../markdown/ir.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feishu Post (rich text) format
|
||||||
|
* Reference: https://open.feishu.cn/document/server-docs/im-v1/message-content-description/create_json#c9e08671
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FeishuPostElement =
|
||||||
|
| { tag: "text"; text: string; style?: string[] }
|
||||||
|
| { tag: "a"; text: string; href: string; style?: string[] }
|
||||||
|
| { tag: "at"; user_id: string }
|
||||||
|
| { tag: "img"; image_key: string }
|
||||||
|
| { tag: "media"; file_key: string }
|
||||||
|
| { tag: "emotion"; emoji_type: string };
|
||||||
|
|
||||||
|
export type FeishuPostLine = FeishuPostElement[];
|
||||||
|
|
||||||
|
export type FeishuPostContent = {
|
||||||
|
zh_cn?: {
|
||||||
|
title?: string;
|
||||||
|
content: FeishuPostLine[];
|
||||||
|
};
|
||||||
|
en_us?: {
|
||||||
|
title?: string;
|
||||||
|
content: FeishuPostLine[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuFormattedChunk = {
|
||||||
|
post: FeishuPostContent;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StyleState = {
|
||||||
|
bold: boolean;
|
||||||
|
italic: boolean;
|
||||||
|
strikethrough: boolean;
|
||||||
|
code: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MarkdownIR to Feishu Post format
|
||||||
|
*/
|
||||||
|
function renderFeishuPost(ir: MarkdownIR): FeishuPostContent {
|
||||||
|
const lines: FeishuPostLine[] = [];
|
||||||
|
const text = ir.text;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return { zh_cn: { content: [[{ tag: "text", text: "" }]] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of style ranges for quick lookup
|
||||||
|
const styleRanges = buildStyleRanges(ir.styles, text.length);
|
||||||
|
const linkMap = buildLinkMap(ir.links);
|
||||||
|
|
||||||
|
// Split text into lines
|
||||||
|
const textLines = text.split("\n");
|
||||||
|
let charIndex = 0;
|
||||||
|
|
||||||
|
for (const line of textLines) {
|
||||||
|
const lineElements: FeishuPostElement[] = [];
|
||||||
|
|
||||||
|
if (line.length === 0) {
|
||||||
|
// Empty line - add empty text element
|
||||||
|
lineElements.push({ tag: "text", text: "" });
|
||||||
|
} else {
|
||||||
|
// Process each character segment with consistent styling
|
||||||
|
let segmentStart = charIndex;
|
||||||
|
let currentStyles = getStylesAt(styleRanges, segmentStart);
|
||||||
|
let currentLink = getLinkAt(linkMap, segmentStart);
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const pos = charIndex + i;
|
||||||
|
const newStyles = getStylesAt(styleRanges, pos);
|
||||||
|
const newLink = getLinkAt(linkMap, pos);
|
||||||
|
|
||||||
|
// Check if style or link changed
|
||||||
|
const stylesChanged = !stylesEqual(currentStyles, newStyles);
|
||||||
|
const linkChanged = currentLink !== newLink;
|
||||||
|
|
||||||
|
if (stylesChanged || linkChanged) {
|
||||||
|
// Emit previous segment
|
||||||
|
const segmentText = text.slice(segmentStart, pos);
|
||||||
|
if (segmentText) {
|
||||||
|
lineElements.push(createPostElement(segmentText, currentStyles, currentLink));
|
||||||
|
}
|
||||||
|
segmentStart = pos;
|
||||||
|
currentStyles = newStyles;
|
||||||
|
currentLink = newLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit final segment of the line
|
||||||
|
const finalText = text.slice(segmentStart, charIndex + line.length);
|
||||||
|
if (finalText) {
|
||||||
|
lineElements.push(createPostElement(finalText, currentStyles, currentLink));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(lineElements.length > 0 ? lineElements : [{ tag: "text", text: "" }]);
|
||||||
|
charIndex += line.length + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
zh_cn: {
|
||||||
|
content: lines,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStyleRanges(styles: MarkdownStyleSpan[], textLength: number): StyleState[] {
|
||||||
|
const ranges: StyleState[] = Array(textLength)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => ({
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
strikethrough: false,
|
||||||
|
code: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const span of styles) {
|
||||||
|
for (let i = span.start; i < span.end && i < textLength; i++) {
|
||||||
|
switch (span.style) {
|
||||||
|
case "bold":
|
||||||
|
ranges[i].bold = true;
|
||||||
|
break;
|
||||||
|
case "italic":
|
||||||
|
ranges[i].italic = true;
|
||||||
|
break;
|
||||||
|
case "strikethrough":
|
||||||
|
ranges[i].strikethrough = true;
|
||||||
|
break;
|
||||||
|
case "code":
|
||||||
|
case "code_block":
|
||||||
|
ranges[i].code = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkMap(links: MarkdownLinkSpan[]): Map<number, string> {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
for (const link of links) {
|
||||||
|
for (let i = link.start; i < link.end; i++) {
|
||||||
|
map.set(i, link.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStylesAt(ranges: StyleState[], pos: number): StyleState {
|
||||||
|
return ranges[pos] ?? { bold: false, italic: false, strikethrough: false, code: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkAt(linkMap: Map<number, string>, pos: number): string | undefined {
|
||||||
|
return linkMap.get(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stylesEqual(a: StyleState, b: StyleState): boolean {
|
||||||
|
return (
|
||||||
|
a.bold === b.bold &&
|
||||||
|
a.italic === b.italic &&
|
||||||
|
a.strikethrough === b.strikethrough &&
|
||||||
|
a.code === b.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPostElement(text: string, styles: StyleState, link?: string): FeishuPostElement {
|
||||||
|
const styleArray: string[] = [];
|
||||||
|
|
||||||
|
if (styles.bold) {
|
||||||
|
styleArray.push("bold");
|
||||||
|
}
|
||||||
|
if (styles.italic) {
|
||||||
|
styleArray.push("italic");
|
||||||
|
}
|
||||||
|
if (styles.strikethrough) {
|
||||||
|
styleArray.push("lineThrough");
|
||||||
|
}
|
||||||
|
if (styles.code) {
|
||||||
|
styleArray.push("code");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
return {
|
||||||
|
tag: "a",
|
||||||
|
text,
|
||||||
|
href: link,
|
||||||
|
...(styleArray.length > 0 ? { style: styleArray } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: "text",
|
||||||
|
text,
|
||||||
|
...(styleArray.length > 0 ? { style: styleArray } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Markdown to Feishu Post format
|
||||||
|
*/
|
||||||
|
export function markdownToFeishuPost(
|
||||||
|
markdown: string,
|
||||||
|
options: { tableMode?: MarkdownTableMode } = {},
|
||||||
|
): FeishuPostContent {
|
||||||
|
const ir = markdownToIR(markdown ?? "", {
|
||||||
|
linkify: true,
|
||||||
|
headingStyle: "bold",
|
||||||
|
blockquotePrefix: "| ",
|
||||||
|
tableMode: options.tableMode,
|
||||||
|
});
|
||||||
|
return renderFeishuPost(ir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Markdown to Feishu Post chunks (for long messages)
|
||||||
|
*/
|
||||||
|
export function markdownToFeishuChunks(
|
||||||
|
markdown: string,
|
||||||
|
limit: number,
|
||||||
|
options: { tableMode?: MarkdownTableMode } = {},
|
||||||
|
): FeishuFormattedChunk[] {
|
||||||
|
const ir = markdownToIR(markdown ?? "", {
|
||||||
|
linkify: true,
|
||||||
|
headingStyle: "bold",
|
||||||
|
blockquotePrefix: "| ",
|
||||||
|
tableMode: options.tableMode,
|
||||||
|
});
|
||||||
|
const chunks = chunkMarkdownIR(ir, limit);
|
||||||
|
return chunks.map((chunk) => ({
|
||||||
|
post: renderFeishuPost(chunk),
|
||||||
|
text: chunk.text,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains Markdown formatting
|
||||||
|
*/
|
||||||
|
export function containsMarkdown(text: string): boolean {
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check for common Markdown patterns
|
||||||
|
const markdownPatterns = [
|
||||||
|
/\*\*[^*]+\*\*/, // bold
|
||||||
|
/\*[^*]+\*/, // italic
|
||||||
|
/~~[^~]+~~/, // strikethrough
|
||||||
|
/`[^`]+`/, // inline code
|
||||||
|
/```[\s\S]*```/, // code block
|
||||||
|
/\[.+\]\(.+\)/, // links
|
||||||
|
/^#{1,6}\s/m, // headings
|
||||||
|
/^[-*]\s/m, // unordered list
|
||||||
|
/^\d+\.\s/m, // ordered list
|
||||||
|
];
|
||||||
|
return markdownPatterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export * from "./types.js";
|
||||||
|
export * from "./client.js";
|
||||||
|
export * from "./bot.js";
|
||||||
|
export * from "./send.js";
|
||||||
|
export * from "./message.js";
|
||||||
|
export * from "./probe.js";
|
||||||
|
export * from "./accounts.js";
|
||||||
|
export * from "./monitor.js";
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { logVerbose } from "../globals.js";
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { isSenderAllowed, normalizeAllowFromWithStore, resolveSenderAllowMatch } from "./access.js";
|
||||||
|
import {
|
||||||
|
resolveFeishuConfig,
|
||||||
|
resolveFeishuGroupConfig,
|
||||||
|
resolveFeishuGroupEnabled,
|
||||||
|
type ResolvedFeishuConfig,
|
||||||
|
} from "./config.js";
|
||||||
|
import { resolveFeishuMedia, type FeishuMediaRef } from "./download.js";
|
||||||
|
import { readFeishuAllowFromStore, upsertFeishuPairingRequest } from "./pairing-store.js";
|
||||||
|
import { sendMessageFeishu } from "./send.js";
|
||||||
|
import { FeishuStreamingSession } from "./streaming-card.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-message" });
|
||||||
|
|
||||||
|
type FeishuSender = {
|
||||||
|
sender_id?: {
|
||||||
|
open_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
union_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuMention = {
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuMessage = {
|
||||||
|
chat_id?: string;
|
||||||
|
chat_type?: string;
|
||||||
|
message_type?: string;
|
||||||
|
content?: string;
|
||||||
|
mentions?: FeishuMention[];
|
||||||
|
create_time?: string | number;
|
||||||
|
message_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuEventPayload = {
|
||||||
|
message?: FeishuMessage;
|
||||||
|
event?: {
|
||||||
|
message?: FeishuMessage;
|
||||||
|
sender?: FeishuSender;
|
||||||
|
};
|
||||||
|
sender?: FeishuSender;
|
||||||
|
mentions?: FeishuMention[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Supported message types for processing
|
||||||
|
const SUPPORTED_MSG_TYPES = new Set(["text", "image", "file", "audio", "media", "sticker"]);
|
||||||
|
|
||||||
|
export type ProcessFeishuMessageOptions = {
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
resolvedConfig?: ResolvedFeishuConfig;
|
||||||
|
/** Feishu app credentials for streaming card API */
|
||||||
|
credentials?: { appId: string; appSecret: string; domain?: string };
|
||||||
|
/** Bot name for streaming card title (optional, defaults to no title) */
|
||||||
|
botName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function processFeishuMessage(
|
||||||
|
client: Client,
|
||||||
|
data: unknown,
|
||||||
|
appId: string,
|
||||||
|
options: ProcessFeishuMessageOptions = {},
|
||||||
|
) {
|
||||||
|
const cfg = options.cfg ?? loadConfig();
|
||||||
|
const accountId = options.accountId ?? appId;
|
||||||
|
const feishuCfg = options.resolvedConfig ?? resolveFeishuConfig({ cfg, accountId });
|
||||||
|
|
||||||
|
const payload = data as FeishuEventPayload;
|
||||||
|
|
||||||
|
// SDK 2.0 schema: data directly contains message, sender, etc.
|
||||||
|
const message = payload.message ?? payload.event?.message;
|
||||||
|
const sender = payload.sender ?? payload.event?.sender;
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
logger.warn(`Received event without message field`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatId = message.chat_id;
|
||||||
|
if (!chatId) {
|
||||||
|
logger.warn("Received message without chat_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isGroup = message.chat_type === "group";
|
||||||
|
const msgType = message.message_type;
|
||||||
|
const senderId = sender?.sender_id?.open_id || sender?.sender_id?.user_id || "unknown";
|
||||||
|
const senderUnionId = sender?.sender_id?.union_id;
|
||||||
|
const maxMediaBytes = feishuCfg.mediaMaxMb * 1024 * 1024;
|
||||||
|
|
||||||
|
// Check if this is a supported message type
|
||||||
|
if (!msgType || !SUPPORTED_MSG_TYPES.has(msgType)) {
|
||||||
|
logger.debug(`Skipping unsupported message type: ${msgType ?? "unknown"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load allowlist from store
|
||||||
|
const storeAllowFrom = await readFeishuAllowFromStore().catch(() => []);
|
||||||
|
|
||||||
|
// ===== Access Control =====
|
||||||
|
|
||||||
|
// Group access control
|
||||||
|
if (isGroup) {
|
||||||
|
// Check if group is enabled
|
||||||
|
if (!resolveFeishuGroupEnabled({ cfg, accountId, chatId })) {
|
||||||
|
logVerbose(`Blocked feishu group ${chatId} (group disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId });
|
||||||
|
|
||||||
|
// Check group-level allowFrom override
|
||||||
|
if (groupConfig?.allowFrom) {
|
||||||
|
const groupAllow = normalizeAllowFromWithStore({
|
||||||
|
allowFrom: groupConfig.allowFrom,
|
||||||
|
storeAllowFrom,
|
||||||
|
});
|
||||||
|
if (!isSenderAllowed({ allow: groupAllow, senderId })) {
|
||||||
|
logVerbose(`Blocked feishu group sender ${senderId} (group allowFrom override)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply groupPolicy
|
||||||
|
const groupPolicy = feishuCfg.groupPolicy;
|
||||||
|
if (groupPolicy === "disabled") {
|
||||||
|
logVerbose(`Blocked feishu group message (groupPolicy: disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupPolicy === "allowlist") {
|
||||||
|
const groupAllow = normalizeAllowFromWithStore({
|
||||||
|
allowFrom:
|
||||||
|
feishuCfg.groupAllowFrom.length > 0 ? feishuCfg.groupAllowFrom : feishuCfg.allowFrom,
|
||||||
|
storeAllowFrom,
|
||||||
|
});
|
||||||
|
if (!groupAllow.hasEntries) {
|
||||||
|
logVerbose(`Blocked feishu group message (groupPolicy: allowlist, no entries)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isSenderAllowed({ allow: groupAllow, senderId })) {
|
||||||
|
logVerbose(`Blocked feishu group sender ${senderId} (groupPolicy: allowlist)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM access control
|
||||||
|
if (!isGroup) {
|
||||||
|
const dmPolicy = feishuCfg.dmPolicy;
|
||||||
|
|
||||||
|
if (dmPolicy === "disabled") {
|
||||||
|
logVerbose(`Blocked feishu DM (dmPolicy: disabled)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dmPolicy !== "open") {
|
||||||
|
const dmAllow = normalizeAllowFromWithStore({
|
||||||
|
allowFrom: feishuCfg.allowFrom,
|
||||||
|
storeAllowFrom,
|
||||||
|
});
|
||||||
|
const allowMatch = resolveSenderAllowMatch({ allow: dmAllow, senderId });
|
||||||
|
const allowed = dmAllow.hasWildcard || (dmAllow.hasEntries && allowMatch.allowed);
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
if (dmPolicy === "pairing") {
|
||||||
|
// Generate pairing code for unknown sender
|
||||||
|
try {
|
||||||
|
const { code, created } = await upsertFeishuPairingRequest({
|
||||||
|
openId: senderId,
|
||||||
|
unionId: senderUnionId,
|
||||||
|
name: sender?.sender_id?.user_id,
|
||||||
|
});
|
||||||
|
if (created) {
|
||||||
|
logger.info({ openId: senderId, unionId: senderUnionId }, "feishu pairing request");
|
||||||
|
await sendMessageFeishu(
|
||||||
|
client,
|
||||||
|
senderId,
|
||||||
|
{
|
||||||
|
text: [
|
||||||
|
"OpenClaw access not configured.",
|
||||||
|
"",
|
||||||
|
`Your Feishu Open ID: ${senderId}`,
|
||||||
|
"",
|
||||||
|
`Pairing code: ${code}`,
|
||||||
|
"",
|
||||||
|
"Ask the OpenClaw admin to approve with:",
|
||||||
|
`openclaw pairing approve feishu ${code}`,
|
||||||
|
].join("\n"),
|
||||||
|
},
|
||||||
|
{ receiveIdType: "open_id" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to create pairing request: ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowlist policy: silently block
|
||||||
|
logVerbose(`Blocked feishu DM from ${senderId} (dmPolicy: allowlist)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle @mentions for group chats
|
||||||
|
const mentions = message.mentions ?? payload.mentions ?? [];
|
||||||
|
const wasMentioned = mentions.length > 0;
|
||||||
|
|
||||||
|
// In group chat, check requireMention setting
|
||||||
|
if (isGroup) {
|
||||||
|
const { groupConfig } = resolveFeishuGroupConfig({ cfg, accountId, chatId });
|
||||||
|
const requireMention = groupConfig?.requireMention ?? true;
|
||||||
|
if (requireMention && !wasMentioned) {
|
||||||
|
logger.debug(`Ignoring group message without @mention (requireMention: true)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content (for text messages or captions)
|
||||||
|
let text = "";
|
||||||
|
if (msgType === "text") {
|
||||||
|
try {
|
||||||
|
if (message.content) {
|
||||||
|
const content = JSON.parse(message.content);
|
||||||
|
text = content.text || "";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to parse text message content: ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove @mention placeholders from text
|
||||||
|
for (const mention of mentions) {
|
||||||
|
if (mention.key) {
|
||||||
|
text = text.replace(mention.key, "").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve media if present
|
||||||
|
let media: FeishuMediaRef | null = null;
|
||||||
|
if (msgType !== "text") {
|
||||||
|
try {
|
||||||
|
media = await resolveFeishuMedia(client, message, maxMediaBytes);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to download media: ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build body text
|
||||||
|
let bodyText = text;
|
||||||
|
if (!bodyText && media) {
|
||||||
|
bodyText = media.placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no content
|
||||||
|
if (!bodyText && !media) {
|
||||||
|
logger.debug(`Empty message after processing, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderName = sender?.sender_id?.user_id || "unknown";
|
||||||
|
|
||||||
|
// Streaming mode support
|
||||||
|
const streamingEnabled = (feishuCfg.streaming ?? true) && Boolean(options.credentials);
|
||||||
|
const streamingSession =
|
||||||
|
streamingEnabled && options.credentials
|
||||||
|
? new FeishuStreamingSession(client, options.credentials)
|
||||||
|
: null;
|
||||||
|
let streamingStarted = false;
|
||||||
|
let lastPartialText = "";
|
||||||
|
|
||||||
|
// Context construction
|
||||||
|
const ctx = {
|
||||||
|
Body: bodyText,
|
||||||
|
RawBody: text || media?.placeholder || "",
|
||||||
|
From: senderId,
|
||||||
|
To: chatId,
|
||||||
|
SenderId: senderId,
|
||||||
|
SenderName: senderName,
|
||||||
|
ChatType: isGroup ? "group" : "dm",
|
||||||
|
Provider: "feishu",
|
||||||
|
Surface: "feishu",
|
||||||
|
Timestamp: Number(message.create_time),
|
||||||
|
MessageSid: message.message_id,
|
||||||
|
AccountId: accountId,
|
||||||
|
OriginatingChannel: "feishu",
|
||||||
|
OriginatingTo: chatId,
|
||||||
|
// Media fields (similar to Telegram)
|
||||||
|
MediaPath: media?.path,
|
||||||
|
MediaType: media?.contentType,
|
||||||
|
MediaUrl: media?.path,
|
||||||
|
WasMentioned: isGroup ? wasMentioned : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await dispatchReplyWithBufferedBlockDispatcher({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: async (payload, info) => {
|
||||||
|
const hasMedia = payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0);
|
||||||
|
if (!payload.text && !hasMedia) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle block replies - update streaming card with partial text
|
||||||
|
if (streamingSession?.isActive() && info?.kind === "block" && payload.text) {
|
||||||
|
logger.debug(`Updating streaming card with block text: ${payload.text.length} chars`);
|
||||||
|
await streamingSession.update(payload.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If streaming was active, close it with the final text
|
||||||
|
if (streamingSession?.isActive() && info?.kind === "final") {
|
||||||
|
await streamingSession.close(payload.text);
|
||||||
|
streamingStarted = false;
|
||||||
|
return; // Card already contains the final text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle media URLs
|
||||||
|
const mediaUrls = payload.mediaUrls?.length
|
||||||
|
? payload.mediaUrls
|
||||||
|
: payload.mediaUrl
|
||||||
|
? [payload.mediaUrl]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (mediaUrls.length > 0) {
|
||||||
|
// Close streaming session before sending media
|
||||||
|
if (streamingSession?.isActive()) {
|
||||||
|
await streamingSession.close();
|
||||||
|
streamingStarted = false;
|
||||||
|
}
|
||||||
|
// Send each media item
|
||||||
|
for (let i = 0; i < mediaUrls.length; i++) {
|
||||||
|
const mediaUrl = mediaUrls[i];
|
||||||
|
const caption = i === 0 ? payload.text || "" : "";
|
||||||
|
await sendMessageFeishu(
|
||||||
|
client,
|
||||||
|
chatId,
|
||||||
|
{ text: caption },
|
||||||
|
{
|
||||||
|
mediaUrl,
|
||||||
|
receiveIdType: "chat_id",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (payload.text) {
|
||||||
|
// If streaming wasn't used, send as regular message
|
||||||
|
if (!streamingSession?.isActive()) {
|
||||||
|
await sendMessageFeishu(
|
||||||
|
client,
|
||||||
|
chatId,
|
||||||
|
{ text: payload.text },
|
||||||
|
{
|
||||||
|
msgType: "text",
|
||||||
|
receiveIdType: "chat_id",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
logger.error(`Reply error: ${formatErrorMessage(err)}`);
|
||||||
|
// Clean up streaming session on error
|
||||||
|
if (streamingSession?.isActive()) {
|
||||||
|
streamingSession.close().catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReplyStart: async () => {
|
||||||
|
// Start streaming card when reply generation begins
|
||||||
|
if (streamingSession && !streamingStarted) {
|
||||||
|
try {
|
||||||
|
await streamingSession.start(chatId, "chat_id", options.botName);
|
||||||
|
streamingStarted = true;
|
||||||
|
logger.debug(`Started streaming card for chat ${chatId}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Failed to start streaming card: ${formatErrorMessage(err)}`);
|
||||||
|
// Continue without streaming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
replyOptions: {
|
||||||
|
disableBlockStreaming: !feishuCfg.blockStreaming,
|
||||||
|
onPartialReply: streamingSession
|
||||||
|
? async (payload) => {
|
||||||
|
if (!streamingSession.isActive() || !payload.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.text === lastPartialText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPartialText = payload.text;
|
||||||
|
await streamingSession.update(payload.text);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onReasoningStream: streamingSession
|
||||||
|
? async (payload) => {
|
||||||
|
// Also update on reasoning stream for extended thinking models
|
||||||
|
if (!streamingSession.isActive() || !payload.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.text === lastPartialText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPartialText = payload.text;
|
||||||
|
await streamingSession.update(payload.text);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure streaming session is closed on completion
|
||||||
|
if (streamingSession?.isActive()) {
|
||||||
|
await streamingSession.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
addChannelAllowFromStoreEntry,
|
||||||
|
approveChannelPairingCode,
|
||||||
|
listChannelPairingRequests,
|
||||||
|
readChannelAllowFromStore,
|
||||||
|
upsertChannelPairingRequest,
|
||||||
|
} from "../pairing/pairing-store.js";
|
||||||
|
|
||||||
|
export type FeishuPairingListEntry = {
|
||||||
|
openId: string;
|
||||||
|
unionId?: string;
|
||||||
|
name?: string;
|
||||||
|
code: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROVIDER = "feishu" as const;
|
||||||
|
|
||||||
|
export async function readFeishuAllowFromStore(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return readChannelAllowFromStore(PROVIDER, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addFeishuAllowFromStoreEntry(params: {
|
||||||
|
entry: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||||
|
return addChannelAllowFromStoreEntry({
|
||||||
|
channel: PROVIDER,
|
||||||
|
entry: params.entry,
|
||||||
|
env: params.env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFeishuPairingRequests(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): Promise<FeishuPairingListEntry[]> {
|
||||||
|
const list = await listChannelPairingRequests(PROVIDER, env);
|
||||||
|
return list.map((r) => ({
|
||||||
|
openId: r.id,
|
||||||
|
code: r.code,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
lastSeenAt: r.lastSeenAt,
|
||||||
|
unionId: r.meta?.unionId,
|
||||||
|
name: r.meta?.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertFeishuPairingRequest(params: {
|
||||||
|
openId: string;
|
||||||
|
unionId?: string;
|
||||||
|
name?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ code: string; created: boolean }> {
|
||||||
|
return upsertChannelPairingRequest({
|
||||||
|
channel: PROVIDER,
|
||||||
|
id: params.openId,
|
||||||
|
env: params.env,
|
||||||
|
meta: {
|
||||||
|
unionId: params.unionId,
|
||||||
|
name: params.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveFeishuPairingCode(params: {
|
||||||
|
code: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ openId: string; entry?: FeishuPairingListEntry } | null> {
|
||||||
|
const res = await approveChannelPairingCode({
|
||||||
|
channel: PROVIDER,
|
||||||
|
code: params.code,
|
||||||
|
env: params.env,
|
||||||
|
});
|
||||||
|
if (!res) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = res.entry
|
||||||
|
? {
|
||||||
|
openId: res.entry.id,
|
||||||
|
code: res.entry.code,
|
||||||
|
createdAt: res.entry.createdAt,
|
||||||
|
lastSeenAt: res.entry.lastSeenAt,
|
||||||
|
unionId: res.entry.meta?.unionId,
|
||||||
|
name: res.entry.meta?.name,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return { openId: res.id, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveFeishuEffectiveAllowFrom(params: {
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
accountId?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ dm: string[]; group: string[] }> {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const feishuCfg = params.cfg.channels?.feishu;
|
||||||
|
const accountCfg = params.accountId ? feishuCfg?.accounts?.[params.accountId] : undefined;
|
||||||
|
|
||||||
|
// Account-level config takes precedence over top-level
|
||||||
|
const allowFrom = accountCfg?.allowFrom ?? feishuCfg?.allowFrom ?? [];
|
||||||
|
const groupAllowFrom = accountCfg?.groupAllowFrom ?? feishuCfg?.groupAllowFrom ?? [];
|
||||||
|
|
||||||
|
const cfgAllowFrom = allowFrom
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((v) => v.replace(/^feishu:/i, ""))
|
||||||
|
.filter((v) => v !== "*");
|
||||||
|
|
||||||
|
const cfgGroupAllowFrom = groupAllowFrom
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((v) => v.replace(/^feishu:/i, ""))
|
||||||
|
.filter((v) => v !== "*");
|
||||||
|
|
||||||
|
const storeAllowFrom = await readFeishuAllowFromStore(env);
|
||||||
|
|
||||||
|
const dm = Array.from(new Set([...cfgAllowFrom, ...storeAllowFrom]));
|
||||||
|
const group = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(cfgGroupAllowFrom.length > 0 ? cfgGroupAllowFrom : cfgAllowFrom),
|
||||||
|
...storeAllowFrom,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return { dm, group };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { resolveFeishuApiBase } from "./domain.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-probe" });
|
||||||
|
|
||||||
|
export type FeishuProbe = {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
bot?: {
|
||||||
|
appId?: string | null;
|
||||||
|
appName?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenResponse = {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
tenant_access_token?: string;
|
||||||
|
expire?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BotInfoResponse = {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
bot?: {
|
||||||
|
app_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
open_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { ...options, signal: controller.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeFeishu(
|
||||||
|
appId: string,
|
||||||
|
appSecret: string,
|
||||||
|
timeoutMs: number = 5000,
|
||||||
|
domain?: string,
|
||||||
|
): Promise<FeishuProbe> {
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
const result: FeishuProbe = {
|
||||||
|
ok: false,
|
||||||
|
error: null,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiBase = resolveFeishuApiBase(domain);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Get tenant_access_token
|
||||||
|
const tokenRes = await fetchWithTimeout(
|
||||||
|
`${apiBase}/auth/v3/tenant_access_token/internal`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokenJson = (await tokenRes.json()) as TokenResponse;
|
||||||
|
if (tokenJson.code !== 0 || !tokenJson.tenant_access_token) {
|
||||||
|
result.error = tokenJson.msg || `Failed to get access token: code ${tokenJson.code}`;
|
||||||
|
result.elapsedMs = Date.now() - started;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenJson.tenant_access_token;
|
||||||
|
|
||||||
|
// Step 2: Get bot info
|
||||||
|
const botRes = await fetchWithTimeout(
|
||||||
|
`${apiBase}/bot/v3/info`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
const botJson = (await botRes.json()) as BotInfoResponse;
|
||||||
|
if (botJson.code !== 0) {
|
||||||
|
result.error = botJson.msg || `Failed to get bot info: code ${botJson.code}`;
|
||||||
|
result.elapsedMs = Date.now() - started;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ok = true;
|
||||||
|
result.bot = {
|
||||||
|
appId: appId,
|
||||||
|
appName: botJson.bot?.app_name ?? null,
|
||||||
|
avatarUrl: botJson.bot?.avatar_url ?? null,
|
||||||
|
};
|
||||||
|
result.elapsedMs = Date.now() - started;
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = formatErrorMessage(err);
|
||||||
|
logger.debug?.(`Feishu probe failed: ${errMsg}`);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
error: errMsg,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||||
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { containsMarkdown, markdownToFeishuPost } from "./format.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-send" });
|
||||||
|
|
||||||
|
export type FeishuMsgType = "text" | "image" | "file" | "audio" | "media" | "post" | "interactive";
|
||||||
|
|
||||||
|
export type FeishuSendOpts = {
|
||||||
|
msgType?: FeishuMsgType;
|
||||||
|
receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id";
|
||||||
|
/** URL of media to upload and send (for image/file/audio/media types) */
|
||||||
|
mediaUrl?: string;
|
||||||
|
/** Max bytes for media download */
|
||||||
|
maxBytes?: number;
|
||||||
|
/** Whether to auto-convert Markdown to rich text (post). Default: true */
|
||||||
|
autoRichText?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuSendResult = {
|
||||||
|
message_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeishuMessageContent = ({ text?: string } & Record<string, unknown>) | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an image to Feishu and get image_key
|
||||||
|
*/
|
||||||
|
export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise<string> {
|
||||||
|
const res = await client.im.image.create({
|
||||||
|
data: {
|
||||||
|
image_type: "message",
|
||||||
|
image: imageBuffer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res?.image_key) {
|
||||||
|
throw new Error(`Feishu image upload failed: no image_key returned`);
|
||||||
|
}
|
||||||
|
return res.image_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to Feishu and get file_key
|
||||||
|
* @param fileType - opus (audio), mp4 (video), pdf, doc, xls, ppt, stream (other)
|
||||||
|
*/
|
||||||
|
export async function uploadFileFeishu(
|
||||||
|
client: Client,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream",
|
||||||
|
duration?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
logger.info(
|
||||||
|
`Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let res: Awaited<ReturnType<typeof client.im.file.create>>;
|
||||||
|
try {
|
||||||
|
res = await client.im.file.create({
|
||||||
|
data: {
|
||||||
|
file_type: fileType,
|
||||||
|
file_name: fileName,
|
||||||
|
file: fileBuffer,
|
||||||
|
...(duration ? { duration } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = formatErrorMessage(err);
|
||||||
|
// Log the full error details
|
||||||
|
logger.error(`Feishu file upload exception: ${errMsg}`);
|
||||||
|
if (err && typeof err === "object") {
|
||||||
|
const response = (err as { response?: { data?: unknown; status?: number } }).response;
|
||||||
|
if (response?.data) {
|
||||||
|
logger.error(`Response data: ${JSON.stringify(response.data)}`);
|
||||||
|
}
|
||||||
|
if (response?.status) {
|
||||||
|
logger.error(`Response status: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Feishu file upload failed: ${errMsg}`, { cause: err });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full response for debugging
|
||||||
|
logger.info(`Feishu file upload response: ${JSON.stringify(res)}`);
|
||||||
|
|
||||||
|
const responseMeta =
|
||||||
|
res && typeof res === "object" ? (res as { code?: number; msg?: string }) : {};
|
||||||
|
// Check for API error code (if provided by SDK)
|
||||||
|
if (typeof responseMeta.code === "number" && responseMeta.code !== 0) {
|
||||||
|
const code = responseMeta.code;
|
||||||
|
const msg = responseMeta.msg || "unknown error";
|
||||||
|
logger.error(`Feishu file upload API error: code=${code}, msg=${msg}`);
|
||||||
|
throw new Error(`Feishu file upload failed: ${msg} (code: ${code})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileKey = res?.file_key;
|
||||||
|
if (!fileKey) {
|
||||||
|
logger.error(`Feishu file upload failed - no file_key in response: ${JSON.stringify(res)}`);
|
||||||
|
throw new Error(`Feishu file upload failed: no file_key returned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Feishu file upload successful: file_key=${fileKey}`);
|
||||||
|
return fileKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine Feishu file_type from content type
|
||||||
|
*/
|
||||||
|
function resolveFeishuFileType(
|
||||||
|
contentType?: string,
|
||||||
|
fileName?: string,
|
||||||
|
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
||||||
|
const ct = contentType?.toLowerCase() ?? "";
|
||||||
|
const fn = fileName?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
// Audio - Feishu only supports opus for audio messages
|
||||||
|
if (ct.includes("audio/") || fn.endsWith(".opus") || fn.endsWith(".ogg")) {
|
||||||
|
return "opus";
|
||||||
|
}
|
||||||
|
// Video
|
||||||
|
if (ct.includes("video/") || fn.endsWith(".mp4") || fn.endsWith(".mov")) {
|
||||||
|
return "mp4";
|
||||||
|
}
|
||||||
|
// Documents
|
||||||
|
if (ct.includes("pdf") || fn.endsWith(".pdf")) {
|
||||||
|
return "pdf";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ct.includes("msword") ||
|
||||||
|
ct.includes("wordprocessingml") ||
|
||||||
|
fn.endsWith(".doc") ||
|
||||||
|
fn.endsWith(".docx")
|
||||||
|
) {
|
||||||
|
return "doc";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ct.includes("excel") ||
|
||||||
|
ct.includes("spreadsheetml") ||
|
||||||
|
fn.endsWith(".xls") ||
|
||||||
|
fn.endsWith(".xlsx")
|
||||||
|
) {
|
||||||
|
return "xls";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ct.includes("powerpoint") ||
|
||||||
|
ct.includes("presentationml") ||
|
||||||
|
fn.endsWith(".ppt") ||
|
||||||
|
fn.endsWith(".pptx")
|
||||||
|
) {
|
||||||
|
return "ppt";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to Feishu
|
||||||
|
*/
|
||||||
|
export async function sendMessageFeishu(
|
||||||
|
client: Client,
|
||||||
|
receiveId: string,
|
||||||
|
content: FeishuMessageContent,
|
||||||
|
opts: FeishuSendOpts = {},
|
||||||
|
): Promise<FeishuSendResult | null> {
|
||||||
|
const receiveIdType = opts.receiveIdType || "chat_id";
|
||||||
|
let msgType = opts.msgType || "text";
|
||||||
|
let finalContent = content;
|
||||||
|
const contentText =
|
||||||
|
typeof content === "object" && content !== null && "text" in content
|
||||||
|
? (content as { text?: string }).text
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Handle media URL - upload first, then send
|
||||||
|
if (opts.mediaUrl) {
|
||||||
|
try {
|
||||||
|
logger.info(`Loading media from: ${opts.mediaUrl}`);
|
||||||
|
const media = await loadWebMedia(opts.mediaUrl, opts.maxBytes);
|
||||||
|
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||||
|
const fileName = media.fileName ?? "file";
|
||||||
|
logger.info(
|
||||||
|
`Media loaded: kind=${kind}, contentType=${media.contentType}, fileName=${fileName}, size=${media.buffer.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kind === "image") {
|
||||||
|
// Upload image and send as image message
|
||||||
|
const imageKey = await uploadImageFeishu(client, media.buffer);
|
||||||
|
msgType = "image";
|
||||||
|
finalContent = { image_key: imageKey };
|
||||||
|
} else if (kind === "video") {
|
||||||
|
// Upload video file and send as media message
|
||||||
|
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "mp4");
|
||||||
|
msgType = "media";
|
||||||
|
finalContent = { file_key: fileKey };
|
||||||
|
} else if (kind === "audio") {
|
||||||
|
// Feishu audio messages (msg_type: "audio") only support opus format
|
||||||
|
// For other audio formats (mp3, wav, etc.), send as file instead
|
||||||
|
const isOpus =
|
||||||
|
media.contentType?.includes("opus") ||
|
||||||
|
media.contentType?.includes("ogg") ||
|
||||||
|
fileName.toLowerCase().endsWith(".opus") ||
|
||||||
|
fileName.toLowerCase().endsWith(".ogg");
|
||||||
|
|
||||||
|
if (isOpus) {
|
||||||
|
logger.info(`Uploading opus audio: ${fileName}`);
|
||||||
|
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "opus");
|
||||||
|
logger.info(`Opus upload successful, file_key: ${fileKey}`);
|
||||||
|
msgType = "audio";
|
||||||
|
finalContent = { file_key: fileKey };
|
||||||
|
} else {
|
||||||
|
// Send non-opus audio as file attachment
|
||||||
|
logger.info(`Uploading non-opus audio as file: ${fileName}`);
|
||||||
|
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "stream");
|
||||||
|
logger.info(`File upload successful, file_key: ${fileKey}`);
|
||||||
|
msgType = "file";
|
||||||
|
finalContent = { file_key: fileKey };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Upload as file
|
||||||
|
const fileType = resolveFeishuFileType(media.contentType, fileName);
|
||||||
|
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, fileType);
|
||||||
|
msgType = "file";
|
||||||
|
finalContent = { file_key: fileKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's text alongside media, we need to send two messages
|
||||||
|
// First send the media, then send text as a follow-up
|
||||||
|
if (typeof contentText === "string" && contentText.trim()) {
|
||||||
|
// Send media first
|
||||||
|
const mediaRes = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: msgType,
|
||||||
|
content: JSON.stringify(finalContent),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mediaRes.code !== 0) {
|
||||||
|
logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`);
|
||||||
|
throw new Error(`Feishu API Error: ${mediaRes.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then send text
|
||||||
|
const textRes = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: "text",
|
||||||
|
content: JSON.stringify({ text: contentText }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return textRes.data ?? null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = formatErrorMessage(err);
|
||||||
|
const errStack = err instanceof Error ? err.stack : undefined;
|
||||||
|
logger.error(`Feishu media upload/send error: ${errMsg}`);
|
||||||
|
if (errStack) {
|
||||||
|
logger.error(`Stack: ${errStack}`);
|
||||||
|
}
|
||||||
|
// Re-throw the error instead of falling back to text
|
||||||
|
// This makes debugging easier and prevents silent failures
|
||||||
|
throw new Error(`Feishu media upload failed: ${errMsg}`, { cause: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-convert Markdown to rich text if enabled and content is text with Markdown
|
||||||
|
const autoRichText = opts.autoRichText !== false;
|
||||||
|
const finalText =
|
||||||
|
typeof finalContent === "object" && finalContent !== null && "text" in finalContent
|
||||||
|
? (finalContent as { text?: string }).text
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
autoRichText &&
|
||||||
|
msgType === "text" &&
|
||||||
|
typeof finalText === "string" &&
|
||||||
|
containsMarkdown(finalText)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const postContent = markdownToFeishuPost(finalText);
|
||||||
|
msgType = "post";
|
||||||
|
finalContent = postContent;
|
||||||
|
logger.debug(`Converted Markdown to Feishu post format`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to convert Markdown to post, falling back to text: ${formatErrorMessage(err)}`,
|
||||||
|
);
|
||||||
|
// Fall back to plain text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: msgType,
|
||||||
|
content: contentStr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.code !== 0) {
|
||||||
|
logger.error(`Feishu send failed: ${res.code} - ${res.msg}`);
|
||||||
|
throw new Error(`Feishu API Error: ${res.msg}`);
|
||||||
|
}
|
||||||
|
return res.data ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Feishu send error: ${formatErrorMessage(err)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
/**
|
||||||
|
* Feishu Streaming Card Support
|
||||||
|
*
|
||||||
|
* Implements typing indicator and streaming text output for Feishu using
|
||||||
|
* the Card Kit streaming API.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Create a card entity with streaming_mode: true
|
||||||
|
* 2. Send the card as a message (shows "[Generating...]" in chat preview)
|
||||||
|
* 3. Stream text updates to the card using the cardkit API
|
||||||
|
* 4. Close streaming mode when done
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||||
|
import { getChildLogger } from "../logging.js";
|
||||||
|
import { resolveFeishuApiBase, resolveFeishuDomain } from "./domain.js";
|
||||||
|
|
||||||
|
const logger = getChildLogger({ module: "feishu-streaming" });
|
||||||
|
|
||||||
|
export type FeishuStreamingCredentials = {
|
||||||
|
appId: string;
|
||||||
|
appSecret: string;
|
||||||
|
domain?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeishuStreamingCardState = {
|
||||||
|
cardId: string;
|
||||||
|
messageId: string;
|
||||||
|
sequence: number;
|
||||||
|
elementId: string;
|
||||||
|
currentText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token cache (keyed by domain + appId)
|
||||||
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
const getTokenCacheKey = (credentials: FeishuStreamingCredentials) =>
|
||||||
|
`${resolveFeishuDomain(credentials.domain)}|${credentials.appId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant access token (with caching)
|
||||||
|
*/
|
||||||
|
async function getTenantAccessToken(credentials: FeishuStreamingCredentials): Promise<string> {
|
||||||
|
const cacheKey = getTokenCacheKey(credentials);
|
||||||
|
const cached = tokenCache.get(cacheKey);
|
||||||
|
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||||
|
return cached.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = resolveFeishuApiBase(credentials.domain);
|
||||||
|
const response = await fetch(`${apiBase}/auth/v3/tenant_access_token/internal`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
app_id: credentials.appId,
|
||||||
|
app_secret: credentials.appSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
tenant_access_token?: string;
|
||||||
|
expire?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.tenant_access_token) {
|
||||||
|
throw new Error(`Failed to get tenant access token: ${result.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache token (expire 2 hours, we refresh 1 minute early)
|
||||||
|
tokenCache.set(cacheKey, {
|
||||||
|
token: result.tenant_access_token,
|
||||||
|
expiresAt: Date.now() + (result.expire ?? 7200) * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.tenant_access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a streaming card entity
|
||||||
|
*/
|
||||||
|
export async function createStreamingCard(
|
||||||
|
credentials: FeishuStreamingCredentials,
|
||||||
|
title?: string,
|
||||||
|
): Promise<{ cardId: string }> {
|
||||||
|
const cardJson = {
|
||||||
|
schema: "2.0",
|
||||||
|
...(title
|
||||||
|
? {
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
content: title,
|
||||||
|
tag: "plain_text",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
config: {
|
||||||
|
streaming_mode: true,
|
||||||
|
summary: {
|
||||||
|
content: "[Generating...]",
|
||||||
|
},
|
||||||
|
streaming_config: {
|
||||||
|
print_frequency_ms: { default: 50 },
|
||||||
|
print_step: { default: 2 },
|
||||||
|
print_strategy: "fast",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
tag: "markdown",
|
||||||
|
content: "⏳ Thinking...",
|
||||||
|
element_id: "streaming_content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiBase = resolveFeishuApiBase(credentials.domain);
|
||||||
|
const response = await fetch(`${apiBase}/cardkit/v1/cards`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "card_json",
|
||||||
|
data: JSON.stringify(cardJson),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data?: { card_id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.code !== 0 || !result.data?.card_id) {
|
||||||
|
throw new Error(`Failed to create streaming card: ${result.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Created streaming card: ${result.data.card_id}`);
|
||||||
|
return { cardId: result.data.card_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a streaming card as a message
|
||||||
|
*/
|
||||||
|
export async function sendStreamingCard(
|
||||||
|
client: Client,
|
||||||
|
receiveId: string,
|
||||||
|
cardId: string,
|
||||||
|
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
||||||
|
): Promise<{ messageId: string }> {
|
||||||
|
const content = JSON.stringify({
|
||||||
|
type: "card",
|
||||||
|
data: { card_id: cardId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await client.im.message.create({
|
||||||
|
params: { receive_id_type: receiveIdType },
|
||||||
|
data: {
|
||||||
|
receive_id: receiveId,
|
||||||
|
msg_type: "interactive",
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.code !== 0 || !res.data?.message_id) {
|
||||||
|
throw new Error(`Failed to send streaming card: ${res.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Sent streaming card message: ${res.data.message_id}`);
|
||||||
|
return { messageId: res.data.message_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update streaming card text content
|
||||||
|
*/
|
||||||
|
export async function updateStreamingCardText(
|
||||||
|
credentials: FeishuStreamingCredentials,
|
||||||
|
cardId: string,
|
||||||
|
elementId: string,
|
||||||
|
text: string,
|
||||||
|
sequence: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const apiBase = resolveFeishuApiBase(credentials.domain);
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/cardkit/v1/cards/${cardId}/elements/${elementId}/content`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: text,
|
||||||
|
sequence,
|
||||||
|
uuid: `stream_${cardId}_${sequence}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = (await response.json()) as { code: number; msg: string };
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
logger.warn(`Failed to update streaming card text: ${result.msg}`);
|
||||||
|
// Don't throw - streaming updates can fail occasionally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close streaming mode on a card
|
||||||
|
*/
|
||||||
|
export async function closeStreamingMode(
|
||||||
|
credentials: FeishuStreamingCredentials,
|
||||||
|
cardId: string,
|
||||||
|
sequence: number,
|
||||||
|
finalSummary?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Build config object - summary must be set to clear "[Generating...]"
|
||||||
|
const configObj: Record<string, unknown> = {
|
||||||
|
streaming_mode: false,
|
||||||
|
summary: { content: finalSummary || "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = { config: configObj };
|
||||||
|
|
||||||
|
const apiBase = resolveFeishuApiBase(credentials.domain);
|
||||||
|
const response = await fetch(`${apiBase}/cardkit/v1/cards/${cardId}/settings`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getTenantAccessToken(credentials)}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: JSON.stringify(settings),
|
||||||
|
sequence,
|
||||||
|
uuid: `close_${cardId}_${sequence}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
const result = (await response.json()) as { code: number; msg: string };
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
logger.warn(`Failed to close streaming mode: ${result.msg}`);
|
||||||
|
} else {
|
||||||
|
logger.debug(`Closed streaming mode for card: ${cardId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High-level streaming card manager
|
||||||
|
*/
|
||||||
|
export class FeishuStreamingSession {
|
||||||
|
private client: Client;
|
||||||
|
private credentials: FeishuStreamingCredentials;
|
||||||
|
private state: FeishuStreamingCardState | null = null;
|
||||||
|
private updateQueue: Promise<void> = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(client: Client, credentials: FeishuStreamingCredentials) {
|
||||||
|
this.client = client;
|
||||||
|
this.credentials = credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a streaming session - creates and sends a streaming card
|
||||||
|
*/
|
||||||
|
async start(
|
||||||
|
receiveId: string,
|
||||||
|
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
||||||
|
title?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.state) {
|
||||||
|
logger.warn("Streaming session already started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { cardId } = await createStreamingCard(this.credentials, title);
|
||||||
|
const { messageId } = await sendStreamingCard(this.client, receiveId, cardId, receiveIdType);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
cardId,
|
||||||
|
messageId,
|
||||||
|
sequence: 1,
|
||||||
|
elementId: "streaming_content",
|
||||||
|
currentText: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Started streaming session: cardId=${cardId}, messageId=${messageId}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to start streaming session: ${String(err)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the streaming card with new text (appends to existing)
|
||||||
|
*/
|
||||||
|
async update(text: string): Promise<void> {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue updates to ensure order
|
||||||
|
this.updateQueue = this.updateQueue.then(async () => {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.currentText = text;
|
||||||
|
this.state.sequence += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateStreamingCardText(
|
||||||
|
this.credentials,
|
||||||
|
this.state.cardId,
|
||||||
|
this.state.elementId,
|
||||||
|
text,
|
||||||
|
this.state.sequence,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`Streaming update failed (will retry): ${String(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.updateQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalize and close the streaming session
|
||||||
|
*/
|
||||||
|
async close(finalText?: string, summary?: string): Promise<void> {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closed = true;
|
||||||
|
|
||||||
|
// Wait for pending updates
|
||||||
|
await this.updateQueue;
|
||||||
|
|
||||||
|
const text = finalText ?? this.state.currentText;
|
||||||
|
this.state.sequence += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update final text
|
||||||
|
if (text) {
|
||||||
|
await updateStreamingCardText(
|
||||||
|
this.credentials,
|
||||||
|
this.state.cardId,
|
||||||
|
this.state.elementId,
|
||||||
|
text,
|
||||||
|
this.state.sequence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close streaming mode
|
||||||
|
this.state.sequence += 1;
|
||||||
|
await closeStreamingMode(
|
||||||
|
this.credentials,
|
||||||
|
this.state.cardId,
|
||||||
|
this.state.sequence,
|
||||||
|
summary ?? truncateForSummary(text),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Closed streaming session: cardId=${this.state.cardId}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Failed to close streaming session: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is active
|
||||||
|
*/
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.state !== null && !this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message ID of the streaming card
|
||||||
|
*/
|
||||||
|
getMessageId(): string | null {
|
||||||
|
return this.state?.messageId ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text to create a summary for chat preview
|
||||||
|
*/
|
||||||
|
function truncateForSummary(text: string, maxLength: number = 50): string {
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const cleaned = text.replace(/\n/g, " ").trim();
|
||||||
|
if (cleaned.length <= maxLength) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
return cleaned.slice(0, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { FeishuAccountConfig, FeishuConfig } from "../config/types.feishu.js";
|
||||||
|
|
||||||
|
export type { FeishuConfig, FeishuAccountConfig };
|
||||||
|
|
||||||
|
export type FeishuContext = {
|
||||||
|
appId: string;
|
||||||
|
chatId?: string;
|
||||||
|
openId?: string;
|
||||||
|
userId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
messageType?: string;
|
||||||
|
text?: string;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
@ -816,6 +816,61 @@ function resolveTlonSession(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feishu ID formats:
|
||||||
|
* - oc_xxx: chat_id (group chat)
|
||||||
|
* - ou_xxx: user open_id (DM)
|
||||||
|
* - on_xxx: user union_id (DM)
|
||||||
|
* - cli_xxx: app_id (not a valid send target)
|
||||||
|
*/
|
||||||
|
function resolveFeishuSession(
|
||||||
|
params: ResolveOutboundSessionRouteParams,
|
||||||
|
): OutboundSessionRoute | null {
|
||||||
|
let trimmed = stripProviderPrefix(params.target, "feishu");
|
||||||
|
trimmed = stripProviderPrefix(trimmed, "lark").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
let isGroup = false;
|
||||||
|
|
||||||
|
if (lower.startsWith("group:") || lower.startsWith("chat:")) {
|
||||||
|
trimmed = trimmed.replace(/^(group|chat):/i, "").trim();
|
||||||
|
isGroup = true;
|
||||||
|
} else if (lower.startsWith("user:") || lower.startsWith("dm:")) {
|
||||||
|
trimmed = trimmed.replace(/^(user|dm):/i, "").trim();
|
||||||
|
isGroup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idLower = trimmed.toLowerCase();
|
||||||
|
if (idLower.startsWith("oc_")) {
|
||||||
|
isGroup = true;
|
||||||
|
} else if (idLower.startsWith("ou_") || idLower.startsWith("on_")) {
|
||||||
|
isGroup = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer: RoutePeer = {
|
||||||
|
kind: isGroup ? "group" : "dm",
|
||||||
|
id: trimmed,
|
||||||
|
};
|
||||||
|
const baseSessionKey = buildBaseSessionKey({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentId: params.agentId,
|
||||||
|
channel: "feishu",
|
||||||
|
accountId: params.accountId,
|
||||||
|
peer,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
sessionKey: baseSessionKey,
|
||||||
|
baseSessionKey,
|
||||||
|
peer,
|
||||||
|
chatType: isGroup ? "group" : "direct",
|
||||||
|
from: isGroup ? `feishu:group:${trimmed}` : `feishu:${trimmed}`,
|
||||||
|
to: trimmed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFallbackSession(
|
function resolveFallbackSession(
|
||||||
params: ResolveOutboundSessionRouteParams,
|
params: ResolveOutboundSessionRouteParams,
|
||||||
): OutboundSessionRoute | null {
|
): OutboundSessionRoute | null {
|
||||||
|
|
@ -890,6 +945,8 @@ export async function resolveOutboundSessionRoute(
|
||||||
return resolveNostrSession({ ...params, target });
|
return resolveNostrSession({ ...params, target });
|
||||||
case "tlon":
|
case "tlon":
|
||||||
return resolveTlonSession({ ...params, target });
|
return resolveTlonSession({ ...params, target });
|
||||||
|
case "feishu":
|
||||||
|
return resolveFeishuSession({ ...params, target });
|
||||||
default:
|
default:
|
||||||
return resolveFallbackSession({ ...params, target });
|
return resolveFallbackSession({ ...params, target });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -369,5 +369,22 @@ export {
|
||||||
} from "../line/markdown-to-line.js";
|
} from "../line/markdown-to-line.js";
|
||||||
export type { ProcessedLineMessage } from "../line/markdown-to-line.js";
|
export type { ProcessedLineMessage } from "../line/markdown-to-line.js";
|
||||||
|
|
||||||
|
// Channel: Feishu
|
||||||
|
export {
|
||||||
|
listFeishuAccountIds,
|
||||||
|
resolveDefaultFeishuAccountId,
|
||||||
|
resolveFeishuAccount,
|
||||||
|
type ResolvedFeishuAccount,
|
||||||
|
} from "../feishu/accounts.js";
|
||||||
|
export {
|
||||||
|
resolveFeishuConfig,
|
||||||
|
resolveFeishuGroupEnabled,
|
||||||
|
resolveFeishuGroupRequireMention,
|
||||||
|
} from "../feishu/config.js";
|
||||||
|
export { feishuOutbound } from "../channels/plugins/outbound/feishu.js";
|
||||||
|
export { normalizeFeishuTarget } from "../channels/plugins/normalize/feishu.js";
|
||||||
|
export { probeFeishu, type FeishuProbe } from "../feishu/probe.js";
|
||||||
|
export { monitorFeishuProvider } from "../feishu/monitor.js";
|
||||||
|
|
||||||
// Media utilities
|
// Media utilities
|
||||||
export { loadWebMedia, type WebMediaResult } from "../web/media.js";
|
export { loadWebMedia, type WebMediaResult } from "../web/media.js";
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ vi.mock("../auto-reply/reply.js", () => {
|
||||||
|
|
||||||
describe("telegram inbound media", () => {
|
describe("telegram inbound media", () => {
|
||||||
// Parallel vitest shards can make this suite slower than the standalone run.
|
// Parallel vitest shards can make this suite slower than the standalone run.
|
||||||
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000;
|
const INBOUND_MEDIA_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 90_000;
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"downloads media via file_path (no file.download)",
|
"downloads media via file_path (no file.download)",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue