From aeac1220f20629f93ccea3269261f05d6f171861 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 30 Jan 2026 15:01:27 +0800 Subject: [PATCH] Add lark extension for Feishu/Lark integration --- extensions/lark/SKILL.md | 336 +++++++++++++ extensions/lark/clawdbot.plugin.json | 11 + extensions/lark/index.ts | 18 + extensions/lark/package-lock.json | 582 +++++++++++++++++++++++ extensions/lark/package.json | 17 + extensions/lark/readme.md | 73 +++ extensions/lark/src/accounts.ts | 141 ++++++ extensions/lark/src/channel.ts | 343 ++++++++++++++ extensions/lark/src/client.ts | 170 +++++++ extensions/lark/src/monitor.ts | 678 +++++++++++++++++++++++++++ extensions/lark/src/oauth.ts | 312 ++++++++++++ extensions/lark/src/runtime.ts | 14 + extensions/lark/src/send.ts | 159 +++++++ extensions/lark/src/types.ts | 129 +++++ 14 files changed, 2983 insertions(+) create mode 100644 extensions/lark/SKILL.md create mode 100644 extensions/lark/clawdbot.plugin.json create mode 100644 extensions/lark/index.ts create mode 100644 extensions/lark/package-lock.json create mode 100644 extensions/lark/package.json create mode 100644 extensions/lark/readme.md create mode 100644 extensions/lark/src/accounts.ts create mode 100644 extensions/lark/src/channel.ts create mode 100644 extensions/lark/src/client.ts create mode 100644 extensions/lark/src/monitor.ts create mode 100644 extensions/lark/src/oauth.ts create mode 100644 extensions/lark/src/runtime.ts create mode 100644 extensions/lark/src/send.ts create mode 100644 extensions/lark/src/types.ts diff --git a/extensions/lark/SKILL.md b/extensions/lark/SKILL.md new file mode 100644 index 000000000..0f30d087f --- /dev/null +++ b/extensions/lark/SKILL.md @@ -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 -s --domain https://open.larksuite.com +``` + +登录后会保存 token,后续调用会自动使用。 + +### 2. MCP 工具调用方式 + +使用 mcporter 调用 lark-mcp 工具: + +```bash +# 查看所有可用工具 +mcporter list lark-mcp --schema + +# 调用工具 +mcporter call lark-mcp. param1=value1 param2=value2 --output json +``` + +--- + +## 文档操作 (Docx) + +### 获取文档内容 + +```bash +# 获取文档元信息 +mcporter call lark-mcp.docx.v1.document.get document_id= --output json + +# 获取文档纯文本内容 +mcporter call lark-mcp.docx.v1.document.raw_content document_id= --output json + +# 获取文档块列表 +mcporter call lark-mcp.docx.v1.document_block.list document_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= --output json + +# 列出所有数据表 +mcporter call lark-mcp.bitable.v1.app_table.list app_token= --output json +``` + +### 读取表格记录 + +```bash +# 获取记录列表 +mcporter call lark-mcp.bitable.v1.app_table_record.list \ + app_token= \ + table_id= \ + --output json + +# 获取单条记录 +mcporter call lark-mcp.bitable.v1.app_table_record.get \ + app_token= \ + table_id= \ + record_id= \ + --output json + +# 搜索记录(带筛选条件) +mcporter call lark-mcp.bitable.v1.app_table_record.search \ + app_token= \ + 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= \ + table_id= \ + --args '{"fields":{"标题":"新任务","状态":"待处理","负责人":[{"id":"ou_xxx"}]}}' \ + --output json + +# 更新记录 +mcporter call lark-mcp.bitable.v1.app_table_record.update \ + app_token= \ + table_id= \ + record_id= \ + --args '{"fields":{"状态":"已完成"}}' \ + --output json + +# 删除记录 +mcporter call lark-mcp.bitable.v1.app_table_record.delete \ + app_token= \ + table_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= --output json + +# 列出任务 +mcporter call lark-mcp.task.v2.task.list --output json +``` + +### 更新任务 + +```bash +mcporter call lark-mcp.task.v2.task.patch \ + task_guid= \ + --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= \ + --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= \ + event_id= \ + --output json + +# 列出日程 +mcporter call lark-mcp.calendar.v4.calendar_event.list \ + calendar_id= \ + start_time= \ + end_time= \ + --output json +``` + +### 使用预设日历工具 + +```bash +# 使用默认日历预设(更简单) +mcporter call lark-mcp.preset.calendar.default. ... +``` + +--- + +## IM 消息操作 + +### 发送消息 + +```bash +# 发送文本消息到群聊 +mcporter call lark-mcp.im.v1.message.create \ + receive_id_type=chat_id \ + --args '{ + "receive_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": "", + "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= \ + --output json + +# 获取单条消息 +mcporter call lark-mcp.im.v1.message.get 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= --output json + +# 获取群成员 +mcporter call lark-mcp.im.v1.chat_members.get 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= --output json + +# 获取节点信息 +mcporter call lark-mcp.wiki.v2.space_node.get token= --output json +``` + +--- + +## 用户与通讯录 + +### 获取用户信息 + +```bash +# 通过 open_id 获取用户 +mcporter call lark-mcp.contact.v3.user.get \ + user_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**: 参数错误,检查参数格式 diff --git a/extensions/lark/clawdbot.plugin.json b/extensions/lark/clawdbot.plugin.json new file mode 100644 index 000000000..ea3f156a5 --- /dev/null +++ b/extensions/lark/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "lark", + "channels": [ + "lark" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/lark/index.ts b/extensions/lark/index.ts new file mode 100644 index 000000000..916f6f3a2 --- /dev/null +++ b/extensions/lark/index.ts @@ -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; diff --git a/extensions/lark/package-lock.json b/extensions/lark/package-lock.json new file mode 100644 index 000000000..e4f45a577 --- /dev/null +++ b/extensions/lark/package-lock.json @@ -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 + } + } + } + } +} diff --git a/extensions/lark/package.json b/extensions/lark/package.json new file mode 100644 index 000000000..9ffa3f659 --- /dev/null +++ b/extensions/lark/package.json @@ -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" + } +} diff --git a/extensions/lark/readme.md b/extensions/lark/readme.md new file mode 100644 index 000000000..54e4aefac --- /dev/null +++ b/extensions/lark/readme.md @@ -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 ID,App secret,权限配置好; +4. 运行clawdbot: + 让他去支持:Lark-MCP:(https://github.com/larksuite/lark-openapi-mcp) + diff --git a/extensions/lark/src/accounts.ts b/extensions/lark/src/accounts.ts new file mode 100644 index 000000000..6f691ec93 --- /dev/null +++ b/extensions/lark/src/accounts.ts @@ -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(); + + // 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); +} diff --git a/extensions/lark/src/channel.ts b/extensions/lark/src/channel.ts new file mode 100644 index 000000000..28c0b1c8c --- /dev/null +++ b/extensions/lark/src/channel.ts @@ -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 = { + 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 `, + 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: "", + }, + }, + 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, + }); + }, + }, +}; diff --git a/extensions/lark/src/client.ts b/extensions/lark/src/client.ts new file mode 100644 index 000000000..2552c8ade --- /dev/null +++ b/extensions/lark/src/client.ts @@ -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(); + +// 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 { + 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 ?? "", + }; +} diff --git a/extensions/lark/src/monitor.ts b/extensions/lark/src/monitor.ts new file mode 100644 index 000000000..671a5481d --- /dev/null +++ b/extensions/lark/src/monitor.ts @@ -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(); + +// 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(); + +// 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("

授权失败

缺少授权码

"); + 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(` + + 授权成功 + +

✅ 授权成功!

+

你的 Lark 账号已成功授权给机器人。

+

正在处理你之前的消息,请回到 Lark 查看回复。

+ + + `); + } else { + log(`[lark:${account.accountId}] No pending message found for ${token.openId}`); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(` + + 授权成功 + +

✅ 授权成功!

+

你的 Lark 账号已成功授权给机器人。

+

现在可以关闭此页面,回到 Lark 继续使用。

+ + + `); + } + } catch (err) { + error(`[lark:${account.accountId}] OAuth callback error: ${err}`); + res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" }); + res.end(` + + 授权失败 + +

❌ 授权失败

+

错误: ${err}

+

请重试或联系管理员。

+ + + `); + } + 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((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(); + }); + }); +} diff --git a/extensions/lark/src/oauth.ts b/extensions/lark/src/oauth.ts new file mode 100644 index 000000000..9748f1c92 --- /dev/null +++ b/extensions/lark/src/oauth.ts @@ -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 { + 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 { + 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 { + 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 { + 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 []; + } +} diff --git a/extensions/lark/src/runtime.ts b/extensions/lark/src/runtime.ts new file mode 100644 index 000000000..64fb545b5 --- /dev/null +++ b/extensions/lark/src/runtime.ts @@ -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; +} diff --git a/extensions/lark/src/send.ts b/extensions/lark/src/send.ts new file mode 100644 index 000000000..cc723f9dd --- /dev/null +++ b/extensions/lark/src/send.ts @@ -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 { + 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), + }; + } +} diff --git a/extensions/lark/src/types.ts b/extensions/lark/src/types.ts new file mode 100644 index 000000000..5f2e1a867 --- /dev/null +++ b/extensions/lark/src/types.ts @@ -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; +}; + +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; + }; + } +}