Add lark extension for Feishu/Lark integration

This commit is contained in:
Ubuntu 2026-01-30 15:01:27 +08:00
parent 9025da2296
commit aeac1220f2
14 changed files with 2983 additions and 0 deletions

336
extensions/lark/SKILL.md Normal file
View File

@ -0,0 +1,336 @@
---
name: lark-mcp
description: 使用飞书/Lark官方 MCP 工具操作飞书平台。支持文档读取、多维表格操作、任务管理、日程创建、群消息读取、IM消息发送等。当用户需要操作飞书文档、表格、任务、日程或消息时使用此 skill。
metadata: {"moltbot":{"emoji":"📋","requires":{"bins":["npx"]}}}
---
# Lark MCP 飞书操作工具
使用飞书官方 `@larksuiteoapi/lark-mcp` MCP 工具操作飞书平台。
GitHub: https://github.com/larksuite/lark-openapi-mcp
## 前置条件
### 1. 用户授权登录(访问用户数据必须)
如果上下文中有 `UserAccessToken`,可以直接使用。否则需要先登录:
```bash
npx -y @larksuiteoapi/lark-mcp login -a <APP_ID> -s <APP_SECRET> --domain https://open.larksuite.com
```
登录后会保存 token后续调用会自动使用。
### 2. MCP 工具调用方式
使用 mcporter 调用 lark-mcp 工具:
```bash
# 查看所有可用工具
mcporter list lark-mcp --schema
# 调用工具
mcporter call lark-mcp.<tool_name> param1=value1 param2=value2 --output json
```
---
## 文档操作 (Docx)
### 获取文档内容
```bash
# 获取文档元信息
mcporter call lark-mcp.docx.v1.document.get document_id=<doc_id> --output json
# 获取文档纯文本内容
mcporter call lark-mcp.docx.v1.document.raw_content document_id=<doc_id> --output json
# 获取文档块列表
mcporter call lark-mcp.docx.v1.document_block.list document_id=<doc_id> --output json
```
### 文档 ID 获取方式
从文档 URL 提取:`https://xxx.larksuite.com/docx/ABC123` → document_id = `ABC123`
---
## 多维表格 (Bitable)
### 获取表格信息
```bash
# 获取多维表格元信息
mcporter call lark-mcp.bitable.v1.app.get app_token=<app_token> --output json
# 列出所有数据表
mcporter call lark-mcp.bitable.v1.app_table.list app_token=<app_token> --output json
```
### 读取表格记录
```bash
# 获取记录列表
mcporter call lark-mcp.bitable.v1.app_table_record.list \
app_token=<app_token> \
table_id=<table_id> \
--output json
# 获取单条记录
mcporter call lark-mcp.bitable.v1.app_table_record.get \
app_token=<app_token> \
table_id=<table_id> \
record_id=<record_id> \
--output json
# 搜索记录(带筛选条件)
mcporter call lark-mcp.bitable.v1.app_table_record.search \
app_token=<app_token> \
table_id=<table_id> \
--args '{"filter":{"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["进行中"]}]}}' \
--output json
```
### 写入表格记录
```bash
# 创建记录
mcporter call lark-mcp.bitable.v1.app_table_record.create \
app_token=<app_token> \
table_id=<table_id> \
--args '{"fields":{"标题":"新任务","状态":"待处理","负责人":[{"id":"ou_xxx"}]}}' \
--output json
# 更新记录
mcporter call lark-mcp.bitable.v1.app_table_record.update \
app_token=<app_token> \
table_id=<table_id> \
record_id=<record_id> \
--args '{"fields":{"状态":"已完成"}}' \
--output json
# 删除记录
mcporter call lark-mcp.bitable.v1.app_table_record.delete \
app_token=<app_token> \
table_id=<table_id> \
record_id=<record_id> \
--output json
```
### Bitable Token 获取方式
从 URL 提取:`https://xxx.larksuite.com/base/ABC123` → app_token = `ABC123`
---
## 任务管理 (Task)
### 创建任务
```bash
mcporter call lark-mcp.task.v2.task.create \
--args '{
"summary": "任务标题",
"description": "任务描述",
"due": {"timestamp": "1735689600"},
"members": [{"id": "ou_xxx", "role": "assignee"}]
}' \
--output json
```
### 查询任务
```bash
# 获取任务详情
mcporter call lark-mcp.task.v2.task.get task_guid=<task_id> --output json
# 列出任务
mcporter call lark-mcp.task.v2.task.list --output json
```
### 更新任务
```bash
mcporter call lark-mcp.task.v2.task.patch \
task_guid=<task_id> \
--args '{"task":{"summary":"更新后的标题","completed_at":"1735689600"}}' \
--output json
```
---
## 日程管理 (Calendar)
### 获取日历列表
```bash
mcporter call lark-mcp.calendar.v4.calendar.list --output json
```
### 创建日程
```bash
mcporter call lark-mcp.calendar.v4.calendar_event.create \
calendar_id=<calendar_id> \
--args '{
"summary": "会议标题",
"description": "会议描述",
"start_time": {"timestamp": "1735689600"},
"end_time": {"timestamp": "1735693200"},
"attendee_ability": "can_modify_event",
"attendees": [{"type": "user", "user_id": "ou_xxx"}]
}' \
--output json
```
### 查询日程
```bash
# 获取日程详情
mcporter call lark-mcp.calendar.v4.calendar_event.get \
calendar_id=<calendar_id> \
event_id=<event_id> \
--output json
# 列出日程
mcporter call lark-mcp.calendar.v4.calendar_event.list \
calendar_id=<calendar_id> \
start_time=<start_timestamp> \
end_time=<end_timestamp> \
--output json
```
### 使用预设日历工具
```bash
# 使用默认日历预设(更简单)
mcporter call lark-mcp.preset.calendar.default.<action> ...
```
---
## IM 消息操作
### 发送消息
```bash
# 发送文本消息到群聊
mcporter call lark-mcp.im.v1.message.create \
receive_id_type=chat_id \
--args '{
"receive_id": "<chat_id>",
"msg_type": "text",
"content": "{\"text\":\"Hello from MCP!\"}"
}' \
--output json
# 发送富文本消息
mcporter call lark-mcp.im.v1.message.create \
receive_id_type=chat_id \
--args '{
"receive_id": "<chat_id>",
"msg_type": "post",
"content": "{\"zh_cn\":{\"title\":\"标题\",\"content\":[[{\"tag\":\"text\",\"text\":\"内容\"}]]}}"
}' \
--output json
```
### 读取群消息
```bash
# 获取群聊消息列表
mcporter call lark-mcp.im.v1.message.list \
container_id_type=chat \
container_id=<chat_id> \
--output json
# 获取单条消息
mcporter call lark-mcp.im.v1.message.get message_id=<message_id> --output json
```
### 群聊管理
```bash
# 获取群聊列表
mcporter call lark-mcp.im.v1.chat.list --output json
# 获取群聊信息
mcporter call lark-mcp.im.v1.chat.get chat_id=<chat_id> --output json
# 获取群成员
mcporter call lark-mcp.im.v1.chat_members.get chat_id=<chat_id> --output json
```
---
## 知识库 (Wiki)
### 获取知识库信息
```bash
# 列出知识空间
mcporter call lark-mcp.wiki.v2.space.list --output json
# 获取知识空间节点
mcporter call lark-mcp.wiki.v2.space_node.list space_id=<space_id> --output json
# 获取节点信息
mcporter call lark-mcp.wiki.v2.space_node.get token=<node_token> --output json
```
---
## 用户与通讯录
### 获取用户信息
```bash
# 通过 open_id 获取用户
mcporter call lark-mcp.contact.v3.user.get \
user_id=<open_id> \
user_id_type=open_id \
--output json
# 搜索用户
mcporter call lark-mcp.contact.v3.user.batch_get_id \
--args '{"emails":["user@example.com"]}' \
--output json
```
---
## 常用预设工具
lark-mcp 提供了一些预设工具集,使用更简单:
```bash
# 日历预设
preset.calendar.default
# IM 预设
preset.im.default
# 文档预设
preset.docx.default
```
启用预设工具需要在 MCP 配置中指定,例如:
`-t preset.calendar.default,preset.im.default`
---
## 注意事项
1. **权限要求**:确保飞书应用已申请对应 API 的权限
2. **用户授权**:访问用户私有数据(文档、日程等)需要 user_access_token
3. **ID 类型**user_id 有多种类型open_id, union_id, user_id注意指定 `user_id_type`
4. **时间戳**:时间参数使用 Unix 时间戳(秒)
5. **JSON 参数**:复杂参数使用 `--args '{...}'` 传递 JSON
## 错误排查
- **99991663**: 权限不足,检查应用权限配置
- **99991668**: token 过期,重新登录授权
- **99991400**: 参数错误,检查参数格式

View File

@ -0,0 +1,11 @@
{
"id": "lark",
"channels": [
"lark"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

18
extensions/lark/index.ts Normal file
View File

@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { larkPlugin } from "./src/channel.js";
import { setLarkRuntime } from "./src/runtime.js";
const plugin = {
id: "lark",
name: "Lark",
description: "Lark (Feishu) channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setLarkRuntime(api.runtime);
api.registerChannel({ plugin: larkPlugin });
},
};
export default plugin;

582
extensions/lark/package-lock.json generated Normal file
View File

@ -0,0 +1,582 @@
{
"name": "@clawdbot/lark",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@clawdbot/lark",
"version": "0.0.1",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.37.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/@larksuiteoapi/node-sdk": {
"version": "1.56.1",
"resolved": "http://mirrors.tencentyun.com/npm/@larksuiteoapi/node-sdk/-/node-sdk-1.56.1.tgz",
"integrity": "sha512-/ixtyJnWOmcupKgDXz+6G6qTLMi3cNrR+LGOuq2PMwcJ6hhXTUJNyAF+ADY7ah9OoeDniGU/UJwMb2gqKdxwcA==",
"license": "MIT",
"dependencies": {
"axios": "0.27.2",
"lodash.identity": "^3.0.0",
"lodash.merge": "^4.6.2",
"lodash.pickby": "^4.6.0",
"protobufjs": "^7.2.6",
"qs": "^6.13.0",
"ws": "^8.16.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@types/node": {
"version": "25.0.10",
"resolved": "http://mirrors.tencentyun.com/npm/@types/node/-/node-25.0.10.tgz",
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "http://mirrors.tencentyun.com/npm/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "0.27.2",
"resolved": "http://mirrors.tencentyun.com/npm/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "http://mirrors.tencentyun.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "http://mirrors.tencentyun.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "http://mirrors.tencentyun.com/npm/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "http://mirrors.tencentyun.com/npm/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "http://mirrors.tencentyun.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "http://mirrors.tencentyun.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "http://mirrors.tencentyun.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "http://mirrors.tencentyun.com/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "http://mirrors.tencentyun.com/npm/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "http://mirrors.tencentyun.com/npm/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "http://mirrors.tencentyun.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "http://mirrors.tencentyun.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "http://mirrors.tencentyun.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "http://mirrors.tencentyun.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "http://mirrors.tencentyun.com/npm/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "http://mirrors.tencentyun.com/npm/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lodash.identity": {
"version": "3.0.0",
"resolved": "http://mirrors.tencentyun.com/npm/lodash.identity/-/lodash.identity-3.0.0.tgz",
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "http://mirrors.tencentyun.com/npm/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.pickby": {
"version": "4.6.0",
"resolved": "http://mirrors.tencentyun.com/npm/lodash.pickby/-/lodash.pickby-4.6.0.tgz",
"integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "http://mirrors.tencentyun.com/npm/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "http://mirrors.tencentyun.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "http://mirrors.tencentyun.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "http://mirrors.tencentyun.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "http://mirrors.tencentyun.com/npm/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "http://mirrors.tencentyun.com/npm/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "http://mirrors.tencentyun.com/npm/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "http://mirrors.tencentyun.com/npm/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "http://mirrors.tencentyun.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "http://mirrors.tencentyun.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "http://mirrors.tencentyun.com/npm/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "http://mirrors.tencentyun.com/npm/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "http://mirrors.tencentyun.com/npm/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -0,0 +1,17 @@
{
"name": "@clawdbot/lark",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot Lark (Feishu) channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
},
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.37.0"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

73
extensions/lark/readme.md Normal file
View File

@ -0,0 +1,73 @@
clawdbot config set channels.lark.enabled true
clawdbot config set channels.lark.appId "cli_a9fc5173f1b8ded0"
clawdbot config set channels.lark.appSecret "zg3K9mVcKMcGdDBZTrf9Ybr3lKa2YFN6"
# Lark bot openID: Mentioned Only Response
clawdbot config set channels.lark.botOpenId "ou_60fbc6f87749c6b6f7816136d1d816b6"
## Cloudflare构建Lark Webhook链接
cloudflared tunnel login
cloudflared tunnel create lark-webhook
# 每次都会变,临时方案:
cloudflared tunnel --url http://localhost:9000
# Oauth Lark
https://warming-evanescence-tone-unions.trycloudflare.com/oauth/callback
clawdbot config set channels.lark.oauthRedirectUri "https://warming-evanescence-tone-unions.trycloudflare.com/oauth/callback"
# Oauth权限
clawdbot config set channels.lark.oauthScope "docx:document wiki:wiki:readonly drive:drive:readonly bitable:app"
# Skills
clawdbot config set 'skills.load.extraDirs' '["~/.clawdbot/skills"]'
# Architecture:
Deploy: Tencent Cloud (Kaze大管家)
Clawdbot:
1. Skills:
mcporter -> Lark-MCP
Larkmcp Skills;
Webhook: Lark开发者平台Bot App回调 ->
接受信息群里面有人at Bot -> (回调) https://warming-evanescence-tone-unions.trycloudflare.com/webhook (CloudFlare Tunnel)
-> 转发到 Tencent Cloud 43.162.107.61 本机9000端口 -> Lark Extension -> Clawdbot Channel
-> Agent 操作
Agent操作
通过Lark-MCP(https://github.com/larksuite/lark-openapi-mcp)
1. 读写群消息;
2. 读写文档;多维表格;
.....
发送信息:
Lark-MCP -> 发送信息:发到群里面;
改动点:
1. Fork了一份 -> 增加了extension下的lark
/home/ubuntu/kaze_moltbot/moltbot/extensions/lark
运行的是预装Clawdbot
读指定路径下的extension
2. 增加了lark-mcp skill(SLILL.md)
/home/ubuntu/.clawdbot/skills/lark-mcp/SKILL.md
完全重新配置:
1. 先Tencent Cloud开一台预装了Clawdbot金属机
2. 增加我的这些改动点;
extension: lark
skill: lark-mcp
3. 在lark开发者平台上创建新的Bot App拿到App IDApp secret权限配置好
4. 运行clawdbot
让他去支持Lark-MCP(https://github.com/larksuite/lark-openapi-mcp)

View File

@ -0,0 +1,141 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { LarkChannelConfig, ResolvedLarkAccount, LarkAccountConfig } from "./types.js";
export const DEFAULT_ACCOUNT_ID = "default";
// Get lark channel config from clawdbot config
function getLarkConfig(cfg: ClawdbotConfig): LarkChannelConfig | undefined {
return cfg.channels?.lark as LarkChannelConfig | undefined;
}
// List all configured lark account IDs
export function listLarkAccountIds(cfg: ClawdbotConfig): string[] {
const larkConfig = getLarkConfig(cfg);
if (!larkConfig) return [];
const accountIds = new Set<string>();
// Check if base config has credentials (default account)
if (larkConfig.appId || process.env.LARK_APP_ID) {
accountIds.add(DEFAULT_ACCOUNT_ID);
}
// Add named accounts
if (larkConfig.accounts) {
for (const accountId of Object.keys(larkConfig.accounts)) {
accountIds.add(accountId);
}
}
return Array.from(accountIds);
}
// Resolve default account ID
export function resolveDefaultLarkAccountId(cfg: ClawdbotConfig): string {
const accountIds = listLarkAccountIds(cfg);
return accountIds.length > 0 ? accountIds[0] : DEFAULT_ACCOUNT_ID;
}
// Normalize account ID
export function normalizeAccountId(accountId?: string): string {
const trimmed = accountId?.trim().toLowerCase() ?? "";
if (!trimmed || trimmed === "default") return DEFAULT_ACCOUNT_ID;
return trimmed;
}
// Resolve a specific lark account
export function resolveLarkAccount(params: {
cfg: ClawdbotConfig;
accountId?: string;
}): ResolvedLarkAccount {
const { cfg, accountId } = params;
const resolvedAccountId = normalizeAccountId(accountId);
const larkConfig = getLarkConfig(cfg);
// Check for named account first
const accountConfig =
resolvedAccountId !== DEFAULT_ACCOUNT_ID
? larkConfig?.accounts?.[resolvedAccountId]
: undefined;
// Resolve credentials with fallback chain: account config -> base config -> env
const appIdFromAccount = accountConfig?.appId?.trim();
const appIdFromBase = larkConfig?.appId?.trim();
const appIdFromEnv = process.env.LARK_APP_ID?.trim();
const appSecretFromAccount = accountConfig?.appSecret?.trim();
const appSecretFromBase = larkConfig?.appSecret?.trim();
const appSecretFromEnv = process.env.LARK_APP_SECRET?.trim();
const appId = appIdFromAccount || appIdFromBase || appIdFromEnv || "";
const appSecret = appSecretFromAccount || appSecretFromBase || appSecretFromEnv || "";
// Determine source of credentials
let appIdSource: "config" | "env" | "none" = "none";
if (appIdFromAccount || appIdFromBase) {
appIdSource = "config";
} else if (appIdFromEnv) {
appIdSource = "env";
}
let appSecretSource: "config" | "env" | "none" = "none";
if (appSecretFromAccount || appSecretFromBase) {
appSecretSource = "config";
} else if (appSecretFromEnv) {
appSecretSource = "env";
}
// Resolve other config values
const encryptKey =
accountConfig?.encryptKey?.trim() ||
larkConfig?.encryptKey?.trim() ||
process.env.LARK_ENCRYPT_KEY?.trim();
const verificationToken =
accountConfig?.verificationToken?.trim() ||
larkConfig?.verificationToken?.trim() ||
process.env.LARK_VERIFICATION_TOKEN?.trim();
const botOpenId =
accountConfig?.botOpenId?.trim() ||
larkConfig?.botOpenId?.trim() ||
process.env.LARK_BOT_OPEN_ID?.trim();
// Determine if enabled
const enabled =
resolvedAccountId !== DEFAULT_ACCOUNT_ID
? accountConfig?.enabled !== false
: larkConfig?.enabled !== false;
// Build merged config for this account
const mergedConfig: LarkAccountConfig = {
...larkConfig,
...accountConfig,
appId,
appSecret,
encryptKey,
verificationToken,
botOpenId,
};
return {
accountId: resolvedAccountId,
name: accountConfig?.name || (resolvedAccountId === DEFAULT_ACCOUNT_ID ? undefined : resolvedAccountId),
enabled,
appId,
appSecret,
encryptKey,
verificationToken,
appIdSource,
appSecretSource,
config: mergedConfig,
};
}
// List all enabled lark accounts
export function listEnabledLarkAccounts(cfg: ClawdbotConfig): ResolvedLarkAccount[] {
const accountIds = listLarkAccountIds(cfg);
return accountIds
.map((accountId) => resolveLarkAccount({ cfg, accountId }))
.filter((account) => account.enabled && account.appId && account.appSecret);
}

View File

@ -0,0 +1,343 @@
import {
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
} from "clawdbot/plugin-sdk";
import {
listLarkAccountIds,
resolveLarkAccount,
resolveDefaultLarkAccountId,
listEnabledLarkAccounts,
normalizeAccountId,
} from "./accounts.js";
import { sendMessageLark, probeLark } from "./send.js";
import { monitorLarkProvider } from "./monitor.js";
import { getLarkRuntime } from "./runtime.js";
import type { ResolvedLarkAccount } from "./types.js";
// Channel metadata
const meta = {
id: "lark" as const,
name: "Lark",
displayName: "Lark (Feishu)",
icon: "💬",
docsUrl: "https://docs.clawd.bot/channels/lark",
};
// Normalize target ID for messaging
function normalizeLarkMessagingTarget(target: string): string {
return target
.trim()
.replace(/^lark:/i, "")
.replace(/^feishu:/i, "")
.trim();
}
// Check if target looks like a Lark ID
function looksLikeLarkTargetId(target: string): boolean {
const normalized = normalizeLarkMessagingTarget(target);
// Lark chat IDs typically start with 'oc_' for group chats
// or are user open_ids
return /^oc_[\w-]+$/.test(normalized) || /^ou_[\w-]+$/.test(normalized) || normalized.length > 10;
}
export const larkPlugin: ChannelPlugin<ResolvedLarkAccount> = {
id: "lark",
meta: {
...meta,
quickstartAllowFrom: true,
},
capabilities: {
chatTypes: ["direct", "group"],
reactions: false,
threads: true,
media: true,
nativeCommands: false,
},
reload: { configPrefixes: ["channels.lark"] },
config: {
listAccountIds: (cfg) => listLarkAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveLarkAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultLarkAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const resolvedAccountId = normalizeAccountId(accountId);
const updated = { ...cfg };
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
updated.channels = {
...updated.channels,
lark: {
...updated.channels?.lark,
enabled,
},
};
} else {
updated.channels = {
...updated.channels,
lark: {
...updated.channels?.lark,
accounts: {
...updated.channels?.lark?.accounts,
[resolvedAccountId]: {
...updated.channels?.lark?.accounts?.[resolvedAccountId],
enabled,
},
},
},
};
}
return updated;
},
deleteAccount: ({ cfg, accountId }) => {
const resolvedAccountId = normalizeAccountId(accountId);
const updated = { ...cfg };
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
if (updated.channels?.lark) {
const { appId, appSecret, encryptKey, verificationToken, ...rest } = updated.channels.lark;
updated.channels = {
...updated.channels,
lark: rest,
};
}
} else {
const accounts = { ...updated.channels?.lark?.accounts };
delete accounts[resolvedAccountId];
updated.channels = {
...updated.channels,
lark: {
...updated.channels?.lark,
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
},
};
}
return updated;
},
isConfigured: (account) => Boolean(account.appId && account.appSecret),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.appId && account.appSecret),
appIdSource: account.appIdSource,
appSecretSource: account.appSecretSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveLarkAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(lark|feishu):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.lark?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.lark.accounts.${resolvedAccountId}.`
: "channels.lark.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: `clawdbot config set channels.lark.allowFrom <user_open_id>`,
normalizeEntry: (raw) => raw.replace(/^(lark|feishu):/i, ""),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- Lark groups: groupPolicy="open" with no group allowlist; any group can add the bot.`,
];
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveLarkAccount({ cfg, accountId });
const groups = account.config.groups;
const groupConfig = groups?.[groupId] ?? groups?.["*"];
return groupConfig?.requireMention ?? true;
},
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
const account = resolveLarkAccount({ cfg, accountId });
const groups = account.config.groups;
const groupConfig = groups?.[groupId] ?? groups?.["*"];
return groupConfig?.toolPolicy ?? "default";
},
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.lark?.replyToMode ?? "first",
},
messaging: {
normalizeTarget: normalizeLarkMessagingTarget,
targetResolver: {
looksLikeId: looksLikeLarkTargetId,
hint: "<chatId|openId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const resolvedAccountId = normalizeAccountId(accountId);
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return cfg;
}
return {
...cfg,
channels: {
...cfg.channels,
lark: {
...cfg.channels?.lark,
accounts: {
...cfg.channels?.lark?.accounts,
[resolvedAccountId]: {
...cfg.channels?.lark?.accounts?.[resolvedAccountId],
name,
},
},
},
},
};
},
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "Lark env credentials can only be used for the default account.";
}
if (!input.useEnv && (!input.appId || !input.appSecret)) {
return "Lark requires --app-id and --app-secret (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const resolvedAccountId = normalizeAccountId(accountId);
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
lark: {
...cfg.channels?.lark,
enabled: true,
...(input.useEnv
? {}
: {
...(input.appId ? { appId: input.appId } : {}),
...(input.appSecret ? { appSecret: input.appSecret } : {}),
}),
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
lark: {
...cfg.channels?.lark,
enabled: true,
accounts: {
...cfg.channels?.lark?.accounts,
[resolvedAccountId]: {
...cfg.channels?.lark?.accounts?.[resolvedAccountId],
enabled: true,
...(input.appId ? { appId: input.appId } : {}),
...(input.appSecret ? { appSecret: input.appSecret } : {}),
},
},
},
},
};
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getLarkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageLark(to, text, {
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "lark", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
const result = await sendMessageLark(to, text, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "lark", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
appIdSource: snapshot.appIdSource ?? "none",
appSecretSource: snapshot.appSecretSource ?? "none",
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
if (!account.appId || !account.appSecret) {
return { ok: false, error: "missing credentials" };
}
return await probeLark(account.appId, account.appSecret, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => {
const configured = Boolean(account.appId && account.appSecret);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
appIdSource: account.appIdSource,
appSecretSource: account.appSecretSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.log?.info(`[${account.accountId}] starting Lark provider`);
return monitorLarkProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPort: account.config.webhookPort,
webhookPath: account.config.webhookPath,
});
},
},
};

View File

@ -0,0 +1,170 @@
import * as lark from "@larksuiteoapi/node-sdk";
import type { ResolvedLarkAccount } from "./types.js";
// Cache for Lark clients
const clientCache = new Map<string, lark.Client>();
// Create or get cached Lark client
export function getLarkClient(account: ResolvedLarkAccount): lark.Client {
const cacheKey = `${account.appId}:${account.appSecret}`;
let client = clientCache.get(cacheKey);
if (!client) {
client = new lark.Client({
appId: account.appId,
appSecret: account.appSecret,
disableTokenCache: false,
});
clientCache.set(cacheKey, client);
}
return client;
}
// Clear client cache (useful for testing or credential rotation)
export function clearLarkClientCache(): void {
clientCache.clear();
}
// Send text message to a chat
export async function sendLarkMessage(params: {
client: lark.Client;
chatId: string;
content: string;
msgType?: "text" | "post" | "interactive";
}): Promise<{ messageId: string; chatId: string }> {
const { client, chatId, content, msgType = "text" } = params;
// Prepare content based on message type
let formattedContent: string;
if (msgType === "text") {
formattedContent = JSON.stringify({ text: content });
} else {
formattedContent = content;
}
const response = await client.im.message.create({
params: {
receive_id_type: "chat_id",
},
data: {
receive_id: chatId,
msg_type: msgType,
content: formattedContent,
},
});
if (response.code !== 0) {
throw new Error(`Lark API error: ${response.msg} (code: ${response.code})`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId,
};
}
// Reply to a message in a thread
export async function replyLarkMessage(params: {
client: lark.Client;
messageId: string;
content: string;
msgType?: "text" | "post" | "interactive";
}): Promise<{ messageId: string }> {
const { client, messageId, content, msgType = "text" } = params;
let formattedContent: string;
if (msgType === "text") {
formattedContent = JSON.stringify({ text: content });
} else {
formattedContent = content;
}
const response = await client.im.message.reply({
path: {
message_id: messageId,
},
data: {
msg_type: msgType,
content: formattedContent,
},
});
if (response.code !== 0) {
throw new Error(`Lark API error: ${response.msg} (code: ${response.code})`);
}
return {
messageId: response.data?.message_id ?? "unknown",
};
}
// Send image message
export async function sendLarkImage(params: {
client: lark.Client;
chatId: string;
imageKey: string;
}): Promise<{ messageId: string; chatId: string }> {
const { client, chatId, imageKey } = params;
const content = JSON.stringify({ image_key: imageKey });
const response = await client.im.message.create({
params: {
receive_id_type: "chat_id",
},
data: {
receive_id: chatId,
msg_type: "image",
content,
},
});
if (response.code !== 0) {
throw new Error(`Lark API error: ${response.msg} (code: ${response.code})`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId,
};
}
// Upload image to Lark
export async function uploadLarkImage(params: {
client: lark.Client;
image: Buffer;
imageType?: "message" | "avatar";
}): Promise<string> {
const { client, image, imageType = "message" } = params;
const response = await client.im.image.create({
data: {
image_type: imageType,
image: Buffer.from(image),
},
});
if (response.code !== 0) {
throw new Error(`Lark image upload error: ${response.msg} (code: ${response.code})`);
}
return response.data?.image_key ?? "";
}
// Get bot info
export async function getLarkBotInfo(client: lark.Client): Promise<{
appName: string;
openId: string;
}> {
const response = await client.bot.botInfo.get({});
if (response.code !== 0) {
throw new Error(`Lark API error: ${response.msg} (code: ${response.code})`);
}
return {
appName: response.data?.bot?.bot_name ?? "unknown",
openId: response.data?.bot?.open_id ?? "",
};
}

View File

@ -0,0 +1,678 @@
import * as http from "node:http";
import * as url from "node:url";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ResolvedLarkAccount } from "./types.js";
import { resolveLarkAccount } from "./accounts.js";
import { getLarkClient } from "./client.js";
import { getLarkRuntime } from "./runtime.js";
import {
hasUserAuthorized,
generateAuthUrl,
exchangeCodeForToken,
getValidUserToken,
type UserToken,
} from "./oauth.js";
export type MonitorLarkOpts = {
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
webhookPort?: number;
webhookPath?: string;
};
// Pending message type - stores messages waiting for user authorization
type PendingMessage = {
senderId: string;
chatId: string;
messageText: string;
messageId: string;
chatType: "p2p" | "group";
senderName?: string;
timestamp: number;
};
// Cache for pending messages (keyed by senderId)
const pendingMessagesCache = new Map<string, PendingMessage>();
// Store a pending message for later processing after auth
function storePendingMessage(msg: PendingMessage) {
pendingMessagesCache.set(msg.senderId, msg);
// Clean up old pending messages (older than 10 minutes)
const now = Date.now();
for (const [key, value] of pendingMessagesCache.entries()) {
if (now - value.timestamp > 10 * 60 * 1000) {
pendingMessagesCache.delete(key);
}
}
}
// Get and remove pending message for a user
function getPendingMessage(senderId: string): PendingMessage | undefined {
const msg = pendingMessagesCache.get(senderId);
if (msg) {
pendingMessagesCache.delete(senderId);
}
return msg;
}
// Extract text content from Lark message
function extractMessageText(content: string, messageType: string): string {
try {
if (messageType === "text") {
const parsed = JSON.parse(content);
return parsed.text ?? "";
}
// Handle other message types as needed
return content;
} catch {
return content;
}
}
// Send authorization link to user
async function sendAuthLink(params: {
account: ResolvedLarkAccount;
chatId: string;
senderId: string;
webhookPort: number;
log: (...args: any[]) => void;
}) {
const { account, chatId, senderId, webhookPort, log } = params;
// Build redirect URI
const redirectUri = account.config.oauthRedirectUri ||
`http://localhost:${webhookPort}/oauth/callback`;
// Default scopes for document access
const scope = account.config.oauthScope ||
"docx:document:readonly wiki:wiki:readonly drive:drive:readonly";
// Generate auth URL with state containing senderId for tracking
const authUrl = generateAuthUrl({
appId: account.appId,
redirectUri,
state: senderId,
scope,
});
log(`[lark:${account.accountId}] Sending auth link to ${senderId}`);
// Send message with authorization link
const client = getLarkClient(account);
await client.im.message.create({
params: {
receive_id_type: "chat_id",
},
data: {
receive_id: chatId,
msg_type: "interactive",
content: JSON.stringify({
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: "plain_text",
content: "🔐 需要授权",
},
},
elements: [
{
tag: "div",
text: {
tag: "lark_md",
content: "要使用文档相关功能,需要你先授权机器人访问你的文档。\n\n点击下方按钮完成授权只需一次",
},
},
{
tag: "action",
actions: [
{
tag: "button",
text: {
tag: "plain_text",
content: "点击授权",
},
type: "primary",
url: authUrl,
},
],
},
],
}),
},
});
}
// Handle incoming Lark event
async function handleLarkEvent(params: {
data: any;
account: ResolvedLarkAccount;
config: ClawdbotConfig;
runtime?: RuntimeEnv;
webhookPort?: number;
skipMentionCheck?: boolean; // Skip mention check for pending message reprocessing
}) {
const { data, account, config, runtime, webhookPort = 9000, skipMentionCheck = false } = params;
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
// Get event type from header or schema
const eventType = data.header?.event_type ?? data.event?.type;
if (eventType !== "im.message.receive_v1") {
log(`[lark:${account.accountId}] Ignoring event type: ${eventType}`);
return;
}
const event = data.event;
if (!event) {
log(`[lark:${account.accountId}] No event data`);
return;
}
const message = event.message;
const sender = event.sender;
if (!message || !sender) {
log(`[lark:${account.accountId}] Missing message or sender`);
return;
}
const messageText = extractMessageText(message.content, message.message_type);
const chatId = message.chat_id;
const chatType = message.chat_type;
const senderId = sender.sender_id?.open_id ?? sender.sender_id?.user_id ?? "unknown";
const senderUnionId = sender.sender_id?.union_id;
const messageId = message.message_id;
const isGroup = chatType !== "p2p";
// Try to get sender's name from Lark API (requires contact:user.base:readonly permission)
let senderName: string | undefined;
try {
const client = getLarkClient(account);
const userInfo = await client.contact.v3.user.get({
path: { user_id: senderId },
params: { user_id_type: "open_id" },
});
senderName = userInfo.data?.user?.name;
} catch {
// Silently ignore - app may not have user info permission
// Sender name is optional, we'll just use open_id
}
// Build sender info string for logging and context
const senderInfo = senderName
? `${senderName} (${senderId})`
: senderId;
// Get bot's open_id from cache
const botOpenId = botOpenIdCache.get(account.accountId);
// Check if bot was mentioned in the message
// Only respond if THIS bot is @mentioned, not just any @mention
const mentions = message.mentions ?? [];
const isBotMentioned = botOpenId
? mentions.some((m: any) => m.id?.open_id === botOpenId)
: false; // If we don't have bot info cached, don't respond to avoid responding to other bots' mentions
// Only log mention details if there are mentions
if (mentions.length > 0) {
log(`[lark:${account.accountId}] Mentions: ${mentions.map((m: any) => m.name).join(", ")}, isBotMentioned: ${isBotMentioned}`);
}
// For group chats, only respond when bot is @mentioned (unless skipMentionCheck is true)
if (isGroup && !isBotMentioned && !skipMentionCheck) {
log(`[lark:${account.accountId}] Ignoring group message (bot not mentioned)`);
return;
}
// Build session key for this conversation
const sessionKey = `lark:${chatId}`;
// Log inbound message
log(`[lark:${account.accountId}] Received message from ${senderInfo} in ${chatType}: ${messageText.slice(0, 50)}...`);
// Record channel activity
try {
getLarkRuntime().channel.activity.record({
channel: "lark",
accountId: account.accountId,
direction: "inbound",
});
} catch (err) {
// Ignore activity recording errors
}
// Resolve agent route for session management
const route = getLarkRuntime().channel.routing.resolveAgentRoute({
cfg: config,
channel: "lark",
accountId: account.accountId,
});
const agentId = route.agentId;
// Use a single shared session for all Lark chats (groups and DMs)
const finalSessionKey = route.mainSessionKey;
// Check if user has authorized (for document access features)
const userAuthorized = hasUserAuthorized(senderId);
let userToken: UserToken | null = null;
if (userAuthorized) {
userToken = await getValidUserToken({ account, openId: senderId });
}
// Check if message requires document access (simple keyword check)
const needsDocAccess = /文档|授权|document|doc|wiki|知识库|云文档|飞书文档/i.test(messageText);
// If needs doc access but not authorized, send auth link and store pending message
if (needsDocAccess && !userToken && account.config.requireUserAuth !== false) {
log(`[lark:${account.accountId}] User ${senderId} needs authorization for document access`);
try {
// Store the message for processing after authorization
const pendingMsg = {
senderId,
chatId,
messageText,
messageId,
chatType: chatType as "p2p" | "group",
senderName,
timestamp: Date.now(),
};
storePendingMessage(pendingMsg);
log(`[lark:${account.accountId}] Stored pending message for senderId: ${senderId}, messageId: ${messageId}`);
await sendAuthLink({
account,
chatId,
senderId,
webhookPort,
log,
});
return; // Don't process the message further until user authorizes
} catch (authErr) {
error(`[lark:${account.accountId}] Failed to send auth link: ${authErr}`);
// Continue processing without user token
}
}
// Build the inbound context for clawdbot
// Include sender info (name and open_id) so the agent knows who sent the message
const bodyWithSource = isGroup
? `[From: ${senderInfo} in lark group: ${chatId}]\n${messageText}`
: `[From: ${senderInfo}]\n${messageText}`;
const inboundCtx = {
Body: bodyWithSource,
RawBody: messageText,
From: `lark:${senderId}`,
FromName: senderName,
FromOpenId: senderId,
To: `lark:${chatId}`,
SessionKey: finalSessionKey,
AccountId: account.accountId,
ChatType: isGroup ? "group" : "direct",
Surface: "lark",
Provider: "lark",
MessageSid: messageId,
IsMentioned: isBotMentioned,
UserAuthorized: userAuthorized,
UserAccessToken: userToken?.accessToken,
OriginatingChannel: "lark" as const,
OriginatingTo: `lark:${chatId}`,
};
// Try to dispatch through clawdbot's reply system
try {
const finalizedCtx = getLarkRuntime().channel.reply.finalizeInboundContext(inboundCtx);
// Record session for chat history
try {
const storePath = getLarkRuntime().channel.session.resolveStorePath(config.session?.store, { agentId });
await getLarkRuntime().channel.session.recordInboundSession({
storePath,
sessionKey: finalizedCtx.SessionKey ?? finalSessionKey,
ctx: finalizedCtx,
createIfMissing: true,
// Always update last route so replies go to the most recent chat
updateLastRoute: {
sessionKey: route.mainSessionKey,
channel: "lark",
to: chatId,
accountId: account.accountId,
},
onRecordError: (err) => {
log(`[lark:${account.accountId}] Session record error: ${err}`);
},
});
} catch (sessionErr) {
log(`[lark:${account.accountId}] Failed to record session: ${sessionErr}`);
}
// Create a simple dispatcher that sends replies back to Lark
const client = getLarkClient(account);
const sendReply = async (text: string) => {
await client.im.message.create({
params: {
receive_id_type: "chat_id",
},
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text }),
},
});
log(`[lark:${account.accountId}] Sent reply to ${chatId}`);
// Record outbound activity
getLarkRuntime().channel.activity.record({
channel: "lark",
accountId: account.accountId,
direction: "outbound",
});
};
// Use dispatchReplyWithBufferedBlockDispatcher for AI reply
const result = await getLarkRuntime().channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: finalizedCtx,
cfg: config,
dispatcherOptions: {
deliver: async (payload: any, info: any) => {
if (payload.text) {
await sendReply(payload.text);
}
},
onError: (err: any, info: any) => {
error(`[lark:${account.accountId}] Reply error: ${err}`);
},
},
});
if (!result.queuedFinal) {
log(`[lark:${account.accountId}] No response generated`);
}
} catch (err) {
error(`[lark:${account.accountId}] Failed to dispatch reply: ${err}`);
// Fallback: send simple echo reply
try {
const client = getLarkClient(account);
await client.im.message.create({
params: {
receive_id_type: "chat_id",
},
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: `收到: ${messageText.slice(0, 100)}` }),
},
});
} catch (fallbackErr) {
error(`[lark:${account.accountId}] Fallback reply also failed: ${fallbackErr}`);
}
}
}
// Cache for bot open_id per account
const botOpenIdCache = new Map<string, string>();
// Start webhook server to receive Lark events
export async function monitorLarkProvider(opts: MonitorLarkOpts = {}) {
const cfg = opts.config ?? getLarkRuntime().config.loadConfig();
const account = resolveLarkAccount({
cfg,
accountId: opts.accountId,
});
if (!account.appId || !account.appSecret) {
throw new Error(
`Lark credentials missing for account "${account.accountId}" (set channels.lark.appId/appSecret or LARK_APP_ID/LARK_APP_SECRET).`,
);
}
const port = opts.webhookPort ?? account.config.webhookPort ?? 9000;
const webhookPath = opts.webhookPath ?? account.config.webhookPath ?? "/webhook";
const log = opts.runtime?.log ?? console.log;
const error = opts.runtime?.error ?? console.error;
// Get bot's open_id from config or environment
const botOpenId = account.config.botOpenId?.trim() || process.env.LARK_BOT_OPEN_ID?.trim();
if (botOpenId) {
botOpenIdCache.set(account.accountId, botOpenId);
log(`[lark:${account.accountId}] Bot open_id from config: ${botOpenId}`);
} else {
log(`[lark:${account.accountId}] Warning: No botOpenId configured. Bot will not respond to @mentions in groups.`);
log(`[lark:${account.accountId}] Set channels.lark.botOpenId in config or LARK_BOT_OPEN_ID env var.`);
}
// Helper to check if URL matches webhook path
const matchesWebhookPath = (url: string | undefined): boolean => {
if (!url) return false;
const urlPath = url.split("?")[0]; // Remove query string
return urlPath === webhookPath || urlPath === webhookPath + "/";
};
// Create HTTP server
const server = http.createServer(async (req, res) => {
// Health check endpoint
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", channel: "lark", accountId: account.accountId }));
return;
}
// Handle webhook POST requests
if (req.method === "POST" && matchesWebhookPath(req.url)) {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
const data = JSON.parse(body);
log(`[lark:${account.accountId}] Received webhook: ${JSON.stringify(data).slice(0, 200)}...`);
// Handle URL verification challenge from Lark
if (data.type === "url_verification") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ challenge: data.challenge }));
log(`[lark:${account.accountId}] URL verification completed`);
return;
}
// Handle event callback (v2 schema)
if (data.schema === "2.0" || data.header) {
// Process event asynchronously
handleLarkEvent({
data,
account,
config: cfg,
runtime: opts.runtime as RuntimeEnv,
webhookPort: port,
}).catch((err) => {
error(`[lark:${account.accountId}] Event handler error: ${err}`);
});
// Respond immediately to Lark
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
return;
}
// Handle event callback (v1 schema)
if (data.event) {
handleLarkEvent({
data,
account,
config: cfg,
runtime: opts.runtime as RuntimeEnv,
webhookPort: port,
}).catch((err) => {
error(`[lark:${account.accountId}] Event handler error: ${err}`);
});
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
return;
}
// Unknown request type
log(`[lark:${account.accountId}] Unknown webhook data: ${JSON.stringify(data).slice(0, 100)}`);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
} catch (err) {
error(`[lark:${account.accountId}] Webhook error: ${err}`);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
return;
}
// OAuth callback endpoint
if (req.method === "GET" && req.url?.startsWith("/oauth/callback")) {
try {
const parsedUrl = url.parse(req.url, true);
const code = parsedUrl.query.code as string;
const state = parsedUrl.query.state as string;
if (!code) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>授权失败</h1><p>缺少授权码</p>");
return;
}
log(`[lark:${account.accountId}] OAuth callback received, code: ${code.slice(0, 10)}...`);
// Get the redirect_uri that was used for this OAuth flow
const redirectUri = account.config.oauthRedirectUri ||
`http://localhost:${port}/oauth/callback`;
// Exchange code for token
const token = await exchangeCodeForToken({ account, code, redirectUri });
log(`[lark:${account.accountId}] User authorized: ${token.openId}`);
// Check for pending message from this user
log(`[lark:${account.accountId}] Looking for pending message with key: ${token.openId}`);
log(`[lark:${account.accountId}] Current pending messages: ${JSON.stringify([...pendingMessagesCache.keys()])}`);
const pendingMsg = getPendingMessage(token.openId);
if (pendingMsg) {
log(`[lark:${account.accountId}] Found pending message: ${JSON.stringify(pendingMsg)}`);
log(`[lark:${account.accountId}] Processing pending message for ${token.openId}`);
// Process the pending message with the new user token
// Build a fake event data structure to reprocess the message
const fakeEventData = {
schema: "2.0",
header: { event_type: "im.message.receive_v1" },
event: {
sender: {
sender_id: { open_id: pendingMsg.senderId },
},
message: {
message_id: pendingMsg.messageId,
chat_id: pendingMsg.chatId,
chat_type: pendingMsg.chatType,
message_type: "text",
content: JSON.stringify({ text: pendingMsg.messageText }),
mentions: [], // User already authorized, no need for mention check bypass
},
},
};
// Process asynchronously so we can respond to the OAuth callback immediately
handleLarkEvent({
data: fakeEventData,
account,
config: cfg,
runtime: opts.runtime as RuntimeEnv,
webhookPort: port,
skipMentionCheck: true, // Skip mention check for pending messages
}).catch((err) => {
error(`[lark:${account.accountId}] Failed to process pending message: ${err}`);
});
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`
<html>
<head><title></title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1> </h1>
<p> Lark </p>
<p> Lark </p>
</body>
</html>
`);
} else {
log(`[lark:${account.accountId}] No pending message found for ${token.openId}`);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`
<html>
<head><title></title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1> </h1>
<p> Lark </p>
<p> Lark 使</p>
</body>
</html>
`);
}
} catch (err) {
error(`[lark:${account.accountId}] OAuth callback error: ${err}`);
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
res.end(`
<html>
<head><title></title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1> </h1>
<p>错误: ${err}</p>
<p></p>
</body>
</html>
`);
}
return;
}
// 404 for other requests
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
// Handle abort signal
if (opts.abortSignal) {
opts.abortSignal.addEventListener(
"abort",
() => {
log(`[lark:${account.accountId}] Stopping webhook server`);
server.close();
},
{ once: true },
);
}
// Start server
return new Promise<void>((resolve, reject) => {
server.on("error", (err) => {
error(`[lark:${account.accountId}] Server error: ${err}`);
reject(err);
});
server.listen(port, () => {
log(`[lark:${account.accountId}] Webhook server listening on port ${port} at ${webhookPath}`);
resolve();
});
});
}

View File

@ -0,0 +1,312 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type { ResolvedLarkAccount } from "./types.js";
// Token storage directory
const TOKEN_STORAGE_DIR = process.env.LARK_TOKEN_DIR || path.join(process.env.HOME || "/tmp", ".clawdbot", "lark-tokens");
// Ensure token directory exists
function ensureTokenDir() {
if (!fs.existsSync(TOKEN_STORAGE_DIR)) {
fs.mkdirSync(TOKEN_STORAGE_DIR, { recursive: true });
}
}
// Token data structure
export type UserToken = {
openId: string;
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp in ms
refreshExpiresAt: number; // Unix timestamp in ms
createdAt: number;
updatedAt: number;
};
// Get token file path for a user
function getTokenPath(openId: string): string {
ensureTokenDir();
// Sanitize openId for filename
const safeId = openId.replace(/[^a-zA-Z0-9_-]/g, "_");
return path.join(TOKEN_STORAGE_DIR, `${safeId}.json`);
}
// Load user token from storage
export function loadUserToken(openId: string): UserToken | null {
const tokenPath = getTokenPath(openId);
try {
if (fs.existsSync(tokenPath)) {
const data = fs.readFileSync(tokenPath, "utf-8");
return JSON.parse(data) as UserToken;
}
} catch (err) {
console.error(`[lark-oauth] Failed to load token for ${openId}:`, err);
}
return null;
}
// Save user token to storage
export function saveUserToken(token: UserToken): void {
const tokenPath = getTokenPath(token.openId);
try {
ensureTokenDir();
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2));
} catch (err) {
console.error(`[lark-oauth] Failed to save token for ${token.openId}:`, err);
}
}
// Delete user token
export function deleteUserToken(openId: string): void {
const tokenPath = getTokenPath(openId);
try {
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
}
} catch (err) {
console.error(`[lark-oauth] Failed to delete token for ${openId}:`, err);
}
}
// Check if token is expired (with 5 minute buffer)
export function isTokenExpired(token: UserToken): boolean {
return Date.now() > token.expiresAt - 5 * 60 * 1000;
}
// Check if refresh token is expired
export function isRefreshTokenExpired(token: UserToken): boolean {
return Date.now() > token.refreshExpiresAt;
}
// API base URL - use Lark (International) or Feishu (China)
// Set LARK_API_BASE=https://open.feishu.cn for China version
const API_BASE = process.env.LARK_API_BASE || "https://open.larksuite.com";
// Generate OAuth authorization URL
export function generateAuthUrl(params: {
appId: string;
redirectUri: string;
state?: string;
scope?: string;
}): string {
const { appId, redirectUri, state, scope } = params;
const baseUrl = `${API_BASE}/open-apis/authen/v1/authorize`;
const queryParams = new URLSearchParams({
app_id: appId,
redirect_uri: redirectUri,
response_type: "code",
});
if (state) {
queryParams.set("state", state);
}
if (scope) {
queryParams.set("scope", scope);
}
return `${baseUrl}?${queryParams.toString()}`;
}
// Get app_access_token for API calls
async function getAppAccessToken(appId: string, appSecret: string): Promise<string> {
const response = await fetch(`${API_BASE}/open-apis/auth/v3/app_access_token/internal`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
app_id: appId,
app_secret: appSecret,
}),
});
const data = await response.json();
if (data.code !== 0 || !data.app_access_token) {
throw new Error(`Failed to get app_access_token: ${data.msg} (code: ${data.code})`);
}
return data.app_access_token;
}
// Exchange authorization code for tokens
export async function exchangeCodeForToken(params: {
account: ResolvedLarkAccount;
code: string;
redirectUri?: string;
}): Promise<UserToken> {
const { account, code, redirectUri } = params;
// Step 1: Get app_access_token
const appAccessToken = await getAppAccessToken(account.appId, account.appSecret);
console.log("[lark-oauth] Got app_access_token");
// Step 2: Exchange code for user_access_token using app_access_token
const tokenResponse = await fetch(`${API_BASE}/open-apis/authen/v1/access_token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${appAccessToken}`,
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
}),
});
const tokenData = await tokenResponse.json();
console.log("[lark-oauth] Token response:", JSON.stringify(tokenData, null, 2));
if (tokenData.code !== 0 || !tokenData.data) {
throw new Error(`Failed to exchange code: ${tokenData.msg} (code: ${tokenData.code})`);
}
const data = tokenData.data;
const now = Date.now();
// Get user info using the user access token
const userInfoResponse = await fetch(`${API_BASE}/open-apis/authen/v1/user_info`, {
method: "GET",
headers: {
"Authorization": `Bearer ${data.access_token}`,
},
});
const userInfoData = await userInfoResponse.json();
if (userInfoData.code !== 0 || !userInfoData.data) {
throw new Error(`Failed to get user info: ${userInfoData.msg}`);
}
const openId = userInfoData.data.open_id;
if (!openId) {
throw new Error("No open_id in user info response");
}
const token: UserToken = {
openId,
accessToken: data.access_token!,
refreshToken: data.refresh_token!,
expiresAt: now + (data.expires_in ?? 7200) * 1000,
refreshExpiresAt: now + (data.refresh_expires_in ?? 30 * 24 * 3600) * 1000,
createdAt: now,
updatedAt: now,
};
saveUserToken(token);
return token;
}
// Refresh user access token
export async function refreshUserToken(params: {
account: ResolvedLarkAccount;
token: UserToken;
}): Promise<UserToken> {
const { account, token } = params;
if (isRefreshTokenExpired(token)) {
deleteUserToken(token.openId);
throw new Error("Refresh token expired, user needs to re-authorize");
}
// Get app_access_token first
const appAccessToken = await getAppAccessToken(account.appId, account.appSecret);
// Use app_access_token to refresh user token
const response = await fetch(`${API_BASE}/open-apis/authen/v1/refresh_access_token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${appAccessToken}`,
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: token.refreshToken,
}),
});
const responseData = await response.json();
if (responseData.code !== 0 || !responseData.data) {
throw new Error(`Failed to refresh token: ${responseData.msg} (code: ${responseData.code})`);
}
const data = responseData.data;
const now = Date.now();
const newToken: UserToken = {
...token,
accessToken: data.access_token!,
refreshToken: data.refresh_token!,
expiresAt: now + (data.expires_in ?? 7200) * 1000,
refreshExpiresAt: now + (data.refresh_expires_in ?? 30 * 24 * 3600) * 1000,
updatedAt: now,
};
saveUserToken(newToken);
return newToken;
}
// Get valid user access token (refresh if needed)
export async function getValidUserToken(params: {
account: ResolvedLarkAccount;
openId: string;
}): Promise<UserToken | null> {
const { account, openId } = params;
const token = loadUserToken(openId);
if (!token) {
return null;
}
if (isRefreshTokenExpired(token)) {
deleteUserToken(openId);
return null;
}
if (isTokenExpired(token)) {
try {
return await refreshUserToken({ account, token });
} catch (err) {
console.error(`[lark-oauth] Failed to refresh token for ${openId}:`, err);
deleteUserToken(openId);
return null;
}
}
return token;
}
// Check if user has authorized
export function hasUserAuthorized(openId: string): boolean {
const token = loadUserToken(openId);
if (!token) return false;
if (isRefreshTokenExpired(token)) {
deleteUserToken(openId);
return false;
}
return true;
}
// List all authorized users
export function listAuthorizedUsers(): string[] {
ensureTokenDir();
try {
const files = fs.readdirSync(TOKEN_STORAGE_DIR);
return files
.filter(f => f.endsWith(".json"))
.map(f => {
try {
const data = fs.readFileSync(path.join(TOKEN_STORAGE_DIR, f), "utf-8");
const token = JSON.parse(data) as UserToken;
return token.openId;
} catch {
return null;
}
})
.filter((id): id is string => id !== null);
} catch {
return [];
}
}

View File

@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setLarkRuntime(next: PluginRuntime) {
runtime = next;
}
export function getLarkRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Lark runtime not initialized");
}
return runtime;
}

159
extensions/lark/src/send.ts Normal file
View File

@ -0,0 +1,159 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveLarkAccount } from "./accounts.js";
import { getLarkClient, sendLarkMessage, uploadLarkImage, sendLarkImage } from "./client.js";
import { getLarkRuntime } from "./runtime.js";
export type LarkSendOpts = {
accountId?: string;
mediaUrl?: string;
replyToId?: string;
};
export type LarkSendResult = {
messageId: string;
chatId: string;
};
// Normalize chat ID - handle various input formats
function normalizeChatId(to: string): string {
const trimmed = to.trim();
if (!trimmed) throw new Error("Recipient is required for Lark sends");
// Strip common prefixes
let normalized = trimmed
.replace(/^lark:/i, "")
.replace(/^feishu:/i, "")
.trim();
if (!normalized) throw new Error("Recipient is required for Lark sends");
return normalized;
}
// Send message to Lark
export async function sendMessageLark(
to: string,
text: string,
opts: LarkSendOpts = {},
): Promise<LarkSendResult> {
const cfg = getLarkRuntime().config.loadConfig();
const account = resolveLarkAccount({
cfg,
accountId: opts.accountId,
});
if (!account.appId || !account.appSecret) {
throw new Error(
`Lark credentials missing for account "${account.accountId}" (set channels.lark.appId/appSecret or LARK_APP_ID/LARK_APP_SECRET).`,
);
}
const chatId = normalizeChatId(to);
const client = getLarkClient(account);
// Handle media if provided
if (opts.mediaUrl) {
try {
const media = await getLarkRuntime().media.loadWebMedia(opts.mediaUrl);
const mime = media.contentType?.toLowerCase() ?? "";
// Upload and send image if it's an image type
if (mime.startsWith("image/")) {
const imageKey = await uploadLarkImage({
client,
image: media.buffer,
});
// If there's text, send image first then text
const imageResult = await sendLarkImage({
client,
chatId,
imageKey,
});
if (text?.trim()) {
const textResult = await sendLarkMessage({
client,
chatId,
content: text,
});
return textResult;
}
return imageResult;
}
// For non-image media, include URL in text
const textWithMedia = `${text}\n\n📎 ${opts.mediaUrl}`;
return await sendLarkMessage({
client,
chatId,
content: textWithMedia,
});
} catch (err) {
// Fallback to text with media link on error
console.warn(`Lark media upload failed, falling back to link: ${err}`);
const textWithMedia = `${text}\n\n📎 ${opts.mediaUrl}`;
return await sendLarkMessage({
client,
chatId,
content: textWithMedia,
});
}
}
// Send text message
if (!text?.trim()) {
throw new Error("Message must be non-empty for Lark sends");
}
return await sendLarkMessage({
client,
chatId,
content: text,
});
}
// Probe Lark connection
export async function probeLark(
appId: string,
appSecret: string,
timeoutMs = 5000,
): Promise<{ ok: boolean; bot?: { appName: string; openId: string }; error?: string }> {
try {
const client = getLarkClient({
accountId: "probe",
name: "probe",
enabled: true,
appId,
appSecret,
appIdSource: "config",
appSecretSource: "config",
config: { appId, appSecret },
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await client.bot.botInfo.get({});
if (response.code !== 0) {
return { ok: false, error: response.msg };
}
return {
ok: true,
bot: {
appName: response.data?.bot?.bot_name ?? "unknown",
openId: response.data?.bot?.open_id ?? "",
},
};
} finally {
clearTimeout(timeout);
}
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@ -0,0 +1,129 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
// Lark configuration stored in clawdbot config file
export type LarkChannelConfig = {
enabled?: boolean;
appId?: string;
appSecret?: string;
encryptKey?: string;
verificationToken?: string;
// Bot's open_id for mention filtering (get from Lark developer console or event logs)
botOpenId?: string;
// OAuth configuration for user authorization
oauthRedirectUri?: string; // e.g., "http://localhost:9000/oauth/callback"
oauthScope?: string; // e.g., "docx:document:readonly wiki:wiki:readonly"
requireUserAuth?: boolean; // If true, require user authorization for certain features
// Webhook configuration
webhookPort?: number;
webhookPath?: string;
// DM policy
dmPolicy?: "open" | "pairing" | "allowlist";
allowFrom?: string[];
// Group policy
groupPolicy?: "open" | "allowlist";
groups?: Record<
string,
{
enabled?: boolean;
requireMention?: boolean;
toolPolicy?: string;
}
>;
// Named accounts for multi-bot setups
accounts?: Record<string, LarkAccountConfig>;
};
export type LarkAccountConfig = {
enabled?: boolean;
name?: string;
appId?: string;
appSecret?: string;
encryptKey?: string;
verificationToken?: string;
botOpenId?: string;
// OAuth configuration
oauthRedirectUri?: string;
oauthScope?: string;
requireUserAuth?: boolean;
webhookPort?: number;
webhookPath?: string;
dmPolicy?: "open" | "pairing" | "allowlist";
allowFrom?: string[];
groupPolicy?: "open" | "allowlist";
groups?: Record<
string,
{
enabled?: boolean;
requireMention?: boolean;
toolPolicy?: string;
}
>;
};
export type ResolvedLarkAccount = {
accountId: string;
name?: string;
enabled: boolean;
appId: string;
appSecret: string;
encryptKey?: string;
verificationToken?: string;
appIdSource: "config" | "env" | "none";
appSecretSource: "config" | "env" | "none";
config: LarkAccountConfig;
};
// Lark event types
export type LarkMessageEvent = {
schema: string;
header: {
event_id: string;
event_type: string;
create_time: string;
token: string;
app_id: string;
tenant_key: string;
};
event: {
sender: {
sender_id: {
union_id: string;
user_id: string;
open_id: string;
};
sender_type: string;
tenant_key: string;
};
message: {
message_id: string;
root_id?: string;
parent_id?: string;
create_time: string;
update_time?: string;
chat_id: string;
chat_type: "p2p" | "group";
message_type: string;
content: string;
mentions?: Array<{
key: string;
id: {
union_id: string;
user_id: string;
open_id: string;
};
name: string;
tenant_key: string;
}>;
};
};
};
// Extend ClawdbotConfig to include lark channel
declare module "clawdbot/plugin-sdk" {
interface ClawdbotConfig {
channels?: {
lark?: LarkChannelConfig;
[key: string]: unknown;
};
}
}