Add lark extension for Feishu/Lark integration
This commit is contained in:
parent
9025da2296
commit
aeac1220f2
336
extensions/lark/SKILL.md
Normal file
336
extensions/lark/SKILL.md
Normal 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**: 参数错误,检查参数格式
|
||||
11
extensions/lark/clawdbot.plugin.json
Normal file
11
extensions/lark/clawdbot.plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "lark",
|
||||
"channels": [
|
||||
"lark"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
18
extensions/lark/index.ts
Normal file
18
extensions/lark/index.ts
Normal 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
582
extensions/lark/package-lock.json
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
extensions/lark/package.json
Normal file
17
extensions/lark/package.json
Normal 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
73
extensions/lark/readme.md
Normal 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 ID,App secret,权限配置好;
|
||||
4. 运行clawdbot:
|
||||
让他去支持:Lark-MCP:(https://github.com/larksuite/lark-openapi-mcp)
|
||||
|
||||
141
extensions/lark/src/accounts.ts
Normal file
141
extensions/lark/src/accounts.ts
Normal 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);
|
||||
}
|
||||
343
extensions/lark/src/channel.ts
Normal file
343
extensions/lark/src/channel.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
170
extensions/lark/src/client.ts
Normal file
170
extensions/lark/src/client.ts
Normal 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 ?? "",
|
||||
};
|
||||
}
|
||||
678
extensions/lark/src/monitor.ts
Normal file
678
extensions/lark/src/monitor.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
312
extensions/lark/src/oauth.ts
Normal file
312
extensions/lark/src/oauth.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
14
extensions/lark/src/runtime.ts
Normal file
14
extensions/lark/src/runtime.ts
Normal 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
159
extensions/lark/src/send.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
129
extensions/lark/src/types.ts
Normal file
129
extensions/lark/src/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user