Merge 4d2e1e03b0 into 09be5d45d5
This commit is contained in:
commit
028f41e42b
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@ -3,6 +3,11 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/bluebubbles/**"
|
||||
- "docs/channels/bluebubbles.md"
|
||||
"channel: feishu":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/feishu/**"
|
||||
- "docs/channels/feishu.md"
|
||||
"channel: discord":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
187
docs/channels/feishu.md
Normal file
187
docs/channels/feishu.md
Normal file
@ -0,0 +1,187 @@
|
||||
---
|
||||
summary: "Feishu/Lark (飞书) bot support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Feishu features or integration
|
||||
---
|
||||
# Feishu (飞书 / Lark)
|
||||
|
||||
Status: production-ready for bot DMs + groups via WebSocket long connection.
|
||||
|
||||
## Quick setup
|
||||
|
||||
1. Create a self-built app on [Feishu Open Platform](https://open.feishu.cn) (or [Lark Developer](https://open.larksuite.com) for international).
|
||||
2. Get your App ID and App Secret from the Credentials page.
|
||||
3. Enable required permissions (see below).
|
||||
4. Configure event subscriptions (see below).
|
||||
5. Set the credentials:
|
||||
|
||||
```bash
|
||||
openclaw config set channels.feishu.appId "cli_xxxxx"
|
||||
openclaw config set channels.feishu.appSecret "your_app_secret"
|
||||
openclaw config set channels.feishu.enabled true
|
||||
```
|
||||
|
||||
Minimal config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "cli_xxxxx",
|
||||
appSecret: "secret",
|
||||
dmPolicy: "pairing"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Required permissions
|
||||
|
||||
| Permission | Scope | Description |
|
||||
|------------|-------|-------------|
|
||||
| `contact:user.base:readonly` | User info | Get basic user info (required to resolve sender display names) |
|
||||
| `im:message` | Messaging | Send and receive messages |
|
||||
| `im:message.p2p_msg:readonly` | DM | Read direct messages to bot |
|
||||
| `im:message.group_at_msg:readonly` | Group | Receive @mention messages in groups |
|
||||
| `im:message:send_as_bot` | Send | Send messages as the bot |
|
||||
| `im:resource` | Media | Upload and download images/files |
|
||||
|
||||
## Optional permissions
|
||||
|
||||
| Permission | Scope | Description |
|
||||
|------------|-------|-------------|
|
||||
| `im:message.group_msg` | Group | Read all group messages (sensitive) |
|
||||
| `im:message:readonly` | Read | Get message history |
|
||||
| `im:message:update` | Edit | Update/edit sent messages |
|
||||
| `im:message:recall` | Recall | Recall sent messages |
|
||||
| `im:message.reactions:read` | Reactions | View message reactions |
|
||||
|
||||
## Event subscriptions
|
||||
|
||||
In the Feishu Open Platform console, go to **Events & Callbacks**:
|
||||
|
||||
1. **Event configuration**: Select **Long connection** (recommended).
|
||||
2. **Add event subscriptions**:
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `im.message.receive_v1` | Receive messages (required) |
|
||||
| `im.message.message_read_v1` | Message read receipts |
|
||||
| `im.chat.member.bot.added_v1` | Bot added to group |
|
||||
| `im.chat.member.bot.deleted_v1` | Bot removed from group |
|
||||
|
||||
3. Ensure the event permissions are approved.
|
||||
|
||||
## Configuration options
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "cli_xxxxx",
|
||||
appSecret: "secret",
|
||||
// Domain: "feishu" (China) or "lark" (International)
|
||||
domain: "feishu",
|
||||
// Connection mode: "websocket" (recommended) or "webhook"
|
||||
connectionMode: "websocket",
|
||||
// DM policy: "pairing" | "open" | "allowlist"
|
||||
dmPolicy: "pairing",
|
||||
// Group policy: "open" | "allowlist" | "disabled"
|
||||
groupPolicy: "allowlist",
|
||||
// Require @mention in groups
|
||||
requireMention: true,
|
||||
// Max media size in MB (default: 30)
|
||||
mediaMaxMb: 30,
|
||||
// Render mode for bot replies: "auto" | "raw" | "card"
|
||||
renderMode: "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Render mode
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `auto` | (Default) Automatically detect: use card for messages with code blocks or tables, plain text otherwise. |
|
||||
| `raw` | Always send replies as plain text. Markdown tables are converted to ASCII. |
|
||||
| `card` | Always send replies as interactive cards with full markdown rendering (syntax highlighting, tables, clickable links). |
|
||||
|
||||
### Domain setting
|
||||
|
||||
- `feishu`: China mainland (open.feishu.cn)
|
||||
- `lark`: International (open.larksuite.com)
|
||||
|
||||
## Features
|
||||
|
||||
- WebSocket and Webhook connection modes
|
||||
- Direct messages and group chats
|
||||
- Message replies and quoted message context
|
||||
- Inbound media support: AI can see images, read files (PDF, Excel, etc.), and process rich text with embedded images
|
||||
- Image and file uploads (outbound)
|
||||
- Typing indicator (via emoji reactions)
|
||||
- Pairing flow for DM approval
|
||||
- User and group directory lookup
|
||||
- Card render mode with syntax highlighting
|
||||
|
||||
## Access control
|
||||
|
||||
### DM access
|
||||
|
||||
- Default: `channels.feishu.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved.
|
||||
- Approve via:
|
||||
- `openclaw pairing list feishu`
|
||||
- `openclaw pairing approve feishu <CODE>`
|
||||
|
||||
### Group access
|
||||
|
||||
- `channels.feishu.groupPolicy`: `open | allowlist | disabled` (default: allowlist)
|
||||
- `channels.feishu.requireMention`: Require @bot mention in groups (default: true)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bot cannot receive messages
|
||||
|
||||
Check the following:
|
||||
1. Have you configured event subscriptions?
|
||||
2. Is the event configuration set to **long connection**?
|
||||
3. Did you add the `im.message.receive_v1` event?
|
||||
4. Are the permissions approved?
|
||||
|
||||
### 403 error when sending messages
|
||||
|
||||
Ensure `im:message:send_as_bot` permission is approved.
|
||||
|
||||
### How to clear history / start new conversation
|
||||
|
||||
Send `/new` command in the chat.
|
||||
|
||||
### Why is the output not streaming
|
||||
|
||||
Feishu API has rate limits. Streaming updates can easily trigger throttling. OpenClaw uses complete-then-send approach for stability.
|
||||
|
||||
### Cannot find the bot in Feishu
|
||||
|
||||
1. Ensure the app is published (at least to test version).
|
||||
2. Search for the bot name in Feishu search box.
|
||||
3. Check if your account is in the app's availability scope.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
- `channels.feishu.enabled`: enable/disable channel startup.
|
||||
- `channels.feishu.appId`: Feishu app ID.
|
||||
- `channels.feishu.appSecret`: Feishu app secret.
|
||||
- `channels.feishu.domain`: `feishu` (China) or `lark` (International).
|
||||
- `channels.feishu.connectionMode`: `websocket` (default) or `webhook`.
|
||||
- `channels.feishu.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.feishu.allowFrom`: DM allowlist (user IDs).
|
||||
- `channels.feishu.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.feishu.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.feishu.requireMention`: require @mention in groups (default: true).
|
||||
- `channels.feishu.renderMode`: `auto | raw | card` (default: auto).
|
||||
- `channels.feishu.mediaMaxMb`: inbound/outbound media cap (MB, default: 30).
|
||||
@ -29,6 +29,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [Feishu](/channels/feishu) — Feishu/Lark (飞书) Bot API; China/International enterprise messaging (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
## Notes
|
||||
|
||||
@ -1012,6 +1012,7 @@
|
||||
"channels/matrix",
|
||||
"channels/zalo",
|
||||
"channels/zalouser",
|
||||
"channels/feishu",
|
||||
"broadcast-groups",
|
||||
"channels/troubleshooting",
|
||||
"channels/location"
|
||||
|
||||
41
extensions/feishu/index.ts
Normal file
41
extensions/feishu/index.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { feishuPlugin } from "./src/channel.js";
|
||||
import { setFeishuRuntime } from "./src/runtime.js";
|
||||
|
||||
export { monitorFeishuProvider } from "./src/monitor.js";
|
||||
export {
|
||||
sendMessageFeishu,
|
||||
sendCardFeishu,
|
||||
updateCardFeishu,
|
||||
editMessageFeishu,
|
||||
getMessageFeishu,
|
||||
} from "./src/send.js";
|
||||
export {
|
||||
uploadImageFeishu,
|
||||
uploadFileFeishu,
|
||||
sendImageFeishu,
|
||||
sendFileFeishu,
|
||||
sendMediaFeishu,
|
||||
} from "./src/media.js";
|
||||
export { probeFeishu } from "./src/probe.js";
|
||||
export {
|
||||
addReactionFeishu,
|
||||
removeReactionFeishu,
|
||||
listReactionsFeishu,
|
||||
FeishuEmoji,
|
||||
} from "./src/reactions.js";
|
||||
export { feishuPlugin } from "./src/channel.js";
|
||||
|
||||
const plugin = {
|
||||
id: "feishu",
|
||||
name: "Feishu",
|
||||
description: "Feishu/Lark channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/feishu/openclaw.plugin.json
Normal file
9
extensions/feishu/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "feishu",
|
||||
"channels": ["feishu"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
32
extensions/feishu/package.json
Normal file
32
extensions/feishu/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.1.29",
|
||||
"type": "module",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Feishu/Lark (飞书)",
|
||||
"docsPath": "/channels/feishu",
|
||||
"docsLabel": "feishu",
|
||||
"blurb": "Feishu/Lark enterprise messaging for China and International markets.",
|
||||
"aliases": [
|
||||
"lark"
|
||||
],
|
||||
"order": 72,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/feishu",
|
||||
"localPath": "extensions/feishu",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.30.0"
|
||||
}
|
||||
}
|
||||
53
extensions/feishu/src/accounts.ts
Normal file
53
extensions/feishu/src/accounts.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig, FeishuDomain, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
export function resolveFeishuCredentials(cfg?: FeishuConfig): {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
encryptKey?: string;
|
||||
verificationToken?: string;
|
||||
domain: FeishuDomain;
|
||||
} | null {
|
||||
const appId = cfg?.appId?.trim();
|
||||
const appSecret = cfg?.appSecret?.trim();
|
||||
if (!appId || !appSecret) return null;
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
encryptKey: cfg?.encryptKey?.trim() || undefined,
|
||||
verificationToken: cfg?.verificationToken?.trim() || undefined,
|
||||
domain: cfg?.domain ?? "feishu",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveFeishuAccount(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedFeishuAccount {
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const enabled = feishuCfg?.enabled !== false;
|
||||
const creds = resolveFeishuCredentials(feishuCfg);
|
||||
|
||||
return {
|
||||
accountId: params.accountId?.trim() || DEFAULT_ACCOUNT_ID,
|
||||
enabled,
|
||||
configured: Boolean(creds),
|
||||
appId: creds?.appId,
|
||||
domain: creds?.domain ?? "feishu",
|
||||
};
|
||||
}
|
||||
|
||||
export function listFeishuAccountIds(_cfg: ClawdbotConfig): string[] {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
export function resolveDefaultFeishuAccountId(_cfg: ClawdbotConfig): string {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function listEnabledFeishuAccounts(cfg: ClawdbotConfig): ResolvedFeishuAccount[] {
|
||||
return listFeishuAccountIds(cfg)
|
||||
.map((accountId) => resolveFeishuAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled && account.configured);
|
||||
}
|
||||
654
extensions/feishu/src/bot.ts
Normal file
654
extensions/feishu/src/bot.ts
Normal file
@ -0,0 +1,654 @@
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
clearHistoryEntriesIfEnabled,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import {
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuReplyPolicy,
|
||||
resolveFeishuAllowlistMatch,
|
||||
isFeishuGroupAllowed,
|
||||
} from "./policy.js";
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
||||
|
||||
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
||||
// Cache display names by open_id to avoid an API call on every message.
|
||||
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
||||
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
||||
|
||||
async function resolveFeishuSenderName(params: {
|
||||
feishuCfg?: FeishuConfig;
|
||||
senderOpenId: string;
|
||||
log: (...args: any[]) => void;
|
||||
}): Promise<string | undefined> {
|
||||
const { feishuCfg, senderOpenId, log } = params;
|
||||
if (!feishuCfg) return undefined;
|
||||
if (!senderOpenId) return undefined;
|
||||
|
||||
const cached = senderNameCache.get(senderOpenId);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) return cached.name;
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
// contact/v3/users/:user_id?user_id_type=open_id
|
||||
const res: any = await client.contact.user.get({
|
||||
path: { user_id: senderOpenId },
|
||||
params: { user_id_type: "open_id" },
|
||||
});
|
||||
|
||||
const name: string | undefined =
|
||||
res?.data?.user?.name ||
|
||||
res?.data?.user?.display_name ||
|
||||
res?.data?.user?.nickname ||
|
||||
res?.data?.user?.en_name;
|
||||
|
||||
if (name && typeof name === "string") {
|
||||
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
||||
return name;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
// Best-effort. Don't fail message handling if name lookup fails.
|
||||
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
sender_type?: string;
|
||||
tenant_key?: string;
|
||||
};
|
||||
message: {
|
||||
message_id: string;
|
||||
root_id?: string;
|
||||
parent_id?: string;
|
||||
chat_id: string;
|
||||
chat_type: "p2p" | "group";
|
||||
message_type: string;
|
||||
content: string;
|
||||
mentions?: Array<{
|
||||
key: string;
|
||||
id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
name: string;
|
||||
tenant_key?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type FeishuBotAddedEvent = {
|
||||
chat_id: string;
|
||||
operator_id: {
|
||||
open_id?: string;
|
||||
user_id?: string;
|
||||
union_id?: string;
|
||||
};
|
||||
external: boolean;
|
||||
operator_tenant_key?: string;
|
||||
};
|
||||
|
||||
function parseMessageContent(content: string, messageType: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (messageType === "text") {
|
||||
return parsed.text || "";
|
||||
}
|
||||
if (messageType === "post") {
|
||||
// Extract text content from rich text post
|
||||
const { textContent } = parsePostContent(content);
|
||||
return textContent;
|
||||
}
|
||||
return content;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length === 0) return false;
|
||||
if (!botOpenId) return mentions.length > 0;
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
|
||||
function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
|
||||
if (!mentions || mentions.length === 0) return text;
|
||||
let result = text;
|
||||
for (const mention of mentions) {
|
||||
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
||||
result = result.replace(new RegExp(mention.key, "g"), "").trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse media keys from message content based on message type.
|
||||
*/
|
||||
function parseMediaKeys(
|
||||
content: string,
|
||||
messageType: string,
|
||||
): {
|
||||
imageKey?: string;
|
||||
fileKey?: string;
|
||||
fileName?: string;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
switch (messageType) {
|
||||
case "image":
|
||||
return { imageKey: parsed.image_key };
|
||||
case "file":
|
||||
return { fileKey: parsed.file_key, fileName: parsed.file_name };
|
||||
case "audio":
|
||||
return { fileKey: parsed.file_key };
|
||||
case "video":
|
||||
// Video has both file_key (video) and image_key (thumbnail)
|
||||
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
|
||||
case "sticker":
|
||||
return { fileKey: parsed.file_key };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse post (rich text) content and extract embedded image keys.
|
||||
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
|
||||
*/
|
||||
function parsePostContent(content: string): {
|
||||
textContent: string;
|
||||
imageKeys: string[];
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const title = parsed.title || "";
|
||||
const contentBlocks = parsed.content || [];
|
||||
let textContent = title ? `${title}\n\n` : "";
|
||||
const imageKeys: string[] = [];
|
||||
|
||||
for (const paragraph of contentBlocks) {
|
||||
if (Array.isArray(paragraph)) {
|
||||
for (const element of paragraph) {
|
||||
if (element.tag === "text") {
|
||||
textContent += element.text || "";
|
||||
} else if (element.tag === "a") {
|
||||
// Link: show text or href
|
||||
textContent += element.text || element.href || "";
|
||||
} else if (element.tag === "at") {
|
||||
// Mention: @username
|
||||
textContent += `@${element.user_name || element.user_id || ""}`;
|
||||
} else if (element.tag === "img" && element.image_key) {
|
||||
// Embedded image
|
||||
imageKeys.push(element.image_key);
|
||||
}
|
||||
}
|
||||
textContent += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: textContent.trim() || "[富文本消息]",
|
||||
imageKeys,
|
||||
};
|
||||
} catch {
|
||||
return { textContent: "[富文本消息]", imageKeys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer placeholder text based on message type.
|
||||
*/
|
||||
function inferPlaceholder(messageType: string): string {
|
||||
switch (messageType) {
|
||||
case "image":
|
||||
return "<media:image>";
|
||||
case "file":
|
||||
return "<media:document>";
|
||||
case "audio":
|
||||
return "<media:audio>";
|
||||
case "video":
|
||||
return "<media:video>";
|
||||
case "sticker":
|
||||
return "<media:sticker>";
|
||||
default:
|
||||
return "<media:document>";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve media from a Feishu message, downloading and saving to disk.
|
||||
* Similar to Discord's resolveMediaList().
|
||||
*/
|
||||
async function resolveFeishuMediaList(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
maxBytes: number;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<FeishuMediaInfo[]> {
|
||||
const { cfg, messageId, messageType, content, maxBytes, log } = params;
|
||||
|
||||
// Only process media message types (including post for embedded images)
|
||||
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
||||
if (!mediaTypes.includes(messageType)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: FeishuMediaInfo[] = [];
|
||||
const core = getFeishuRuntime();
|
||||
|
||||
// Handle post (rich text) messages with embedded images
|
||||
if (messageType === "post") {
|
||||
const { imageKeys } = parsePostContent(content);
|
||||
if (imageKeys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
||||
|
||||
for (const imageKey of imageKeys) {
|
||||
try {
|
||||
// Embedded images in post use messageResource API with image_key as file_key
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey: imageKey,
|
||||
type: "image",
|
||||
});
|
||||
|
||||
let contentType = result.contentType;
|
||||
if (!contentType) {
|
||||
contentType = await core.media.detectMime({ buffer: result.buffer });
|
||||
}
|
||||
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
result.buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
|
||||
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
||||
} catch (err) {
|
||||
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Handle other media types
|
||||
const mediaKeys = parseMediaKeys(content, messageType);
|
||||
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
let buffer: Buffer;
|
||||
let contentType: string | undefined;
|
||||
let fileName: string | undefined;
|
||||
|
||||
// For message media, always use messageResource API
|
||||
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
||||
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
|
||||
if (!fileKey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourceType = messageType === "image" ? "image" : "file";
|
||||
const result = await downloadMessageResourceFeishu({
|
||||
cfg,
|
||||
messageId,
|
||||
fileKey,
|
||||
type: resourceType,
|
||||
});
|
||||
buffer = result.buffer;
|
||||
contentType = result.contentType;
|
||||
fileName = result.fileName || mediaKeys.fileName;
|
||||
|
||||
// Detect mime type if not provided
|
||||
if (!contentType) {
|
||||
contentType = await core.media.detectMime({ buffer });
|
||||
}
|
||||
|
||||
// Save to disk using core's saveMediaBuffer
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
fileName,
|
||||
);
|
||||
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder(messageType),
|
||||
});
|
||||
|
||||
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
||||
} catch (err) {
|
||||
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build media payload for inbound context.
|
||||
* Similar to Discord's buildDiscordMediaPayload().
|
||||
*/
|
||||
function buildFeishuMediaPayload(
|
||||
mediaList: FeishuMediaInfo[],
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
const first = mediaList[0];
|
||||
const mediaPaths = mediaList.map((media) => media.path);
|
||||
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
||||
return {
|
||||
MediaPath: first?.path,
|
||||
MediaType: first?.contentType,
|
||||
MediaUrl: first?.path,
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseFeishuMessageEvent(
|
||||
event: FeishuMessageEvent,
|
||||
botOpenId?: string,
|
||||
): FeishuMessageContext {
|
||||
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
||||
const mentionedBot = checkBotMentioned(event, botOpenId);
|
||||
const content = stripBotMention(rawContent, event.message.mentions);
|
||||
|
||||
return {
|
||||
chatId: event.message.chat_id,
|
||||
messageId: event.message.message_id,
|
||||
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
|
||||
senderOpenId: event.sender.sender_id.open_id || "",
|
||||
chatType: event.message.chat_type,
|
||||
mentionedBot,
|
||||
rootId: event.message.root_id || undefined,
|
||||
parentId: event.message.parent_id || undefined,
|
||||
content,
|
||||
contentType: event.message.message_type,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleFeishuMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuMessageEvent;
|
||||
botOpenId?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
}): Promise<void> {
|
||||
const { cfg, event, botOpenId, runtime, chatHistories } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
|
||||
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
||||
const senderName = await resolveFeishuSenderName({
|
||||
feishuCfg,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
log,
|
||||
});
|
||||
if (senderName) ctx = { ...ctx, senderName };
|
||||
|
||||
log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
|
||||
|
||||
const historyLimit = Math.max(
|
||||
0,
|
||||
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
);
|
||||
|
||||
if (isGroup) {
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
|
||||
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
||||
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
||||
|
||||
const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom;
|
||||
const allowed = isFeishuGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: senderAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderName: ctx.senderName,
|
||||
});
|
||||
|
||||
if (!allowed) {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group allowlist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { requireMention } = resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
globalConfig: feishuCfg,
|
||||
groupConfig,
|
||||
});
|
||||
|
||||
if (requireMention && !ctx.mentionedBot) {
|
||||
log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`);
|
||||
if (chatHistories) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey: ctx.chatId,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: ctx.senderOpenId,
|
||||
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
||||
timestamp: Date.now(),
|
||||
messageId: ctx.messageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
||||
const allowFrom = feishuCfg?.allowFrom ?? [];
|
||||
|
||||
if (dmPolicy === "allowlist") {
|
||||
const match = resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
});
|
||||
if (!match.allowed) {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const core = getFeishuRuntime();
|
||||
|
||||
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
||||
// Using a group-scoped From causes the agent to treat different users as the same person.
|
||||
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? ctx.chatId : ctx.senderOpenId,
|
||||
},
|
||||
});
|
||||
|
||||
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isGroup
|
||||
? `Feishu message in group ${ctx.chatId}`
|
||||
: `Feishu DM from ${ctx.senderOpenId}`;
|
||||
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
|
||||
});
|
||||
|
||||
// Resolve media from message
|
||||
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
||||
const mediaList = await resolveFeishuMediaList({
|
||||
cfg,
|
||||
messageId: ctx.messageId,
|
||||
messageType: event.message.message_type,
|
||||
content: event.message.content,
|
||||
maxBytes: mediaMaxBytes,
|
||||
log,
|
||||
});
|
||||
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
let quotedContent: string | undefined;
|
||||
if (ctx.parentId) {
|
||||
try {
|
||||
const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId });
|
||||
if (quotedMsg) {
|
||||
quotedContent = quotedMsg.content;
|
||||
log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`feishu: failed to fetch quoted message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
|
||||
// Build message body with quoted content if available
|
||||
let messageBody = ctx.content;
|
||||
if (quotedContent) {
|
||||
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
||||
}
|
||||
|
||||
// Include a readable speaker label so the model can attribute instructions.
|
||||
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
||||
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
||||
messageBody = `${speaker}: ${messageBody}`;
|
||||
|
||||
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
||||
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: envelopeFrom,
|
||||
timestamp: new Date(),
|
||||
envelope: envelopeOptions,
|
||||
body: messageBody,
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
const historyKey = isGroup ? ctx.chatId : undefined;
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
currentMessage: combinedBody,
|
||||
formatEntry: (entry) =>
|
||||
core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
// Preserve speaker identity in group history as well.
|
||||
from: `${ctx.chatId}:${entry.sender}`,
|
||||
timestamp: entry.timestamp,
|
||||
body: entry.body,
|
||||
envelope: envelopeOptions,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: ctx.content,
|
||||
CommandBody: ctx.content,
|
||||
From: feishuFrom,
|
||||
To: feishuTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? ctx.chatId : undefined,
|
||||
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
||||
SenderId: ctx.senderOpenId,
|
||||
Provider: "feishu" as const,
|
||||
Surface: "feishu" as const,
|
||||
MessageSid: ctx.messageId,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: ctx.mentionedBot,
|
||||
CommandAuthorized: true,
|
||||
OriginatingChannel: "feishu" as const,
|
||||
OriginatingTo: feishuTo,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
replyToMessageId: ctx.messageId,
|
||||
});
|
||||
|
||||
log(`feishu: dispatching to agent (session=${route.sessionKey})`);
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions,
|
||||
});
|
||||
|
||||
markDispatchIdle();
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
clearHistoryEntriesIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey,
|
||||
limit: historyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
||||
} catch (err) {
|
||||
error(`feishu: failed to dispatch message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
224
extensions/feishu/src/channel.ts
Normal file
224
extensions/feishu/src/channel.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
import { resolveFeishuAccount, resolveFeishuCredentials } from "./accounts.js";
|
||||
import { feishuOutbound } from "./outbound.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import {
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryPeersLive,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
} from "./directory.js";
|
||||
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
const meta = {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
selectionLabel: "Feishu/Lark (飞书)",
|
||||
docsPath: "/channels/feishu",
|
||||
docsLabel: "feishu",
|
||||
blurb: "飞书/Lark enterprise messaging.",
|
||||
aliases: ["lark"],
|
||||
order: 70,
|
||||
} as const;
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "feishuUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: id,
|
||||
text: PAIRING_APPROVED_MESSAGE,
|
||||
});
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel"],
|
||||
polls: false,
|
||||
threads: true,
|
||||
media: true,
|
||||
reactions: true,
|
||||
edit: true,
|
||||
reply: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.",
|
||||
"- Feishu supports interactive cards for rich messages.",
|
||||
],
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
appId: { type: "string" },
|
||||
appSecret: { type: "string" },
|
||||
encryptKey: { type: "string" },
|
||||
verificationToken: { type: "string" },
|
||||
domain: { type: "string", enum: ["feishu", "lark"] },
|
||||
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
||||
webhookPath: { type: "string" },
|
||||
webhookPort: { type: "integer", minimum: 1 },
|
||||
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
||||
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
||||
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
|
||||
groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
||||
requireMention: { type: "boolean" },
|
||||
historyLimit: { type: "integer", minimum: 0 },
|
||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||
textChunkLimit: { type: "integer", minimum: 1 },
|
||||
chunkMode: { type: "string", enum: ["length", "newline"] },
|
||||
mediaMaxMb: { type: "number", minimum: 0 },
|
||||
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
resolveAccount: (cfg) => resolveFeishuAccount({ cfg }),
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
setAccountEnabled: ({ cfg, enabled }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
deleteAccount: ({ cfg }) => {
|
||||
const next = { ...cfg } as ClawdbotConfig;
|
||||
const nextChannels = { ...cfg.channels };
|
||||
delete (nextChannels as Record<string, unknown>).feishu;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
next.channels = nextChannels;
|
||||
} else {
|
||||
delete next.channels;
|
||||
}
|
||||
return next;
|
||||
},
|
||||
isConfigured: (_account, cfg) =>
|
||||
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined)),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg }) =>
|
||||
(cfg.channels?.feishu as FeishuConfig | undefined)?.allowFrom ?? [],
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
collectWarnings: ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy !== "open") return [];
|
||||
return [
|
||||
`- Feishu groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
|
||||
];
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeFeishuTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeFeishuId,
|
||||
hint: "<chatId|user:openId|chat:chatId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, query, limit }) =>
|
||||
listFeishuDirectoryPeers({ cfg, query, limit }),
|
||||
listGroups: async ({ cfg, query, limit }) =>
|
||||
listFeishuDirectoryGroups({ cfg, query, limit }),
|
||||
listPeersLive: async ({ cfg, query, limit }) =>
|
||||
listFeishuDirectoryPeersLive({ cfg, query, limit }),
|
||||
listGroupsLive: async ({ cfg, query, limit }) =>
|
||||
listFeishuDirectoryGroupsLive({ cfg, query, limit }),
|
||||
},
|
||||
outbound: feishuOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
port: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
port: snapshot.port ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg }) =>
|
||||
await probeFeishu(cfg.channels?.feishu as FeishuConfig | undefined),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
port: runtime?.port ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { monitorFeishuProvider } = await import("./monitor.js");
|
||||
const feishuCfg = ctx.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const port = feishuCfg?.webhookPort ?? null;
|
||||
ctx.setStatus({ accountId: ctx.accountId, port });
|
||||
ctx.log?.info(`starting feishu provider (mode: ${feishuCfg?.connectionMode ?? "websocket"})`);
|
||||
return monitorFeishuProvider({
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
accountId: ctx.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
66
extensions/feishu/src/client.ts
Normal file
66
extensions/feishu/src/client.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { FeishuConfig, FeishuDomain } from "./types.js";
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
|
||||
let cachedClient: Lark.Client | null = null;
|
||||
let cachedConfig: { appId: string; appSecret: string; domain: FeishuDomain } | null = null;
|
||||
|
||||
function resolveDomain(domain: FeishuDomain) {
|
||||
return domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
|
||||
}
|
||||
|
||||
export function createFeishuClient(cfg: FeishuConfig): Lark.Client {
|
||||
const creds = resolveFeishuCredentials(cfg);
|
||||
if (!creds) {
|
||||
throw new Error("Feishu credentials not configured (appId, appSecret required)");
|
||||
}
|
||||
|
||||
if (
|
||||
cachedClient &&
|
||||
cachedConfig &&
|
||||
cachedConfig.appId === creds.appId &&
|
||||
cachedConfig.appSecret === creds.appSecret &&
|
||||
cachedConfig.domain === creds.domain
|
||||
) {
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
const client = new Lark.Client({
|
||||
appId: creds.appId,
|
||||
appSecret: creds.appSecret,
|
||||
appType: Lark.AppType.SelfBuild,
|
||||
domain: resolveDomain(creds.domain),
|
||||
});
|
||||
|
||||
cachedClient = client;
|
||||
cachedConfig = { appId: creds.appId, appSecret: creds.appSecret, domain: creds.domain };
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function createFeishuWSClient(cfg: FeishuConfig): Lark.WSClient {
|
||||
const creds = resolveFeishuCredentials(cfg);
|
||||
if (!creds) {
|
||||
throw new Error("Feishu credentials not configured (appId, appSecret required)");
|
||||
}
|
||||
|
||||
return new Lark.WSClient({
|
||||
appId: creds.appId,
|
||||
appSecret: creds.appSecret,
|
||||
domain: resolveDomain(creds.domain),
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEventDispatcher(cfg: FeishuConfig): Lark.EventDispatcher {
|
||||
const creds = resolveFeishuCredentials(cfg);
|
||||
return new Lark.EventDispatcher({
|
||||
encryptKey: creds?.encryptKey,
|
||||
verificationToken: creds?.verificationToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearClientCache() {
|
||||
cachedClient = null;
|
||||
cachedConfig = null;
|
||||
}
|
||||
107
extensions/feishu/src/config-schema.ts
Normal file
107
extensions/feishu/src/config-schema.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
export { z };
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
const FeishuDomainSchema = z.enum(["feishu", "lark"]);
|
||||
const FeishuConnectionModeSchema = z.enum(["websocket", "webhook"]);
|
||||
|
||||
const ToolPolicySchema = z
|
||||
.object({
|
||||
allow: z.array(z.string()).optional(),
|
||||
deny: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const DmConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MarkdownConfigSchema = z
|
||||
.object({
|
||||
mode: z.enum(["native", "escape", "strip"]).optional(),
|
||||
tableMode: z.enum(["native", "ascii", "simple"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
||||
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
||||
|
||||
const BlockStreamingCoalesceSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
minDelayMs: z.number().int().positive().optional(),
|
||||
maxDelayMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const ChannelHeartbeatVisibilitySchema = z
|
||||
.object({
|
||||
visibility: z.enum(["visible", "hidden"]).optional(),
|
||||
intervalMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const FeishuGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const FeishuConfigSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
appId: z.string().optional(),
|
||||
appSecret: z.string().optional(),
|
||||
encryptKey: z.string().optional(),
|
||||
verificationToken: z.string().optional(),
|
||||
domain: FeishuDomainSchema.optional().default("feishu"),
|
||||
connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
|
||||
webhookPath: z.string().optional().default("/feishu/events"),
|
||||
webhookPort: z.number().int().positive().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
configWrites: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
requireMention: z.boolean().optional().default(true),
|
||||
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema,
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dmPolicy === "open") {
|
||||
const allowFrom = value.allowFrom ?? [];
|
||||
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
||||
if (!hasWildcard) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.feishu.dmPolicy="open" requires channels.feishu.allowFrom to include "*"',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
159
extensions/feishu/src/directory.ts
Normal file
159
extensions/feishu/src/directory.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { normalizeFeishuTarget } from "./targets.js";
|
||||
|
||||
export type FeishuDirectoryPeer = {
|
||||
kind: "user";
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type FeishuDirectoryGroup = {
|
||||
kind: "group";
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export async function listFeishuDirectoryPeers(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}): Promise<FeishuDirectoryPeer[]> {
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of feishuCfg?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
|
||||
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
|
||||
const trimmed = userId.trim();
|
||||
if (trimmed) ids.add(trimmed);
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "user" as const, id }));
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryGroups(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}): Promise<FeishuDirectoryGroup[]> {
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
|
||||
const trimmed = groupId.trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
|
||||
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, params.limit && params.limit > 0 ? params.limit : undefined)
|
||||
.map((id) => ({ kind: "group" as const, id }));
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryPeersLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}): Promise<FeishuDirectoryPeer[]> {
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const peers: FeishuDirectoryPeer[] = [];
|
||||
const limit = params.limit ?? 50;
|
||||
|
||||
const response = await client.contact.user.list({
|
||||
params: {
|
||||
page_size: Math.min(limit, 50),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const user of response.data.items) {
|
||||
if (user.open_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = user.name || "";
|
||||
if (!q || user.open_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
peers.push({
|
||||
kind: "user",
|
||||
id: user.open_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (peers.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
return peers;
|
||||
} catch {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFeishuDirectoryGroupsLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
}): Promise<FeishuDirectoryGroup[]> {
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg?.appId || !feishuCfg?.appSecret) {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const groups: FeishuDirectoryGroup[] = [];
|
||||
const limit = params.limit ?? 50;
|
||||
|
||||
const response = await client.im.chat.list({
|
||||
params: {
|
||||
page_size: Math.min(limit, 100),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data?.items) {
|
||||
for (const chat of response.data.items) {
|
||||
if (chat.chat_id) {
|
||||
const q = params.query?.trim().toLowerCase() || "";
|
||||
const name = chat.name || "";
|
||||
if (!q || chat.chat_id.toLowerCase().includes(q) || name.toLowerCase().includes(q)) {
|
||||
groups.push({
|
||||
kind: "group",
|
||||
id: chat.chat_id,
|
||||
name: name || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (groups.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
} catch {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
}
|
||||
515
extensions/feishu/src/media.ts
Normal file
515
extensions/feishu/src/media.ts
Normal file
@ -0,0 +1,515 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
export type DownloadImageResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export type DownloadMessageResourceResult = {
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download an image from Feishu using image_key.
|
||||
* Used for downloading images sent in messages.
|
||||
*/
|
||||
export async function downloadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
imageKey: string;
|
||||
}): Promise<DownloadImageResult> {
|
||||
const { cfg, imageKey } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
const response = await client.im.image.get({
|
||||
path: { image_key: imageKey },
|
||||
});
|
||||
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu image download failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
// Handle various response formats from Feishu SDK
|
||||
let buffer: Buffer;
|
||||
|
||||
if (Buffer.isBuffer(response)) {
|
||||
buffer = response;
|
||||
} else if (response instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(response);
|
||||
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
|
||||
buffer = responseAny.data;
|
||||
} else if (responseAny.data instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(responseAny.data);
|
||||
} else if (typeof responseAny.getReadableStream === "function") {
|
||||
// SDK provides getReadableStream method
|
||||
const stream = responseAny.getReadableStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.writeFile === "function") {
|
||||
// SDK provides writeFile method - use a temp file
|
||||
const tmpPath = path.join(os.tmpdir(), `feishu_img_${Date.now()}_${imageKey}`);
|
||||
await responseAny.writeFile(tmpPath);
|
||||
buffer = await fs.promises.readFile(tmpPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
|
||||
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
|
||||
// Response is an async iterable
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.read === "function") {
|
||||
// Response is a Readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny as Readable) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else {
|
||||
// Debug: log what we actually received
|
||||
const keys = Object.keys(responseAny);
|
||||
const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", ");
|
||||
throw new Error(
|
||||
`Feishu image download failed: unexpected response format. Keys: [${types}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a message resource (file/image/audio/video) from Feishu.
|
||||
* Used for downloading files, audio, and video from messages.
|
||||
*/
|
||||
export async function downloadMessageResourceFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
fileKey: string;
|
||||
type: "image" | "file";
|
||||
}): Promise<DownloadMessageResourceResult> {
|
||||
const { cfg, messageId, fileKey, type } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
const response = await client.im.messageResource.get({
|
||||
path: { message_id: messageId, file_key: fileKey },
|
||||
params: { type },
|
||||
});
|
||||
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(
|
||||
`Feishu message resource download failed: ${responseAny.msg || `code ${responseAny.code}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle various response formats from Feishu SDK
|
||||
let buffer: Buffer;
|
||||
|
||||
if (Buffer.isBuffer(response)) {
|
||||
buffer = response;
|
||||
} else if (response instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(response);
|
||||
} else if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
|
||||
buffer = responseAny.data;
|
||||
} else if (responseAny.data instanceof ArrayBuffer) {
|
||||
buffer = Buffer.from(responseAny.data);
|
||||
} else if (typeof responseAny.getReadableStream === "function") {
|
||||
// SDK provides getReadableStream method
|
||||
const stream = responseAny.getReadableStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.writeFile === "function") {
|
||||
// SDK provides writeFile method - use a temp file
|
||||
const tmpPath = path.join(os.tmpdir(), `feishu_${Date.now()}_${fileKey}`);
|
||||
await responseAny.writeFile(tmpPath);
|
||||
buffer = await fs.promises.readFile(tmpPath);
|
||||
await fs.promises.unlink(tmpPath).catch(() => {}); // cleanup
|
||||
} else if (typeof responseAny[Symbol.asyncIterator] === "function") {
|
||||
// Response is an async iterable
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else if (typeof responseAny.read === "function") {
|
||||
// Response is a Readable stream
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of responseAny as Readable) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
buffer = Buffer.concat(chunks);
|
||||
} else {
|
||||
// Debug: log what we actually received
|
||||
const keys = Object.keys(responseAny);
|
||||
const types = keys.map(k => `${k}: ${typeof responseAny[k]}`).join(", ");
|
||||
throw new Error(
|
||||
`Feishu message resource download failed: unexpected response format. Keys: [${types}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return { buffer };
|
||||
}
|
||||
|
||||
export type UploadImageResult = {
|
||||
imageKey: string;
|
||||
};
|
||||
|
||||
export type UploadFileResult = {
|
||||
fileKey: string;
|
||||
};
|
||||
|
||||
export type SendMediaResult = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload an image to Feishu and get an image_key for sending.
|
||||
* Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
|
||||
*/
|
||||
export async function uploadImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
image: Buffer | string; // Buffer or file path
|
||||
imageType?: "message" | "avatar";
|
||||
}): Promise<UploadImageResult> {
|
||||
const { cfg, image, imageType = "message" } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
// SDK expects a Readable stream, not a Buffer
|
||||
// Use type assertion since SDK actually accepts any Readable at runtime
|
||||
const imageStream =
|
||||
typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
|
||||
|
||||
const response = await client.im.image.create({
|
||||
data: {
|
||||
image_type: imageType,
|
||||
image: imageStream as any,
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
// On error, it throws or returns { code, msg }
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
|
||||
if (!imageKey) {
|
||||
throw new Error("Feishu image upload failed: no image_key returned");
|
||||
}
|
||||
|
||||
return { imageKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to Feishu and get a file_key for sending.
|
||||
* Max file size: 30MB
|
||||
*/
|
||||
export async function uploadFileFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
file: Buffer | string; // Buffer or file path
|
||||
fileName: string;
|
||||
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
|
||||
duration?: number; // Required for audio/video files, in milliseconds
|
||||
}): Promise<UploadFileResult> {
|
||||
const { cfg, file, fileName, fileType, duration } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
// SDK expects a Readable stream, not a Buffer
|
||||
// Use type assertion since SDK actually accepts any Readable at runtime
|
||||
const fileStream =
|
||||
typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
|
||||
|
||||
const response = await client.im.file.create({
|
||||
data: {
|
||||
file_type: fileType,
|
||||
file_name: fileName,
|
||||
file: fileStream as any,
|
||||
...(duration !== undefined && { duration }),
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
|
||||
if (!fileKey) {
|
||||
throw new Error("Feishu file upload failed: no file_key returned");
|
||||
}
|
||||
|
||||
return { fileKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image message using an image_key
|
||||
*/
|
||||
export async function sendImageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
imageKey: string;
|
||||
replyToMessageId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, imageKey, replyToMessageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify({ image_key: imageKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "image",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "image",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file message using a file_key
|
||||
*/
|
||||
export async function sendFileFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
fileKey: string;
|
||||
replyToMessageId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, fileKey, replyToMessageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify({ file_key: fileKey });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "file",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "file",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to detect file type from extension
|
||||
*/
|
||||
export function detectFileType(
|
||||
fileName: string,
|
||||
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
||||
const ext = path.extname(fileName).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".opus":
|
||||
case ".ogg":
|
||||
return "opus";
|
||||
case ".mp4":
|
||||
case ".mov":
|
||||
case ".avi":
|
||||
return "mp4";
|
||||
case ".pdf":
|
||||
return "pdf";
|
||||
case ".doc":
|
||||
case ".docx":
|
||||
return "doc";
|
||||
case ".xls":
|
||||
case ".xlsx":
|
||||
return "xls";
|
||||
case ".ppt":
|
||||
case ".pptx":
|
||||
return "ppt";
|
||||
default:
|
||||
return "stream";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a local file path (not a URL)
|
||||
*/
|
||||
function isLocalPath(urlOrPath: string): boolean {
|
||||
// Starts with / or ~ or drive letter (Windows)
|
||||
if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
|
||||
return true;
|
||||
}
|
||||
// Try to parse as URL - if it fails or has no protocol, it's likely a local path
|
||||
try {
|
||||
const url = new URL(urlOrPath);
|
||||
return url.protocol === "file:";
|
||||
} catch {
|
||||
return true; // Not a valid URL, treat as local path
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload and send media (image or file) from URL, local path, or buffer
|
||||
*/
|
||||
export async function sendMediaFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
mediaUrl?: string;
|
||||
mediaBuffer?: Buffer;
|
||||
fileName?: string;
|
||||
replyToMessageId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId } = params;
|
||||
|
||||
let buffer: Buffer;
|
||||
let name: string;
|
||||
|
||||
if (mediaBuffer) {
|
||||
buffer = mediaBuffer;
|
||||
name = fileName ?? "file";
|
||||
} else if (mediaUrl) {
|
||||
if (isLocalPath(mediaUrl)) {
|
||||
// Local file path - read directly
|
||||
const filePath = mediaUrl.startsWith("~")
|
||||
? mediaUrl.replace("~", process.env.HOME ?? "")
|
||||
: mediaUrl.replace("file://", "");
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`Local file not found: ${filePath}`);
|
||||
}
|
||||
buffer = fs.readFileSync(filePath);
|
||||
name = fileName ?? path.basename(filePath);
|
||||
} else {
|
||||
// Remote URL - fetch
|
||||
const response = await fetch(mediaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media from URL: ${response.status}`);
|
||||
}
|
||||
buffer = Buffer.from(await response.arrayBuffer());
|
||||
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
|
||||
}
|
||||
} else {
|
||||
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
||||
}
|
||||
|
||||
// Determine if it's an image based on extension
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
|
||||
|
||||
if (isImage) {
|
||||
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer });
|
||||
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId });
|
||||
} else {
|
||||
const fileType = detectFileType(name);
|
||||
const { fileKey } = await uploadFileFeishu({
|
||||
cfg,
|
||||
file: buffer,
|
||||
fileName: name,
|
||||
fileType,
|
||||
});
|
||||
return sendFileFeishu({ cfg, to, fileKey, replyToMessageId });
|
||||
}
|
||||
}
|
||||
151
extensions/feishu/src/monitor.ts
Normal file
151
extensions/feishu/src/monitor.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuWSClient, createEventDispatcher } from "./client.js";
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
|
||||
export type MonitorFeishuOpts = {
|
||||
config?: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
let currentWsClient: Lark.WSClient | null = null;
|
||||
let botOpenId: string | undefined;
|
||||
|
||||
async function fetchBotOpenId(cfg: FeishuConfig): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await probeFeishu(cfg);
|
||||
return result.ok ? result.botOpenId : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
|
||||
const cfg = opts.config;
|
||||
if (!cfg) {
|
||||
throw new Error("Config is required for Feishu monitor");
|
||||
}
|
||||
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const creds = resolveFeishuCredentials(feishuCfg);
|
||||
if (!creds) {
|
||||
throw new Error("Feishu credentials not configured (appId, appSecret required)");
|
||||
}
|
||||
|
||||
const log = opts.runtime?.log ?? console.log;
|
||||
const error = opts.runtime?.error ?? console.error;
|
||||
|
||||
if (feishuCfg) {
|
||||
botOpenId = await fetchBotOpenId(feishuCfg);
|
||||
log(`feishu: bot open_id resolved: ${botOpenId ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const connectionMode = feishuCfg?.connectionMode ?? "websocket";
|
||||
|
||||
if (connectionMode === "websocket") {
|
||||
return monitorWebSocket({ cfg, feishuCfg: feishuCfg!, runtime: opts.runtime, abortSignal: opts.abortSignal });
|
||||
}
|
||||
|
||||
log("feishu: webhook mode not implemented in monitor, use HTTP server directly");
|
||||
}
|
||||
|
||||
async function monitorWebSocket(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
feishuCfg: FeishuConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const { cfg, feishuCfg, runtime, abortSignal } = params;
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
log("feishu: starting WebSocket connection...");
|
||||
|
||||
const wsClient = createFeishuWSClient(feishuCfg);
|
||||
currentWsClient = wsClient;
|
||||
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
const eventDispatcher = createEventDispatcher(feishuCfg);
|
||||
|
||||
eventDispatcher.register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuMessageEvent;
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId,
|
||||
runtime,
|
||||
chatHistories,
|
||||
});
|
||||
} catch (err) {
|
||||
error(`feishu: error handling message event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
"im.message.message_read_v1": async () => {
|
||||
// Ignore read receipts
|
||||
},
|
||||
"im.chat.member.bot.added_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuBotAddedEvent;
|
||||
log(`feishu: bot added to chat ${event.chat_id}`);
|
||||
} catch (err) {
|
||||
error(`feishu: error handling bot added event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
"im.chat.member.bot.deleted_v1": async (data) => {
|
||||
try {
|
||||
const event = data as unknown as { chat_id: string };
|
||||
log(`feishu: bot removed from chat ${event.chat_id}`);
|
||||
} catch (err) {
|
||||
error(`feishu: error handling bot removed event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
if (currentWsClient === wsClient) {
|
||||
currentWsClient = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAbort = () => {
|
||||
log("feishu: abort signal received, stopping WebSocket client");
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
cleanup();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
||||
|
||||
try {
|
||||
wsClient.start({
|
||||
eventDispatcher,
|
||||
});
|
||||
|
||||
log("feishu: WebSocket client started");
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
abortSignal?.removeEventListener("abort", handleAbort);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stopFeishuMonitor(): void {
|
||||
if (currentWsClient) {
|
||||
currentWsClient = null;
|
||||
}
|
||||
}
|
||||
358
extensions/feishu/src/onboarding.ts
Normal file
358
extensions/feishu/src/onboarding.ts
Normal file
@ -0,0 +1,358 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ClawdbotConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
|
||||
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
const channel = "feishu" as const;
|
||||
|
||||
function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptFeishuAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig> {
|
||||
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist Feishu DMs by open_id or user_id.",
|
||||
"You can find user open_id in Feishu admin console or via API.",
|
||||
"Examples:",
|
||||
"- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
].join("\n"),
|
||||
"Feishu allowlist",
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: "Feishu allowFrom (user open_ids)",
|
||||
placeholder: "ou_xxxxx, ou_yyyyy",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note("Enter at least one user.", "Feishu allowlist");
|
||||
continue;
|
||||
}
|
||||
|
||||
const unique = [
|
||||
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts]),
|
||||
];
|
||||
return setFeishuAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
|
||||
async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Go to Feishu Open Platform (open.feishu.cn)",
|
||||
"2) Create a self-built app",
|
||||
"3) Get App ID and App Secret from Credentials page",
|
||||
"4) Enable required permissions: im:message, im:chat, contact:user.base:readonly",
|
||||
"5) Publish the app or add it to a test group",
|
||||
"Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.",
|
||||
`Docs: ${formatDocsLink("/channels/feishu", "feishu")}`,
|
||||
].join("\n"),
|
||||
"Feishu credentials",
|
||||
);
|
||||
}
|
||||
|
||||
function setFeishuGroupPolicy(
|
||||
cfg: ClawdbotConfig,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groupAllowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Feishu",
|
||||
channel,
|
||||
policyKey: "channels.feishu.dmPolicy",
|
||||
allowFromKey: "channels.feishu.allowFrom",
|
||||
getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
|
||||
export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const configured = Boolean(resolveFeishuCredentials(feishuCfg));
|
||||
|
||||
// Try to probe if configured
|
||||
let probeResult = null;
|
||||
if (configured && feishuCfg) {
|
||||
try {
|
||||
probeResult = await probeFeishu(feishuCfg);
|
||||
} catch {
|
||||
// Ignore probe errors
|
||||
}
|
||||
}
|
||||
|
||||
const statusLines: string[] = [];
|
||||
if (!configured) {
|
||||
statusLines.push("Feishu: needs app credentials");
|
||||
} else if (probeResult?.ok) {
|
||||
statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`);
|
||||
} else {
|
||||
statusLines.push("Feishu: configured (connection not verified)");
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines,
|
||||
selectionHint: configured ? "configured" : "needs app creds",
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
};
|
||||
},
|
||||
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolved = resolveFeishuCredentials(feishuCfg);
|
||||
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
|
||||
const canUseEnv = Boolean(
|
||||
!hasConfigCreds &&
|
||||
process.env.FEISHU_APP_ID?.trim() &&
|
||||
process.env.FEISHU_APP_SECRET?.trim(),
|
||||
);
|
||||
|
||||
let next = cfg;
|
||||
let appId: string | null = null;
|
||||
let appSecret: string | null = null;
|
||||
|
||||
if (!resolved) {
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
}
|
||||
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: { ...next.channels?.feishu, enabled: true },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Feishu credentials already configured. Keep them?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
appId = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App ID",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
appSecret = String(
|
||||
await prompter.text({
|
||||
message: "Enter Feishu App Secret",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (appId && appSecret) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
enabled: true,
|
||||
appId,
|
||||
appSecret,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Test connection
|
||||
const testCfg = next.channels?.feishu as FeishuConfig;
|
||||
try {
|
||||
const probe = await probeFeishu(testCfg);
|
||||
if (probe.ok) {
|
||||
await prompter.note(
|
||||
`Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
} else {
|
||||
await prompter.note(
|
||||
`Connection failed: ${probe.error ?? "unknown error"}`,
|
||||
"Feishu connection test",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(`Connection test failed: ${String(err)}`, "Feishu connection test");
|
||||
}
|
||||
}
|
||||
|
||||
// Domain selection
|
||||
const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
|
||||
const domain = await prompter.select({
|
||||
message: "Which Feishu domain?",
|
||||
options: [
|
||||
{ value: "feishu", label: "Feishu (feishu.cn) - China" },
|
||||
{ value: "lark", label: "Lark (larksuite.com) - International" },
|
||||
],
|
||||
initialValue: currentDomain,
|
||||
});
|
||||
if (domain) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
domain: domain as "feishu" | "lark",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Group policy
|
||||
const groupPolicy = await prompter.select({
|
||||
message: "Group chat policy",
|
||||
options: [
|
||||
{ value: "allowlist", label: "Allowlist - only respond in specific groups" },
|
||||
{ value: "open", label: "Open - respond in all groups (requires mention)" },
|
||||
{ value: "disabled", label: "Disabled - don't respond in groups" },
|
||||
],
|
||||
initialValue:
|
||||
(next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist",
|
||||
});
|
||||
if (groupPolicy) {
|
||||
next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled");
|
||||
}
|
||||
|
||||
// Group allowlist if needed
|
||||
if (groupPolicy === "allowlist") {
|
||||
const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Group chat allowlist (chat_ids)",
|
||||
placeholder: "oc_xxxxx, oc_yyyyy",
|
||||
initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
|
||||
});
|
||||
if (entry) {
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length > 0) {
|
||||
next = setFeishuGroupAllowFrom(next, parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
|
||||
dmPolicy,
|
||||
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: { ...cfg.channels?.feishu, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
40
extensions/feishu/src/outbound.ts
Normal file
40
extensions/feishu/src/outbound.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { sendMediaFeishu } from "./media.js";
|
||||
|
||||
export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text }) => {
|
||||
const result = await sendMessageFeishu({ cfg, to, text });
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
|
||||
// Send text first if provided
|
||||
if (text?.trim()) {
|
||||
await sendMessageFeishu({ cfg, to, text });
|
||||
}
|
||||
|
||||
// Upload and send media if URL provided
|
||||
if (mediaUrl) {
|
||||
try {
|
||||
const result = await sendMediaFeishu({ cfg, to, mediaUrl });
|
||||
return { channel: "feishu", ...result };
|
||||
} catch (err) {
|
||||
// Log the error for debugging
|
||||
console.error(`[feishu] sendMediaFeishu failed:`, err);
|
||||
// Fallback to URL link if upload fails
|
||||
const fallbackText = `📎 ${mediaUrl}`;
|
||||
const result = await sendMessageFeishu({ cfg, to, text: fallbackText });
|
||||
return { channel: "feishu", ...result };
|
||||
}
|
||||
}
|
||||
|
||||
// No media URL, just return text result
|
||||
const result = await sendMessageFeishu({ cfg, to, text: text ?? "" });
|
||||
return { channel: "feishu", ...result };
|
||||
},
|
||||
};
|
||||
92
extensions/feishu/src/policy.ts
Normal file
92
extensions/feishu/src/policy.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
|
||||
|
||||
export type FeishuAllowlistMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "name";
|
||||
};
|
||||
|
||||
export function resolveFeishuAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): FeishuAllowlistMatch {
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => String(entry).trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (allowFrom.length === 0) return { allowed: false };
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
|
||||
const senderId = params.senderId.toLowerCase();
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
|
||||
const senderName = params.senderName?.toLowerCase();
|
||||
if (senderName && allowFrom.includes(senderName)) {
|
||||
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
||||
}
|
||||
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupConfig(params: {
|
||||
cfg?: FeishuConfig;
|
||||
groupId?: string | null;
|
||||
}): FeishuGroupConfig | undefined {
|
||||
const groups = params.cfg?.groups ?? {};
|
||||
const groupId = params.groupId?.trim();
|
||||
if (!groupId) return undefined;
|
||||
|
||||
const direct = groups[groupId] as FeishuGroupConfig | undefined;
|
||||
if (direct) return direct;
|
||||
|
||||
const lowered = groupId.toLowerCase();
|
||||
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
||||
return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined;
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!cfg) return undefined;
|
||||
|
||||
const groupConfig = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
|
||||
return groupConfig?.tools;
|
||||
}
|
||||
|
||||
export function isFeishuGroupAllowed(params: {
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): boolean {
|
||||
const { groupPolicy } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
return resolveFeishuAllowlistMatch(params).allowed;
|
||||
}
|
||||
|
||||
export function resolveFeishuReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
globalConfig?: FeishuConfig;
|
||||
groupConfig?: FeishuGroupConfig;
|
||||
}): { requireMention: boolean } {
|
||||
if (params.isDirectMessage) {
|
||||
return { requireMention: false };
|
||||
}
|
||||
|
||||
const requireMention =
|
||||
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
|
||||
|
||||
return { requireMention };
|
||||
}
|
||||
46
extensions/feishu/src/probe.ts
Normal file
46
extensions/feishu/src/probe.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import type { FeishuConfig, FeishuProbeResult } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveFeishuCredentials } from "./accounts.js";
|
||||
|
||||
export async function probeFeishu(cfg?: FeishuConfig): Promise<FeishuProbeResult> {
|
||||
const creds = resolveFeishuCredentials(cfg);
|
||||
if (!creds) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "missing credentials (appId, appSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(cfg!);
|
||||
// Use im.chat.list as a simple connectivity test
|
||||
// The bot info API path varies by SDK version
|
||||
const response = await (client as any).request({
|
||||
method: "GET",
|
||||
url: "/open-apis/bot/v3/info",
|
||||
data: {},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `API error: ${response.msg || `code ${response.code}`}`,
|
||||
};
|
||||
}
|
||||
|
||||
const bot = response.bot || response.data?.bot;
|
||||
return {
|
||||
ok: true,
|
||||
appId: creds.appId,
|
||||
botName: bot?.bot_name,
|
||||
botOpenId: bot?.open_id,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
157
extensions/feishu/src/reactions.ts
Normal file
157
extensions/feishu/src/reactions.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
export type FeishuReaction = {
|
||||
reactionId: string;
|
||||
emojiType: string;
|
||||
operatorType: "app" | "user";
|
||||
operatorId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a reaction (emoji) to a message.
|
||||
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export async function addReactionFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
emojiType: string;
|
||||
}): Promise<{ reactionId: string }> {
|
||||
const { cfg, messageId, emojiType } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
const response = (await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
reaction_type: {
|
||||
emoji_type: emojiType,
|
||||
},
|
||||
},
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: { reaction_id?: string };
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const reactionId = response.data?.reaction_id;
|
||||
if (!reactionId) {
|
||||
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
||||
}
|
||||
|
||||
return { reactionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reaction from a message.
|
||||
*/
|
||||
export async function removeReactionFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
reactionId: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, reactionId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
const response = (await client.im.messageReaction.delete({
|
||||
path: {
|
||||
message_id: messageId,
|
||||
reaction_id: reactionId,
|
||||
},
|
||||
})) as { code?: number; msg?: string };
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all reactions for a message.
|
||||
*/
|
||||
export async function listReactionsFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
emojiType?: string;
|
||||
}): Promise<FeishuReaction[]> {
|
||||
const { cfg, messageId, emojiType } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
const response = (await client.im.messageReaction.list({
|
||||
path: { message_id: messageId },
|
||||
params: emojiType ? { reaction_type: emojiType } : undefined,
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
reaction_id?: string;
|
||||
reaction_type?: { emoji_type?: string };
|
||||
operator_type?: string;
|
||||
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
const items = response.data?.items ?? [];
|
||||
return items.map((item) => ({
|
||||
reactionId: item.reaction_id ?? "",
|
||||
emojiType: item.reaction_type?.emoji_type ?? "",
|
||||
operatorType: item.operator_type === "app" ? "app" : "user",
|
||||
operatorId:
|
||||
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Feishu emoji types for convenience.
|
||||
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
*/
|
||||
export const FeishuEmoji = {
|
||||
// Common reactions
|
||||
THUMBSUP: "THUMBSUP",
|
||||
THUMBSDOWN: "THUMBSDOWN",
|
||||
HEART: "HEART",
|
||||
SMILE: "SMILE",
|
||||
GRINNING: "GRINNING",
|
||||
LAUGHING: "LAUGHING",
|
||||
CRY: "CRY",
|
||||
ANGRY: "ANGRY",
|
||||
SURPRISED: "SURPRISED",
|
||||
THINKING: "THINKING",
|
||||
CLAP: "CLAP",
|
||||
OK: "OK",
|
||||
FIST: "FIST",
|
||||
PRAY: "PRAY",
|
||||
FIRE: "FIRE",
|
||||
PARTY: "PARTY",
|
||||
CHECK: "CHECK",
|
||||
CROSS: "CROSS",
|
||||
QUESTION: "QUESTION",
|
||||
EXCLAMATION: "EXCLAMATION",
|
||||
} as const;
|
||||
|
||||
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
||||
156
extensions/feishu/src/reply-dispatcher.ts
Normal file
156
extensions/feishu/src/reply-dispatcher.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import {
|
||||
createReplyPrefixContext,
|
||||
createTypingCallbacks,
|
||||
logTypingFailure,
|
||||
type ClawdbotConfig,
|
||||
type RuntimeEnv,
|
||||
type ReplyPayload,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import {
|
||||
addTypingIndicator,
|
||||
removeTypingIndicator,
|
||||
type TypingIndicatorState,
|
||||
} from "./typing.js";
|
||||
|
||||
/**
|
||||
* Detect if text contains markdown elements that benefit from card rendering.
|
||||
* Used by auto render mode.
|
||||
*/
|
||||
function shouldUseCard(text: string): boolean {
|
||||
// Code blocks (fenced)
|
||||
if (/```[\s\S]*?```/.test(text)) return true;
|
||||
// Tables (at least header + separator row with |)
|
||||
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CreateFeishuReplyDispatcherParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
runtime: RuntimeEnv;
|
||||
chatId: string;
|
||||
replyToMessageId?: string;
|
||||
};
|
||||
|
||||
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
||||
const core = getFeishuRuntime();
|
||||
const { cfg, agentId, chatId, replyToMessageId } = params;
|
||||
|
||||
const prefixContext = createReplyPrefixContext({
|
||||
cfg,
|
||||
agentId,
|
||||
});
|
||||
|
||||
// Feishu doesn't have a native typing indicator API.
|
||||
// We use message reactions as a typing indicator substitute.
|
||||
let typingState: TypingIndicatorState | null = null;
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
if (!replyToMessageId) return;
|
||||
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId });
|
||||
params.runtime.log?.(`feishu: added typing indicator reaction`);
|
||||
},
|
||||
stop: async () => {
|
||||
if (!typingState) return;
|
||||
await removeTypingIndicator({ cfg, state: typingState });
|
||||
typingState = null;
|
||||
params.runtime.log?.(`feishu: removed typing indicator reaction`);
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.runtime.log?.(message),
|
||||
channel: "feishu",
|
||||
action: "start",
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => params.runtime.log?.(message),
|
||||
channel: "feishu",
|
||||
action: "stop",
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
defaultLimit: 4000,
|
||||
});
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`);
|
||||
const text = payload.text ?? "";
|
||||
if (!text.trim()) {
|
||||
params.runtime.log?.(`feishu deliver: empty text, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check render mode: auto (default), raw, or card
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const renderMode = feishuCfg?.renderMode ?? "auto";
|
||||
|
||||
// Determine if we should use card for this message
|
||||
const useCard =
|
||||
renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
|
||||
if (useCard) {
|
||||
// Card mode: send as interactive card with markdown rendering
|
||||
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
||||
params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
await sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Raw mode: send as plain text with table conversion
|
||||
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
||||
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
||||
params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`);
|
||||
typingCallbacks.onIdle?.();
|
||||
},
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
});
|
||||
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
},
|
||||
markDispatchIdle,
|
||||
};
|
||||
}
|
||||
14
extensions/feishu/src/runtime.ts
Normal file
14
extensions/feishu/src/runtime.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setFeishuRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getFeishuRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Feishu runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
308
extensions/feishu/src/send.ts
Normal file
308
extensions/feishu/src/send.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig, FeishuSendResult } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
|
||||
export type FeishuMessageInfo = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
senderId?: string;
|
||||
senderOpenId?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
createTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a message by its ID.
|
||||
* Useful for fetching quoted/replied message content.
|
||||
*/
|
||||
export async function getMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
}): Promise<FeishuMessageInfo | null> {
|
||||
const { cfg, messageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
try {
|
||||
const response = (await client.im.message.get({
|
||||
path: { message_id: messageId },
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: {
|
||||
id?: string;
|
||||
id_type?: string;
|
||||
sender_type?: string;
|
||||
};
|
||||
create_time?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = response.data?.items?.[0];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse content based on message type
|
||||
let content = item.body?.content ?? "";
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (item.msg_type === "text" && parsed.text) {
|
||||
content = parsed.text;
|
||||
}
|
||||
} catch {
|
||||
// Keep raw content if parsing fails
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: item.message_id ?? messageId,
|
||||
chatId: item.chat_id ?? "",
|
||||
senderId: item.sender?.id,
|
||||
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
||||
content,
|
||||
contentType: item.msg_type ?? "text",
|
||||
createTime: item.create_time ? parseInt(item.create_time, 10) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type SendFeishuMessageParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
};
|
||||
|
||||
export async function sendMessageFeishu(params: SendFeishuMessageParams): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
|
||||
const content = JSON.stringify({ text: messageText });
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "text",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "text",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
export type SendFeishuCardParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
card: Record<string, unknown>;
|
||||
replyToMessageId?: string;
|
||||
};
|
||||
|
||||
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
||||
const { cfg, to, card, replyToMessageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const receiveId = normalizeFeishuTarget(to);
|
||||
if (!receiveId) {
|
||||
throw new Error(`Invalid Feishu target: ${to}`);
|
||||
}
|
||||
|
||||
const receiveIdType = resolveReceiveIdType(receiveId);
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
if (replyToMessageId) {
|
||||
const response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: response.data?.message_id ?? "unknown",
|
||||
chatId: receiveId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
card: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, card } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
const response = await client.im.message.patch({
|
||||
path: { message_id: messageId },
|
||||
data: { content },
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu card update failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Feishu interactive card with markdown content.
|
||||
* Cards render markdown properly (code blocks, tables, links, etc.)
|
||||
*/
|
||||
export function buildMarkdownCard(text: string): Record<string, unknown> {
|
||||
return {
|
||||
config: {
|
||||
wide_screen_mode: true,
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
tag: "markdown",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message as a markdown card (interactive message).
|
||||
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
|
||||
*/
|
||||
export async function sendMarkdownCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
}): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId } = params;
|
||||
const card = buildMarkdownCard(text);
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an existing text message.
|
||||
* Note: Feishu only allows editing messages within 24 hours.
|
||||
*/
|
||||
export async function editMessageFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
text: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, text } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
throw new Error("Feishu channel not configured");
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const messageText = getFeishuRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode);
|
||||
const content = JSON.stringify({ text: messageText });
|
||||
|
||||
const response = await client.im.message.update({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
msg_type: "text",
|
||||
content,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu message edit failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
58
extensions/feishu/src/targets.ts
Normal file
58
extensions/feishu/src/targets.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { FeishuIdType } from "./types.js";
|
||||
|
||||
const CHAT_ID_PREFIX = "oc_";
|
||||
const OPEN_ID_PREFIX = "ou_";
|
||||
const USER_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
export function detectIdType(id: string): FeishuIdType | null {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
|
||||
if (USER_ID_REGEX.test(trimmed)) return "user_id";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeFeishuTarget(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const lowered = trimmed.toLowerCase();
|
||||
if (lowered.startsWith("chat:")) {
|
||||
return trimmed.slice("chat:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return trimmed.slice("user:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("open_id:")) {
|
||||
return trimmed.slice("open_id:".length).trim() || null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function formatFeishuTarget(id: string, type?: FeishuIdType): string {
|
||||
const trimmed = id.trim();
|
||||
if (type === "chat_id" || trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
return `chat:${trimmed}`;
|
||||
}
|
||||
if (type === "open_id" || trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_id" {
|
||||
const trimmed = id.trim();
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
|
||||
return "open_id";
|
||||
}
|
||||
|
||||
export function looksLikeFeishuId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^(chat|user|open_id):/i.test(trimmed)) return true;
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) return true;
|
||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) return true;
|
||||
return false;
|
||||
}
|
||||
50
extensions/feishu/src/types.ts
Normal file
50
extensions/feishu/src/types.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { FeishuConfigSchema, FeishuGroupSchema, z } from "./config-schema.js";
|
||||
|
||||
export type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
|
||||
export type FeishuGroupConfig = z.infer<typeof FeishuGroupSchema>;
|
||||
|
||||
export type FeishuDomain = "feishu" | "lark";
|
||||
export type FeishuConnectionMode = "websocket" | "webhook";
|
||||
|
||||
export type ResolvedFeishuAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
appId?: string;
|
||||
domain: FeishuDomain;
|
||||
};
|
||||
|
||||
export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
|
||||
|
||||
export type FeishuMessageContext = {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
senderId: string;
|
||||
senderOpenId: string;
|
||||
senderName?: string;
|
||||
chatType: "p2p" | "group";
|
||||
mentionedBot: boolean;
|
||||
rootId?: string;
|
||||
parentId?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type FeishuSendResult = {
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
export type FeishuProbeResult = {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
appId?: string;
|
||||
botName?: string;
|
||||
botOpenId?: string;
|
||||
};
|
||||
|
||||
export type FeishuMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
73
extensions/feishu/src/typing.ts
Normal file
73
extensions/feishu/src/typing.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
// Feishu emoji types for typing indicator
|
||||
// See: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
||||
// Full list: https://github.com/go-lark/lark/blob/main/emoji.go
|
||||
const TYPING_EMOJI = "Typing"; // Typing indicator emoji
|
||||
|
||||
export type TypingIndicatorState = {
|
||||
messageId: string;
|
||||
reactionId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a typing indicator (reaction) to a message
|
||||
*/
|
||||
export async function addTypingIndicator(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
messageId: string;
|
||||
}): Promise<TypingIndicatorState> {
|
||||
const { cfg, messageId } = params;
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) {
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
try {
|
||||
const response = await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
data: {
|
||||
reaction_type: { emoji_type: TYPING_EMOJI },
|
||||
},
|
||||
});
|
||||
|
||||
const reactionId = (response as any)?.data?.reaction_id ?? null;
|
||||
return { messageId, reactionId };
|
||||
} catch (err) {
|
||||
// Silently fail - typing indicator is not critical
|
||||
console.log(`[feishu] failed to add typing indicator: ${err}`);
|
||||
return { messageId, reactionId: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a typing indicator (reaction) from a message
|
||||
*/
|
||||
export async function removeTypingIndicator(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
state: TypingIndicatorState;
|
||||
}): Promise<void> {
|
||||
const { cfg, state } = params;
|
||||
if (!state.reactionId) return;
|
||||
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (!feishuCfg) return;
|
||||
|
||||
const client = createFeishuClient(feishuCfg);
|
||||
|
||||
try {
|
||||
await client.im.messageReaction.delete({
|
||||
path: {
|
||||
message_id: state.messageId,
|
||||
reaction_id: state.reactionId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Silently fail - cleanup is not critical
|
||||
console.log(`[feishu] failed to remove typing indicator: ${err}`);
|
||||
}
|
||||
}
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@ -304,6 +304,12 @@ importers:
|
||||
|
||||
extensions/discord: {}
|
||||
|
||||
extensions/feishu:
|
||||
dependencies:
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.30.0
|
||||
version: 1.58.0
|
||||
|
||||
extensions/google-antigravity-auth: {}
|
||||
|
||||
extensions/google-gemini-cli-auth: {}
|
||||
@ -1333,6 +1339,9 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-4hSpglL/G/cW2JCcohaYz/BS0uOSJNV9IEYdMm0EiPEvDLayoI2hGq2D86uYPQFD2gvgkyhmzdShpWLG3P5r3w==}
|
||||
engines: {node: '>=20'}
|
||||
@ -3080,6 +3089,9 @@ packages:
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
axios@1.13.4:
|
||||
resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@ -4166,6 +4178,9 @@ packages:
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.identity@3.0.0:
|
||||
resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
@ -4184,9 +4199,15 @@ packages:
|
||||
lodash.isstring@4.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lodash.pickby@4.6.0:
|
||||
resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==}
|
||||
|
||||
lodash@4.17.23:
|
||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
||||
|
||||
@ -6883,6 +6904,20 @@ snapshots:
|
||||
'@lancedb/lancedb-win32-arm64-msvc': 0.23.0
|
||||
'@lancedb/lancedb-win32-x64-msvc': 0.23.0
|
||||
|
||||
'@larksuiteoapi/node-sdk@1.58.0':
|
||||
dependencies:
|
||||
axios: 1.13.4
|
||||
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':
|
||||
dependencies:
|
||||
'@types/node': 24.10.9
|
||||
@ -8938,6 +8973,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axios@1.13.4:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11(debug@4.4.3)
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
balanced-match@3.0.1: {}
|
||||
@ -10174,6 +10217,8 @@ snapshots:
|
||||
lodash.debounce@4.0.8:
|
||||
optional: true
|
||||
|
||||
lodash.identity@3.0.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
@ -10186,8 +10231,12 @@ snapshots:
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lodash.pickby@4.6.0: {}
|
||||
|
||||
lodash@4.17.23: {}
|
||||
|
||||
log-symbols@6.0.0:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user