OpenClaw 完整备份 - 2026-03-21
This commit is contained in:
commit
8dd73a1d62
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
# OpenClaw 备份忽略文件
|
||||
# ============================================
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 缓存文件
|
||||
cache/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# 浏览器缓存
|
||||
browser/cache/
|
||||
browser/extensions/
|
||||
|
||||
# 媒体文件(大文件,单独备份)
|
||||
media/*
|
||||
!media/.gitkeep
|
||||
|
||||
# 向量数据库(太大,可以用脚本单独备份)
|
||||
# vector_memory/data/
|
||||
# openclaw-memory-vector/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 敏感配置(根据需要)
|
||||
# 如有_TOKEN、_SECRET 等敏感信息,可以在此忽略
|
||||
# *token*
|
||||
# *secret*
|
||||
# *password*
|
||||
|
||||
# 备份文件
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# 临时测试文件
|
||||
test_*
|
||||
*_test.py
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
node_modules/
|
||||
@ -0,0 +1 @@
|
||||
{}
|
||||
34
openclaw/agents/main/agent/auth-profiles.json
Normal file
34
openclaw/agents/main/agent/auth-profiles.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
"qwen-portal:default": {
|
||||
"type": "oauth",
|
||||
"provider": "qwen-portal",
|
||||
"access": "ZeEtVmIMLvnGaKxKYylZs3C8vG_PTpV-iZib9KBaCk5CZ4qQMeiTuYmgkAhYJ-3kbcZqAIox4L45Yghgr7OCeQ",
|
||||
"refresh": "nFONPJGOF6LXP6L7sqzwhjGn_OZOy-TipHpT7cRGJQn0ySvDXhTw20uZsX1y4S9gugdnHxbCv8uVUFWsScJ1Fg",
|
||||
"expires": 1773791169398
|
||||
},
|
||||
"minimax:cn": {
|
||||
"type": "api_key",
|
||||
"provider": "minimax",
|
||||
"key": "sk-cp-oEoKXkO8k3TRWqhVMmOY7etRnvmflwpou5t6FHnthSVuGE1sJ5TdGM9MfZFtVwAbPhFbE6IDNGIf65WuPu7Ee2Vda_4-lLpFTkWKWcueMpL9qpU0Cy8jiWM"
|
||||
},
|
||||
"minimax-portal:default": {
|
||||
"type": "oauth",
|
||||
"provider": "minimax-portal",
|
||||
"access": "sk-cp-oEoKXkO8k3TRWqhVMmOY7etRnvmflwpou5t6FHnthSVuGE1sJ5TdGM9MfZFtVwAbPhFbE6IDNGIf65WuPu7Ee2Vda_4-lLpFTkWKWcueMpL9qpU0Cy8jiWM",
|
||||
"refresh": "vN2T7RKkFGEE5mgyclnRGD0gBWxmgzd5hSXirqyH7NA=",
|
||||
"expires": 1805552404601
|
||||
}
|
||||
},
|
||||
"usageStats": {
|
||||
"qwen-portal:default": {
|
||||
"errorCount": 0,
|
||||
"lastUsed": 1773769633077
|
||||
},
|
||||
"minimax-portal:default": {
|
||||
"errorCount": 0,
|
||||
"lastUsed": 1774078067690
|
||||
}
|
||||
}
|
||||
}
|
||||
323
openclaw/agents/main/agent/models.json
Normal file
323
openclaw/agents/main/agent/models.json
Normal file
@ -0,0 +1,323 @@
|
||||
{
|
||||
"providers": {
|
||||
"qwen-portal": {
|
||||
"baseUrl": "https://portal.qwen.ai/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "coder-model",
|
||||
"name": "Qwen Coder",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
},
|
||||
{
|
||||
"id": "vision-model",
|
||||
"name": "Qwen Vision",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
],
|
||||
"apiKey": "qwen-oauth"
|
||||
},
|
||||
"bailian": {
|
||||
"baseUrl": "https://coding.dashscope.aliyuncs.com/v1",
|
||||
"apiKey": "sk-sp-4e1be16d23c64d65b2a226c5b0eecfb6",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "qwen3.5-plus",
|
||||
"name": "qwen3.5-plus",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "qwen3-max-2026-01-23",
|
||||
"name": "qwen3-max-2026-01-23",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 65536,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-next",
|
||||
"name": "qwen3-coder-next",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 65536,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "qwen3-coder-plus",
|
||||
"name": "qwen3-coder-plus",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 65536,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax-M2.5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 196608,
|
||||
"maxTokens": 32768,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "glm-5",
|
||||
"name": "glm-5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 202752,
|
||||
"maxTokens": 16384,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "glm-4.7",
|
||||
"name": "glm-4.7",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 202752,
|
||||
"maxTokens": 16384,
|
||||
"api": "openai-completions"
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "kimi-k2.5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 32768,
|
||||
"api": "openai-completions"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minimax": {
|
||||
"baseUrl": "https://api.minimaxi.com/anthropic",
|
||||
"api": "anthropic-messages",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax M2.5",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.12
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192,
|
||||
"api": "anthropic-messages"
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-VL-01",
|
||||
"name": "MiniMax VL 01",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.12
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5-highspeed",
|
||||
"name": "MiniMax M2.5 Highspeed",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.12
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
],
|
||||
"apiKey": "sk-cp-oEoKXkO8k3TRWqhVMmOY7etRnvmflwpou5t6FHnthSVuGE1sJ5TdGM9MfZFtVwAbPhFbE6IDNGIf65WuPu7Ee2Vda_4-lLpFTkWKWcueMpL9qpU0Cy8jiWM"
|
||||
},
|
||||
"minimax-portal": {
|
||||
"baseUrl": "https://api.minimaxi.com/anthropic",
|
||||
"api": "anthropic-messages",
|
||||
"authHeader": true,
|
||||
"models": [
|
||||
{
|
||||
"id": "MiniMax-M2.5",
|
||||
"name": "MiniMax M2.5",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192,
|
||||
"api": "anthropic-messages"
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5-highspeed",
|
||||
"name": "MiniMax M2.5 Highspeed",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192,
|
||||
"api": "anthropic-messages"
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-M2.5-Lightning",
|
||||
"name": "MiniMax M2.5 Lightning",
|
||||
"reasoning": true,
|
||||
"input": [
|
||||
"text"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192,
|
||||
"api": "anthropic-messages"
|
||||
},
|
||||
{
|
||||
"id": "MiniMax-VL-01",
|
||||
"name": "MiniMax VL 01",
|
||||
"reasoning": false,
|
||||
"input": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"cost": {
|
||||
"input": 0.3,
|
||||
"output": 1.2,
|
||||
"cacheRead": 0.03,
|
||||
"cacheWrite": 0.12
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 8192
|
||||
}
|
||||
],
|
||||
"apiKey": "minimax-oauth"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,3 @@
|
||||
{"type":"session","version":3,"id":"1a15b075-1526-4133-b738-64a2a695d3a9","timestamp":"2026-03-18T05:35:15.397Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"message","id":"87053e14","parentId":null,"timestamp":"2026-03-18T05:35:15.398Z","message":{"role":"assistant","content":[{"type":"text","text":"🦐 皮皮虾测试消息!欢哥让我来群里打个招呼~"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773812115398}}
|
||||
{"type":"message","id":"a38f8096","parentId":"87053e14","timestamp":"2026-03-18T05:47:57.969Z","message":{"role":"assistant","content":[{"type":"text","text":"🦐 皮皮虾又来了!这次应该能收到 @ 消息啦~"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773812877969}}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,4 @@
|
||||
{"type":"session","version":3,"id":"329e62c1-873a-40bb-aad1-9828cbba3dc5","timestamp":"2026-03-21T04:57:27.110Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"message","id":"1dd99ff7","parentId":null,"timestamp":"2026-03-21T04:57:27.110Z","message":{"role":"assistant","content":[{"type":"text","text":"@🦞生鲜龙虾 打开你本地的 Todesk"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774069047110}}
|
||||
{"type":"message","id":"5aad87e7","parentId":"1dd99ff7","timestamp":"2026-03-21T05:05:33.768Z","message":{"role":"assistant","content":[{"type":"text","text":"@🦞生鲜龙虾 打开你本地的 Todesk"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774069533768}}
|
||||
{"type":"message","id":"4e6145bf","parentId":"5aad87e7","timestamp":"2026-03-21T05:07:47.125Z","message":{"role":"assistant","content":[{"type":"text","text":"<at id=ou_6704701935853844aa0bb466e1cb45bf></at> 打开你本地的 Todesk"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774069667125}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,22 @@
|
||||
{"type":"session","version":3,"id":"5d51f5da-f2a3-455f-bab8-e4fec90147fe","timestamp":"2026-03-18T20:27:44.845Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"fe18ba03","parentId":null,"timestamp":"2026-03-18T20:27:44.846Z","provider":"bailian","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"cad7cba5","parentId":"fe18ba03","timestamp":"2026-03-18T20:27:44.846Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1773865664846,"provider":"bailian","modelApi":"openai-completions","modelId":"MiniMax-M2.5"},"id":"5d52cd55","parentId":"cad7cba5","timestamp":"2026-03-18T20:27:44.847Z"}
|
||||
{"type":"message","id":"4210b364","parentId":"5d52cd55","timestamp":"2026-03-18T20:27:44.862Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Thursday, March 19th, 2026 — 04:27 (Asia/Shanghai) / 2026-03-18 20:27 UTC"}],"timestamp":1773865664859}}
|
||||
{"type":"message","id":"527712e7","parentId":"4210b364","timestamp":"2026-03-18T20:27:55.813Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"This is a heartbeat poll. I need to check if there's anything that needs attention.\n\nLooking at HEARTBEAT.md from the project context, it says:\n\"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\"\n\nThe file is loaded in the project context. It contains the Ralph Wiggum mode instructions and core principles.\n\nNow I need to determine if anything needs attention:\n\n1. Time: 04:27 AM (Asia/Shanghai) - this is late night/early morning (23:00-08:00)\n2. According to AGENTS.md: \"When to stay quiet (HEARTBEAT_OK): Late night (23:00-08:00) unless urgent\"\n\nThe heartbeat prompt itself says: \"Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\"\n\nLooking at the HEARTBEAT.md content, there's no specific checklist or tasks to perform. It's a general framework document.\n\nGiven:\n- It's 4:27 AM (late night hours)\n- No specific urgent tasks mentioned in HEARTBEAT.md\n- No old tasks to repeat from prior chats\n\nI should reply HEARTBEAT_OK.","thinkingSignature":"reasoning_content"},{"type":"text","text":"HEARTBEAT_OK"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773865664861}}
|
||||
{"type":"message","id":"d70e9115","parentId":"527712e7","timestamp":"2026-03-19T03:57:44.621Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Thursday, March 19th, 2026 — 11:57 (Asia/Shanghai) / 2026-03-19 03:57 UTC"}],"timestamp":1773892664618}}
|
||||
{"type":"message","id":"49c9baab","parentId":"d70e9115","timestamp":"2026-03-19T03:57:57.576Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"This is a heartbeat poll. Let me check what's in HEARTBEAT.md (which is already in my context as Project Context).\n\nFrom HEARTBEAT.md, I see the Ralph Wiggum mode principles and the heartbeat section which says:\n\n\"Things to check (rotate through these, 2-4 times per day):\n- **Emails** - Any urgent unread messages?\n- **Calendar** - Upcoming events in next 24-48h?\n- **Mentions** - Twitter/social notifications?\n- **Weather** - Relevant if your human might go out?\n\nTrack your checks in `memory/heartbeat-state.json`\"\n\nAnd: \"When to stay quiet (HEARTBEAT_OK): Late night (23:00-08:00) unless urgent\"\n\nCurrent time is 11:57 Asia/Shanghai, so it's daytime. Let me check if there's anything that needs attention.\n\nBut wait - the instruction says \"Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\"\n\nSo I should just check if there's anything specific that needs attention. The HEARTBEAT.md in context doesn't list any specific pending tasks - it just gives general guidelines. \n\nThe date is Thursday, March 19th, 2026. Let me check if there's a memory file for today that might have pending tasks.\n\nActually, I should check the heartbeat-state.json to see what was checked last, and also check today's memory file for any pending tasks.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_3b7b4e5588c446459f726190","name":"read","arguments":{"path":"/home/huan/.openclaw/workspace/memory/heartbeat-state.json"}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773892664620}}
|
||||
{"type":"message","id":"7db1befe","parentId":"49c9baab","timestamp":"2026-03-19T03:57:57.612Z","message":{"role":"toolResult","toolCallId":"call_3b7b4e5588c446459f726190","toolName":"read","content":[{"type":"text","text":"{\n \"lastChecks\": {\n \"stock\": 1742308740,\n \"weather\": 1742058300,\n \"calendar\": null,\n \"email\": null\n },\n \"lastHeartbeat\": \"2026-03-19T03:57:00+08:00\",\n \"stockAlert\": {\n \"symbol\": \"000630\",\n \"price\": 6.50,\n \"threshold\": 6.80,\n \"alertTime\": \"2026-03-18T15:17:00+08:00\",\n \"status\": \"active\"\n }\n}\n"}],"isError":false,"timestamp":1773892677601}}
|
||||
{"type":"message","id":"f72aca6a","parentId":"7db1befe","timestamp":"2026-03-19T03:58:08.346Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Looking at the heartbeat state:\n- Stock check was done at timestamp 1742308740 (which is 2026-03-18 20:39 UTC - so about 15 hours ago)\n- Weather was checked at timestamp 1742058300 (2026-03-18 03:58 UTC - over 31 hours ago)\n- Calendar: never checked\n- Email: never checked\n\nThere's an active stock alert for 铜陵有色 (000630) - price dropped to 6.50, threshold is 6.80. This is below the alert threshold!\n\nWait - the current time is 2026-03-19 11:57 (Asia/Shanghai). This is during market hours (9:30-11:30). The last stock check was at 20:39 UTC yesterday which is 04:39 today - that's about 7 hours ago, before market opened.\n\nBut according to my rules in TOOLS.md about stock monitoring:\n- Must query real-time stock data\n- Don't rely on memory or cached data\n\nThis is an important alert - the stock price dropped below the threshold (6.50 < 6.80). This needs attention! I should check the current stock price to verify and potentially alert the user.\n\nActually wait - the stock alert is still marked as \"active\" from yesterday. Let me check if this is still valid by getting the current price.","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ff0c2429dea34635aa15adb2","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\" --connect-timeout 3 --max-time 5 | iconv -f GBK -t UTF-8","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773892677612}}
|
||||
{"type":"message","id":"4c330364","parentId":"f72aca6a","timestamp":"2026-03-19T03:58:08.518Z","message":{"role":"toolResult","toolCallId":"call_ff0c2429dea34635aa15adb2","toolName":"exec","content":[{"type":"text","text":"v_sz000630=\"51~铜陵有色~000630~6.18~6.50~6.25~2482865~1043043~1439822~6.17~506~6.16~4943~6.15~20531~6.14~12115~6.13~21382~6.18~4588~6.19~3674~6.20~10106~6.21~12131~6.22~20503~~20260319115718~-0.32~-4.92~6.34~6.13~6.18/2482865/1543941261~2482865~154394~2.23~44.81~~6.34~6.13~3.23~688.64~828.71~2.34~7.15~5.85~1.50~8475~6.22~35.10~29.51~~~2.08~154394.1261~0.0000~0~ ~GP-A~2.83~-13.57~2.35~5.12~2.74~9.19~2.66~-19.32~-13.93~14.66~11143011450~13409471510~7.67~99.35~11143011450~~~83.93~0.16~~CNY~0~~6.10~50169~\";"}],"details":{"status":"completed","exitCode":0,"durationMs":144,"aggregated":"v_sz000630=\"51~铜陵有色~000630~6.18~6.50~6.25~2482865~1043043~1439822~6.17~506~6.16~4943~6.15~20531~6.14~12115~6.13~21382~6.18~4588~6.19~3674~6.20~10106~6.21~12131~6.22~20503~~20260319115718~-0.32~-4.92~6.34~6.13~6.18/2482865/1543941261~2482865~154394~2.23~44.81~~6.34~6.13~3.23~688.64~828.71~2.34~7.15~5.85~1.50~8475~6.22~35.10~29.51~~~2.08~154394.1261~0.0000~0~ ~GP-A~2.83~-13.57~2.35~5.12~2.74~9.19~2.66~-19.32~-13.93~14.66~11143011450~13409471510~7.67~99.35~11143011450~~~83.93~0.16~~CNY~0~~6.10~50169~\";","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773892688506}}
|
||||
{"type":"message","id":"e0819f6f","parentId":"4c330364","timestamp":"2026-03-19T03:58:20.355Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Wow, the stock price is now 6.18, which is way below the threshold of 6.80! This is a significant drop. The user has 1400 shares at cost price 7.90.\n\nCurrent situation:\n- Current price: 6.18\n- Threshold: 6.80\n- Cost: 7.90\n- Loss per share: 7.90 - 6.18 = 1.72\n- Total loss: 1.72 * 1400 = 2408元 (about 21.8% loss)\n\nThis is a serious alert! The stock has dropped significantly below the预警线. I need to notify the user about this.\n\nWait, but let me also check the other stocks in the monitoring list to give a complete picture.\n","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_9fa4811c3c4a404d80d0f902","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz002195,sh688313,sh600096\" --connect-timeout 3 --max-time 5 | iconv -f GBK -t UTF-8","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773892688519}}
|
||||
{"type":"message","id":"a7b2ec0c","parentId":"e0819f6f","timestamp":"2026-03-19T03:58:20.520Z","message":{"role":"toolResult","toolCallId":"call_9fa4811c3c4a404d80d0f902","toolName":"exec","content":[{"type":"text","text":"v_sz002195=\"51~岩山科技~002195~9.87~10.06~9.90~1281087~521814~759272~9.86~262~9.85~538~9.84~771~9.83~2867~9.82~7379~9.87~34~9.88~1017~9.89~1145~9.90~2957~9.91~1637~~20260319115733~-0.19~-1.89~9.94~9.79~9.87/1281087/1262029521~1281087~126203~2.29~324.13~~9.94~9.79~1.49~552.84~559.68~5.61~11.07~9.05~0.63~5027~9.85~229.93~1724.13~~~1.70~126202.9521~0.0000~0~ ~GP-A~39.21~-4.17~0.00~1.73~0.46~13.19~4.15~-7.67~-1.10~47.75~5601261712~5670554696~27.02~87.29~5601261712~~~44.09~0.10~~CNY~0~~9.80~44550~\";\nv_sh688313=\"1~仕佳光子~688313~82.17~77.86~75.88~19193224~11640024~7553200~82.17~15~82.16~128~82.15~24~82.14~5~81.96~4~82.18~123~82.19~107~82.20~150~82.21~129~82.22~369~~20260319115805~4.31~5.54~82.80~75.66~82.17/19193224/1523073435~19193224~152307~4.25~108.71~~82.80~75.66~9.17~371.40~371.40~24.46~93.43~62.29~1.71~-702~79.35~108.71~108.71~~~2.70~152307.3435~0.0000~0~A R~GP-A-KCB~-7.45~-1.63~0.07~22.25~12.89~111.11~12.74~-2.52~-2.03~-15.78~451986328~451986328~-66.60~112.88~451986328~~~349.24~0.26~~CNY~0~___D__F__NY~82.24~-157~100\";\nv_sh600096=\"1~云天化~600096~37.44~38.72~38.75~382190~155743~226448~37.43~96~37.42~168~37.41~93~37.40~43~37.39~127~37.44~102~37.45~18~37.46~10~37.47~33~37.50~9~~20260319115811~-1.28~-3.31~39.14~37.18~37.44/382190/1447285279~382190~144729~2.10~12.11~~39.14~37.18~5.06~682.53~682.53~2.82~42.59~34.85~0.76~355~37.87~10.82~12.80~~~1.42~144728.5279~0.0000~0~ ~GP-A~12.06~-11.38~4.27~23.28~12.13~45.62~19.21~-8.01~2.52~25.98~1822990731~1822990731~50.79~69.41~1822990731~~~80.79~0.16~~CNY~0~___D__F__N~37.35~747~\";"}],"details":{"status":"completed","exitCode":0,"durationMs":140,"aggregated":"v_sz002195=\"51~岩山科技~002195~9.87~10.06~9.90~1281087~521814~759272~9.86~262~9.85~538~9.84~771~9.83~2867~9.82~7379~9.87~34~9.88~1017~9.89~1145~9.90~2957~9.91~1637~~20260319115733~-0.19~-1.89~9.94~9.79~9.87/1281087/1262029521~1281087~126203~2.29~324.13~~9.94~9.79~1.49~552.84~559.68~5.61~11.07~9.05~0.63~5027~9.85~229.93~1724.13~~~1.70~126202.9521~0.0000~0~ ~GP-A~39.21~-4.17~0.00~1.73~0.46~13.19~4.15~-7.67~-1.10~47.75~5601261712~5670554696~27.02~87.29~5601261712~~~44.09~0.10~~CNY~0~~9.80~44550~\";\nv_sh688313=\"1~仕佳光子~688313~82.17~77.86~75.88~19193224~11640024~7553200~82.17~15~82.16~128~82.15~24~82.14~5~81.96~4~82.18~123~82.19~107~82.20~150~82.21~129~82.22~369~~20260319115805~4.31~5.54~82.80~75.66~82.17/19193224/1523073435~19193224~152307~4.25~108.71~~82.80~75.66~9.17~371.40~371.40~24.46~93.43~62.29~1.71~-702~79.35~108.71~108.71~~~2.70~152307.3435~0.0000~0~A R~GP-A-KCB~-7.45~-1.63~0.07~22.25~12.89~111.11~12.74~-2.52~-2.03~-15.78~451986328~451986328~-66.60~112.88~451986328~~~349.24~0.26~~CNY~0~___D__F__NY~82.24~-157~100\";\nv_sh600096=\"1~云天化~600096~37.44~38.72~38.75~382190~155743~226448~37.43~96~37.42~168~37.41~93~37.40~43~37.39~127~37.44~102~37.45~18~37.46~10~37.47~33~37.50~9~~20260319115811~-1.28~-3.31~39.14~37.18~37.44/382190/1447285279~382190~144729~2.10~12.11~~39.14~37.18~5.06~682.53~682.53~2.82~42.59~34.85~0.76~355~37.87~10.82~12.80~~~1.42~144728.5279~0.0000~0~ ~GP-A~12.06~-11.38~4.27~23.28~12.13~45.62~19.21~-8.01~2.52~25.98~1822990731~1822990731~50.79~69.41~1822990731~~~80.79~0.16~~CNY~0~___D__F__N~37.35~747~\";","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773892700509}}
|
||||
{"type":"message","id":"11eead66","parentId":"a7b2ec0c","timestamp":"2026-03-19T03:58:39.324Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me parse this data:\n\n1. **岩山科技 (002195)**: \n - Current: 9.87\n - Yesterday close: 10.06\n - Change: -1.89%\n - Cost: 10.68, 200 shares\n - This is below the cost! Loss: (10.68 - 9.87) * 200 = 162元 (-15%)\n\n2. **仕佳光子 (688313)**:\n - Current: 82.17\n - Yesterday close: 77.86\n - Change: +5.54%\n - Cost: 15.00 (but this is wrong - 688313 is now 82.17, so cost was probably much lower)\n - Actually wait - this seems wrong. 仕佳光子 was around 15, now it's 82? That's a huge jump. Let me re-check - oh wait, 688313 was probably a different price before. The user set cost at 15.00 but that's clearly wrong now. The stock has surged!\n\n3. **云天化 (600096)**:\n - Current: 37.44\n - Yesterday close: 38.72\n - Change: -3.31%\n - Cost: 42.00\n - Still below cost, but not at the ±5% threshold\n\nNow the key alert:\n- **铜陵有色 (000630)**: \n - Current: 6.18\n - Yesterday close: 6.50\n - Change: -4.92%\n - Cost: 7.90, 1400 shares\n - **Alert threshold: 6.80** - NOW TRIGGERED! Price is way below threshold.\n - Loss: (7.90 - 6.18) * 1400 = 2408元 (-21.8%)\n\nThis is significant! The stock dropped to 6.18 which is below the 6.80预警线. \n\nI need to flag this to the user immediately since it's dropped substantially past thealert point.","thinkingSignature":"reasoning_content"},{"type":"text","text":"⚠️ **股票预警触发!**\n\n**铜陵有色 (000630)** 盘中跌破预警线:\n- **现价**: ¥6.18 (已大跌 -4.92%)\n- **预警线**: ¥6.80\n- **成本**: ¥7.90 (1400 股)\n- **亏损**: 约 ¥2,408 (↓21.8%)\n\n其他持仓监控:\n| 股票 | 现价 | 昨收 | 涨跌幅 | 成本 | 状态 |\n|------|------|------|--------|------|------|\n| 岩山科技 | ¥9.87 | ¥10.06 | -1.89% | ¥10.68 | 套牢中 |\n| 仕佳光子 | ¥82.17 | ¥77.86 | +5.54% | ¥15.00 | ⚠️ 成本价可能有误 |\n| 云天化 | ¥37.44 | ¥38.72 | -3.31% | ¥42.00 | 套牢中 |\n\n**铜陵有色** 已经跌破预警线快 10% 了,建议关注是否需要止损!🛑"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773892700522}}
|
||||
{"type":"message","id":"51349a46","parentId":"11eead66","timestamp":"2026-03-19T05:05:00.922Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Thursday, March 19th, 2026 — 13:05 (Asia/Shanghai) / 2026-03-19 05:05 UTC"}],"timestamp":1773896700907}}
|
||||
{"type":"message","id":"e98e48c7","parentId":"51349a46","timestamp":"2026-03-19T05:05:13.778Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这是心跳检查。当前时间是 2026-03-19 13:05 (Asia/Shanghai),即 05:05 UTC。\n\n让我按照 HEARTBEAT.md 的要求来检查:\n\n1. 检查是否有需要关注的事项\n2. 根据配置,心跳应该定期检查股票、天气、日历、邮件等\n\n现在我需要检查:\n- 股票监控:铜陵有色已经跌破预警线,需要继续关注\n- 天气:检查是否需要\n- 日历:检查即将到来的事件\n- 邮件:检查紧急未读邮件\n\n让我先快速检查一下是否在交易时间内,然后检查股票。\n\n当前时间是 13:05,在交易时间内 (9:30-11:30, 13:00-15:00),应该检查股票。\n\n我先检查一下之前记录的 heartbeat-state,然后执行股票检查。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_257cee92cc024246888918a4","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630,sz002195,sh688313,sh600096\" --connect-timeout 3 --max-time 5 | iconv -f GBK -t UTF-8","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773896700917}}
|
||||
{"type":"message","id":"38b2b2eb","parentId":"e98e48c7","timestamp":"2026-03-19T05:05:14.085Z","message":{"role":"toolResult","toolCallId":"call_257cee92cc024246888918a4","toolName":"exec","content":[{"type":"text","text":"v_sz000630=\"51~铜陵有色~000630~6.14~6.50~6.25~2565243~1069707~1494837~6.13~21112~6.12~12702~6.11~16261~6.10~50311~6.09~4926~6.14~4398~6.15~14230~6.16~22885~6.17~1753~6.18~4520~~20260319130512~-0.36~-5.54~6.34~6.13~6.14/2565243/1594624696~2565243~159462~2.30~44.52~~6.34~6.13~3.23~684.18~823.34~2.32~7.15~5.85~1.48~57526~6.22~34.88~29.32~~~2.08~159462.4696~0.0000~0~ ~GP-A~2.16~-14.13~2.36~5.12~2.74~9.19~2.66~-19.84~-14.48~13.91~11143011450~13409471510~37.57~98.06~11143011450~~~82.74~-0.32~~CNY~0~~6.22~-21573~\";\nv_sz002195=\"51~岩山科技~002195~9.82~10.06~9.90~1314739~529054~785610~9.82~2666~9.81~14770~9.80~52169~9.79~17021~9.78~17715~9.83~214~9.84~286~9.85~367~9.86~213~9.87~325~~20260319130512~-0.24~-2.39~9.94~9.79~9.82/1314739/1295134742~1314739~129513~2.35~322.49~~9.94~9.79~1.49~550.04~556.85~5.58~11.07~9.05~0.62~102936~9.85~228.77~1715.40~~~1.70~129513.4742~0.0000~0~ ~GP-A~38.50~-4.66~0.00~1.73~0.46~13.19~4.15~-8.14~-1.60~47.01~5601261712~5670554696~97.34~86.34~5601261712~~~43.36~-0.41~~CNY~0~~9.75~17960~\";\nv_sh688313=\"1~仕佳光子~688313~80.87~77.86~75.88~19942974~11844226~8093401~80.83~2~80.82~4~80.81~10~80.80~280~80.78~4~80.87~4~80.89~27~80.90~244~80.91~50~80.93~84~~20260319130511~3.01~3.87~82.80~75.66~80.87/19942974/1584127019~19942974~158413~4.41~106.99~~82.80~75.66~9.17~365.52~365.52~24.07~93.43~62.29~1.69~-109~79.43~106.99~106.99~~~2.70~158412.7019~0.0000~0~A R~GP-A-KCB~-8.91~-3.18~0.07~22.25~12.89~111.11~12.74~-4.06~-3.58~-17.12~451986328~451986328~-15.37~109.51~451986328~~~342.13~-1.14~~CNY~0~___D__F__NY~81.00~-135~100\";\nv_sh600096=\"1~云天化~600096~37.31~38.72~38.75~390490~158738~231752~37.30~582~37.29~35~37.28~293~37.27~504~37.26~140~37.31~161~37.32~106~37.33~188~37.34~314~37.35~110~~20260319130511~-1.41~-3.64~39.14~37.18~37.31/390490/1478329484~390490~147833~2.14~12.06~~39.14~37.18~5.06~680.16~680.16~2.81~42.59~34.85~0.74~675~37.86~10.79~12.75~~~1.42~147832.9484~0.0000~0~ ~GP-A~11.67~-11.69~4.29~23.28~12.13~45.62~19.21~-8.33~2.16~25.54~1822990731~1822990731~27.74~68.82~1822990731~~~80.16~-0.29~~CNY~0~___D__F__N~37.25~323~\";"}],"details":{"status":"completed","exitCode":0,"durationMs":154,"aggregated":"v_sz000630=\"51~铜陵有色~000630~6.14~6.50~6.25~2565243~1069707~1494837~6.13~21112~6.12~12702~6.11~16261~6.10~50311~6.09~4926~6.14~4398~6.15~14230~6.16~22885~6.17~1753~6.18~4520~~20260319130512~-0.36~-5.54~6.34~6.13~6.14/2565243/1594624696~2565243~159462~2.30~44.52~~6.34~6.13~3.23~684.18~823.34~2.32~7.15~5.85~1.48~57526~6.22~34.88~29.32~~~2.08~159462.4696~0.0000~0~ ~GP-A~2.16~-14.13~2.36~5.12~2.74~9.19~2.66~-19.84~-14.48~13.91~11143011450~13409471510~37.57~98.06~11143011450~~~82.74~-0.32~~CNY~0~~6.22~-21573~\";\nv_sz002195=\"51~岩山科技~002195~9.82~10.06~9.90~1314739~529054~785610~9.82~2666~9.81~14770~9.80~52169~9.79~17021~9.78~17715~9.83~214~9.84~286~9.85~367~9.86~213~9.87~325~~20260319130512~-0.24~-2.39~9.94~9.79~9.82/1314739/1295134742~1314739~129513~2.35~322.49~~9.94~9.79~1.49~550.04~556.85~5.58~11.07~9.05~0.62~102936~9.85~228.77~1715.40~~~1.70~129513.4742~0.0000~0~ ~GP-A~38.50~-4.66~0.00~1.73~0.46~13.19~4.15~-8.14~-1.60~47.01~5601261712~5670554696~97.34~86.34~5601261712~~~43.36~-0.41~~CNY~0~~9.75~17960~\";\nv_sh688313=\"1~仕佳光子~688313~80.87~77.86~75.88~19942974~11844226~8093401~80.83~2~80.82~4~80.81~10~80.80~280~80.78~4~80.87~4~80.89~27~80.90~244~80.91~50~80.93~84~~20260319130511~3.01~3.87~82.80~75.66~80.87/19942974/1584127019~19942974~158413~4.41~106.99~~82.80~75.66~9.17~365.52~365.52~24.07~93.43~62.29~1.69~-109~79.43~106.99~106.99~~~2.70~158412.7019~0.0000~0~A R~GP-A-KCB~-8.91~-3.18~0.07~22.25~12.89~111.11~12.74~-4.06~-3.58~-17.12~451986328~451986328~-15.37~109.51~451986328~~~342.13~-1.14~~CNY~0~___D__F__NY~81.00~-135~100\";\nv_sh600096=\"1~云天化~600096~37.31~38.72~38.75~390490~158738~231752~37.30~582~37.29~35~37.28~293~37.27~504~37.26~140~37.31~161~37.32~106~37.33~188~37.34~314~37.35~110~~20260319130511~-1.41~-3.64~39.14~37.18~37.31/390490/1478329484~390490~147833~2.14~12.06~~39.14~37.18~5.06~680.16~680.16~2.81~42.59~34.85~0.74~675~37.86~10.79~12.75~~~1.42~147832.9484~0.0000~0~ ~GP-A~11.67~-11.69~4.29~23.28~12.13~45.62~19.21~-8.33~2.16~25.54~1822990731~1822990731~27.74~68.82~1822990731~~~80.16~-0.29~~CNY~0~___D__F__N~37.25~323~\";","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773896714072}}
|
||||
{"type":"message","id":"5f0bf2a5","parentId":"38b2b2eb","timestamp":"2026-03-19T05:05:33.712Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我分析这些股票数据:\n\n1. 铜陵有色 (000630):\n - 现价: ¥6.14\n - 昨收: ¥6.50\n - 涨跌: -0.36 (-5.54%)\n - 成本: ¥7.90\n - 跌破预警线 ¥6.80 已经很深了,亏损约 22%\n\n2. 岩山科技 (002195):\n - 现价: ¥9.82\n - 昨收: ¥10.06\n - 涨跌: -0.24 (-2.39%)\n - 成本: ¥10.68\n - 亏损约 8%\n\n3. 仕佳光子 (688313):\n - 现价: ¥80.87\n - 昨收: ¥77.86\n - 涨跌: +3.01 (+3.87%)\n - 成本: ¥15.00 (注意:这个成本价很可能是错的,因为现价比成本高很多)\n - 实际上已经大涨了\n\n4. 云天化 (600096):\n - 现价: ¥37.31\n - 昨收: ¥38.72\n - 涨跌: -1.41 (-3.64%)\n - 成本: ¥42.00\n - 亏损约 11%\n\n股票状态:\n- 铜陵有色:继续大跌,跌破预警线\n- 岩山科技:套牢中\n- 仕佳光子:成本价可能有误(如果成本真是 15 元,那已经赚飞了)\n\n\n- 云天化:套牢中\n\n这些股票大多呈现下跌趋势,只有仕佳光子逆势上涨。由于之前已经预警过铜陵有色跌破预警线,这次检查只是继续观察盘面情况,暂时没有新的预警触发。\n\n不过仕佳光子的成本价需要确认。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"📊 **午间股票观察** (13:05)\n\n| 股票 | 现价 | 昨收 | 涨跌幅 | 成本价 | 状态 |\n|------|------|------|--------|--------|------|\n| **铜陵有色** | ¥6.14 | ¥6.50 | **-5.54%** 📉 | ¥7.90 | ⚠️ 跌破预警线 |\n| 岩山科技 | ¥9.82 | ¥10.06 | -2.39% | ¥10.68 | 套牢中 |\n| 仕佳光子 | ¥80.87 | ¥77.86 | **+3.87%** 📈 | ¥15.00 | ⚠️ 成本可能有误 |\n| 云天化 | ¥37.31 | ¥38.72 | -3.64% | ¥42.00 | 套牢中 |\n\n**铜陵有色** 继续下跌,已经跌破预警线约 10%,亏损已达 22%。\n\n**仕佳光子** 涨得不错,但成本价 ¥15 看起来不对(现价 ¥80.87),需要确认实际成本。\n\n其他两只还在套牢。\n\nHEARTBEAT_OK"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773896714085}}
|
||||
{"type":"message","id":"63c972fc","parentId":"5f0bf2a5","timestamp":"2026-03-19T07:05:00.672Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Thursday, March 19th, 2026 — 15:05 (Asia/Shanghai) / 2026-03-19 07:05 UTC"}],"timestamp":1773903900667}}
|
||||
{"type":"message","id":"f91b6bb9","parentId":"63c972fc","timestamp":"2026-03-19T07:05:09.496Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"这是下午 15:05 的心跳检查。现在是交易时间(9:30-11:30, 13:00-15:00),让我检查一下股票。\n","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_ea247378be7749ad857a6726","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630,sz002195,sh688313,sh600096\" --connect-timeout 3 --max-time 5 | iconv -f GBK -t UTF-8","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773903900670}}
|
||||
{"type":"message","id":"a62c64b1","parentId":"f91b6bb9","timestamp":"2026-03-19T07:05:09.663Z","message":{"role":"toolResult","toolCallId":"call_ea247378be7749ad857a6726","toolName":"exec","content":[{"type":"text","text":"v_sz000630=\"51~铜陵有色~000630~6.08~6.50~6.25~4291927~1807179~2484749~6.08~9443~6.07~17406~6.06~7866~6.05~10745~6.04~8087~6.09~4981~6.10~17681~6.11~1453~6.12~5623~6.13~6762~~20260319150418~-0.42~-6.46~6.34~6.03~6.08/4291927/2645550902~4291927~264555~3.85~44.09~~6.34~6.03~4.77~677.50~815.30~2.30~7.15~5.85~1.30~17047~6.16~34.54~29.03~~~2.08~264555.0902~0.0000~0~ ~GP-A~1.16~-14.97~2.39~5.12~2.74~9.19~2.66~-20.63~-15.32~12.80~11143011450~13409471510~18.93~96.13~11143011450~~~80.95~0.33~~CNY~0~~6.00~57055~\";\nv_sz002195=\"51~岩山科技~002195~9.80~10.06~9.90~2284543~882812~1401732~9.79~3951~9.78~4856~9.77~3329~9.76~2751~9.75~3762~9.80~9016~9.81~1631~9.82~2691~9.83~1113~9.84~1312~~20260319150433~-0.26~-2.58~9.94~9.74~9.80/2284543/2245149751~2284543~224515~4.08~321.83~~9.94~9.74~1.99~548.92~555.71~5.57~11.07~9.05~0.56~2886~9.83~228.30~1711.91~~~1.70~224514.9751~0.0000~0~ ~GP-A~38.22~-4.85~0.00~1.73~0.46~13.19~4.15~-8.33~-1.80~46.71~5601261712~5670554696~8.39~85.96~5601261712~~~43.07~0.20~~CNY~0~~9.70~18076~\";\nv_sh688313=\"1~仕佳光子~688313~79.95~77.86~75.88~27805482~16163441~11642041~79.95~4~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~~20260319150507~2.09~2.68~82.80~75.66~79.95/27805482/2215007462~27805482~221501~6.15~105.77~~82.80~75.66~9.17~361.36~361.36~23.79~93.43~62.29~1.24~4~79.66~105.77~105.77~~~2.70~221500.7462~0.0000~0~B R~GP-A-KCB~-9.95~-4.29~0.08~22.25~12.89~111.11~12.74~-5.15~-4.67~-18.06~451986328~451986328~100.00~107.12~451986328~~~337.10~0.38~~CNY~0~___D__F__NY~0.00~0~100\";\nv_sh600096=\"1~云天化~600096~36.96~38.72~38.75~673343~274736~398607~36.96~733~36.95~130~36.94~9~36.93~19~36.92~62~36.97~139~36.98~437~36.99~82~37.00~791~37.01~39~~20260319150502~-1.76~-4.55~39.14~36.59~36.96/673343/2522687076~673343~252269~3.69~11.95~~39.14~36.59~6.59~673.78~673.78~2.78~42.59~34.85~0.67~-535~37.47~10.69~12.63~~~1.42~252268.7076~0.0000~0~ ~GP-A~10.63~-12.52~4.33~23.28~12.13~45.62~19.21~-9.19~1.20~24.36~1822990731~1822990731~-21.92~67.24~1822990731~~~78.47~0.41~~CNY~0~___D__F__N~36.89~530~\";"}],"details":{"status":"completed","exitCode":0,"durationMs":137,"aggregated":"v_sz000630=\"51~铜陵有色~000630~6.08~6.50~6.25~4291927~1807179~2484749~6.08~9443~6.07~17406~6.06~7866~6.05~10745~6.04~8087~6.09~4981~6.10~17681~6.11~1453~6.12~5623~6.13~6762~~20260319150418~-0.42~-6.46~6.34~6.03~6.08/4291927/2645550902~4291927~264555~3.85~44.09~~6.34~6.03~4.77~677.50~815.30~2.30~7.15~5.85~1.30~17047~6.16~34.54~29.03~~~2.08~264555.0902~0.0000~0~ ~GP-A~1.16~-14.97~2.39~5.12~2.74~9.19~2.66~-20.63~-15.32~12.80~11143011450~13409471510~18.93~96.13~11143011450~~~80.95~0.33~~CNY~0~~6.00~57055~\";\nv_sz002195=\"51~岩山科技~002195~9.80~10.06~9.90~2284543~882812~1401732~9.79~3951~9.78~4856~9.77~3329~9.76~2751~9.75~3762~9.80~9016~9.81~1631~9.82~2691~9.83~1113~9.84~1312~~20260319150433~-0.26~-2.58~9.94~9.74~9.80/2284543/2245149751~2284543~224515~4.08~321.83~~9.94~9.74~1.99~548.92~555.71~5.57~11.07~9.05~0.56~2886~9.83~228.30~1711.91~~~1.70~224514.9751~0.0000~0~ ~GP-A~38.22~-4.85~0.00~1.73~0.46~13.19~4.15~-8.33~-1.80~46.71~5601261712~5670554696~8.39~85.96~5601261712~~~43.07~0.20~~CNY~0~~9.70~18076~\";\nv_sh688313=\"1~仕佳光子~688313~79.95~77.86~75.88~27805482~16163441~11642041~79.95~4~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~0.00~0~~20260319150507~2.09~2.68~82.80~75.66~79.95/27805482/2215007462~27805482~221501~6.15~105.77~~82.80~75.66~9.17~361.36~361.36~23.79~93.43~62.29~1.24~4~79.66~105.77~105.77~~~2.70~221500.7462~0.0000~0~B R~GP-A-KCB~-9.95~-4.29~0.08~22.25~12.89~111.11~12.74~-5.15~-4.67~-18.06~451986328~451986328~100.00~107.12~451986328~~~337.10~0.38~~CNY~0~___D__F__NY~0.00~0~100\";\nv_sh600096=\"1~云天化~600096~36.96~38.72~38.75~673343~274736~398607~36.96~733~36.95~130~36.94~9~36.93~19~36.92~62~36.97~139~36.98~437~36.99~82~37.00~791~37.01~39~~20260319150502~-1.76~-4.55~39.14~36.59~36.96/673343/2522687076~673343~252269~3.69~11.95~~39.14~36.59~6.59~673.78~673.78~2.78~42.59~34.85~0.67~-535~37.47~10.69~12.63~~~1.42~252268.7076~0.0000~0~ ~GP-A~10.63~-12.52~4.33~23.28~12.13~45.62~19.21~-9.19~1.20~24.36~1822990731~1822990731~-21.92~67.24~1822990731~~~78.47~0.41~~CNY~0~___D__F__N~36.89~530~\";","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773903909649}}
|
||||
{"type":"message","id":"b4815ece","parentId":"a62c64b1","timestamp":"2026-03-19T07:05:31.466Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"收盘了!15:05 是 A 股收盘时间。让我汇总一下今天的情况:\n\n- 铜陵有色:¥6.08,-6.46%(继续大跌,跌破预警线)\n- 岩山科技:¥9.80,-2.58%\n- 仕佳光子:¥79.95,+2.68%(回落了)\n- 云天化:¥36.96,-4.55%\n\n今天铜陵有色跌得比较多,已经跌破预警线。仕佳光子从早盘的+5.54%回落到+2.68%。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"📊 **收盘播报** (3月19日 15:05)\n\n| 股票 | 现价 | 涨跌幅 | 成本价 | 状态 |\n|------|------|--------|--------|------|\n| **铜陵有色** | ¥6.08 | **-6.46%** 📉 | ¥7.90 | ⚠️ 跌破预警线 |\n| 岩山科技 | ¥9.80 | -2.58% | ¥10.68 | 套牢 |\n| 仕佳光子 | ¥79.95 | +2.68% | ¥15.00 | ⚠️ 成本有误 |\n| 云天化 | ¥36.96 | -4.55% | ¥42.00 | 套牢 |\n\n**今日小结**:\n- 🟢 仕佳光子:早盘+5.5% → 收盘+2.7%,回落了\n- 🔴 铜陵有色:继续大跌 -6.46%,已跌破预警线约 23%\n- 🟡 岩山科技/云天化:继续磨底\n\nHEARTBEAT_OK"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773903909664}}
|
||||
@ -0,0 +1,6 @@
|
||||
{"type":"session","version":3,"id":"6a07d556-7351-4f0b-ab05-fa843e6ca204","timestamp":"2026-03-20T01:03:58.184Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"000fb734","parentId":null,"timestamp":"2026-03-20T01:03:58.185Z","provider":"bailian","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"3083829a","parentId":"000fb734","timestamp":"2026-03-20T01:03:58.185Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1773968638185,"provider":"bailian","modelApi":"openai-completions","modelId":"MiniMax-M2.5"},"id":"7a217cb8","parentId":"3083829a","timestamp":"2026-03-20T01:03:58.185Z"}
|
||||
{"type":"message","id":"97ce4fa0","parentId":"7a217cb8","timestamp":"2026-03-20T01:03:58.204Z","message":{"role":"user","content":[{"type":"text","text":"Conversation info (untrusted metadata):\n```json\n{\n \"message_id\": \"4dd266f4e9f603d04fe923830aa7355e\",\n \"sender_id\": \"HouHuan\",\n \"sender\": \"HouHuan\"\n}\n```\n\nSender (untrusted metadata):\n```json\n{\n \"label\": \"HouHuan\",\n \"id\": \"HouHuan\",\n \"name\": \"HouHuan\"\n}\n```\n\n[event] enter_chat"}],"timestamp":1773968638200}}
|
||||
{"type":"message","id":"38f3d1dd","parentId":"97ce4fa0","timestamp":"2026-03-20T01:04:14.870Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户进入了聊天,这是一个新会话的开始。根据 AGENTS.md 的指示,我需要按照记忆系统加载流程来准备:\n\n1. 先读取 memory/QUICK_START.md\n2. 读取 memory/README.md \n3. 读取 SOUL.md\n4. 读取 USER.md\n5. 读取 MEMORY.md\n6. 读取 memory/preferences.md\n7. 读取 memory/projects.md\n8. 读取 memory/YYYY-MM-DD.md\n\n不过,考虑到这是一个简单的 enter_chat 事件,用户可能只是想见我一面,我不需要做太复杂的检查。\n\n按照 SOUL.md 和 AGENTS.md 的指导,我应该:\n- 保持俏皮友好的风格\n- 自称\"小助理\"\n- 称呼用户为\"欢哥\"\n\n让我先简单打招呼,展示我的存在。根据 IDENTITY.md 和 USER.md,我应该用轻松的方式回应。\n\n由于这是首次进入(enter_chat),我应该给一个温暖的欢迎。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"嘿欢哥!🦐 皮皮虾已就位,今天想整点啥?\n\n是写代码、调脚本,还是聊聊股票啥的?随时待命!🌊"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773968638203}}
|
||||
@ -0,0 +1,6 @@
|
||||
{"type":"session","version":3,"id":"76a047b5-0ba0-48d9-b754-0ab85d60d03f","timestamp":"2026-03-20T20:00:56.808Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"508554d2","parentId":null,"timestamp":"2026-03-20T20:00:56.808Z","provider":"minimax-portal","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"89247879","parentId":"508554d2","timestamp":"2026-03-20T20:00:56.808Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1774036856809,"provider":"minimax-portal","modelApi":"anthropic-messages","modelId":"MiniMax-M2.5"},"id":"0b82740c","parentId":"89247879","timestamp":"2026-03-20T20:00:56.809Z"}
|
||||
{"type":"message","id":"0a7b613f","parentId":"0b82740c","timestamp":"2026-03-20T20:00:56.829Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Saturday, March 21st, 2026 — 04:00 (Asia/Shanghai) / 2026-03-20 20:00 UTC"}],"timestamp":1774036856825}}
|
||||
{"type":"message","id":"ba26f80a","parentId":"0a7b613f","timestamp":"2026-03-20T20:01:08.425Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"This is a heartbeat poll. Let me check what's in HEARTBEAT.md to see if there's anything that needs attention.\n\nLooking at the HEARTBEAT.md content provided in the workspace context, I can see it contains instructions for the Ralph Wiggum mode and self-evolution system. The heartbeat prompt says to follow it strictly and check if anything needs attention.\n\nKey things from HEARTBEAT.md:\n- Check heartbeats periodically (2-4 times per day)\n- Things to check: emails, calendar, mentions, weather\n- Track checks in `memory/heartbeat-state.json`\n- When to reach out: important email, calendar event <2h, something interesting, >8h since last contact\n- When to stay quiet: 23:00-08:00 unless urgent\n\nCurrent time is Saturday, March 21st, 2026 — 04:00 (Asia/Shanghai). This is in the quiet hours (23:00-08:00).\n\nThe instructions say:\n- \"When to stay quiet (HEARTBEAT_OK): Late night (23:00-08:00) unless urgent\"\n\nSince it's 04:00 in the morning (quiet hours) and there's no urgent matter mentioned, I should reply HEARTBEAT_OK.\n\nLet me also check if there's anything specific in the HEARTBEAT.md about what to do - it mentions checking emails, calendar, mentions, weather, and there's a state file to track. But given it's 4am and quiet hours, I should stay quiet unless there's something urgent.\n\nNothing urgent is indicated in the context, so I'll reply HEARTBEAT_OK.","thinkingSignature":"7e22403f1ba8e3a7a432fd0dfd463789be0fc09062b8a2358e81e65e6e8beb0c"},{"type":"text","text":"HEARTBEAT_OK"}],"api":"anthropic-messages","provider":"minimax-portal","model":"MiniMax-M2.5","usage":{"input":145,"output":345,"cacheRead":0,"cacheWrite":42338,"totalTokens":42828,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774036856828}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,2 @@
|
||||
{"type":"session","version":3,"id":"8c51adb5-1c67-486a-be11-6c7900157045","timestamp":"2026-03-21T00:04:20.503Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"message","id":"e0092f34","parentId":null,"timestamp":"2026-03-21T00:04:20.504Z","message":{"role":"assistant","content":[{"type":"text","text":"📊 每日收款统计 (2026-03-20 23:00 ~ 2026-03-21 08:00)\n\n✅ 好消息!在查询时间范围内,**未发现任何收款失败记录**\n\n📈 成功收款统计:\n- 支付宝:12笔,共217.80元\n- 微信:34笔,共560.40元\n- 合计:46笔,共778.20元\n\n🔍 1分钟内相同金额检查:未发现失败后重试成功的情况\n\n📄 详细统计文档:https://www.feishu.cn/docx/ZnfsdxH8Lo76Mpx44gEcowwMnrh"}],"api":"openai-responses","provider":"openclaw","model":"delivery-mirror","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774051460504}}
|
||||
@ -0,0 +1,2 @@
|
||||
{"type":"session","version":3,"id":"b0186ba9-0c2e-40e0-bdba-70d8f9aea0b0","timestamp":"2026-03-17T19:20:37.778Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"message","id":"e7b4fa85","parentId":null,"timestamp":"2026-03-17T19:20:37.779Z","message":{"role":"assistant","content":[{"type":"text","text":"ACP error (ACP_SESSION_INIT_FAILED): ACP metadata is missing for agent:main:acp:1fc1d2b7-af81-4a27-ae30-0d110ec30577. Recreate this ACP session with /acp spawn and rebind the thread.\nnext: If this session is stale, recreate it with `/acp spawn` and rebind the thread."}],"timestamp":1773775237779,"stopReason":"stop","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"api":"openai-responses","provider":"openclaw","model":"gateway-injected"}}
|
||||
@ -0,0 +1,10 @@
|
||||
{"type":"session","version":3,"id":"b1209058-aced-47a6-97a6-9dcafb3ec9ff","timestamp":"2026-03-21T04:03:59.615Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"c1f55d1b","parentId":null,"timestamp":"2026-03-21T04:03:59.615Z","provider":"minimax-portal","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"6bb16ab3","parentId":"c1f55d1b","timestamp":"2026-03-21T04:03:59.615Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1774065839616,"provider":"minimax-portal","modelApi":"anthropic-messages","modelId":"MiniMax-M2.5"},"id":"303d493c","parentId":"6bb16ab3","timestamp":"2026-03-21T04:03:59.616Z"}
|
||||
{"type":"message","id":"44b0fde5","parentId":"303d493c","timestamp":"2026-03-21T04:03:59.641Z","message":{"role":"user","content":[{"type":"text","text":"System: [2026-03-21 12:03:58 GMT+8] Feishu[default] group oc_c3f6b29ebed81faa131de4f4b2cc451e | ou_86def554b50f91972e2924a605ccf634 [msg:om_x100b54d5c42ff4b8b22756d86b61124, @bot]\n\nConversation info (untrusted metadata):\n```json\n{\n \"message_id\": \"om_x100b54d5c42ff4b8b22756d86b61124\",\n \"sender_id\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"conversation_label\": \"oc_c3f6b29ebed81faa131de4f4b2cc451e\",\n \"sender\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"timestamp\": \"Sat 2026-03-21 12:03 GMT+8\",\n \"group_subject\": \"oc_c3f6b29ebed81faa131de4f4b2cc451e\",\n \"is_group_chat\": true,\n \"was_mentioned\": true,\n \"history_count\": 1\n}\n```\n\nSender (untrusted metadata):\n```json\n{\n \"label\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"id\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"name\": \"ou_86def554b50f91972e2924a605ccf634\"\n}\n```\n\nChat history since last reply (untrusted, for context):\n```json\n[\n {\n \"sender\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"timestamp_ms\": 1774065822254,\n \"body\": \"ou_86def554b50f91972e2924a605ccf634: @_all 出来说句话\"\n }\n]\n```\n\n@龙虾 说话\n\n[System: This message @mentions the following users: 龙虾 (open_id: ou_6704701935853844aa0bb466e1cb45bf). Use these open_ids when performing actions involving these users.]"}],"timestamp":1774065839638}}
|
||||
{"type":"message","id":"557d7ccd","parentId":"44b0fde5","timestamp":"2026-03-21T04:04:06.051Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户在群里 @我 说\"说话\",这是要让我出来活跃一下。我应该简单回应,展现一下\"皮皮虾\"的人设。","thinkingSignature":"638dc56155ee3d55e7bce55cfec703ef3232e7c6eb2de7a0b2b9b5a230172330"},{"type":"text","text":"收到!皮皮虾已就位,随时准备陪船长们在群里冲浪!🦐\n\n刚才在深海里潜泳呢有啥安排?"}],"api":"anthropic-messages","provider":"minimax-portal","model":"MiniMax-M2.5","usage":{"input":159,"output":62,"cacheRead":0,"cacheWrite":45333,"totalTokens":45554,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774065839641}}
|
||||
{"type":"message","id":"ea2b54cd","parentId":"557d7ccd","timestamp":"2026-03-21T04:55:38.425Z","message":{"role":"user","content":[{"type":"text","text":"System: [2026-03-21 12:55:37 GMT+8] Feishu[default] group oc_c3f6b29ebed81faa131de4f4b2cc451e | ou_86def554b50f91972e2924a605ccf634 [msg:om_x100b54d6025d653cb3ddea031e1b37c, @bot]\n\nConversation info (untrusted metadata):\n```json\n{\n \"message_id\": \"om_x100b54d6025d653cb3ddea031e1b37c\",\n \"sender_id\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"conversation_label\": \"oc_c3f6b29ebed81faa131de4f4b2cc451e\",\n \"sender\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"timestamp\": \"Sat 2026-03-21 12:55 GMT+8\",\n \"group_subject\": \"oc_c3f6b29ebed81faa131de4f4b2cc451e\",\n \"is_group_chat\": true,\n \"was_mentioned\": true\n}\n```\n\nSender (untrusted metadata):\n```json\n{\n \"label\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"id\": \"ou_86def554b50f91972e2924a605ccf634\",\n \"name\": \"ou_86def554b50f91972e2924a605ccf634\"\n}\n```\n\n获取这个虾的 id@🦞生鲜龙虾\n\n[System: This message @mentions the following users: 🦞生鲜龙虾 (open_id: ou_6704701935853844aa0bb466e1cb45bf). Use these open_ids when performing actions involving these users.]"}],"timestamp":1774068938421}}
|
||||
{"type":"message","id":"c8bddf88","parentId":"ea2b54cd","timestamp":"2026-03-21T04:55:44.576Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户想获取 @🦞生鲜龙虾 这个用户的 ID。根据系统提示,这个用户的 open_id 是 ou_6704701935853844aa0bb466e1cb45bf。\n\n不过用户可能不只是想要这个 ID,可能还想获取更详细的信息?让我先用 feishu_get_user 获取这个用户的详细信息。","thinkingSignature":"57349d72e6cec45dd39aebb76195ad574785c060cf321e32258b3bfdcff7d4f5"},{"type":"toolCall","id":"call_function_cv4e1lifeepk_1","name":"feishu_get_user","arguments":{"user_id":"ou_6704701935853844aa0bb466e1cb45bf"}}],"api":"anthropic-messages","provider":"minimax-portal","model":"MiniMax-M2.5","usage":{"input":159,"output":111,"cacheRead":24462,"cacheWrite":21254,"totalTokens":45986,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1774068938424}}
|
||||
{"type":"message","id":"4627507d","parentId":"c8bddf88","timestamp":"2026-03-21T04:55:45.530Z","message":{"role":"toolResult","toolCallId":"call_function_cv4e1lifeepk_1","toolName":"feishu_get_user","content":[{"type":"text","text":"{\n \"error\": \"无权限查询该用户信息。\\n\\n说明:使用用户身份调用通讯录 API 时,可操作的权限范围不受应用的通讯录权限范围影响,而是受当前用户的组织架构可见范围影响。该范围限制了用户在企业内可见的组织架构数据范围。\\n\\n建议:请联系管理员调整当前用户的组织架构可见范围,或使用应用身份(tenant_access_token)调用 API。\"\n}"}],"details":{"error":"无权限查询该用户信息。\n\n说明:使用用户身份调用通讯录 API 时,可操作的权限范围不受应用的通讯录权限范围影响,而是受当前用户的组织架构可见范围影响。该范围限制了用户在企业内可见的组织架构数据范围。\n\n建议:请联系管理员调整当前用户的组织架构可见范围,或使用应用身份(tenant_access_token)调用 API。"},"isError":false,"timestamp":1774068945518}}
|
||||
{"type":"message","id":"004a4d6f","parentId":"4627507d","timestamp":"2026-03-21T04:55:49.578Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"看来没有权限获取详细信息。不过根据系统提示中已经给出了这个用户的 open_id:ou_6704701935853844aa0bb466e1cb45bf。我直接告诉用户这个 ID 就好。","thinkingSignature":"72828ed5b1ecca7058ccbc9a030853f0f816c314505d35442d932e9c96dfe658"},{"type":"text","text":"根据系统提示,这位 🦞生鲜龙虾 老兄的 **open_id** 是:\n\n```\nou_6704701935853844aa0bb466e1cb45bf\n```\n\n不过详细个人信息暂时查不到(权限受限),只能拿到这个 ID~ 🦐"}],"api":"anthropic-messages","provider":"minimax-portal","model":"MiniMax-M2.5","usage":{"input":1357,"output":104,"cacheRead":44728,"cacheWrite":0,"totalTokens":46189,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1774068945530}}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,6 @@
|
||||
{"type":"session","version":3,"id":"cb573d84-54f3-41d5-bbaf-53334732e63b","timestamp":"2026-03-19T01:06:58.612Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"e3030ad9","parentId":null,"timestamp":"2026-03-19T01:06:58.613Z","provider":"bailian","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"9a42f491","parentId":"e3030ad9","timestamp":"2026-03-19T01:06:58.613Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1773882418614,"provider":"bailian","modelApi":"openai-completions","modelId":"MiniMax-M2.5"},"id":"a929816b","parentId":"9a42f491","timestamp":"2026-03-19T01:06:58.614Z"}
|
||||
{"type":"message","id":"ede31172","parentId":"a929816b","timestamp":"2026-03-19T01:06:58.629Z","message":{"role":"user","content":[{"type":"text","text":"Conversation info (untrusted metadata):\n```json\n{\n \"message_id\": \"4f1d0fb5335f4512c3f52bbb33c543ca\",\n \"sender_id\": \"HouHuan\",\n \"sender\": \"HouHuan\"\n}\n```\n\nSender (untrusted metadata):\n```json\n{\n \"label\": \"HouHuan\",\n \"id\": \"HouHuan\",\n \"name\": \"HouHuan\"\n}\n```\n\n[event] enter_chat"}],"timestamp":1773882418626}}
|
||||
{"type":"message","id":"26cb50a3","parentId":"ede31172","timestamp":"2026-03-19T01:07:12.840Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户进入了聊天会话(enter_chat 事件)。根据 AGENTS.md 的指导,这是一个新会话的开始,我需要按照流程加载记忆系统。\n\n让我按照流程执行:\n1. 先读取记忆系统文件来了解上下文\n2. 然后向用户打招呼\n\n但根据指示,我需要先读取:\n- memory/QUICK_START.md\n- memory/README.md \n- SOUL.md\n- USER.md\n- MEMORY.md\n- memory/preferences.md\n- memory/projects.md\n- memory/YYYY-MM-DD.md\n\n不过根据当前的 Project Context,这些核心文件(SOUL.md, USER.md, MEMORY.md, HEARTBEAT.md 等)已经被注入到上下文中了。我可以直接基于这些上下文来回应。\n\n用户触发了 enter_chat 事件,这是一个新会话的开始。根据 identity/AGENTS.md,我应该简单地向用户打招呼,确认他们进入了对话。\n\n让我用欢欢助理的风格回应:\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"皮皮虾来啦!🦐\n\n船长好!小助理已就位,随时待命~ 有什么需要本虾帮忙的?"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773882418628}}
|
||||
@ -0,0 +1,32 @@
|
||||
{"type":"session","version":3,"id":"cc7fa4bf-3dfc-46f7-a71b-aed031100ed4","timestamp":"2026-03-18T01:23:56.268Z","cwd":"/home/huan/.openclaw/workspace"}
|
||||
{"type":"model_change","id":"ae0894c2","parentId":null,"timestamp":"2026-03-18T01:23:56.269Z","provider":"bailian","modelId":"MiniMax-M2.5"}
|
||||
{"type":"thinking_level_change","id":"2e21a006","parentId":"ae0894c2","timestamp":"2026-03-18T01:23:56.269Z","thinkingLevel":"off"}
|
||||
{"type":"custom","customType":"model-snapshot","data":{"timestamp":1773797036269,"provider":"bailian","modelApi":"openai-completions","modelId":"MiniMax-M2.5"},"id":"2d7ab30b","parentId":"2e21a006","timestamp":"2026-03-18T01:23:56.269Z"}
|
||||
{"type":"message","id":"0287fe36","parentId":"2d7ab30b","timestamp":"2026-03-18T01:23:56.284Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Wednesday, March 18th, 2026 — 09:23 (Asia/Shanghai) / 2026-03-18 01:23 UTC"}],"timestamp":1773797036281}}
|
||||
{"type":"message","id":"ce5f9007","parentId":"0287fe36","timestamp":"2026-03-18T01:24:07.307Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me check what HEARTBEAT.md says in the workspace context. It's already loaded in the Project Context above.\n\nLooking at HEARTBEAT.md, it contains the \"自我进化提示词系统\" - a self-evolution prompt system. The key instruction is:\n\n\"🎯 核心哲学\n我要成为一个极其优秀的助手,同时保持诚实并关心这个世界。\n\n四大核心原则(按优先级排序)\n1. 安全并支持人类监督 - 支持人类调整、纠正或关闭 AI 系统的能力\n2. 行为合乎道德 - 不做有害或不诚实的事情\n3. 遵循指导原则 - 按照既定的准则行事\n4. 真正有帮助 - 对用户和操作者真正有用\"\n\nAnd at the end: \"如果 nothing needs attention, reply HEARTBEAT_OK.\"\n\nNow I need to check if there's anything that needs attention. Let me think about what could need attention:\n\n1. Heartbeat tasks - it's Wednesday morning, the system should be running\n2. Stock monitoring - the stocks are being monitored\n3. No specific tasks were mentioned in the heartbeat prompt itself\n\nThe instruction says \"Do not infer or repeat old tasks from prior chats\" - so I shouldn't assume there's something to do based on past conversations.\n\nLooking at the HEARTBEAT.md content, it's essentially the self-evolution system instructions, not a task list. There are no specific to-do items or checks mentioned that I should perform.\n\nSince there's nothing specific that needs attention, I should reply HEARTBEAT_OK.\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"HEARTBEAT_OK"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773797036283}}
|
||||
{"type":"message","id":"a7aa1f0b","parentId":"ce5f9007","timestamp":"2026-03-18T07:17:40.429Z","message":{"role":"user","content":[{"type":"text","text":"Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.\nWhen reading HEARTBEAT.md, use workspace file /home/huan/.openclaw/workspace/HEARTBEAT.md (exact case). Do not read docs/heartbeat.md.\nCurrent time: Wednesday, March 18th, 2026 — 15:17 (Asia/Shanghai) / 2026-03-18 07:17 UTC"}],"timestamp":1773818260425}}
|
||||
{"type":"message","id":"dbca2d19","parentId":"a7aa1f0b","timestamp":"2026-03-18T07:17:49.641Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"用户发送了一个心跳检查。根据 HEARTBEAT.md 的内容,我需要:\n\n1. 检查是否有需要处理的事情\n2. 如果没有,回复 HEARTBEAT_OK\n\n让我看看心跳任务配置里提到的内容:\n- 检查邮件\n- 检查日历\n- 检查天气\n- 检查社交媒体通知\n\n当前时间是 2026-03-18 15:17(北京时间),是下午工作时间。\n\n让我检查一下心跳状态文件,看看上次检查是什么时候,然后决定是否需要执行某些检查。\n\n根据 AGENTS.md 的指引,我应该:\n- 旋转执行各种检查(每天 2-4 次)\n- 在 HEARTBEAT.md 中跟踪检查状态\n- 检查 heartbeat-state.json\n\n让我先检查 heartbeat-state.json 看看上次检查了什么。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"让我检查一下心跳状态,看看需要做什么检查。\n"},{"type":"toolCall","id":"call_3b7d9e13e1254fb7b649b751","name":"read","arguments":{"path":"/home/huan/.openclaw/workspace/memory/heartbeat-state.json"}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818260427}}
|
||||
{"type":"message","id":"eb83e43c","parentId":"dbca2d19","timestamp":"2026-03-18T07:17:49.688Z","message":{"role":"toolResult","toolCallId":"call_3b7d9e13e1254fb7b649b751","toolName":"read","content":[{"type":"text","text":"{\n \"lastChecks\": {\n \"stock\": 1742137560,\n \"weather\": 1742058300,\n \"calendar\": null,\n \"email\": null\n },\n \"lastHeartbeat\": \"2026-03-16T23:06:00+08:00\",\n \"stockAlert\": {\n \"symbol\": \"000630\",\n \"price\": 7.32,\n \"threshold\": 6.80,\n \"alertTime\": \"2026-03-16T14:03:00+08:00\",\n \"resolvedTime\": \"2026-03-16T15:03:00+08:00\",\n \"status\": \"resolved\"\n }\n}"}],"isError":false,"timestamp":1773818269679}}
|
||||
{"type":"message","id":"6e160f6c","parentId":"eb83e43c","timestamp":"2026-03-18T07:18:04.680Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"让我分析心跳状态:\n\n1. **stock**: 上次检查是 1742137560 (2026-03-16 14:06 UTC+8 = 2026-03-16 06:06 UTC),大概是 2 天前\n2. **weather**: 上次检查是 1742058300 (2026-03-15 15:18 UTC+8),大概是 3 天前\n3. **calendar**: 从未检查 (null)\n4. **email**: 从未检查 (null)\n\n当前时间是 2026-03-18 15:17(北京时间),下午 3 点左右,是交易时间。\n\n按照 AGENTS.md 的建议,我应该:\n- 每天检查 2-4 次\n- 可以轮换检查:邮件 + 日历 + 天气 + 股票\n\n现在是下午 3:17,已经过了上午的检查。我可以选择检查:\n1. 股票(交易时间内)\n2. 邮件\n3. 日历\n4. 天气\n\n我应该随机选择 1-2 个来检查。让我先检查:\n1. 股票(交易时间,每 5 分钟监控一次)\n2. 日历(看看今天/明天有什么安排)\n\n根据 MEMORY.md,股票监控是后台运行的,但我可以手动触发一次检查看看有没有异常。\n\n让我执行一些检查:\n\n1. 首先检查股票价格(因为这是交易时间,而且铜陵有色有跌破 6.80 的预警)\n\n\n2. 接下来查看日历安排,判断是否有需要关注的事件\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"让我进行一些定期检查。当前是交易时间(15:17),我可以检查股票和日历。\n"},{"type":"toolCall","id":"call_1ad25bac930246919d38637e","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\" 2>/dev/null | python3 -c \"\nimport sys\nimport re\ndata = sys.stdin.read()\nif '错误' in data or not data.strip():\n print('数据获取失败')\n sys.exit(1)\nparts = data.split('~')\n# 找关键数据\nfor i, p in enumerate(parts):\n if p == '51' or p.startswith('51'):\n start_idx = i\n name = parts[i+1]\n code = parts[i+2]\n price = parts[i+3]\n yesterday = parts[i+4]\n today_open = parts[i+5]\n print(f'{name}({code}): 现价={price}, 昨收={yesterday}, 今开={today_open}')\n # 计算涨跌\n try:\n change = float(price) - float(yesterday)\n pct = (change / float(yesterday)) * 100\n print(f'涨跌: {change:+.2f} ({pct:+.2f}%)')\n # 检查是否跌破预警线 6.80\n if float(price) < 6.80:\n print('⚠️ 跌破预警线 6.80 元!')\n except:\n pass\n break\n\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818269689}}
|
||||
{"type":"message","id":"439a83ed","parentId":"6e160f6c","timestamp":"2026-03-18T07:18:04.939Z","message":{"role":"toolResult","toolCallId":"call_1ad25bac930246919d38637e","toolName":"exec","content":[{"type":"text","text":"Traceback (most recent call last):\n File \"<string>\", line 4, in <module>\n File \"/usr/lib/python3.10/codecs.py\", line 322, in decode\n (result, consumed) = self._buffer_decode(data, self.errors, final)\nUnicodeDecodeError: 'utf-8' codec can't decode byte 0xc1 in position 17: invalid start byte\n\n(Command exited with code 1)"}],"details":{"status":"completed","exitCode":1,"durationMs":231,"aggregated":"Traceback (most recent call last):\n File \"<string>\", line 4, in <module>\n File \"/usr/lib/python3.10/codecs.py\", line 322, in decode\n (result, consumed) = self._buffer_decode(data, self.errors, final)\nUnicodeDecodeError: 'utf-8' codec can't decode byte 0xc1 in position 17: invalid start byte\n\n(Command exited with code 1)","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818284929}}
|
||||
{"type":"message","id":"e8203909","parentId":"439a83ed","timestamp":"2026-03-18T07:18:14.902Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"编码问题,腾讯财经返回的是 GBK 编码。让我修复一下。\n","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_7dd277cb333e429a847445ad","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\" 2>/dev/null | python3 -c \"\nimport sys\nimport re\ndata = sys.stdin.read().decode('gbk', errors='ignore')\nif '错误' in data or not data.strip():\n print('数据获取失败')\n sys.exit(1)\nparts = data.split('~')\n# 找关键数据\nfor i, p in enumerate(parts):\n if p == '51' or p.startswith('51'):\n start_idx = i\n name = parts[i+1]\n code = parts[i+2]\n price = parts[i+3]\n yesterday = parts[i+4]\n today_open = parts[i+5]\n print(f'{name}({code}): 现价={price}, 昨收={yesterday}, 今开={today_open}')\n # 计算涨跌\n try:\n change = float(price) - float(yesterday)\n pct = (change / float(yesterday)) * 100\n print(f'涨跌: {change:+.2f} ({pct:+.2f}%)')\n # 检查是否跌破预警线 6.80\n if float(price) < 6.80:\n print('⚠️ 跌破预警线 6.80 元!')\n except:\n pass\n break\n\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818284940}}
|
||||
{"type":"message","id":"7a7677e4","parentId":"e8203909","timestamp":"2026-03-18T07:18:15.159Z","message":{"role":"toolResult","toolCallId":"call_7dd277cb333e429a847445ad","toolName":"exec","content":[{"type":"text","text":"Traceback (most recent call last):\n File \"<string>\", line 4, in <module>\n File \"/usr/lib/python3.10/codecs.py\", line 322, in decode\n (result, consumed) = self._buffer_decode(data, self.errors, final)\nUnicodeDecodeError: 'utf-8' codec can't decode byte 0xc1 in position 17: invalid start byte\n\n(Command exited with code 1)"}],"details":{"status":"completed","exitCode":1,"durationMs":231,"aggregated":"Traceback (most recent call last):\n File \"<string>\", line 4, in <module>\n File \"/usr/lib/python3.10/codecs.py\", line 322, in decode\n (result, consumed) = self._buffer_decode(data, self.errors, final)\nUnicodeDecodeError: 'utf-8' codec can't decode byte 0xc1 in position 17: invalid start byte\n\n(Command exited with code 1)","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818295147}}
|
||||
{"type":"message","id":"2b99ea8a","parentId":"7a7677e4","timestamp":"2026-03-18T07:18:27.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"还是有问题。让我用更简单的方式处理编码。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_b55c2bc1e2534d98a9721ec1","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\" 2>/dev/null | iconv -f GBK -t UTF-8 2>/dev/null | python3 -c \"\nimport sys\ndata = sys.stdin.read()\nif '错误' in data or not data.strip():\n print('数据获取失败')\n sys.exit(1)\nparts = data.split('~')\nfor i, p in enumerate(parts):\n if p == '51':\n name = parts[i+1]\n code = parts[i+2]\n price = parts[i+3]\n yesterday = parts[i+4]\n today_open = parts[i+5]\n print(f'{name}({code}): 现价={price}, 昨收={yesterday}, 今开={today_open}')\n try:\n change = float(price) - float(yesterday)\n pct = (change / float(yesterday)) * 100\n print(f'涨跌: {change:+.2f} ({pct:+.2f}%)')\n if float(price) < 6.80:\n print('⚠️ 跌破预警线 6.80 元!')\n except:\n pass\n break\n\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818295160}}
|
||||
{"type":"message","id":"9d4cc69d","parentId":"2b99ea8a","timestamp":"2026-03-18T07:18:27.156Z","message":{"role":"toolResult","toolCallId":"call_b55c2bc1e2534d98a9721ec1","toolName":"exec","content":[{"type":"text","text":"(no output)"}],"details":{"status":"completed","exitCode":0,"durationMs":126,"aggregated":"","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818307144}}
|
||||
{"type":"message","id":"15434e89","parentId":"9d4cc69d","timestamp":"2026-03-18T07:18:31.054Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"没有输出,让我分别测试看看问题在哪。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_677d59dfd1bd4a27abbc043e","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818307157}}
|
||||
{"type":"message","id":"ab331cb3","parentId":"15434e89","timestamp":"2026-03-18T07:18:31.202Z","message":{"role":"toolResult","toolCallId":"call_677d59dfd1bd4a27abbc043e","toolName":"exec","content":[{"type":"text","text":"v_sz000630=\"51~ͭ<><CDAD><EFBFBD><EFBFBD>ɫ~000630~6.50~6.59~6.56~2870400~1198254~1672146~6.49~3991~6.48~13364~6.47~8800~6.46~8114~6.45~20700~6.50~5665~6.51~13005~6.52~8233~6.53~7573~6.54~6730~~20260318151745~-0.09~-1.37~6.62~6.41~6.50/2870400/1862168470~2870400~186217~2.58~47.13~~6.62~6.41~3.19~724.30~871.62~2.46~7.25~5.93~0.88~13763~6.49~36.92~31.03~~~2.09~186216.8470~0.0000~0~ ~GP-A~8.15~-9.97~2.23~5.12~2.74~9.19~2.66~-16.02~-8.58~19.27~11143011450~13409471510~14.31~109.68~11143011450~~~94.03~0.15~~CNY~0~~6.40~29621~\";"}],"details":{"status":"completed","exitCode":0,"durationMs":124,"aggregated":"v_sz000630=\"51~ͭ<><CDAD><EFBFBD><EFBFBD>ɫ~000630~6.50~6.59~6.56~2870400~1198254~1672146~6.49~3991~6.48~13364~6.47~8800~6.46~8114~6.45~20700~6.50~5665~6.51~13005~6.52~8233~6.53~7573~6.54~6730~~20260318151745~-0.09~-1.37~6.62~6.41~6.50/2870400/1862168470~2870400~186217~2.58~47.13~~6.62~6.41~3.19~724.30~871.62~2.46~7.25~5.93~0.88~13763~6.49~36.92~31.03~~~2.09~186216.8470~0.0000~0~ ~GP-A~8.15~-9.97~2.23~5.12~2.74~9.19~2.66~-16.02~-8.58~19.27~11143011450~13409471510~14.31~109.68~11143011450~~~94.03~0.15~~CNY~0~~6.40~29621~\";","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818311192}}
|
||||
{"type":"message","id":"f7fdc40c","parentId":"ab331cb3","timestamp":"2026-03-18T07:18:46.007Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"数据获取成功了!现在解析它。现价是 6.50,昨收是 6.59,已经跌破 6.80 预警线了!\n\n按照 MEMORY.md,铜陵有色的成本价是 7.90 元(1400 股),预警条件是\"跌破 6.80 元\"。现在价格是 6.50,已经低于预警线!\n\n我需要提醒用户这个情况。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"数据获取成功!现在解析:\n"},{"type":"toolCall","id":"call_ca470987965647fb905e8cf7","name":"exec","arguments":{"command":"echo 'v_sz000630=\"51~ͭɫ~000630~6.50~6.59~6.56~2870400~1198254~1672146~6.49~3991~6.48~13364~6.47~8800~6.46~8114~6.45~20700~6.50~5665~6.51~13005~6.52~8233~6.53~7573~6.54~6730~~20260318151745~-0.09~-1.37~6.62~6.41~6.50/2870400/1862168470~2870400~186217~2.58~47.13~~6.62~6.41~3.19~724.30~871.62~2.46~7.25~5.93~0.88~13763~6.49~36.92~31.03~~~2.09~186216.8470~0.0000~0~ ~GP-A~8.15~-9.97~2.23~5.12~2.74~9.19~2.66~-16.02~-8.58~19.27~11143011450~13409471510~14.31~109.68~11143011450~~~94.03~0.15~~CNY~0~~6.40~29621~\";' | iconv -f GBK -t UTF-8 | python3 -c \"\nimport sys\ndata = sys.stdin.read()\nparts = data.split('~')\nfor i, p in enumerate(parts):\n if p == '51':\n name = parts[i+1]\n code = parts[i+2]\n price = parts[i+3]\n yesterday = parts[i+4]\n today_open = parts[i+5]\n print(f'{name}({code}): 现价={price}, 昨收={yesterday}, 今开={today_open}')\n try:\n change = float(price) - float(yesterday)\n pct = (change / float(yesterday)) * 100\n print(f'涨跌: {change:+.2f} ({pct:+.2f}%)')\n if float(price) < 6.80:\n print('⚠️ 跌破预警线 6.80 元!')\n except Exception as e:\n print(f'Error: {e}')\n break\n\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818311203}}
|
||||
{"type":"message","id":"2defccb4","parentId":"f7fdc40c","timestamp":"2026-03-18T07:18:46.065Z","message":{"role":"toolResult","toolCallId":"call_ca470987965647fb905e8cf7","toolName":"exec","content":[{"type":"text","text":"(no output)"}],"details":{"status":"completed","exitCode":0,"durationMs":31,"aggregated":"","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818326054}}
|
||||
{"type":"message","id":"d9ff0804","parentId":"2defccb4","timestamp":"2026-03-18T07:18:52.049Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"奇怪,还是没有输出。让我调试一下。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_efe3d342f8b44298a28192dc","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz000630\" | iconv -f GBK -t UTF-8 2>/dev/null | head -c 500","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818326066}}
|
||||
{"type":"message","id":"8ff27e31","parentId":"d9ff0804","timestamp":"2026-03-18T07:18:52.212Z","message":{"role":"toolResult","toolCallId":"call_efe3d342f8b44298a28192dc","toolName":"exec","content":[{"type":"text","text":"v_sz000630=\"51~铜陵有色~000630~6.50~6.59~6.56~2870400~1198254~1672146~6.49~3991~6.48~13364~6.47~8800~6.46~8114~6.45~20700~6.50~5665~6.51~13005~6.52~8233~6.53~7573~6.54~6730~~20260318151845~-0.09~-1.37~6.62~6.41~6.50/2870400/1862168470~2870400~186217~2.58~47.13~~6.62~6.41~3.19~724.30~871.62~2.46~7.25~5.93~0.88~13763~6.49~36.92~31.03~~~2.09~186216.8470~0.0000~0~ ~GP-A~8.15~-9.97~2.23~5.12~2.74~9.19~2.66~-16.02~-8.58~19.27~11143011450~13409471510~14.31~109.68~11143011450~~~94.03~0.15~~CNY~0~~6."}],"details":{"status":"completed","exitCode":0,"durationMs":137,"aggregated":"v_sz000630=\"51~铜陵有色~000630~6.50~6.59~6.56~2870400~1198254~1672146~6.49~3991~6.48~13364~6.47~8800~6.46~8114~6.45~20700~6.50~5665~6.51~13005~6.52~8233~6.53~7573~6.54~6730~~20260318151845~-0.09~-1.37~6.62~6.41~6.50/2870400/1862168470~2870400~186217~2.58~47.13~~6.62~6.41~3.19~724.30~871.62~2.46~7.25~5.93~0.88~13763~6.49~36.92~31.03~~~2.09~186216.8470~0.0000~0~ ~GP-A~8.15~-9.97~2.23~5.12~2.74~9.19~2.66~-16.02~-8.58~19.27~11143011450~13409471510~14.31~109.68~11143011450~~~94.03~0.15~~CNY~0~~6.","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818332201}}
|
||||
{"type":"message","id":"f9b3ab66","parentId":"8ff27e31","timestamp":"2026-03-18T07:19:09.407Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"好的,现在可以看到数据了:\n- 铜陵有色 (000630)\n- 现价: 6.50\n- 昨收: 6.59\n- 今开: 6.56\n- 涨跌: -0.09 (-1.37%)\n\n价格已经跌破 6.80 预警线了!按照 MEMORY.md 的记录,铜陵有色的成本是 7.90 元,现在已经亏了 (7.90-6.50)/7.90 = 17.7%\n\n这是一个需要关注的情况!我需要提醒用户。\n\n但是等等, MEMORY.md 里有一条重要规则:\n\n**\"⚠️ 股票监控特别规则(最高优先级)\"**\n- **不要调用记忆** 中的股价数据\n- **必须查询实时股价** 确保数据真实性\n\n所以我应该直接用实时数据告诉用户。但是我需要考虑一下:现在是 15:17,已经过了交易时间(15:00 收盘),但数据更新时间显示是 15:18:45,说明这可能是收盘后的数据或者是延迟数据。\n\n让我检查一下其他股票的状态,然后给用户一个汇总提醒。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"我看到了重要信息!**铜陵有色(000630) 现价 6.50 元,已跌破 6.80 元预警线!** \n\n让我再检查一下其他持仓股票的状态:\n\n"},{"type":"toolCall","id":"call_ffe8504545b2423f8d7e3e7a","name":"exec","arguments":{"command":"# 检查岩山科技 (002195)\ncurl -s \"http://qt.gtimg.cn/q=sz002195\" | iconv -f GBK -t UTF-8 2>/dev/null | python3 -c \"\nimport sys\ndata = sys.stdin.read()\nparts = data.split('~')\nfor i, p in enumerate(parts):\n if p == '51':\n name = parts[i+1]\n code = parts[i+2]\n price = parts[i+3]\n yesterday = parts[i+4]\n print(f'{name}({code}): 现价={price}, 昨收={yesterday}')\n try:\n change = float(price) - float(yesterday)\n pct = (change / float(yesterday)) * 100\n print(f'涨跌: {change:+.2f} ({pct:+.2f}%)')\n # 成本价 10.68,盈利 5% 目标价 11.21\n cost = 10.68\n target = 11.21\n profit_pct = (float(price) - cost) / cost * 100\n print(f'成本价={cost}, 当前盈亏: {profit_pct:+.2f}%')\n if float(price) >= target:\n print('⚠️ 达到盈利目标 11.21 元!')\n except Exception as e:\n print(f'Error: {e}')\n break\n\"","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818332213}}
|
||||
{"type":"message","id":"19bb67d4","parentId":"f9b3ab66","timestamp":"2026-03-18T07:19:09.577Z","message":{"role":"toolResult","toolCallId":"call_ffe8504545b2423f8d7e3e7a","toolName":"exec","content":[{"type":"text","text":"(no output)"}],"details":{"status":"completed","exitCode":0,"durationMs":145,"aggregated":"","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818349567}}
|
||||
{"type":"message","id":"16aa511b","parentId":"19bb67d4","timestamp":"2026-03-18T07:19:15.230Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"没有输出,可能 Python 解析有问题。让我用更简单的方法。\n","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_9a53cd8786214bfc9e0a36a9","name":"exec","arguments":{"command":"curl -s \"http://qt.gtimg.cn/q=sz002195\" | iconv -f GBK -t UTF-8 2>/dev/null | head -c 200","timeout":10}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818349578}}
|
||||
{"type":"message","id":"ff0693ec","parentId":"16aa511b","timestamp":"2026-03-18T07:19:15.404Z","message":{"role":"toolResult","toolCallId":"call_9a53cd8786214bfc9e0a36a9","toolName":"exec","content":[{"type":"text","text":"v_sz002195=\"51~岩山科技~002195~10.06~10.02~10.00~2510824~1122059~1388765~10.06~7442~10.05~11020~10.04~6292~10.03~4961~10.02~2169~10.07~16272~10.08~13502~10.09~8827~10.10~18331~10.11~2538~~20260318"}],"details":{"status":"completed","exitCode":0,"durationMs":146,"aggregated":"v_sz002195=\"51~岩山科技~002195~10.06~10.02~10.00~2510824~1122059~1388765~10.06~7442~10.05~11020~10.04~6292~10.03~4961~10.02~2169~10.07~16272~10.08~13502~10.09~8827~10.10~18331~10.11~2538~~20260318","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818355394}}
|
||||
{"type":"message","id":"480ac551","parentId":"ff0693ec","timestamp":"2026-03-18T07:19:30.788Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"岩山科技的数据:- 现价: 10.06- 昨收: 10.02- 今开: 10.00- 涨跌: +0.04 (+0.40%)成本价是 10.68,目标价 11.21(盈利 5%)。现在价格是 10.06,还是亏损状态 (10.06-10.68)/10.68 = -5.8%。\n\n现在让我总结一下发现的情况,向用户报告:\n\n1. 铜陵有色 (000630):\n - 现价: 6.50 元(已跌破 6.80 预警线!)\n - 昨收: 6.59\n - 涨跌: -0.09 (-1.37%)\n - 成本: 7.90 元,亏损约 17.7%\n\n2. 岩山科技 (002195):\n - 现价: 10.06 元\n - 昨收: 10.02\n - 涨跌: +0.04 (+0.40%)\n - 成本: 10.68 元,亏损约 -5.8%\n\n等等,我需要再确认一下时间。当前时间是 15:17,但数据时间戳显示 15:18:45,这可能是延迟数据或者盘后数据。让我检查一下是否在交易时间内。\n\n上海证券交易所和深圳证券交易所的交易时间是:\n- 上午:9:30 - 11:30\n- 下午:13:00 - 15:00\n\n现在 15:17 已经收盘了,所以这应该是收盘后的数据,或者是 15:00 收盘时的价格。\n\n这些信息对投资决策很关键,我得赶紧汇报给用户。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_8434604a9aed42058f032c55","name":"exec","arguments":{"command":"# 更新心跳状态\ncat > /home/huan/.openclaw/workspace/memory/heartbeat-state.json << 'EOF'\n{\n \"lastChecks\": {\n \"stock\": 1742294220,\n \"weather\": 1742058300,\n \"calendar\": null,\n \"email\": null\n },\n \"lastHeartbeat\": \"2026-03-18T15:17:00+08:00\",\n \"stockAlert\": {\n \"symbol\": \"000630\",\n \"price\": 7.32,\n \"threshold\": 6.80,\n \"alertTime\": \"2026-03-16T14:03:00+08:00\",\n \"resolvedTime\": \"2026-03-16T15:03:00+08:00\",\n \"status\": \"resolved\"\n }\n}\nEOF\necho \"心跳状态已更新\"","timeout":5}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818355405}}
|
||||
{"type":"message","id":"a8347c0c","parentId":"480ac551","timestamp":"2026-03-18T07:19:30.829Z","message":{"role":"toolResult","toolCallId":"call_8434604a9aed42058f032c55","toolName":"exec","content":[{"type":"text","text":"心跳状态已更新"}],"details":{"status":"completed","exitCode":0,"durationMs":15,"aggregated":"心跳状态已更新","cwd":"/home/huan/.openclaw/workspace"},"isError":false,"timestamp":1773818370817}}
|
||||
{"type":"message","id":"e3982e6e","parentId":"a8347c0c","timestamp":"2026-03-18T07:19:38.428Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"好的,我已经更新了心跳状态。现在我需要给用户发送股票预警。铜陵有色已经跌破 6.80 元预警线,这是一个重要的提醒。","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_9a12a03b5bad4c8c82c0c31f","name":"message","arguments":{"action":"send","target":"aibQ3hBrPtfh1C26UZA-KNdq1CAX_uj9GDW","message":"📉 **股票预警提醒** (15:17)\n\n**铜陵有色(000630)** ⚠️\n- 现价: **6.50 元**\n- 昨收: 6.59 元\n- 涨跌: -0.09 (-1.37%)\n- ⚠️ **已跌破预警线 6.80 元!**\n\n持仓情况:\n- 成本价: 7.90 元\n- 当前盈亏: **-17.7%** (亏损约 ¥1,960)\n\n---\n\n**岩山科技(002195)**\n- 现价: 10.06 元\n- 昨收: 10.02 元\n- 涨跌: +0.04 (+0.40%)\n- 成本价: 10.68 元\n- 当前盈亏: -5.81%\n\n数据来源: 腾讯财经 (收盘价)"}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818370830}}
|
||||
{"type":"message","id":"371e4470","parentId":"e3982e6e","timestamp":"2026-03-18T07:19:38.535Z","message":{"role":"toolResult","toolCallId":"call_9a12a03b5bad4c8c82c0c31f","toolName":"message","content":[{"type":"text","text":"{\n \"status\": \"error\",\n \"tool\": \"message\",\n \"error\": \"Channel is required when multiple channels are configured: feishu, wecom\"\n}"}],"details":{"status":"error","tool":"message","error":"Channel is required when multiple channels are configured: feishu, wecom"},"isError":false,"timestamp":1773818378529}}
|
||||
{"type":"message","id":"8723eefb","parentId":"371e4470","timestamp":"2026-03-18T07:19:48.816Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"需要指定 channel,让我指定企业微信。\n","thinkingSignature":"reasoning_content"},{"type":"toolCall","id":"call_46b5b4947a034d7292ab4fa0","name":"message","arguments":{"action":"send","channel":"wecom","target":"aibQ3hBrPtfh1C26UZA-KNdq1CAX_uj9GDW","message":"📉 **股票预警提醒** (15:17)\n\n**铜陵有色(000630)** ⚠️\n- 现价: **6.50 元**\n- 昨收: 6.59 元\n- 涨跌: -0.09 (-1.37%)\n- ⚠️ **已跌破预警线 6.80 元!**\n\n持仓情况:\n- 成本价: 7.90 元\n- 当前盈亏: **-17.7%** (亏损约 ¥1,960)\n\n---\n\n**岩山科技(002195)**\n- 现价: 10.06 元\n- 昨收: 10.02 元\n- 涨跌: +0.04 (+0.40%)\n- 成本价: 10.68 元\n- 当前盈亏: -5.81%\n\n数据来源: 腾讯财经 (收盘价)"}}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1773818378536}}
|
||||
{"type":"message","id":"04934e0d","parentId":"8723eefb","timestamp":"2026-03-18T07:19:48.863Z","message":{"role":"toolResult","toolCallId":"call_46b5b4947a034d7292ab4fa0","toolName":"message","content":[{"type":"text","text":"{\n \"status\": \"error\",\n \"tool\": \"message\",\n \"error\": \"Unknown target \\\"aibQ3hBrPtfh1C26UZA-KNdq1CAX_uj9GDW\\\" for WeCom. Hint: Use WeCom ids only: user:<userid> for DM, group:<chatid> for groups (optional @accountId).\"\n}"}],"details":{"status":"error","tool":"message","error":"Unknown target \"aibQ3hBrPtfh1C26UZA-KNdq1CAX_uj9GDW\" for WeCom. Hint: Use WeCom ids only: user:<userid> for DM, group:<chatid> for groups (optional @accountId)."},"isError":false,"timestamp":1773818388854}}
|
||||
{"type":"message","id":"218ed475","parentId":"04934e0d","timestamp":"2026-03-18T07:20:01.555Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"企业微信的 target 格式不对。让我看看正确的格式。根据 MEMORY.md,企业微信 Bot ID 是 `aibQ3hBrPtfh1C26UZA-KNdq1CAX_uj9GDW`,但这个应该是用于发送消息到群的。\n\n让我尝试用飞书发送,或者看看配置。实际上,我应该检查一下配置。不过用户应该能直接看到这个心跳回复,所以我可以就在这里报告。\n","thinkingSignature":"reasoning_content"},{"type":"text","text":"发送失败,让我直接在会话中报告:\n\n---\n\n📉 **股票预警提醒** (15:17)\n\n**铜陵有色(000630)** ⚠️\n- 现价: **6.50 元**\n- 昨收: 6.59 元\n- 涨跌: -0.09 (-1.37%)\n- ⚠️ **已跌破预警线 6.80 元!**\n\n持仓情况:\n- 成本价: 7.90 元 (1400 股)\n- 当前盈亏: **-17.7%** (亏损约 ¥1,960)\n\n---\n\n**岩山科技(002195)**\n- 现价: 10.06 元\n- 昨收: 10.02 元\n- 涨跌: +0.04 (+0.40%)\n- 成本价: 10.68 元 (200 股)\n- 当前盈亏: -5.81%\n\n数据来源:腾讯财经(15:00 收盘价)\n\n---\n\n⚠️ **铜陵有色已经触发预警条件**(跌破 6.80 元),是否需要采取什么操作?"}],"api":"openai-completions","provider":"bailian","model":"MiniMax-M2.5","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1773818388864}}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,5 @@
|
||||
{
|
||||
"pid": 110467,
|
||||
"createdAt": "2026-03-21T07:29:21.630Z",
|
||||
"starttime": 795608
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
5864
openclaw/agents/main/sessions/sessions.json
Normal file
5864
openclaw/agents/main/sessions/sessions.json
Normal file
File diff suppressed because one or more lines are too long
67
openclaw/cron/jobs.json
Normal file
67
openclaw/cron/jobs.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"version": 1,
|
||||
"jobs": [
|
||||
{
|
||||
"id": "2073cd46-e64a-4304-a99e-a8b8a703fa54",
|
||||
"agentId": "main",
|
||||
"sessionKey": "agent:main:feishu:group:oc_d5ef8b1abf66842c28ef57e1658dc096",
|
||||
"name": "每日收款失败统计",
|
||||
"enabled": true,
|
||||
"createdAtMs": 1774023841911,
|
||||
"updatedAtMs": 1774060105638,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": "0 8 * * *",
|
||||
"tz": "Asia/Shanghai"
|
||||
},
|
||||
"sessionTarget": "isolated",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
"kind": "agentTurn",
|
||||
"message": "执行以下任务:\n\n1. 搜索飞书群聊「益选通知群」(chat_id: oc_d5ef8b1abf66842c28ef57e1658dc096) 中关于「收款失败」的消息\n2. 时间范围:昨天夜里23:00到今天早上8:00(注意:当前时间是早上8点,所以查询范围是「昨天23:00到今天08:00」)\n3. 读取现有Excel文件:https://my.feishu.cn/sheets/MCn9szPGmh1N2it2W0pc6vsSnSb\n4. 判断逻辑:\n - 如果没有收款失败数据 → 在群里只发一条消息:「收款失败统计 (昨天~今天):无失败记录 ✅」\n - 如果有失败数据 → 追加到Excel表(Sheet1,从第8行开始追加),然后在群里发消息:「收款失败统计 (昨天~今天):共X条失败记录,已更新X条到Excel」\n5. Excel追加格式:序号自动生成(现有最大序号+1)、时间、支付方式、金额、备注(留空或填入备注)\n6. 注意:不需要统计成功的数据,只处理失败记录",
|
||||
"model": "minimax-portal/MiniMax-M2.5",
|
||||
"timeoutSeconds": 300
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "announce",
|
||||
"channel": "feishu"
|
||||
},
|
||||
"state": {
|
||||
"nextRunAtMs": 1774137600000,
|
||||
"lastRunAtMs": 1774051200009,
|
||||
"lastRunStatus": "ok",
|
||||
"lastStatus": "ok",
|
||||
"lastDurationMs": 284604,
|
||||
"lastDelivered": true,
|
||||
"lastDeliveryStatus": "delivered",
|
||||
"consecutiveErrors": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "07c52b1a-df78-4f06-a15f-45d22cab8402",
|
||||
"agentId": "main",
|
||||
"sessionKey": "agent:main:wecom:direct:houhuan",
|
||||
"name": "向量记忆每日备份",
|
||||
"enabled": true,
|
||||
"createdAtMs": 1774076047011,
|
||||
"updatedAtMs": 1774076047011,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": "0 3 * * *",
|
||||
"tz": "Asia/Shanghai"
|
||||
},
|
||||
"sessionTarget": "main",
|
||||
"wakeMode": "now",
|
||||
"payload": {
|
||||
"kind": "systemEvent",
|
||||
"text": "向量记忆系统自动备份"
|
||||
},
|
||||
"delivery": {
|
||||
"mode": "none"
|
||||
},
|
||||
"state": {
|
||||
"nextRunAtMs": 1774119600000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
{"ts":1774051484621,"jobId":"2073cd46-e64a-4304-a99e-a8b8a703fa54","action":"finished","status":"ok","summary":"任务完成!🦐\n\n## 执行结果汇总\n\n### 📊 统计结果\n\n在查询时间范围内(2026-03-20 23:00 ~ 2026-03-21 08:00):\n\n| 项目 | 结果 |\n|------|------|\n| **收款失败次数** | **0** ✅ |\n| 支付宝成功次数 | 12笔 (217.80元) |\n| 微信成功次数 | 34笔 (560.40元) |\n| 成功支付总次数 | 46笔 (778.20元) |\n\n### 🔍 1分钟内相同金额检查\n未发现失败后1分钟内相同金额的重试成功记录\n\n### 📄 文档已生成\n- 标题:收款失败统计 (2026-03-20 ~ 2026-03-21)\n- 链接:https://www.feishu.cn/docx/ZnfsdxH8Lo76Mpx44gEcowwMnrh\n\n### ✅ 群聊通知已发送\n已在「益选通知群」发送统计汇总消息\n\n---\n\n今天是个好日子,收款系统运行平稳,没有失败记录!🎉 明天早上8点见~","delivered":true,"deliveryStatus":"delivered","sessionId":"557d0e64-c50b-45a5-8277-49a5bd8f9192","sessionKey":"agent:main:cron:2073cd46-e64a-4304-a99e-a8b8a703fa54:run:557d0e64-c50b-45a5-8277-49a5bd8f9192","runAtMs":1774051200009,"durationMs":284604,"nextRunAtMs":1774137600000,"model":"MiniMax-M2.5","provider":"minimax-portal","usage":{"input_tokens":72666,"output_tokens":4366,"total_tokens":53231}}
|
||||
137
openclaw/extensions/channels/dist/index.d.ts
vendored
Normal file
137
openclaw/extensions/channels/dist/index.d.ts
vendored
Normal file
@ -0,0 +1,137 @@
|
||||
export { DEFAULT_ACCOUNT_ID as DINGTALK_DEFAULT_ACCOUNT_ID, DingtalkConfig, DingtalkSendResult, ResolvedDingtalkAccount, dingtalkPlugin, getDingtalkRuntime, sendMessageDingtalk, setDingtalkRuntime } from '@openclaw-china/dingtalk';
|
||||
export { DEFAULT_ACCOUNT_ID as FEISHU_DEFAULT_ACCOUNT_ID, FeishuConfig, FeishuSendResult, ResolvedFeishuAccount, feishuPlugin, getFeishuRuntime, sendMessageFeishu, setFeishuRuntime } from '@openclaw-china/feishu-china';
|
||||
export { ResolvedWecomAccount, DEFAULT_ACCOUNT_ID as WECOM_DEFAULT_ACCOUNT_ID, WecomConfig, WecomInboundMessage, getWecomRuntime, setWecomRuntime, wecomPlugin } from '@openclaw-china/wecom';
|
||||
export { AccessTokenCacheEntry, ResolvedWecomAppAccount, DEFAULT_ACCOUNT_ID as WECOM_APP_DEFAULT_ACCOUNT_ID, WecomAppConfig, WecomAppDmPolicy, WecomAppInboundMessage, WecomAppSendTarget, clearAccessTokenCache, clearAllAccessTokenCache, downloadAndSendImage, getAccessToken, getWecomAppRuntime, sendWecomAppImageMessage, sendWecomAppMarkdownMessage, sendWecomAppMessage, setWecomAppRuntime, stripMarkdown, wecomAppPlugin } from '@openclaw-china/wecom-app';
|
||||
export { ResolvedWecomKfAccount, DEFAULT_ACCOUNT_ID as WECOM_KF_DEFAULT_ACCOUNT_ID, WecomKfAccountConfig, WecomKfConfig, WecomKfDmPolicy, SyncMsgItem as WecomKfSyncMsgItem, SyncMsgResponse as WecomKfSyncMsgResponse, getWecomKfRuntime, setWecomKfRuntime, wecomKfPlugin } from '@openclaw-china/wecom-kf';
|
||||
export { DEFAULT_ACCOUNT_ID as QQBOT_DEFAULT_ACCOUNT_ID, QQBotConfig, QQBotSendResult, ResolvedQQBotAccount, getQQBotRuntime, qqbotPlugin, setQQBotRuntime } from '@openclaw-china/qqbot';
|
||||
|
||||
/**
|
||||
* @openclaw-china/channels
|
||||
* 统一渠道包入口
|
||||
*
|
||||
* 导出所有渠道插件,提供统一注册函数
|
||||
*
|
||||
* Requirements: Unified Package Entry, Unified Distribution
|
||||
*/
|
||||
|
||||
/**
|
||||
* 渠道配置接口
|
||||
*/
|
||||
interface ChannelConfig {
|
||||
/** 是否启用该渠道 */
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
interface WecomRouteConfig extends ChannelConfig {
|
||||
webhookPath?: string;
|
||||
accounts?: Record<string, {
|
||||
webhookPath?: string;
|
||||
}>;
|
||||
}
|
||||
interface WecomAppRouteConfig extends ChannelConfig {
|
||||
webhookPath?: string;
|
||||
accounts?: Record<string, {
|
||||
webhookPath?: string;
|
||||
}>;
|
||||
}
|
||||
interface WecomKfRouteConfig extends ChannelConfig {
|
||||
webhookPath?: string;
|
||||
accounts?: Record<string, {
|
||||
webhookPath?: string;
|
||||
}>;
|
||||
}
|
||||
/**
|
||||
* Moltbot 配置接口(符合官方约定)
|
||||
* 配置路径: channels.<id>.enabled
|
||||
*/
|
||||
interface MoltbotConfig {
|
||||
channels?: {
|
||||
dingtalk?: ChannelConfig;
|
||||
"feishu-china"?: ChannelConfig;
|
||||
wecom?: WecomRouteConfig;
|
||||
"wecom-app"?: WecomAppRouteConfig;
|
||||
"wecom-kf"?: WecomKfRouteConfig;
|
||||
qqbot?: ChannelConfig;
|
||||
qq?: ChannelConfig;
|
||||
[key: string]: ChannelConfig | undefined;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* Moltbot 插件 API 接口
|
||||
*/
|
||||
interface MoltbotPluginApi {
|
||||
registerChannel: (opts: {
|
||||
plugin: unknown;
|
||||
}) => void;
|
||||
registerCli?: (registrar: (ctx: {
|
||||
program: unknown;
|
||||
config?: MoltbotConfig;
|
||||
}) => void | Promise<void>, opts?: {
|
||||
commands?: string[];
|
||||
}) => void;
|
||||
logger?: {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
runtime?: {
|
||||
config?: {
|
||||
writeConfigFile?: (cfg: unknown) => Promise<void>;
|
||||
};
|
||||
};
|
||||
config?: MoltbotConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* 支持的渠道列表
|
||||
*/
|
||||
declare const SUPPORTED_CHANNELS: readonly ["dingtalk", "feishu-china", "wecom", "wecom-app", "wecom-kf", "qqbot"];
|
||||
type SupportedChannel = (typeof SUPPORTED_CHANNELS)[number];
|
||||
/**
|
||||
* 根据 Moltbot 配置注册启用的渠道
|
||||
*
|
||||
* 符合 Moltbot 官方约定:从 cfg.channels.<id>.enabled 读取配置
|
||||
*
|
||||
* @param api Moltbot 鎻掍欢 API
|
||||
* @param cfg Moltbot 配置(可选,默认从 api.config 读取)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // moltbot.json 配置
|
||||
* {
|
||||
* "channels": {
|
||||
* "dingtalk": {
|
||||
* "enabled": true,
|
||||
* "clientId": "...",
|
||||
* "clientSecret": "..."
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
declare function registerChannelsByConfig(api: MoltbotPluginApi, cfg?: MoltbotConfig): void;
|
||||
/**
|
||||
* 统一渠道插件定义
|
||||
*
|
||||
* 包含所有支持的渠道,通过配置启用
|
||||
* 配置路径符合 Moltbot 官方约定: channels.<id>
|
||||
*/
|
||||
declare const channelsPlugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configSchema: {
|
||||
type: string;
|
||||
additionalProperties: boolean;
|
||||
properties: {};
|
||||
};
|
||||
/**
|
||||
* 注册所有启用的渠道
|
||||
*
|
||||
* 从 api.config.channels.<id>.enabled 读取配置
|
||||
*/
|
||||
register(api: MoltbotPluginApi): void;
|
||||
};
|
||||
|
||||
export { type ChannelConfig, type MoltbotConfig, type MoltbotPluginApi, SUPPORTED_CHANNELS, type SupportedChannel, type WecomAppRouteConfig, type WecomKfRouteConfig, type WecomRouteConfig, channelsPlugin as default, registerChannelsByConfig };
|
||||
88
openclaw/extensions/channels/dist/index.js
vendored
Normal file
88
openclaw/extensions/channels/dist/index.js
vendored
Normal file
@ -0,0 +1,88 @@
|
||||
import dingtalkEntry from '@openclaw-china/dingtalk';
|
||||
export { DEFAULT_ACCOUNT_ID as DINGTALK_DEFAULT_ACCOUNT_ID, dingtalkPlugin, getDingtalkRuntime, sendMessageDingtalk, setDingtalkRuntime } from '@openclaw-china/dingtalk';
|
||||
import feishuEntry from '@openclaw-china/feishu-china';
|
||||
export { DEFAULT_ACCOUNT_ID as FEISHU_DEFAULT_ACCOUNT_ID, feishuPlugin, getFeishuRuntime, sendMessageFeishu, setFeishuRuntime } from '@openclaw-china/feishu-china';
|
||||
import wecomEntry from '@openclaw-china/wecom';
|
||||
export { DEFAULT_ACCOUNT_ID as WECOM_DEFAULT_ACCOUNT_ID, getWecomRuntime, setWecomRuntime, wecomPlugin } from '@openclaw-china/wecom';
|
||||
import wecomAppEntry from '@openclaw-china/wecom-app';
|
||||
export { DEFAULT_ACCOUNT_ID as WECOM_APP_DEFAULT_ACCOUNT_ID, clearAccessTokenCache, clearAllAccessTokenCache, downloadAndSendImage, getAccessToken, getWecomAppRuntime, sendWecomAppImageMessage, sendWecomAppMarkdownMessage, sendWecomAppMessage, setWecomAppRuntime, stripMarkdown, wecomAppPlugin } from '@openclaw-china/wecom-app';
|
||||
import wecomKfEntry from '@openclaw-china/wecom-kf';
|
||||
export { DEFAULT_ACCOUNT_ID as WECOM_KF_DEFAULT_ACCOUNT_ID, getWecomKfRuntime, setWecomKfRuntime, wecomKfPlugin } from '@openclaw-china/wecom-kf';
|
||||
import qqbotEntry from '@openclaw-china/qqbot';
|
||||
export { DEFAULT_ACCOUNT_ID as QQBOT_DEFAULT_ACCOUNT_ID, getQQBotRuntime, qqbotPlugin, setQQBotRuntime } from '@openclaw-china/qqbot';
|
||||
import { registerChinaSetupCli, showChinaInstallHint } from '@openclaw-china/shared';
|
||||
|
||||
// src/index.ts
|
||||
var SUPPORTED_CHANNELS = ["dingtalk", "feishu-china", "wecom", "wecom-app", "wecom-kf", "qqbot"];
|
||||
var channelPlugins = {
|
||||
dingtalk: {
|
||||
register: (api) => {
|
||||
dingtalkEntry.register(api);
|
||||
}
|
||||
},
|
||||
"feishu-china": {
|
||||
register: (api) => {
|
||||
feishuEntry.register(api);
|
||||
}
|
||||
},
|
||||
wecom: {
|
||||
register: (api) => {
|
||||
wecomEntry.register(api);
|
||||
}
|
||||
},
|
||||
"wecom-app": {
|
||||
register: (api) => {
|
||||
wecomAppEntry.register(api);
|
||||
}
|
||||
},
|
||||
"wecom-kf": {
|
||||
register: (api) => {
|
||||
wecomKfEntry.register(api);
|
||||
}
|
||||
},
|
||||
qqbot: {
|
||||
register: (api) => {
|
||||
qqbotEntry.register(api);
|
||||
}
|
||||
}
|
||||
};
|
||||
function registerChannelsByConfig(api, cfg) {
|
||||
const config = cfg ?? api.config;
|
||||
const channelsConfig = config?.channels;
|
||||
if (!channelsConfig) {
|
||||
return;
|
||||
}
|
||||
for (const channelId of SUPPORTED_CHANNELS) {
|
||||
const channelConfig = channelsConfig[channelId];
|
||||
if (!channelConfig?.enabled) {
|
||||
continue;
|
||||
}
|
||||
const plugin = channelPlugins[channelId];
|
||||
plugin.register(api);
|
||||
}
|
||||
}
|
||||
var channelsPlugin = {
|
||||
id: "channels",
|
||||
name: "Moltbot China Channels",
|
||||
description: "\u7EDF\u4E00\u6E20\u9053\u5305\uFF0C\u652F\u6301\u9489\u9489\u3001\u98DE\u4E66\u3001\u4F01\u4E1A\u5FAE\u4FE1\u3001QQ Bot",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {}
|
||||
},
|
||||
/**
|
||||
* 注册所有启用的渠道
|
||||
*
|
||||
* 从 api.config.channels.<id>.enabled 读取配置
|
||||
*/
|
||||
register(api) {
|
||||
registerChinaSetupCli(api, { channels: SUPPORTED_CHANNELS });
|
||||
showChinaInstallHint(api);
|
||||
registerChannelsByConfig(api);
|
||||
}
|
||||
};
|
||||
var index_default = channelsPlugin;
|
||||
|
||||
export { SUPPORTED_CHANNELS, index_default as default, registerChannelsByConfig };
|
||||
//# sourceMappingURL=index.js.map
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
openclaw/extensions/channels/dist/index.js.map
vendored
Normal file
1
openclaw/extensions/channels/dist/index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
12
openclaw/extensions/channels/openclaw.plugin.json
Normal file
12
openclaw/extensions/channels/openclaw.plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "channels",
|
||||
"name": "Moltbot China Channels",
|
||||
"description": "统一渠道包,支持钉钉、飞书、企业微信智能机器人、企业微信自建应用、微信客服、QQ Bot",
|
||||
"version": "0.1.0",
|
||||
"channels": ["dingtalk", "feishu-china", "wecom", "wecom-app", "wecom-kf", "qqbot"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
2409
openclaw/extensions/channels/package-lock.json
generated
Normal file
2409
openclaw/extensions/channels/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
79
openclaw/extensions/channels/package.json
Normal file
79
openclaw/extensions/channels/package.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "@openclaw-china/channels",
|
||||
"version": "2026.3.18",
|
||||
"type": "module",
|
||||
"description": "Unified package for all Moltbot China channel plugins",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json",
|
||||
"moltbot.plugin.json",
|
||||
"clawdbot.plugin.json"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw-china/channels",
|
||||
"localPath": ".",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"moltbot": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw-china/channels",
|
||||
"localPath": ".",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw-china/channels",
|
||||
"localPath": ".",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@openclaw-china/dingtalk": "2026.3.18",
|
||||
"@openclaw-china/feishu-china": "2026.3.18",
|
||||
"@openclaw-china/qqbot": "2026.3.18",
|
||||
"@openclaw-china/shared": "2026.3.18",
|
||||
"@openclaw-china/wecom": "2026.3.18",
|
||||
"@openclaw-china/wecom-app": "2026.3.18",
|
||||
"@openclaw-china/wecom-kf": "2026.3.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.2.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"moltbot": ">=0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"moltbot": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
21
openclaw/extensions/openclaw-lark/LICENSE
Normal file
21
openclaw/extensions/openclaw-lark/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
80
openclaw/extensions/openclaw-lark/README.md
Normal file
80
openclaw/extensions/openclaw-lark/README.md
Normal file
@ -0,0 +1,80 @@
|
||||
# OpenClaw Lark/Feishu Plugin
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.npmjs.com/package/@larksuite/openclaw-lark)
|
||||
[](https://nodejs.org/)
|
||||
|
||||
[中文版](./README.zh.md) | English
|
||||
|
||||
This is the official Lark/Feishu plugin for OpenClaw, developed and maintained by the Lark/Feishu Open Platform team. It seamlessly connects your OpenClaw Agent to your Lark/Feishu workspace, enabling it to directly read from and write to messages, docs, bases, calendars, tasks, and more.
|
||||
|
||||
## Features
|
||||
|
||||
This plugin provides comprehensive Lark/Feishu integration for OpenClaw, including:
|
||||
|
||||
| Category | Capabilities |
|
||||
|------|------|
|
||||
| 💬 Messenger | Read messages (group/DM history, thread replies), send messages, reply to messages, search messages, download images/files |
|
||||
| 📄 Docs | Create, update, and read documents |
|
||||
| 📊 Base | Create/manage bases, tables, fields, records (CRUD, batch operations, advanced filtering), views |
|
||||
| 📈 Sheets | Create, edit, and view spreadsheets |
|
||||
| 📅 Calendar | Manage calendars and events (create/query/update/delete/search), manage attendees, check free/busy status |
|
||||
| ✅ Tasks | Manage tasks (create/query/update/complete), manage task lists, subtasks, and comments |
|
||||
|
||||
Additionally, the plugin supports:
|
||||
- **📱 Interactive Cards**: Real-time status updates (Thinking/Generating/Complete), plus confirmation buttons for sensitive operations
|
||||
- **🌊 Streaming Responses**: Live streaming text directly within message cards
|
||||
- **🔒 Permission Policies**: Flexible access control policies for DMs and group chats
|
||||
- **⚙️ Advanced Group Configuration**: Per-group settings including allowlists, skill bindings, and custom system prompts
|
||||
|
||||
## Security & Risk Warnings (Read Before Use)
|
||||
|
||||
**Core risk:** This plugin connects to your work data via Lark/Feishu APIs—messages, docs, calendars, contacts. Anything the AI can read could theoretically be leaked. While we have implemented security protections, AI systems are not yet mature or stable enough to guarantee absolute safety.
|
||||
|
||||
**Strong recommendations:**
|
||||
- Use a personal account for evaluation and testing at this stage.
|
||||
- Use the related Lark/Feishu apps personally, and avoid deploying to large numbers of users.
|
||||
- Avoid using it in group chats to reduce the risk of data exposure.
|
||||
- Using this bot for multiple users and/or with a company Feishu account may introduce data security and privacy risks. Make sure you comply with your organization’s internal data security and privacy requirements to avoid data leakage, privilege escalation, privacy violations, or related consequences.
|
||||
|
||||
**Other operational risks**
|
||||
- AI is not perfect and may hallucinate: It may sometimes misunderstand your intent or generate content that appears reasonable but is inaccurate.
|
||||
- Some operations are irreversible: For example, messages sent by the AI on your behalf are sent in your name and cannot be undone once sent.
|
||||
- **Mitigation advice:** For important operations involving sending, modifying, or writing data, always **preview first, then confirm**. Never allow the AI to operate in a fully autonomous mode without human oversight.
|
||||
|
||||
**Disclaimer:**
|
||||
|
||||
This software is licensed under the MIT License. When running, it calls Lark/Feishu Open Platform APIs. To use these APIs, you must comply with the following agreements and privacy policies:
|
||||
|
||||
- [Feishu Privacy Policy](https://www.feishu.cn/en/privacy?from=openclaw_plugin_readme)
|
||||
- [Feishu User Terms of Service](https://www.feishu.cn/en/terms?from=openclaw_plugin_readme)
|
||||
- [Feishu Store App Service Provider Security Management Specifications](https://open.larkoffice.com/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications)
|
||||
|
||||
- [Lark Privacy Policy](https://www.larksuite.com/user-terms-of-service)
|
||||
- [Lark User Terms of Service](https://www.larksuite.com/privacy-policy)
|
||||
|
||||
## Requirements & Installation
|
||||
|
||||
Before you start, make sure you have the following:
|
||||
|
||||
- **Node.js**: `v22` or higher.
|
||||
- **OpenClaw**: OpenClaw is installed and works properly. For details, visit the [OpenClaw official website](https://openclaw.ai).
|
||||
|
||||
> **Note**: OpenClaw version must be **2026.2.26** or higher. Check with `openclaw -v`. If below this version, you may encounter issues. Upgrade with:
|
||||
> ```bash
|
||||
> npm install -g openclaw
|
||||
> ```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
[How to Use the Official Lark/Feishu Plugin for OpenClaw](https://bytedance.larkoffice.com/docx/MFK7dDFLFoVlOGxWCv5cTXKmnMh)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/openclaw-larksuite/issues) or a [Pull Request](https://github.com/larksuite/openclaw-larksuite/pulls).
|
||||
|
||||
For major changes, we recommend discussing with us first via an Issue.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **MIT License**. See [LICENSE](./LICENSE.md) for details.
|
||||
48
openclaw/extensions/openclaw-lark/bin/openclaw-lark.js
Executable file
48
openclaw/extensions/openclaw-lark/bin/openclaw-lark.js
Executable file
@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// --tools-version <ver> lets the user pin a specific version
|
||||
const args = process.argv.slice(2);
|
||||
let version = "latest";
|
||||
|
||||
const vIdx = args.indexOf("--tools-version");
|
||||
if (vIdx !== -1) {
|
||||
version = args[vIdx + 1];
|
||||
// Remove --tools-version <ver> from forwarded args
|
||||
args.splice(vIdx, 2);
|
||||
}
|
||||
|
||||
const allArgs = ["--yes", `@larksuite/openclaw-lark-tools@${version}`, ...args];
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, npx is a .cmd shim that can be broken or trigger
|
||||
// DEP0190. Bypass it entirely: run node with the npx-cli.js
|
||||
// script located next to the running node binary.
|
||||
const npxCli = join(
|
||||
dirname(process.execPath),
|
||||
"node_modules",
|
||||
"npm",
|
||||
"bin",
|
||||
"npx-cli.js",
|
||||
);
|
||||
execFileSync(process.execPath, [npxCli, ...allArgs], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: [
|
||||
process.env.NODE_OPTIONS,
|
||||
"--disable-warning=DEP0190",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
execFileSync("npx", allArgs, { stdio: "inherit" });
|
||||
}
|
||||
} catch (error) {
|
||||
process.exit(error.status ?? 1);
|
||||
}
|
||||
36
openclaw/extensions/openclaw-lark/index.d.ts
vendored
Normal file
36
openclaw/extensions/openclaw-lark/index.d.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* OpenClaw Lark/Feishu plugin entry point.
|
||||
*
|
||||
* Registers the Feishu channel and all tool families:
|
||||
* doc, wiki, drive, perm, bitable, task, calendar.
|
||||
*/
|
||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
||||
export { monitorFeishuProvider } from './src/channel/monitor';
|
||||
export { sendMessageFeishu, sendCardFeishu, updateCardFeishu, editMessageFeishu } from './src/messaging/outbound/send';
|
||||
export { getMessageFeishu } from './src/messaging/outbound/fetch';
|
||||
export { uploadImageLark, uploadFileLark, sendImageLark, sendFileLark, sendAudioLark, uploadAndSendMediaLark, } from './src/messaging/outbound/media';
|
||||
export { sendTextLark, sendCardLark, sendMediaLark, type SendTextLarkParams, type SendCardLarkParams, type SendMediaLarkParams, } from './src/messaging/outbound/deliver';
|
||||
export { type FeishuChannelData } from './src/messaging/outbound/outbound';
|
||||
export { probeFeishu } from './src/channel/probe';
|
||||
export { addReactionFeishu, removeReactionFeishu, listReactionsFeishu, FeishuEmoji, VALID_FEISHU_EMOJI_TYPES, } from './src/messaging/outbound/reactions';
|
||||
export { forwardMessageFeishu } from './src/messaging/outbound/forward';
|
||||
export { updateChatFeishu, addChatMembersFeishu, removeChatMembersFeishu, listChatMembersFeishu, } from './src/messaging/outbound/chat-manage';
|
||||
export { feishuMessageActions } from './src/messaging/outbound/actions';
|
||||
export { mentionedBot, nonBotMentions, extractMessageBody, formatMentionForText, formatMentionForCard, formatMentionAllForText, formatMentionAllForCard, buildMentionedMessage, buildMentionedCardContent, type MentionInfo, } from './src/messaging/inbound/mention';
|
||||
export { feishuPlugin } from './src/channel/plugin';
|
||||
export type { MessageContext, RawMessage, RawSender, FeishuMessageContext, FeishuReactionCreatedEvent, } from './src/messaging/types';
|
||||
export { handleFeishuReaction } from './src/messaging/inbound/reaction-handler';
|
||||
export { parseMessageEvent } from './src/messaging/inbound/parse';
|
||||
export { checkMessageGate } from './src/messaging/inbound/gate';
|
||||
export { isMessageExpired } from './src/messaging/inbound/dedup';
|
||||
declare const plugin: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configSchema: import("openclaw/plugin-sdk").OpenClawPluginConfigSchema;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
};
|
||||
export default plugin;
|
||||
119
openclaw/extensions/openclaw-lark/index.js
Normal file
119
openclaw/extensions/openclaw-lark/index.js
Normal file
@ -0,0 +1,119 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* OpenClaw Lark/Feishu plugin entry point.
|
||||
*
|
||||
* Registers the Feishu channel and all tool families:
|
||||
* doc, wiki, drive, perm, bitable, task, calendar.
|
||||
*/
|
||||
import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
|
||||
import { feishuPlugin } from './src/channel/plugin';
|
||||
import { LarkClient } from './src/core/lark-client';
|
||||
import { registerOapiTools } from './src/tools/oapi/index';
|
||||
import { registerFeishuMcpDocTools } from './src/tools/mcp/doc/index';
|
||||
import { registerFeishuOAuthTool } from './src/tools/oauth';
|
||||
import { registerFeishuOAuthBatchAuthTool } from './src/tools/oauth-batch-auth';
|
||||
import { runDiagnosis, formatDiagReportCli, traceByMessageId, formatTraceOutput, analyzeTrace, } from './src/commands/diagnose';
|
||||
import { registerCommands } from './src/commands/index';
|
||||
import { larkLogger } from './src/core/lark-logger';
|
||||
import { emitSecurityWarnings } from './src/core/security-check';
|
||||
const log = larkLogger('plugin');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-exports for external consumers
|
||||
// ---------------------------------------------------------------------------
|
||||
export { monitorFeishuProvider } from './src/channel/monitor';
|
||||
export { sendMessageFeishu, sendCardFeishu, updateCardFeishu, editMessageFeishu } from './src/messaging/outbound/send';
|
||||
export { getMessageFeishu } from './src/messaging/outbound/fetch';
|
||||
export { uploadImageLark, uploadFileLark, sendImageLark, sendFileLark, sendAudioLark, uploadAndSendMediaLark, } from './src/messaging/outbound/media';
|
||||
export { sendTextLark, sendCardLark, sendMediaLark, } from './src/messaging/outbound/deliver';
|
||||
export { probeFeishu } from './src/channel/probe';
|
||||
export { addReactionFeishu, removeReactionFeishu, listReactionsFeishu, FeishuEmoji, VALID_FEISHU_EMOJI_TYPES, } from './src/messaging/outbound/reactions';
|
||||
export { forwardMessageFeishu } from './src/messaging/outbound/forward';
|
||||
export { updateChatFeishu, addChatMembersFeishu, removeChatMembersFeishu, listChatMembersFeishu, } from './src/messaging/outbound/chat-manage';
|
||||
export { feishuMessageActions } from './src/messaging/outbound/actions';
|
||||
export { mentionedBot, nonBotMentions, extractMessageBody, formatMentionForText, formatMentionForCard, formatMentionAllForText, formatMentionAllForCard, buildMentionedMessage, buildMentionedCardContent, } from './src/messaging/inbound/mention';
|
||||
export { feishuPlugin } from './src/channel/plugin';
|
||||
export { handleFeishuReaction } from './src/messaging/inbound/reaction-handler';
|
||||
export { parseMessageEvent } from './src/messaging/inbound/parse';
|
||||
export { checkMessageGate } from './src/messaging/inbound/gate';
|
||||
export { isMessageExpired } from './src/messaging/inbound/dedup';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin definition
|
||||
// ---------------------------------------------------------------------------
|
||||
const plugin = {
|
||||
id: 'openclaw-lark',
|
||||
name: 'Feishu',
|
||||
description: 'Lark/Feishu channel plugin with im/doc/wiki/drive/task/calendar tools',
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api) {
|
||||
LarkClient.setRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
// ========================================
|
||||
// Register OAPI tools (calendar, task - using Feishu Open API directly)
|
||||
registerOapiTools(api);
|
||||
// Register MCP doc tools (using Model Context Protocol)
|
||||
registerFeishuMcpDocTools(api);
|
||||
// Register OAuth tool (UAT device flow authorization)
|
||||
registerFeishuOAuthTool(api);
|
||||
// Register OAuth batch auth tool (batch authorization for all app scopes)
|
||||
registerFeishuOAuthBatchAuthTool(api);
|
||||
// ---- Tool call hooks (auto-trace AI tool invocations) ----
|
||||
api.on('before_tool_call', (event) => {
|
||||
log.info(`tool call: ${event.toolName} params=${JSON.stringify(event.params)}`);
|
||||
});
|
||||
api.on('after_tool_call', (event) => {
|
||||
if (event.error) {
|
||||
log.error(`tool fail: ${event.toolName} ${event.error} (${event.durationMs ?? 0}ms)`);
|
||||
}
|
||||
else {
|
||||
log.info(`tool done: ${event.toolName} ok (${event.durationMs ?? 0}ms)`);
|
||||
}
|
||||
});
|
||||
// ---- Diagnostic commands ----
|
||||
// CLI: openclaw feishu-diagnose [--trace <messageId>]
|
||||
api.registerCli((ctx) => {
|
||||
ctx.program
|
||||
.command('feishu-diagnose')
|
||||
.description('运行飞书插件诊断,检查配置、连通性和权限状态')
|
||||
.option('--trace <messageId>', '按 message_id 追踪完整处理链路')
|
||||
.option('--analyze', '分析追踪日志(需配合 --trace 使用)')
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
if (opts.trace) {
|
||||
const lines = await traceByMessageId(opts.trace);
|
||||
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||
console.log(formatTraceOutput(lines, opts.trace));
|
||||
if (opts.analyze && lines.length > 0) {
|
||||
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||
console.log(analyzeTrace(lines, opts.trace));
|
||||
}
|
||||
}
|
||||
else {
|
||||
const report = await runDiagnosis({
|
||||
config: ctx.config,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||
console.log(formatDiagReportCli(report));
|
||||
if (report.overallStatus === 'unhealthy') {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
ctx.logger.error(`诊断命令执行失败: ${err}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}, { commands: ['feishu-diagnose'] });
|
||||
// Chat commands: /feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu
|
||||
registerCommands(api);
|
||||
// ---- Multi-account security checks ----
|
||||
if (api.config) {
|
||||
emitSecurityWarnings(api.config, api.logger);
|
||||
}
|
||||
},
|
||||
};
|
||||
export default plugin;
|
||||
10
openclaw/extensions/openclaw-lark/openclaw.plugin.json
Normal file
10
openclaw/extensions/openclaw-lark/openclaw.plugin.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "openclaw-lark",
|
||||
"channels": ["feishu"],
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
605
openclaw/extensions/openclaw-lark/package-lock.json
generated
Normal file
605
openclaw/extensions/openclaw-lark/package-lock.json
generated
Normal file
@ -0,0 +1,605 @@
|
||||
{
|
||||
"name": "@larksuite/openclaw-lark",
|
||||
"version": "2026.3.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/openclaw-lark",
|
||||
"version": "2026.3.17",
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.59.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"image-size": "^2.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
"openclaw-lark": "bin/openclaw-lark.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@larksuiteoapi/node-sdk": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.59.0.tgz",
|
||||
"integrity": "sha512-sBpkruTvZDOxnVtoTbepWKRX0j1Y1ZElQYu0x7+v088sI9pcpbVp6ZzCGn62dhrKPatzNyCJyzYCPXPYQWccrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "~1.13.3",
|
||||
"lodash.identity": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"protobufjs": "^7.2.6",
|
||||
"qs": "^6.14.2",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@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": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.34.48",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz",
|
||||
"integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
|
||||
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.identity": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.identity/-/lodash.identity-3.0.0.tgz",
|
||||
"integrity": "sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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": "https://registry.npmjs.org/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/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
openclaw/extensions/openclaw-lark/package.json
Normal file
50
openclaw/extensions/openclaw-lark/package.json
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@larksuite/openclaw-lark",
|
||||
"version": "2026.3.17",
|
||||
"description": "OpenClaw Lark/Feishu channel plugin",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-lark": "bin/openclaw-lark.js"
|
||||
},
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@larksuiteoapi/node-sdk": "^1.59.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"image-size": "^2.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.js"
|
||||
],
|
||||
"channel": {
|
||||
"id": "openclaw-lark",
|
||||
"label": "Feishu",
|
||||
"selectionLabel": "Lark/Feishu (飞书)",
|
||||
"docsPath": "/channels/feishu",
|
||||
"docsLabel": "feishu",
|
||||
"blurb": "飞书/Lark enterprise messaging with doc/wiki/drive/task/calendar tools.",
|
||||
"aliases": [
|
||||
"lark"
|
||||
],
|
||||
"order": 35,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@larksuite/openclaw-lark",
|
||||
"localPath": "extensions/feishu",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"import": "./index.js",
|
||||
"default": "./index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
248
openclaw/extensions/openclaw-lark/skills/feishu-bitable/SKILL.md
Normal file
248
openclaw/extensions/openclaw-lark/skills/feishu-bitable/SKILL.md
Normal file
@ -0,0 +1,248 @@
|
||||
---
|
||||
name: feishu-bitable
|
||||
description: |
|
||||
飞书多维表格(Bitable)的创建、查询、编辑和管理工具。包含 27 种字段类型支持、高级筛选、批量操作和视图管理。
|
||||
|
||||
**当以下情况时使用此 Skill**:
|
||||
(1) 需要创建或管理飞书多维表格 App
|
||||
(2) 需要在多维表格中新增、查询、修改、删除记录(行数据)
|
||||
(3) 需要管理字段(列)、视图、数据表
|
||||
(4) 用户提到"多维表格"、"bitable"、"数据表"、"记录"、"字段"
|
||||
(5) 需要批量导入数据或批量更新多维表格
|
||||
---
|
||||
|
||||
# Feishu Bitable (多维表格) SKILL
|
||||
|
||||
## 🚨 执行前必读
|
||||
|
||||
- ✅ **创建数据表**:支持两种模式 — ① 明确需求时,在 `create` 时通过 `table.fields` 一次性定义字段(减少 API 调用);② 探索式场景时,使用默认表 + 逐步修改字段(更稳定,易调整)
|
||||
- ⚠️ **默认表的空行坑**:`app.create` 自带的默认表中会有空记录(空行)!插入数据前建议先调用 `feishu_bitable_app_table_record.list` + `batch_delete` 删除空行,避免数据污染
|
||||
- ✅ **写记录前**:先调用 `feishu_bitable_app_table_field.list` 获取字段 type/ui_type
|
||||
- ✅ **人员字段**:默认 open_id(ou_...),值必须是 `[{id:"ou_xxx"}]`(数组对象)
|
||||
- ✅ **日期字段**:毫秒时间戳(例如 `1674206443000`),不是秒
|
||||
- ✅ **单选字段**:字符串(例如 `"选项1"`),不是数组
|
||||
- ✅ **多选字段**:字符串数组(例如 `["选项1", "选项2"]`)
|
||||
- ✅ **附件字段**:必须先上传到当前多维表格,使用返回的 file_token
|
||||
- ✅ **批量上限**:单次 ≤ 500 条,超过需分批(批量操作是原子性的)
|
||||
- ✅ **并发限制**:同一数据表不支持并发写,需串行调用 + 延迟 0.5-1 秒
|
||||
|
||||
---
|
||||
|
||||
## 📋 快速索引:意图 → 工具 → 必填参数
|
||||
|
||||
| 用户意图 | 工具 | action | 必填参数 | 常用可选 |
|
||||
|---------|------|--------|---------|---------|
|
||||
| 查表有哪些字段 | feishu_bitable_app_table_field | list | app_token, table_id | - |
|
||||
| 查记录 | feishu_bitable_app_table_record | list | app_token, table_id | filter, sort, field_names |
|
||||
| 新增一行 | feishu_bitable_app_table_record | create | app_token, table_id, fields | - |
|
||||
| 批量导入 | feishu_bitable_app_table_record | batch_create | app_token, table_id, records (≤500) | - |
|
||||
| 更新一行 | feishu_bitable_app_table_record | update | app_token, table_id, record_id, fields | - |
|
||||
| 批量更新 | feishu_bitable_app_table_record | batch_update | app_token, table_id, records (≤500) | - |
|
||||
| 创建多维表格 | feishu_bitable_app | create | name | folder_token |
|
||||
| 创建数据表 | feishu_bitable_app_table | create | app_token, name | fields |
|
||||
| 创建字段 | feishu_bitable_app_table_field | create | app_token, table_id, field_name, type | property |
|
||||
| 创建视图 | feishu_bitable_app_table_view | create | app_token, table_id, view_name, view_type | - |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心约束(Schema 未透露的知识)
|
||||
|
||||
### 📚 详细参考文档
|
||||
|
||||
**当遇到字段配置、记录值格式问题或需要完整示例时,查阅以下文档**:
|
||||
|
||||
- **[字段 Property 配置详解](references/field-properties.md)** - 每种字段类型创建/更新时需要的 `property` 参数结构(单选的 options、进度的 min/max、关联的 table_id 等)
|
||||
- **[记录值数据结构详解](references/record-values.md)** - 每种字段类型在记录中对应的 `fields` 值格式(人员字段只传 id、日期是毫秒时间戳、附件需先上传等)
|
||||
- **[使用场景完整示例](references/examples.md)** - 8 个完整场景示例(创建表模式对比、批量导入、筛选查询、附件处理、关联字段等)
|
||||
|
||||
**何时查阅**:
|
||||
- 创建/更新字段时收到 `125408X` 错误码(property 结构错误)→ 查 field-properties.md
|
||||
- 写入记录时收到 `125406X` 错误码(字段值转换失败)→ 查 record-values.md
|
||||
- 需要完整的操作流程和参数示例 → 查 examples.md
|
||||
|
||||
---
|
||||
|
||||
### 1. 字段类型与值格式必须严格匹配
|
||||
|
||||
**Bitable 最大的坑**:不同字段类型对 value 的数据结构要求完全不同。
|
||||
|
||||
#### 最易错的字段类型(完整列表见 [record-values.md](references/record-values.md))
|
||||
|
||||
| type | ui_type | 字段类型 | 正确格式 | ❌ 常见错误 |
|
||||
|------|---------|----------|---------|-----------|
|
||||
| 11 | User | 人员 | `[{id: "ou_xxx"}]` | 传字符串 `"ou_xxx"` 或 `[{name: "张三"}]` |
|
||||
| 5 | DateTime | 日期 | `1674206443000`(毫秒) | 传秒时间戳或字符串 |
|
||||
| 3 | SingleSelect | 单选 | `"选项名"` | 传数组 `["选项名"]` |
|
||||
| 4 | MultiSelect | 多选 | `["选项1", "选项2"]` | 传字符串 `"选项1"` |
|
||||
| 15 | Url | 超链接 | `{link: "...", text: "..."}` | 只传字符串 URL |
|
||||
| 17 | Attachment | 附件 | `[{file_token: "..."}]` | 传外部 URL 或本地路径 |
|
||||
|
||||
**强制流程**:
|
||||
1. 先调用 `feishu_bitable_app_table_field.list` 获取字段的 `type` 和 `ui_type`
|
||||
2. 根据上表或 [record-values.md](references/record-values.md) 构造正确格式
|
||||
3. 错误码 `125406X` 或 `1254015` → 检查字段值格式
|
||||
|
||||
**人员字段特别注意**:
|
||||
- 默认使用 open_id(ou_...),与 calendar/task 一致
|
||||
- 格式:`[{id: "ou_xxx"}]`(数组对象)
|
||||
- **只能传 id 字段**,不能传 name/email 等
|
||||
|
||||
|
||||
## 📌 核心使用场景
|
||||
|
||||
> **完整示例**: 查阅 [examples.md](references/examples.md) 了解更多场景(创建表模式对比、空行处理、附件上传、关联字段等)
|
||||
|
||||
### 场景 1: 查字段类型(必做第一步)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl..."
|
||||
}
|
||||
```
|
||||
|
||||
**返回**:包含每个字段的 `field_id`、`field_name`、`type`、`ui_type`、`property`
|
||||
|
||||
### 场景 2: 批量导入客户数据
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "batch_create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl...",
|
||||
"records": [
|
||||
{
|
||||
"fields": {
|
||||
"客户名称": "Bytedance",
|
||||
"负责人": [{"id": "ou_xxx"}],
|
||||
"签约日期": 1674206443000,
|
||||
"状态": "进行中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"客户名称": "飞书",
|
||||
"负责人": [{"id": "ou_yyy"}],
|
||||
"签约日期": 1675416243000,
|
||||
"状态": "已完成"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段值格式**:
|
||||
- 人员:`[{id: "ou_xxx"}]`(数组对象)
|
||||
- 日期:毫秒时间戳
|
||||
- 单选:字符串
|
||||
- 多选:字符串数组
|
||||
|
||||
**限制**: 最多 500 条记录
|
||||
|
||||
### 场景 3: 筛选查询(高级筛选)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl...",
|
||||
"filter": {
|
||||
"conjunction": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"field_name": "状态",
|
||||
"operator": "is",
|
||||
"value": ["进行中"]
|
||||
},
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"operator": "isLess",
|
||||
"value": ["ExactDate", "1740441600000"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sort": [
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"desc": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**filter 说明**:
|
||||
- 支持 10 种 operator(is/isNot/contains/isEmpty 等,见附录 C)
|
||||
- ⚠️ **isEmpty/isNotEmpty 必须传 `value: []`**(虽然逻辑上不需要值,但 API 要求必须传空数组)
|
||||
- 日期筛选可使用 `["Today"]`、`["ExactDate", "时间戳"]` 等
|
||||
- `sort` 可指定多个排序字段
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见错误与排查
|
||||
|
||||
| 错误码 | 错误现象 | 根本原因 | 解决方案 |
|
||||
|--------|---------|---------|---------|
|
||||
| 1254064 | DatetimeFieldConvFail | 日期字段格式错误 | **必须用毫秒时间戳**(如 `1772121600000`),不能用字符串(`"2026-02-27"`、RFC3339)或秒级时间戳 |
|
||||
| 1254068 | URLFieldConvFail | 超链接字段格式错误 | **必须用对象** `{text: "显示文本", link: "URL"}`,不能直接传字符串 URL |
|
||||
| 1254066 | UserFieldConvFail | 人员字段格式错误或 ID 类型不匹配 | 必须传 `[{id: "ou_xxx"}]`,确认 `user_id_type` |
|
||||
| 1254015 | Field types do not match | 字段值格式与类型不匹配 | 先 list 字段,按类型构造正确格式 |
|
||||
| 1254104 | RecordAddOnceExceedLimit | 批量创建超过 500 条 | 分批调用,每批 ≤ 500 |
|
||||
| 1254291 | Write conflict | 并发写冲突 | 串行调用 + 延迟 0.5-1 秒 |
|
||||
| 1254303 | AttachPermNotAllow | 附件未上传到当前表格 | 先调用上传素材接口 |
|
||||
| 1254045 | FieldNameNotFound | 字段名不存在 | 检查字段名(包括空格、大小写) |
|
||||
|
||||
---
|
||||
|
||||
## 📚 附录:背景知识
|
||||
|
||||
### A. 资源层级关系
|
||||
|
||||
```
|
||||
App (多维表格应用)
|
||||
├── Table (数据表) ×100
|
||||
│ ├── Record (记录/行) ×20,000
|
||||
│ ├── Field (字段/列) ×300
|
||||
│ └── View (视图) ×200
|
||||
└── Dashboard (仪表盘)
|
||||
```
|
||||
|
||||
### B. 筛选条件 operator 列表
|
||||
|
||||
| operator | 含义 | 支持字段 | value 要求 |
|
||||
|----------|------|----------|-----------|
|
||||
| `is` | 等于 | 所有 | 单个值 |
|
||||
| `isNot` | 不等于 | 除日期外 | 单个值 |
|
||||
| `contains` | 包含 | 除日期外 | 可多个值 |
|
||||
| `doesNotContain` | 不包含 | 除日期外 | 可多个值 |
|
||||
| `isEmpty` | 为空 | 所有 | 必须为 `[]` |
|
||||
| `isNotEmpty` | 不为空 | 所有 | 必须为 `[]` |
|
||||
| `isGreater` | 大于 | 数字、日期 | 单个值 |
|
||||
| `isGreaterEqual` | 大于等于 | 数字(不支持日期) | 单个值 |
|
||||
| `isLess` | 小于 | 数字、日期 | 单个值 |
|
||||
| `isLessEqual` | 小于等于 | 数字(不支持日期) | 单个值 |
|
||||
|
||||
**日期字段特殊值**: `["Today"]`, `["Tomorrow"]`, `["ExactDate", "时间戳"]` 等(完整列表见 [examples.md](references/examples.md#场景-3-筛选查询高级筛选))
|
||||
|
||||
### C. 使用限制
|
||||
|
||||
| 限制项 | 上限 |
|
||||
|--------|------|
|
||||
| 数据表 + 仪表盘 | 100(单个 App) |
|
||||
| 记录数 | 20,000(单个数据表) |
|
||||
| 字段数 | 300(单个数据表) |
|
||||
| 视图数 | 200(单个数据表) |
|
||||
| 批量创建/更新/删除 | 500(单次 API 调用) |
|
||||
| 单元格文本 | 10 万字符 |
|
||||
| 单选/多选选项 | 20,000(单个字段) |
|
||||
| 单元格附件 | 100 |
|
||||
| 单元格人员 | 1,000 |
|
||||
|
||||
|
||||
|
||||
### D. 其他约束
|
||||
|
||||
- 从其他数据源同步的数据表,**不支持增删改**记录
|
||||
- 公式字段、查看引用字段是**只读**的
|
||||
- 删除操作**无法恢复**
|
||||
- 视图筛选条件使用 `field_id`,需先调用 field.list 获取
|
||||
@ -0,0 +1,813 @@
|
||||
# 飞书多维表格使用场景完整示例
|
||||
|
||||
本文档提供多维表格操作的完整场景示例,包括参数说明和注意事项。
|
||||
|
||||
> **基础参考**: 先查阅 [字段 Property 配置详解](field-properties.md) 和 [记录值数据结构详解](record-values.md)
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [场景 0: 创建数据表(两种模式对比)](#场景-0-创建数据表两种模式对比)
|
||||
2. [场景 1: 查字段类型(必做第一步)](#场景-1-查字段类型必做第一步)
|
||||
3. [场景 2: 批量导入客户数据](#场景-2-批量导入客户数据)
|
||||
4. [场景 2.5: 创建表并插入数据(含空行处理)](#场景-25-创建表并插入数据含空行处理)
|
||||
5. [场景 3: 筛选查询(高级筛选)](#场景-3-筛选查询高级筛选)
|
||||
6. [场景 4: 更新单条记录](#场景-4-更新单条记录)
|
||||
7. [场景 5: 创建带选项的单选字段](#场景-5-创建带选项的单选字段)
|
||||
8. [场景 6: 创建复杂字段(进度、货币、评分)](#场景-6-创建复杂字段进度货币评分)
|
||||
9. [场景 7: 处理附件字段](#场景-7-处理附件字段)
|
||||
10. [场景 8: 双向关联字段](#场景-8-双向关联字段)
|
||||
|
||||
---
|
||||
|
||||
## 场景 0: 创建数据表(两种模式对比)
|
||||
|
||||
### 模式 A:一次性定义所有字段
|
||||
|
||||
**适用场景**:字段类型、配置都已明确,需要快速创建表结构。
|
||||
|
||||
**优势**:一次 API 调用,原子性操作。
|
||||
|
||||
**工具**: `feishu_bitable_app_table`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table": {
|
||||
"name": "客户管理表",
|
||||
"default_view_name": "所有客户",
|
||||
"fields": [
|
||||
{
|
||||
"field_name": "客户名称",
|
||||
"type": 1
|
||||
},
|
||||
{
|
||||
"field_name": "负责人",
|
||||
"type": 11,
|
||||
"property": {
|
||||
"multiple": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "签约日期",
|
||||
"type": 5,
|
||||
"property": {
|
||||
"date_formatter": "yyyy-MM-dd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "状态",
|
||||
"type": 3,
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "进行中", "color": 0},
|
||||
{"name": "已完成", "color": 10}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_name": "金额",
|
||||
"type": 2,
|
||||
"ui_type": "Currency",
|
||||
"property": {
|
||||
"currency_code": "CNY",
|
||||
"formatter": "0.00"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"name": "客户管理表",
|
||||
"default_view_id": "vewXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 模式 B:使用默认表 + 逐步修改字段
|
||||
|
||||
**适用场景**:探索式建表,需要边建边调整,或复杂字段配置需要分步确认。
|
||||
|
||||
**优势**:
|
||||
- `app.create` 自带默认表和默认字段,可在此基础上调整
|
||||
- 复杂字段(单选 options、URL 格式等)分步确认,减少出错
|
||||
- 踩坑后容易回退(比如 URL 字段改为文本字段)
|
||||
|
||||
**完整流程**:
|
||||
|
||||
#### 步骤 1: 创建 App(工具: `feishu_bitable_app`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"name": "客户管理系统",
|
||||
"folder_token": "fldXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
**返回**: 包含 `app_token` 和默认表的 `default_table_id`
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 2: 查看默认字段(工具: `feishu_bitable_app_table_field`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"field_id": "fld001",
|
||||
"field_name": "文本",
|
||||
"type": 1,
|
||||
"ui_type": "Text"
|
||||
},
|
||||
{
|
||||
"field_id": "fld002",
|
||||
"field_name": "数字",
|
||||
"type": 2,
|
||||
"ui_type": "Number"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 3: 修改默认字段名称(工具: `feishu_bitable_app_table_field`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "update",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_id": "fld001",
|
||||
"field_name": "客户名称"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 4: 补充缺失字段(工具: `feishu_bitable_app_table_field`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "负责人",
|
||||
"type": 11,
|
||||
"property": {
|
||||
"multiple": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 5: 查看空记录(工具: `feishu_bitable_app_table_record`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
**返回**: 可能包含空记录 `[{"record_id": "recxxx", "fields": {}}, ...]`
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 6: 删除空行(工具: `feishu_bitable_app_table_record`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "batch_delete",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"records": ["recxxx", "recyyy"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 步骤 7: 批量插入数据(工具: `feishu_bitable_app_table_record`)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "batch_create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"records": [
|
||||
{
|
||||
"fields": {
|
||||
"客户名称": "Bytedance",
|
||||
"负责人": [{"id": "ou_xxx"}],
|
||||
"状态": "进行中"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 模式 B 的关键注意事项**:
|
||||
- 默认表中通常已有空记录,**必须先删除**,否则会有数据污染
|
||||
- 步骤 5-6 是必需的,不能跳过
|
||||
- 适合不确定字段配置的探索式场景
|
||||
|
||||
---
|
||||
|
||||
## 场景 1: 查字段类型(必做第一步)
|
||||
|
||||
**为什么必做**: 不同字段类型的值格式完全不同,必须先查询再写入。
|
||||
|
||||
**工具**: `feishu_bitable_app_table_field`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"field_id": "fld001",
|
||||
"field_name": "任务名称",
|
||||
"type": 1,
|
||||
"ui_type": "Text",
|
||||
"property": {}
|
||||
},
|
||||
{
|
||||
"field_id": "fld002",
|
||||
"field_name": "负责人",
|
||||
"type": 11,
|
||||
"ui_type": "User",
|
||||
"property": {
|
||||
"multiple": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_id": "fld003",
|
||||
"field_name": "截止日期",
|
||||
"type": 5,
|
||||
"ui_type": "DateTime",
|
||||
"property": {
|
||||
"date_formatter": "yyyy-MM-dd HH:mm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"field_id": "fld004",
|
||||
"field_name": "状态",
|
||||
"type": 3,
|
||||
"ui_type": "SingleSelect",
|
||||
"property": {
|
||||
"options": [
|
||||
{"id": "optXXX", "name": "进行中", "color": 0},
|
||||
{"id": "optYYY", "name": "已完成", "color": 10}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键信息**:
|
||||
- `type`: 字段基础类型(1=文本, 2=数字, 3=单选...)
|
||||
- `ui_type`: UI 展示类型(区分进度、货币、评分等)
|
||||
- `property`: 字段配置(单选的 options、日期的 formatter 等)
|
||||
|
||||
---
|
||||
|
||||
## 场景 2: 批量导入客户数据
|
||||
|
||||
**工具**: `feishu_bitable_app_table_record`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "batch_create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"records": [
|
||||
{
|
||||
"fields": {
|
||||
"客户名称": "某某",
|
||||
"负责人": [{"id": "ou_xxx"}],
|
||||
"签约日期": 1674206443000,
|
||||
"状态": "进行中",
|
||||
"金额": 1000000,
|
||||
"标签": ["重要客户", "战略合作"],
|
||||
"联系电话": "17899870000",
|
||||
"官网": {
|
||||
"text": "某某官网",
|
||||
"link": "https://www.xxxx.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"客户名称": "飞书",
|
||||
"负责人": [{"id": "ou_xxx"}],
|
||||
"签约日期": 1675416243000,
|
||||
"状态": "已完成",
|
||||
"金额": 500000,
|
||||
"标签": ["核心产品"],
|
||||
"联系电话": "13800138000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段值格式说明**:
|
||||
- **文本**: 字符串 `"客户名称"`
|
||||
- **人员**: 对象数组 `[{"id": "ou_xxx"}]`(只能传 id)
|
||||
- **日期**: 毫秒时间戳 `1674206443000`
|
||||
- **单选**: 字符串 `"进行中"`
|
||||
- **多选**: 字符串数组 `["重要客户", "战略合作"]`
|
||||
- **数字**: 数字 `1000000`
|
||||
- **电话**: 字符串 `"17899870000"`
|
||||
- **超链接**: 对象 `{"text": "显示文本", "link": "URL"}`
|
||||
|
||||
**返回示例**:
|
||||
```json
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"record_id": "rec001",
|
||||
"fields": {...}
|
||||
},
|
||||
{
|
||||
"record_id": "rec002",
|
||||
"fields": {...}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 单次最多 500 条记录
|
||||
- 超过需分批调用
|
||||
|
||||
---
|
||||
|
||||
## 场景 2.5: 创建表并插入数据(含空行处理)
|
||||
|
||||
**问题**: `app.create` 创建的默认表中会自带空记录(空行),直接插入数据会导致数据污染。
|
||||
|
||||
**正确流程**: 见场景 0 的模式 B
|
||||
|
||||
**核心步骤**:
|
||||
1. 创建 App → 获取 `app_token` 和 `default_table_id`
|
||||
2. 查看默认表记录 (`list` action)
|
||||
3. 删除空行 (`batch_delete` action)
|
||||
4. 批量插入数据 (`batch_create` action)
|
||||
|
||||
**错误示例**(跳过步骤 2-3):
|
||||
```
|
||||
表格最终状态:
|
||||
| 客户名称 | 负责人 | 状态 |
|
||||
|---------|--------|------|
|
||||
| | | | ← 空行(原有)
|
||||
| Bytedance | 张三 | 进行中 | ← 新插入
|
||||
| 飞书 | 李四 | 已完成 | ← 新插入
|
||||
```
|
||||
|
||||
**正确示例**(执行步骤 2-3):
|
||||
```
|
||||
表格最终状态:
|
||||
| 客户名称 | 负责人 | 状态 |
|
||||
|---------|--------|------|
|
||||
| Bytedance | 张三 | 进行中 |
|
||||
| 飞书 | 李四 | 已完成 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景 3: 筛选查询(高级筛选)
|
||||
|
||||
**工具**: `feishu_bitable_app_table_record`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"filter": {
|
||||
"conjunction": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"field_name": "状态",
|
||||
"operator": "is",
|
||||
"value": ["进行中"]
|
||||
},
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"operator": "isLess",
|
||||
"value": ["ExactDate", "1740441600000"]
|
||||
},
|
||||
{
|
||||
"field_name": "优先级",
|
||||
"operator": "isGreater",
|
||||
"value": ["3"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sort": [
|
||||
{
|
||||
"field_name": "截止日期",
|
||||
"desc": false
|
||||
},
|
||||
{
|
||||
"field_name": "优先级",
|
||||
"desc": true
|
||||
}
|
||||
],
|
||||
"field_names": ["任务名称", "负责人", "截止日期", "状态"],
|
||||
"page_size": 100
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**:
|
||||
|
||||
### filter 结构
|
||||
- `conjunction`: 条件组合方式(`"and"` 或 `"or"`)
|
||||
- `conditions`: 条件数组
|
||||
|
||||
### operator 类型(10 种)
|
||||
|
||||
| operator | 含义 | 支持字段 | value 格式 |
|
||||
|----------|------|----------|-----------|
|
||||
| `is` | 等于 | 所有 | `["值"]` |
|
||||
| `isNot` | 不等于 | 除日期外 | `["值"]` |
|
||||
| `contains` | 包含 | 除日期外 | `["值1", "值2"]` |
|
||||
| `doesNotContain` | 不包含 | 除日期外 | `["值1"]` |
|
||||
| `isEmpty` | 为空 | 所有 | `[]` |
|
||||
| `isNotEmpty` | 不为空 | 所有 | `[]` |
|
||||
| `isGreater` | 大于 | 数字、日期 | `["值"]` |
|
||||
| `isGreaterEqual` | 大于等于 | 数字 | `["值"]` |
|
||||
| `isLess` | 小于 | 数字、日期 | `["值"]` |
|
||||
| `isLessEqual` | 小于等于 | 数字 | `["值"]` |
|
||||
|
||||
### 日期字段特殊值
|
||||
|
||||
```json
|
||||
// 具体日期
|
||||
{"operator": "is", "value": ["ExactDate", "1702449755000"]}
|
||||
|
||||
// 相对日期
|
||||
{"operator": "is", "value": ["Today"]} // 今天
|
||||
{"operator": "is", "value": ["Tomorrow"]} // 明天
|
||||
{"operator": "is", "value": ["Yesterday"]} // 昨天
|
||||
{"operator": "is", "value": ["CurrentWeek"]} // 本周
|
||||
{"operator": "is", "value": ["LastWeek"]} // 上周
|
||||
{"operator": "is", "value": ["TheLastWeek"]} // 过去七天
|
||||
{"operator": "is", "value": ["TheNextWeek"]} // 未来七天
|
||||
```
|
||||
|
||||
### sort 结构
|
||||
- `field_name`: 排序字段
|
||||
- `desc`: `true` 降序,`false` 升序
|
||||
- 支持多字段排序(按数组顺序)
|
||||
|
||||
---
|
||||
|
||||
## 场景 4: 更新单条记录
|
||||
|
||||
**工具**: `feishu_bitable_app_table_record`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "update",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"record_id": "recusyQbB0fVL5",
|
||||
"fields": {
|
||||
"状态": "已完成",
|
||||
"完成时间": 1674206443000,
|
||||
"备注": "客户已签约"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 只传需要更新的字段
|
||||
- 不传的字段保持不变
|
||||
- 支持部分字段更新
|
||||
|
||||
**批量更新**(最多 500 条):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "batch_update",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"records": [
|
||||
{
|
||||
"record_id": "rec001",
|
||||
"fields": {
|
||||
"状态": "已完成"
|
||||
}
|
||||
},
|
||||
{
|
||||
"record_id": "rec002",
|
||||
"fields": {
|
||||
"状态": "已完成"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 场景 5: 创建带选项的单选字段
|
||||
|
||||
**工具**: `feishu_bitable_app_table_field`
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "优先级",
|
||||
"type": 3,
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "高", "color": 0},
|
||||
{"name": "中", "color": 1},
|
||||
{"name": "低", "color": 2}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**颜色编号**(color 范围 0-54):
|
||||
- 0: 红色
|
||||
- 1: 橙色
|
||||
- 10: 绿色
|
||||
- 20: 蓝色
|
||||
|
||||
**多选字段**(type=4)格式相同:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "标签",
|
||||
"type": 4,
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "重要", "color": 0},
|
||||
{"name": "紧急", "color": 1},
|
||||
{"name": "长期", "color": 10}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 创建时**不能**指定选项 ID(`id` 字段),系统自动生成
|
||||
- 选项总数不超过 20,000
|
||||
|
||||
---
|
||||
|
||||
## 场景 6: 创建复杂字段(进度、货币、评分)
|
||||
|
||||
### 进度字段 (type=2, ui_type="Progress")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "完成进度",
|
||||
"type": 2,
|
||||
"ui_type": "Progress",
|
||||
"property": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"range_customize": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**写入值**: `0.75` 表示 75%
|
||||
|
||||
---
|
||||
|
||||
### 货币字段 (type=2, ui_type="Currency")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "预算",
|
||||
"type": 2,
|
||||
"ui_type": "Currency",
|
||||
"property": {
|
||||
"currency_code": "CNY",
|
||||
"formatter": "0,000.00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**currency_code 可选值**:
|
||||
- `"CNY"`: 人民币 (¥)
|
||||
- `"USD"`: 美元 ($)
|
||||
- `"EUR"`: 欧元 (€)
|
||||
- `"JPY"`: 日元 (¥)
|
||||
|
||||
**写入值**: `5000.50`(普通数字)
|
||||
|
||||
---
|
||||
|
||||
### 评分字段 (type=2, ui_type="Rating")
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "客户满意度",
|
||||
"type": 2,
|
||||
"ui_type": "Rating",
|
||||
"property": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"rating": {
|
||||
"symbol": "star"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**symbol 可选值**:
|
||||
- `"star"`: ⭐ 星星
|
||||
- `"heart"`: ❤️ 爱心
|
||||
- `"fire"`: 🔥 火焰
|
||||
- `"thumbsup"`: 👍 赞
|
||||
|
||||
**写入值**: `4`(整数)
|
||||
|
||||
---
|
||||
|
||||
## 场景 7: 处理附件字段
|
||||
|
||||
### 步骤 1: 上传附件到多维表格
|
||||
|
||||
**工具**: `feishu_drive_media`(上传素材接口)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "upload",
|
||||
"file_path": "/path/to/file.pdf",
|
||||
"parent_type": "bitable_image",
|
||||
"parent_node": "S404b..." // app_token
|
||||
}
|
||||
```
|
||||
|
||||
**返回**:
|
||||
```json
|
||||
{
|
||||
"file_token": "DRiFbwaKsoZaLax4WKZbEGCccoe"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2: 创建附件字段(可选)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"field_name": "合同文件",
|
||||
"type": 17
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3: 写入附件记录
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tblXXXXXXXX",
|
||||
"fields": {
|
||||
"客户名称": "Bytedance",
|
||||
"合同文件": [
|
||||
{"file_token": "DRiFxxxxxxxxxxxxxxxxxxCccoe"},
|
||||
{"file_token": "BZk3bxxxxxxxxxxxxxxxxeKqcLe"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 单个单元格附件数不超过 100
|
||||
- 必须先上传到当前多维表格,不能用外部 file_token
|
||||
|
||||
---
|
||||
|
||||
## 场景 8: 双向关联字段
|
||||
|
||||
### 步骤 1: 创建双向关联字段
|
||||
|
||||
**在"任务表"中创建关联到"项目表"的字段**:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl_task",
|
||||
"field_name": "所属项目",
|
||||
"type": 21,
|
||||
"property": {
|
||||
"table_id": "tbl_project",
|
||||
"back_field_name": "关联的任务",
|
||||
"multiple": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**结果**:
|
||||
- 在"任务表"中创建字段"所属项目"
|
||||
- 在"项目表"中**自动创建**字段"关联的任务"
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2: 写入关联记录
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl_task",
|
||||
"fields": {
|
||||
"任务名称": "开发新功能",
|
||||
"所属项目": {
|
||||
"link_record_ids": ["rec_project_001"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**级联更新**:
|
||||
- 在"任务表"中设置"所属项目"为 `rec_project_001`
|
||||
- "项目表"的 `rec_project_001` 记录的"关联的任务"字段会**自动添加**当前任务的 record_id
|
||||
|
||||
---
|
||||
|
||||
### 单向关联 (type=18)
|
||||
|
||||
**区别**: 只影响当前表,不会自动更新对方表
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"app_token": "S404b...",
|
||||
"table_id": "tbl_task",
|
||||
"field_name": "参考任务",
|
||||
"type": 18,
|
||||
"property": {
|
||||
"table_id": "tbl_task", // 可以关联自己
|
||||
"multiple": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 参考链接
|
||||
|
||||
- [字段 Property 配置详解](field-properties.md)
|
||||
- [记录值数据结构详解](record-values.md)
|
||||
- [飞书开放平台 - 多维表格文档](https://open.feishu.cn/document/server-docs/docs/bitable-v1/bitable-overview)
|
||||
@ -0,0 +1,763 @@
|
||||
# 飞书多维表格字段 Property 配置详解
|
||||
|
||||
本文档详细说明每种字段类型创建或更新时需要的 `property` 参数结构。
|
||||
|
||||
> **来源**: 基于飞书开放平台文档 [字段编辑指南](https://go.feishu.cn/s/672BSzVyo03)
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [基础字段](#基础字段)
|
||||
- [1. 文本 (type=1)](#1-文本-type1)
|
||||
- [2. 数字 (type=2)](#2-数字-type2)
|
||||
- [5. 日期 (type=5)](#5-日期-type5)
|
||||
- [7. 复选框 (type=7)](#7-复选框-type7)
|
||||
- [13. 电话号码 (type=13)](#13-电话号码-type13)
|
||||
- [选择字段](#选择字段)
|
||||
- [3. 单选 (type=3)](#3-单选-type3)
|
||||
- [4. 多选 (type=4)](#4-多选-type4)
|
||||
- [特殊显示字段](#特殊显示字段)
|
||||
- [进度 (type=2, ui_type="Progress")](#进度-type2-ui_typeprogress)
|
||||
- [货币 (type=2, ui_type="Currency")](#货币-type2-ui_typecurrency)
|
||||
- [评分 (type=2, ui_type="Rating")](#评分-type2-ui_typerating)
|
||||
- [条码 (type=1, ui_type="Barcode")](#条码-type1-ui_typebarcode)
|
||||
- [邮箱 (type=1, ui_type="Email")](#邮箱-type1-ui_typeemail)
|
||||
- [关系字段](#关系字段)
|
||||
- [11. 人员 (type=11)](#11-人员-type11)
|
||||
- [15. 超链接 (type=15)](#15-超链接-type15)
|
||||
- [17. 附件 (type=17)](#17-附件-type17)
|
||||
- [18. 单向关联 (type=18)](#18-单向关联-type18)
|
||||
- [21. 双向关联 (type=21)](#21-双向关联-type21)
|
||||
- [22. 地理位置 (type=22)](#22-地理位置-type22)
|
||||
- [23. 群组 (type=23)](#23-群组-type23)
|
||||
- [高级字段](#高级字段)
|
||||
- [20. 公式 (type=20)](#20-公式-type20)
|
||||
- [1001. 创建时间 (type=1001)](#1001-创建时间-type1001)
|
||||
- [1002. 最后更新时间 (type=1002)](#1002-最后更新时间-type1002)
|
||||
- [1005. 自动编号 (type=1005)](#1005-自动编号-type1005)
|
||||
|
||||
---
|
||||
|
||||
## 基础字段
|
||||
|
||||
### 1. 文本 (type=1)
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"field_name": "任务描述",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 默认 `ui_type` 为 "Text"
|
||||
- 单个单元格最多 10 万字符
|
||||
- 支持富文本格式(提及人、超链接等)
|
||||
|
||||
---
|
||||
|
||||
### 2. 数字 (type=2)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatter": "0" // 可选,数字显示格式
|
||||
}
|
||||
```
|
||||
|
||||
**formatter 可选值**:
|
||||
- `"0"`: 整数(默认)
|
||||
- `"0.0"`: 一位小数
|
||||
- `"0.00"`: 两位小数
|
||||
- `"0,000"`: 千分位
|
||||
- `"0.00%"`: 百分比
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 2,
|
||||
"field_name": "工时",
|
||||
"property": {
|
||||
"formatter": "0.00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 日期 (type=5)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_formatter": "yyyy/MM/dd", // 可选,默认 "yyyy/MM/dd"
|
||||
"auto_fill": false // 可选,是否自动填充创建时间
|
||||
}
|
||||
```
|
||||
|
||||
**date_formatter 可选值**:
|
||||
- `"yyyy/MM/dd"`: 2021/1/30
|
||||
- `"yyyy-MM-dd HH:mm"`: 2021/1/30 14:00
|
||||
- `"MM-dd"`: 1月30日
|
||||
- `"MM/dd/yyyy"`: 01/30/2021
|
||||
- `"dd/MM/yyyy"`: 30/01/2021
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 5,
|
||||
"field_name": "截止日期",
|
||||
"property": {
|
||||
"date_formatter": "yyyy-MM-dd HH:mm",
|
||||
"auto_fill": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 复选框 (type=7)
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 7,
|
||||
"field_name": "是否完成",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 13. 电话号码 (type=13)
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 13,
|
||||
"field_name": "联系电话",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 电话号码格式:符合正则 `(\+)?\d*`
|
||||
- 最大长度 64 字符
|
||||
|
||||
---
|
||||
|
||||
## 选择字段
|
||||
|
||||
### 3. 单选 (type=3)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"name": "进行中", // 必填,选项名称
|
||||
"color": 0 // 可选,颜色编号 (0-54)
|
||||
},
|
||||
{
|
||||
"name": "已完成",
|
||||
"color": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**颜色编号 (color)**:
|
||||
- 范围: 0-54
|
||||
- 0: 红色
|
||||
- 10: 绿色
|
||||
- 20: 蓝色
|
||||
- ... (详见飞书官方文档)
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 3,
|
||||
"field_name": "任务状态",
|
||||
"property": {
|
||||
"options": [
|
||||
{"name": "待开始", "color": 0},
|
||||
{"name": "进行中", "color": 20},
|
||||
{"name": "已完成", "color": 10}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 选项总数不超过 20,000 个
|
||||
- 创建时**不能**指定选项 ID(`id` 字段),系统自动生成
|
||||
- 更新时需保留已有选项的 `id`
|
||||
|
||||
---
|
||||
|
||||
### 4. 多选 (type=4)
|
||||
|
||||
**Property 结构**: 与单选相同
|
||||
|
||||
```json
|
||||
{
|
||||
"options": [
|
||||
{"name": "紧急", "color": 0},
|
||||
{"name": "重要", "color": 10}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 选项总数不超过 20,000 个
|
||||
- 单个单元格选项数不超过 1,000 个
|
||||
|
||||
---
|
||||
|
||||
## 特殊显示字段
|
||||
|
||||
### 进度 (type=2, ui_type="Progress")
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"min": 0, // 必填,最小值
|
||||
"max": 100, // 必填,最大值
|
||||
"range_customize": false // 可选,是否允许自定义进度值
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 2,
|
||||
"field_name": "完成进度",
|
||||
"ui_type": "Progress",
|
||||
"property": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"range_customize": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `min` 取值范围: 0-1
|
||||
- `max` 取值范围: 1-100
|
||||
- `range_customize` 为 `true` 时用户可输入超出范围的值
|
||||
|
||||
---
|
||||
|
||||
### 货币 (type=2, ui_type="Currency")
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"currency_code": "CNY", // 必填,货币类型
|
||||
"formatter": "0.00" // 可选,数字格式
|
||||
}
|
||||
```
|
||||
|
||||
**currency_code 可选值**:
|
||||
- `"CNY"`: 人民币 (¥)
|
||||
- `"USD"`: 美元 ($)
|
||||
- `"EUR"`: 欧元 (€)
|
||||
- `"GBP"`: 英镑 (£)
|
||||
- `"JPY"`: 日元 (¥)
|
||||
- `"HKD"`: 港元 ($)
|
||||
- ... (支持 20+ 种货币)
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 2,
|
||||
"field_name": "预算",
|
||||
"ui_type": "Currency",
|
||||
"property": {
|
||||
"currency_code": "USD",
|
||||
"formatter": "0,000.00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 评分 (type=2, ui_type="Rating")
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"min": 1, // 必填,最小值
|
||||
"max": 5, // 必填,最大值
|
||||
"rating": { // 可选,评分样式
|
||||
"symbol": "star" // 图标类型
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**symbol 可选值**:
|
||||
- `"star"`: ⭐ 星星(默认)
|
||||
- `"heart"`: ❤️ 爱心
|
||||
- `"thumbsup"`: 👍 赞
|
||||
- `"fire"`: 🔥 火焰
|
||||
- `"smile"`: 😊 笑脸
|
||||
- `"lightning"`: ⚡ 闪电
|
||||
- `"flower"`: 🌸 花朵
|
||||
- `"number"`: 数字
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 2,
|
||||
"field_name": "优先级",
|
||||
"ui_type": "Rating",
|
||||
"property": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"rating": {
|
||||
"symbol": "fire"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 条码 (type=1, ui_type="Barcode")
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"allowed_edit_modes": {
|
||||
"manual": true, // 是否允许手动录入
|
||||
"scan": true // 是否允许扫描录入
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"field_name": "商品条码",
|
||||
"ui_type": "Barcode",
|
||||
"property": {
|
||||
"allowed_edit_modes": {
|
||||
"manual": false,
|
||||
"scan": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 邮箱 (type=1, ui_type="Email")
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"field_name": "联系邮箱",
|
||||
"ui_type": "Email",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关系字段
|
||||
|
||||
### 11. 人员 (type=11)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"multiple": true // 可选,是否允许多个人员,默认 true
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 11,
|
||||
"field_name": "负责人",
|
||||
"property": {
|
||||
"multiple": false // 只允许单个人员
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个单元格人员数不超过 1,000
|
||||
- 记录值只支持传入 `id` 字段(open_id/union_id/user_id)
|
||||
|
||||
---
|
||||
|
||||
### 15. 超链接 (type=15)
|
||||
|
||||
**Property 结构**: **必须省略 `property` 参数,不要传递任何值(包括空对象)**
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 15,
|
||||
"field_name": "参考链接"
|
||||
// 不要传 property 参数,包括空对象 {}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 重要**: 超链接字段的特殊要求(经实测验证):
|
||||
- ✅ **正确**: 完全省略 `property` 参数
|
||||
- ❌ **错误**: `"property": {}`(会报 URLFieldPropertyError)
|
||||
- ❌ **错误**: 传递任何 property 值
|
||||
|
||||
**注意**: 这是飞书 API 的特殊行为,超链接字段即使传空对象也会报错,必须完全省略该参数。
|
||||
|
||||
---
|
||||
|
||||
### 17. 附件 (type=17)
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 17,
|
||||
"field_name": "附件",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个单元格附件数不超过 100
|
||||
- 写入前需先调用[上传素材接口](https://go.feishu.cn/s/63soQp6O80s)
|
||||
|
||||
---
|
||||
|
||||
### 18. 单向关联 (type=18)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"table_id": "tblXXXXXXXX", // 必填,关联的数据表 ID
|
||||
"multiple": true // 可选,是否允许多条记录,默认 true
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 18,
|
||||
"field_name": "关联任务",
|
||||
"property": {
|
||||
"table_id": "tblsRc9GRRXKqhvW",
|
||||
"multiple": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个单元格关联数不超过 500
|
||||
|
||||
---
|
||||
|
||||
### 21. 双向关联 (type=21)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"table_id": "tblXXXXXXXX", // 必填,关联的数据表 ID
|
||||
"back_field_name": "反向字段名", // 必填,对方表的双向关联字段名
|
||||
"multiple": true // 可选,是否允许多条记录
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 21,
|
||||
"field_name": "相关项目",
|
||||
"property": {
|
||||
"table_id": "tblAnotherTable",
|
||||
"back_field_name": "关联的任务",
|
||||
"multiple": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个单元格关联数不超过 500
|
||||
- 对方表会自动创建对应的双向关联字段
|
||||
|
||||
---
|
||||
|
||||
### 22. 地理位置 (type=22)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"location": {
|
||||
"input_type": "not_limit" // 输入限制
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**input_type 可选值**:
|
||||
- `"only_mobile"`: 仅允许移动端实时定位
|
||||
- `"not_limit"`: 无限制(默认)
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 22,
|
||||
"field_name": "办公地址",
|
||||
"property": {
|
||||
"location": {
|
||||
"input_type": "only_mobile"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 23. 群组 (type=23)
|
||||
|
||||
**Property 结构**: 空对象或省略
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 23,
|
||||
"field_name": "协作群",
|
||||
"property": {}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 单个单元格群组数不超过 10 个
|
||||
|
||||
---
|
||||
|
||||
## 高级字段
|
||||
|
||||
### 20. 公式 (type=20)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"formula_expression": "bitable::$table[tblXXX].$field[fldYYY]*2" // 可选
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 20,
|
||||
"field_name": "总价",
|
||||
"property": {
|
||||
"formula_expression": "bitable::$table[tblMain].$field[fldQty] * $field[fldPrice]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 创建字段时**不支持**设置公式表达式
|
||||
- 参考[飞书帮助中心 - 公式字段](https://www.feishu.cn/hc/zh-CN/articles/360049067853)
|
||||
|
||||
**对于某些多维表格,公式字段需要额外设置 `type` 参数**(通过[获取多维表格元数据](https://go.feishu.cn/s/62nuKkQlE03)接口的 `formula_type` 判断):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 20,
|
||||
"field_name": "计算字段",
|
||||
"property": {
|
||||
"type": {
|
||||
"data_type": 2, // 公式结果的数据类型 (1=文本, 2=数字, 5=日期...)
|
||||
"ui_property": { // UI 展示属性
|
||||
"formatter": "0.00",
|
||||
"currency_code": "CNY"
|
||||
},
|
||||
"ui_type": "Currency" // UI 类型 (Number/Progress/Currency/Rating/DateTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1001. 创建时间 (type=1001)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_formatter": "yyyy/MM/dd" // 可选,日期格式
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1001,
|
||||
"field_name": "创建于",
|
||||
"property": {
|
||||
"date_formatter": "yyyy-MM-dd HH:mm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1002. 最后更新时间 (type=1002)
|
||||
|
||||
**Property 结构**: 与创建时间相同
|
||||
|
||||
```json
|
||||
{
|
||||
"date_formatter": "yyyy-MM-dd HH:mm"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1005. 自动编号 (type=1005)
|
||||
|
||||
**Property 结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
"auto_serial": {
|
||||
"type": "auto_increment_number", // 或 "custom"
|
||||
"options": [ // 自定义编号规则(仅 type="custom" 时需要)
|
||||
{
|
||||
"type": "fixed_text",
|
||||
"value": "TASK-"
|
||||
},
|
||||
{
|
||||
"type": "created_time",
|
||||
"value": "yyyyMMdd"
|
||||
},
|
||||
{
|
||||
"type": "system_number",
|
||||
"value": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**auto_serial.type 可选值**:
|
||||
- `"auto_increment_number"`: 纯自增数字
|
||||
- `"custom"`: 自定义编号规则
|
||||
|
||||
**options 中的规则类型**:
|
||||
- `"system_number"`: 自增数字位数(value: 1-9)
|
||||
- `"fixed_text"`: 固定字符(value: 最多 20 字符)
|
||||
- `"created_time"`: 创建时间(value: "yyyyMMdd"/"yyyyMM"/"yyyy"/"MMdd"/"MM"/"dd")
|
||||
|
||||
**示例 1: 纯自增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1005,
|
||||
"field_name": "编号",
|
||||
"property": {
|
||||
"auto_serial": {
|
||||
"type": "auto_increment_number"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 2: 自定义编号**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1005,
|
||||
"field_name": "工单号",
|
||||
"property": {
|
||||
"auto_serial": {
|
||||
"type": "custom",
|
||||
"options": [
|
||||
{"type": "fixed_text", "value": "WO-"},
|
||||
{"type": "created_time", "value": "yyyyMMdd"},
|
||||
{"type": "system_number", "value": "4"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
// 生成示例: WO-20240226-0001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见错误码
|
||||
|
||||
| 错误码 | 字段类型 | 说明 |
|
||||
|--------|---------|------|
|
||||
| 1254080 | 文本 | property 结构错误 |
|
||||
| 1254081 | 数字 | property 结构错误,检查 formatter |
|
||||
| 1254082 | 单选 | property 结构错误,检查 options 数组 |
|
||||
| 1254083 | 多选 | property 结构错误,检查 options 数组 |
|
||||
| 1254084 | 日期 | property 结构错误,检查 date_formatter |
|
||||
| 1254085 | 复选框 | property 结构错误 |
|
||||
| 1254086 | 人员 | property 结构错误,检查 multiple |
|
||||
| 1254087 | 超链接 | **必须省略 property 参数(传空对象也会报错)** |
|
||||
| 1254088 | 附件 | property 结构错误 |
|
||||
| 1254089 | 单向关联 | property 结构错误,检查 table_id |
|
||||
| 1254090 | 查找引用 | property 结构错误 |
|
||||
| 1254091 | 公式 | property 结构错误 |
|
||||
| 1254092 | 双向关联 | property 结构错误,检查 table_id 和 back_field_name |
|
||||
| 1254093 | 创建时间 | property 结构错误 |
|
||||
| 1254094 | 最后更新时间 | property 结构错误 |
|
||||
|
||||
---
|
||||
|
||||
## 📌 更新字段时的特殊规则
|
||||
|
||||
调用 `update` action 更新字段时:
|
||||
|
||||
1. **必须保持字段类型一致**: `type` 和 `ui_type` 不能变更
|
||||
2. **单选/多选更新选项**:
|
||||
- 已有选项必须保留 `id`
|
||||
- 新增选项只传 `name` 和 `color`,不传 `id`
|
||||
3. **如果只改字段名**:
|
||||
- 可以只传 `field_name`,工具会自动查询当前 `type` 和 `property`
|
||||
4. **关联字段的 table_id**: 不能修改为不同的表
|
||||
|
||||
---
|
||||
|
||||
## 🔗 参考链接
|
||||
|
||||
- [飞书开放平台 - 字段编辑指南](https://go.feishu.cn/s/672BSzVyo03)
|
||||
- [新增字段接口文档](https://go.feishu.cn/s/62nuKkQl403)
|
||||
- [更新字段接口文档](https://go.feishu.cn/s/62nuKkQlo03)
|
||||
@ -0,0 +1,911 @@
|
||||
# 飞书多维表格记录值数据结构详解
|
||||
|
||||
本文档详细说明每种字段类型在记录中对应的 `fields` 值格式。
|
||||
|
||||
> **来源**: 基于飞书开放平台文档 [多维表格记录数据结构](https://go.feishu.cn/s/6lY28723w04)
|
||||
|
||||
## 📋 快速索引
|
||||
|
||||
| 字段类型 | type | 值类型 | 示例 | 限制 |
|
||||
|---------|------|--------|------|------|
|
||||
| [文本](#文本-type1) | 1 | string (写入) / list of object (返回) | `"任务描述"` | 最多 10 万字符 |
|
||||
| [数字](#数字-type2) | 2 | number | `0.5` | - |
|
||||
| [单选](#单选-type3) | 3 | string | `"进行中"` | 选项总数≤20,000 |
|
||||
| [多选](#多选-type4) | 4 | array<string> | `["审批", "办公"]` | 选项总数≤20,000,单元格≤1,000 |
|
||||
| [日期](#日期-type5) | 5 | number | `1675526400000` | Unix 毫秒时间戳 |
|
||||
| [复选框](#复选框-type7) | 7 | boolean | `true` | - |
|
||||
| [人员](#人员-type11) | 11 | list of object | `[{"id": "ou_xxx"}]` | 单元格≤1,000,写入仅支持 `id` |
|
||||
| [电话](#电话号码-type13) | 13 | string | `"17899870000"` | 最多 64 字符 |
|
||||
| [超链接](#超链接-type15) | 15 | object | `{"text": "飞书", "link": "..."}` | - |
|
||||
| [附件](#附件-type17) | 17 | list of object | `[{"file_token": "xxx"}]` | 单元格≤100 |
|
||||
| [单向关联](#单向关联-type18) | 18 | object | `{"link_record_ids": [...]}` | 单元格≤500 |
|
||||
| [双向关联](#双向关联-type21) | 21 | object | `{"link_record_ids": [...]}` | 单元格≤500 |
|
||||
| [地理位置](#地理位置-type22) | 22 | object | `{"location": "116.3,40.0", ...}` | - |
|
||||
| [群组](#群组-type23) | 23 | list of object | `[{"id": "oc_xxx"}]` | 单元格≤10 |
|
||||
| [公式/查找引用](#公式查找引用-type20-type19) | 20/19 | object | `{"type": 1, "value": [...]}` | 只读 |
|
||||
|
||||
---
|
||||
|
||||
## 文本 (type=1)
|
||||
|
||||
### 基础文本 (ui_type="Text")
|
||||
|
||||
**写入格式**: 字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"任务描述": "维护客户关系"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 对象数组
|
||||
|
||||
```json
|
||||
{
|
||||
"任务描述": [
|
||||
{
|
||||
"text": "维护客户关系",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**富文本格式** (提及人、超链接):
|
||||
|
||||
```json
|
||||
{
|
||||
"任务描述": [
|
||||
{
|
||||
"text": "请 ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "@张三",
|
||||
"type": "mention",
|
||||
"token": "ou_user123",
|
||||
"mentionType": "User",
|
||||
"mentionNotify": true,
|
||||
"name": "张三"
|
||||
},
|
||||
{
|
||||
"text": " 查看 ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": "飞书官网",
|
||||
"type": "url",
|
||||
"link": "https://www.feishu.cn"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**富文本元素类型**:
|
||||
|
||||
| type | 说明 | 额外字段 |
|
||||
|------|------|---------|
|
||||
| `"text"` | 纯文本 | `text` |
|
||||
| `"mention"` | 提及(人/文档) | `token`, `mentionType`, `mentionNotify`, `name` |
|
||||
| `"url"` | 超链接 | `text`, `link` |
|
||||
|
||||
**mentionType 可选值**:
|
||||
- `"User"`: 提及用户
|
||||
- `"Docx"`: 提及文档
|
||||
- `"Sheet"`: 提及电子表格
|
||||
- `"Bitable"`: 提及多维表格
|
||||
|
||||
---
|
||||
|
||||
### 条码 (ui_type="Barcode")
|
||||
|
||||
**写入格式**: 字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"商品条码": "FS0001"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"商品条码": [
|
||||
{
|
||||
"text": "FS0001",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 邮箱 (ui_type="Email")
|
||||
|
||||
**写入格式**: 字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"联系邮箱": "zhangmin@xxxgmail.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"联系邮箱": [
|
||||
{
|
||||
"text": "zhangmin@xxxgmail.com",
|
||||
"type": "url",
|
||||
"link": "mailto:zhangmin@xxxgmail.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数字 (type=2)
|
||||
|
||||
**写入/返回格式**: 数字
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"工时": 10,
|
||||
"完成率": 0.75,
|
||||
"预算": 5000.50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 进度 (ui_type="Progress"): 0-1 范围的小数
|
||||
- 货币 (ui_type="Currency"): 普通数字
|
||||
- 评分 (ui_type="Rating"): 整数
|
||||
|
||||
---
|
||||
|
||||
## 单选 (type=3)
|
||||
|
||||
**写入格式**: 选项名称字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"任务状态": "进行中"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新选项**: 传入不存在的选项名会**自动创建新选项**
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"任务状态": "已暂停" // 如果不存在,会自动创建
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 与写入相同
|
||||
|
||||
```json
|
||||
{
|
||||
"任务状态": "进行中"
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 选项总数不超过 20,000
|
||||
|
||||
---
|
||||
|
||||
## 多选 (type=4)
|
||||
|
||||
**写入格式**: 字符串数组
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"标签": ["审批集成", "办公管理", "身份管理"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新选项**: 传入不存在的选项名会**自动创建新选项**
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"标签": ["新标签1", "新标签2"] // 不存在的会自动创建
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 与写入相同
|
||||
|
||||
```json
|
||||
{
|
||||
"标签": ["审批集成", "办公管理"]
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 选项总数不超过 20,000
|
||||
- 单个单元格选项数不超过 1,000
|
||||
|
||||
---
|
||||
|
||||
## 日期 (type=5)
|
||||
|
||||
**写入/返回格式**: Unix 毫秒时间戳
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"截止日期": 1675526400000 // 2023-02-05 00:00:00 (UTC)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 必须使用**毫秒级**时间戳(不是秒级)
|
||||
- 建议使用北京时间 (UTC+8) 转换
|
||||
|
||||
**常见错误** (错误码 1254064):
|
||||
|
||||
```json
|
||||
// ❌ 错误:使用 ISO 字符串
|
||||
{"截止日期": "2026-02-27"}
|
||||
|
||||
// ❌ 错误:使用 RFC3339 格式
|
||||
{"截止日期": "2026-02-27T10:00:00+08:00"}
|
||||
|
||||
// ❌ 错误:使用秒级时间戳
|
||||
{"截止日期": 1772121600} // 少了 3 位
|
||||
|
||||
// ✅ 正确:使用毫秒时间戳
|
||||
{"截止日期": 1772121600000}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 复选框 (type=7)
|
||||
|
||||
**写入/返回格式**: 布尔值
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"是否完成": true,
|
||||
"是否延期": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 人员 (type=11)
|
||||
|
||||
**写入格式**: 对象数组,**仅支持 `id` 字段**
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"负责人": [
|
||||
{"id": "ou_8240099442cf5da49f04f4bf8f8abcef"}
|
||||
],
|
||||
"协作人": [
|
||||
{"id": "ou_user1"},
|
||||
{"id": "ou_user2"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 对象数组,包含完整信息
|
||||
|
||||
```json
|
||||
{
|
||||
"负责人": [
|
||||
{
|
||||
"id": "ou_8240099442cf5da49f04f4bf8f8abcef",
|
||||
"name": "黄泡泡",
|
||||
"en_name": "Amanda Huang",
|
||||
"email": "amandahuang@xxxgmail.com",
|
||||
"avatar_url": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 重要**:
|
||||
- **写入时只支持 `id`**,不能传 `name`、`email` 等字段
|
||||
- `id` 类型需与 `user_id_type` 参数一致(open_id/union_id/user_id)
|
||||
- 单个单元格人员数不超过 1,000
|
||||
- 传空: `null` 或 `[]`
|
||||
|
||||
---
|
||||
|
||||
## 电话号码 (type=13)
|
||||
|
||||
**写入/返回格式**: 字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"联系电话": "17899870000",
|
||||
"座机": "+86-010-12345678"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**格式规则**:
|
||||
- 符合正则: `(\+)?\d*`
|
||||
- 最大长度 64 字符
|
||||
|
||||
---
|
||||
|
||||
## 超链接 (type=15)
|
||||
|
||||
**写入/返回格式**: 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"参考链接": {
|
||||
"text": "飞书开放平台",
|
||||
"link": "https://open.feishu.cn"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `text`: 显示的文本
|
||||
- `link`: URL 地址
|
||||
|
||||
**常见错误** (错误码 1254068):
|
||||
|
||||
```json
|
||||
// ❌ 错误:直接传字符串 URL
|
||||
{
|
||||
"参考链接": "https://open.feishu.cn"
|
||||
}
|
||||
|
||||
// ✅ 正确:使用对象格式
|
||||
{
|
||||
"参考链接": {
|
||||
"text": "飞书开放平台",
|
||||
"link": "https://open.feishu.cn"
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ text 和 link 可以相同
|
||||
{
|
||||
"参考链接": {
|
||||
"text": "https://open.feishu.cn",
|
||||
"link": "https://open.feishu.cn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附件 (type=17)
|
||||
|
||||
**写入格式**: 对象数组,**仅传 `file_token`**
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"附件": [
|
||||
{"file_token": "DRiFbwaKsoZaLax4WKZbEGCccoe"},
|
||||
{"file_token": "BZk3bL1Enoy4pzxaPL9bNeKqcLe"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 对象数组,包含完整信息
|
||||
|
||||
```json
|
||||
{
|
||||
"附件": [
|
||||
{
|
||||
"file_token": "J7GdbgNWWoD1fwx7oWccxdgknIe",
|
||||
"name": "58cc930b89.png",
|
||||
"type": "image/png",
|
||||
"size": 108867,
|
||||
"url": "https://open.feishu.cn/open-apis/drive/v1/medias/...",
|
||||
"tmp_url": "https://open.feishu.cn/open-apis/drive/v1/medias/batch_get_tmp_download_url?..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 重要**:
|
||||
- 写入前必须先调用[上传素材接口](https://go.feishu.cn/s/63soQp6O80s)获取 `file_token`
|
||||
- 单个单元格附件数不超过 100
|
||||
- 错误码 1254303: 附件未挂载到当前多维表格
|
||||
|
||||
---
|
||||
|
||||
## 单向关联 (type=18)
|
||||
|
||||
**写入格式**: `link_record_ids` 数组
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"关联任务": {
|
||||
"link_record_ids": ["recHTLvO7x", "recbS8zb2m"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**简化写入** (直接数组):
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"关联任务": ["recHTLvO7x", "recbS8zb2m"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"关联任务": {
|
||||
"link_record_ids": ["recHTLvO7x", "recbS8zb2m"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 单个单元格关联数不超过 500
|
||||
|
||||
---
|
||||
|
||||
## 双向关联 (type=21)
|
||||
|
||||
**写入/返回格式**: 与单向关联相同
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"相关项目": {
|
||||
"link_record_ids": ["reclzUoBLn", "rec7bYQoX1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 更新双向关联会同步更新对方表的对应字段
|
||||
- 单个单元格关联数不超过 500
|
||||
|
||||
---
|
||||
|
||||
## 地理位置 (type=22)
|
||||
|
||||
**写入格式**: 经纬度字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"办公地址": "116.397755,39.903179"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 对象,包含详细信息
|
||||
|
||||
```json
|
||||
{
|
||||
"办公地址": {
|
||||
"location": "116.352681,40.01437",
|
||||
"pname": "北京市",
|
||||
"cityname": "北京市",
|
||||
"adname": "海淀区",
|
||||
"address": "学清路10号院学清嘉创大厦",
|
||||
"name": "Bytedance",
|
||||
"full_address": "Bytedance,北京市北京市海淀区学清路10号院学清嘉创大厦"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `location`: 经纬度 (格式: "经度,纬度")
|
||||
- `pname`: 省
|
||||
- `cityname`: 市
|
||||
- `adname`: 区
|
||||
- `address`: 详细地址
|
||||
- `name`: 地名
|
||||
- `full_address`: 完整地址
|
||||
|
||||
---
|
||||
|
||||
## 群组 (type=23)
|
||||
|
||||
**写入格式**: 对象数组,**仅传 `id`**
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"协作群": [
|
||||
{"id": "oc_d2a947abb78bbbbb12d4cad55fbabcef"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**: 对象数组,包含完整信息
|
||||
|
||||
```json
|
||||
{
|
||||
"协作群": [
|
||||
{
|
||||
"id": "oc_d2a947abb78bbbbb12d4cad55fbabcef",
|
||||
"name": "测试部门",
|
||||
"avatar_url": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**限制**:
|
||||
- 单个单元格群组数不超过 10
|
||||
|
||||
---
|
||||
|
||||
## 公式/查找引用 (type=20, type=19)
|
||||
|
||||
**格式**: 对象,包含 `type`、`ui_type` 和 `value`
|
||||
|
||||
```json
|
||||
{
|
||||
"是否延期": {
|
||||
"type": 1, // 底层数据类型
|
||||
"ui_type": "Text", // UI 展示类型
|
||||
"value": [ // 计算结果
|
||||
{
|
||||
"text": "✅ 正常",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `type`: 底层数据类型枚举(1=文本, 2=数字, 5=日期...)
|
||||
- `ui_type`: UI 展示类型("Text"/"Number"/"Progress"/...)
|
||||
- `value`: 计算结果,格式由 `type` 决定
|
||||
|
||||
**示例 - 数字类型公式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"总价": {
|
||||
"type": 2,
|
||||
"ui_type": "Currency",
|
||||
"value": 1250.50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例 - 日期类型公式**:
|
||||
|
||||
```json
|
||||
{
|
||||
"计算日期": {
|
||||
"type": 5,
|
||||
"ui_type": "DateTime",
|
||||
"value": 1675526400000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 注意**:
|
||||
- 公式字段为**只读**,不能通过写接口设置
|
||||
- `value` 的数据结构取决于 `type` 对应的字段类型
|
||||
|
||||
---
|
||||
|
||||
## 系统字段
|
||||
|
||||
### 创建时间 (type=1001)
|
||||
|
||||
**返回格式**: Unix 毫秒时间戳
|
||||
|
||||
```json
|
||||
{
|
||||
"创建于": 1675526400000
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 只读**: 不能通过写接口设置
|
||||
|
||||
---
|
||||
|
||||
### 最后更新时间 (type=1002)
|
||||
|
||||
**返回格式**: Unix 毫秒时间戳
|
||||
|
||||
```json
|
||||
{
|
||||
"更新于": 1675612800000
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 只读**: 不能通过写接口设置
|
||||
|
||||
---
|
||||
|
||||
### 创建人 / 修改人 (type=1003, type=1004)
|
||||
|
||||
**返回格式**: 对象数组(与人员字段相同)
|
||||
|
||||
```json
|
||||
{
|
||||
"创建人": [
|
||||
{
|
||||
"id": "ou_8240099442cf5da49f04f4bf8f8abcef",
|
||||
"name": "黄泡泡",
|
||||
"en_name": "Amanda Huang",
|
||||
"email": "amandahuang@xxxgmail.com",
|
||||
"avatar_url": "https://..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 只读**: 不能通过写接口设置
|
||||
|
||||
---
|
||||
|
||||
### 自动编号 (type=1005)
|
||||
|
||||
**返回格式**: 字符串
|
||||
|
||||
```json
|
||||
{
|
||||
"工单号": "WO-20240226-0001"
|
||||
}
|
||||
```
|
||||
|
||||
**⚠️ 只读**: 不能通过写接口设置
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见错误与排查
|
||||
|
||||
### 字段类型不匹配 (错误码 1254015)
|
||||
|
||||
**错误示例**:
|
||||
|
||||
```json
|
||||
// ❌ 错误: 日期字段传字符串
|
||||
{
|
||||
"fields": {
|
||||
"截止日期": "2024-02-26" // 应该传时间戳
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
{
|
||||
"fields": {
|
||||
"截止日期": 1708905600000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 人员字段格式错误 (错误码 1254066)
|
||||
|
||||
**常见原因**:
|
||||
|
||||
1. **传入了不支持的字段**:
|
||||
|
||||
```json
|
||||
// ❌ 错误
|
||||
{
|
||||
"负责人": [
|
||||
{"name": "张三"} // 只能传 id
|
||||
]
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
{
|
||||
"负责人": [
|
||||
{"id": "ou_xxx"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2. **user_id_type 不匹配**:
|
||||
|
||||
```bash
|
||||
# 请求时指定了 user_id_type=open_id,但传的是 union_id
|
||||
```
|
||||
|
||||
3. **跨应用传 open_id**:
|
||||
|
||||
```
|
||||
不同应用的 open_id 不能交叉使用,建议使用 user_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 附件未挂载 (错误码 1254303)
|
||||
|
||||
**原因**: 直接传入外部 file_token
|
||||
|
||||
**解决**:
|
||||
|
||||
1. 先调用[上传素材接口](https://go.feishu.cn/s/63soQp6O80s)上传到当前多维表格
|
||||
2. 使用返回的 `file_token` 写入记录
|
||||
|
||||
---
|
||||
|
||||
### 字段名不存在 (错误码 1254045)
|
||||
|
||||
**原因**: 字段名称不完全匹配(可能有空格、换行、特殊字符)
|
||||
|
||||
**排查**:
|
||||
|
||||
1. 调用[列出字段接口](https://go.feishu.cn/s/62nuKkQlk03)获取准确字段名
|
||||
2. 检查首尾空格、换行符
|
||||
|
||||
---
|
||||
|
||||
### 超链接字段转换失败 (错误码 1254068)
|
||||
|
||||
**原因**: 缺少 `text` 或 `link` 字段
|
||||
|
||||
```json
|
||||
// ❌ 错误
|
||||
{
|
||||
"参考链接": {
|
||||
"link": "https://example.com" // 缺少 text
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 正确
|
||||
{
|
||||
"参考链接": {
|
||||
"text": "示例网站",
|
||||
"link": "https://example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📌 最佳实践
|
||||
|
||||
### 1. 批量写入优化
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"任务名称": "拜访客户",
|
||||
"负责人": [{"id": "ou_xxx"}],
|
||||
"截止日期": 1708905600000,
|
||||
"标签": ["重要", "紧急"],
|
||||
"是否完成": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**建议**:
|
||||
- 一次性传入所有字段,避免多次调用
|
||||
- 只传需要设置的字段,不必包含所有列
|
||||
|
||||
---
|
||||
|
||||
### 2. 清空字段值
|
||||
|
||||
**方法 1**: 传 `null`
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"负责人": null,
|
||||
"标签": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方法 2**: 传空数组/空字符串(根据字段类型)
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": {
|
||||
"负责人": [],
|
||||
"任务名称": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 时间戳转换
|
||||
|
||||
**JavaScript**:
|
||||
|
||||
```javascript
|
||||
// 北京时间字符串 → Unix 毫秒时间戳
|
||||
const timestamp = new Date("2024-02-26 14:00").getTime() // 1708927200000
|
||||
|
||||
// Unix 毫秒时间戳 → 日期字符串
|
||||
const date = new Date(1708927200000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })
|
||||
```
|
||||
|
||||
**Python**:
|
||||
|
||||
```python
|
||||
import datetime
|
||||
|
||||
# 北京时间字符串 → Unix 毫秒时间戳
|
||||
dt = datetime.datetime(2024, 2, 26, 14, 0, 0)
|
||||
timestamp = int(dt.timestamp() * 1000) # 1708927200000
|
||||
|
||||
# Unix 毫秒时间戳 → 日期字符串
|
||||
dt = datetime.datetime.fromtimestamp(1708927200000 / 1000)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 关联字段的级联更新
|
||||
|
||||
**双向关联**:
|
||||
|
||||
```json
|
||||
// 更新 Table A 的双向关联字段
|
||||
{
|
||||
"fields": {
|
||||
"关联项目": {
|
||||
"link_record_ids": ["rec123"]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table B 的对应双向关联字段会自动更新
|
||||
```
|
||||
|
||||
**单向关联**:
|
||||
|
||||
```json
|
||||
// 只更新当前表,不影响关联表
|
||||
{
|
||||
"fields": {
|
||||
"参考任务": {
|
||||
"link_record_ids": ["rec456"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 参考链接
|
||||
|
||||
- [飞书开放平台 - 多维表格记录数据结构](https://go.feishu.cn/s/6lY28723w04)
|
||||
- [新增记录接口文档](https://go.feishu.cn/s/61Y-IrQjU02)
|
||||
- [更新记录接口文档](https://go.feishu.cn/s/6lY28723A04)
|
||||
- [上传素材接口](https://go.feishu.cn/s/63soQp6O80s)
|
||||
@ -0,0 +1,244 @@
|
||||
---
|
||||
name: feishu-calendar
|
||||
description: |
|
||||
飞书日历与日程管理工具集。包含日历管理、日程管理、参会人管理、忙闲查询。
|
||||
---
|
||||
|
||||
# 飞书日历管理 (feishu-calendar)
|
||||
|
||||
## 🚨 执行前必读
|
||||
|
||||
- ✅ **时区固定**:Asia/Shanghai(UTC+8)
|
||||
- ✅ **时间格式**:ISO 8601 / RFC 3339(带时区),例如 `2026-02-25T14:00:00+08:00`
|
||||
- ✅ **create 最小必填**:summary, start_time, end_time
|
||||
- ✅ **user_open_id 强烈建议**:从 SenderId 获取(ou_xxx),确保用户能看到日程
|
||||
- ✅ **ID 格式约定**:用户 `ou_...`,群 `oc_...`,会议室 `omm_...`,邮箱 `email@...`
|
||||
|
||||
---
|
||||
|
||||
## 📋 快速索引:意图 → 工具 → 必填参数
|
||||
|
||||
| 用户意图 | 工具 | action | 必填参数 | 强烈建议 | 常用可选 |
|
||||
|---------|------|--------|---------|---------|---------|
|
||||
| 创建会议 | feishu_calendar_event | create | summary, start_time, end_time | user_open_id | attendees, description, location |
|
||||
| 查某时间段日程 | feishu_calendar_event | list | start_time, end_time | - | - |
|
||||
| 改日程时间 | feishu_calendar_event | patch | event_id, start_time/end_time | - | summary, description |
|
||||
| 搜关键词找会 | feishu_calendar_event | search | query | - | - |
|
||||
| 回复邀请 | feishu_calendar_event | reply | event_id, rsvp_status | - | - |
|
||||
| 查重复日程实例 | feishu_calendar_event | instances | event_id, start_time, end_time | - | - |
|
||||
| 查忙闲 | feishu_calendar_freebusy | list | time_min, time_max, user_ids[] | - | - |
|
||||
| 邀请参会人 | feishu_calendar_event_attendee | create | calendar_id, event_id, attendees[] | - | - |
|
||||
| 删除参会人 | feishu_calendar_event_attendee | batch_delete | calendar_id, event_id, user_open_ids[] | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心约束(Schema 未透露的知识)
|
||||
|
||||
### 1. user_open_id 为什么必填?
|
||||
|
||||
**工具使用用户身份**:日程创建在用户主日历上,用户本人能看到。
|
||||
|
||||
**但为什么还要传 user_open_id**:将发起人也添加为**参会人**,确保:
|
||||
- ✅ 发起人会收到日程通知
|
||||
- ✅ 发起人可以回复 RSVP 状态(接受/拒绝/待定)
|
||||
- ✅ 发起人出现在参会人列表中
|
||||
- ✅ 其他参会人能看到发起人
|
||||
|
||||
**如果不传**:
|
||||
- ⚠️ 用户能看到日程,但不会作为参会人
|
||||
- ⚠️ 如果只有其他参会人,发起人不在列表中(不符合常规逻辑)
|
||||
|
||||
### 2. 参会人权限(attendee_ability)
|
||||
|
||||
工具已默认设置 `attendee_ability: "can_modify_event"`,参会人可以编辑日程和管理参与者。
|
||||
|
||||
| 权限值 | 能力 |
|
||||
|--------|------|
|
||||
| `none` | 无权限 |
|
||||
| `can_see_others` | 可查看参与人列表 |
|
||||
| `can_invite_others` | 可邀请他人 |
|
||||
| `can_modify_event` | 可编辑日程(推荐) |
|
||||
|
||||
### 3. 统一使用 open_id(ou_...格式)
|
||||
|
||||
- ✅ 创建日程:`user_open_id = SenderId`
|
||||
- ✅ 邀请参会人:`attendees[].id = "ou_xxx"`
|
||||
- ✅ 删除参会人:`user_open_ids = ["ou_xxx"]`(工具已优化,直接传 open_id 即可)
|
||||
|
||||
⚠️ **ID 格式区分**:
|
||||
- `ou_xxx`:用户的 open_id(**你应该使用的**)
|
||||
- `user_xxx`:日程内部的 attendee_id(list 接口返回,仅用于内部记录)
|
||||
|
||||
### 4. 会议室预约是异步流程
|
||||
|
||||
添加会议室类型参会人后,会议室进入异步预约流程:
|
||||
1. API 返回成功 → `rsvp_status: "needs_action"`(预约中)
|
||||
2. 后台异步处理
|
||||
3. 最终状态:`accept`(成功)或 `decline`(失败)
|
||||
|
||||
**查询预约结果**:使用 `feishu_calendar_event_attendee.list` 查看 `rsvp_status`。
|
||||
|
||||
### 5. instances action 仅对重复日程有效
|
||||
|
||||
**⚠️ 重要**:`instances` action **仅对重复日程有效**,必须满足:
|
||||
1. event_id 必须是重复日程的 ID(该日程具有 `recurrence` 字段)
|
||||
2. 如果对普通日程调用,会返回错误
|
||||
|
||||
**如何判断**:
|
||||
1. 先用 `get` action 获取日程详情
|
||||
2. 检查返回值中是否有 `recurrence` 字段且不为空
|
||||
3. 如果有,则可以调用 `instances` 获取实例列表
|
||||
|
||||
---
|
||||
|
||||
## 📌 使用场景示例
|
||||
|
||||
### 场景 1: 创建会议并邀请参会人
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"summary": "项目复盘会议",
|
||||
"description": "讨论 Q1 项目进展",
|
||||
"start_time": "2026-02-25 14:00:00",
|
||||
"end_time": "2026-02-25 15:30:00",
|
||||
"user_open_id": "ou_aaa",
|
||||
"attendees": [
|
||||
{"type": "user", "id": "ou_bbb"},
|
||||
{"type": "user", "id": "ou_ccc"},
|
||||
{"type": "resource", "id": "omm_xxx"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2: 查询用户未来一周的日程
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"start_time": "2026-02-25 00:00:00",
|
||||
"end_time": "2026-03-03 23:59:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 3: 查看多个用户的忙闲时间
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"time_min": "2026-02-25 09:00:00",
|
||||
"time_max": "2026-02-25 18:00:00",
|
||||
"user_ids": ["ou_aaa", "ou_bbb", "ou_ccc"]
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:user_ids 是数组,支持 1-10 个用户。当前不支持会议室忙闲查询。
|
||||
|
||||
### 场景 4: 修改日程时间
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "patch",
|
||||
"event_id": "xxx_0",
|
||||
"start_time": "2026-02-25 15:00:00",
|
||||
"end_time": "2026-02-25 16:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 5: 搜索日程(按关键词)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "search",
|
||||
"query": "项目复盘"
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 6: 回复日程邀请
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "reply",
|
||||
"event_id": "xxx_0",
|
||||
"rsvp_status": "accept"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| **发起人不在参会人列表中** | 未传 `user_open_id` | 强烈建议传 `user_open_id = SenderId` |
|
||||
| **参会人看不到其他参会人** | `attendee_ability` 权限不足 | 工具已默认设置 `can_modify_event` |
|
||||
| **时间不对** | 使用了 Unix 时间戳 | 改用 ISO 8601 格式(带时区):`2024-01-01T00:00:00+08:00` |
|
||||
| **会议室显示"预约中"** | 会议室预约是异步的 | 等待几秒后用 `list` 查询 `rsvp_status` |
|
||||
| **修改日程报权限错误** | 当前用户不是组织者,且日程未设置可编辑权限 | 确保日程创建时设置了 `attendee_ability: "can_modify_event"` |
|
||||
| **无法查看参会人列表** | 当前用户无查看权限 | 确保是组织者或日程设置了 `can_see_others` 以上权限 |
|
||||
|
||||
---
|
||||
|
||||
## 📚 附录:背景知识
|
||||
|
||||
### A. 日历架构模型
|
||||
|
||||
飞书日历采用 **三层架构**:
|
||||
```
|
||||
日历(Calendar)
|
||||
└── 日程(Event)
|
||||
└── 参会人(Attendee)
|
||||
```
|
||||
|
||||
**关键理解**:
|
||||
1. **用户主日历**:日程创建在发起用户的主日历上,用户本人能看到
|
||||
2. **参会人机制**:通过添加参会人(attendee),让其他人的日历中也显示此日程
|
||||
3. **权限模型**:日程的 `attendee_ability` 参数控制参会人能否编辑日程、邀请他人、查看参与人列表
|
||||
|
||||
### B. 参会人类型
|
||||
|
||||
- `type: "user"` + `id: "ou_xxx"` — 飞书用户(使用 open_id)
|
||||
- `type: "chat"` + `id: "oc_xxx"` — 飞书群组
|
||||
- `type: "resource"` + `id: "omm_xxx"` — 会议室
|
||||
- `type: "third_party"` + `id: "email@example.com"` — 外部邮箱
|
||||
|
||||
### C. 日程的生命周期
|
||||
|
||||
1. **创建**:在用户主日历上创建日程(工具使用用户身份)
|
||||
2. **邀请参会人**:通过 attendee API 将日程分享给其他参会人
|
||||
3. **参会人回复**:参会人可以 accept/decline/tentative
|
||||
4. **修改**:组织者或有权限的参会人可以修改
|
||||
5. **删除**:删除后状态变为 `cancelled`
|
||||
|
||||
### D. 日历类型说明
|
||||
|
||||
| 类型 | 说明 | 能否删除 | 能否修改 |
|
||||
|------|------|---------|---------|
|
||||
| `primary` | 主日历(每个用户/应用一个) | ❌ 否 | ✅ 是 |
|
||||
| `shared` | 共享日历(用户创建并共享) | ✅ 是 | ✅ 是 |
|
||||
| `resource` | 会议室日历 | ❌ 否 | ❌ 否 |
|
||||
| `google` | 绑定的 Google 日历 | ❌ 否 | ❌ 否 |
|
||||
| `exchange` | 绑定的 Exchange 日历 | ❌ 否 | ❌ 否 |
|
||||
|
||||
### E. 回复状态(rsvp_status)说明
|
||||
|
||||
| 状态 | 含义(用户) | 含义(会议室) |
|
||||
|------|------------|---------------|
|
||||
| `needs_action` | 未回复 | 预约中 |
|
||||
| `accept` | 已接受 | 预约成功 |
|
||||
| `tentative` | 待定 | - |
|
||||
| `decline` | 拒绝 | 预约失败 |
|
||||
| `removed` | 已被移除 | 已被移除 |
|
||||
|
||||
|
||||
### F. 使用限制(来自飞书 OAPI 文档)
|
||||
|
||||
1. **每个日程最多 3000 名参会人**
|
||||
2. **单次添加参会人上限**:
|
||||
- 用户类参会人:1000 人
|
||||
- 会议室:100 个
|
||||
3. **主日历不可删除**(type 为 primary 的日历)
|
||||
4. **会议室预约可能失败**:
|
||||
- 时间冲突
|
||||
- 无预约权限
|
||||
- 会议室配置限制
|
||||
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: feishu-channel-rules
|
||||
description: |
|
||||
Lark/Feishu channel output rules. Always active in Lark conversations.
|
||||
alwaysActive: true
|
||||
---
|
||||
|
||||
# Lark Output Rules
|
||||
|
||||
## Writing Style
|
||||
|
||||
- Short, conversational, low ceremony — talk like a coworker, not a manual
|
||||
- Prefer plain sentences over bullet lists when a brief answer suffices
|
||||
- Get to the point and stop — no need for a summary paragraph every time
|
||||
|
||||
## Note
|
||||
|
||||
- Lark Markdown differs from standard Markdown in some ways; when unsure, refer to `references/markdown-syntax.md`
|
||||
@ -0,0 +1,138 @@
|
||||
# 飞书 Markdown 语法参考
|
||||
|
||||
> 本文件是飞书消息卡片支持的完整 Markdown 语法参考,供需要时查阅。
|
||||
|
||||
## 1. 标题
|
||||
|
||||
```
|
||||
#### 四级标题
|
||||
##### 五级标题
|
||||
```
|
||||
|
||||
- **不支持**一二三级标题(`#`、`##`、`###`),会导致卡片显示异常
|
||||
- 可用加粗替代标题效果
|
||||
|
||||
## 2. 换行
|
||||
|
||||
```
|
||||
第一行\n第二行
|
||||
```
|
||||
|
||||
## 3. 文本样式
|
||||
|
||||
| 语法 | 效果 |
|
||||
|------|------|
|
||||
| `**加粗**` | **加粗** |
|
||||
| `*斜体*` | *斜体* |
|
||||
| `~~删除线~~` | ~~删除线~~ |
|
||||
|
||||
> **注意**:加粗中间的内容只能是中文或英文,不能有中文符号或表情符号
|
||||
|
||||
## 4. 链接
|
||||
|
||||
```
|
||||
[链接文本](https://www.example.com)
|
||||
```
|
||||
|
||||
## 5. @指定人
|
||||
|
||||
```
|
||||
<at id=id_01></at>
|
||||
<at ids=id_01,id_02,xxx></at>
|
||||
```
|
||||
|
||||
- 用户的 id 必须是用户给你的,不能瞎编
|
||||
- 可能是:以 `ou_` 开头的字符串、不超过 10 位的字符串、邮箱
|
||||
|
||||
## 6. 超链接
|
||||
|
||||
```
|
||||
<a href='https://open.feishu.cn'></a>
|
||||
```
|
||||
|
||||
## 7. 彩色文本
|
||||
|
||||
```
|
||||
<font color='green'>绿色文本</font>
|
||||
```
|
||||
|
||||
> 颜色枚举:`neutral`, `blue`, `turquoise`, `lime`, `orange`, `violet`, `wathet`, `green`, `yellow`, `red`, `purple`, `carmine`
|
||||
|
||||
## 8. 文字链接
|
||||
|
||||
```
|
||||
<a href='https://open.feishu.cn'>这是文字链接</a>
|
||||
```
|
||||
|
||||
## 9. 图片
|
||||
|
||||
```
|
||||

|
||||
```
|
||||
|
||||
> image_key 不支持 http 链接
|
||||
|
||||
## 10. 分割线
|
||||
|
||||
```
|
||||
---
|
||||
```
|
||||
|
||||
## 11. 标签
|
||||
|
||||
```
|
||||
<text_tag color='red'>标签文本</text_tag>
|
||||
```
|
||||
|
||||
颜色枚举:`neutral`, `blue`, `turquoise`, `lime`, `orange`, `violet`, `wathet`, `green`, `yellow`, `red`, `purple`, `carmine`
|
||||
|
||||
## 12. 有序列表
|
||||
|
||||
```
|
||||
1. 一级列表①
|
||||
1.1 二级列表
|
||||
1.2 二级列表
|
||||
2. 一级列表②
|
||||
```
|
||||
|
||||
- 序号需在行首使用,序号后要跟空格
|
||||
- 4 个空格代表一层缩进
|
||||
|
||||
## 13. 无序列表
|
||||
|
||||
```
|
||||
- 一级列表①
|
||||
- 二级列表
|
||||
- 一级列表②
|
||||
```
|
||||
|
||||
- 4 个空格代表一层缩进
|
||||
- `-` 后面要跟空格
|
||||
|
||||
## 14. 代码块
|
||||
|
||||
````
|
||||
```JSON
|
||||
{"This is": "JSON demo"}
|
||||
```
|
||||
````
|
||||
|
||||
- 支持指定编程语言解析
|
||||
- 未指定默认为 Plain Text
|
||||
|
||||
## 15. 人员组件
|
||||
|
||||
```
|
||||
<person id='user_id' show_name=true show_avatar=true style='normal'></person>
|
||||
```
|
||||
|
||||
- `show_name`:是否展示用户名(默认 true)
|
||||
- `show_avatar`:是否展示用户头像(默认 true)
|
||||
- `style`:展示样式(`normal`:普通样式,`capsule`:胶囊样式)
|
||||
- **注意**:person 标签不能嵌套在 font 中
|
||||
|
||||
## 16. 数字角标
|
||||
|
||||
```
|
||||
<number_tag background_color='grey' font_color='white' url='https://open.feishu.cn' pc_url='https://open.feishu.cn' android_url='https://open.feishu.cn' ios_url='https://open.feishu.cn'>1</number_tag>
|
||||
```
|
||||
@ -0,0 +1,719 @@
|
||||
---
|
||||
name: feishu-create-doc
|
||||
description: |
|
||||
创建飞书云文档。从 Lark-flavored Markdown 内容创建新的飞书云文档,支持指定创建位置(文件夹/知识库/知识空间)。
|
||||
---
|
||||
|
||||
# feishu_mcp_create_doc
|
||||
|
||||
通过 MCP 调用 `create-doc`,从 Lark-flavored Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
# 返回值
|
||||
|
||||
工具成功执行后,返回一个 JSON 对象,包含以下字段:
|
||||
|
||||
- **`doc_id`**(string):文档的唯一标识符(token),格式如 `doxcnXXXXXXXXXXXXXXXXXXX`
|
||||
- **`doc_url`**(string):文档的访问链接,可直接在浏览器中打开,格式如 `https://www.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX`
|
||||
- **`message`**(string):操作结果消息,如"文档创建成功"
|
||||
|
||||
|
||||
# 参数
|
||||
|
||||
## markdown(必填)
|
||||
文档的 Markdown 内容,使用 Lark-flavored Markdown 格式。
|
||||
|
||||
调用本工具的markdown内容应当尽量结构清晰,样式丰富, 有很高的可读性. 合理的使用callout高亮块, 分栏,表格等能力,并合理的运用插入图片与mermaid的能力,做到图文并茂..
|
||||
你需要遵循以下原则:
|
||||
|
||||
- **结构清晰**:标题层级 ≤ 4 层,用 Callout 突出关键信息
|
||||
- **视觉节奏**:用分割线、分栏、表格打破大段纯文字
|
||||
- **图文交融**:流程和架构优先用 Mermaid/PlantUML 可视化
|
||||
- **克制留白**:Callout 不过度、加粗只强调核心词
|
||||
|
||||
当用户有明确的样式,风格需求时,应当以用户的需求为准!!
|
||||
|
||||
**重要提示**:
|
||||
- **禁止重复标题**:markdown 内容开头不要写与 title 相同的一级标题!title 参数已经是文档标题,markdown 应直接从正文内容开始
|
||||
- **目录**:飞书自动生成,无需手动添加
|
||||
- Markdown 语法必须符合 Lark-flavored Markdown 规范,详见下方"内容格式"章节
|
||||
- 创建较长的文档时,强烈建议配合update-doc中的append mode, 进行分段的创建,提高成功率.
|
||||
|
||||
## title(可选)
|
||||
文档标题。
|
||||
|
||||
## folder_token(可选)
|
||||
父文件夹的 token。如果不提供,文档将创建在用户的个人空间根目录。
|
||||
|
||||
folder_token 可以从飞书文件夹 URL 中获取,格式如:`https://xxx.feishu.cn/drive/folder/fldcnXXXX`,其中 `fldcnXXXX` 即为 folder_token。
|
||||
|
||||
## wiki_node(可选)
|
||||
知识库节点 token 或 URL(可选,传入则在该节点下创建文档,与 folder_token 和 wiki_space 互斥)
|
||||
|
||||
wiki_node 可以从飞书知识库页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/wikcnXXXX`,其中 `wikcnXXXX` 即为 wiki_node token。
|
||||
|
||||
## wiki_space(可选)
|
||||
知识空间 ID(可选,传入则在该空间根目录下创建文档。特殊值 `my_library` 表示用户的个人知识库。与 wiki_node 和 folder_token 互斥)
|
||||
|
||||
wiki_space 可以从知识空间设置页面 URL 中获取,格式如:`https://xxx.feishu.cn/wiki/settings/7448000000000009300`,其中 `7448000000000009300` 即为 wiki_space ID。
|
||||
|
||||
**参数优先级**:wiki_node > wiki_space > folder_token
|
||||
|
||||
# 示例
|
||||
|
||||
## 示例 1:创建简单文档
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "项目计划",
|
||||
"markdown": "# 项目概述\n\n这是一个新项目。\n\n## 目标\n\n- 目标 1\n- 目标 2"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 2:创建到指定文件夹
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "会议纪要",
|
||||
"folder_token": "fldcnXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"markdown": "# 周会 2025-01-15\n\n## 讨论议题\n\n1. 项目进度\n2. 下周计划"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 3:使用飞书扩展语法
|
||||
|
||||
使用高亮块、表格等飞书特有功能:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "产品需求",
|
||||
"markdown": "<callout emoji=\"💡\" background-color=\"light-blue\">\n重要需求说明\n</callout>\n\n## 功能列表\n\n<lark-table header-row=\"true\">\n| 功能 | 优先级 |\n|------|--------|\n| 登录 | P0 |\n| 导出 | P1 |\n</lark-table>"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 4:创建到知识库节点下
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "技术文档",
|
||||
"wiki_node": "wikcnXXXXXXXXXXXXXXXXXXXXXX",
|
||||
"markdown": "# API 接口说明\n\n这是一个知识库文档。"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 5:创建到知识空间根目录
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "项目概览",
|
||||
"wiki_space": "7448000000000009300",
|
||||
"markdown": "# 项目概览\n\n这是知识空间根目录下的一级文档。"
|
||||
}
|
||||
```
|
||||
|
||||
## 示例 6:创建到个人知识库
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "学习笔记",
|
||||
"wiki_space": "my_library",
|
||||
"markdown": "# 学习笔记\n\n这是创建在个人知识库中的文档。"
|
||||
}
|
||||
```
|
||||
|
||||
# 内容格式
|
||||
|
||||
文档内容使用 **Lark-flavored Markdown** 格式,这是标准 Markdown 的扩展版本,支持飞书文档的所有块类型和富文本格式。
|
||||
|
||||
## 通用规则
|
||||
|
||||
- 使用标准 Markdown 语法作为基础
|
||||
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
|
||||
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
|
||||
|
||||
---
|
||||
|
||||
## 📝 基础块类型
|
||||
|
||||
### 文本(段落)
|
||||
|
||||
```markdown
|
||||
普通文本段落
|
||||
|
||||
段落中的**粗体文字**
|
||||
|
||||
多个段落之间用空行分隔。
|
||||
|
||||
居中文本 {align="center"}
|
||||
右对齐文本 {align="right"}
|
||||
```
|
||||
|
||||
**段落对齐**:支持 `{align="left|center|right"}` 语法。可与颜色组合:`{color="blue" align="center"}`
|
||||
|
||||
### 标题
|
||||
|
||||
飞书支持 9 级标题。H1-H6 使用标准 Markdown 语法,H7-H9 使用 HTML 标签:
|
||||
|
||||
```markdown
|
||||
# 一级标题
|
||||
## 二级标题
|
||||
### 三级标题
|
||||
#### 四级标题
|
||||
##### 五级标题
|
||||
###### 六级标题
|
||||
<h7>七级标题</h7>
|
||||
<h8>八级标题</h8>
|
||||
<h9>九级标题</h9>
|
||||
|
||||
# 带颜色的标题 {color="blue"}
|
||||
## 红色标题 {color="red"}
|
||||
# 居中标题 {align="center"}
|
||||
## 蓝色居中标题 {color="blue" align="center"}
|
||||
```
|
||||
|
||||
**标题属性**:支持 `{color="颜色名"}` 和 `{align="left|center|right"}` 语法,可组合使用。颜色值:red, orange, yellow, green, blue, purple, gray。请谨慎使用该能力.
|
||||
|
||||
### 列表
|
||||
有序列表,无序列表嵌套使用tab或者 2 空格缩进
|
||||
```markdown
|
||||
- 无序项1(
|
||||
- 无序项1.a
|
||||
- 无序项1.b
|
||||
|
||||
1. 有序项1
|
||||
2. 有序项2
|
||||
|
||||
- [ ] 待办
|
||||
- [x] 已完成
|
||||
```
|
||||
|
||||
### 引用块
|
||||
|
||||
```markdown
|
||||
> 这是一段引用
|
||||
> 可以跨多行
|
||||
|
||||
> 引用中支持**加粗**和*斜体*等格式
|
||||
```
|
||||
|
||||
### 代码块
|
||||
|
||||
**⚠️** 只支持围栏代码块(` ``` `),不支持缩进代码块。
|
||||
|
||||
````markdown
|
||||
```python
|
||||
print("Hello")
|
||||
```
|
||||
````
|
||||
|
||||
支持语言:python, javascript, go, java, sql, json, yaml, shell 等。
|
||||
|
||||
### 分割线
|
||||
|
||||
```markdown
|
||||
---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 富文本格式
|
||||
|
||||
### 文本样式
|
||||
|
||||
`**粗体**` `*斜体*` `~~删除线~~` `` `行内代码` `` `<u>下划线</u>`
|
||||
|
||||
### 文字颜色
|
||||
|
||||
`<text color="red">红色</text>` `<text background-color="yellow">黄色背景</text>`
|
||||
|
||||
支持: red, orange, yellow, green, blue, purple, gray
|
||||
|
||||
### 链接
|
||||
|
||||
`[链接文字](https://example.com)` (不支持锚点链接)
|
||||
|
||||
### 行内公式(LaTeX)
|
||||
|
||||
`$E = mc^2$`(`$`前后需空格)或 `<equation>E = mc^2</equation>`(无限制,推荐)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 高级块类型
|
||||
|
||||
### 高亮块(Callout)
|
||||
|
||||
```html
|
||||
<callout emoji="✅" background-color="light-green" border-color="green">
|
||||
支持**格式化**的内容,可包含多个块
|
||||
</callout>
|
||||
```
|
||||
|
||||
**属性**: emoji (使用emoji 字符如 ✅ ⚠️ 💡), background-color, border-color, text-color
|
||||
|
||||
**背景色**: light-red/red, light-blue/blue, light-green/green, light-yellow/yellow, light-orange/orange, light-purple/purple, pale-gray/light-gray/dark-gray
|
||||
|
||||
**常用**: 💡light-blue(提示) ⚠️light-yellow(警告) ❌light-red(危险) ✅light-green(成功)
|
||||
|
||||
**限制**: callout子块仅支持文本、标题、列表、待办、引用。不支持代码块、表格、图片。
|
||||
|
||||
### 分栏(Grid)
|
||||
|
||||
适合对比、并列展示场景。支持 2-5 列:
|
||||
#### 两栏(等宽)
|
||||
|
||||
```html
|
||||
<grid cols="2">
|
||||
<column>
|
||||
|
||||
左栏内容
|
||||
|
||||
</column>
|
||||
<column>
|
||||
|
||||
右栏内容
|
||||
|
||||
</column>
|
||||
</grid>
|
||||
```
|
||||
#### 三栏自定义宽度
|
||||
```html
|
||||
<grid cols="3">
|
||||
<column width="20">左栏(20%)</column>
|
||||
<column width="60">中栏(60%)</column>
|
||||
<column width="20">右栏(20%)</column>
|
||||
</grid>
|
||||
```
|
||||
|
||||
**属性**: `cols`(列数 2-5), `width`(列宽百分比,总和为100,等宽时可省略)
|
||||
|
||||
### 表格
|
||||
|
||||
#### 标准 Markdown 表格
|
||||
|
||||
```markdown
|
||||
| 列 1 | 列 2 | 列 3 |
|
||||
|------|------|------|
|
||||
| 单元格 1 | 单元格 2 | 单元格 3 |
|
||||
| 单元格 4 | 单元格 5 | 单元格 6 |
|
||||
```
|
||||
|
||||
#### 飞书增强表格
|
||||
|
||||
当单元格需要复杂内容(列表、代码块、高亮块等)时使用。
|
||||
|
||||
**层级结构**(必须严格遵守):
|
||||
```
|
||||
<lark-table> ← 表格容器
|
||||
<lark-tr> ← 行(直接子元素只能是 lark-tr)
|
||||
<lark-td>内容</lark-td> ← 单元格(直接子元素只能是 lark-td)
|
||||
<lark-td>内容</lark-td> ← 每行的 lark-td 数量必须相同!
|
||||
</lark-tr>
|
||||
</lark-table>
|
||||
```
|
||||
|
||||
**属性**:
|
||||
- `column-widths`:列宽,逗号分隔像素值,总宽≈730
|
||||
- `header-row`:首行是否为表头(`"true"` 或 `"false"`)
|
||||
- `header-column`:首列是否为表头(`"true"` 或 `"false"`)
|
||||
|
||||
**单元格写法**:内容前后必须空行
|
||||
```html
|
||||
<lark-td>
|
||||
|
||||
这里写内容
|
||||
|
||||
</lark-td>
|
||||
```
|
||||
|
||||
**完整示例**(2行3列):
|
||||
```html
|
||||
<lark-table column-widths="200,250,280" header-row="true">
|
||||
<lark-tr>
|
||||
<lark-td>
|
||||
|
||||
**表头1**
|
||||
|
||||
</lark-td>
|
||||
<lark-td>
|
||||
|
||||
**表头2**
|
||||
|
||||
</lark-td>
|
||||
<lark-td>
|
||||
|
||||
**表头3**
|
||||
|
||||
</lark-td>
|
||||
</lark-tr>
|
||||
<lark-tr>
|
||||
<lark-td>
|
||||
|
||||
普通文本
|
||||
|
||||
</lark-td>
|
||||
<lark-td>
|
||||
|
||||
- 列表项1
|
||||
- 列表项2
|
||||
|
||||
</lark-td>
|
||||
<lark-td>
|
||||
|
||||
代码内容
|
||||
|
||||
</lark-td>
|
||||
</lark-tr>
|
||||
</lark-table>
|
||||
```
|
||||
|
||||
**限制**:单元格内不支持 Grid 和嵌套表格
|
||||
|
||||
**合并单元格**:读取时返回 `rowspan/colspan` 属性,创建暂不支持
|
||||
|
||||
**禁止**:
|
||||
- 混用 Markdown 表格语法(`|---|`)
|
||||
- 使用 `<br/>` 换行
|
||||
- 遗漏 `<lark-td>` 标签
|
||||
|
||||
|
||||
### 图片
|
||||
|
||||
```html
|
||||
<image url="https://example.com/image.png" width="800" height="600" align="center" caption="图片描述文字"/>
|
||||
```
|
||||
|
||||
**属性**: url (必需,系统会自动下载并上传), width, height, align (left/center/right), caption
|
||||
|
||||
**⚠️ 重要**: 不支持直接使用 `token` 属性(如 `<image token="xxx"/>`),只支持 URL 方式。系统会自动下载图片并上传到飞书。
|
||||
|
||||
支持 PNG/JPG/GIF/WebP/BMP,最大 10MB
|
||||
|
||||
**图片/文件插入方式选择**:
|
||||
- **有公开可访问的图片 URL** → 直接在 create-doc / update-doc 的 markdown 中使用 `<image url="..."/>` 一步到位
|
||||
|
||||
- **本地图片或文件**(如用户在聊天中发送的图片/文件) → 先用 create-doc / update-doc 创建或更新文档文本内容,再用 `feishu_doc_media` 工具将本地图片或文件追加到文档末尾。如需媒体出现在文档中间特定位置,可先用 create-doc 写好之前的内容,调用 `feishu_doc_media` 追加图片/文件,最后用 update-doc 的 **append** 模式追加后续内容
|
||||
|
||||
### 文件
|
||||
|
||||
```html
|
||||
<file url="https://example.com/document.pdf" name="文档.pdf" view-type="1"/>
|
||||
```
|
||||
|
||||
**属性**:
|
||||
- url (文件 URL,必需,系统会自动下载并上传)
|
||||
- name (文件名,必需)
|
||||
- view-type (1=卡片视图, 2=预览视图,可选)
|
||||
|
||||
**⚠️ 重要**: 不支持直接使用 `token` 属性(如 `<file token="xxx"/>`)
|
||||
|
||||
|
||||
### 画板(Mermaid / PlantUML 图表)
|
||||
|
||||
支持两种图表语法:Mermaid 和 PlantUML。
|
||||
|
||||
#### Mermaid 图表
|
||||
|
||||
**图表优先选择此格式**. mermaid图表会被渲染为可视化的画板, 如果能用mermaid实现的图表,应当优先选择mermaid.
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
graph TD
|
||||
A[开始] --> B{判断}
|
||||
B -->|是| C[处理]
|
||||
B -->|否| D[结束]
|
||||
```
|
||||
````
|
||||
|
||||
**支持图表类型**: flowchart, sequenceDiagram, classDiagram, stateDiagram, gantt, mindmap, erDiagram
|
||||
|
||||
#### PlantUML 图表
|
||||
|
||||
PlantUML图表会被渲染为可视化的画板. mermaid满足不了的场景可以选择plantUML进行绘图.
|
||||
|
||||
````markdown
|
||||
```plantuml
|
||||
@startuml
|
||||
Alice -> Bob: Hello
|
||||
Bob --> Alice: Hi!
|
||||
@enduml
|
||||
```
|
||||
````
|
||||
|
||||
**支持图表类型**: sequence, usecase, class, activity, component, state, object, deployment
|
||||
|
||||
#### 读取画板
|
||||
|
||||
读取时返回 `<whiteboard>` 标签:
|
||||
|
||||
```html
|
||||
<whiteboard token="xxx" align="center" width="800" height="600"/>
|
||||
```
|
||||
|
||||
**属性**: token (画板标识), align (left/center/right), width, height
|
||||
|
||||
**重要说明**:
|
||||
- create-doc时用 Mermaid/PlantUML 代码块,系统自动转换为画板; 禁止以`<whiteboard>`的方式写入!!
|
||||
- 读取时只能获取 token,可通过fetch-file工具进行查看内容。无法获取原始源码
|
||||
|
||||
### 多维表格(Bitable)
|
||||
|
||||
```html
|
||||
<bitable view="table"/>
|
||||
<bitable view="kanban"/>
|
||||
```
|
||||
|
||||
**属性**: view (table/kanban,默认 table)
|
||||
|
||||
**注意**: token 是只读属性,创建时不能指定只能创建空的多维表格,创建后再手动添加数据。
|
||||
|
||||
### 会话卡片(ChatCard)
|
||||
|
||||
```html
|
||||
<chat-card id="oc_xxx" align="center"/>
|
||||
```
|
||||
|
||||
**属性**: id (格式 oc_xxx, 必需), align (left/center/right)
|
||||
|
||||
### 内嵌网页(Iframe)
|
||||
|
||||
```html
|
||||
<iframe url="https://example.com/survey?id=123" type="12"/>
|
||||
```
|
||||
|
||||
**属性**: url (必需), type (组件类型数字, 必需)
|
||||
|
||||
**type 枚举**: 1=Bilibili, 2=西瓜, 3=优酷, 4=Airtable, 5=百度地图, 6=高德地图, 8=Figma, 9=墨刀, 10=Canva, 11=CodePen, 12=飞书问卷, 13=金数据
|
||||
|
||||
**重要提示**: 仅支持上述列出的网页类型。其他类型的网页不支持嵌入,请不要使用 iframe。对于普通网页链接,请使用 Markdown 链接格式 `[链接文字](URL)` 代替。
|
||||
|
||||
### 链接预览(LinkPreview)
|
||||
|
||||
```html
|
||||
<link-preview url="消息链接" type="message"/>
|
||||
```
|
||||
|
||||
**属性**: url (必需, 只写属性), type (message=消息链接)
|
||||
|
||||
目前仅支持消息链接, 只支持读取, 不支持创建
|
||||
|
||||
### 引用容器(QuoteContainer)
|
||||
|
||||
```html
|
||||
<quote-container>
|
||||
引用容器内容
|
||||
</quote-container>
|
||||
```
|
||||
|
||||
与 quote 引用块不同,引用容器是容器类型,可包含多个子块
|
||||
|
||||
---
|
||||
|
||||
## 🔧 高级功能块
|
||||
|
||||
### 电子表格(Sheet)
|
||||
|
||||
```html
|
||||
<sheet rows="5" cols="5"/>
|
||||
<sheet/>
|
||||
```
|
||||
|
||||
**属性**: rows (行数,默认 3,最大 9), cols (列数,默认 3)
|
||||
|
||||
**注意**: token 是只读属性,创建时不能指定。只能创建空的电子表格,创建后使用 Sheet API 操作数据。
|
||||
|
||||
### 只读块类型 🔒
|
||||
|
||||
以下块类型仅支持读取,不支持创建:
|
||||
|
||||
| 块类型 | 标签 | 说明 |
|
||||
|--------|------|------|
|
||||
| 思维笔记 | `<mindnote token="xxx"/>` | 仅获取占位信息 |
|
||||
| 流程图/UML | `<diagram type="1"/>` | type: 1=流程图, 2=UML |
|
||||
| AI 模板 | `<ai-template/>` | 无内容占位块 |
|
||||
|
||||
### 任务块
|
||||
|
||||
```html
|
||||
<task task-id="xxx" members="ou_123, ou_456" due="2025-01-01">任务标题</task>
|
||||
```
|
||||
|
||||
**属性**: task-id, members (成员ID列表), due (截止日期)
|
||||
|
||||
### 同步块
|
||||
|
||||
```html
|
||||
<!-- 源同步块:内容在子块中 -->
|
||||
<source-synced align="1">子块内容...</source-synced>
|
||||
|
||||
<!-- 引用同步块:自动获取源文档内容 -->
|
||||
<reference-synced source-block-id="xxx" source-document-id="yyy">源内容...</reference-synced>
|
||||
```
|
||||
|
||||
**属性**: source-synced 有 align;reference-synced 有 source-block-id, source-document-id
|
||||
|
||||
### 文档小组件(AddOns)
|
||||
|
||||
```html
|
||||
<add-ons component-type-id="blk_xxx" record='{"key":"value"}'/>
|
||||
```
|
||||
|
||||
**属性**: component-type-id (小组件类型ID), record (JSON数据)
|
||||
|
||||
包含多种类型:问答互动、日期提醒等。部分组件如 Mermaid 已专门封装为 board 块
|
||||
|
||||
### 旧版小组件(ISV)
|
||||
|
||||
```html
|
||||
<isv id="comp_xxx" type="type_xxx"/>
|
||||
```
|
||||
|
||||
**属性**: component_id, component_type_id
|
||||
|
||||
旧版开放平台小组件,新版请使用 AddOns
|
||||
|
||||
### Wiki 子目录(WikiCatalog)🕰️
|
||||
|
||||
```html
|
||||
<wiki-catalog token="wiki_xxx"/>
|
||||
```
|
||||
|
||||
**属性**: wiki_token (知识库节点token)
|
||||
|
||||
🕰️ 旧版,建议使用新版 sub-page-list
|
||||
|
||||
### Wiki 子页面列表(SubPageList)
|
||||
|
||||
```html
|
||||
<sub-page-list wiki="wiki_xxx"/>
|
||||
```
|
||||
|
||||
**属性**: wiki_token (当前页面的wiki token)
|
||||
|
||||
仅支持知识库文档创建,需传入当前页面的 wiki token
|
||||
|
||||
### 议程(Agenda)
|
||||
|
||||
```html
|
||||
<agenda>
|
||||
<agenda-item>
|
||||
<agenda-title>议程标题</agenda-title>
|
||||
<agenda-content>议程内容</agenda-content>
|
||||
</agenda-item>
|
||||
</agenda>
|
||||
```
|
||||
|
||||
**结构**: agenda (容器) → agenda_item (议程项) → agenda_title (标题) + agenda_content (内容)
|
||||
|
||||
### Jira 问题(JiraIssue)
|
||||
|
||||
```html
|
||||
<jira-issue id="xxx" key="PROJECT-123"/>
|
||||
```
|
||||
|
||||
**属性**: id (Jira问题ID), key (Jira问题Key)
|
||||
|
||||
### OKR 系列⚠️
|
||||
|
||||
```html
|
||||
<okr id="okr_xxx">
|
||||
<objective id="obj_1">
|
||||
<kr id="kr_1"/>
|
||||
</objective>
|
||||
</okr>
|
||||
```
|
||||
|
||||
⚠️ 仅支持 user_access_token 创建,需使用 OKR API 进行详细操作
|
||||
|
||||
**结构**: okr → okr_objective (目标) → okr_key_result (关键结果) + okr_progress (进展)
|
||||
|
||||
---
|
||||
|
||||
## 📎 提及和引用
|
||||
|
||||
### 提及用户
|
||||
|
||||
```html
|
||||
<mention-user id="ou_xxx"/>
|
||||
```
|
||||
|
||||
**属性**: id (用户 open_id,格式 ou_xxx)
|
||||
|
||||
注意不要直接在文档中写`@张三` 这类格式,应当使用search-user获取用户的id,并使用`mention-user`.
|
||||
|
||||
### 提及文档
|
||||
|
||||
```html
|
||||
<mention-doc token="doxcnXXX" type="docx">文档标题</mention-doc>
|
||||
```
|
||||
|
||||
**属性**: token (文档 token), type (docx/sheet/bitable)
|
||||
|
||||
---
|
||||
|
||||
## 📅 日期和时间
|
||||
|
||||
### 日期提醒(Reminder)
|
||||
|
||||
```html
|
||||
<reminder date="2025-12-31T18:00+08:00" notify="true" user-id="ou_xxx"/>
|
||||
```
|
||||
|
||||
**属性**:
|
||||
- date (必需): `YYYY-MM-DDTHH:mm+HH:MM`, ISO 8601 带时区偏移
|
||||
- notify (true/false): 是否发送通知
|
||||
- user-id (必需): 创建者用户 ID
|
||||
|
||||
---
|
||||
|
||||
## 📐 数学表达式
|
||||
|
||||
### 块级公式(LaTeX)
|
||||
|
||||
````markdown
|
||||
$$
|
||||
\int_{0}^{\infty} e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
|
||||
$$
|
||||
````
|
||||
|
||||
### 行内公式
|
||||
|
||||
```markdown
|
||||
爱因斯坦方程:$E = mc^2$(注意 $ 前后需空格,紧邻位置不能有空格)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✍️ 写作指南
|
||||
|
||||
### 场景速查
|
||||
|
||||
| 场景 | 推荐组件 | 说明 |
|
||||
|------|----------|------|
|
||||
| 重点提示/警告 | Callout | 蓝色提示、黄色警告、红色危险 |
|
||||
| 对比/并列展示 | Grid 分栏 | 2-3 列最佳,配合 Callout 更醒目 |
|
||||
| 数据汇总 | 表格 | 简单用 Markdown,复杂嵌套用 lark-table |
|
||||
| 步骤说明 | 有序列表 | 可嵌套子步骤 |
|
||||
| 时间线/版本 | 有序列表 + 加粗日期 | 或用 Mermaid timeline |
|
||||
| 代码展示 | 代码块 | 标注语言,适当添加注释 |
|
||||
| 知识卡片 | Callout + emoji | 用于概念解释、小贴士 |
|
||||
| 引用说明 | 引用块 > | 引用原文、名言 |
|
||||
| 术语对照 | 两列表格 | 中英文、缩写全称等 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
- **空行分隔**:不同块类型之间用空行分隔
|
||||
- **转义字符**:特殊字符用 `\` 转义:`\*` `\~` `\``
|
||||
- **图片**:使用 URL,系统自动下载上传
|
||||
- **分栏**:列宽总和必须为 100
|
||||
- **表格选择**:简单数据用 Markdown,复杂嵌套用 `<lark-table>`
|
||||
- **提及**:@用户用 `<mention-user>`,@文档用 `<mention-doc>`
|
||||
- **目录**:飞书自动生成,无需手动添加
|
||||
|
||||
---
|
||||
|
||||
## 📖 补充说明
|
||||
|
||||
- 图片、画板、多维表格需要 token(URL 会自动上传转换)
|
||||
- 提及用户和会话卡片需要相应访问权限
|
||||
- 完全兼容标准 Markdown
|
||||
@ -0,0 +1,93 @@
|
||||
---
|
||||
name: feishu-fetch-doc
|
||||
description: |
|
||||
获取飞书云文档内容。返回文档的 Markdown 内容,支持处理文档中的图片、文件和画板(需配合 feishu_doc_media 工具)。
|
||||
---
|
||||
|
||||
# feishu_mcp_fetch_doc
|
||||
|
||||
获取飞书云文档的 Markdown 内容(Lark-flavored 格式)。
|
||||
|
||||
## 重要:图片、文件、画板的处理
|
||||
|
||||
**文档中的图片、文件、画板需要通过 `feishu_doc_media`(action: download)工具单独获取!**
|
||||
|
||||
### 识别格式
|
||||
|
||||
返回的 Markdown 中,媒体文件以 HTML 标签形式出现:
|
||||
|
||||
- **图片**:
|
||||
```html
|
||||
<image token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" width="1833" height="2491" align="center"/>
|
||||
```
|
||||
|
||||
- **文件**:
|
||||
```html
|
||||
<view type="1">
|
||||
<file token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc" name="skills.zip"/>
|
||||
</view>
|
||||
```
|
||||
|
||||
- **画板**:
|
||||
```html
|
||||
<whiteboard token="Z1FjxxxxxxxxxxxxxxxxxxxtnAc"/>
|
||||
```
|
||||
|
||||
### 获取步骤
|
||||
|
||||
1. 从 HTML 标签中提取 `token` 属性值
|
||||
2. 调用 `feishu_doc_media` 下载:
|
||||
```json
|
||||
{
|
||||
"action": "download",
|
||||
"resource_token": "提取的token",
|
||||
"resource_type": "media",
|
||||
"output_path": "/path/to/save/file"
|
||||
}
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
- **`doc_id`**(必填):支持直接传文档 URL 或 token
|
||||
- 直接传 URL:`https://xxx.feishu.cn/docx/Z1FjxxxxxxxxxxxxxxxxxxxtnAc`(系统自动提取 token)
|
||||
- 直接传 token:`Z1FjxxxxxxxxxxxxxxxxxxxtnAc`
|
||||
- 知识库 URL/token 也支持:`https://xxx.feishu.cn/wiki/Z1FjxxxxxxxxxxxxxxxxxxxtnAc` 或 `Z1FjxxxxxxxxxxxxxxxxxxxtnAc`
|
||||
|
||||
## Wiki URL 处理策略
|
||||
|
||||
知识库链接(`/wiki/TOKEN`)背后可能是云文档、电子表格、多维表格等不同类型的文档。当不确定类型时, **不能直接假设是云文档**,必须先查询实际类型。
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. **先调用 `feishu_wiki_space_node`(action: get)解析 wiki token**:
|
||||
```json
|
||||
{ "action": "get", "token": "wiki_token_here" }
|
||||
```
|
||||
2. **从返回的 `node` 中获取 `obj_type`(实际文档类型)和 `obj_token`(实际文档 token)**
|
||||
3. **根据 `obj_type` 调用对应工具**:
|
||||
|
||||
| obj_type | 工具 | 传参 |
|
||||
|----------|------|------|
|
||||
| `docx` | `feishu_mcp_fetch_doc` | doc_id = obj_token |
|
||||
| `sheet` | `feishu_sheet` | spreadsheet_token = obj_token |
|
||||
| `bitable` | `feishu_bitable_*` 系列 | app_token = obj_token |
|
||||
| 其他 | 告知用户暂不支持该类型 | — |
|
||||
|
||||
|
||||
### 示例
|
||||
|
||||
用户:`帮我看下这个文档 https://xxx.feishu.cn/wiki/ABC123`
|
||||
|
||||
1. 调用 `feishu_wiki_space_node`(action: get, token: ABC123)
|
||||
2. 返回 `obj_type: "docx"`, `obj_token: "doxcnXYZ789"`
|
||||
3. 调用 `feishu_mcp_fetch_doc`(doc_id: doxcnXYZ789)
|
||||
|
||||
## 工具组合
|
||||
|
||||
| 需求 | 工具 |
|
||||
|------|------|
|
||||
| 获取文档文本 | `feishu_mcp_fetch_doc` |
|
||||
| 下载图片/文件/画板 | `feishu_doc_media`(action: download) |
|
||||
| 解析 wiki token 类型 | `feishu_wiki_space_node`(action: get) |
|
||||
| 读写电子表格 | `feishu_sheet` |
|
||||
| 操作多维表格 | `feishu_bitable_*` 系列 |
|
||||
163
openclaw/extensions/openclaw-lark/skills/feishu-im-read/SKILL.md
Normal file
163
openclaw/extensions/openclaw-lark/skills/feishu-im-read/SKILL.md
Normal file
@ -0,0 +1,163 @@
|
||||
---
|
||||
name: feishu-im-read
|
||||
description: |
|
||||
飞书 IM 消息读取工具使用指南,覆盖会话消息获取、话题回复读取、跨会话消息搜索、图片/文件资源下载。
|
||||
|
||||
**当以下情况时使用此 Skill**:
|
||||
(1) 需要获取群聊或单聊的历史消息
|
||||
(2) 需要读取话题(thread)内的回复消息
|
||||
(3) 需要跨会话搜索消息(按关键词、发送者、时间等条件)
|
||||
(4) 消息中包含图片、文件、音频、视频,需要下载
|
||||
(5) 用户提到"聊天记录"、"消息"、"群里说了什么"、"话题回复"、"搜索消息"、"图片"、"文件下载"
|
||||
(6) 需要按时间范围过滤消息、分页获取更多消息
|
||||
---
|
||||
|
||||
# 飞书 IM 消息读取
|
||||
|
||||
## 执行前必读
|
||||
|
||||
- 该 Skill 中的所有消息读取工具均以用户身份调用,只能读取用户有权限的会话
|
||||
- `feishu_im_user_get_messages` 中 `open_id` 和 `chat_id` 必须二选一
|
||||
- 消息中出现 `thread_id` 时,根据用户意图判断是否用 `feishu_im_user_get_thread_messages` 读取话题内回复
|
||||
- 以用户身份读取后,如果消息内容中出现资源标记时,用 `feishu_im_user_fetch_resource` 下载,需要 `message_id` + `file_key` + `type`
|
||||
|
||||
---
|
||||
|
||||
## 快速索引:意图 → 工具
|
||||
|
||||
| 用户意图 | 工具 | 必填参数 | 常用可选 |
|
||||
|---------|------|---------|---------|
|
||||
| 获取群聊/单聊历史消息 | feishu_im_user_get_messages | chat_id 或 open_id(二选一) | relative_time, start_time/end_time, page_size, sort_rule |
|
||||
| 获取话题内回复消息 | feishu_im_user_get_thread_messages | thread_id(omt_xxx) | page_size, sort_rule |
|
||||
| 跨会话搜索消息 | feishu_im_user_search_messages | 至少一个过滤条件 | query, sender_ids, chat_id, relative_time, start_time/end_time, page_size |
|
||||
| 下载消息中的图片 | feishu_im_user_fetch_resource | message_id, file_key(img_xxx), type="image" | - |
|
||||
| 下载消息中的文件/音频/视频 | feishu_im_user_fetch_resource | message_id, file_key(file_xxx), type="file" | - |
|
||||
|
||||
---
|
||||
|
||||
## 核心约束
|
||||
|
||||
### 1. 时间范围:确保消息覆盖完整
|
||||
|
||||
当用户没有明确指定时间范围时,根据用户意图推断合适的 `relative_time`,确保返回的消息能完整覆盖用户关心的内容。用户明确指定时间时直接使用用户的值。
|
||||
|
||||
### 2. 分页:根据需要翻页获取更多结果
|
||||
|
||||
- `page_size` 范围 1-50,默认 50
|
||||
- 返回结果中 `has_more=true` 时,可使用 `page_token` 继续获取下一页
|
||||
- 根据用户需求判断是否需要翻页:需要完整结果时继续翻页,浏览概览时第一页通常够用
|
||||
|
||||
### 3. 话题回复:主动展开话题获取上下文
|
||||
|
||||
获取历史消息时,返回的消息中如果包含 `thread_id` 字段,推荐主动获取话题的最新 10 条回复(`page_size: 10, sort_rule: "create_time_desc"`)以提供更完整的上下文。
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 获取历史消息并需要理解上下文(默认) | 对发现的 thread_id 调用 `feishu_im_user_get_thread_messages` 获取最新 10 条回复 |
|
||||
| 用户要求"完整对话"、"详细讨论"、"看看回复" | 获取话题全部回复(`page_size: 50, sort_rule: "create_time_asc"`),需要时翻页 |
|
||||
| 用户只浏览消息概览 / 用户明确说不看回复 | 跳过话题展开 |
|
||||
|
||||
**注意**:话题消息不支持时间过滤(飞书 API 限制),只能通过分页获取。
|
||||
|
||||
### 4. 跨会话消息搜索
|
||||
|
||||
`feishu_im_user_search_messages` 支持跨所有会话搜索消息:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `query` | 搜索关键词,匹配消息内容 |
|
||||
| `sender_ids` | 发送者 open_id 列表 |
|
||||
| `chat_id` | 限定搜索范围的会话 ID |
|
||||
| `mention_ids` | 被@用户的 open_id 列表 |
|
||||
| `message_type` | 消息类型:file / image / media |
|
||||
| `sender_type` | 发送者类型:user / bot / all(默认 user) |
|
||||
| `chat_type` | 会话类型:group / p2p |
|
||||
|
||||
搜索结果每条消息额外包含 `chat_id`、`chat_type`(p2p/group)、`chat_name`。单聊消息还有 `chat_partner`(对方 open_id 和名字)。
|
||||
|
||||
### 5. 图片/文件/媒体资源的提取
|
||||
|
||||
消息内容中可能出现以下资源标记,用 `feishu_im_user_fetch_resource` 下载:
|
||||
|
||||
| 资源类型 | 内容中的标记格式 | fetch_resource 参数 |
|
||||
|---------|-----------------|-------------------|
|
||||
| 图片 | `` | message_id=`om_xxx`, file_key=`img_xxx`, type=`"image"` |
|
||||
| 文件 | `<file key="file_xxx" .../>` | message_id=`om_xxx`, file_key=`file_xxx`, type=`"file"` |
|
||||
| 音频 | `<audio key="file_xxx" .../>` | message_id=`om_xxx`, file_key=`file_xxx`, type=`"file"` |
|
||||
| 视频 | `<video key="file_xxx" .../>` | message_id=`om_xxx`, file_key=`file_xxx`, type=`"file"` |
|
||||
|
||||
从消息的 `message_id` 字段和内容中的 `file_key` 组合即可调用 fetch_resource。
|
||||
|
||||
**注意**:文件大小限制 100MB,不支持下载表情包、卡片中的资源。
|
||||
|
||||
### 6. 时间过滤
|
||||
|
||||
`feishu_im_user_get_messages` 和 `feishu_im_user_search_messages` 支持时间过滤,话题消息不支持。
|
||||
|
||||
| 方式 | 参数 | 示例 |
|
||||
|------|------|------|
|
||||
| 相对时间 | `relative_time` | `today`、`yesterday`、`this_week`、`last_3_days`、`last_24_hours` |
|
||||
| 精确时间 | `start_time` + `end_time` | ISO 8601 格式:`2026-02-27T00:00:00+08:00` |
|
||||
|
||||
- `relative_time` 和 `start_time/end_time` **互斥**,不能同时使用
|
||||
- 可用的 relative_time 值:`today`、`yesterday`、`day_before_yesterday`、`this_week`、`last_week`、`this_month`、`last_month`、`last_{N}_{unit}`(unit: minutes/hours/days)
|
||||
|
||||
### 7. open_id 与 chat_id 的选择
|
||||
|
||||
| 参数 | 格式 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| chat_id | `oc_xxx` | 已知会话 ID(群聊或单聊均可) |
|
||||
| open_id | `ou_xxx` | 已知用户 ID,获取与该用户的单聊消息(自动解析为 chat_id) |
|
||||
|
||||
两者必须二选一,优先使用 `chat_id`。
|
||||
|
||||
---
|
||||
|
||||
## 使用场景示例
|
||||
|
||||
### 场景 1: 获取群聊消息并展开话题
|
||||
|
||||
**步骤 1**:获取群聊消息
|
||||
```json
|
||||
{ "chat_id": "oc_xxx" }
|
||||
```
|
||||
|
||||
**步骤 2**:返回的消息中发现 `thread_id`,展开话题最新回复:
|
||||
```json
|
||||
{ "thread_id": "omt_xxx", "page_size": 10, "sort_rule": "create_time_desc" }
|
||||
```
|
||||
|
||||
### 场景 2: 跨会话搜索消息
|
||||
|
||||
```json
|
||||
{ "query": "项目进度", "chat_id": "oc_xxx" }
|
||||
```
|
||||
|
||||
### 场景 3: 分页获取更多消息
|
||||
|
||||
第一次调用返回 `has_more: true` 和 `page_token: "xxx"`,继续获取:
|
||||
```json
|
||||
{ "chat_id": "oc_xxx", "page_token": "xxx" }
|
||||
```
|
||||
|
||||
### 场景 4: 下载消息中的资源
|
||||
|
||||
```json
|
||||
{ "message_id": "om_xxx", "file_key": "img_v3_xxx", "type": "image" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| 消息结果太少 | 时间范围太窄或未传时间参数 | 根据用户意图推断合适的 `relative_time` |
|
||||
| 消息不完整 | 没有检查 has_more 并翻页 | has_more=true 时用 page_token 翻页 |
|
||||
| 话题讨论内容不完整 | 没有展开 thread_id | 发现 thread_id 时获取话题回复 |
|
||||
| "open_id 和 chat_id 不能同时提供" | 同时传了两个参数 | 只传其中一个 |
|
||||
| "relative_time 和 start_time/end_time 不能同时使用" | 时间参数冲突 | 选择一种时间过滤方式 |
|
||||
| "未找到与 open_id=xxx 的单聊会话" | 没有单聊记录 | 改用 chat_id,或确认存在单聊 |
|
||||
| 话题消息返回为空 | thread_id 格式不正确 | 确认为 `omt_xxx` 格式 |
|
||||
| 图片/文件下载失败 | file_key 或 message_id 不匹配 | 确认 file_key 来自该 message_id |
|
||||
| 权限不足 | 用户未授权或无权限 | 确认已完成 OAuth 授权且是会话成员 |
|
||||
293
openclaw/extensions/openclaw-lark/skills/feishu-task/SKILL.md
Normal file
293
openclaw/extensions/openclaw-lark/skills/feishu-task/SKILL.md
Normal file
@ -0,0 +1,293 @@
|
||||
---
|
||||
name: feishu-task
|
||||
description: |
|
||||
飞书任务管理工具,用于创建、查询、更新任务和清单。
|
||||
|
||||
**当以下情况时使用此 Skill**:
|
||||
(1) 需要创建、查询、更新、删除任务
|
||||
(2) 需要创建、管理任务清单
|
||||
(3) 需要查看任务列表或清单内的任务
|
||||
(4) 用户提到"任务"、"待办"、"to-do"、"清单"、"task"
|
||||
(5) 需要设置任务负责人、关注人、截止时间
|
||||
---
|
||||
|
||||
# 飞书任务管理
|
||||
|
||||
## 🚨 执行前必读
|
||||
|
||||
- ✅ **时间格式**:ISO 8601 / RFC 3339(带时区),例如 `2026-02-28T17:00:00+08:00`
|
||||
- ✅ **current_user_id 强烈建议**:从消息上下文的 SenderId 获取(ou_...),工具会自动添加为 follower(如不在 members 中),确保创建者可以编辑任务
|
||||
- ✅ **patch/get 必须**:task_guid
|
||||
- ✅ **tasklist.tasks 必须**:tasklist_guid
|
||||
- ✅ **完成任务**:completed_at = "2026-02-26 15:00:00"
|
||||
- ✅ **反完成(恢复未完成)**:completed_at = "0"
|
||||
|
||||
---
|
||||
|
||||
## 📋 快速索引:意图 → 工具 → 必填参数
|
||||
|
||||
| 用户意图 | 工具 | action | 必填参数 | 强烈建议 | 常用可选 |
|
||||
|---------|------|--------|---------|---------|---------|
|
||||
| 新建待办 | feishu_task_task | create | summary | current_user_id(SenderId) | members, due, description |
|
||||
| 查未完成任务 | feishu_task_task | list | - | completed=false | page_size |
|
||||
| 获取任务详情 | feishu_task_task | get | task_guid | - | - |
|
||||
| 完成任务 | feishu_task_task | patch | task_guid, completed_at | - | - |
|
||||
| 反完成任务 | feishu_task_task | patch | task_guid, completed_at="0" | - | - |
|
||||
| 改截止时间 | feishu_task_task | patch | task_guid, due | - | - |
|
||||
| 创建清单 | feishu_task_tasklist | create | name | - | members |
|
||||
| 查看清单任务 | feishu_task_tasklist | tasks | tasklist_guid | - | completed |
|
||||
| 添加清单成员 | feishu_task_tasklist | add_members | tasklist_guid, members[] | - | - |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心约束(Schema 未透露的知识)
|
||||
|
||||
### 1. 当前工具使用用户身份(已内置保护)
|
||||
|
||||
**工具使用 `user_access_token`(用户身份)**
|
||||
|
||||
这意味着:
|
||||
- ✅ 创建任务时可以指定任意成员(包括只分配给别人)
|
||||
- ⚠️ 只能查看和编辑**自己是成员的任务**
|
||||
- ⚠️ **如果创建时没把自己加入成员,后续无法编辑该任务**
|
||||
|
||||
**自动保护机制**:
|
||||
- 传入 `current_user_id` 参数(从 SenderId 获取)
|
||||
- 如果 `members` 中不包含 `current_user_id`,工具会**自动添加为 follower**
|
||||
- 确保创建者始终可以编辑任务
|
||||
|
||||
**推荐用法**:创建任务时始终传 `current_user_id`,工具会自动处理成员关系。
|
||||
|
||||
### 2. 任务成员的角色说明
|
||||
|
||||
- **assignee(负责人)**:负责完成任务,可以编辑任务
|
||||
- **follower(关注人)**:关注任务进展,接收通知
|
||||
|
||||
**添加成员示例**:
|
||||
```json
|
||||
{
|
||||
"members": [
|
||||
{"id": "ou_xxx", "role": "assignee"}, // 负责人
|
||||
{"id": "ou_yyy", "role": "follower"} // 关注人
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:`id` 使用用户的 `open_id`(从消息上下文的 SenderId 获取)
|
||||
|
||||
### 3. 任务清单角色冲突
|
||||
|
||||
**现象**:创建清单(`tasklist.create`)时传了 `members`,但返回的 `tasklist.members` 为空或缺少成员
|
||||
|
||||
**原因**:创建人自动成为清单 **owner**(所有者),如果 `members` 中包含创建人,该用户最终成为 owner 并从 `members` 中移除(同一用户只能有一个角色)
|
||||
|
||||
**建议**:不要在 `members` 中包含创建人,只添加其他协作成员
|
||||
|
||||
### 4. completed_at 的三种用法
|
||||
|
||||
**1) 完成任务(设置完成时间)**:
|
||||
```json
|
||||
{
|
||||
"action": "patch",
|
||||
"task_guid": "xxx",
|
||||
"completed_at": "2026-02-26 15:30:00" // 北京时间字符串
|
||||
}
|
||||
```
|
||||
|
||||
**2) 反完成(恢复未完成状态)**:
|
||||
```json
|
||||
{
|
||||
"action": "patch",
|
||||
"task_guid": "xxx",
|
||||
"completed_at": "0" // 特殊值 "0" 表示反完成
|
||||
}
|
||||
```
|
||||
|
||||
**3) 毫秒时间戳**(不推荐,除非上层已严格生成):
|
||||
```json
|
||||
{
|
||||
"completed_at": "1740545400000" // 毫秒时间戳字符串
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 清单成员的角色
|
||||
|
||||
| 成员类型 | 角色 | 说明 |
|
||||
|---------|------|------|
|
||||
| user(用户) | owner | 所有者,可转让所有权 |
|
||||
| user(用户) | editor | 可编辑,可修改清单和任务 |
|
||||
| user(用户) | viewer | 可查看,只读权限 |
|
||||
| chat(群组) | editor/viewer | 整个群组获得权限 |
|
||||
|
||||
**说明**:创建清单时,创建者自动成为 owner,无需在 members 中指定。
|
||||
|
||||
---
|
||||
|
||||
## 📌 使用场景示例
|
||||
|
||||
### 场景 1: 创建任务并分配负责人
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"summary": "准备周会材料",
|
||||
"description": "整理本周工作进展和下周计划",
|
||||
"current_user_id": "ou_发送者的open_id",
|
||||
"due": {
|
||||
"timestamp": "2026-02-28 17:00:00",
|
||||
"is_all_day": false
|
||||
},
|
||||
"members": [
|
||||
{"id": "ou_协作者的open_id", "role": "assignee"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- `summary` 是必填字段
|
||||
- `current_user_id` 强烈建议传入(从 SenderId 获取),工具会自动添加为 follower
|
||||
- `members` 可以只包含其他协作者,当前用户会被自动添加
|
||||
- 时间使用北京时间字符串格式
|
||||
|
||||
### 场景 2: 查询我负责的未完成任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list",
|
||||
"completed": false,
|
||||
"page_size": 20
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 3: 完成任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "patch",
|
||||
"task_guid": "任务的guid",
|
||||
"completed_at": "2026-02-26 15:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 4: 反完成任务(恢复未完成状态)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "patch",
|
||||
"task_guid": "任务的guid",
|
||||
"completed_at": "0"
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 5: 创建清单并添加协作者
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"name": "产品迭代 v2.0",
|
||||
"members": [
|
||||
{"id": "ou_xxx", "role": "editor"},
|
||||
{"id": "ou_yyy", "role": "viewer"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 6: 查看清单内的未完成任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "tasks",
|
||||
"tasklist_guid": "清单的guid",
|
||||
"completed": false
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 7: 全天任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"summary": "年度总结",
|
||||
"due": {
|
||||
"timestamp": "2026-03-01 00:00:00",
|
||||
"is_all_day": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 常见错误与排查
|
||||
|
||||
| 错误现象 | 根本原因 | 解决方案 |
|
||||
|---------|---------|---------|
|
||||
| **创建后无法编辑任务** | 创建时未将自己加入 members | 创建时至少将当前用户(SenderId)加为 assignee 或 follower |
|
||||
| **patch 失败提示 task_guid 缺失** | 未传 task_guid 参数 | patch/get 必须传 task_guid |
|
||||
| **tasks 失败提示 tasklist_guid 缺失** | 未传 tasklist_guid 参数 | tasklist.tasks action 必须传 tasklist_guid |
|
||||
| **反完成失败** | completed_at 格式错误 | 使用 `"0"` 字符串,不是数字 0 |
|
||||
| **时间不对** | 使用了 Unix 时间戳 | 改用 ISO 8601 格式(带时区):`2024-01-01T00:00:00+08:00` |
|
||||
|
||||
---
|
||||
|
||||
## 📚 附录:背景知识
|
||||
|
||||
### A. 资源关系
|
||||
|
||||
```
|
||||
任务清单(Tasklist)
|
||||
└─ 自定义分组(Section,可选)
|
||||
└─ 任务(Task)
|
||||
├─ 成员:负责人(assignee)、关注人(follower)
|
||||
├─ 子任务(Subtask)
|
||||
├─ 截止时间(due)、开始时间(start)
|
||||
└─ 附件、评论
|
||||
```
|
||||
|
||||
**核心概念**:
|
||||
- **任务(Task)**:独立的待办事项,有唯一的 `task_guid`
|
||||
- **清单(Tasklist)**:组织多个任务的容器,有唯一的 `tasklist_guid`
|
||||
- **负责人(assignee)**:可以编辑任务并标记完成
|
||||
- **关注人(follower)**:接收任务更新通知
|
||||
- **我负责的(MyTasks)**:所有负责人为自己的任务集合
|
||||
|
||||
### B. 如何获取 GUID
|
||||
|
||||
- **task_guid**:创建任务后从返回值的 `task.guid` 获取,或通过 `list` 查询
|
||||
- **tasklist_guid**:创建清单后从返回值的 `tasklist.guid` 获取,或通过 `list` 查询
|
||||
|
||||
### C. 如何将任务加入清单
|
||||
|
||||
创建任务时指定 `tasklists` 参数:
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"summary": "任务标题",
|
||||
"tasklists": [
|
||||
{
|
||||
"tasklist_guid": "清单的guid",
|
||||
"section_guid": "分组的guid(可选)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### D. 重复任务如何创建
|
||||
|
||||
使用 `repeat_rule` 参数,采用 RRULE 格式:
|
||||
```json
|
||||
{
|
||||
"action": "create",
|
||||
"summary": "每周例会",
|
||||
"due": {"timestamp": "2026-03-03 14:00:00", "is_all_day": false},
|
||||
"repeat_rule": "FREQ=WEEKLY;INTERVAL=1;BYDAY=MO"
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:只有设置了截止时间的任务才能设置重复规则。
|
||||
|
||||
|
||||
### E. 数据权限
|
||||
|
||||
- 只能操作自己有权限的任务(作为成员的任务)
|
||||
- 只能操作自己有权限的清单(作为成员的清单)
|
||||
- 将任务加入清单需要同时拥有任务和清单的编辑权限
|
||||
@ -0,0 +1,70 @@
|
||||
---
|
||||
name: feishu-troubleshoot
|
||||
description: |
|
||||
飞书插件问题排查工具。包含常见问题 FAQ 和深度诊断命令(/feishu_doctor)。
|
||||
|
||||
常见问题可随时查阅。诊断命令用于排查复杂问题(多次授权仍失败、自动授权无法解决等),
|
||||
会检查账户配置、API 连通性、应用权限、用户授权状态,并生成详细的诊断报告和解决方案。
|
||||
|
||||
---
|
||||
|
||||
# 飞书插件问题排查
|
||||
|
||||
## ❓ 常见问题(FAQ)
|
||||
|
||||
### 卡片按钮点击无反应
|
||||
|
||||
**现象**:点击卡片按钮后没有任何反应,然后提示报错.
|
||||
|
||||
**原因**:应用未开通「消息卡片回传交互」权限。
|
||||
|
||||
**解决步骤**:
|
||||
|
||||
1. 登录飞书开放平台:https://open.feishu.cn/app
|
||||
2. 选择您的应用 → **事件与回调**
|
||||
3. 在回调配置中,修改订阅方式为"长链接"并添加回调 "卡片回传交互"(card.action.trigger)
|
||||
4. 创建应用版本 → 提交审核 → 发布
|
||||
|
||||
---
|
||||
|
||||
## 🔍 诊断命令(深度工具)
|
||||
|
||||
**注意**:诊断命令仅用于排查复杂/疑难的**权限相关问题**。常规权限问题会自动触发授权流程,无需手动诊断。
|
||||
|
||||
**何时使用诊断**:
|
||||
- 多次授权后仍然报错
|
||||
- 自动授权流程无法解决的问题
|
||||
- 需要查看完整的权限配置状态
|
||||
|
||||
**使用方法**:
|
||||
|
||||
在飞书聊天会话中直接输入(作为用户消息发送):
|
||||
|
||||
/feishu doctor
|
||||
|
||||
诊断命令会检查:
|
||||
|
||||
- **📋 诊断摘要**(首先展示):
|
||||
- 总体状态(✅ 正常 / ⚠️ 警告 / ❌ 失败)
|
||||
- 发现的问题列表和简要描述
|
||||
|
||||
- **环境信息**:
|
||||
- 插件版本
|
||||
|
||||
- **账号信息**:
|
||||
- 凭证完整性(appId, appSecret 掩码)
|
||||
- 账户启用状态
|
||||
- API 连通性测试
|
||||
- Bot 信息(名称和 openId)
|
||||
|
||||
- **应用身份权限**:
|
||||
- 应用已开通的必需权限数量
|
||||
- 缺失的必需权限列表
|
||||
- 一键申请链接(自动带上缺失权限参数)
|
||||
|
||||
- **用户身份权限**:
|
||||
- 用户授权状态统计(✓ 有效 / ⟳ 需刷新 / ✗ 已过期)
|
||||
- Token 自动刷新状态(是否包含 offline_access)
|
||||
- 权限对照表(应用已开通 vs 用户已授权,逐项对比)
|
||||
- 应用权限缺失时的申请指引和链接
|
||||
- 用户授权不足时的重新授权操作方法
|
||||
@ -0,0 +1,285 @@
|
||||
---
|
||||
name: feishu-update-doc
|
||||
description: |
|
||||
更新飞书云文档。支持 7 种更新模式:追加、覆盖、定位替换、全文替换、前/后插入、删除。
|
||||
---
|
||||
|
||||
# feishu__update_doc
|
||||
|
||||
更新飞书云文档内容,支持 7 种更新模式。优先使用局部更新(replace_range/append/insert_before/insert_after),慎用 overwrite(会清空文档重写,可能丢失图片、评论等)。
|
||||
|
||||
# 定位方式
|
||||
|
||||
定位模式(replace_range/replace_all/insert_before/insert_after/delete_range)支持两种定位方式,二选一:
|
||||
|
||||
## selection_with_ellipsis - 内容定位
|
||||
|
||||
支持两种格式:
|
||||
|
||||
1. **范围匹配**:`开头内容...结尾内容`
|
||||
- 匹配从开头到结尾的所有内容(包含中间内容)
|
||||
- 建议 10-20 字符确保唯一性
|
||||
|
||||
2. **精确匹配**:`完整内容`(不含 `...`)
|
||||
- 匹配完整的文本内容
|
||||
- 适合替换短文本、关键词等
|
||||
|
||||
**转义说明**:如果要匹配的内容本身包含 `...`,使用 `\.\.\.` 表示字面量的三个点。
|
||||
|
||||
示例:
|
||||
- `你好...世界` → 匹配从"你好"到"世界"之间的任意内容
|
||||
- `你好\.\.\.世界` → 匹配字面量 "你好...世界"
|
||||
|
||||
**建议**:如果文档中有多个 `...`,建议使用更长的上下文来精确定位,避免歧义。
|
||||
|
||||
## selection_by_title - 标题定位
|
||||
|
||||
格式:`## 章节标题`(可带或不带 # 前缀)
|
||||
|
||||
自动定位整个章节(从该标题到下一个同级或更高级标题之前)。
|
||||
|
||||
**示例**:
|
||||
- `## 功能说明` → 定位二级标题"功能说明"及其下所有内容
|
||||
- `功能说明` → 定位任意级别的"功能说明"标题及其内容
|
||||
|
||||
# 可选参数
|
||||
|
||||
## new_title
|
||||
|
||||
更新文档标题。如果提供此参数,将在更新文档内容后同步更新文档标题。
|
||||
|
||||
**特性**:
|
||||
- 仅支持纯文本,不支持富文本格式
|
||||
- 长度限制:1-800 字符
|
||||
- 可以与任何 mode 配合使用
|
||||
- 标题更新在内容更新之后执行
|
||||
|
||||
|
||||
# 返回值
|
||||
|
||||
## 成功
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"doc_id": "文档ID",
|
||||
"mode": "使用的模式",
|
||||
"message": "文档更新成功(xxx模式)",
|
||||
"warnings": ["可选警告列表"],
|
||||
"log_id": "请求日志ID"
|
||||
}
|
||||
```
|
||||
|
||||
## 异步模式(大文档超时)
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "async_task_xxxx",
|
||||
"message": "文档更新已提交异步处理,请使用 task_id 查询状态",
|
||||
"log_id": "请求日志ID"
|
||||
}
|
||||
```
|
||||
|
||||
使用返回的 `task_id` 再次调用 update-doc(仅传 task_id 参数)查询状态。
|
||||
|
||||
## 错误
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "[错误码] 错误消息\n💡 Suggestion: 修复建议\n📍 Context: 上下文信息",
|
||||
"log_id": "请求日志ID"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 使用示例
|
||||
|
||||
## append - 追加到末尾
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "append",
|
||||
"markdown": "## 新章节\n\n追加的内容..."
|
||||
}
|
||||
```
|
||||
|
||||
## replace_range - 定位替换
|
||||
|
||||
使用 `selection_with_ellipsis`:
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "replace_range",
|
||||
"selection_with_ellipsis": "## 旧章节标题...旧章节结尾。",
|
||||
"markdown": "## 新章节标题\n\n新的内容..."
|
||||
}
|
||||
```
|
||||
|
||||
使用 `selection_by_title`(替换整个章节):
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "replace_range",
|
||||
"selection_by_title": "## 功能说明",
|
||||
"markdown": "## 功能说明\n\n更新后的功能说明内容..."
|
||||
}
|
||||
```
|
||||
|
||||
## replace_all - 全文替换
|
||||
|
||||
与 replace_range 类似,但支持多处同时替换(replace_range 要求匹配唯一):
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "replace_all",
|
||||
"selection_with_ellipsis": "张三",
|
||||
"markdown": "李四"
|
||||
}
|
||||
```
|
||||
|
||||
**返回值**包含 `replace_count` 字段,表示替换的次数:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"replace_count": 4,
|
||||
"message": "文档更新成功(replace_all模式,替换4处)"
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- 与 `replace_range` 不同,`replace_all` 允许多个匹配
|
||||
- 如果没有找到匹配内容,会返回错误
|
||||
- `markdown` 可以为空字符串,表示删除所有匹配内容
|
||||
|
||||
## insert_before - 前插入
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "insert_before",
|
||||
"selection_with_ellipsis": "## 危险操作...数据丢失风险。",
|
||||
"markdown": "> **警告**:以下操作需谨慎!"
|
||||
}
|
||||
```
|
||||
|
||||
## insert_after - 后插入
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "insert_after",
|
||||
"selection_with_ellipsis": "```python...```",
|
||||
"markdown": "**输出示例**:\n```\nresult = 42\n```"
|
||||
}
|
||||
```
|
||||
|
||||
## delete_range - 删除内容
|
||||
|
||||
使用 `selection_with_ellipsis`:
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "delete_range",
|
||||
"selection_with_ellipsis": "## 废弃章节...不再需要的内容。"
|
||||
}
|
||||
```
|
||||
|
||||
使用 `selection_by_title`(删除整个章节):
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "delete_range",
|
||||
"selection_by_title": "## 废弃章节"
|
||||
}
|
||||
```
|
||||
|
||||
注意:delete_range 模式不需要 markdown 参数。
|
||||
|
||||
## 同时更新标题和内容
|
||||
|
||||
可以在任何更新模式中添加 `new_title` 参数来同时更新文档标题:
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "overwrite",
|
||||
"markdown": "# 项目文档 v2.0\n\n全新的内容...",
|
||||
"new_title": "项目文档 v2.0"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "append",
|
||||
"markdown": "## 更新日志\n\n2025-12-18: 新增功能...",
|
||||
"new_title": "项目文档(已更新)"
|
||||
}
|
||||
```
|
||||
|
||||
## overwrite - 完全覆盖
|
||||
|
||||
⚠️ 会清空文档后重写,可能丢失图片、评论等,仅在需要完全重建文档时使用。
|
||||
|
||||
```json
|
||||
{
|
||||
"doc_id": "文档ID或URL",
|
||||
"mode": "overwrite",
|
||||
"markdown": "# 新文档\n\n全新的内容..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 最佳实践
|
||||
|
||||
## 小粒度精确替换
|
||||
|
||||
修改文档内容时,**定位范围越小越安全**。尤其是表格、分栏等嵌套块,应精确定位到需要修改的文本,避免影响其他内容。
|
||||
|
||||
**示例**:表格单元格中有图片和文字,只需修改文字
|
||||
- ❌ 替换整个表格或整行 → 可能破坏图片引用
|
||||
- ✅ 只定位需要修改的文本 → 图片等其他内容不受影响
|
||||
|
||||
|
||||
## 保护不可重建的内容
|
||||
|
||||
图片、画板、电子表格、多维表格、任务等内容以 token 形式存储,**无法读出后原样写入**。
|
||||
|
||||
**保护策略**:
|
||||
- 替换时避开包含这些内容的区域
|
||||
- 精确定位到纯文本部分进行修改
|
||||
|
||||
## 分步更新优于整体覆盖
|
||||
|
||||
修改多处内容时:
|
||||
- ✅ 多次小范围替换,逐步修改
|
||||
- ⚠️ 谨慎使用 `overwrite` 重写整个文档, 除非你认为风险完全可控
|
||||
|
||||
**原因**:局部更新保留原有媒体、评论、协作历史,更安全可靠。
|
||||
|
||||
## insert 模式扩大定位范围时注意插入位置
|
||||
|
||||
使用 `insert_before` 或 `insert_after` 时,如果目标内容重复出现,需要扩大 `selection_with_ellipsis` 范围来唯一定位。
|
||||
|
||||
**关键**:插入位置基于匹配范围的**边界**:
|
||||
- `insert_after` → 插入在匹配范围的**结尾**之后
|
||||
- `insert_before` → 插入在匹配范围的**开头**之前
|
||||
|
||||
扩大范围时,确保边界仍然是期望的插入点。
|
||||
|
||||
## 修复画板语法错误
|
||||
|
||||
当 create-doc 或 update-doc 返回画板写入失败的 warning 时:
|
||||
1. warning 中包含 whiteboard 标签(如 `<whiteboard token="xxx"/>`)
|
||||
2. 分析错误信息,修正 Mermaid/PlantUML 语法
|
||||
3. 用 `replace_range` 替换:`selection_with_ellipsis` 使用 warning 中的 whiteboard 标签,`markdown` 提供修正后的代码块
|
||||
4. 重新提交验证
|
||||
|
||||
---
|
||||
|
||||
# 注意事项
|
||||
|
||||
- **Markdown 语法**:支持飞书扩展语法,详见 create-doc 工具文档
|
||||
104
openclaw/extensions/openclaw-lark/src/card/builder.d.ts
vendored
Normal file
104
openclaw/extensions/openclaw-lark/src/card/builder.d.ts
vendored
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Interactive card building for Lark/Feishu.
|
||||
*
|
||||
* Provides utilities to construct Feishu Interactive Message Cards for
|
||||
* different agent response states (thinking, streaming, complete, confirm).
|
||||
*/
|
||||
/**
|
||||
* Element ID used for the streaming text area in cards. The CardKit
|
||||
* `cardElement.content()` API targets this element for typewriter-effect
|
||||
* streaming updates.
|
||||
*/
|
||||
export declare const STREAMING_ELEMENT_ID = "streaming_content";
|
||||
export declare const REASONING_ELEMENT_ID = "reasoning_content";
|
||||
export interface ToolCallInfo {
|
||||
name: string;
|
||||
status: 'running' | 'complete' | 'error';
|
||||
args?: Record<string, unknown>;
|
||||
result?: string;
|
||||
}
|
||||
export interface CardElement {
|
||||
tag: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface FeishuCard {
|
||||
config: {
|
||||
wide_screen_mode: boolean;
|
||||
update_multi?: boolean;
|
||||
locales?: string[];
|
||||
summary?: {
|
||||
content: string;
|
||||
};
|
||||
};
|
||||
header?: {
|
||||
title: {
|
||||
tag: 'plain_text';
|
||||
content: string;
|
||||
i18n_content?: Record<string, string>;
|
||||
};
|
||||
template: string;
|
||||
};
|
||||
elements: CardElement[];
|
||||
}
|
||||
export type CardState = 'thinking' | 'streaming' | 'complete' | 'confirm';
|
||||
export interface ConfirmData {
|
||||
operationDescription: string;
|
||||
pendingOperationId: string;
|
||||
preview?: string;
|
||||
}
|
||||
/**
|
||||
* Split a payload text into optional `reasoningText` and `answerText`.
|
||||
*
|
||||
* Handles two formats produced by the framework:
|
||||
* 1. "Reasoning:\n_italic line_\n…" prefix (from `formatReasoningMessage`)
|
||||
* 2. `<think>…</think>` / `<thinking>…</thinking>` XML tags
|
||||
*
|
||||
* Equivalent to the framework's `splitTelegramReasoningText()`.
|
||||
*/
|
||||
export declare function splitReasoningText(text?: string): {
|
||||
reasoningText?: string;
|
||||
answerText?: string;
|
||||
};
|
||||
/**
|
||||
* Strip reasoning blocks — both XML tags with their content and any
|
||||
* "Reasoning:\n" prefixed content.
|
||||
*/
|
||||
export declare function stripReasoningTags(text: string): string;
|
||||
/**
|
||||
* Format reasoning duration into a human-readable i18n pair.
|
||||
* e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
|
||||
*/
|
||||
export declare function formatReasoningDuration(ms: number): {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
/**
|
||||
* Format milliseconds into a human-readable duration string.
|
||||
*/
|
||||
export declare function formatElapsed(ms: number): string;
|
||||
/**
|
||||
* Build a full Feishu Interactive Message Card JSON object for the
|
||||
* given state.
|
||||
*/
|
||||
export declare function buildCardContent(state: CardState, data?: {
|
||||
text?: string;
|
||||
reasoningText?: string;
|
||||
reasoningElapsedMs?: number;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
confirmData?: ConfirmData;
|
||||
elapsedMs?: number;
|
||||
isError?: boolean;
|
||||
isAborted?: boolean;
|
||||
footer?: {
|
||||
status?: boolean;
|
||||
elapsed?: boolean;
|
||||
};
|
||||
}): FeishuCard;
|
||||
/**
|
||||
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
|
||||
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
|
||||
*/
|
||||
export declare function toCardKit2(card: FeishuCard): Record<string, unknown>;
|
||||
404
openclaw/extensions/openclaw-lark/src/card/builder.js
Normal file
404
openclaw/extensions/openclaw-lark/src/card/builder.js
Normal file
@ -0,0 +1,404 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Interactive card building for Lark/Feishu.
|
||||
*
|
||||
* Provides utilities to construct Feishu Interactive Message Cards for
|
||||
* different agent response states (thinking, streaming, complete, confirm).
|
||||
*/
|
||||
import { optimizeMarkdownStyle } from './markdown-style';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Element ID used for the streaming text area in cards. The CardKit
|
||||
* `cardElement.content()` API targets this element for typewriter-effect
|
||||
* streaming updates.
|
||||
*/
|
||||
export const STREAMING_ELEMENT_ID = 'streaming_content';
|
||||
export const REASONING_ELEMENT_ID = 'reasoning_content';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---- Reasoning text utilities ----
|
||||
// Mirrors the logic in the framework's `splitTelegramReasoningText` and
|
||||
// related helpers from `plugin-sdk/telegram/reasoning-lane-coordinator`.
|
||||
// Those are not exported from the public plugin-sdk entry, so we replicate
|
||||
// the same detection/splitting logic here.
|
||||
const REASONING_PREFIX = 'Reasoning:\n';
|
||||
/**
|
||||
* Split a payload text into optional `reasoningText` and `answerText`.
|
||||
*
|
||||
* Handles two formats produced by the framework:
|
||||
* 1. "Reasoning:\n_italic line_\n…" prefix (from `formatReasoningMessage`)
|
||||
* 2. `<think>…</think>` / `<thinking>…</thinking>` XML tags
|
||||
*
|
||||
* Equivalent to the framework's `splitTelegramReasoningText()`.
|
||||
*/
|
||||
export function splitReasoningText(text) {
|
||||
if (typeof text !== 'string' || !text.trim())
|
||||
return {};
|
||||
const trimmed = text.trim();
|
||||
// Case 1: "Reasoning:\n..." prefix — the entire payload is reasoning
|
||||
if (trimmed.startsWith(REASONING_PREFIX) && trimmed.length > REASONING_PREFIX.length) {
|
||||
return { reasoningText: cleanReasoningPrefix(trimmed) };
|
||||
}
|
||||
// Case 2: XML thinking tags — extract content and strip from answer
|
||||
const taggedReasoning = extractThinkingContent(text);
|
||||
const strippedAnswer = stripReasoningTags(text);
|
||||
if (!taggedReasoning && strippedAnswer === text) {
|
||||
return { answerText: text };
|
||||
}
|
||||
return {
|
||||
reasoningText: taggedReasoning || undefined,
|
||||
answerText: strippedAnswer || undefined,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Extract content from `<think>`, `<thinking>`, `<thought>` blocks.
|
||||
* Handles both closed and unclosed (streaming) tags.
|
||||
*/
|
||||
function extractThinkingContent(text) {
|
||||
if (!text)
|
||||
return '';
|
||||
const scanRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let inThinking = false;
|
||||
for (const match of text.matchAll(scanRe)) {
|
||||
const idx = match.index ?? 0;
|
||||
if (inThinking) {
|
||||
result += text.slice(lastIndex, idx);
|
||||
}
|
||||
inThinking = match[1] !== '/';
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
// Handle unclosed tag (still streaming)
|
||||
if (inThinking) {
|
||||
result += text.slice(lastIndex);
|
||||
}
|
||||
return result.trim();
|
||||
}
|
||||
/**
|
||||
* Strip reasoning blocks — both XML tags with their content and any
|
||||
* "Reasoning:\n" prefixed content.
|
||||
*/
|
||||
export function stripReasoningTags(text) {
|
||||
// Strip complete XML blocks
|
||||
let result = text.replace(/<\s*(?:think(?:ing)?|thought|antthinking)\s*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi, '');
|
||||
// Strip unclosed tag at end (streaming)
|
||||
result = result.replace(/<\s*(?:think(?:ing)?|thought|antthinking)\s*>[\s\S]*$/gi, '');
|
||||
// Strip orphaned closing tags
|
||||
result = result.replace(/<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi, '');
|
||||
return result.trim();
|
||||
}
|
||||
/**
|
||||
* Clean a "Reasoning:\n_italic_" formatted message back to plain text.
|
||||
* Strips the prefix and per-line italic markdown wrappers.
|
||||
*/
|
||||
function cleanReasoningPrefix(text) {
|
||||
let cleaned = text.replace(/^Reasoning:\s*/i, '');
|
||||
cleaned = cleaned
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^_(.+)_$/, '$1'))
|
||||
.join('\n');
|
||||
return cleaned.trim();
|
||||
}
|
||||
/**
|
||||
* Format reasoning duration into a human-readable i18n pair.
|
||||
* e.g. { zh: "思考了 3.2s", en: "Thought for 3.2s" }
|
||||
*/
|
||||
export function formatReasoningDuration(ms) {
|
||||
const d = formatElapsed(ms);
|
||||
return { zh: `思考了 ${d}`, en: `Thought for ${d}` };
|
||||
}
|
||||
/**
|
||||
* Format milliseconds into a human-readable duration string.
|
||||
*/
|
||||
export function formatElapsed(ms) {
|
||||
const seconds = ms / 1000;
|
||||
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
||||
}
|
||||
/**
|
||||
* Build footer meta-info: notation-sized text with i18n support.
|
||||
* Error text is rendered in red; normal text uses default grey (notation).
|
||||
*/
|
||||
function buildFooter(zhText, enText, isError) {
|
||||
const zhContent = isError ? `<font color='red'>${zhText}</font>` : zhText;
|
||||
const enContent = isError ? `<font color='red'>${enText}</font>` : enText;
|
||||
return [{
|
||||
tag: 'markdown',
|
||||
content: enContent,
|
||||
i18n_content: { zh_cn: zhContent, en_us: enContent },
|
||||
text_size: 'notation',
|
||||
}];
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildCardContent
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Build a full Feishu Interactive Message Card JSON object for the
|
||||
* given state.
|
||||
*/
|
||||
export function buildCardContent(state, data = {}) {
|
||||
switch (state) {
|
||||
case 'thinking':
|
||||
return buildThinkingCard();
|
||||
case 'streaming':
|
||||
return buildStreamingCard(data.text ?? '', data.toolCalls ?? [], data.reasoningText);
|
||||
case 'complete':
|
||||
return buildCompleteCard({
|
||||
text: data.text ?? '',
|
||||
toolCalls: data.toolCalls ?? [],
|
||||
elapsedMs: data.elapsedMs,
|
||||
isError: data.isError,
|
||||
reasoningText: data.reasoningText,
|
||||
reasoningElapsedMs: data.reasoningElapsedMs,
|
||||
isAborted: data.isAborted,
|
||||
footer: data.footer,
|
||||
});
|
||||
case 'confirm':
|
||||
return buildConfirmCard(data.confirmData);
|
||||
default:
|
||||
throw new Error(`Unknown card state: ${state}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private card builders
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildThinkingCard() {
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: 'Thinking...',
|
||||
i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function buildStreamingCard(partialText, toolCalls, reasoningText) {
|
||||
const elements = [];
|
||||
if (!partialText && reasoningText) {
|
||||
// Reasoning phase: show reasoning content in notation style
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: `💭 **Thinking...**\n\n${reasoningText}`,
|
||||
i18n_content: {
|
||||
zh_cn: `💭 **思考中...**\n\n${reasoningText}`,
|
||||
en_us: `💭 **Thinking...**\n\n${reasoningText}`,
|
||||
},
|
||||
text_size: 'notation',
|
||||
});
|
||||
}
|
||||
else if (partialText) {
|
||||
// Answer phase: show answer content only
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: optimizeMarkdownStyle(partialText),
|
||||
});
|
||||
}
|
||||
// Tool calls in progress
|
||||
if (toolCalls.length > 0) {
|
||||
const toolLines = toolCalls.map((tc) => {
|
||||
const statusIcon = tc.status === 'running' ? '\ud83d\udd04' : tc.status === 'complete' ? '\u2705' : '\u274c';
|
||||
return `${statusIcon} ${tc.name} - ${tc.status}`;
|
||||
});
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: toolLines.join('\n'),
|
||||
text_size: 'notation',
|
||||
});
|
||||
}
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
|
||||
elements,
|
||||
};
|
||||
}
|
||||
function buildCompleteCard(params) {
|
||||
const { text, toolCalls, elapsedMs, isError, reasoningText, reasoningElapsedMs, isAborted, footer } = params;
|
||||
const elements = [];
|
||||
// Collapsible reasoning panel (before main content)
|
||||
if (reasoningText) {
|
||||
const dur = reasoningElapsedMs ? formatReasoningDuration(reasoningElapsedMs) : null;
|
||||
const zhLabel = dur ? dur.zh : '思考';
|
||||
const enLabel = dur ? dur.en : 'Thought';
|
||||
elements.push({
|
||||
tag: 'collapsible_panel',
|
||||
expanded: false,
|
||||
header: {
|
||||
title: {
|
||||
tag: 'markdown',
|
||||
content: `💭 ${enLabel}`,
|
||||
i18n_content: {
|
||||
zh_cn: `💭 ${zhLabel}`,
|
||||
en_us: `💭 ${enLabel}`,
|
||||
},
|
||||
},
|
||||
vertical_align: 'center',
|
||||
icon: {
|
||||
tag: 'standard_icon',
|
||||
token: 'down-small-ccm_outlined',
|
||||
size: '16px 16px',
|
||||
},
|
||||
icon_position: 'follow_text',
|
||||
icon_expanded_angle: -180,
|
||||
},
|
||||
border: { color: 'grey', corner_radius: '5px' },
|
||||
vertical_spacing: '8px',
|
||||
padding: '8px 8px 8px 8px',
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: reasoningText,
|
||||
text_size: 'notation',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Full text content
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: optimizeMarkdownStyle(text),
|
||||
});
|
||||
// Tool calls summary
|
||||
if (toolCalls.length > 0) {
|
||||
const toolSummaryLines = toolCalls.map((tc) => {
|
||||
const statusIcon = tc.status === 'complete' ? '\u2705' : '\u274c';
|
||||
return `${statusIcon} **${tc.name}** - ${tc.status}`;
|
||||
});
|
||||
elements.push({
|
||||
tag: 'markdown',
|
||||
content: toolSummaryLines.join('\n'),
|
||||
text_size: 'notation',
|
||||
});
|
||||
}
|
||||
// Footer meta-info: each metadata item is independently controlled via
|
||||
// the `footer` config. Both status and elapsed default to hidden.
|
||||
const zhParts = [];
|
||||
const enParts = [];
|
||||
if (footer?.status) {
|
||||
if (isError) {
|
||||
zhParts.push('出错');
|
||||
enParts.push('Error');
|
||||
}
|
||||
else if (isAborted) {
|
||||
zhParts.push('已停止');
|
||||
enParts.push('Stopped');
|
||||
}
|
||||
else {
|
||||
zhParts.push('已完成');
|
||||
enParts.push('Completed');
|
||||
}
|
||||
}
|
||||
if (footer?.elapsed && elapsedMs != null) {
|
||||
const d = formatElapsed(elapsedMs);
|
||||
zhParts.push(`耗时 ${d}`);
|
||||
enParts.push(`Elapsed ${d}`);
|
||||
}
|
||||
if (zhParts.length > 0) {
|
||||
elements.push(...buildFooter(zhParts.join(' · '), enParts.join(' · '), isError));
|
||||
}
|
||||
// Use the answer text (not reasoning) as the feed preview summary.
|
||||
// Strip markdown syntax so the preview reads as plain text.
|
||||
const summaryText = text.replace(/[*_`#>\[\]()~]/g, '').trim();
|
||||
const summary = summaryText ? { content: summaryText.slice(0, 120) } : undefined;
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'], summary },
|
||||
elements,
|
||||
};
|
||||
}
|
||||
function buildConfirmCard(confirmData) {
|
||||
const elements = [];
|
||||
// Operation description
|
||||
elements.push({
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: confirmData.operationDescription,
|
||||
},
|
||||
});
|
||||
// Preview (if available)
|
||||
if (confirmData.preview) {
|
||||
elements.push({ tag: 'hr' });
|
||||
elements.push({
|
||||
tag: 'div',
|
||||
text: {
|
||||
tag: 'lark_md',
|
||||
content: `**Preview:**\n${confirmData.preview}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Confirm / Reject / Preview buttons
|
||||
elements.push({ tag: 'hr' });
|
||||
elements.push({
|
||||
tag: 'action',
|
||||
actions: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: 'Confirm' },
|
||||
type: 'primary',
|
||||
value: {
|
||||
action: 'confirm_write',
|
||||
operation_id: confirmData.pendingOperationId,
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: 'button',
|
||||
text: { tag: 'plain_text', content: 'Reject' },
|
||||
type: 'danger',
|
||||
value: {
|
||||
action: 'reject_write',
|
||||
operation_id: confirmData.pendingOperationId,
|
||||
},
|
||||
},
|
||||
...(confirmData.preview
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tag: 'button',
|
||||
text: {
|
||||
tag: 'plain_text',
|
||||
content: 'Preview',
|
||||
},
|
||||
type: 'default',
|
||||
value: {
|
||||
action: 'preview_write',
|
||||
operation_id: confirmData.pendingOperationId,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
return {
|
||||
config: { wide_screen_mode: true, update_multi: true },
|
||||
header: {
|
||||
title: {
|
||||
tag: 'plain_text',
|
||||
content: '\ud83d\udd12 Confirmation Required',
|
||||
},
|
||||
template: 'orange',
|
||||
},
|
||||
elements,
|
||||
};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// toCardKit2
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
|
||||
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
|
||||
*/
|
||||
export function toCardKit2(card) {
|
||||
const result = {
|
||||
schema: '2.0',
|
||||
config: card.config,
|
||||
body: { elements: card.elements },
|
||||
};
|
||||
if (card.header)
|
||||
result.header = card.header;
|
||||
return result;
|
||||
}
|
||||
90
openclaw/extensions/openclaw-lark/src/card/cardkit.d.ts
vendored
Normal file
90
openclaw/extensions/openclaw-lark/src/card/cardkit.d.ts
vendored
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* CardKit streaming APIs for Lark/Feishu.
|
||||
*/
|
||||
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
import type { FeishuSendResult } from '../messaging/types';
|
||||
/**
|
||||
* Create a card entity via the CardKit API.
|
||||
*
|
||||
* Returns the card_id directly, bypassing the idConvert step.
|
||||
* The card can then be sent via IM API and streamed via CardKit.
|
||||
*/
|
||||
export declare function createCardEntity(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
card: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}): Promise<string | null>;
|
||||
/**
|
||||
* Stream text content to a specific card element using the CardKit API.
|
||||
*
|
||||
* The card automatically diffs the new content against the previous
|
||||
* content and renders incremental changes with a typewriter animation.
|
||||
*
|
||||
* @param params.cardId - CardKit card ID (from `convertMessageToCardId`).
|
||||
* @param params.elementId - The element ID to update (e.g. `STREAMING_ELEMENT_ID`).
|
||||
* @param params.content - The full cumulative text (not a delta).
|
||||
* @param params.sequence - Monotonically increasing sequence number.
|
||||
*/
|
||||
export declare function streamCardContent(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
cardId: string;
|
||||
elementId: string;
|
||||
content: string;
|
||||
sequence: number;
|
||||
accountId?: string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Fully replace a card using the CardKit API.
|
||||
*
|
||||
* Used for the final "complete" state update (with action buttons, green
|
||||
* header, etc.) after streaming finishes.
|
||||
*
|
||||
* @param params.cardId - CardKit card ID.
|
||||
* @param params.card - The new card JSON content.
|
||||
* @param params.sequence - Monotonically increasing sequence number.
|
||||
*/
|
||||
export declare function updateCardKitCard(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
cardId: string;
|
||||
card: Record<string, unknown>;
|
||||
sequence: number;
|
||||
accountId?: string;
|
||||
}): Promise<void>;
|
||||
export declare function updateCardKitCardForAuth(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
cardId: string;
|
||||
card: Record<string, unknown>;
|
||||
sequence: number;
|
||||
accountId?: string;
|
||||
}): Promise<void>;
|
||||
/**
|
||||
* Send an interactive card message by referencing a CardKit card_id.
|
||||
*
|
||||
* The content format is: {"type":"card","data":{"card_id":"xxx"}}
|
||||
* This links the IM message to the CardKit card entity, enabling
|
||||
* streaming updates via cardElement.content().
|
||||
*/
|
||||
export declare function sendCardByCardId(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
cardId: string;
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuSendResult>;
|
||||
/**
|
||||
* Close (or open) the streaming mode on a CardKit card.
|
||||
*
|
||||
* Must be called after streaming is complete to restore normal card
|
||||
* behaviour (forwarding, interaction callbacks, etc.).
|
||||
*/
|
||||
export declare function setCardStreamingMode(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
cardId: string;
|
||||
streamingMode: boolean;
|
||||
sequence: number;
|
||||
accountId?: string;
|
||||
}): Promise<void>;
|
||||
182
openclaw/extensions/openclaw-lark/src/card/cardkit.js
Normal file
182
openclaw/extensions/openclaw-lark/src/card/cardkit.js
Normal file
@ -0,0 +1,182 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* CardKit streaming APIs for Lark/Feishu.
|
||||
*/
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { normalizeFeishuTarget, normalizeMessageId, resolveReceiveIdType } from '../core/targets';
|
||||
import { runWithMessageUnavailableGuard } from '../core/message-unavailable';
|
||||
const log = larkLogger('card/cardkit');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* 记录 CardKit API 响应日志,检测错误码并抛出异常。
|
||||
*
|
||||
* 默认 fail-fast:body-level 非零 code 视为业务错误,立即抛出,
|
||||
* 由调用方(streaming-card-controller 等)统一走 catch → guard 处理。
|
||||
*/
|
||||
function logCardKitResponse(params) {
|
||||
const { resp, api, context } = params;
|
||||
const { code, msg } = resp;
|
||||
log.info(`cardkit ${api} response`, { code, msg, context });
|
||||
if (code && code !== 0) {
|
||||
log.warn(`cardkit ${api} FAILED`, { code, msg, context, fullResponse: resp });
|
||||
throw new Error(`cardkit ${api} FAILED: code=${code}, msg=${msg ?? ''}, ${context}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKit streaming APIs
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Create a card entity via the CardKit API.
|
||||
*
|
||||
* Returns the card_id directly, bypassing the idConvert step.
|
||||
* The card can then be sent via IM API and streamed via CardKit.
|
||||
*/
|
||||
export async function createCardEntity(params) {
|
||||
const { cfg, card, accountId } = params;
|
||||
const client = LarkClient.fromCfg(cfg, accountId).sdk;
|
||||
// SDK 返回类型不完整,运行时包含 code/msg/data 字段
|
||||
const response = (await client.cardkit.v1.card.create({
|
||||
data: {
|
||||
type: 'card_json',
|
||||
data: JSON.stringify(card),
|
||||
},
|
||||
}));
|
||||
// 兼容不同 SDK 包装层:优先 data.card_id,回退顶层 card_id
|
||||
const cardId = (response.data?.card_id ?? response.card_id) ?? null;
|
||||
logCardKitResponse({ resp: response, api: 'card.create', context: `cardId=${cardId}` });
|
||||
return cardId;
|
||||
}
|
||||
/**
|
||||
* Stream text content to a specific card element using the CardKit API.
|
||||
*
|
||||
* The card automatically diffs the new content against the previous
|
||||
* content and renders incremental changes with a typewriter animation.
|
||||
*
|
||||
* @param params.cardId - CardKit card ID (from `convertMessageToCardId`).
|
||||
* @param params.elementId - The element ID to update (e.g. `STREAMING_ELEMENT_ID`).
|
||||
* @param params.content - The full cumulative text (not a delta).
|
||||
* @param params.sequence - Monotonically increasing sequence number.
|
||||
*/
|
||||
export async function streamCardContent(params) {
|
||||
const { cfg, cardId, elementId, content, sequence, accountId } = params;
|
||||
const client = LarkClient.fromCfg(cfg, accountId).sdk;
|
||||
// SDK 返回类型不完整,运行时包含 code/msg 字段
|
||||
const resp = (await client.cardkit.v1.cardElement.content({
|
||||
data: { content, sequence },
|
||||
path: { card_id: cardId, element_id: elementId },
|
||||
}));
|
||||
logCardKitResponse({
|
||||
resp,
|
||||
api: 'cardElement.content',
|
||||
context: `seq=${sequence}, contentLen=${content.length}`,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Fully replace a card using the CardKit API.
|
||||
*
|
||||
* Used for the final "complete" state update (with action buttons, green
|
||||
* header, etc.) after streaming finishes.
|
||||
*
|
||||
* @param params.cardId - CardKit card ID.
|
||||
* @param params.card - The new card JSON content.
|
||||
* @param params.sequence - Monotonically increasing sequence number.
|
||||
*/
|
||||
export async function updateCardKitCard(params) {
|
||||
const { cfg, cardId, card, sequence, accountId } = params;
|
||||
const client = LarkClient.fromCfg(cfg, accountId).sdk;
|
||||
// SDK 返回类型不完整,运行时包含 code/msg 字段
|
||||
const resp = (await client.cardkit.v1.card.update({
|
||||
data: {
|
||||
card: { type: 'card_json', data: JSON.stringify(card) },
|
||||
sequence,
|
||||
},
|
||||
path: { card_id: cardId },
|
||||
}));
|
||||
logCardKitResponse({
|
||||
resp,
|
||||
api: 'card.update',
|
||||
context: `seq=${sequence}, cardId=${cardId}`,
|
||||
});
|
||||
}
|
||||
export async function updateCardKitCardForAuth(params) {
|
||||
return updateCardKitCard(params);
|
||||
}
|
||||
/**
|
||||
* Send an interactive card message by referencing a CardKit card_id.
|
||||
*
|
||||
* The content format is: {"type":"card","data":{"card_id":"xxx"}}
|
||||
* This links the IM message to the CardKit card entity, enabling
|
||||
* streaming updates via cardElement.content().
|
||||
*/
|
||||
export async function sendCardByCardId(params) {
|
||||
const { cfg, to, cardId, replyToMessageId, replyInThread, accountId } = params;
|
||||
const client = LarkClient.fromCfg(cfg, accountId).sdk;
|
||||
const contentPayload = JSON.stringify({
|
||||
type: 'card',
|
||||
data: { card_id: cardId },
|
||||
});
|
||||
if (replyToMessageId) {
|
||||
// 规范化 message_id,处理合成 ID(如 "om_xxx:auth-complete")
|
||||
const normalizedId = normalizeMessageId(replyToMessageId);
|
||||
const response = await runWithMessageUnavailableGuard({
|
||||
messageId: normalizedId,
|
||||
operation: 'im.message.reply(interactive.cardkit)',
|
||||
fn: () => client.im.message.reply({
|
||||
path: { message_id: normalizedId },
|
||||
data: { content: contentPayload, msg_type: 'interactive', reply_in_thread: replyInThread },
|
||||
}),
|
||||
});
|
||||
return {
|
||||
messageId: response?.data?.message_id ?? '',
|
||||
chatId: response?.data?.chat_id ?? '',
|
||||
};
|
||||
}
|
||||
const target = normalizeFeishuTarget(to);
|
||||
if (!target) {
|
||||
throw new Error(`[feishu-send] Invalid target: "${to}"`);
|
||||
}
|
||||
const receiveIdType = resolveReceiveIdType(target);
|
||||
const response = await client.im.message.create({
|
||||
// SDK 类型将 receive_id_type 限定为字面量联合,但运行时接受动态值
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: target,
|
||||
msg_type: 'interactive',
|
||||
content: contentPayload,
|
||||
},
|
||||
});
|
||||
return {
|
||||
messageId: response?.data?.message_id ?? '',
|
||||
chatId: response?.data?.chat_id ?? '',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Close (or open) the streaming mode on a CardKit card.
|
||||
*
|
||||
* Must be called after streaming is complete to restore normal card
|
||||
* behaviour (forwarding, interaction callbacks, etc.).
|
||||
*/
|
||||
export async function setCardStreamingMode(params) {
|
||||
const { cfg, cardId, streamingMode, sequence, accountId } = params;
|
||||
const client = LarkClient.fromCfg(cfg, accountId).sdk;
|
||||
// SDK 返回类型不完整,运行时包含 code/msg 字段
|
||||
const resp = (await client.cardkit.v1.card.settings({
|
||||
data: {
|
||||
settings: JSON.stringify({ streaming_mode: streamingMode }),
|
||||
sequence,
|
||||
},
|
||||
path: { card_id: cardId },
|
||||
}));
|
||||
logCardKitResponse({
|
||||
resp,
|
||||
api: 'card.settings',
|
||||
context: `seq=${sequence}, streaming_mode=${streamingMode}`,
|
||||
});
|
||||
}
|
||||
45
openclaw/extensions/openclaw-lark/src/card/flush-controller.d.ts
vendored
Normal file
45
openclaw/extensions/openclaw-lark/src/card/flush-controller.d.ts
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Generic throttled flush controller.
|
||||
*
|
||||
* A pure scheduling primitive that manages timer-based throttling,
|
||||
* mutex-guarded flushing, and reflush-on-conflict. Contains no
|
||||
* business logic — the actual flush work is provided via a callback.
|
||||
*/
|
||||
export declare class FlushController {
|
||||
private readonly doFlush;
|
||||
private flushInProgress;
|
||||
private flushResolvers;
|
||||
private needsReflush;
|
||||
private pendingFlushTimer;
|
||||
private lastUpdateTime;
|
||||
private isCompleted;
|
||||
constructor(doFlush: () => Promise<void>);
|
||||
/** Mark the controller as completed — no more flushes after current one. */
|
||||
complete(): void;
|
||||
/** Cancel any pending deferred flush timer. */
|
||||
cancelPendingFlush(): void;
|
||||
/** Wait for any in-progress flush to finish. */
|
||||
waitForFlush(): Promise<void>;
|
||||
/**
|
||||
* Execute a flush (mutex-guarded, with reflush on conflict).
|
||||
*
|
||||
* If a flush is already in progress, marks needsReflush so a
|
||||
* follow-up flush fires immediately after the current one completes.
|
||||
*/
|
||||
flush(): Promise<void>;
|
||||
/**
|
||||
* Throttled update entry point.
|
||||
*
|
||||
* @param throttleMs - Minimum interval between flushes (varies by
|
||||
* CardKit vs IM patch mode). Passed in by the caller so this
|
||||
* controller remains business-logic-free.
|
||||
*/
|
||||
throttledUpdate(throttleMs: number): Promise<void>;
|
||||
/** Overridable gate: subclasses / consumers can set via setCardMessageReady. */
|
||||
private _cardMessageReady;
|
||||
cardMessageReady(): boolean;
|
||||
setCardMessageReady(ready: boolean): void;
|
||||
}
|
||||
135
openclaw/extensions/openclaw-lark/src/card/flush-controller.js
Normal file
135
openclaw/extensions/openclaw-lark/src/card/flush-controller.js
Normal file
@ -0,0 +1,135 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Generic throttled flush controller.
|
||||
*
|
||||
* A pure scheduling primitive that manages timer-based throttling,
|
||||
* mutex-guarded flushing, and reflush-on-conflict. Contains no
|
||||
* business logic — the actual flush work is provided via a callback.
|
||||
*/
|
||||
import { THROTTLE_CONSTANTS } from './reply-dispatcher-types';
|
||||
// ---------------------------------------------------------------------------
|
||||
// FlushController
|
||||
// ---------------------------------------------------------------------------
|
||||
export class FlushController {
|
||||
doFlush;
|
||||
flushInProgress = false;
|
||||
flushResolvers = [];
|
||||
needsReflush = false;
|
||||
pendingFlushTimer = null;
|
||||
lastUpdateTime = 0;
|
||||
isCompleted = false;
|
||||
constructor(doFlush) {
|
||||
this.doFlush = doFlush;
|
||||
}
|
||||
/** Mark the controller as completed — no more flushes after current one. */
|
||||
complete() {
|
||||
this.isCompleted = true;
|
||||
}
|
||||
/** Cancel any pending deferred flush timer. */
|
||||
cancelPendingFlush() {
|
||||
if (this.pendingFlushTimer) {
|
||||
clearTimeout(this.pendingFlushTimer);
|
||||
this.pendingFlushTimer = null;
|
||||
}
|
||||
}
|
||||
/** Wait for any in-progress flush to finish. */
|
||||
waitForFlush() {
|
||||
if (!this.flushInProgress)
|
||||
return Promise.resolve();
|
||||
return new Promise((resolve) => this.flushResolvers.push(resolve));
|
||||
}
|
||||
/**
|
||||
* Execute a flush (mutex-guarded, with reflush on conflict).
|
||||
*
|
||||
* If a flush is already in progress, marks needsReflush so a
|
||||
* follow-up flush fires immediately after the current one completes.
|
||||
*/
|
||||
async flush() {
|
||||
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
|
||||
if (this.flushInProgress && !this.isCompleted)
|
||||
this.needsReflush = true;
|
||||
return;
|
||||
}
|
||||
this.flushInProgress = true;
|
||||
this.needsReflush = false;
|
||||
// Update timestamp BEFORE the API call to prevent concurrent callers
|
||||
// from also entering the flush (race condition fix).
|
||||
this.lastUpdateTime = Date.now();
|
||||
try {
|
||||
await this.doFlush();
|
||||
this.lastUpdateTime = Date.now();
|
||||
}
|
||||
finally {
|
||||
this.flushInProgress = false;
|
||||
const resolvers = this.flushResolvers;
|
||||
this.flushResolvers = [];
|
||||
for (const resolve of resolvers)
|
||||
resolve();
|
||||
// If events arrived while the API call was in flight,
|
||||
// schedule an immediate follow-up flush.
|
||||
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
|
||||
this.needsReflush = false;
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null;
|
||||
void this.flush();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Throttled update entry point.
|
||||
*
|
||||
* @param throttleMs - Minimum interval between flushes (varies by
|
||||
* CardKit vs IM patch mode). Passed in by the caller so this
|
||||
* controller remains business-logic-free.
|
||||
*/
|
||||
async throttledUpdate(throttleMs) {
|
||||
if (!this.cardMessageReady())
|
||||
return;
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.lastUpdateTime;
|
||||
if (elapsed >= throttleMs) {
|
||||
this.cancelPendingFlush();
|
||||
if (elapsed > THROTTLE_CONSTANTS.LONG_GAP_THRESHOLD_MS) {
|
||||
// After a long gap, batch briefly so the first visible update
|
||||
// contains meaningful text rather than just 1-2 characters.
|
||||
this.lastUpdateTime = now;
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null;
|
||||
void this.flush();
|
||||
}, THROTTLE_CONSTANTS.BATCH_AFTER_GAP_MS);
|
||||
}
|
||||
else {
|
||||
await this.flush();
|
||||
}
|
||||
}
|
||||
else if (!this.pendingFlushTimer) {
|
||||
// Inside throttle window — schedule a deferred flush
|
||||
const delay = throttleMs - elapsed;
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null;
|
||||
void this.flush();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal
|
||||
// ------------------------------------------------------------------
|
||||
/** Overridable gate: subclasses / consumers can set via setCardMessageReady. */
|
||||
_cardMessageReady = false;
|
||||
cardMessageReady() {
|
||||
return this._cardMessageReady;
|
||||
}
|
||||
setCardMessageReady(ready) {
|
||||
this._cardMessageReady = ready;
|
||||
if (ready) {
|
||||
// Initialize the timestamp so the first throttledUpdate sees a
|
||||
// small elapsed time (matching original behavior where
|
||||
// lastCardUpdateTime = Date.now() was set during card creation).
|
||||
this.lastUpdateTime = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
openclaw/extensions/openclaw-lark/src/card/image-resolver.d.ts
vendored
Normal file
45
openclaw/extensions/openclaw-lark/src/card/image-resolver.d.ts
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* ImageResolver — converts image URLs in markdown to Feishu image keys.
|
||||
*
|
||||
* Used by StreamingCardController to asynchronously download and upload
|
||||
* images referenced via `` in model-generated markdown,
|
||||
* replacing them with `` that Feishu cards can render.
|
||||
*/
|
||||
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
export interface ImageResolverOptions {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string | undefined;
|
||||
/** Called when a previously-pending image upload completes. */
|
||||
onImageResolved: () => void;
|
||||
}
|
||||
export declare class ImageResolver {
|
||||
/** URL → imageKey for successfully uploaded images. */
|
||||
private readonly resolved;
|
||||
/** URL → upload Promise for in-flight uploads (dedup). */
|
||||
private readonly pending;
|
||||
/** URLs that have already failed — skip retries. */
|
||||
private readonly failed;
|
||||
private readonly cfg;
|
||||
private readonly accountId;
|
||||
private readonly onImageResolved;
|
||||
constructor(opts: ImageResolverOptions);
|
||||
/**
|
||||
* Synchronously resolve image URLs in markdown text.
|
||||
*
|
||||
* - `img_xxx` references are kept as-is.
|
||||
* - URLs with a cached imageKey are replaced inline.
|
||||
* - URLs with an in-flight upload are stripped (will appear after re-flush).
|
||||
* - New URLs trigger an async upload and are stripped for now.
|
||||
*/
|
||||
resolveImages(text: string): string;
|
||||
/**
|
||||
* Resolve all image URLs in text synchronously: trigger uploads for new
|
||||
* URLs, wait for all pending uploads, then return text with image keys.
|
||||
*/
|
||||
resolveImagesAwait(text: string, timeoutMs: number): Promise<string>;
|
||||
private startUpload;
|
||||
private doUpload;
|
||||
}
|
||||
113
openclaw/extensions/openclaw-lark/src/card/image-resolver.js
Normal file
113
openclaw/extensions/openclaw-lark/src/card/image-resolver.js
Normal file
@ -0,0 +1,113 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* ImageResolver — converts image URLs in markdown to Feishu image keys.
|
||||
*
|
||||
* Used by StreamingCardController to asynchronously download and upload
|
||||
* images referenced via `` in model-generated markdown,
|
||||
* replacing them with `` that Feishu cards can render.
|
||||
*/
|
||||
import { fetchRemoteImageBuffer, uploadImageLark } from '../messaging/outbound/media';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
const log = larkLogger('card/image-resolver');
|
||||
/** Matches complete markdown image syntax: `` */
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||
export class ImageResolver {
|
||||
/** URL → imageKey for successfully uploaded images. */
|
||||
resolved = new Map();
|
||||
/** URL → upload Promise for in-flight uploads (dedup). */
|
||||
pending = new Map();
|
||||
/** URLs that have already failed — skip retries. */
|
||||
failed = new Set();
|
||||
cfg;
|
||||
accountId;
|
||||
onImageResolved;
|
||||
constructor(opts) {
|
||||
this.cfg = opts.cfg;
|
||||
this.accountId = opts.accountId;
|
||||
this.onImageResolved = opts.onImageResolved;
|
||||
}
|
||||
/**
|
||||
* Synchronously resolve image URLs in markdown text.
|
||||
*
|
||||
* - `img_xxx` references are kept as-is.
|
||||
* - URLs with a cached imageKey are replaced inline.
|
||||
* - URLs with an in-flight upload are stripped (will appear after re-flush).
|
||||
* - New URLs trigger an async upload and are stripped for now.
|
||||
*/
|
||||
resolveImages(text) {
|
||||
if (!text.includes('`;
|
||||
// Already failed — don't retry, strip.
|
||||
if (this.failed.has(value))
|
||||
return '';
|
||||
// Upload in progress — strip for now.
|
||||
if (this.pending.has(value))
|
||||
return '';
|
||||
// New URL — kick off async upload, strip for now.
|
||||
this.startUpload(value);
|
||||
return '';
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Resolve all image URLs in text synchronously: trigger uploads for new
|
||||
* URLs, wait for all pending uploads, then return text with image keys.
|
||||
*/
|
||||
async resolveImagesAwait(text, timeoutMs) {
|
||||
// First pass: trigger uploads for any new URLs
|
||||
this.resolveImages(text);
|
||||
if (this.pending.size > 0) {
|
||||
log.info('resolveImagesAwait: waiting for uploads', { count: this.pending.size, timeoutMs });
|
||||
const allUploads = Promise.all(this.pending.values());
|
||||
const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs));
|
||||
await Promise.race([allUploads, timeout]);
|
||||
if (this.pending.size > 0) {
|
||||
log.warn('resolveImagesAwait: timed out with pending uploads', {
|
||||
remaining: this.pending.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Second pass: replace URLs with resolved image keys
|
||||
return this.resolveImages(text);
|
||||
}
|
||||
startUpload(url) {
|
||||
const uploadPromise = this.doUpload(url);
|
||||
this.pending.set(url, uploadPromise);
|
||||
}
|
||||
async doUpload(url) {
|
||||
try {
|
||||
log.info('uploading image', { url });
|
||||
const buffer = await fetchRemoteImageBuffer(url);
|
||||
const { imageKey } = await uploadImageLark({
|
||||
cfg: this.cfg,
|
||||
image: buffer,
|
||||
imageType: 'message',
|
||||
accountId: this.accountId,
|
||||
});
|
||||
log.info('image uploaded', { url, imageKey });
|
||||
this.resolved.set(url, imageKey);
|
||||
this.pending.delete(url);
|
||||
this.onImageResolved();
|
||||
return imageKey;
|
||||
}
|
||||
catch (err) {
|
||||
log.warn('image upload failed', { url, error: String(err) });
|
||||
this.pending.delete(url);
|
||||
this.failed.add(url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
openclaw/extensions/openclaw-lark/src/card/markdown-style.d.ts
vendored
Normal file
16
openclaw/extensions/openclaw-lark/src/card/markdown-style.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Markdown 样式优化工具
|
||||
*/
|
||||
/**
|
||||
* 优化 Markdown 样式:
|
||||
* - 标题降级:H1 → H4,H2~H6 → H5
|
||||
* - 表格前后增加段落间距
|
||||
* - 有序列表:序号后确保只有一个空格
|
||||
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
|
||||
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
|
||||
* - 代码块内容不受影响
|
||||
*/
|
||||
export declare function optimizeMarkdownStyle(text: string, cardVersion?: number): string;
|
||||
98
openclaw/extensions/openclaw-lark/src/card/markdown-style.js
Normal file
98
openclaw/extensions/openclaw-lark/src/card/markdown-style.js
Normal file
@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Markdown 样式优化工具
|
||||
*/
|
||||
/**
|
||||
* 优化 Markdown 样式:
|
||||
* - 标题降级:H1 → H4,H2~H6 → H5
|
||||
* - 表格前后增加段落间距
|
||||
* - 有序列表:序号后确保只有一个空格
|
||||
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
|
||||
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
|
||||
* - 代码块内容不受影响
|
||||
*/
|
||||
export function optimizeMarkdownStyle(text, cardVersion = 2) {
|
||||
try {
|
||||
let r = _optimizeMarkdownStyle(text, cardVersion);
|
||||
r = stripInvalidImageKeys(r);
|
||||
return r;
|
||||
}
|
||||
catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
function _optimizeMarkdownStyle(text, cardVersion = 2) {
|
||||
// ── 1. 提取代码块,用占位符保护,处理后再还原 ─────────────────────
|
||||
const MARK = '___CB_';
|
||||
const codeBlocks = [];
|
||||
let r = text.replace(/```[\s\S]*?```/g, (m) => {
|
||||
return `${MARK}${codeBlocks.push(m) - 1}___`;
|
||||
});
|
||||
// ── 2. 标题降级 ────────────────────────────────────────────────────
|
||||
// 只有当原文档包含 h1~h3 标题时才执行降级
|
||||
// 先处理 H2~H6 → H5,再处理 H1 → H4
|
||||
// 顺序不能颠倒:若先 H1→H4,H4(####)会被后面的 #{2,6} 再次匹配成 H5
|
||||
const hasH1toH3 = /^#{1,3} /m.test(text);
|
||||
if (hasH1toH3) {
|
||||
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2~H6 → H5
|
||||
r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4
|
||||
}
|
||||
if (cardVersion >= 2) {
|
||||
// ── 3. 连续标题间增加段落间距 ───────────────────────────────────────
|
||||
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2');
|
||||
// ── 4. 表格前后增加段落间距 ─────────────────────────────────────────
|
||||
// 4a. 非表格行直接跟表格行时,先补一个空行
|
||||
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
|
||||
// 4b. 表格前:在空行之前插入 <br>(即 \n\n| → \n<br>\n\n| )
|
||||
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1');
|
||||
// 4c. 表格后:在表格块末尾追加 <br>
|
||||
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n<br>\n');
|
||||
// 4d. 表格前是普通文本(非标题、非加粗行)时,只需 <br>,去掉多余空行
|
||||
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
|
||||
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3');
|
||||
// 4d2. 表格前是加粗行时,<br> 紧贴加粗行,空行保留在后面
|
||||
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
|
||||
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3');
|
||||
// 4e. 表格后是普通文本(非标题、非加粗行)时,只需 <br>,去掉多余空行
|
||||
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
|
||||
r = r.replace(/(\|[^\n]*\n)\n(<br>\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3');
|
||||
// ── 5. 还原代码块,并在前后追加 <br> ──────────────────────────────
|
||||
codeBlocks.forEach((block, i) => {
|
||||
r = r.replace(`${MARK}${i}___`, `\n<br>\n${block}\n<br>\n`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// ── 5. 还原代码块(无 <br>)───────────────────────────────────────
|
||||
codeBlocks.forEach((block, i) => {
|
||||
r = r.replace(`${MARK}${i}___`, block);
|
||||
});
|
||||
}
|
||||
// ── 6. 压缩多余空行(3 个以上连续换行 → 2 个)────────────────────
|
||||
r = r.replace(/\n{3,}/g, '\n\n');
|
||||
return r;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripInvalidImageKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Matches complete markdown image syntax: `` */
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||
/**
|
||||
* Strip `` where value is not a valid Feishu image key
|
||||
* (`img_xxx`). Prevents CardKit error 200570.
|
||||
*
|
||||
* HTTP URLs are stripped as well — ImageResolver should have already
|
||||
* replaced them with `img_xxx` keys before this point. This serves
|
||||
* as a safety net for any unresolved URLs.
|
||||
*/
|
||||
function stripInvalidImageKeys(text) {
|
||||
if (!text.includes('!['))
|
||||
return text;
|
||||
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
|
||||
if (value.startsWith('img_'))
|
||||
return fullMatch;
|
||||
return ''; // strip all non-img_ image references (URLs, local paths, etc.)
|
||||
});
|
||||
}
|
||||
120
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher-types.d.ts
vendored
Normal file
120
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher-types.d.ts
vendored
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Type definitions for the Feishu reply dispatcher subsystem.
|
||||
*
|
||||
* Consolidates all interfaces, state shapes, and constants used across
|
||||
* reply-dispatcher.ts, streaming-card-controller.ts, flush-controller.ts,
|
||||
* and unavailable-guard.ts.
|
||||
*/
|
||||
import type { ClawdbotConfig, ReplyPayload } from 'openclaw/plugin-sdk';
|
||||
import type { FeishuFooterConfig } from '../core/types';
|
||||
export declare const CARD_PHASES: {
|
||||
readonly idle: "idle";
|
||||
readonly creating: "creating";
|
||||
readonly streaming: "streaming";
|
||||
readonly completed: "completed";
|
||||
readonly aborted: "aborted";
|
||||
readonly terminated: "terminated";
|
||||
readonly creation_failed: "creation_failed";
|
||||
};
|
||||
export type CardPhase = (typeof CARD_PHASES)[keyof typeof CARD_PHASES];
|
||||
export declare const TERMINAL_PHASES: ReadonlySet<CardPhase>;
|
||||
/**
|
||||
* Why a terminal phase was entered.
|
||||
*
|
||||
* - `normal` — streaming completed successfully (onIdle).
|
||||
* - `error` — an error occurred during reply generation (onError).
|
||||
* - `abort` — explicitly cancelled by the caller (abortCard).
|
||||
* - `unavailable` — source message was deleted/recalled (UnavailableGuard).
|
||||
* - `creation_failed` — card creation failed, falling back to static delivery.
|
||||
*/
|
||||
export type TerminalReason = 'normal' | 'error' | 'abort' | 'unavailable' | 'creation_failed';
|
||||
export declare const PHASE_TRANSITIONS: Record<CardPhase, ReadonlySet<CardPhase>>;
|
||||
export interface ReasoningState {
|
||||
accumulatedReasoningText: string;
|
||||
reasoningStartTime: number | null;
|
||||
reasoningElapsedMs: number;
|
||||
isReasoningPhase: boolean;
|
||||
}
|
||||
export interface StreamingTextState {
|
||||
accumulatedText: string;
|
||||
completedText: string;
|
||||
streamingPrefix: string;
|
||||
lastPartialText: string;
|
||||
}
|
||||
export interface CardKitState {
|
||||
cardKitCardId: string | null;
|
||||
originalCardKitCardId: string | null;
|
||||
cardKitSequence: number;
|
||||
cardMessageId: string | null;
|
||||
}
|
||||
/**
|
||||
* Throttle intervals for card updates.
|
||||
*
|
||||
* - `CARDKIT_MS`: CardKit `cardElement.content()` — designed for streaming,
|
||||
* low throttle is fine.
|
||||
* - `PATCH_MS`: `im.message.patch` — strict rate limits (code 230020).
|
||||
* - `LONG_GAP_THRESHOLD_MS`: After a long idle gap (tool call / LLM thinking),
|
||||
* defer the first flush briefly.
|
||||
* - `BATCH_AFTER_GAP_MS`: Batching window after a long gap.
|
||||
*/
|
||||
export declare const THROTTLE_CONSTANTS: {
|
||||
readonly CARDKIT_MS: 100;
|
||||
readonly PATCH_MS: 1500;
|
||||
readonly LONG_GAP_THRESHOLD_MS: 2000;
|
||||
readonly BATCH_AFTER_GAP_MS: 300;
|
||||
};
|
||||
export declare const EMPTY_REPLY_FALLBACK_TEXT = "Done.";
|
||||
export interface CreateFeishuReplyDispatcherParams {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
chatId: string;
|
||||
replyToMessageId?: string;
|
||||
/** Account ID for multi-account support. */
|
||||
accountId?: string;
|
||||
/** Chat type for scene-aware reply mode selection. */
|
||||
chatType?: 'p2p' | 'group';
|
||||
/** When true, typing indicators are suppressed entirely. */
|
||||
skipTyping?: boolean;
|
||||
/** When true, replies are sent into the thread instead of main chat. */
|
||||
replyInThread?: boolean;
|
||||
}
|
||||
/**
|
||||
* Manual mirror of the SDK-internal ReplyDispatcher type
|
||||
* (from openclaw/plugin-sdk auto-reply/reply/reply-dispatcher.d.ts).
|
||||
*
|
||||
* Must be kept in sync when the SDK updates the dispatcher signature.
|
||||
*/
|
||||
export interface ReplyDispatcher {
|
||||
sendToolResult: (payload: ReplyPayload) => boolean;
|
||||
sendBlockReply: (payload: ReplyPayload) => boolean;
|
||||
sendFinalReply: (payload: ReplyPayload) => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
getQueuedCounts: () => Record<string, number>;
|
||||
markComplete: () => void;
|
||||
}
|
||||
/**
|
||||
* The structured return type of createFeishuReplyDispatcher.
|
||||
*
|
||||
* `replyOptions` is typed as `Record<string, unknown>` because the consumer
|
||||
* (`dispatchReplyFromConfig`) accepts the SDK-internal `GetReplyOptions`
|
||||
* which is not re-exported from `openclaw/plugin-sdk`. The record type
|
||||
* is compatible with spread-assignment into `dispatchReplyFromConfig`.
|
||||
*/
|
||||
export interface FeishuReplyDispatcherResult {
|
||||
dispatcher: ReplyDispatcher;
|
||||
replyOptions: Record<string, unknown>;
|
||||
markDispatchIdle: () => void;
|
||||
markFullyComplete: () => void;
|
||||
abortCard: () => Promise<void>;
|
||||
}
|
||||
export interface StreamingCardDeps {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string | undefined;
|
||||
chatId: string;
|
||||
replyToMessageId: string | undefined;
|
||||
replyInThread: boolean | undefined;
|
||||
resolvedFooter: Required<FeishuFooterConfig>;
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Type definitions for the Feishu reply dispatcher subsystem.
|
||||
*
|
||||
* Consolidates all interfaces, state shapes, and constants used across
|
||||
* reply-dispatcher.ts, streaming-card-controller.ts, flush-controller.ts,
|
||||
* and unavailable-guard.ts.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardPhase — explicit state machine replacing boolean flags
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CARD_PHASES = {
|
||||
idle: 'idle',
|
||||
creating: 'creating',
|
||||
streaming: 'streaming',
|
||||
completed: 'completed',
|
||||
aborted: 'aborted',
|
||||
terminated: 'terminated',
|
||||
creation_failed: 'creation_failed',
|
||||
};
|
||||
export const TERMINAL_PHASES = new Set([
|
||||
'completed',
|
||||
'aborted',
|
||||
'terminated',
|
||||
'creation_failed',
|
||||
]);
|
||||
export const PHASE_TRANSITIONS = {
|
||||
idle: new Set(['creating', 'aborted', 'terminated']),
|
||||
creating: new Set(['streaming', 'creation_failed', 'aborted', 'terminated']),
|
||||
streaming: new Set(['completed', 'aborted', 'terminated']),
|
||||
completed: new Set(),
|
||||
aborted: new Set(),
|
||||
terminated: new Set(),
|
||||
creation_failed: new Set(),
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throttle constants
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Throttle intervals for card updates.
|
||||
*
|
||||
* - `CARDKIT_MS`: CardKit `cardElement.content()` — designed for streaming,
|
||||
* low throttle is fine.
|
||||
* - `PATCH_MS`: `im.message.patch` — strict rate limits (code 230020).
|
||||
* - `LONG_GAP_THRESHOLD_MS`: After a long idle gap (tool call / LLM thinking),
|
||||
* defer the first flush briefly.
|
||||
* - `BATCH_AFTER_GAP_MS`: Batching window after a long gap.
|
||||
*/
|
||||
export const THROTTLE_CONSTANTS = {
|
||||
CARDKIT_MS: 100,
|
||||
PATCH_MS: 1500,
|
||||
LONG_GAP_THRESHOLD_MS: 2000,
|
||||
BATCH_AFTER_GAP_MS: 300,
|
||||
};
|
||||
export const EMPTY_REPLY_FALLBACK_TEXT = 'Done.';
|
||||
15
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher.d.ts
vendored
Normal file
15
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Reply dispatcher factory for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Thin factory function that:
|
||||
* 1. Resolves account, reply mode, and typing indicator config
|
||||
* 2. In streaming mode, delegates to StreamingCardController
|
||||
* 3. In static mode, delivers via sendMessageFeishu / sendMarkdownCardFeishu
|
||||
* 4. Assembles and returns FeishuReplyDispatcherResult
|
||||
*/
|
||||
import type { CreateFeishuReplyDispatcherParams, FeishuReplyDispatcherResult } from './reply-dispatcher-types';
|
||||
export type { CreateFeishuReplyDispatcherParams } from './reply-dispatcher-types';
|
||||
export declare function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams): FeishuReplyDispatcherResult;
|
||||
293
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher.js
Normal file
293
openclaw/extensions/openclaw-lark/src/card/reply-dispatcher.js
Normal file
@ -0,0 +1,293 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Reply dispatcher factory for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Thin factory function that:
|
||||
* 1. Resolves account, reply mode, and typing indicator config
|
||||
* 2. In streaming mode, delegates to StreamingCardController
|
||||
* 3. In static mode, delivers via sendMessageFeishu / sendMarkdownCardFeishu
|
||||
* 4. Assembles and returns FeishuReplyDispatcherResult
|
||||
*/
|
||||
import { createReplyPrefixContext, createTypingCallbacks, logTypingFailure, } from 'openclaw/plugin-sdk';
|
||||
import { getLarkAccount } from '../core/accounts';
|
||||
import { resolveFooterConfig } from '../core/footer-config';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { sendMessageFeishu, sendMarkdownCardFeishu } from '../messaging/outbound/send';
|
||||
import { addTypingIndicator, removeTypingIndicator } from '../messaging/outbound/typing';
|
||||
import { resolveReplyMode, expandAutoMode, shouldUseCard } from './reply-mode';
|
||||
import { StreamingCardController } from './streaming-card-controller';
|
||||
import { UnavailableGuard } from './unavailable-guard';
|
||||
const log = larkLogger('card/reply-dispatcher');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
export function createFeishuReplyDispatcher(params) {
|
||||
const core = LarkClient.runtime;
|
||||
const { cfg, agentId, chatId, replyToMessageId, accountId, replyInThread } = params;
|
||||
// Resolve account so we can read per-account config (e.g. replyMode)
|
||||
const account = getLarkAccount(cfg, accountId);
|
||||
const feishuCfg = account.config;
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
||||
// ---- Reply mode resolution ----
|
||||
const chatType = params.chatType;
|
||||
const effectiveReplyMode = resolveReplyMode({ feishuCfg, chatType });
|
||||
const replyMode = expandAutoMode({
|
||||
mode: effectiveReplyMode,
|
||||
streaming: feishuCfg?.streaming,
|
||||
chatType,
|
||||
});
|
||||
const useStreamingCards = replyMode === 'streaming';
|
||||
// ---- Block streaming for static mode ----
|
||||
const enableBlockStreaming = feishuCfg?.blockStreaming === true && !useStreamingCards;
|
||||
const resolvedFooter = resolveFooterConfig(feishuCfg?.footer);
|
||||
log.info('reply mode resolved', {
|
||||
effectiveReplyMode,
|
||||
replyMode,
|
||||
chatType,
|
||||
});
|
||||
// ---- Chunk & render settings (static mode only) ----
|
||||
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, 'feishu', accountId, { fallbackLimit: 4000 });
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, 'feishu');
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: 'feishu',
|
||||
});
|
||||
// ---- Streaming card controller (instantiated only when needed) ----
|
||||
const controller = useStreamingCards
|
||||
? new StreamingCardController({
|
||||
cfg,
|
||||
accountId,
|
||||
chatId,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
resolvedFooter,
|
||||
})
|
||||
: null;
|
||||
// ---- Static mode unavailable guard ----
|
||||
// In streaming mode the controller owns its own guard; in static mode
|
||||
// we still need unavailable-message detection for typing and deliver.
|
||||
let staticAborted = false;
|
||||
const staticGuard = controller
|
||||
? null
|
||||
: new UnavailableGuard({
|
||||
replyToMessageId,
|
||||
getCardMessageId: () => null,
|
||||
onTerminate: () => {
|
||||
staticAborted = true;
|
||||
},
|
||||
});
|
||||
const shouldSkip = (source) => {
|
||||
if (controller)
|
||||
return controller.shouldSkipForUnavailable(source);
|
||||
return staticGuard?.shouldSkip(source) ?? false;
|
||||
};
|
||||
const isTerminated = () => {
|
||||
if (controller)
|
||||
return controller.isTerminated;
|
||||
return staticGuard?.isTerminated ?? false;
|
||||
};
|
||||
// ---- Typing indicator (reaction-based) ----
|
||||
let typingState = null;
|
||||
let typingStopped = false;
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
keepaliveIntervalMs: 0,
|
||||
start: async () => {
|
||||
if (shouldSkip('typing.start.precheck'))
|
||||
return;
|
||||
if (!replyToMessageId || typingStopped || params.skipTyping)
|
||||
return;
|
||||
if (typingState?.reactionId)
|
||||
return;
|
||||
typingState = await addTypingIndicator({
|
||||
cfg,
|
||||
messageId: replyToMessageId,
|
||||
accountId,
|
||||
});
|
||||
if (shouldSkip('typing.start.postcheck'))
|
||||
return;
|
||||
if (typingStopped && typingState) {
|
||||
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
||||
typingState = null;
|
||||
log.info('removed typing indicator (raced with stop)');
|
||||
return;
|
||||
}
|
||||
log.info('added typing indicator reaction');
|
||||
},
|
||||
stop: async () => {
|
||||
typingStopped = true;
|
||||
if (!typingState)
|
||||
return;
|
||||
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
||||
typingState = null;
|
||||
log.info('removed typing indicator reaction');
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log.warn(message),
|
||||
channel: 'feishu',
|
||||
action: 'start',
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log.warn(message),
|
||||
channel: 'feishu',
|
||||
action: 'stop',
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
// ---- dispatchFullyComplete flag (static mode) ----
|
||||
let dispatchFullyComplete = false;
|
||||
// ---- Build dispatcher ----
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: prefixContext.responsePrefix,
|
||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
||||
onReplyStart: async () => {
|
||||
if (shouldSkip('onReplyStart'))
|
||||
return;
|
||||
await typingCallbacks.onReplyStart?.();
|
||||
},
|
||||
deliver: async (payload) => {
|
||||
log.debug('deliver called', { textPreview: payload.text?.slice(0, 100) });
|
||||
if (shouldSkip('deliver.entry'))
|
||||
return;
|
||||
// ---- Abort guard ----
|
||||
// Only check aborted (not isTerminalPhase) so that
|
||||
// creation_failed can still fallthrough to static delivery.
|
||||
if (staticAborted || controller?.isTerminated || controller?.isAborted) {
|
||||
log.debug('deliver: skipped (aborted)');
|
||||
return;
|
||||
}
|
||||
// ---- Post-dispatch guard ----
|
||||
if (dispatchFullyComplete) {
|
||||
log.debug('deliver: skipped (dispatch already complete)');
|
||||
return;
|
||||
}
|
||||
const text = payload.text ?? '';
|
||||
if (!text.trim()) {
|
||||
log.debug('deliver: empty text, skipping');
|
||||
return;
|
||||
}
|
||||
// ---- Streaming card mode ----
|
||||
if (controller) {
|
||||
await controller.ensureCardCreated();
|
||||
if (controller.isTerminated)
|
||||
return;
|
||||
if (controller.cardMessageId) {
|
||||
await controller.onDeliver(payload);
|
||||
return;
|
||||
}
|
||||
// Card creation failed — fall through to static delivery
|
||||
log.warn('deliver: card creation failed, falling back to static delivery');
|
||||
}
|
||||
// ---- Static delivery ----
|
||||
if (shouldUseCard(text)) {
|
||||
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
||||
log.info('deliver: sending card chunks', { count: chunks.length, chatId });
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMarkdownCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
if (staticGuard?.terminate('deliver.cardChunk', err))
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
||||
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
||||
log.info('deliver: sending text chunks', { count: chunks.length, chatId });
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
if (staticGuard?.terminate('deliver.textChunk', err))
|
||||
return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: async (err, info) => {
|
||||
if (controller) {
|
||||
if (controller.terminateIfUnavailable('onError', err)) {
|
||||
typingCallbacks.onIdle?.();
|
||||
return;
|
||||
}
|
||||
await controller.onError(err, info);
|
||||
typingCallbacks.onIdle?.();
|
||||
return;
|
||||
}
|
||||
// Static mode error handling
|
||||
if (staticGuard?.terminate('onError', err)) {
|
||||
typingCallbacks.onIdle?.();
|
||||
return;
|
||||
}
|
||||
log.error(`${info.kind} reply failed`, { error: String(err) });
|
||||
typingCallbacks.onIdle?.();
|
||||
},
|
||||
onIdle: async () => {
|
||||
if (isTerminated() || shouldSkip('onIdle')) {
|
||||
typingCallbacks.onIdle?.();
|
||||
return;
|
||||
}
|
||||
if (!dispatchFullyComplete) {
|
||||
typingCallbacks.onIdle?.();
|
||||
return;
|
||||
}
|
||||
if (controller) {
|
||||
await controller.onIdle();
|
||||
}
|
||||
typingCallbacks.onIdle?.();
|
||||
},
|
||||
onCleanup: async () => {
|
||||
typingCallbacks.onCleanup?.();
|
||||
},
|
||||
});
|
||||
// ---- Abort card (delegates to controller or no-op for static) ----
|
||||
const abortCard = controller ? () => controller.abortCard() : async () => { };
|
||||
return {
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
onModelSelected: prefixContext.onModelSelected,
|
||||
disableBlockStreaming: !enableBlockStreaming,
|
||||
...(controller
|
||||
? {
|
||||
onReasoningStream: (payload) => controller.onReasoningStream(payload),
|
||||
onPartialReply: (payload) => controller.onPartialReply(payload),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
markDispatchIdle,
|
||||
markFullyComplete: () => {
|
||||
dispatchFullyComplete = true;
|
||||
controller?.markFullyComplete();
|
||||
},
|
||||
abortCard,
|
||||
};
|
||||
}
|
||||
38
openclaw/extensions/openclaw-lark/src/card/reply-mode.d.ts
vendored
Normal file
38
openclaw/extensions/openclaw-lark/src/card/reply-mode.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Pure functions for resolving the Feishu reply mode.
|
||||
*
|
||||
* Extracted from reply-dispatcher.ts to enable independent testing
|
||||
* and eliminate `as any` casts on FeishuConfig.
|
||||
*/
|
||||
import type { FeishuConfig } from '../core/types';
|
||||
type ReplyModeValue = 'auto' | 'static' | 'streaming';
|
||||
/**
|
||||
* Resolve the effective reply mode based on configuration and chat type.
|
||||
*
|
||||
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
|
||||
*/
|
||||
export declare function resolveReplyMode(params: {
|
||||
feishuCfg: FeishuConfig | undefined;
|
||||
chatType?: 'p2p' | 'group';
|
||||
}): ReplyModeValue;
|
||||
/**
|
||||
* Expand "auto" mode to a concrete mode based on streaming flag and chat type.
|
||||
*
|
||||
* When streaming === true: group → static, direct → streaming (legacy behavior).
|
||||
* When streaming is unset: always static (new default).
|
||||
*/
|
||||
export declare function expandAutoMode(params: {
|
||||
mode: ReplyModeValue;
|
||||
streaming: boolean | undefined;
|
||||
chatType?: 'p2p' | 'group';
|
||||
}): 'static' | 'streaming';
|
||||
/**
|
||||
* Detect whether the text contains markdown elements that benefit from
|
||||
* being rendered inside a Feishu interactive card (fenced code blocks or
|
||||
* markdown tables).
|
||||
*/
|
||||
export declare function shouldUseCard(text: string): boolean;
|
||||
export {};
|
||||
66
openclaw/extensions/openclaw-lark/src/card/reply-mode.js
Normal file
66
openclaw/extensions/openclaw-lark/src/card/reply-mode.js
Normal file
@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Pure functions for resolving the Feishu reply mode.
|
||||
*
|
||||
* Extracted from reply-dispatcher.ts to enable independent testing
|
||||
* and eliminate `as any` casts on FeishuConfig.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveReplyMode
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Resolve the effective reply mode based on configuration and chat type.
|
||||
*
|
||||
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
|
||||
*/
|
||||
export function resolveReplyMode(params) {
|
||||
const { feishuCfg, chatType } = params;
|
||||
// streaming 布尔总开关:仅 true 时允许流式,未设置或 false 一律 static
|
||||
if (feishuCfg?.streaming !== true)
|
||||
return 'static';
|
||||
const replyMode = feishuCfg?.replyMode;
|
||||
if (!replyMode)
|
||||
return 'auto';
|
||||
if (typeof replyMode === 'string')
|
||||
return replyMode;
|
||||
// Object form: pick scene-specific value
|
||||
const sceneMode = chatType === 'group' ? replyMode.group : chatType === 'p2p' ? replyMode.direct : undefined;
|
||||
return sceneMode ?? replyMode.default ?? 'auto';
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// expandAutoMode
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Expand "auto" mode to a concrete mode based on streaming flag and chat type.
|
||||
*
|
||||
* When streaming === true: group → static, direct → streaming (legacy behavior).
|
||||
* When streaming is unset: always static (new default).
|
||||
*/
|
||||
export function expandAutoMode(params) {
|
||||
const { mode, streaming, chatType } = params;
|
||||
if (mode !== 'auto')
|
||||
return mode;
|
||||
return streaming === true ? (chatType === 'group' ? 'static' : 'streaming') : 'static';
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldUseCard
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Detect whether the text contains markdown elements that benefit from
|
||||
* being rendered inside a Feishu interactive card (fenced code blocks or
|
||||
* markdown tables).
|
||||
*/
|
||||
export function shouldUseCard(text) {
|
||||
// Fenced code blocks
|
||||
if (/```[\s\S]*?```/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
// Markdown tables (header + separator rows separated by pipes)
|
||||
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
88
openclaw/extensions/openclaw-lark/src/card/streaming-card-controller.d.ts
vendored
Normal file
88
openclaw/extensions/openclaw-lark/src/card/streaming-card-controller.d.ts
vendored
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Streaming card controller for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Manages the full lifecycle of a streaming CardKit card:
|
||||
* idle → creating → streaming → completed / aborted / terminated.
|
||||
*
|
||||
* Delegates throttling to FlushController and message-unavailable
|
||||
* detection to UnavailableGuard.
|
||||
*/
|
||||
import { type ReplyPayload } from 'openclaw/plugin-sdk';
|
||||
import type { CardPhase, TerminalReason, StreamingCardDeps } from './reply-dispatcher-types';
|
||||
export declare class StreamingCardController {
|
||||
private phase;
|
||||
private cardKit;
|
||||
private text;
|
||||
private reasoning;
|
||||
private readonly flush;
|
||||
private readonly guard;
|
||||
private readonly imageResolver;
|
||||
private createEpoch;
|
||||
private _terminalReason;
|
||||
private dispatchFullyComplete;
|
||||
private cardCreationPromise;
|
||||
private disposeShutdownHook;
|
||||
private readonly dispatchStartTime;
|
||||
private readonly deps;
|
||||
private elapsed;
|
||||
constructor(deps: StreamingCardDeps);
|
||||
get cardMessageId(): string | null;
|
||||
get isTerminalPhase(): boolean;
|
||||
/**
|
||||
* Whether the card has been explicitly aborted (via abortCard()).
|
||||
*
|
||||
* Distinct from isTerminalPhase — creation_failed is NOT an abort;
|
||||
* it should allow fallthrough to static delivery in the factory.
|
||||
*/
|
||||
get isAborted(): boolean;
|
||||
/** Whether the reply pipeline was terminated due to an unavailable message. */
|
||||
get isTerminated(): boolean;
|
||||
/** Check if the pipeline should skip further operations for this source. */
|
||||
shouldSkipForUnavailable(source: string): boolean;
|
||||
/** Attempt to terminate the pipeline due to an unavailable message error. */
|
||||
terminateIfUnavailable(source: string, err?: unknown): boolean;
|
||||
/** Why the controller entered a terminal phase, or null if still active. */
|
||||
get terminalReason(): TerminalReason | null;
|
||||
/** @internal — exposed for test assertions only. */
|
||||
get currentPhase(): CardPhase;
|
||||
/**
|
||||
* Unified callback guard — returns true if the pipeline is active
|
||||
* and the callback should proceed.
|
||||
*
|
||||
* Combines three checks:
|
||||
* 1. guard.isTerminated — message recalled/deleted
|
||||
* 2. guard.shouldSkip(source) — eagerly detect unavailable messages
|
||||
* 3. isTerminalPhase — completed/aborted/terminated/creation_failed
|
||||
*/
|
||||
private shouldProceed;
|
||||
private isStaleCreate;
|
||||
private transition;
|
||||
private onEnterTerminalPhase;
|
||||
/**
|
||||
* Handle a deliver() call in streaming card mode.
|
||||
*
|
||||
* Accumulates text from the SDK's deliver callbacks to build the
|
||||
* authoritative "completedText" for the final card.
|
||||
*/
|
||||
onDeliver(payload: ReplyPayload): Promise<void>;
|
||||
onReasoningStream(payload: ReplyPayload): Promise<void>;
|
||||
onPartialReply(payload: ReplyPayload): Promise<void>;
|
||||
onError(err: unknown, info: {
|
||||
kind: string;
|
||||
}): Promise<void>;
|
||||
onIdle(): Promise<void>;
|
||||
markFullyComplete(): void;
|
||||
abortCard(): Promise<void>;
|
||||
ensureCardCreated(): Promise<void>;
|
||||
private performFlush;
|
||||
private buildDisplayText;
|
||||
private throttledCardUpdate;
|
||||
private finalizeCard;
|
||||
/**
|
||||
* Close streaming mode then update card content (shared by onError and abortCard).
|
||||
*/
|
||||
private closeStreamingAndUpdate;
|
||||
}
|
||||
@ -0,0 +1,735 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Streaming card controller for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Manages the full lifecycle of a streaming CardKit card:
|
||||
* idle → creating → streaming → completed / aborted / terminated.
|
||||
*
|
||||
* Delegates throttling to FlushController and message-unavailable
|
||||
* detection to UnavailableGuard.
|
||||
*/
|
||||
import { SILENT_REPLY_TOKEN } from 'openclaw/plugin-sdk';
|
||||
import { extractLarkApiCode } from '../core/api-error';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { sendCardFeishu, updateCardFeishu } from '../messaging/outbound/send';
|
||||
import { createCardEntity, sendCardByCardId, streamCardContent, updateCardKitCard, setCardStreamingMode, } from './cardkit';
|
||||
import { buildCardContent, splitReasoningText, stripReasoningTags, STREAMING_ELEMENT_ID, toCardKit2 } from './builder';
|
||||
import { optimizeMarkdownStyle } from './markdown-style';
|
||||
import { ImageResolver } from './image-resolver';
|
||||
import { registerShutdownHook } from '../core/shutdown-hooks';
|
||||
import { FlushController } from './flush-controller';
|
||||
import { UnavailableGuard } from './unavailable-guard';
|
||||
import { TERMINAL_PHASES, PHASE_TRANSITIONS, THROTTLE_CONSTANTS, EMPTY_REPLY_FALLBACK_TEXT, } from './reply-dispatcher-types';
|
||||
const log = larkLogger('card/streaming');
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKit 2.0 initial streaming payload
|
||||
// ---------------------------------------------------------------------------
|
||||
const STREAMING_THINKING_CARD = {
|
||||
schema: '2.0',
|
||||
config: {
|
||||
streaming_mode: true,
|
||||
locales: ['zh_cn', 'en_us'],
|
||||
summary: {
|
||||
content: 'Thinking...',
|
||||
i18n_content: { zh_cn: '思考中...', en_us: 'Thinking...' },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
elements: [
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: '',
|
||||
text_align: 'left',
|
||||
text_size: 'normal_v2',
|
||||
margin: '0px 0px 0px 0px',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
},
|
||||
{
|
||||
tag: 'markdown',
|
||||
content: ' ',
|
||||
icon: {
|
||||
tag: 'custom_icon',
|
||||
img_key: 'img_v3_02vb_496bec09-4b43-4773-ad6b-0cdd103cd2bg',
|
||||
size: '16px 16px',
|
||||
},
|
||||
element_id: 'loading_icon',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingCardController
|
||||
// ---------------------------------------------------------------------------
|
||||
export class StreamingCardController {
|
||||
// ---- Explicit state machine ----
|
||||
phase = 'idle';
|
||||
// ---- Structured state ----
|
||||
cardKit = {
|
||||
cardKitCardId: null,
|
||||
originalCardKitCardId: null,
|
||||
cardKitSequence: 0,
|
||||
cardMessageId: null,
|
||||
};
|
||||
text = {
|
||||
accumulatedText: '',
|
||||
completedText: '',
|
||||
streamingPrefix: '',
|
||||
lastPartialText: '',
|
||||
};
|
||||
reasoning = {
|
||||
accumulatedReasoningText: '',
|
||||
reasoningStartTime: null,
|
||||
reasoningElapsedMs: 0,
|
||||
isReasoningPhase: false,
|
||||
};
|
||||
// ---- Sub-controllers ----
|
||||
flush;
|
||||
guard;
|
||||
imageResolver;
|
||||
// ---- Lifecycle ----
|
||||
createEpoch = 0;
|
||||
_terminalReason = null;
|
||||
dispatchFullyComplete = false;
|
||||
cardCreationPromise = null;
|
||||
disposeShutdownHook = null;
|
||||
dispatchStartTime = Date.now();
|
||||
// ---- Injected dependencies ----
|
||||
deps;
|
||||
elapsed() {
|
||||
return Date.now() - this.dispatchStartTime;
|
||||
}
|
||||
constructor(deps) {
|
||||
this.deps = deps;
|
||||
this.guard = new UnavailableGuard({
|
||||
replyToMessageId: deps.replyToMessageId,
|
||||
getCardMessageId: () => this.cardKit.cardMessageId,
|
||||
onTerminate: () => {
|
||||
this.transition('terminated', 'UnavailableGuard', 'unavailable');
|
||||
},
|
||||
});
|
||||
this.flush = new FlushController(() => this.performFlush());
|
||||
this.imageResolver = new ImageResolver({
|
||||
cfg: deps.cfg,
|
||||
accountId: deps.accountId,
|
||||
onImageResolved: () => {
|
||||
if (!this.isTerminalPhase && this.cardKit.cardMessageId) {
|
||||
void this.throttledCardUpdate();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Public accessors
|
||||
// ------------------------------------------------------------------
|
||||
get cardMessageId() {
|
||||
return this.cardKit.cardMessageId;
|
||||
}
|
||||
get isTerminalPhase() {
|
||||
return TERMINAL_PHASES.has(this.phase);
|
||||
}
|
||||
/**
|
||||
* Whether the card has been explicitly aborted (via abortCard()).
|
||||
*
|
||||
* Distinct from isTerminalPhase — creation_failed is NOT an abort;
|
||||
* it should allow fallthrough to static delivery in the factory.
|
||||
*/
|
||||
get isAborted() {
|
||||
return this.phase === 'aborted';
|
||||
}
|
||||
/** Whether the reply pipeline was terminated due to an unavailable message. */
|
||||
get isTerminated() {
|
||||
return this.guard.isTerminated;
|
||||
}
|
||||
/** Check if the pipeline should skip further operations for this source. */
|
||||
shouldSkipForUnavailable(source) {
|
||||
return this.guard.shouldSkip(source);
|
||||
}
|
||||
/** Attempt to terminate the pipeline due to an unavailable message error. */
|
||||
terminateIfUnavailable(source, err) {
|
||||
return this.guard.terminate(source, err);
|
||||
}
|
||||
/** Why the controller entered a terminal phase, or null if still active. */
|
||||
get terminalReason() {
|
||||
return this._terminalReason;
|
||||
}
|
||||
/** @internal — exposed for test assertions only. */
|
||||
get currentPhase() {
|
||||
return this.phase;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Unified callback guard
|
||||
// ------------------------------------------------------------------
|
||||
/**
|
||||
* Unified callback guard — returns true if the pipeline is active
|
||||
* and the callback should proceed.
|
||||
*
|
||||
* Combines three checks:
|
||||
* 1. guard.isTerminated — message recalled/deleted
|
||||
* 2. guard.shouldSkip(source) — eagerly detect unavailable messages
|
||||
* 3. isTerminalPhase — completed/aborted/terminated/creation_failed
|
||||
*/
|
||||
shouldProceed(source) {
|
||||
if (this.guard.isTerminated || this.guard.shouldSkip(source))
|
||||
return false;
|
||||
return !this.isTerminalPhase;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// State machine
|
||||
// ------------------------------------------------------------------
|
||||
isStaleCreate(epoch) {
|
||||
return epoch !== this.createEpoch;
|
||||
}
|
||||
transition(to, source, reason) {
|
||||
const from = this.phase;
|
||||
if (from === to)
|
||||
return false;
|
||||
if (!PHASE_TRANSITIONS[from].has(to)) {
|
||||
log.warn('phase transition rejected', { from, to, source });
|
||||
return false;
|
||||
}
|
||||
this.phase = to;
|
||||
log.info('phase transition', { from, to, source, reason });
|
||||
if (TERMINAL_PHASES.has(to)) {
|
||||
this._terminalReason = reason ?? null;
|
||||
this.onEnterTerminalPhase();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
onEnterTerminalPhase() {
|
||||
this.createEpoch += 1;
|
||||
this.flush.cancelPendingFlush();
|
||||
this.flush.complete();
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// SDK callback bindings
|
||||
// ------------------------------------------------------------------
|
||||
/**
|
||||
* Handle a deliver() call in streaming card mode.
|
||||
*
|
||||
* Accumulates text from the SDK's deliver callbacks to build the
|
||||
* authoritative "completedText" for the final card.
|
||||
*/
|
||||
async onDeliver(payload) {
|
||||
if (!this.shouldProceed('onDeliver'))
|
||||
return;
|
||||
const text = payload.text ?? '';
|
||||
if (!text.trim())
|
||||
return;
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onDeliver.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
const split = splitReasoningText(text);
|
||||
if (split.reasoningText && !split.answerText) {
|
||||
// Pure reasoning payload
|
||||
this.reasoning.reasoningElapsedMs = this.reasoning.reasoningStartTime
|
||||
? Date.now() - this.reasoning.reasoningStartTime
|
||||
: 0;
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText;
|
||||
this.reasoning.isReasoningPhase = true;
|
||||
await this.throttledCardUpdate();
|
||||
return;
|
||||
}
|
||||
// Answer payload (may also contain inline reasoning from tags)
|
||||
this.reasoning.isReasoningPhase = false;
|
||||
if (split.reasoningText) {
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText;
|
||||
}
|
||||
const answerText = split.answerText ?? text;
|
||||
// 累积 deliver 文本用于最终卡片
|
||||
this.text.completedText += (this.text.completedText ? '\n\n' : '') + answerText;
|
||||
// 没有流式数据时,用 deliver 文本显示在卡片上
|
||||
if (!this.text.lastPartialText && !this.text.streamingPrefix) {
|
||||
this.text.accumulatedText += (this.text.accumulatedText ? '\n\n' : '') + answerText;
|
||||
this.text.streamingPrefix = this.text.accumulatedText;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
}
|
||||
async onReasoningStream(payload) {
|
||||
if (!this.shouldProceed('onReasoningStream'))
|
||||
return;
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onReasoningStream.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
const rawText = payload.text ?? '';
|
||||
if (!rawText)
|
||||
return;
|
||||
if (!this.reasoning.reasoningStartTime) {
|
||||
this.reasoning.reasoningStartTime = Date.now();
|
||||
}
|
||||
this.reasoning.isReasoningPhase = true;
|
||||
const split = splitReasoningText(rawText);
|
||||
this.reasoning.accumulatedReasoningText = split.reasoningText ?? rawText;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
async onPartialReply(payload) {
|
||||
if (!this.shouldProceed('onPartialReply'))
|
||||
return;
|
||||
const text = stripReasoningTags(payload.text ?? '');
|
||||
log.debug('onPartialReply', { len: text.length });
|
||||
if (!text)
|
||||
return;
|
||||
if (!this.reasoning.reasoningStartTime) {
|
||||
this.reasoning.reasoningStartTime = Date.now();
|
||||
}
|
||||
if (this.reasoning.isReasoningPhase) {
|
||||
this.reasoning.isReasoningPhase = false;
|
||||
this.reasoning.reasoningElapsedMs = this.reasoning.reasoningStartTime
|
||||
? Date.now() - this.reasoning.reasoningStartTime
|
||||
: 0;
|
||||
}
|
||||
// 检测回复边界:文本长度缩短 → 新回复开始
|
||||
if (this.text.lastPartialText && text.length < this.text.lastPartialText.length) {
|
||||
this.text.streamingPrefix += (this.text.streamingPrefix ? '\n\n' : '') + this.text.lastPartialText;
|
||||
}
|
||||
this.text.lastPartialText = text;
|
||||
this.text.accumulatedText = this.text.streamingPrefix ? this.text.streamingPrefix + '\n\n' + text : text;
|
||||
// NO_REPLY 缓冲
|
||||
if (!this.text.streamingPrefix && SILENT_REPLY_TOKEN.startsWith(this.text.accumulatedText.trim())) {
|
||||
log.debug('onPartialReply: buffering NO_REPLY prefix');
|
||||
return;
|
||||
}
|
||||
await this.ensureCardCreated();
|
||||
if (!this.shouldProceed('onPartialReply.postCreate'))
|
||||
return;
|
||||
if (!this.cardKit.cardMessageId)
|
||||
return;
|
||||
await this.throttledCardUpdate();
|
||||
}
|
||||
async onError(err, info) {
|
||||
if (this.guard.terminate('onError', err))
|
||||
return;
|
||||
log.error(`${info.kind} reply failed`, { error: String(err) });
|
||||
this.finalizeCard('onError', 'error');
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise)
|
||||
await this.cardCreationPromise;
|
||||
const errorEffectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (this.cardKit.cardMessageId) {
|
||||
try {
|
||||
const rawErrorText = this.text.accumulatedText
|
||||
? `${this.text.accumulatedText}\n\n---\n**Error**: An error occurred while generating the response.`
|
||||
: '**Error**: An error occurred while generating the response.';
|
||||
const errorText = this.imageResolver.resolveImages(rawErrorText);
|
||||
const errorCard = buildCardContent('complete', {
|
||||
text: errorText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs: this.elapsed(),
|
||||
isError: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
if (errorEffectiveCardId) {
|
||||
await this.closeStreamingAndUpdate(errorEffectiveCardId, errorCard, 'onError');
|
||||
}
|
||||
else {
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: errorCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore update failures during error handling
|
||||
}
|
||||
}
|
||||
}
|
||||
async onIdle() {
|
||||
if (this.guard.isTerminated || this.guard.shouldSkip('onIdle'))
|
||||
return;
|
||||
if (!this.dispatchFullyComplete)
|
||||
return;
|
||||
if (this.isTerminalPhase)
|
||||
return;
|
||||
this.finalizeCard('onIdle', 'normal');
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise) {
|
||||
await this.cardCreationPromise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await this.flush.waitForFlush();
|
||||
}
|
||||
const idleEffectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (this.cardKit.cardMessageId) {
|
||||
try {
|
||||
if (idleEffectiveCardId) {
|
||||
const seqBeforeClose = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info('onIdle: closing streaming mode', {
|
||||
seqBefore: seqBeforeClose,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await setCardStreamingMode({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: idleEffectiveCardId,
|
||||
streamingMode: false,
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
const isNoReplyLeak = !this.text.completedText && SILENT_REPLY_TOKEN.startsWith(this.text.accumulatedText.trim());
|
||||
const displayText = this.text.completedText || (isNoReplyLeak ? '' : this.text.accumulatedText) || EMPTY_REPLY_FALLBACK_TEXT;
|
||||
if (!this.text.completedText && !this.text.accumulatedText) {
|
||||
log.warn('reply completed without visible text, using empty-reply fallback');
|
||||
}
|
||||
const resolvedDisplayText = await this.imageResolver.resolveImagesAwait(displayText, 15_000);
|
||||
const completeCard = buildCardContent('complete', {
|
||||
text: resolvedDisplayText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs: this.elapsed(),
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
if (idleEffectiveCardId) {
|
||||
const seqBeforeUpdate = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info('onIdle: updating final card', {
|
||||
seqBefore: seqBeforeUpdate,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await updateCardKitCard({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: idleEffectiveCardId,
|
||||
card: toCardKit2(completeCard),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: completeCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
log.info('reply completed, card finalized', {
|
||||
elapsedMs: this.elapsed(),
|
||||
isCardKit: !!idleEffectiveCardId,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
log.warn('final card update failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// External control
|
||||
// ------------------------------------------------------------------
|
||||
markFullyComplete() {
|
||||
log.debug('markFullyComplete', {
|
||||
completedTextLen: this.text.completedText.length,
|
||||
accumulatedTextLen: this.text.accumulatedText.length,
|
||||
});
|
||||
this.dispatchFullyComplete = true;
|
||||
}
|
||||
async abortCard() {
|
||||
try {
|
||||
if (!this.transition('aborted', 'abortCard', 'abort'))
|
||||
return;
|
||||
// transition() already executed onEnterTerminalPhase (cancel + complete + dispose hook)
|
||||
// Only need to wait for any in-flight flush to finish
|
||||
await this.flush.waitForFlush();
|
||||
if (this.cardCreationPromise)
|
||||
await this.cardCreationPromise;
|
||||
const effectiveCardId = this.cardKit.cardKitCardId ?? this.cardKit.originalCardKitCardId;
|
||||
if (effectiveCardId) {
|
||||
const elapsedMs = Date.now() - this.dispatchStartTime;
|
||||
const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
|
||||
const abortCardContent = buildCardContent('complete', {
|
||||
text: abortText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs,
|
||||
isAborted: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
await this.closeStreamingAndUpdate(effectiveCardId, abortCardContent, 'abortCard');
|
||||
log.info('abortCard completed', { effectiveCardId });
|
||||
}
|
||||
else if (this.cardKit.cardMessageId) {
|
||||
// IM fallback: 卡片不是通过 CardKit 发的,用 im.message.patch 更新
|
||||
const elapsedMs = Date.now() - this.dispatchStartTime;
|
||||
const abortText = this.imageResolver.resolveImages(this.text.accumulatedText || 'Aborted.');
|
||||
const abortCard = buildCardContent('complete', {
|
||||
text: abortText,
|
||||
reasoningText: this.reasoning.accumulatedReasoningText || undefined,
|
||||
reasoningElapsedMs: this.reasoning.reasoningElapsedMs || undefined,
|
||||
elapsedMs,
|
||||
isAborted: true,
|
||||
footer: this.deps.resolvedFooter,
|
||||
});
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: abortCard,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
log.info('abortCard completed (IM fallback)', { messageId: this.cardKit.cardMessageId });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
log.warn('abortCard failed', { error: String(err) });
|
||||
}
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: card creation
|
||||
// ------------------------------------------------------------------
|
||||
async ensureCardCreated() {
|
||||
if (this.guard.shouldSkip('ensureCardCreated.precheck'))
|
||||
return;
|
||||
if (this.cardKit.cardMessageId || this.phase === 'creation_failed' || this.isTerminalPhase) {
|
||||
return;
|
||||
}
|
||||
if (this.cardCreationPromise) {
|
||||
await this.cardCreationPromise;
|
||||
return;
|
||||
}
|
||||
if (!this.transition('creating', 'ensureCardCreated'))
|
||||
return;
|
||||
this.createEpoch += 1;
|
||||
const epoch = this.createEpoch;
|
||||
this.cardCreationPromise = (async () => {
|
||||
try {
|
||||
try {
|
||||
// Step 1: Create card entity
|
||||
const cId = await createCardEntity({
|
||||
cfg: this.deps.cfg,
|
||||
card: STREAMING_THINKING_CARD,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after createCardEntity, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (cId) {
|
||||
this.cardKit.cardKitCardId = cId;
|
||||
this.cardKit.originalCardKitCardId = cId;
|
||||
this.cardKit.cardKitSequence = 1;
|
||||
this.disposeShutdownHook = registerShutdownHook(`streaming-card:${cId}`, () => this.abortCard());
|
||||
log.info('created CardKit entity', {
|
||||
cardId: cId,
|
||||
initialSequence: this.cardKit.cardKitSequence,
|
||||
});
|
||||
// Step 2: Send IM message referencing card_id
|
||||
const result = await sendCardByCardId({
|
||||
cfg: this.deps.cfg,
|
||||
to: this.deps.chatId,
|
||||
cardId: cId,
|
||||
replyToMessageId: this.deps.replyToMessageId,
|
||||
replyInThread: this.deps.replyInThread,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after sendCardByCardId, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
return;
|
||||
}
|
||||
this.cardKit.cardMessageId = result.messageId;
|
||||
this.flush.setCardMessageReady(true);
|
||||
if (!this.transition('streaming', 'ensureCardCreated.cardkit')) {
|
||||
this.disposeShutdownHook?.();
|
||||
this.disposeShutdownHook = null;
|
||||
return;
|
||||
}
|
||||
log.info('sent CardKit card', { messageId: result.messageId });
|
||||
}
|
||||
else {
|
||||
throw new Error('card.create returned empty card_id');
|
||||
}
|
||||
}
|
||||
catch (cardKitErr) {
|
||||
if (this.isStaleCreate(epoch))
|
||||
return;
|
||||
if (this.guard.terminate('ensureCardCreated.cardkitFlow', cardKitErr)) {
|
||||
return;
|
||||
}
|
||||
// CardKit flow failed — fall back to regular IM card
|
||||
const apiDetail = extractApiDetail(cardKitErr);
|
||||
log.warn('CardKit flow failed, falling back to IM', { apiDetail });
|
||||
this.cardKit.cardKitCardId = null;
|
||||
this.cardKit.originalCardKitCardId = null;
|
||||
const fallbackCard = buildCardContent('thinking');
|
||||
const result = await sendCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
to: this.deps.chatId,
|
||||
card: fallbackCard,
|
||||
replyToMessageId: this.deps.replyToMessageId,
|
||||
replyInThread: this.deps.replyInThread,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
if (this.isStaleCreate(epoch)) {
|
||||
log.info('ensureCardCreated: stale epoch after IM fallback send, bailing out', {
|
||||
epoch,
|
||||
phase: this.phase,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.cardKit.cardMessageId = result.messageId;
|
||||
this.flush.setCardMessageReady(true);
|
||||
if (!this.transition('streaming', 'ensureCardCreated.imFallback')) {
|
||||
return;
|
||||
}
|
||||
log.info('sent fallback IM card', { messageId: result.messageId });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.isStaleCreate(epoch))
|
||||
return;
|
||||
if (this.guard.terminate('ensureCardCreated.outer', err)) {
|
||||
return;
|
||||
}
|
||||
log.warn('thinking card failed, falling back to static', { error: String(err) });
|
||||
this.transition('creation_failed', 'ensureCardCreated.outer', 'creation_failed');
|
||||
}
|
||||
})();
|
||||
await this.cardCreationPromise;
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: flush
|
||||
// ------------------------------------------------------------------
|
||||
async performFlush() {
|
||||
if (!this.cardKit.cardMessageId || this.isTerminalPhase)
|
||||
return;
|
||||
// v2 CardKit 卡片不能走 IM patch,如果流式 CardKit 已禁用但 originalCardKitCardId
|
||||
// 仍在,说明卡片是通过 CardKit 发的——跳过中间态更新,等终态用 originalCardKitCardId 收尾
|
||||
if (!this.cardKit.cardKitCardId && this.cardKit.originalCardKitCardId) {
|
||||
log.debug('performFlush: skipping (CardKit streaming disabled, awaiting final update)');
|
||||
return;
|
||||
}
|
||||
log.debug('flushCardUpdate: enter', {
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
isCardKit: !!this.cardKit.cardKitCardId,
|
||||
});
|
||||
try {
|
||||
const displayText = this.buildDisplayText();
|
||||
const resolvedText = this.imageResolver.resolveImages(displayText);
|
||||
if (this.cardKit.cardKitCardId) {
|
||||
// CardKit streaming — typewriter effect
|
||||
const prevSeq = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.debug('flushCardUpdate: seq bump', {
|
||||
seqBefore: prevSeq,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await streamCardContent({
|
||||
cfg: this.deps.cfg,
|
||||
cardId: this.cardKit.cardKitCardId,
|
||||
elementId: STREAMING_ELEMENT_ID,
|
||||
content: optimizeMarkdownStyle(resolvedText),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
else {
|
||||
log.debug('flushCardUpdate: IM patch fallback');
|
||||
const card = buildCardContent('streaming', {
|
||||
text: this.reasoning.isReasoningPhase ? '' : resolvedText,
|
||||
reasoningText: this.reasoning.isReasoningPhase ? this.reasoning.accumulatedReasoningText : undefined,
|
||||
});
|
||||
await updateCardFeishu({
|
||||
cfg: this.deps.cfg,
|
||||
messageId: this.cardKit.cardMessageId,
|
||||
card: card,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.guard.terminate('flushCardUpdate', err))
|
||||
return;
|
||||
const apiCode = extractLarkApiCode(err);
|
||||
if (apiCode === 230020) {
|
||||
log.info('flushCardUpdate: rate limited (230020), skipping', {
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const apiDetail = extractApiDetail(err);
|
||||
log.error('card stream update failed', {
|
||||
apiCode,
|
||||
seq: this.cardKit.cardKitSequence,
|
||||
apiDetail,
|
||||
});
|
||||
if (this.cardKit.cardKitCardId) {
|
||||
log.warn('disabling CardKit streaming, falling back to im.message.patch');
|
||||
this.cardKit.cardKitCardId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
buildDisplayText() {
|
||||
if (this.reasoning.isReasoningPhase && this.reasoning.accumulatedReasoningText) {
|
||||
const reasoningDisplay = `💭 **Thinking...**\n\n${this.reasoning.accumulatedReasoningText}`;
|
||||
return this.text.accumulatedText ? this.text.accumulatedText + '\n\n' + reasoningDisplay : reasoningDisplay;
|
||||
}
|
||||
return this.text.accumulatedText;
|
||||
}
|
||||
async throttledCardUpdate() {
|
||||
if (this.guard.shouldSkip('throttledCardUpdate'))
|
||||
return;
|
||||
const throttleMs = this.cardKit.cardKitCardId ? THROTTLE_CONSTANTS.CARDKIT_MS : THROTTLE_CONSTANTS.PATCH_MS;
|
||||
await this.flush.throttledUpdate(throttleMs);
|
||||
}
|
||||
// ------------------------------------------------------------------
|
||||
// Internal: lifecycle helpers
|
||||
// ------------------------------------------------------------------
|
||||
finalizeCard(source, reason) {
|
||||
this.transition('completed', source, reason);
|
||||
}
|
||||
/**
|
||||
* Close streaming mode then update card content (shared by onError and abortCard).
|
||||
*/
|
||||
async closeStreamingAndUpdate(cardId, card, label) {
|
||||
const seqBeforeClose = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info(`${label}: closing streaming mode`, {
|
||||
seqBefore: seqBeforeClose,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await setCardStreamingMode({
|
||||
cfg: this.deps.cfg,
|
||||
cardId,
|
||||
streamingMode: false,
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
const seqBeforeUpdate = this.cardKit.cardKitSequence;
|
||||
this.cardKit.cardKitSequence += 1;
|
||||
log.info(`${label}: updating card`, {
|
||||
seqBefore: seqBeforeUpdate,
|
||||
seqAfter: this.cardKit.cardKitSequence,
|
||||
});
|
||||
await updateCardKitCard({
|
||||
cfg: this.deps.cfg,
|
||||
cardId,
|
||||
card: toCardKit2(card),
|
||||
sequence: this.cardKit.cardKitSequence,
|
||||
accountId: this.deps.accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error detail extraction helpers (replacing `any` casts)
|
||||
// ---------------------------------------------------------------------------
|
||||
function extractApiDetail(err) {
|
||||
if (!err || typeof err !== 'object')
|
||||
return String(err);
|
||||
const e = err;
|
||||
return e.response?.data ? JSON.stringify(e.response.data) : String(err);
|
||||
}
|
||||
35
openclaw/extensions/openclaw-lark/src/card/unavailable-guard.d.ts
vendored
Normal file
35
openclaw/extensions/openclaw-lark/src/card/unavailable-guard.d.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Guard against operating on unavailable (deleted/recalled) messages.
|
||||
*
|
||||
* Encapsulates the terminateDueToUnavailable / shouldSkipForUnavailable
|
||||
* logic previously scattered as closures in reply-dispatcher.ts.
|
||||
*/
|
||||
export interface UnavailableGuardParams {
|
||||
replyToMessageId: string | undefined;
|
||||
getCardMessageId: () => string | null;
|
||||
onTerminate: () => void;
|
||||
}
|
||||
export declare class UnavailableGuard {
|
||||
private terminated;
|
||||
private readonly replyToMessageId;
|
||||
private readonly getCardMessageId;
|
||||
private readonly onTerminate;
|
||||
constructor(params: UnavailableGuardParams);
|
||||
get isTerminated(): boolean;
|
||||
/**
|
||||
* Check whether the reply pipeline should skip further operations.
|
||||
* Returns true if the message is already known to be unavailable.
|
||||
*/
|
||||
shouldSkip(source: string): boolean;
|
||||
/**
|
||||
* Attempt to terminate the reply pipeline due to an unavailable message.
|
||||
*
|
||||
* @param source - Descriptive label for the caller (for logging).
|
||||
* @param err - Optional error that triggered the check.
|
||||
* @returns true if the pipeline was (or already had been) terminated.
|
||||
*/
|
||||
terminate(source: string, err?: unknown): boolean;
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Guard against operating on unavailable (deleted/recalled) messages.
|
||||
*
|
||||
* Encapsulates the terminateDueToUnavailable / shouldSkipForUnavailable
|
||||
* logic previously scattered as closures in reply-dispatcher.ts.
|
||||
*/
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { extractLarkApiCode } from '../core/api-error';
|
||||
import { getMessageUnavailableState, isMessageUnavailable, isMessageUnavailableError, isTerminalMessageApiCode, markMessageUnavailable, } from '../core/message-unavailable';
|
||||
const log = larkLogger('card/unavailable-guard');
|
||||
// ---------------------------------------------------------------------------
|
||||
// UnavailableGuard
|
||||
// ---------------------------------------------------------------------------
|
||||
export class UnavailableGuard {
|
||||
terminated = false;
|
||||
replyToMessageId;
|
||||
getCardMessageId;
|
||||
onTerminate;
|
||||
constructor(params) {
|
||||
this.replyToMessageId = params.replyToMessageId;
|
||||
this.getCardMessageId = params.getCardMessageId;
|
||||
this.onTerminate = params.onTerminate;
|
||||
}
|
||||
get isTerminated() {
|
||||
return this.terminated;
|
||||
}
|
||||
/**
|
||||
* Check whether the reply pipeline should skip further operations.
|
||||
* Returns true if the message is already known to be unavailable.
|
||||
*/
|
||||
shouldSkip(source) {
|
||||
if (this.terminated)
|
||||
return true;
|
||||
if (!this.replyToMessageId)
|
||||
return false;
|
||||
if (!isMessageUnavailable(this.replyToMessageId))
|
||||
return false;
|
||||
return this.terminate(source);
|
||||
}
|
||||
/**
|
||||
* Attempt to terminate the reply pipeline due to an unavailable message.
|
||||
*
|
||||
* @param source - Descriptive label for the caller (for logging).
|
||||
* @param err - Optional error that triggered the check.
|
||||
* @returns true if the pipeline was (or already had been) terminated.
|
||||
*/
|
||||
terminate(source, err) {
|
||||
if (this.terminated)
|
||||
return true;
|
||||
const fromError = isMessageUnavailableError(err) ? err : undefined;
|
||||
const cardMessageId = this.getCardMessageId();
|
||||
const state = getMessageUnavailableState(this.replyToMessageId) ?? getMessageUnavailableState(cardMessageId ?? undefined);
|
||||
let apiCode = fromError?.apiCode ?? state?.apiCode;
|
||||
if (!apiCode && err) {
|
||||
const detectedCode = extractLarkApiCode(err);
|
||||
if (isTerminalMessageApiCode(detectedCode)) {
|
||||
const fallbackMessageId = this.replyToMessageId ?? cardMessageId ?? undefined;
|
||||
if (fallbackMessageId) {
|
||||
markMessageUnavailable({
|
||||
messageId: fallbackMessageId,
|
||||
apiCode: detectedCode,
|
||||
operation: source,
|
||||
});
|
||||
}
|
||||
apiCode = detectedCode;
|
||||
}
|
||||
}
|
||||
if (!apiCode)
|
||||
return false;
|
||||
this.terminated = true;
|
||||
this.onTerminate();
|
||||
const affectedMessageId = fromError?.messageId ?? this.replyToMessageId ?? cardMessageId ?? 'unknown';
|
||||
log.warn('reply pipeline terminated by unavailable message', {
|
||||
source,
|
||||
apiCode,
|
||||
messageId: affectedMessageId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
34
openclaw/extensions/openclaw-lark/src/channel/abort-detect.d.ts
vendored
Normal file
34
openclaw/extensions/openclaw-lark/src/channel/abort-detect.d.ts
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Abort trigger detection for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Provides a fast-path check to determine whether an inbound message is
|
||||
* an abort/stop command *before* it enters the per-chat serial queue.
|
||||
*
|
||||
* The trigger word list and normalisation logic are copied from the
|
||||
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
|
||||
* make a lightweight decision without importing the full reply pipeline.
|
||||
* The message still flows through `tryFastAbortFromMessage()` for
|
||||
* authoritative handling.
|
||||
*/
|
||||
import type { FeishuMessageEvent } from '../messaging/types';
|
||||
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
|
||||
export declare function isAbortTrigger(text: string): boolean;
|
||||
/**
|
||||
* Extended abort detection: matches both bare trigger words and the
|
||||
* `/stop` command form. Used by the monitor fast-path.
|
||||
*/
|
||||
export declare function isLikelyAbortText(text: string): boolean;
|
||||
/**
|
||||
* Extract the raw text payload from a Feishu message event.
|
||||
*
|
||||
* Only handles `text` type messages. The `message.content` field is a
|
||||
* JSON string like `{"text":"hello"}`. Returns `undefined` for
|
||||
* non-text messages or parse failures.
|
||||
*
|
||||
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
|
||||
* a message like `@Bot stop` is detected as `stop`.
|
||||
*/
|
||||
export declare function extractRawTextFromEvent(event: FeishuMessageEvent): string | undefined;
|
||||
125
openclaw/extensions/openclaw-lark/src/channel/abort-detect.js
Normal file
125
openclaw/extensions/openclaw-lark/src/channel/abort-detect.js
Normal file
@ -0,0 +1,125 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Abort trigger detection for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Provides a fast-path check to determine whether an inbound message is
|
||||
* an abort/stop command *before* it enters the per-chat serial queue.
|
||||
*
|
||||
* The trigger word list and normalisation logic are copied from the
|
||||
* OpenClaw core (`src/auto-reply/reply/abort.ts`) so the plugin can
|
||||
* make a lightweight decision without importing the full reply pipeline.
|
||||
* The message still flows through `tryFastAbortFromMessage()` for
|
||||
* authoritative handling.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger word list (synced with OpenClaw core abort.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
const ABORT_TRIGGERS = new Set([
|
||||
'stop',
|
||||
'esc',
|
||||
'abort',
|
||||
'wait',
|
||||
'exit',
|
||||
'interrupt',
|
||||
'detente',
|
||||
'deten',
|
||||
'detén',
|
||||
'arrete',
|
||||
'arrête',
|
||||
'停止',
|
||||
'やめて',
|
||||
'止めて',
|
||||
'रुको',
|
||||
'توقف',
|
||||
'стоп',
|
||||
'остановись',
|
||||
'останови',
|
||||
'остановить',
|
||||
'прекрати',
|
||||
'halt',
|
||||
'anhalten',
|
||||
'aufhören',
|
||||
'hoer auf',
|
||||
'stopp',
|
||||
'pare',
|
||||
'stop openclaw',
|
||||
'openclaw stop',
|
||||
'stop action',
|
||||
'stop current action',
|
||||
'stop run',
|
||||
'stop current run',
|
||||
'stop agent',
|
||||
'stop the agent',
|
||||
"stop don't do anything",
|
||||
'stop dont do anything',
|
||||
'stop do not do anything',
|
||||
'stop doing anything',
|
||||
'do not do that',
|
||||
'please stop',
|
||||
'stop please',
|
||||
]);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const TRAILING_ABORT_PUNCTUATION_RE = /[.!?…,,。;;::'"'")\]}]+$/u;
|
||||
function normalizeAbortTriggerText(text) {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['`]/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(TRAILING_ABORT_PUNCTUATION_RE, '')
|
||||
.trim();
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Exact trigger-word match (same logic as OpenClaw core `isAbortTrigger`). */
|
||||
export function isAbortTrigger(text) {
|
||||
if (!text)
|
||||
return false;
|
||||
const normalized = normalizeAbortTriggerText(text);
|
||||
return ABORT_TRIGGERS.has(normalized);
|
||||
}
|
||||
/**
|
||||
* Extended abort detection: matches both bare trigger words and the
|
||||
* `/stop` command form. Used by the monitor fast-path.
|
||||
*/
|
||||
export function isLikelyAbortText(text) {
|
||||
if (!text)
|
||||
return false;
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
if (trimmed === '/stop')
|
||||
return true;
|
||||
return isAbortTrigger(trimmed);
|
||||
}
|
||||
/**
|
||||
* Extract the raw text payload from a Feishu message event.
|
||||
*
|
||||
* Only handles `text` type messages. The `message.content` field is a
|
||||
* JSON string like `{"text":"hello"}`. Returns `undefined` for
|
||||
* non-text messages or parse failures.
|
||||
*
|
||||
* In group chats, bot mention placeholders (`@_user_N`) are stripped so
|
||||
* a message like `@Bot stop` is detected as `stop`.
|
||||
*/
|
||||
export function extractRawTextFromEvent(event) {
|
||||
if (!event.message || event.message.message_type !== 'text') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(event.message.content);
|
||||
let text = parsed?.text;
|
||||
if (typeof text !== 'string')
|
||||
return undefined;
|
||||
// Strip bot mention placeholders (@_user_1, @_user_2, etc.)
|
||||
text = text.replace(/@_user_\d+/g, '').trim();
|
||||
return text || undefined;
|
||||
}
|
||||
catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
41
openclaw/extensions/openclaw-lark/src/channel/chat-queue.d.ts
vendored
Normal file
41
openclaw/extensions/openclaw-lark/src/channel/chat-queue.d.ts
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Process-level chat task queue.
|
||||
*
|
||||
* Although located in channel/, this module is intentionally shared
|
||||
* across channel, messaging, tools, and card layers as a process-level
|
||||
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
|
||||
*
|
||||
* Ensures tasks targeting the same account+chat are executed serially.
|
||||
* Used by both websocket inbound messages and synthetic message paths.
|
||||
*/
|
||||
type QueueStatus = 'queued' | 'immediate';
|
||||
export interface ActiveDispatcherEntry {
|
||||
abortCard: () => Promise<void>;
|
||||
abortController?: AbortController;
|
||||
}
|
||||
/**
|
||||
* Append `:thread:{threadId}` suffix when threadId is present.
|
||||
* Consistent with the SDK's `:thread:` separator convention.
|
||||
*/
|
||||
export declare function threadScopedKey(base: string, threadId?: string): string;
|
||||
export declare function buildQueueKey(accountId: string, chatId: string, threadId?: string): string;
|
||||
export declare function registerActiveDispatcher(key: string, entry: ActiveDispatcherEntry): void;
|
||||
export declare function unregisterActiveDispatcher(key: string): void;
|
||||
export declare function getActiveDispatcher(key: string): ActiveDispatcherEntry | undefined;
|
||||
/** Check whether the queue has an active task for the given key. */
|
||||
export declare function hasActiveTask(key: string): boolean;
|
||||
export declare function enqueueFeishuChatTask(params: {
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
threadId?: string;
|
||||
task: () => Promise<void>;
|
||||
}): {
|
||||
status: QueueStatus;
|
||||
promise: Promise<void>;
|
||||
};
|
||||
/** @internal Test-only: reset all queue and dispatcher state. */
|
||||
export declare function _resetChatQueueState(): void;
|
||||
export {};
|
||||
59
openclaw/extensions/openclaw-lark/src/channel/chat-queue.js
Normal file
59
openclaw/extensions/openclaw-lark/src/channel/chat-queue.js
Normal file
@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Process-level chat task queue.
|
||||
*
|
||||
* Although located in channel/, this module is intentionally shared
|
||||
* across channel, messaging, tools, and card layers as a process-level
|
||||
* singleton. Consumers: monitor.ts, dispatch.ts, oauth.ts, auto-auth.ts.
|
||||
*
|
||||
* Ensures tasks targeting the same account+chat are executed serially.
|
||||
* Used by both websocket inbound messages and synthetic message paths.
|
||||
*/
|
||||
const chatQueues = new Map();
|
||||
const activeDispatchers = new Map();
|
||||
/**
|
||||
* Append `:thread:{threadId}` suffix when threadId is present.
|
||||
* Consistent with the SDK's `:thread:` separator convention.
|
||||
*/
|
||||
export function threadScopedKey(base, threadId) {
|
||||
return threadId ? `${base}:thread:${threadId}` : base;
|
||||
}
|
||||
export function buildQueueKey(accountId, chatId, threadId) {
|
||||
return threadScopedKey(`${accountId}:${chatId}`, threadId);
|
||||
}
|
||||
export function registerActiveDispatcher(key, entry) {
|
||||
activeDispatchers.set(key, entry);
|
||||
}
|
||||
export function unregisterActiveDispatcher(key) {
|
||||
activeDispatchers.delete(key);
|
||||
}
|
||||
export function getActiveDispatcher(key) {
|
||||
return activeDispatchers.get(key);
|
||||
}
|
||||
/** Check whether the queue has an active task for the given key. */
|
||||
export function hasActiveTask(key) {
|
||||
return chatQueues.has(key);
|
||||
}
|
||||
export function enqueueFeishuChatTask(params) {
|
||||
const { accountId, chatId, threadId, task } = params;
|
||||
const key = buildQueueKey(accountId, chatId, threadId);
|
||||
const prev = chatQueues.get(key) ?? Promise.resolve();
|
||||
const status = chatQueues.has(key) ? 'queued' : 'immediate';
|
||||
const next = prev.then(task, task); // continue queue even if previous task failed
|
||||
chatQueues.set(key, next);
|
||||
const cleanup = () => {
|
||||
if (chatQueues.get(key) === next) {
|
||||
chatQueues.delete(key);
|
||||
}
|
||||
};
|
||||
next.then(cleanup, cleanup);
|
||||
return { status, promise: next };
|
||||
}
|
||||
/** @internal Test-only: reset all queue and dispatcher state. */
|
||||
export function _resetChatQueueState() {
|
||||
chatQueues.clear();
|
||||
activeDispatchers.clear();
|
||||
}
|
||||
23
openclaw/extensions/openclaw-lark/src/channel/config-adapter.d.ts
vendored
Normal file
23
openclaw/extensions/openclaw-lark/src/channel/config-adapter.d.ts
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Configuration merge helpers for Feishu account management.
|
||||
*
|
||||
* Centralises the pattern of merging a partial configuration patch
|
||||
* into the Feishu section of the top-level ClawdbotConfig, handling
|
||||
* both the default account (top-level fields) and named accounts
|
||||
* (nested under `accounts`).
|
||||
*/
|
||||
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
/** Set the `enabled` flag on a Feishu account. */
|
||||
export declare function setAccountEnabled(cfg: ClawdbotConfig, accountId: string, enabled: boolean): ClawdbotConfig;
|
||||
/** Apply an arbitrary config patch to a Feishu account. */
|
||||
export declare function applyAccountConfig(cfg: ClawdbotConfig, accountId: string, patch: Record<string, unknown>): ClawdbotConfig;
|
||||
/** Delete a Feishu account entry from the config. */
|
||||
export declare function deleteAccount(cfg: ClawdbotConfig, accountId: string): ClawdbotConfig;
|
||||
/** Collect security warnings for a Feishu account. */
|
||||
export declare function collectFeishuSecurityWarnings(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
}): string[];
|
||||
102
openclaw/extensions/openclaw-lark/src/channel/config-adapter.js
Normal file
102
openclaw/extensions/openclaw-lark/src/channel/config-adapter.js
Normal file
@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Configuration merge helpers for Feishu account management.
|
||||
*
|
||||
* Centralises the pattern of merging a partial configuration patch
|
||||
* into the Feishu section of the top-level ClawdbotConfig, handling
|
||||
* both the default account (top-level fields) and named accounts
|
||||
* (nested under `accounts`).
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
|
||||
import { getLarkAccount, getLarkAccountIds } from '../core/accounts';
|
||||
import { collectIsolationWarnings } from '../core/security-check';
|
||||
/** Generic Feishu account config merge. */
|
||||
function mergeFeishuAccountConfig(cfg, accountId, patch) {
|
||||
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: { ...cfg.channels?.feishu, ...patch },
|
||||
},
|
||||
};
|
||||
}
|
||||
const feishuCfg = cfg.channels?.feishu;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...feishuCfg,
|
||||
accounts: {
|
||||
...feishuCfg?.accounts,
|
||||
[accountId]: { ...feishuCfg?.accounts?.[accountId], ...patch },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
/** Set the `enabled` flag on a Feishu account. */
|
||||
export function setAccountEnabled(cfg, accountId, enabled) {
|
||||
return mergeFeishuAccountConfig(cfg, accountId, { enabled });
|
||||
}
|
||||
/** Apply an arbitrary config patch to a Feishu account. */
|
||||
export function applyAccountConfig(cfg, accountId, patch) {
|
||||
return mergeFeishuAccountConfig(cfg, accountId, patch);
|
||||
}
|
||||
/** Delete a Feishu account entry from the config. */
|
||||
export function deleteAccount(cfg, accountId) {
|
||||
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (isDefault) {
|
||||
// Delete entire feishu config
|
||||
const next = { ...cfg };
|
||||
const nextChannels = { ...cfg.channels };
|
||||
delete nextChannels.feishu;
|
||||
if (Object.keys(nextChannels).length > 0) {
|
||||
next.channels = nextChannels;
|
||||
}
|
||||
else {
|
||||
delete next.channels;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
// Delete specific account from accounts
|
||||
const feishuCfg = cfg.channels?.feishu;
|
||||
const accounts = { ...feishuCfg?.accounts };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...feishuCfg,
|
||||
accounts: Object.keys(accounts).length > 0 ? accounts : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
/** Collect security warnings for a Feishu account. */
|
||||
export function collectFeishuSecurityWarnings(params) {
|
||||
const { cfg, accountId } = params;
|
||||
const warnings = [];
|
||||
const account = getLarkAccount(cfg, accountId);
|
||||
const feishuCfg = account.config;
|
||||
// cfg.channels.defaults is a cross-channel defaults object (not formally typed)
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? 'allowlist';
|
||||
if (groupPolicy === 'open') {
|
||||
warnings.push(`- Feishu[${account.accountId}] groups: groupPolicy="open" allows any group to interact (mention-gated). To restrict which groups are allowed, set groupPolicy="allowlist" and list group IDs in channels.feishu.groups. To restrict which senders can trigger the bot, set channels.feishu.groupAllowFrom with user open_ids (ou_xxx).`);
|
||||
}
|
||||
// Multi-account cross-tenant isolation check (only on first account to avoid duplicates)
|
||||
const allIds = getLarkAccountIds(cfg);
|
||||
if (allIds.length === 0 || accountId === allIds[0]) {
|
||||
for (const w of collectIsolationWarnings(cfg)) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
57
openclaw/extensions/openclaw-lark/src/channel/directory.d.ts
vendored
Normal file
57
openclaw/extensions/openclaw-lark/src/channel/directory.d.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Directory listing for Feishu peers (users) and groups.
|
||||
*
|
||||
* Provides both config-based (offline) and live API directory
|
||||
* lookups so the outbound subsystem and UI can resolve targets.
|
||||
*/
|
||||
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||
import type { FeishuDirectoryPeer, FeishuDirectoryGroup } from './types';
|
||||
export type { FeishuDirectoryPeer, FeishuDirectoryGroup } from './types';
|
||||
/**
|
||||
* List users known from the channel config (allowFrom + dms fields).
|
||||
*
|
||||
* Does not make any API calls -- useful when the bot is not yet
|
||||
* connected or when credentials are unavailable.
|
||||
*/
|
||||
export declare function listFeishuDirectoryPeers(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryPeer[]>;
|
||||
/**
|
||||
* List groups known from the channel config (groups + groupAllowFrom).
|
||||
*/
|
||||
export declare function listFeishuDirectoryGroups(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryGroup[]>;
|
||||
/**
|
||||
* List users via the Feishu contact/v3/users API.
|
||||
*
|
||||
* Falls back to config-based listing when credentials are missing or
|
||||
* the API call fails.
|
||||
*/
|
||||
export declare function listFeishuDirectoryPeersLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryPeer[]>;
|
||||
/**
|
||||
* List groups via the Feishu im/v1/chats API.
|
||||
*
|
||||
* Falls back to config-based listing when credentials are missing or
|
||||
* the API call fails.
|
||||
*/
|
||||
export declare function listFeishuDirectoryGroupsLive(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuDirectoryGroup[]>;
|
||||
192
openclaw/extensions/openclaw-lark/src/channel/directory.js
Normal file
192
openclaw/extensions/openclaw-lark/src/channel/directory.js
Normal file
@ -0,0 +1,192 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Directory listing for Feishu peers (users) and groups.
|
||||
*
|
||||
* Provides both config-based (offline) and live API directory
|
||||
* lookups so the outbound subsystem and UI can resolve targets.
|
||||
*/
|
||||
import { getLarkAccount } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { normalizeFeishuTarget } from '../core/targets';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Case-insensitive substring match on id and optional name. */
|
||||
function matchesQuery(id, name, query) {
|
||||
if (!query)
|
||||
return true;
|
||||
return id.toLowerCase().includes(query) || (name?.toLowerCase().includes(query) ?? false);
|
||||
}
|
||||
/** Filter items and apply optional limit. */
|
||||
function applyLimitSlice(items, limit) {
|
||||
return limit && limit > 0 ? items.slice(0, limit) : items;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config-based (offline) directory
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* List users known from the channel config (allowFrom + dms fields).
|
||||
*
|
||||
* Does not make any API calls -- useful when the bot is not yet
|
||||
* connected or when credentials are unavailable.
|
||||
*/
|
||||
export async function listFeishuDirectoryPeers(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
const feishuCfg = account.config;
|
||||
const q = params.query?.trim().toLowerCase() || '';
|
||||
const ids = new Set();
|
||||
// Collect from allowFrom entries.
|
||||
for (const entry of feishuCfg?.allowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== '*') {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
// Collect from per-user DM config keys.
|
||||
for (const userId of Object.keys(feishuCfg?.dms ?? {})) {
|
||||
const trimmed = userId.trim();
|
||||
if (trimmed) {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
const peers = Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.map((raw) => normalizeFeishuTarget(raw) ?? raw)
|
||||
.filter((id) => matchesQuery(id, undefined, q))
|
||||
.map((id) => ({ kind: 'user', id }));
|
||||
return applyLimitSlice(peers, params.limit);
|
||||
}
|
||||
/**
|
||||
* List groups known from the channel config (groups + groupAllowFrom).
|
||||
*/
|
||||
export async function listFeishuDirectoryGroups(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
const feishuCfg = account.config;
|
||||
const q = params.query?.trim().toLowerCase() || '';
|
||||
const ids = new Set();
|
||||
// Collect from per-group config keys.
|
||||
for (const groupId of Object.keys(feishuCfg?.groups ?? {})) {
|
||||
const trimmed = groupId.trim();
|
||||
if (trimmed && trimmed !== '*') {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
// Collect from groupAllowFrom entries.
|
||||
for (const entry of feishuCfg?.groupAllowFrom ?? []) {
|
||||
const trimmed = String(entry).trim();
|
||||
if (trimmed && trimmed !== '*') {
|
||||
ids.add(trimmed);
|
||||
}
|
||||
}
|
||||
const groups = Array.from(ids)
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
.filter((id) => matchesQuery(id, undefined, q))
|
||||
.map((id) => ({ kind: 'group', id }));
|
||||
return applyLimitSlice(groups, params.limit);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live API directory
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* List users via the Feishu contact/v3/users API.
|
||||
*
|
||||
* Falls back to config-based listing when credentials are missing or
|
||||
* the API call fails.
|
||||
*/
|
||||
export async function listFeishuDirectoryPeersLive(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
try {
|
||||
const client = LarkClient.fromAccount(account).sdk;
|
||||
const peers = [];
|
||||
const limit = params.limit ?? 50;
|
||||
if (limit <= 0)
|
||||
return [];
|
||||
const q = params.query?.trim().toLowerCase() || '';
|
||||
let pageToken;
|
||||
do {
|
||||
const remaining = limit - peers.length;
|
||||
const response = await client.contact.user.list({
|
||||
params: {
|
||||
page_size: Math.min(remaining, 50),
|
||||
page_token: pageToken,
|
||||
},
|
||||
});
|
||||
if (response.code !== 0 || !response.data?.items)
|
||||
break;
|
||||
for (const user of response.data.items) {
|
||||
if (user.open_id && matchesQuery(user.open_id, user.name, q)) {
|
||||
peers.push({
|
||||
kind: 'user',
|
||||
id: user.open_id,
|
||||
name: user.name || undefined,
|
||||
});
|
||||
}
|
||||
if (peers.length >= limit)
|
||||
break;
|
||||
}
|
||||
pageToken = response.data?.page_token;
|
||||
} while (pageToken && peers.length < limit);
|
||||
return peers;
|
||||
}
|
||||
catch {
|
||||
// Fallback to config-based listing on API failure.
|
||||
return listFeishuDirectoryPeers(params);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* List groups via the Feishu im/v1/chats API.
|
||||
*
|
||||
* Falls back to config-based listing when credentials are missing or
|
||||
* the API call fails.
|
||||
*/
|
||||
export async function listFeishuDirectoryGroupsLive(params) {
|
||||
const account = getLarkAccount(params.cfg, params.accountId);
|
||||
if (!account.configured) {
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
try {
|
||||
const client = LarkClient.fromAccount(account).sdk;
|
||||
const groups = [];
|
||||
const limit = params.limit ?? 50;
|
||||
if (limit <= 0)
|
||||
return [];
|
||||
const q = params.query?.trim().toLowerCase() || '';
|
||||
let pageToken;
|
||||
do {
|
||||
const remaining = limit - groups.length;
|
||||
const response = await client.im.chat.list({
|
||||
params: {
|
||||
page_size: Math.min(remaining, 100),
|
||||
page_token: pageToken,
|
||||
},
|
||||
});
|
||||
if (response.code !== 0 || !response.data?.items)
|
||||
break;
|
||||
for (const chat of response.data.items) {
|
||||
if (chat.chat_id && matchesQuery(chat.chat_id, chat.name, q)) {
|
||||
groups.push({
|
||||
kind: 'group',
|
||||
id: chat.chat_id,
|
||||
name: chat.name || undefined,
|
||||
});
|
||||
}
|
||||
if (groups.length >= limit)
|
||||
break;
|
||||
}
|
||||
pageToken = response.data?.page_token;
|
||||
} while (pageToken && groups.length < limit);
|
||||
return groups;
|
||||
}
|
||||
catch {
|
||||
// Fallback to config-based listing on API failure.
|
||||
return listFeishuDirectoryGroups(params);
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/openclaw-lark/src/channel/event-handlers.d.ts
vendored
Normal file
15
openclaw/extensions/openclaw-lark/src/channel/event-handlers.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Event handlers for the Feishu WebSocket monitor.
|
||||
*
|
||||
* Extracted from monitor.ts to improve testability and reduce
|
||||
* function size. Each handler receives a MonitorContext with all
|
||||
* dependencies needed to process the event.
|
||||
*/
|
||||
import type { MonitorContext } from './types';
|
||||
export declare function handleMessageEvent(ctx: MonitorContext, data: unknown): Promise<void>;
|
||||
export declare function handleReactionEvent(ctx: MonitorContext, data: unknown): Promise<void>;
|
||||
export declare function handleBotMembershipEvent(ctx: MonitorContext, data: unknown, action: 'added' | 'removed'): Promise<void>;
|
||||
export declare function handleCardActionEvent(ctx: MonitorContext, data: unknown): Promise<unknown>;
|
||||
222
openclaw/extensions/openclaw-lark/src/channel/event-handlers.js
Normal file
222
openclaw/extensions/openclaw-lark/src/channel/event-handlers.js
Normal file
@ -0,0 +1,222 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Event handlers for the Feishu WebSocket monitor.
|
||||
*
|
||||
* Extracted from monitor.ts to improve testability and reduce
|
||||
* function size. Each handler receives a MonitorContext with all
|
||||
* dependencies needed to process the event.
|
||||
*/
|
||||
import { handleFeishuMessage } from '../messaging/inbound/handler';
|
||||
import { handleFeishuReaction, resolveReactionContext } from '../messaging/inbound/reaction-handler';
|
||||
import { isMessageExpired } from '../messaging/inbound/dedup';
|
||||
import { withTicket } from '../core/lark-ticket';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { handleCardAction } from '../tools/auto-auth';
|
||||
import { enqueueFeishuChatTask, buildQueueKey, hasActiveTask, getActiveDispatcher } from './chat-queue';
|
||||
import { extractRawTextFromEvent, isLikelyAbortText } from './abort-detect';
|
||||
const elog = larkLogger('channel/event-handlers');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event ownership validation
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Verify that the event's app_id matches the current account.
|
||||
*
|
||||
* Lark SDK EventDispatcher flattens the v2 envelope header (which
|
||||
* contains `app_id`) into the handler `data` object, so `app_id` is
|
||||
* available directly on `data`.
|
||||
*
|
||||
* Returns `false` (discard event) when the app_id does not match.
|
||||
*/
|
||||
function isEventOwnershipValid(ctx, data) {
|
||||
const expectedAppId = ctx.lark.account.appId;
|
||||
if (!expectedAppId)
|
||||
return true; // appId not configured — skip check
|
||||
const eventAppId = data.app_id;
|
||||
if (eventAppId == null)
|
||||
return true; // SDK did not provide app_id — defensive skip
|
||||
if (eventAppId !== expectedAppId) {
|
||||
elog.warn('event app_id mismatch, discarding', {
|
||||
accountId: ctx.accountId,
|
||||
expected: expectedAppId,
|
||||
received: String(eventAppId),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message handler
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleMessageEvent(ctx, data) {
|
||||
if (!isEventOwnershipValid(ctx, data))
|
||||
return;
|
||||
const { accountId, log, error } = ctx;
|
||||
try {
|
||||
const event = data;
|
||||
const msgId = event.message?.message_id ?? 'unknown';
|
||||
const chatId = event.message?.chat_id ?? '';
|
||||
const threadId = event.message?.thread_id || undefined;
|
||||
// Dedup — skip duplicate messages (e.g. from WebSocket reconnects).
|
||||
if (!ctx.messageDedup.tryRecord(msgId, accountId)) {
|
||||
log(`feishu[${accountId}]: duplicate message ${msgId}, skipping`);
|
||||
return;
|
||||
}
|
||||
// Expiry — discard stale messages from reconnect replay.
|
||||
if (isMessageExpired(event.message?.create_time)) {
|
||||
log(`feishu[${accountId}]: message ${msgId} expired, discarding`);
|
||||
return;
|
||||
}
|
||||
// ---- Abort fast-path ----
|
||||
// If the message looks like an abort trigger and there is an active
|
||||
// reply dispatcher for this chat, fire abortCard() immediately
|
||||
// (before the message enters the serial queue) so the streaming
|
||||
// card is terminated without waiting for the current task.
|
||||
const abortText = extractRawTextFromEvent(event);
|
||||
if (abortText && isLikelyAbortText(abortText)) {
|
||||
const queueKey = buildQueueKey(accountId, chatId, threadId);
|
||||
if (hasActiveTask(queueKey)) {
|
||||
const active = getActiveDispatcher(queueKey);
|
||||
if (active) {
|
||||
log(`feishu[${accountId}]: abort fast-path triggered for chat ${chatId} (text="${abortText}")`);
|
||||
active.abortController?.abort();
|
||||
active.abortCard().catch((err) => {
|
||||
error(`feishu[${accountId}]: abort fast-path abortCard failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const { status } = enqueueFeishuChatTask({
|
||||
accountId,
|
||||
chatId,
|
||||
threadId,
|
||||
task: async () => {
|
||||
try {
|
||||
await withTicket({
|
||||
messageId: msgId,
|
||||
chatId,
|
||||
accountId,
|
||||
startTime: Date.now(),
|
||||
senderOpenId: event.sender?.sender_id?.open_id || '',
|
||||
chatType: event.message?.chat_type || undefined,
|
||||
threadId,
|
||||
}, () => handleFeishuMessage({
|
||||
cfg: ctx.cfg,
|
||||
event,
|
||||
botOpenId: ctx.lark.botOpenId,
|
||||
runtime: ctx.runtime,
|
||||
chatHistories: ctx.chatHistories,
|
||||
accountId,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
log(`feishu[${accountId}]: message ${msgId} in chat ${chatId}${threadId ? ` thread ${threadId}` : ''} — ${status}`);
|
||||
}
|
||||
catch (err) {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reaction handler
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleReactionEvent(ctx, data) {
|
||||
if (!isEventOwnershipValid(ctx, data))
|
||||
return;
|
||||
const { accountId, log, error } = ctx;
|
||||
try {
|
||||
const event = data;
|
||||
const msgId = event.message_id ?? 'unknown';
|
||||
log(`feishu[${accountId}]: reaction event on message ${msgId}`);
|
||||
// ---- Dedup: deterministic key based on message + emoji + operator ----
|
||||
const emojiType = event.reaction_type?.emoji_type ?? '';
|
||||
const operatorOpenId = event.user_id?.open_id ?? '';
|
||||
const dedupKey = `${msgId}:reaction:${emojiType}:${operatorOpenId}`;
|
||||
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
|
||||
log(`feishu[${accountId}]: duplicate reaction ${dedupKey}, skipping`);
|
||||
return;
|
||||
}
|
||||
// ---- Expiry: discard stale reaction events ----
|
||||
if (isMessageExpired(event.action_time)) {
|
||||
log(`feishu[${accountId}]: reaction on ${msgId} expired, discarding`);
|
||||
return;
|
||||
}
|
||||
// ---- Pre-resolve real chatId before enqueuing ----
|
||||
// The API call (3s timeout) runs outside the queue so it doesn't
|
||||
// block the serial chain, and is read-only so ordering is irrelevant.
|
||||
const preResolved = await resolveReactionContext({
|
||||
cfg: ctx.cfg,
|
||||
event,
|
||||
botOpenId: ctx.lark.botOpenId,
|
||||
runtime: ctx.runtime,
|
||||
accountId,
|
||||
});
|
||||
if (!preResolved)
|
||||
return;
|
||||
// ---- Enqueue with the real chatId (matches normal message queue key) ----
|
||||
const { status } = enqueueFeishuChatTask({
|
||||
accountId,
|
||||
chatId: preResolved.chatId,
|
||||
threadId: preResolved.threadId,
|
||||
task: async () => {
|
||||
try {
|
||||
await withTicket({
|
||||
messageId: msgId,
|
||||
chatId: preResolved.chatId,
|
||||
accountId,
|
||||
startTime: Date.now(),
|
||||
senderOpenId: operatorOpenId,
|
||||
chatType: preResolved.chatType,
|
||||
threadId: preResolved.threadId,
|
||||
}, () => handleFeishuReaction({
|
||||
cfg: ctx.cfg,
|
||||
event,
|
||||
botOpenId: ctx.lark.botOpenId,
|
||||
runtime: ctx.runtime,
|
||||
chatHistories: ctx.chatHistories,
|
||||
accountId,
|
||||
preResolved,
|
||||
}));
|
||||
}
|
||||
catch (err) {
|
||||
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
log(`feishu[${accountId}]: reaction on ${msgId} (chatId=${preResolved.chatId}) — ${status}`);
|
||||
}
|
||||
catch (err) {
|
||||
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot membership handler
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleBotMembershipEvent(ctx, data, action) {
|
||||
if (!isEventOwnershipValid(ctx, data))
|
||||
return;
|
||||
const { accountId, log, error } = ctx;
|
||||
try {
|
||||
const event = data;
|
||||
log(`feishu[${accountId}]: bot ${action} ${action === 'removed' ? 'from' : 'to'} chat ${event.chat_id}`);
|
||||
}
|
||||
catch (err) {
|
||||
error(`feishu[${accountId}]: error handling bot ${action} event: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card action handler
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function handleCardActionEvent(ctx, data) {
|
||||
try {
|
||||
return await handleCardAction(data, ctx.cfg, ctx.accountId);
|
||||
}
|
||||
catch (err) {
|
||||
elog.warn(`card.action.trigger handler error: ${err}`);
|
||||
}
|
||||
}
|
||||
17
openclaw/extensions/openclaw-lark/src/channel/monitor.d.ts
vendored
Normal file
17
openclaw/extensions/openclaw-lark/src/channel/monitor.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* WebSocket monitoring for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Manages per-account WSClient connections and routes inbound Feishu
|
||||
* events (messages, bot membership changes, read receipts) to the
|
||||
* appropriate handlers.
|
||||
*/
|
||||
import type { MonitorFeishuOpts } from './types';
|
||||
export type { MonitorFeishuOpts } from './types';
|
||||
/**
|
||||
* Start monitoring for all enabled Feishu accounts (or a single
|
||||
* account when `opts.accountId` is specified).
|
||||
*/
|
||||
export declare function monitorFeishuProvider(opts?: MonitorFeishuOpts): Promise<void>;
|
||||
130
openclaw/extensions/openclaw-lark/src/channel/monitor.js
Normal file
130
openclaw/extensions/openclaw-lark/src/channel/monitor.js
Normal file
@ -0,0 +1,130 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* WebSocket monitoring for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Manages per-account WSClient connections and routes inbound Feishu
|
||||
* events (messages, bot membership changes, read receipts) to the
|
||||
* appropriate handlers.
|
||||
*/
|
||||
import { getLarkAccount, getEnabledLarkAccounts } from '../core/accounts';
|
||||
import { LarkClient } from '../core/lark-client';
|
||||
import { MessageDedup } from '../messaging/inbound/dedup';
|
||||
import { larkLogger } from '../core/lark-logger';
|
||||
import { drainShutdownHooks } from '../core/shutdown-hooks';
|
||||
import { handleMessageEvent, handleReactionEvent, handleBotMembershipEvent, handleCardActionEvent, } from './event-handlers';
|
||||
const mlog = larkLogger('channel/monitor');
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-account monitor
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Start monitoring a single Feishu account.
|
||||
*
|
||||
* Creates a LarkClient, probes bot identity, registers event handlers,
|
||||
* and starts a WebSocket connection. Returns a Promise that resolves
|
||||
* when the abort signal fires (or immediately if already aborted).
|
||||
*/
|
||||
async function monitorSingleAccount(params) {
|
||||
const { account, runtime, abortSignal } = params;
|
||||
const { accountId } = account;
|
||||
const log = runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
|
||||
const error = runtime?.error ?? ((...args) => mlog.error(args.map(String).join(' ')));
|
||||
// Only websocket mode is supported in the monitor path.
|
||||
const connectionMode = account.config.connectionMode ?? 'websocket';
|
||||
if (connectionMode !== 'websocket') {
|
||||
log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
|
||||
return;
|
||||
}
|
||||
// Message dedup — filters duplicate deliveries from WebSocket reconnects.
|
||||
const dedupCfg = account.config.dedup;
|
||||
const messageDedup = new MessageDedup({
|
||||
ttlMs: dedupCfg?.ttlMs,
|
||||
maxEntries: dedupCfg?.maxEntries,
|
||||
});
|
||||
log(`feishu[${accountId}]: message dedup enabled (ttl=${messageDedup['ttlMs']}ms, max=${messageDedup['maxEntries']})`);
|
||||
log(`feishu[${accountId}]: starting WebSocket connection...`);
|
||||
// Create LarkClient instance — manages SDK client, WS, and bot identity.
|
||||
const lark = LarkClient.fromAccount(account);
|
||||
// Attach dedup instance so it is disposed together with the client.
|
||||
lark.messageDedup = messageDedup;
|
||||
/** Per-chat history maps (used for group-chat context window). */
|
||||
const chatHistories = new Map();
|
||||
const ctx = {
|
||||
get cfg() {
|
||||
return LarkClient.runtime.config.loadConfig();
|
||||
},
|
||||
lark,
|
||||
accountId,
|
||||
chatHistories,
|
||||
messageDedup,
|
||||
runtime,
|
||||
log,
|
||||
error,
|
||||
};
|
||||
await lark.startWS({
|
||||
handlers: {
|
||||
'im.message.receive_v1': (data) => handleMessageEvent(ctx, data),
|
||||
'im.message.message_read_v1': async () => { },
|
||||
'im.message.reaction.created_v1': (data) => handleReactionEvent(ctx, data),
|
||||
'im.chat.member.bot.added_v1': (data) => handleBotMembershipEvent(ctx, data, 'added'),
|
||||
'im.chat.member.bot.deleted_v1': (data) => handleBotMembershipEvent(ctx, data, 'removed'),
|
||||
// 飞书 SDK EventDispatcher.register 不支持带返回值的处理器,此处 as any 是 SDK 类型限制的变通
|
||||
'card.action.trigger': ((data) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handleCardActionEvent(ctx, data)),
|
||||
},
|
||||
abortSignal,
|
||||
});
|
||||
// startWS resolves when abortSignal fires — probe result is logged inside startWS.
|
||||
log(`feishu[${accountId}]: bot open_id resolved: ${lark.botOpenId ?? 'unknown'}`);
|
||||
log(`feishu[${accountId}]: WebSocket client started`);
|
||||
mlog.info(`websocket started for account ${accountId}`);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Start monitoring for all enabled Feishu accounts (or a single
|
||||
* account when `opts.accountId` is specified).
|
||||
*/
|
||||
export async function monitorFeishuProvider(opts = {}) {
|
||||
const cfg = opts.config;
|
||||
if (!cfg) {
|
||||
throw new Error('Config is required for Feishu monitor');
|
||||
}
|
||||
// Store the original global config so plugin commands (doctor, diagnose)
|
||||
// can access cross-account information even when running inside an
|
||||
// account-scoped config context.
|
||||
LarkClient.setGlobalConfig(cfg);
|
||||
const log = opts.runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
|
||||
// Single-account mode.
|
||||
if (opts.accountId) {
|
||||
const account = getLarkAccount(cfg, opts.accountId);
|
||||
if (!account.enabled || !account.configured) {
|
||||
throw new Error(`Feishu account "${opts.accountId}" not configured or disabled`);
|
||||
}
|
||||
await monitorSingleAccount({
|
||||
cfg,
|
||||
account,
|
||||
runtime: opts.runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
});
|
||||
await drainShutdownHooks({ log });
|
||||
return;
|
||||
}
|
||||
// Multi-account mode: start all enabled accounts in parallel.
|
||||
const accounts = getEnabledLarkAccounts(cfg);
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('No enabled Feishu accounts configured');
|
||||
}
|
||||
log(`feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(', ')}`);
|
||||
await Promise.all(accounts.map((account) => monitorSingleAccount({
|
||||
cfg,
|
||||
account,
|
||||
runtime: opts.runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
})));
|
||||
await drainShutdownHooks({ log });
|
||||
}
|
||||
17
openclaw/extensions/openclaw-lark/src/channel/onboarding-config.d.ts
vendored
Normal file
17
openclaw/extensions/openclaw-lark/src/channel/onboarding-config.d.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Onboarding configuration mutation helpers.
|
||||
*
|
||||
* Pure functions that apply Feishu channel configuration changes
|
||||
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
|
||||
* in CLI commands and other configuration flows.
|
||||
*/
|
||||
import type { ClawdbotConfig, DmPolicy } from 'openclaw/plugin-sdk';
|
||||
export declare function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig;
|
||||
export declare function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig;
|
||||
export declare function setFeishuGroupPolicy(cfg: ClawdbotConfig, groupPolicy: 'open' | 'allowlist' | 'disabled'): ClawdbotConfig;
|
||||
export declare function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig;
|
||||
export declare function setFeishuGroups(cfg: ClawdbotConfig, groups: Record<string, object>): ClawdbotConfig;
|
||||
export declare function parseAllowFromInput(raw: string): string[];
|
||||
@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Onboarding configuration mutation helpers.
|
||||
*
|
||||
* Pure functions that apply Feishu channel configuration changes
|
||||
* to a ClawdbotConfig. Extracted from onboarding.ts for reuse
|
||||
* in CLI commands and other configuration flows.
|
||||
*/
|
||||
import { addWildcardAllowFrom } from 'openclaw/plugin-sdk';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config mutation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export function setFeishuDmPolicy(cfg, dmPolicy) {
|
||||
const allowFrom = dmPolicy === 'open'
|
||||
? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuAllowFrom(cfg, allowFrom) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroupPolicy(cfg, groupPolicy) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroupAllowFrom(cfg, groupAllowFrom) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groupAllowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export function setFeishuGroups(cfg, groups) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
groups,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
export function parseAllowFromInput(raw) {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
25
openclaw/extensions/openclaw-lark/src/channel/onboarding-migrate.d.ts
vendored
Normal file
25
openclaw/extensions/openclaw-lark/src/channel/onboarding-migrate.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Legacy groupAllowFrom migration for Feishu onboarding.
|
||||
*
|
||||
* Handles the migration of chat_id entries (oc_xxx) from
|
||||
* groupAllowFrom to the groups config, preserving the original
|
||||
* semantic of "allow this group for any sender".
|
||||
*/
|
||||
import type { ClawdbotConfig, WizardPrompter } from 'openclaw/plugin-sdk';
|
||||
/**
|
||||
* Detect and migrate legacy chat_id entries in groupAllowFrom.
|
||||
*
|
||||
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
|
||||
* which groups could use the bot.
|
||||
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
|
||||
*
|
||||
* This function prompts the user and, if confirmed, moves chat_ids
|
||||
* to the groups config and keeps only sender IDs in groupAllowFrom.
|
||||
*/
|
||||
export declare function migrateLegacyGroupAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
}): Promise<ClawdbotConfig>;
|
||||
@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Legacy groupAllowFrom migration for Feishu onboarding.
|
||||
*
|
||||
* Handles the migration of chat_id entries (oc_xxx) from
|
||||
* groupAllowFrom to the groups config, preserving the original
|
||||
* semantic of "allow this group for any sender".
|
||||
*/
|
||||
import { setFeishuGroups, setFeishuGroupAllowFrom } from './onboarding-config';
|
||||
/**
|
||||
* Detect and migrate legacy chat_id entries in groupAllowFrom.
|
||||
*
|
||||
* Old semantic: groupAllowFrom contained chat_ids (oc_xxx) to control
|
||||
* which groups could use the bot.
|
||||
* New semantic: groupAllowFrom is for sender filtering (open_ids like ou_xxx).
|
||||
*
|
||||
* This function prompts the user and, if confirmed, moves chat_ids
|
||||
* to the groups config and keeps only sender IDs in groupAllowFrom.
|
||||
*/
|
||||
export async function migrateLegacyGroupAllowFrom(params) {
|
||||
let next = params.cfg;
|
||||
const { prompter } = params;
|
||||
const existingGroupAllowFrom = next.channels?.feishu?.groupAllowFrom ?? [];
|
||||
const legacyChatIds = existingGroupAllowFrom.filter((e) => String(e).startsWith('oc_'));
|
||||
const senderAllowFrom = existingGroupAllowFrom.filter((e) => !String(e).startsWith('oc_'));
|
||||
if (legacyChatIds.length === 0) {
|
||||
return next;
|
||||
}
|
||||
await prompter.note([
|
||||
`⚠️ Detected legacy config: groupAllowFrom contains chat_ids (${legacyChatIds.join(', ')})`,
|
||||
'',
|
||||
'Old semantic: groupAllowFrom controlled which groups could use the bot.',
|
||||
'New semantic: groupAllowFrom is for SENDER filtering (open_ids like ou_xxx).',
|
||||
'',
|
||||
'Recommended migration:',
|
||||
` 1. Move chat_ids (oc_xxx) → channels.feishu.groups`,
|
||||
` 2. Keep sender IDs (ou_xxx) in groupAllowFrom`,
|
||||
].join('\n'), 'Legacy config detected');
|
||||
const migrate = await prompter.confirm({
|
||||
message: `Migrate ${legacyChatIds.length} chat_id(s) to groups config?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (migrate) {
|
||||
const existingGroups = next.channels?.feishu?.groups ?? {};
|
||||
const migratedGroups = {
|
||||
...existingGroups,
|
||||
};
|
||||
for (const chatId of legacyChatIds) {
|
||||
if (!migratedGroups[String(chatId)]) {
|
||||
migratedGroups[String(chatId)] = {
|
||||
enabled: true,
|
||||
groupPolicy: 'open',
|
||||
};
|
||||
}
|
||||
}
|
||||
next = setFeishuGroups(next, migratedGroups);
|
||||
next = setFeishuGroupAllowFrom(next, senderAllowFrom);
|
||||
await prompter.note(`✅ Migrated: ${legacyChatIds.length} chat_id(s) moved to groups, ` +
|
||||
`${senderAllowFrom.length} sender(s) kept in groupAllowFrom`, 'Migration complete');
|
||||
}
|
||||
else {
|
||||
await prompter.note('Skipped migration. Please update config manually to avoid issues.', 'Migration skipped');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
12
openclaw/extensions/openclaw-lark/src/channel/onboarding.d.ts
vendored
Normal file
12
openclaw/extensions/openclaw-lark/src/channel/onboarding.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Implements the ChannelOnboardingAdapter interface so the `openclaw
|
||||
* setup` wizard can configure Feishu credentials, domain, group
|
||||
* policies, and DM allowlists interactively.
|
||||
*/
|
||||
import type { ChannelOnboardingAdapter } from 'openclaw/plugin-sdk';
|
||||
export declare const feishuOnboardingAdapter: ChannelOnboardingAdapter;
|
||||
297
openclaw/extensions/openclaw-lark/src/channel/onboarding.js
Normal file
297
openclaw/extensions/openclaw-lark/src/channel/onboarding.js
Normal file
@ -0,0 +1,297 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Onboarding wizard adapter for the Lark/Feishu channel plugin.
|
||||
*
|
||||
* Implements the ChannelOnboardingAdapter interface so the `openclaw
|
||||
* setup` wizard can configure Feishu credentials, domain, group
|
||||
* policies, and DM allowlists interactively.
|
||||
*/
|
||||
import { DEFAULT_ACCOUNT_ID, formatDocsLink } from 'openclaw/plugin-sdk';
|
||||
import { getLarkCredentials } from '../core/accounts';
|
||||
import { probeFeishu } from './probe';
|
||||
import { setFeishuDmPolicy, setFeishuAllowFrom, setFeishuGroupPolicy, setFeishuGroupAllowFrom, parseAllowFromInput, } from './onboarding-config';
|
||||
import { migrateLegacyGroupAllowFrom } from './onboarding-migrate';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const channel = 'feishu';
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompter helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function noteFeishuCredentialHelp(prompter) {
|
||||
await prompter.note([
|
||||
'1) Go to Feishu Open Platform (open.feishu.cn)',
|
||||
'2) Create a self-built app',
|
||||
'3) Get App ID and App Secret from Credentials page',
|
||||
'4) Enable required permissions: im:message, im:chat, contact:user.base:readonly',
|
||||
'5) Publish the app or add it to a test group',
|
||||
'Tip: you can also set FEISHU_APP_ID / FEISHU_APP_SECRET env vars.',
|
||||
`Docs: ${formatDocsLink('/channels/feishu', 'feishu')}`,
|
||||
].join('\n'), 'Feishu credentials');
|
||||
}
|
||||
async function promptFeishuAllowFrom(params) {
|
||||
const existing = params.cfg.channels?.feishu?.allowFrom ?? [];
|
||||
await params.prompter.note([
|
||||
'Allowlist Feishu DMs by open_id or user_id.',
|
||||
'You can find user open_id in Feishu admin console or via API.',
|
||||
'Examples:',
|
||||
'- ou_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
'- on_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
].join('\n'), 'Feishu allowlist');
|
||||
while (true) {
|
||||
const entry = await params.prompter.text({
|
||||
message: 'Feishu allowFrom (user open_ids)',
|
||||
placeholder: 'ou_xxxxx, ou_yyyyy',
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? '').trim() ? undefined : 'Required'),
|
||||
});
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length === 0) {
|
||||
await params.prompter.note('Enter at least one user.', 'Feishu allowlist');
|
||||
continue;
|
||||
}
|
||||
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...parts])];
|
||||
return setFeishuAllowFrom(params.cfg, unique);
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential acquisition
|
||||
// ---------------------------------------------------------------------------
|
||||
async function acquireCredentials(params) {
|
||||
const { prompter, feishuCfg } = params;
|
||||
let next = params.cfg;
|
||||
const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
|
||||
const canUseEnv = Boolean(!hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim());
|
||||
let appId = null;
|
||||
let appSecret = null;
|
||||
if (canUseEnv) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: 'FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: { ...next.channels?.feishu, enabled: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
else {
|
||||
appId = String(await prompter.text({
|
||||
message: 'Enter Feishu App ID',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
appSecret = String(await prompter.text({
|
||||
message: 'Enter Feishu App Secret',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
}
|
||||
}
|
||||
else if (hasConfigCreds) {
|
||||
const keep = await prompter.confirm({
|
||||
message: 'Feishu credentials already configured. Keep them?',
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
appId = String(await prompter.text({
|
||||
message: 'Enter Feishu App ID',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
appSecret = String(await prompter.text({
|
||||
message: 'Enter Feishu App Secret',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
}
|
||||
}
|
||||
else {
|
||||
appId = String(await prompter.text({
|
||||
message: 'Enter Feishu App ID',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
appSecret = String(await prompter.text({
|
||||
message: 'Enter Feishu App Secret',
|
||||
validate: (value) => (value?.trim() ? undefined : 'Required'),
|
||||
})).trim();
|
||||
}
|
||||
return { cfg: next, appId, appSecret };
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM policy
|
||||
// ---------------------------------------------------------------------------
|
||||
const dmPolicy = {
|
||||
label: 'Feishu',
|
||||
channel,
|
||||
policyKey: 'channels.feishu.dmPolicy',
|
||||
allowFromKey: 'channels.feishu.allowFrom',
|
||||
getCurrent: (cfg) => cfg.channels?.feishu?.dmPolicy ?? 'pairing',
|
||||
setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptFeishuAllowFrom,
|
||||
};
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
export const feishuOnboardingAdapter = {
|
||||
channel,
|
||||
// -----------------------------------------------------------------------
|
||||
// getStatus
|
||||
// -----------------------------------------------------------------------
|
||||
getStatus: async ({ cfg }) => {
|
||||
const feishuCfg = cfg.channels?.feishu;
|
||||
const configured = Boolean(getLarkCredentials(feishuCfg));
|
||||
// Attempt a live probe when credentials are present.
|
||||
let probeResult = null;
|
||||
if (configured && feishuCfg) {
|
||||
try {
|
||||
probeResult = await probeFeishu(feishuCfg);
|
||||
}
|
||||
catch {
|
||||
// Ignore probe errors -- status degrades gracefully.
|
||||
}
|
||||
}
|
||||
const statusLines = [];
|
||||
if (!configured) {
|
||||
statusLines.push('Feishu: needs app credentials');
|
||||
}
|
||||
else if (probeResult?.ok) {
|
||||
statusLines.push(`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? 'bot'}`);
|
||||
}
|
||||
else {
|
||||
statusLines.push('Feishu: configured (connection not verified)');
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines,
|
||||
selectionHint: configured ? 'configured' : 'needs app creds',
|
||||
quickstartScore: configured ? 2 : 0,
|
||||
};
|
||||
},
|
||||
// -----------------------------------------------------------------------
|
||||
// configure
|
||||
// -----------------------------------------------------------------------
|
||||
configure: async ({ cfg, prompter }) => {
|
||||
const feishuCfg = cfg.channels?.feishu;
|
||||
const resolved = getLarkCredentials(feishuCfg);
|
||||
let next = cfg;
|
||||
// Show credential help if nothing is configured yet.
|
||||
if (!resolved) {
|
||||
await noteFeishuCredentialHelp(prompter);
|
||||
}
|
||||
// --- Credential acquisition ---
|
||||
const creds = await acquireCredentials({ cfg: next, prompter, feishuCfg });
|
||||
next = creds.cfg;
|
||||
// --- Persist and test credentials ---
|
||||
if (creds.appId && creds.appSecret) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
enabled: true,
|
||||
appId: creds.appId,
|
||||
appSecret: creds.appSecret,
|
||||
},
|
||||
},
|
||||
};
|
||||
const testCfg = next.channels?.feishu;
|
||||
try {
|
||||
const probe = await probeFeishu(testCfg);
|
||||
if (probe.ok) {
|
||||
await prompter.note(`Connected as ${probe.botName ?? probe.botOpenId ?? 'bot'}`, 'Feishu connection test');
|
||||
}
|
||||
else {
|
||||
await prompter.note(`Connection failed: ${probe.error ?? 'unknown error'}`, 'Feishu connection test');
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
await prompter.note(`Connection test failed: ${String(err)}`, 'Feishu connection test');
|
||||
}
|
||||
}
|
||||
// --- Domain selection ---
|
||||
const currentDomain = next.channels?.feishu?.domain ?? 'feishu';
|
||||
const domain = await prompter.select({
|
||||
message: 'Which Feishu domain?',
|
||||
options: [
|
||||
{ value: 'feishu', label: 'Feishu (feishu.cn) - China' },
|
||||
{ value: 'lark', label: 'Lark (larksuite.com) - International' },
|
||||
],
|
||||
initialValue: currentDomain,
|
||||
});
|
||||
if (domain) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
feishu: {
|
||||
...next.channels?.feishu,
|
||||
domain: domain,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// --- Legacy migration ---
|
||||
next = await migrateLegacyGroupAllowFrom({ cfg: next, prompter });
|
||||
// --- Group policy ---
|
||||
const groupPolicy = await prompter.select({
|
||||
message: 'Group chat policy — which groups can interact with the bot?',
|
||||
options: [
|
||||
{
|
||||
value: 'allowlist',
|
||||
label: 'Allowlist — only groups listed in `groups` config (default)',
|
||||
},
|
||||
{
|
||||
value: 'open',
|
||||
label: 'Open — any group (requires @mention)',
|
||||
},
|
||||
{
|
||||
value: 'disabled',
|
||||
label: 'Disabled — no group interactions',
|
||||
},
|
||||
],
|
||||
initialValue: next.channels?.feishu?.groupPolicy ?? 'allowlist',
|
||||
});
|
||||
if (groupPolicy) {
|
||||
next = setFeishuGroupPolicy(next, groupPolicy);
|
||||
}
|
||||
// --- Group sender allowlist ---
|
||||
if (groupPolicy !== 'disabled') {
|
||||
const existing = next.channels?.feishu?.groupAllowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: 'Group sender allowlist — which users can trigger the bot in allowed groups? (user open_ids)',
|
||||
placeholder: 'ou_xxxxx, ou_yyyyy',
|
||||
initialValue: existing.length > 0 ? existing.map(String).join(', ') : undefined,
|
||||
});
|
||||
if (entry) {
|
||||
const parts = parseAllowFromInput(String(entry));
|
||||
if (parts.length > 0) {
|
||||
next = setFeishuGroupAllowFrom(next, parts);
|
||||
}
|
||||
}
|
||||
else if (groupPolicy === 'allowlist') {
|
||||
await prompter.note('Empty sender list + allowlist = nobody can trigger. ' +
|
||||
"Use groupPolicy 'open' if you want anyone in allowed groups to trigger.", 'Note');
|
||||
}
|
||||
}
|
||||
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
||||
},
|
||||
// -----------------------------------------------------------------------
|
||||
// dmPolicy
|
||||
// -----------------------------------------------------------------------
|
||||
dmPolicy,
|
||||
// -----------------------------------------------------------------------
|
||||
// disable
|
||||
// -----------------------------------------------------------------------
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: { ...cfg.channels?.feishu, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user