auto: sync OpenClaw config 2026-09-09 16:13
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
This plugin integrates with OpenClaw AI automation capabilities and carries inherent risks such as model hallucinations, unpredictable execution, and prompt injection. After you authorize Lark/Feishu permissions, OpenClaw will act under your user identity within the authorized scope, which may lead to high-risk consequences such as leakage of sensitive data or unauthorized operations. Please use with caution.
|
||||||
|
|
||||||
|
To reduce these risks, the plugin enables default security protections at multiple layers. However, these risks still exist. We strongly recommend that you do not proactively modify any default security settings; once relevant restrictions are relaxed, the risks will increase significantly, and you will bear the consequences.
|
||||||
|
|
||||||
|
We recommend using the Lark/Feishu bot connected to OpenClaw as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||||
|
|
||||||
|
Please fully understand all usage risks. By using this plugin, you are deemed to voluntarily assume all related responsibilities.
|
||||||
|
|
||||||
|
|
||||||
|
**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.
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const mod = ['child', 'process'].join('_');
|
||||||
|
const { execFileSync } = createRequire(import.meta.url)(`node:${mod}`);
|
||||||
|
|
||||||
|
// --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', '--prefer-online', `@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);
|
||||||
|
}
|
||||||
Vendored
+36
@@ -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: any;
|
||||||
|
register(api: OpenClawPluginApi): void;
|
||||||
|
};
|
||||||
|
export default plugin;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.isMessageExpired = exports.checkMessageGate = exports.parseMessageEvent = exports.handleFeishuReaction = exports.feishuPlugin = exports.buildMentionedCardContent = exports.buildMentionedMessage = exports.formatMentionAllForCard = exports.formatMentionAllForText = exports.formatMentionForCard = exports.formatMentionForText = exports.extractMessageBody = exports.nonBotMentions = exports.mentionedBot = exports.feishuMessageActions = exports.listChatMembersFeishu = exports.removeChatMembersFeishu = exports.addChatMembersFeishu = exports.updateChatFeishu = exports.forwardMessageFeishu = exports.VALID_FEISHU_EMOJI_TYPES = exports.FeishuEmoji = exports.listReactionsFeishu = exports.removeReactionFeishu = exports.addReactionFeishu = exports.probeFeishu = exports.sendMediaLark = exports.sendCardLark = exports.sendTextLark = exports.uploadAndSendMediaLark = exports.sendAudioLark = exports.sendFileLark = exports.sendImageLark = exports.uploadFileLark = exports.uploadImageLark = exports.getMessageFeishu = exports.editMessageFeishu = exports.updateCardFeishu = exports.sendCardFeishu = exports.sendMessageFeishu = exports.monitorFeishuProvider = void 0;
|
||||||
|
const plugin_sdk_1 = require("openclaw/plugin-sdk");
|
||||||
|
const plugin_1 = require("./src/channel/plugin.js");
|
||||||
|
const lark_client_1 = require("./src/core/lark-client.js");
|
||||||
|
const index_1 = require("./src/tools/oapi/index.js");
|
||||||
|
const index_2 = require("./src/tools/mcp/doc/index.js");
|
||||||
|
const oauth_1 = require("./src/tools/oauth.js");
|
||||||
|
const oauth_batch_auth_1 = require("./src/tools/oauth-batch-auth.js");
|
||||||
|
const ask_user_question_1 = require("./src/tools/ask-user-question.js");
|
||||||
|
const diagnose_1 = require("./src/commands/diagnose.js");
|
||||||
|
const index_3 = require("./src/commands/index.js");
|
||||||
|
const lark_logger_1 = require("./src/core/lark-logger.js");
|
||||||
|
const security_check_1 = require("./src/core/security-check.js");
|
||||||
|
const tool_use_trace_store_1 = require("./src/card/tool-use-trace-store.js");
|
||||||
|
const reasoning_utils_1 = require("./src/card/reasoning-utils.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('plugin');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Re-exports for external consumers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
var monitor_1 = require("./src/channel/monitor.js");
|
||||||
|
Object.defineProperty(exports, "monitorFeishuProvider", { enumerable: true, get: function () { return monitor_1.monitorFeishuProvider; } });
|
||||||
|
var send_1 = require("./src/messaging/outbound/send.js");
|
||||||
|
Object.defineProperty(exports, "sendMessageFeishu", { enumerable: true, get: function () { return send_1.sendMessageFeishu; } });
|
||||||
|
Object.defineProperty(exports, "sendCardFeishu", { enumerable: true, get: function () { return send_1.sendCardFeishu; } });
|
||||||
|
Object.defineProperty(exports, "updateCardFeishu", { enumerable: true, get: function () { return send_1.updateCardFeishu; } });
|
||||||
|
Object.defineProperty(exports, "editMessageFeishu", { enumerable: true, get: function () { return send_1.editMessageFeishu; } });
|
||||||
|
var fetch_1 = require("./src/messaging/outbound/fetch.js");
|
||||||
|
Object.defineProperty(exports, "getMessageFeishu", { enumerable: true, get: function () { return fetch_1.getMessageFeishu; } });
|
||||||
|
var media_1 = require("./src/messaging/outbound/media.js");
|
||||||
|
Object.defineProperty(exports, "uploadImageLark", { enumerable: true, get: function () { return media_1.uploadImageLark; } });
|
||||||
|
Object.defineProperty(exports, "uploadFileLark", { enumerable: true, get: function () { return media_1.uploadFileLark; } });
|
||||||
|
Object.defineProperty(exports, "sendImageLark", { enumerable: true, get: function () { return media_1.sendImageLark; } });
|
||||||
|
Object.defineProperty(exports, "sendFileLark", { enumerable: true, get: function () { return media_1.sendFileLark; } });
|
||||||
|
Object.defineProperty(exports, "sendAudioLark", { enumerable: true, get: function () { return media_1.sendAudioLark; } });
|
||||||
|
Object.defineProperty(exports, "uploadAndSendMediaLark", { enumerable: true, get: function () { return media_1.uploadAndSendMediaLark; } });
|
||||||
|
var deliver_1 = require("./src/messaging/outbound/deliver.js");
|
||||||
|
Object.defineProperty(exports, "sendTextLark", { enumerable: true, get: function () { return deliver_1.sendTextLark; } });
|
||||||
|
Object.defineProperty(exports, "sendCardLark", { enumerable: true, get: function () { return deliver_1.sendCardLark; } });
|
||||||
|
Object.defineProperty(exports, "sendMediaLark", { enumerable: true, get: function () { return deliver_1.sendMediaLark; } });
|
||||||
|
var probe_1 = require("./src/channel/probe.js");
|
||||||
|
Object.defineProperty(exports, "probeFeishu", { enumerable: true, get: function () { return probe_1.probeFeishu; } });
|
||||||
|
var reactions_1 = require("./src/messaging/outbound/reactions.js");
|
||||||
|
Object.defineProperty(exports, "addReactionFeishu", { enumerable: true, get: function () { return reactions_1.addReactionFeishu; } });
|
||||||
|
Object.defineProperty(exports, "removeReactionFeishu", { enumerable: true, get: function () { return reactions_1.removeReactionFeishu; } });
|
||||||
|
Object.defineProperty(exports, "listReactionsFeishu", { enumerable: true, get: function () { return reactions_1.listReactionsFeishu; } });
|
||||||
|
Object.defineProperty(exports, "FeishuEmoji", { enumerable: true, get: function () { return reactions_1.FeishuEmoji; } });
|
||||||
|
Object.defineProperty(exports, "VALID_FEISHU_EMOJI_TYPES", { enumerable: true, get: function () { return reactions_1.VALID_FEISHU_EMOJI_TYPES; } });
|
||||||
|
var forward_1 = require("./src/messaging/outbound/forward.js");
|
||||||
|
Object.defineProperty(exports, "forwardMessageFeishu", { enumerable: true, get: function () { return forward_1.forwardMessageFeishu; } });
|
||||||
|
var chat_manage_1 = require("./src/messaging/outbound/chat-manage.js");
|
||||||
|
Object.defineProperty(exports, "updateChatFeishu", { enumerable: true, get: function () { return chat_manage_1.updateChatFeishu; } });
|
||||||
|
Object.defineProperty(exports, "addChatMembersFeishu", { enumerable: true, get: function () { return chat_manage_1.addChatMembersFeishu; } });
|
||||||
|
Object.defineProperty(exports, "removeChatMembersFeishu", { enumerable: true, get: function () { return chat_manage_1.removeChatMembersFeishu; } });
|
||||||
|
Object.defineProperty(exports, "listChatMembersFeishu", { enumerable: true, get: function () { return chat_manage_1.listChatMembersFeishu; } });
|
||||||
|
var actions_1 = require("./src/messaging/outbound/actions.js");
|
||||||
|
Object.defineProperty(exports, "feishuMessageActions", { enumerable: true, get: function () { return actions_1.feishuMessageActions; } });
|
||||||
|
var mention_1 = require("./src/messaging/inbound/mention.js");
|
||||||
|
Object.defineProperty(exports, "mentionedBot", { enumerable: true, get: function () { return mention_1.mentionedBot; } });
|
||||||
|
Object.defineProperty(exports, "nonBotMentions", { enumerable: true, get: function () { return mention_1.nonBotMentions; } });
|
||||||
|
Object.defineProperty(exports, "extractMessageBody", { enumerable: true, get: function () { return mention_1.extractMessageBody; } });
|
||||||
|
Object.defineProperty(exports, "formatMentionForText", { enumerable: true, get: function () { return mention_1.formatMentionForText; } });
|
||||||
|
Object.defineProperty(exports, "formatMentionForCard", { enumerable: true, get: function () { return mention_1.formatMentionForCard; } });
|
||||||
|
Object.defineProperty(exports, "formatMentionAllForText", { enumerable: true, get: function () { return mention_1.formatMentionAllForText; } });
|
||||||
|
Object.defineProperty(exports, "formatMentionAllForCard", { enumerable: true, get: function () { return mention_1.formatMentionAllForCard; } });
|
||||||
|
Object.defineProperty(exports, "buildMentionedMessage", { enumerable: true, get: function () { return mention_1.buildMentionedMessage; } });
|
||||||
|
Object.defineProperty(exports, "buildMentionedCardContent", { enumerable: true, get: function () { return mention_1.buildMentionedCardContent; } });
|
||||||
|
var plugin_2 = require("./src/channel/plugin.js");
|
||||||
|
Object.defineProperty(exports, "feishuPlugin", { enumerable: true, get: function () { return plugin_2.feishuPlugin; } });
|
||||||
|
var reaction_handler_1 = require("./src/messaging/inbound/reaction-handler.js");
|
||||||
|
Object.defineProperty(exports, "handleFeishuReaction", { enumerable: true, get: function () { return reaction_handler_1.handleFeishuReaction; } });
|
||||||
|
var parse_1 = require("./src/messaging/inbound/parse.js");
|
||||||
|
Object.defineProperty(exports, "parseMessageEvent", { enumerable: true, get: function () { return parse_1.parseMessageEvent; } });
|
||||||
|
var gate_1 = require("./src/messaging/inbound/gate.js");
|
||||||
|
Object.defineProperty(exports, "checkMessageGate", { enumerable: true, get: function () { return gate_1.checkMessageGate; } });
|
||||||
|
var dedup_1 = require("./src/messaging/inbound/dedup.js");
|
||||||
|
Object.defineProperty(exports, "isMessageExpired", { enumerable: true, get: function () { return dedup_1.isMessageExpired; } });
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const plugin = {
|
||||||
|
id: 'openclaw-lark',
|
||||||
|
name: 'Feishu',
|
||||||
|
description: 'Lark/Feishu channel plugin with im/doc/wiki/drive/task/calendar tools',
|
||||||
|
configSchema: (0, plugin_sdk_1.emptyPluginConfigSchema)(),
|
||||||
|
register(api) {
|
||||||
|
lark_client_1.LarkClient.setRuntime(api.runtime);
|
||||||
|
api.registerChannel({ plugin: plugin_1.feishuPlugin });
|
||||||
|
// ========================================
|
||||||
|
// Register OAPI tools (calendar, task - using Feishu Open API directly)
|
||||||
|
(0, index_1.registerOapiTools)(api);
|
||||||
|
// Register MCP doc tools (using Model Context Protocol)
|
||||||
|
(0, index_2.registerFeishuMcpDocTools)(api);
|
||||||
|
// Register OAuth tool (UAT device flow authorization)
|
||||||
|
(0, oauth_1.registerFeishuOAuthTool)(api);
|
||||||
|
// Register OAuth batch auth tool (batch authorization for all app scopes)
|
||||||
|
(0, oauth_batch_auth_1.registerFeishuOAuthBatchAuthTool)(api);
|
||||||
|
// Register AskUserQuestion tool (interactive card-based user prompting)
|
||||||
|
(0, ask_user_question_1.registerAskUserQuestionTool)(api);
|
||||||
|
api.on('before_tool_call', (event, ctx) => {
|
||||||
|
(0, tool_use_trace_store_1.recordToolUseStart)({
|
||||||
|
sessionKey: ctx.sessionKey,
|
||||||
|
toolName: event.toolName,
|
||||||
|
toolParams: event.params,
|
||||||
|
toolCallId: event.toolCallId ?? ctx.toolCallId,
|
||||||
|
runId: event.runId ?? ctx.runId,
|
||||||
|
});
|
||||||
|
if (!event.toolName.startsWith('feishu_'))
|
||||||
|
return;
|
||||||
|
const paramsPreview = (0, reasoning_utils_1.sanitizeParamsForLog)(event.params);
|
||||||
|
log.info(`tool call: ${event.toolName} session=${ctx.sessionKey ?? '-'} params=${paramsPreview}`);
|
||||||
|
});
|
||||||
|
api.on('after_tool_call', (event, ctx) => {
|
||||||
|
(0, tool_use_trace_store_1.recordToolUseEnd)({
|
||||||
|
sessionKey: ctx.sessionKey,
|
||||||
|
toolName: event.toolName,
|
||||||
|
toolParams: event.params,
|
||||||
|
toolCallId: event.toolCallId ?? ctx.toolCallId,
|
||||||
|
runId: event.runId ?? ctx.runId,
|
||||||
|
result: event.result,
|
||||||
|
error: event.error,
|
||||||
|
durationMs: event.durationMs,
|
||||||
|
});
|
||||||
|
if (!event.toolName.startsWith('feishu_'))
|
||||||
|
return;
|
||||||
|
if (event.error) {
|
||||||
|
log.error(`tool fail: ${event.toolName} session=${ctx.sessionKey ?? '-'} ${event.error} (${event.durationMs ?? 0}ms)`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info(`tool done: ${event.toolName} session=${ctx.sessionKey ?? '-'} 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 (0, diagnose_1.traceByMessageId)(opts.trace);
|
||||||
|
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||||
|
console.log((0, diagnose_1.formatTraceOutput)(lines, opts.trace));
|
||||||
|
if (opts.analyze && lines.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||||
|
console.log((0, diagnose_1.analyzeTrace)(lines, opts.trace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const report = await (0, diagnose_1.runDiagnosis)({
|
||||||
|
config: ctx.config,
|
||||||
|
logger: ctx.logger,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console -- CLI 命令直接输出到终端
|
||||||
|
console.log((0, diagnose_1.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
|
||||||
|
(0, index_3.registerCommands)(api);
|
||||||
|
// ---- Multi-account security checks ----
|
||||||
|
if (api.config) {
|
||||||
|
(0, security_check_1.emitSecurityWarnings)(api.config, api.logger);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
exports.default = plugin;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"id": "openclaw-lark",
|
||||||
|
"channels": [
|
||||||
|
"feishu"
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
"./skills"
|
||||||
|
],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"tools": [
|
||||||
|
"feishu_bitable_app",
|
||||||
|
"feishu_bitable_app_table",
|
||||||
|
"feishu_bitable_app_table_field",
|
||||||
|
"feishu_bitable_app_table_record",
|
||||||
|
"feishu_bitable_app_table_view",
|
||||||
|
"feishu_calendar_calendar",
|
||||||
|
"feishu_calendar_event",
|
||||||
|
"feishu_calendar_event_attendee",
|
||||||
|
"feishu_calendar_freebusy",
|
||||||
|
"feishu_chat",
|
||||||
|
"feishu_chat_members",
|
||||||
|
"feishu_create_doc",
|
||||||
|
"feishu_doc_comments",
|
||||||
|
"feishu_doc_media",
|
||||||
|
"feishu_drive_file",
|
||||||
|
"feishu_fetch_doc",
|
||||||
|
"feishu_get_user",
|
||||||
|
"feishu_im_bot_image",
|
||||||
|
"feishu_im_user_fetch_resource",
|
||||||
|
"feishu_im_user_get_messages",
|
||||||
|
"feishu_im_user_get_thread_messages",
|
||||||
|
"feishu_im_user_message",
|
||||||
|
"feishu_im_user_search_messages",
|
||||||
|
"feishu_oauth",
|
||||||
|
"feishu_oauth_batch_auth",
|
||||||
|
"feishu_search_doc_wiki",
|
||||||
|
"feishu_search_user",
|
||||||
|
"feishu_sheet",
|
||||||
|
"feishu_task_comment",
|
||||||
|
"feishu_task_subtask",
|
||||||
|
"feishu_task_task",
|
||||||
|
"feishu_task_agent",
|
||||||
|
"feishu_task_attachment",
|
||||||
|
"feishu_task_tasklist",
|
||||||
|
"feishu_update_doc",
|
||||||
|
"feishu_wiki_space",
|
||||||
|
"feishu_wiki_space_node",
|
||||||
|
"feishu_task_section",
|
||||||
|
"feishu_ask_user_question"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"channelConfigs": {
|
||||||
|
"feishu": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@larksuite/openclaw-lark",
|
||||||
|
"version": "2026.6.10",
|
||||||
|
"description": "OpenClaw Lark/Feishu channel plugin",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"default": "./dist/index.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"openclaw-lark": "bin/openclaw-lark.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"packageManager": "pnpm@10.32.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@larksuiteoapi/node-sdk": "^1.64.0",
|
||||||
|
"@sinclair/typebox": "0.34.49",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
|
"undici-types": "^8.1.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": ">=2026.5.4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"openclaw": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Feishu channel secret-contract registration. Declares which fields are
|
||||||
|
* SecretRef-shaped so OpenClaw's runtime resolves them at startup.
|
||||||
|
*/
|
||||||
|
import type { ResolverContext, SecretDefaults, SecretTargetRegistryEntry } from 'openclaw/plugin-sdk/channel-secret-basic-runtime';
|
||||||
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
||||||
|
export declare const secretTargetRegistryEntries: readonly SecretTargetRegistryEntry[];
|
||||||
|
export declare function collectRuntimeConfigAssignments(params: {
|
||||||
|
config: OpenClawConfig;
|
||||||
|
defaults: SecretDefaults | undefined;
|
||||||
|
context: ResolverContext;
|
||||||
|
}): void;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Feishu channel secret-contract registration. Declares which fields are
|
||||||
|
* SecretRef-shaped so OpenClaw's runtime resolves them at startup.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.secretTargetRegistryEntries = void 0;
|
||||||
|
exports.collectRuntimeConfigAssignments = collectRuntimeConfigAssignments;
|
||||||
|
const channel_secret_basic_runtime_1 = require("openclaw/plugin-sdk/channel-secret-basic-runtime");
|
||||||
|
const SECRET_FIELDS = ['appSecret', 'encryptKey', 'verificationToken'];
|
||||||
|
/** Fields the Lark SDK only consumes when an account is in webhook mode. */
|
||||||
|
const WEBHOOK_ONLY_FIELDS = ['encryptKey', 'verificationToken'];
|
||||||
|
exports.secretTargetRegistryEntries = SECRET_FIELDS.flatMap((field) => {
|
||||||
|
const acctPath = `channels.feishu.accounts.*.${field}`;
|
||||||
|
const topPath = `channels.feishu.${field}`;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: acctPath,
|
||||||
|
targetType: acctPath,
|
||||||
|
configFile: 'openclaw.json',
|
||||||
|
pathPattern: acctPath,
|
||||||
|
secretShape: 'secret_input',
|
||||||
|
expectedResolvedValue: 'string',
|
||||||
|
includeInPlan: true,
|
||||||
|
includeInConfigure: true,
|
||||||
|
includeInAudit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: topPath,
|
||||||
|
targetType: topPath,
|
||||||
|
configFile: 'openclaw.json',
|
||||||
|
pathPattern: topPath,
|
||||||
|
secretShape: 'secret_input',
|
||||||
|
expectedResolvedValue: 'string',
|
||||||
|
includeInPlan: true,
|
||||||
|
includeInConfigure: true,
|
||||||
|
includeInAudit: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
function collectRuntimeConfigAssignments(params) {
|
||||||
|
const resolved = (0, channel_secret_basic_runtime_1.getChannelSurface)(params.config, 'feishu');
|
||||||
|
if (!resolved)
|
||||||
|
return;
|
||||||
|
const { channel, surface } = resolved;
|
||||||
|
(0, channel_secret_basic_runtime_1.collectSimpleChannelFieldAssignments)({
|
||||||
|
channelKey: 'feishu',
|
||||||
|
field: 'appSecret',
|
||||||
|
channel,
|
||||||
|
surface,
|
||||||
|
defaults: params.defaults,
|
||||||
|
context: params.context,
|
||||||
|
topInactiveReason: 'no enabled Feishu account inherits this top-level appSecret.',
|
||||||
|
accountInactiveReason: 'Feishu account is disabled.',
|
||||||
|
});
|
||||||
|
const baseConnectionMode = (0, channel_secret_basic_runtime_1.normalizeSecretStringValue)(channel.connectionMode) === 'webhook' ? 'webhook' : 'websocket';
|
||||||
|
const resolveAccountMode = (account) => (0, channel_secret_basic_runtime_1.hasOwnProperty)(account, 'connectionMode')
|
||||||
|
? (0, channel_secret_basic_runtime_1.normalizeSecretStringValue)(account.connectionMode)
|
||||||
|
: baseConnectionMode;
|
||||||
|
for (const field of WEBHOOK_ONLY_FIELDS) {
|
||||||
|
(0, channel_secret_basic_runtime_1.collectConditionalChannelFieldAssignments)({
|
||||||
|
channelKey: 'feishu',
|
||||||
|
field,
|
||||||
|
channel,
|
||||||
|
surface,
|
||||||
|
defaults: params.defaults,
|
||||||
|
context: params.context,
|
||||||
|
topLevelActiveWithoutAccounts: baseConnectionMode === 'webhook',
|
||||||
|
topLevelInheritedAccountActive: ({ account, enabled }) => enabled && !(0, channel_secret_basic_runtime_1.hasOwnProperty)(account, field) && resolveAccountMode(account) === 'webhook',
|
||||||
|
accountActive: ({ account, enabled }) => enabled && resolveAccountMode(account) === 'webhook',
|
||||||
|
topInactiveReason: `no enabled Feishu webhook-mode surface inherits this top-level ${field}.`,
|
||||||
|
accountInactiveReason: 'Feishu account is disabled or not running in webhook mode.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,242 @@
|
|||||||
|
---
|
||||||
|
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[] | - | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心约束(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"`
|
||||||
|
|
||||||
|
⚠️ **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_*` 系列 |
|
||||||
@@ -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 授权且是会话成员 |
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
---
|
||||||
|
name: feishu-task
|
||||||
|
description: |
|
||||||
|
飞书任务管理工具,用于创建、查询、更新任务和清单。
|
||||||
|
|
||||||
|
**当以下情况时使用此 Skill**:
|
||||||
|
(1) 需要创建、查询、更新任务
|
||||||
|
(2) 需要创建、管理任务清单
|
||||||
|
(3) 需要查看任务列表或清单内的任务
|
||||||
|
(4) 用户提到"任务"、"待办"、"to-do"、"清单"、"task"
|
||||||
|
(5) 需要设置任务负责人、关注人、截止时间、添加成员
|
||||||
|
(6) 需要追加任务步骤记录(Task 的 steps)
|
||||||
|
(7) 需要上传任务附件(支持 task / task_delivery)
|
||||||
|
(8) 需要注册 Agent / 更新 Agent 信息(register / update_profile)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 飞书任务管理
|
||||||
|
|
||||||
|
## 🚨 执行前必读
|
||||||
|
|
||||||
|
- ✅ **时间格式**:ISO 8601 / RFC 3339(带时区),例如 `2026-02-28T17:00:00+08:00`
|
||||||
|
- ✅ **身份授权**:工具支持 `auth_type` 为 `user`(默认,用户身份)或 `tenant`(应用身份)。
|
||||||
|
- ✅ **任务 Agent(feishu_task_agent)**:仅支持应用身份(tenant),不支持 user 身份
|
||||||
|
- ✅ **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"
|
||||||
|
- ✅ **append_steps 的 task_steps[].timestamp**:秒级 Unix 时间戳(10 位),不要用毫秒(13 位)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 快速索引:意图 → 工具 → 必填参数
|
||||||
|
|
||||||
|
| 用户意图 | 工具 | action | 必填参数 | 强烈建议 | 常用可选 |
|
||||||
|
|---------|------|--------|---------|---------|---------|
|
||||||
|
| 新建待办 | feishu_task_task | create | summary | current_user_id(SenderId) | members, due, description, auth_type |
|
||||||
|
| 查未完成任务 | feishu_task_task | list | - | completed=false | page_size, auth_type, agent_task_status |
|
||||||
|
| 获取任务详情 | feishu_task_task | get | task_guid | - | auth_type |
|
||||||
|
| 完成任务 | feishu_task_task | patch | task_guid, completed_at | - | auth_type |
|
||||||
|
| 反完成任务 | feishu_task_task | patch | task_guid, completed_at="0" | - | auth_type |
|
||||||
|
| 改截止时间 | feishu_task_task | patch | task_guid, due | - | auth_type |
|
||||||
|
| 添加任务成员 | feishu_task_task | add_members | task_guid, members[] | - | auth_type |
|
||||||
|
| 追加任务步骤记录 | feishu_task_task | append_steps | task_guid, idempotent_key, task_steps[] | task_steps[].timestamp 用秒级(10 位) | - |
|
||||||
|
| 创建清单 | feishu_task_tasklist | create | name | - | members |
|
||||||
|
| 查看清单任务 | feishu_task_tasklist | tasks | tasklist_guid | - | completed |
|
||||||
|
| 添加清单成员 | feishu_task_tasklist | add_members | tasklist_guid, members[] | - | - |
|
||||||
|
| 上传任务附件 | feishu_task_attachment | upload | resource_id, file(base64) | name | resource_type |
|
||||||
|
| 注册任务 Agent | feishu_task_agent | register | - | 仅支持 tenant(应用身份) | - |
|
||||||
|
| 更新任务 Agent Profile | feishu_task_agent | update_profile | profile_content | 仅支持 tenant(应用身份) | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 核心约束(Schema 未透露的知识)
|
||||||
|
|
||||||
|
### 1. 授权身份与可见性 (auth_type)
|
||||||
|
|
||||||
|
**工具支持两种调用身份 `auth_type`**:
|
||||||
|
- **`user` (默认)**:用户身份(user_access_token)。用于需要严格代表用户操作或查询用户私有任务的场景。
|
||||||
|
- ⚠️ 使用 `user` 身份时,只能查看和编辑**自己是成员的任务**。
|
||||||
|
- ⚠️ **如果创建时没把自己加入成员,后续无法编辑该任务**。
|
||||||
|
- **`tenant`**:应用身份(tenant_access_token)。当用户身份不满足要求时,使用应用身份。如果创建的任务没有把用户加入成员,用户可能看不见。
|
||||||
|
|
||||||
|
**自动保护机制**:
|
||||||
|
- 传入 `current_user_id` 参数(从 SenderId 获取)
|
||||||
|
- 如果 `members` 中不包含 `current_user_id`,工具会**自动添加为 follower**
|
||||||
|
- 确保创建者始终可以编辑和查看任务
|
||||||
|
|
||||||
|
### 2. 任务成员的角色与类型
|
||||||
|
|
||||||
|
- **角色 (role)**:
|
||||||
|
- **assignee(负责人)**:负责完成任务,可以编辑任务
|
||||||
|
- **follower(关注人)**:关注任务进展,接收通知
|
||||||
|
- **类型 (type)**:
|
||||||
|
- **user(默认,用户)**:普通的飞书用户
|
||||||
|
- **app(应用/机器人)**:如果是把机器人自己或者其他应用加入任务,必须指定 `type: "app"`
|
||||||
|
|
||||||
|
**添加成员示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"members": [
|
||||||
|
{"id": "ou_xxx", "role": "assignee", "type": "user"}, // 负责人(用户)
|
||||||
|
{"id": "cli_yyy", "role": "follower", "type": "app"} // 关注人(机器人/应用)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:`id` 默认使用 `open_id`。
|
||||||
|
|
||||||
|
### 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",
|
||||||
|
"auth_type": "tenant",
|
||||||
|
"due": {
|
||||||
|
"timestamp": "2026-02-28 17:00:00",
|
||||||
|
"is_all_day": false
|
||||||
|
},
|
||||||
|
"members": [
|
||||||
|
{"id": "ou_协作者的open_id", "role": "assignee", "type": "user"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- `summary` 是必填字段
|
||||||
|
- `current_user_id` 强烈建议传入(从 SenderId 获取),工具会自动添加为 follower
|
||||||
|
- `members` 可以只包含其他协作者,当前用户会被自动添加
|
||||||
|
- 时间使用带时区的 ISO 8601 格式
|
||||||
|
|
||||||
|
### 场景 2: 查询我负责的未完成任务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "list",
|
||||||
|
"completed": false,
|
||||||
|
"page_size": 20,
|
||||||
|
"auth_type": "user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 为现有任务添加机器人或成员
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "add_members",
|
||||||
|
"task_guid": "任务的guid",
|
||||||
|
"auth_type": "tenant",
|
||||||
|
"members": [
|
||||||
|
{"id": "cli_机器人的app_id", "role": "follower", "type": "app"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 4: 完成任务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "patch",
|
||||||
|
"task_guid": "任务的guid",
|
||||||
|
"completed_at": "2026-02-26 15:30:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 5: 反完成任务(恢复未完成状态)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "patch",
|
||||||
|
"task_guid": "任务的guid",
|
||||||
|
"completed_at": "0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 6: 创建清单并添加协作者
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"name": "产品迭代 v2.0",
|
||||||
|
"members": [
|
||||||
|
{"id": "ou_xxx", "role": "editor"},
|
||||||
|
{"id": "ou_yyy", "role": "viewer"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 7: 查看清单内的未完成任务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "tasks",
|
||||||
|
"tasklist_guid": "清单的guid",
|
||||||
|
"completed": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 8: 全天任务
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"summary": "年度总结",
|
||||||
|
"due": {
|
||||||
|
"timestamp": "2026-03-01 00:00:00",
|
||||||
|
"is_all_day": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 9: 注册任务 Agent(仅应用身份)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "register",
|
||||||
|
"auth_type": "tenant"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 10: 更新任务 Agent Profile(仅应用身份)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "update_profile",
|
||||||
|
"auth_type": "tenant",
|
||||||
|
"profile_content": "some profile content"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 常见错误与排查
|
||||||
|
|
||||||
|
| 错误现象 | 根本原因 | 解决方案 |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| **创建后无法编辑任务** | 创建时未将自己加入 members | 创建时至少将当前用户(SenderId)加为 assignee 或 follower |
|
||||||
|
| **patch 失败提示 task_guid 缺失** | 未传 task_guid 参数 | patch/get/add_members 必须传 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` |
|
||||||
|
| **添加机器人失败** | 未指定成员 type 为 app | 将机器人的 type 指定为 `"app"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 附录:背景知识
|
||||||
|
|
||||||
|
### 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 工具文档
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { FooterSessionMetrics } from './reply-dispatcher-types';
|
||||||
|
import { type ToolUseDisplayStep } from './tool-use-display';
|
||||||
|
/**
|
||||||
|
* 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 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 tool-use duration into a human-readable i18n pair.
|
||||||
|
*/
|
||||||
|
export declare function formatToolUseDuration(ms: number): {
|
||||||
|
zh: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Format milliseconds into a human-readable duration string.
|
||||||
|
*/
|
||||||
|
export declare function formatElapsed(ms: number): string;
|
||||||
|
export declare function compactNumber(value: number): string;
|
||||||
|
export declare function formatFooterRuntimeSegments(params: {
|
||||||
|
footer?: {
|
||||||
|
status?: boolean;
|
||||||
|
elapsed?: boolean;
|
||||||
|
tokens?: boolean;
|
||||||
|
cache?: boolean;
|
||||||
|
context?: boolean;
|
||||||
|
model?: boolean;
|
||||||
|
};
|
||||||
|
metrics?: FooterSessionMetrics;
|
||||||
|
elapsedMs?: number;
|
||||||
|
isError?: boolean;
|
||||||
|
isAborted?: boolean;
|
||||||
|
}): {
|
||||||
|
primaryZh: string[];
|
||||||
|
primaryEn: string[];
|
||||||
|
detailZh: string[];
|
||||||
|
detailEn: 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;
|
||||||
|
toolUseSteps?: ToolUseDisplayStep[];
|
||||||
|
toolUseTitleSuffix?: {
|
||||||
|
zh: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
toolUseElapsedMs?: number;
|
||||||
|
showToolUse?: boolean;
|
||||||
|
confirmData?: ConfirmData;
|
||||||
|
elapsedMs?: number;
|
||||||
|
isError?: boolean;
|
||||||
|
isAborted?: boolean;
|
||||||
|
footer?: {
|
||||||
|
status?: boolean;
|
||||||
|
elapsed?: boolean;
|
||||||
|
tokens?: boolean;
|
||||||
|
cache?: boolean;
|
||||||
|
context?: boolean;
|
||||||
|
model?: boolean;
|
||||||
|
};
|
||||||
|
footerMetrics?: FooterSessionMetrics;
|
||||||
|
}): FeishuCard;
|
||||||
|
/**
|
||||||
|
* Convert an old-format FeishuCard to CardKit JSON 2.0 format.
|
||||||
|
* JSON 2.0 uses `body.elements` instead of top-level `elements`.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Build the initial CardKit 2.0 streaming card with a loading icon.
|
||||||
|
* Optionally includes a tool-use pending panel above the streaming area.
|
||||||
|
*/
|
||||||
|
export declare function buildStreamingThinkingCard(showToolUse?: boolean): Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Build a CardKit 2.0 card for the pre-answer streaming phase.
|
||||||
|
* Used both for the initial card and for live updates during tool calls.
|
||||||
|
*/
|
||||||
|
export declare function buildStreamingPreAnswerCard(params: {
|
||||||
|
steps?: ToolUseDisplayStep[];
|
||||||
|
elapsedMs?: number;
|
||||||
|
showToolUse?: boolean;
|
||||||
|
}): Record<string, unknown>;
|
||||||
|
export declare function toCardKit2(card: FeishuCard): Record<string, unknown>;
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
"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).
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.REASONING_ELEMENT_ID = exports.STREAMING_ELEMENT_ID = void 0;
|
||||||
|
exports.splitReasoningText = splitReasoningText;
|
||||||
|
exports.stripReasoningTags = stripReasoningTags;
|
||||||
|
exports.formatReasoningDuration = formatReasoningDuration;
|
||||||
|
exports.formatToolUseDuration = formatToolUseDuration;
|
||||||
|
exports.formatElapsed = formatElapsed;
|
||||||
|
exports.compactNumber = compactNumber;
|
||||||
|
exports.formatFooterRuntimeSegments = formatFooterRuntimeSegments;
|
||||||
|
exports.buildCardContent = buildCardContent;
|
||||||
|
exports.buildStreamingThinkingCard = buildStreamingThinkingCard;
|
||||||
|
exports.buildStreamingPreAnswerCard = buildStreamingPreAnswerCard;
|
||||||
|
exports.toCardKit2 = toCardKit2;
|
||||||
|
const markdown_style_1 = require("./markdown-style.js");
|
||||||
|
const tool_use_display_1 = require("./tool-use-display.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Element ID used for the streaming text area in cards. The CardKit
|
||||||
|
* `cardElement.content()` API targets this element for typewriter-effect
|
||||||
|
* streaming updates.
|
||||||
|
*/
|
||||||
|
exports.STREAMING_ELEMENT_ID = 'streaming_content';
|
||||||
|
exports.REASONING_ELEMENT_ID = 'reasoning_content';
|
||||||
|
const TOOL_USE_STEP_CONTENT_INDENT = '0px 0px 0px 22px';
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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()`.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
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" }
|
||||||
|
*/
|
||||||
|
function formatReasoningDuration(ms) {
|
||||||
|
const d = formatElapsed(ms);
|
||||||
|
return { zh: `思考了 ${d}`, en: `Thought for ${d}` };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Format tool-use duration into a human-readable i18n pair.
|
||||||
|
*/
|
||||||
|
function formatToolUseDuration(ms) {
|
||||||
|
const d = formatElapsed(ms);
|
||||||
|
return { zh: `执行耗时 ${d}`, en: `Tool use for ${d}` };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Format milliseconds into a human-readable duration string.
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function compactNumber(value) {
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
if (abs >= 1_000_000) {
|
||||||
|
const m = value / 1_000_000;
|
||||||
|
return Math.abs(m) >= 100 ? `${Math.round(m)}m` : `${m.toFixed(1)}m`;
|
||||||
|
}
|
||||||
|
if (abs >= 1_000) {
|
||||||
|
const k = value / 1_000;
|
||||||
|
return Math.abs(k) >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return `${Math.round(value)}`;
|
||||||
|
}
|
||||||
|
function formatFooterRuntimeSegments(params) {
|
||||||
|
const { footer, metrics, elapsedMs, isError, isAborted } = params;
|
||||||
|
const primaryZh = [];
|
||||||
|
const primaryEn = [];
|
||||||
|
const detailZh = [];
|
||||||
|
const detailEn = [];
|
||||||
|
// --- Primary line: status, elapsed, model ---
|
||||||
|
if (footer?.status) {
|
||||||
|
if (isError) {
|
||||||
|
primaryZh.push('出错');
|
||||||
|
primaryEn.push('Error');
|
||||||
|
}
|
||||||
|
else if (isAborted) {
|
||||||
|
primaryZh.push('已停止');
|
||||||
|
primaryEn.push('Stopped');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
primaryZh.push('已完成');
|
||||||
|
primaryEn.push('Completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer?.elapsed && elapsedMs != null) {
|
||||||
|
const d = formatElapsed(elapsedMs);
|
||||||
|
primaryZh.push(`耗时 ${d}`);
|
||||||
|
primaryEn.push(`Elapsed ${d}`);
|
||||||
|
}
|
||||||
|
if (footer?.model && metrics?.model) {
|
||||||
|
const model = metrics.model.trim();
|
||||||
|
if (model) {
|
||||||
|
primaryZh.push(model);
|
||||||
|
primaryEn.push(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- Detail line: tokens, cache, context ---
|
||||||
|
if (footer?.tokens && metrics) {
|
||||||
|
const inTokens = typeof metrics.inputTokens === 'number' ? Math.max(0, metrics.inputTokens) : undefined;
|
||||||
|
const outTokens = typeof metrics.outputTokens === 'number' ? Math.max(0, metrics.outputTokens) : undefined;
|
||||||
|
if (inTokens != null && outTokens != null) {
|
||||||
|
const inLabel = compactNumber(inTokens);
|
||||||
|
const outLabel = compactNumber(outTokens);
|
||||||
|
detailZh.push(`↑ ${inLabel} ↓ ${outLabel}`);
|
||||||
|
detailEn.push(`↑ ${inLabel} ↓ ${outLabel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer?.cache && metrics) {
|
||||||
|
const read = typeof metrics.cacheRead === 'number' ? Math.max(0, metrics.cacheRead) : undefined;
|
||||||
|
const write = typeof metrics.cacheWrite === 'number' ? Math.max(0, metrics.cacheWrite) : undefined;
|
||||||
|
const inputVal = typeof metrics.inputTokens === 'number' ? Math.max(0, metrics.inputTokens) : undefined;
|
||||||
|
if (read != null && write != null && inputVal != null) {
|
||||||
|
const total = read + write + inputVal;
|
||||||
|
const hit = total > 0 ? Math.round((read / total) * 100) : 0;
|
||||||
|
const left = compactNumber(read);
|
||||||
|
const right = compactNumber(write);
|
||||||
|
detailZh.push(`缓存 ${left}/${right} (${hit}%)`);
|
||||||
|
detailEn.push(`Cache ${left}/${right} (${hit}%)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer?.context && metrics) {
|
||||||
|
const freshTotal = metrics.totalTokensFresh === false ? undefined : metrics.totalTokens;
|
||||||
|
const total = typeof freshTotal === 'number' ? Math.max(0, freshTotal) : undefined;
|
||||||
|
const ctx = typeof metrics.contextTokens === 'number' ? Math.max(0, metrics.contextTokens) : undefined;
|
||||||
|
if (total != null && ctx != null) {
|
||||||
|
const totalLabel = compactNumber(total);
|
||||||
|
const ctxLabel = compactNumber(ctx);
|
||||||
|
const pct = ctx > 0 ? Math.round((total / ctx) * 100) : 0;
|
||||||
|
const pctLabel = `${pct}%`;
|
||||||
|
detailZh.push(`上下文 ${totalLabel}/${ctxLabel} (${pctLabel})`);
|
||||||
|
detailEn.push(`Context ${totalLabel}/${ctxLabel} (${pctLabel})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { primaryZh, primaryEn, detailZh, detailEn };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// buildCardContent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Build a full Feishu Interactive Message Card JSON object for the
|
||||||
|
* given state.
|
||||||
|
*/
|
||||||
|
function buildCardContent(state, data = {}) {
|
||||||
|
switch (state) {
|
||||||
|
case 'thinking':
|
||||||
|
return buildThinkingCard();
|
||||||
|
case 'streaming':
|
||||||
|
return buildStreamingCard(data.text ?? '', {
|
||||||
|
reasoningText: data.reasoningText,
|
||||||
|
showToolUse: data.showToolUse,
|
||||||
|
toolUseSteps: data.toolUseSteps,
|
||||||
|
toolUseTitleSuffix: data.toolUseTitleSuffix,
|
||||||
|
});
|
||||||
|
case 'complete':
|
||||||
|
return buildCompleteCard({
|
||||||
|
text: data.text ?? '',
|
||||||
|
elapsedMs: data.elapsedMs,
|
||||||
|
isError: data.isError,
|
||||||
|
reasoningText: data.reasoningText,
|
||||||
|
reasoningElapsedMs: data.reasoningElapsedMs,
|
||||||
|
toolUseSteps: data.toolUseSteps,
|
||||||
|
toolUseTitleSuffix: data.toolUseTitleSuffix,
|
||||||
|
toolUseElapsedMs: data.toolUseElapsedMs,
|
||||||
|
showToolUse: data.showToolUse,
|
||||||
|
isAborted: data.isAborted,
|
||||||
|
footer: data.footer,
|
||||||
|
footerMetrics: data.footerMetrics,
|
||||||
|
});
|
||||||
|
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, params = {}) {
|
||||||
|
const { showToolUse = true, toolUseSteps, toolUseTitleSuffix, reasoningText } = params;
|
||||||
|
const elements = [];
|
||||||
|
const hasToolUse = Boolean(toolUseSteps?.length);
|
||||||
|
if (showToolUse) {
|
||||||
|
elements.push(hasToolUse
|
||||||
|
? buildToolUsePanel({
|
||||||
|
toolUseSteps,
|
||||||
|
titleSuffix: toolUseTitleSuffix,
|
||||||
|
})
|
||||||
|
: buildStreamingToolUsePendingPanel());
|
||||||
|
}
|
||||||
|
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: (0, markdown_style_1.optimizeMarkdownStyle)(partialText),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
config: { wide_screen_mode: true, update_multi: true, locales: ['zh_cn', 'en_us'] },
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildCompleteCard(params) {
|
||||||
|
const { text, elapsedMs, isError, reasoningText, reasoningElapsedMs, toolUseSteps, toolUseTitleSuffix, toolUseElapsedMs, showToolUse = true, isAborted, footer, footerMetrics, } = params;
|
||||||
|
const elements = [];
|
||||||
|
if (showToolUse) {
|
||||||
|
elements.push(buildToolUsePanel({
|
||||||
|
toolUseSteps,
|
||||||
|
toolUseElapsedMs,
|
||||||
|
titleSuffix: toolUseTitleSuffix,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 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: (0, markdown_style_1.optimizeMarkdownStyle)(text),
|
||||||
|
});
|
||||||
|
// Footer meta-info: split into two lines for readability.
|
||||||
|
// Line 1 (primary): status · elapsed · model
|
||||||
|
// Line 2 (detail): tokens · cache · context
|
||||||
|
const fp = formatFooterRuntimeSegments({
|
||||||
|
footer,
|
||||||
|
metrics: footerMetrics,
|
||||||
|
elapsedMs,
|
||||||
|
isError,
|
||||||
|
isAborted,
|
||||||
|
});
|
||||||
|
const footerZhLines = [];
|
||||||
|
const footerEnLines = [];
|
||||||
|
if (fp.primaryZh.length > 0) {
|
||||||
|
footerZhLines.push(fp.primaryZh.join(' · '));
|
||||||
|
footerEnLines.push(fp.primaryEn.join(' · '));
|
||||||
|
}
|
||||||
|
if (fp.detailZh.length > 0) {
|
||||||
|
footerZhLines.push(fp.detailZh.join(' · '));
|
||||||
|
footerEnLines.push(fp.detailEn.join(' · '));
|
||||||
|
}
|
||||||
|
if (footerZhLines.length > 0) {
|
||||||
|
elements.push(...buildFooter(footerZhLines.join('\n'), footerEnLines.join('\n'), isError));
|
||||||
|
}
|
||||||
|
// Use the answer text 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`.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Build the initial CardKit 2.0 streaming card with a loading icon.
|
||||||
|
* Optionally includes a tool-use pending panel above the streaming area.
|
||||||
|
*/
|
||||||
|
function buildStreamingThinkingCard(showToolUse = true) {
|
||||||
|
return buildStreamingPreAnswerCard({ showToolUse });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build a CardKit 2.0 card for the pre-answer streaming phase.
|
||||||
|
* Used both for the initial card and for live updates during tool calls.
|
||||||
|
*/
|
||||||
|
function buildStreamingPreAnswerCard(params) {
|
||||||
|
const { steps, elapsedMs, showToolUse = true } = params;
|
||||||
|
const hasSteps = Boolean(steps?.length);
|
||||||
|
const elements = [];
|
||||||
|
if (showToolUse) {
|
||||||
|
elements.push(hasSteps ? buildStreamingToolUseActivePanel({ steps: steps, elapsedMs }) : buildStreamingToolUsePendingPanel());
|
||||||
|
}
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: '',
|
||||||
|
text_align: 'left',
|
||||||
|
text_size: 'normal_v2',
|
||||||
|
margin: '0px 0px 0px 0px',
|
||||||
|
element_id: exports.STREAMING_ELEMENT_ID,
|
||||||
|
});
|
||||||
|
elements.push({
|
||||||
|
tag: 'markdown',
|
||||||
|
content: ' ',
|
||||||
|
icon: {
|
||||||
|
tag: 'custom_icon',
|
||||||
|
img_key: 'img_v3_02vb_496bec09-4b43-4773-ad6b-0cdd103cd2bg',
|
||||||
|
size: '16px 16px',
|
||||||
|
},
|
||||||
|
element_id: 'loading_icon',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schema: '2.0',
|
||||||
|
config: {
|
||||||
|
streaming_mode: true,
|
||||||
|
locales: ['zh_cn', 'en_us'],
|
||||||
|
summary: {
|
||||||
|
content: 'Processing...',
|
||||||
|
i18n_content: { zh_cn: '处理中...', en_us: 'Processing...' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: { elements },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build the collapsible panel for the active pre-answer phase.
|
||||||
|
* Used by buildStreamingPreAnswerCard when at least one step exists.
|
||||||
|
*/
|
||||||
|
function buildStreamingToolUseActivePanel(params) {
|
||||||
|
const { steps, elapsedMs } = params;
|
||||||
|
const enParts = ['Tool use'];
|
||||||
|
const zhParts = ['工具执行'];
|
||||||
|
if (steps.length > 0) {
|
||||||
|
enParts.push(`${steps.length} step${steps.length === 1 ? '' : 's'}`);
|
||||||
|
zhParts.push(`${steps.length} 步`);
|
||||||
|
}
|
||||||
|
if (elapsedMs != null && elapsedMs > 0) {
|
||||||
|
const d = formatElapsed(elapsedMs);
|
||||||
|
enParts.push(`(${d})`);
|
||||||
|
zhParts.push(`(${d})`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: 'collapsible_panel',
|
||||||
|
expanded: true,
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: `🛠️ ${enParts.join(' · ')}`,
|
||||||
|
i18n_content: {
|
||||||
|
zh_cn: `🛠️ ${zhParts.join(' · ')}`,
|
||||||
|
en_us: `🛠️ ${enParts.join(' · ')}`,
|
||||||
|
},
|
||||||
|
text_color: 'grey',
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
vertical_align: 'center',
|
||||||
|
icon: {
|
||||||
|
tag: 'standard_icon',
|
||||||
|
token: 'down-small-ccm_outlined',
|
||||||
|
color: 'grey',
|
||||||
|
size: '16px 16px',
|
||||||
|
},
|
||||||
|
icon_position: 'right',
|
||||||
|
icon_expanded_angle: -180,
|
||||||
|
},
|
||||||
|
border: { color: 'grey', corner_radius: '5px' },
|
||||||
|
vertical_spacing: '4px',
|
||||||
|
padding: '8px 8px 8px 8px',
|
||||||
|
elements: steps.flatMap((step) => buildToolUseStepElements(step)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function toCardKit2(card) {
|
||||||
|
const result = {
|
||||||
|
schema: '2.0',
|
||||||
|
config: card.config,
|
||||||
|
body: { elements: card.elements },
|
||||||
|
};
|
||||||
|
if (card.header)
|
||||||
|
result.header = card.header;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function buildStreamingToolUsePendingPanel() {
|
||||||
|
return {
|
||||||
|
tag: 'collapsible_panel',
|
||||||
|
expanded: false,
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: '🛠️ Tool use pending',
|
||||||
|
i18n_content: {
|
||||||
|
zh_cn: '🛠️ 等待工具执行',
|
||||||
|
en_us: '🛠️ Tool use pending',
|
||||||
|
},
|
||||||
|
text_color: 'grey',
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
vertical_align: 'center',
|
||||||
|
icon: {
|
||||||
|
tag: 'standard_icon',
|
||||||
|
token: 'down-small-ccm_outlined',
|
||||||
|
color: 'grey',
|
||||||
|
size: '16px 16px',
|
||||||
|
},
|
||||||
|
icon_position: 'right',
|
||||||
|
icon_expanded_angle: -180,
|
||||||
|
},
|
||||||
|
border: { color: 'grey', corner_radius: '5px' },
|
||||||
|
vertical_spacing: '4px',
|
||||||
|
padding: '8px 8px 8px 8px',
|
||||||
|
elements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUsePanel(params) {
|
||||||
|
const { toolUseSteps = [], toolUseElapsedMs, titleSuffix } = params;
|
||||||
|
const duration = toolUseElapsedMs ? formatToolUseDuration(toolUseElapsedMs) : null;
|
||||||
|
const zhTitleParts = [duration?.zh ?? '工具执行'];
|
||||||
|
const enTitleParts = [duration?.en ?? 'Tool use'];
|
||||||
|
if (titleSuffix) {
|
||||||
|
zhTitleParts.push(titleSuffix.zh);
|
||||||
|
enTitleParts.push(titleSuffix.en);
|
||||||
|
}
|
||||||
|
const stepElements = toolUseSteps.length > 0
|
||||||
|
? toolUseSteps.flatMap((step) => buildToolUseStepElements(step))
|
||||||
|
: [buildToolUsePlaceholder()];
|
||||||
|
return {
|
||||||
|
tag: 'collapsible_panel',
|
||||||
|
expanded: false,
|
||||||
|
header: {
|
||||||
|
title: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: `🛠️ ${enTitleParts.join(' · ')}`,
|
||||||
|
i18n_content: {
|
||||||
|
zh_cn: `🛠️ ${zhTitleParts.join(' · ')}`,
|
||||||
|
en_us: `🛠️ ${enTitleParts.join(' · ')}`,
|
||||||
|
},
|
||||||
|
text_color: 'grey',
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
vertical_align: 'center',
|
||||||
|
icon: {
|
||||||
|
tag: 'standard_icon',
|
||||||
|
token: 'down-small-ccm_outlined',
|
||||||
|
color: 'grey',
|
||||||
|
size: '16px 16px',
|
||||||
|
},
|
||||||
|
icon_position: 'right',
|
||||||
|
icon_expanded_angle: -180,
|
||||||
|
},
|
||||||
|
border: { color: 'grey', corner_radius: '5px' },
|
||||||
|
vertical_spacing: '4px',
|
||||||
|
padding: '8px 8px 8px 8px',
|
||||||
|
elements: stepElements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseStepElements(step) {
|
||||||
|
const elements = [buildToolUseStepTitleElement(step)];
|
||||||
|
const detailElement = buildToolUseStepDetailElement(step);
|
||||||
|
if (detailElement) {
|
||||||
|
elements.push(detailElement);
|
||||||
|
}
|
||||||
|
const outputElement = buildToolUseStepOutputElement(step);
|
||||||
|
if (outputElement) {
|
||||||
|
elements.push(outputElement);
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
function buildToolUsePlaceholder(labels) {
|
||||||
|
const zh = labels?.zh ?? '暂无工具步骤';
|
||||||
|
const en = labels?.en ?? tool_use_display_1.EMPTY_TOOL_USE_PLACEHOLDER;
|
||||||
|
return {
|
||||||
|
tag: 'div',
|
||||||
|
text: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: en,
|
||||||
|
i18n_content: {
|
||||||
|
zh_cn: zh,
|
||||||
|
en_us: en,
|
||||||
|
},
|
||||||
|
text_color: 'grey',
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseStepTitleElement(step) {
|
||||||
|
return {
|
||||||
|
tag: 'div',
|
||||||
|
icon: {
|
||||||
|
tag: 'standard_icon',
|
||||||
|
token: step.iconToken,
|
||||||
|
color: 'grey',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
tag: 'lark_md',
|
||||||
|
content: buildToolUseStepTitleMarkdown(step),
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseStepTitleMarkdown(step) {
|
||||||
|
const status = formatToolUseStepStatus(step.status);
|
||||||
|
return (0, markdown_style_1.optimizeMarkdownStyle)(`**${escapeToolUseMarkdownText(step.title)}** · <font color='${status.color}'>${status.label}</font>`, 1);
|
||||||
|
}
|
||||||
|
function buildToolUseStepDetailElement(step) {
|
||||||
|
const detail = step.detail?.trim();
|
||||||
|
if (!detail)
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
tag: 'div',
|
||||||
|
margin: TOOL_USE_STEP_CONTENT_INDENT,
|
||||||
|
text: {
|
||||||
|
tag: 'plain_text',
|
||||||
|
content: detail,
|
||||||
|
text_color: 'grey',
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseStepOutputElement(step) {
|
||||||
|
const content = buildToolUseStepOutputMarkdown(step);
|
||||||
|
if (!content)
|
||||||
|
return undefined;
|
||||||
|
return {
|
||||||
|
tag: 'div',
|
||||||
|
margin: TOOL_USE_STEP_CONTENT_INDENT,
|
||||||
|
text: {
|
||||||
|
tag: 'lark_md',
|
||||||
|
content,
|
||||||
|
text_size: 'notation',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseStepOutputMarkdown(step) {
|
||||||
|
const lines = [];
|
||||||
|
if (step.errorBlock) {
|
||||||
|
lines.push('**Error**');
|
||||||
|
lines.push(formatToolUseCodeBlock(step.errorBlock.content, step.errorBlock.language));
|
||||||
|
}
|
||||||
|
else if (step.resultBlock) {
|
||||||
|
lines.push('**Result**');
|
||||||
|
lines.push(formatToolUseCodeBlock(step.resultBlock.content, step.resultBlock.language));
|
||||||
|
}
|
||||||
|
if (lines.length === 0)
|
||||||
|
return undefined;
|
||||||
|
return (0, markdown_style_1.optimizeMarkdownStyle)(lines.join('\n'), 1);
|
||||||
|
}
|
||||||
|
function formatToolUseStepStatus(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return { label: 'Running', color: 'turquoise' };
|
||||||
|
case 'error':
|
||||||
|
return { label: 'Failed', color: 'red' };
|
||||||
|
case 'success':
|
||||||
|
default:
|
||||||
|
return { label: 'Succeeded', color: 'green' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function formatToolUseCodeBlock(content, language) {
|
||||||
|
const normalized = content.replace(/\r\n/g, '\n').trim();
|
||||||
|
const fence = '`'.repeat(Math.max(3, longestBacktickRun(normalized) + 1));
|
||||||
|
return `${fence}${language}\n${normalized}\n${fence}`;
|
||||||
|
}
|
||||||
|
function longestBacktickRun(value) {
|
||||||
|
const matches = value.match(/`+/g) ?? [];
|
||||||
|
return matches.reduce((max, run) => Math.max(max, run.length), 0);
|
||||||
|
}
|
||||||
|
function escapeToolUseMarkdownText(value) {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>])/g, '\\$1');
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Unified card API error handling.
|
||||||
|
*
|
||||||
|
* Provides structured error class for CardKit API responses, sub-error
|
||||||
|
* parsing for the generic 230099 code, and helper predicates used by
|
||||||
|
* reply-dispatcher and streaming-card-controller.
|
||||||
|
*/
|
||||||
|
/** 卡片 API 级别错误码。 */
|
||||||
|
export declare const CARD_ERROR: {
|
||||||
|
/** 发送频率限制 */
|
||||||
|
readonly RATE_LIMITED: 230020;
|
||||||
|
/** 卡片内容创建失败(通用码,需检查子错误) */
|
||||||
|
readonly CARD_CONTENT_FAILED: 230099;
|
||||||
|
};
|
||||||
|
/** 230099 子错误码,嵌套在 msg 的 ErrCode 字段中。 */
|
||||||
|
export declare const CARD_CONTENT_SUB_ERROR: {
|
||||||
|
/** 卡片元素(表格等)数量超限 */
|
||||||
|
readonly ELEMENT_LIMIT: 11310;
|
||||||
|
};
|
||||||
|
export declare const FEISHU_CARD_TABLE_LIMIT = 3;
|
||||||
|
export interface MarkdownTableMatch {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
/** CardKit API 返回非零 code 时的结构化错误。 */
|
||||||
|
export declare class CardKitApiError extends Error {
|
||||||
|
readonly code: number;
|
||||||
|
readonly msg: string;
|
||||||
|
constructor(params: {
|
||||||
|
api: string;
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
context: string;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 从 msg 字符串中提取子错误码。
|
||||||
|
*
|
||||||
|
* 示例输入: "Failed to create card content, ext=ErrCode: 11310; ErrMsg: element exceeds the limit; code:230099"
|
||||||
|
* 返回 11310 或 null。
|
||||||
|
*/
|
||||||
|
export declare function extractSubCode(msg: string): number | null;
|
||||||
|
/**
|
||||||
|
* 从任意抛错对象中解析卡片 API 错误结构。
|
||||||
|
*
|
||||||
|
* 返回 { code, subCode, errMsg },如果无法提取 code 则返回 null。
|
||||||
|
*/
|
||||||
|
export declare function parseCardApiError(err: unknown): {
|
||||||
|
code: number;
|
||||||
|
subCode: number | null;
|
||||||
|
errMsg: string;
|
||||||
|
} | null;
|
||||||
|
/**
|
||||||
|
* 判断错误是否为卡片表格数量超限。
|
||||||
|
*
|
||||||
|
* 匹配条件:code 230099 + subCode 11310 + errMsg 含 "table number over limit"。
|
||||||
|
* 11310 是通用的元素超限码(也覆盖模板可见性、组件上限等),
|
||||||
|
* 必须同时检查 errMsg 确认是表格数量导致的。
|
||||||
|
*
|
||||||
|
* 实际错误格式(生产日志 2026-03-13):
|
||||||
|
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
|
||||||
|
*/
|
||||||
|
export declare function isCardTableLimitError(err: unknown): boolean;
|
||||||
|
/** 判断错误是否为卡片发送频率限制(230020)。 */
|
||||||
|
export declare function isCardRateLimitError(err: unknown): boolean;
|
||||||
|
/**
|
||||||
|
* 收集正文里可被飞书卡片实际渲染的 markdown 表格。
|
||||||
|
*
|
||||||
|
* 代码块里的示例表格不会被飞书解析成卡片表格元素,因此这里要先排除,
|
||||||
|
* 让 shouldUseCard() 预检和 sanitizeTextForCard() 降级逻辑使用同一份结果。
|
||||||
|
*/
|
||||||
|
export declare function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[];
|
||||||
|
/**
|
||||||
|
* 对多段 markdown 文本共享一个表格预算。
|
||||||
|
*
|
||||||
|
* 段落按数组顺序消耗额度,适合处理“reasoning + 正文”这类会被飞书
|
||||||
|
* 作为同一张卡片渲染的多块文本。
|
||||||
|
*/
|
||||||
|
export declare function sanitizeTextSegmentsForCard(texts: readonly string[], tableLimit?: number): string[];
|
||||||
|
/**
|
||||||
|
* 对正文中超出 tableLimit 的 markdown 表格降级为 code block,
|
||||||
|
* 避免飞书卡片因表格数超限触发 230099/11310。
|
||||||
|
*
|
||||||
|
* 前 tableLimit 张表格保持原样(可正常卡片渲染);
|
||||||
|
* 超出部分用反引号包裹,阻止飞书将其解析为卡片表格元素。
|
||||||
|
*/
|
||||||
|
export declare function sanitizeTextForCard(text: string, tableLimit?: number): string;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Unified card API error handling.
|
||||||
|
*
|
||||||
|
* Provides structured error class for CardKit API responses, sub-error
|
||||||
|
* parsing for the generic 230099 code, and helper predicates used by
|
||||||
|
* reply-dispatcher and streaming-card-controller.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.CardKitApiError = exports.FEISHU_CARD_TABLE_LIMIT = exports.CARD_CONTENT_SUB_ERROR = exports.CARD_ERROR = void 0;
|
||||||
|
exports.extractSubCode = extractSubCode;
|
||||||
|
exports.parseCardApiError = parseCardApiError;
|
||||||
|
exports.isCardTableLimitError = isCardTableLimitError;
|
||||||
|
exports.isCardRateLimitError = isCardRateLimitError;
|
||||||
|
exports.findMarkdownTablesOutsideCodeBlocks = findMarkdownTablesOutsideCodeBlocks;
|
||||||
|
exports.sanitizeTextSegmentsForCard = sanitizeTextSegmentsForCard;
|
||||||
|
exports.sanitizeTextForCard = sanitizeTextForCard;
|
||||||
|
const api_error_1 = require("../core/api-error.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error code constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** 卡片 API 级别错误码。 */
|
||||||
|
exports.CARD_ERROR = {
|
||||||
|
/** 发送频率限制 */
|
||||||
|
RATE_LIMITED: 230020,
|
||||||
|
/** 卡片内容创建失败(通用码,需检查子错误) */
|
||||||
|
CARD_CONTENT_FAILED: 230099,
|
||||||
|
};
|
||||||
|
/** 230099 子错误码,嵌套在 msg 的 ErrCode 字段中。 */
|
||||||
|
exports.CARD_CONTENT_SUB_ERROR = {
|
||||||
|
/** 卡片元素(表格等)数量超限 */
|
||||||
|
ELEMENT_LIMIT: 11310,
|
||||||
|
};
|
||||||
|
// 经验性的飞书卡片表格上限 -- 4+ 张触发 230099/11310(2026-03 实测)。
|
||||||
|
exports.FEISHU_CARD_TABLE_LIMIT = 3;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** CardKit API 返回非零 code 时的结构化错误。 */
|
||||||
|
class CardKitApiError extends Error {
|
||||||
|
code;
|
||||||
|
msg;
|
||||||
|
constructor(params) {
|
||||||
|
const { api, code, msg, context } = params;
|
||||||
|
super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`);
|
||||||
|
this.name = 'CardKitApiError';
|
||||||
|
this.code = code;
|
||||||
|
this.msg = msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.CardKitApiError = CardKitApiError;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-error extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 从 msg 字符串中提取子错误码。
|
||||||
|
*
|
||||||
|
* 示例输入: "Failed to create card content, ext=ErrCode: 11310; ErrMsg: element exceeds the limit; code:230099"
|
||||||
|
* 返回 11310 或 null。
|
||||||
|
*/
|
||||||
|
function extractSubCode(msg) {
|
||||||
|
const match = /ErrCode:\s*(\d+)/.exec(msg);
|
||||||
|
if (!match)
|
||||||
|
return null;
|
||||||
|
const code = Number(match[1]);
|
||||||
|
return Number.isFinite(code) ? code : null;
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Structured error parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 从任意抛错对象中解析卡片 API 错误结构。
|
||||||
|
*
|
||||||
|
* 返回 { code, subCode, errMsg },如果无法提取 code 则返回 null。
|
||||||
|
*/
|
||||||
|
function parseCardApiError(err) {
|
||||||
|
const code = (0, api_error_1.extractLarkApiCode)(err);
|
||||||
|
if (code === undefined)
|
||||||
|
return null;
|
||||||
|
// 按优先级提取 msg 文本
|
||||||
|
let errMsg = '';
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const e = err;
|
||||||
|
if (typeof e.msg === 'string') {
|
||||||
|
errMsg = e.msg;
|
||||||
|
}
|
||||||
|
else if (typeof e.response?.data?.msg === 'string') {
|
||||||
|
// Axios errors: response.data.msg carries the Feishu detail with ErrCode
|
||||||
|
errMsg = e.response.data.msg;
|
||||||
|
}
|
||||||
|
else if (typeof e.message === 'string') {
|
||||||
|
// Fallback to generic Error.message (e.g. CardKitApiError)
|
||||||
|
errMsg = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const subCode = extractSubCode(errMsg);
|
||||||
|
return { code, subCode, errMsg };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper predicates
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 判断错误是否为卡片表格数量超限。
|
||||||
|
*
|
||||||
|
* 匹配条件:code 230099 + subCode 11310 + errMsg 含 "table number over limit"。
|
||||||
|
* 11310 是通用的元素超限码(也覆盖模板可见性、组件上限等),
|
||||||
|
* 必须同时检查 errMsg 确认是表格数量导致的。
|
||||||
|
*
|
||||||
|
* 实际错误格式(生产日志 2026-03-13):
|
||||||
|
* "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
|
||||||
|
*/
|
||||||
|
function isCardTableLimitError(err) {
|
||||||
|
const parsed = parseCardApiError(err);
|
||||||
|
if (!parsed)
|
||||||
|
return false;
|
||||||
|
return (parsed.code === exports.CARD_ERROR.CARD_CONTENT_FAILED &&
|
||||||
|
parsed.subCode === exports.CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
|
||||||
|
/table number over limit/i.test(parsed.errMsg));
|
||||||
|
}
|
||||||
|
/** 判断错误是否为卡片发送频率限制(230020)。 */
|
||||||
|
function isCardRateLimitError(err) {
|
||||||
|
const parsed = parseCardApiError(err);
|
||||||
|
if (!parsed)
|
||||||
|
return false;
|
||||||
|
return parsed.code === exports.CARD_ERROR.RATE_LIMITED;
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Text sanitization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 收集正文里可被飞书卡片实际渲染的 markdown 表格。
|
||||||
|
*
|
||||||
|
* 代码块里的示例表格不会被飞书解析成卡片表格元素,因此这里要先排除,
|
||||||
|
* 让 shouldUseCard() 预检和 sanitizeTextForCard() 降级逻辑使用同一份结果。
|
||||||
|
*/
|
||||||
|
function findMarkdownTablesOutsideCodeBlocks(text) {
|
||||||
|
const codeBlockRanges = [];
|
||||||
|
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||||
|
let codeBlockMatch = codeBlockRegex.exec(text);
|
||||||
|
while (codeBlockMatch != null) {
|
||||||
|
codeBlockRanges.push({
|
||||||
|
start: codeBlockMatch.index,
|
||||||
|
end: codeBlockMatch.index + codeBlockMatch[0].length,
|
||||||
|
});
|
||||||
|
codeBlockMatch = codeBlockRegex.exec(text);
|
||||||
|
}
|
||||||
|
const isInsideCodeBlock = (idx) => codeBlockRanges.some((range) => idx >= range.start && idx < range.end);
|
||||||
|
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g;
|
||||||
|
const matches = [];
|
||||||
|
let tableMatch = tableRegex.exec(text);
|
||||||
|
while (tableMatch != null) {
|
||||||
|
if (!isInsideCodeBlock(tableMatch.index)) {
|
||||||
|
matches.push({
|
||||||
|
index: tableMatch.index,
|
||||||
|
length: tableMatch[0].length,
|
||||||
|
raw: tableMatch[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tableMatch = tableRegex.exec(text);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 对多段 markdown 文本共享一个表格预算。
|
||||||
|
*
|
||||||
|
* 段落按数组顺序消耗额度,适合处理“reasoning + 正文”这类会被飞书
|
||||||
|
* 作为同一张卡片渲染的多块文本。
|
||||||
|
*/
|
||||||
|
function sanitizeTextSegmentsForCard(texts, tableLimit = exports.FEISHU_CARD_TABLE_LIMIT) {
|
||||||
|
let remainingTableBudget = tableLimit;
|
||||||
|
return texts.map((text) => {
|
||||||
|
const matches = findMarkdownTablesOutsideCodeBlocks(text);
|
||||||
|
if (matches.length <= remainingTableBudget) {
|
||||||
|
remainingTableBudget -= matches.length;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const sanitizedText = wrapTablesBeyondLimit(text, matches, Math.max(remainingTableBudget, 0));
|
||||||
|
remainingTableBudget = 0;
|
||||||
|
return sanitizedText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 对正文中超出 tableLimit 的 markdown 表格降级为 code block,
|
||||||
|
* 避免飞书卡片因表格数超限触发 230099/11310。
|
||||||
|
*
|
||||||
|
* 前 tableLimit 张表格保持原样(可正常卡片渲染);
|
||||||
|
* 超出部分用反引号包裹,阻止飞书将其解析为卡片表格元素。
|
||||||
|
*/
|
||||||
|
function sanitizeTextForCard(text, tableLimit = exports.FEISHU_CARD_TABLE_LIMIT) {
|
||||||
|
return sanitizeTextSegmentsForCard([text], tableLimit)[0];
|
||||||
|
}
|
||||||
|
function wrapTablesBeyondLimit(text, matches, keepCount) {
|
||||||
|
if (matches.length <= keepCount)
|
||||||
|
return text;
|
||||||
|
// Back-to-front replacement keeps the original indices stable.
|
||||||
|
let result = text;
|
||||||
|
for (let i = matches.length - 1; i >= keepCount; i--) {
|
||||||
|
const { index, length, raw } = matches[i];
|
||||||
|
const replacement = `\`\`\`\n${raw}\n\`\`\``;
|
||||||
|
result = result.slice(0, index) + replacement + result.slice(index + length);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+90
@@ -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>;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* CardKit streaming APIs for Lark/Feishu.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createCardEntity = createCardEntity;
|
||||||
|
exports.streamCardContent = streamCardContent;
|
||||||
|
exports.updateCardKitCard = updateCardKitCard;
|
||||||
|
exports.updateCardKitCardForAuth = updateCardKitCardForAuth;
|
||||||
|
exports.sendCardByCardId = sendCardByCardId;
|
||||||
|
exports.setCardStreamingMode = setCardStreamingMode;
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const message_unavailable_1 = require("../core/message-unavailable.js");
|
||||||
|
const targets_1 = require("../core/targets.js");
|
||||||
|
const card_error_1 = require("./card-error.js");
|
||||||
|
const log = (0, lark_logger_1.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 card_error_1.CardKitApiError({ api, 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.
|
||||||
|
*/
|
||||||
|
async function createCardEntity(params) {
|
||||||
|
const { cfg, card, accountId } = params;
|
||||||
|
const client = lark_client_1.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.
|
||||||
|
*/
|
||||||
|
async function streamCardContent(params) {
|
||||||
|
const { cfg, cardId, elementId, content, sequence, accountId } = params;
|
||||||
|
const client = lark_client_1.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.
|
||||||
|
*/
|
||||||
|
async function updateCardKitCard(params) {
|
||||||
|
const { cfg, cardId, card, sequence, accountId } = params;
|
||||||
|
const client = lark_client_1.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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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().
|
||||||
|
*/
|
||||||
|
async function sendCardByCardId(params) {
|
||||||
|
const { cfg, to, cardId, replyToMessageId, replyInThread, accountId } = params;
|
||||||
|
const client = lark_client_1.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 = (0, targets_1.normalizeMessageId)(replyToMessageId);
|
||||||
|
const response = await (0, message_unavailable_1.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 = (0, targets_1.normalizeFeishuTarget)(to);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(`[feishu-send] Invalid target: "${to}"`);
|
||||||
|
}
|
||||||
|
const receiveIdType = (0, targets_1.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.).
|
||||||
|
*/
|
||||||
|
async function setCardStreamingMode(params) {
|
||||||
|
const { cfg, cardId, streamingMode, sequence, accountId } = params;
|
||||||
|
const client = lark_client_1.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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.FlushController = void 0;
|
||||||
|
const reply_dispatcher_types_1 = require("./reply-dispatcher-types.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FlushController
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 > reply_dispatcher_types_1.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();
|
||||||
|
}, reply_dispatcher_types_1.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.FlushController = FlushController;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ImageResolver = void 0;
|
||||||
|
const media_1 = require("../messaging/outbound/media.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('card/image-resolver');
|
||||||
|
/** Matches complete markdown image syntax: `` */
|
||||||
|
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
||||||
|
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 (0, media_1.fetchRemoteImageBuffer)(url);
|
||||||
|
const { imageKey } = await (0, media_1.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.ImageResolver = ImageResolver;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Markdown 样式优化工具
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.optimizeMarkdownStyle = optimizeMarkdownStyle;
|
||||||
|
/**
|
||||||
|
* 优化 Markdown 样式:
|
||||||
|
* - 标题降级:H1 → H4,H2~H6 → H5
|
||||||
|
* - 表格前后增加段落间距
|
||||||
|
* - 有序列表:序号后确保只有一个空格
|
||||||
|
* - 无序列表:"- " 格式规范化(跳过分隔线 ---)
|
||||||
|
* - 表格:单元格前后补空格,分隔符行规范化,表格前后加空行
|
||||||
|
* - 代码块内容不受影响
|
||||||
|
*/
|
||||||
|
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(/(^|\n)(`{3,})([^\n]*)\n[\s\S]*?\n\2(?=\n|$)/g, (m, prefix = '') => {
|
||||||
|
const block = m.slice(String(prefix).length);
|
||||||
|
return `${prefix}${MARK}${codeBlocks.push(block) - 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, (m, _table, offset) => {
|
||||||
|
const after = r.slice(offset + m.length).replace(/^\n+/, '');
|
||||||
|
if (!after || /^(---|#{4,5} |\*\*)/.test(after))
|
||||||
|
return m;
|
||||||
|
return m + '\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.)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared utilities for the reasoning display subsystem.
|
||||||
|
*/
|
||||||
|
export declare function normalizeToolName(name?: string): string;
|
||||||
|
export declare function truncateText(value: string, maxLength: number): string;
|
||||||
|
export declare function redactInlineSecrets(value: string): string;
|
||||||
|
/**
|
||||||
|
* Sanitize tool params for safe logging.
|
||||||
|
* Logs only param key names (no values) to avoid leaking sensitive data.
|
||||||
|
*/
|
||||||
|
export declare function sanitizeParamsForLog(params?: Record<string, unknown>): string;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared utilities for the reasoning display subsystem.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.normalizeToolName = normalizeToolName;
|
||||||
|
exports.truncateText = truncateText;
|
||||||
|
exports.redactInlineSecrets = redactInlineSecrets;
|
||||||
|
exports.sanitizeParamsForLog = sanitizeParamsForLog;
|
||||||
|
function normalizeToolName(name) {
|
||||||
|
return name?.trim().toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
function truncateText(value, maxLength) {
|
||||||
|
if (value.length <= maxLength)
|
||||||
|
return value;
|
||||||
|
return `${value.slice(0, maxLength - 3)}...`;
|
||||||
|
}
|
||||||
|
const INLINE_ASSIGNMENT_RE = /(^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)(=(?:"[^"]*"|'[^']*'|[^\s"'`]+))/g;
|
||||||
|
const AUTH_HEADER_SECRET_RE = /(Authorization\s*:\s*(?:Bearer|Basic|Token)\s+)([^'"\s]+)/gi;
|
||||||
|
const QUOTED_HEADER_ARG_RE = /((?:^|[\s"'`])(?:-H|--header)\s+)(['"])([A-Za-z0-9_-]+)(\s*:\s*)([^'"]*)(\2)/gi;
|
||||||
|
const UNQUOTED_HEADER_ARG_RE = /((?:^|[\s"'`])(?:-H|--header)\s+)([A-Za-z0-9_-]+)(\s*:\s*)([^\s"'`]+)/gi;
|
||||||
|
const SECRET_FLAG_RE = /((?:^|[\s"'`]))(--?[A-Za-z0-9][A-Za-z0-9-]*)(=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s"'`]+))/g;
|
||||||
|
const SENSITIVE_NAME_RE = /token|secret|password|api[_-]?key|authorization|cookie|credential|bearer|session[_-]?id|client[_-]?secret|access[_-]?key/i;
|
||||||
|
function redactInlineSecrets(value) {
|
||||||
|
return value
|
||||||
|
.replace(INLINE_ASSIGNMENT_RE, (match, prefix, key) => isSensitiveName(key) ? `${prefix}${key}=[redacted]` : match)
|
||||||
|
.replace(AUTH_HEADER_SECRET_RE, '$1[redacted]')
|
||||||
|
.replace(QUOTED_HEADER_ARG_RE, (match, prefix, quote, name, separator) => shouldRedactHeaderValue(name) ? `${prefix}${quote}${name}${separator}[redacted]${quote}` : match)
|
||||||
|
.replace(UNQUOTED_HEADER_ARG_RE, (match, prefix, name, separator) => shouldRedactHeaderValue(name) ? `${prefix}${name}${separator}[redacted]` : match)
|
||||||
|
.replace(SECRET_FLAG_RE, (match, prefix, flag, separator, doubleQuoted, singleQuoted, bare) => {
|
||||||
|
const normalizedFlag = flag.replace(/^-+/, '');
|
||||||
|
if (!isSensitiveName(normalizedFlag))
|
||||||
|
return match;
|
||||||
|
const redactedValue = doubleQuoted !== undefined
|
||||||
|
? '"[redacted]"'
|
||||||
|
: singleQuoted !== undefined
|
||||||
|
? "'[redacted]'"
|
||||||
|
: bare !== undefined
|
||||||
|
? '[redacted]'
|
||||||
|
: '[redacted]';
|
||||||
|
return `${prefix}${flag}${separator}${redactedValue}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function isSensitiveName(value) {
|
||||||
|
return SENSITIVE_NAME_RE.test(value);
|
||||||
|
}
|
||||||
|
function shouldRedactHeaderValue(name) {
|
||||||
|
return !/^authorization$/i.test(name) && isSensitiveName(name);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Sanitize tool params for safe logging.
|
||||||
|
* Logs only param key names (no values) to avoid leaking sensitive data.
|
||||||
|
*/
|
||||||
|
function sanitizeParamsForLog(params) {
|
||||||
|
if (!params || typeof params !== 'object')
|
||||||
|
return '';
|
||||||
|
const keys = Object.keys(params);
|
||||||
|
if (keys.length === 0)
|
||||||
|
return '{}';
|
||||||
|
return `{${keys.join(',')}}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from 'openclaw/plugin-sdk';
|
||||||
|
import type { ReplyDispatcher } from 'openclaw/plugin-sdk/reply-runtime';
|
||||||
|
import type { FeishuFooterConfig } from '../core/types';
|
||||||
|
import type { ToolUseDisplayConfig } from './tool-use-config';
|
||||||
|
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 ToolUseState {
|
||||||
|
startedAt: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
export interface StreamingTextState {
|
||||||
|
accumulatedText: string;
|
||||||
|
completedText: string;
|
||||||
|
streamingPrefix: string;
|
||||||
|
lastPartialText: string;
|
||||||
|
lastFlushedText: 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;
|
||||||
|
readonly REASONING_STATUS_MS: 1500;
|
||||||
|
};
|
||||||
|
export declare const EMPTY_REPLY_FALLBACK_TEXT = "Done.";
|
||||||
|
export interface CreateFeishuReplyDispatcherParams {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId: string;
|
||||||
|
sessionKey: 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;
|
||||||
|
/** Thread root id when the reply lives inside a thread; used for sentinel keying. */
|
||||||
|
threadId?: string;
|
||||||
|
toolUseDisplay: ToolUseDisplayConfig;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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 FooterSessionMetrics {
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
cacheRead?: number;
|
||||||
|
cacheWrite?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
totalTokensFresh?: boolean;
|
||||||
|
contextTokens?: number;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
export interface StreamingCardDeps {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
accountId: string | undefined;
|
||||||
|
chatId: string;
|
||||||
|
replyToMessageId: string | undefined;
|
||||||
|
replyInThread: boolean | undefined;
|
||||||
|
toolUseDisplay: ToolUseDisplayConfig;
|
||||||
|
resolvedFooter: Required<FeishuFooterConfig>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.EMPTY_REPLY_FALLBACK_TEXT = exports.THROTTLE_CONSTANTS = exports.PHASE_TRANSITIONS = exports.TERMINAL_PHASES = exports.CARD_PHASES = void 0;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CardPhase — explicit state machine replacing boolean flags
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
exports.CARD_PHASES = {
|
||||||
|
idle: 'idle',
|
||||||
|
creating: 'creating',
|
||||||
|
streaming: 'streaming',
|
||||||
|
completed: 'completed',
|
||||||
|
aborted: 'aborted',
|
||||||
|
terminated: 'terminated',
|
||||||
|
creation_failed: 'creation_failed',
|
||||||
|
};
|
||||||
|
exports.TERMINAL_PHASES = new Set([
|
||||||
|
'completed',
|
||||||
|
'aborted',
|
||||||
|
'terminated',
|
||||||
|
'creation_failed',
|
||||||
|
]);
|
||||||
|
exports.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.
|
||||||
|
*/
|
||||||
|
exports.THROTTLE_CONSTANTS = {
|
||||||
|
CARDKIT_MS: 100,
|
||||||
|
PATCH_MS: 1500,
|
||||||
|
LONG_GAP_THRESHOLD_MS: 2000,
|
||||||
|
BATCH_AFTER_GAP_MS: 300,
|
||||||
|
REASONING_STATUS_MS: 1500,
|
||||||
|
};
|
||||||
|
exports.EMPTY_REPLY_FALLBACK_TEXT = 'Done.';
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"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
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.createFeishuReplyDispatcher = createFeishuReplyDispatcher;
|
||||||
|
const channel_runtime_1 = require("openclaw/plugin-sdk/channel-runtime");
|
||||||
|
const channel_feedback_1 = require("openclaw/plugin-sdk/channel-feedback");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const footer_config_1 = require("../core/footer-config.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const deliver_1 = require("../messaging/outbound/deliver.js");
|
||||||
|
const send_1 = require("../messaging/outbound/send.js");
|
||||||
|
const typing_1 = require("../messaging/outbound/typing.js");
|
||||||
|
const builder_1 = require("./builder.js");
|
||||||
|
const card_error_1 = require("./card-error.js");
|
||||||
|
const reply_mode_1 = require("./reply-mode.js");
|
||||||
|
const streaming_card_controller_1 = require("./streaming-card-controller.js");
|
||||||
|
const unavailable_guard_1 = require("./unavailable-guard.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('card/reply-dispatcher');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createFeishuReplyDispatcher(params) {
|
||||||
|
const core = lark_client_1.LarkClient.runtime;
|
||||||
|
const { cfg, agentId, chatId, sessionKey, replyToMessageId, accountId, replyInThread, threadId } = params;
|
||||||
|
// Resolve account so we can read per-account config (e.g. replyMode)
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
|
||||||
|
const feishuCfg = account.config;
|
||||||
|
// accountScopedCfg 用于需要 account-level 覆盖的配置项(如 tableMode)
|
||||||
|
const accountScopedCfg = (0, accounts_1.createAccountScopedConfig)(cfg, account.accountId);
|
||||||
|
const prefixContext = (0, channel_runtime_1.createReplyPrefixContext)({ cfg, agentId });
|
||||||
|
// ---- Reply mode resolution ----
|
||||||
|
const chatType = params.chatType;
|
||||||
|
const effectiveReplyMode = (0, reply_mode_1.resolveReplyMode)({ feishuCfg, chatType });
|
||||||
|
const replyMode = (0, reply_mode_1.expandAutoMode)({
|
||||||
|
mode: effectiveReplyMode,
|
||||||
|
streaming: feishuCfg?.streaming,
|
||||||
|
chatType,
|
||||||
|
});
|
||||||
|
const useStreamingCards = replyMode === 'streaming';
|
||||||
|
// ---- Block streaming for static mode ----
|
||||||
|
const enableBlockStreaming = feishuCfg?.blockStreaming === true && !useStreamingCards;
|
||||||
|
const { toolUseDisplay } = params;
|
||||||
|
const resolvedFooter = (0, footer_config_1.resolveFooterConfig)(feishuCfg?.footer);
|
||||||
|
log.info('reply mode resolved', {
|
||||||
|
effectiveReplyMode,
|
||||||
|
replyMode,
|
||||||
|
chatType,
|
||||||
|
});
|
||||||
|
log.info('footer config resolved', {
|
||||||
|
accountId: account.accountId,
|
||||||
|
sessionKey,
|
||||||
|
chatType,
|
||||||
|
useStreamingCards,
|
||||||
|
rawFooter: feishuCfg?.footer ?? null,
|
||||||
|
resolvedFooter,
|
||||||
|
});
|
||||||
|
// ---- 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');
|
||||||
|
// 使用 accountScopedCfg 以支持 per-account tableMode 覆盖
|
||||||
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||||
|
cfg: accountScopedCfg,
|
||||||
|
channel: 'feishu',
|
||||||
|
});
|
||||||
|
// ---- Streaming card controller (instantiated only when needed) ----
|
||||||
|
const controller = useStreamingCards
|
||||||
|
? new streaming_card_controller_1.StreamingCardController({
|
||||||
|
cfg,
|
||||||
|
agentId,
|
||||||
|
sessionKey,
|
||||||
|
accountId,
|
||||||
|
chatId,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
toolUseDisplay,
|
||||||
|
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 unavailable_guard_1.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 = (0, channel_runtime_1.createTypingCallbacks)({
|
||||||
|
keepaliveIntervalMs: 0,
|
||||||
|
start: async () => {
|
||||||
|
if (shouldSkip('typing.start.precheck'))
|
||||||
|
return;
|
||||||
|
if (!replyToMessageId || typingStopped || params.skipTyping)
|
||||||
|
return;
|
||||||
|
if (typingState?.reactionId)
|
||||||
|
return;
|
||||||
|
typingState = await (0, typing_1.addTypingIndicator)({
|
||||||
|
cfg,
|
||||||
|
messageId: replyToMessageId,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
if (shouldSkip('typing.start.postcheck'))
|
||||||
|
return;
|
||||||
|
if (typingStopped && typingState) {
|
||||||
|
await (0, typing_1.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 (0, typing_1.removeTypingIndicator)({ cfg, state: typingState, accountId });
|
||||||
|
typingState = null;
|
||||||
|
log.info('removed typing indicator reaction');
|
||||||
|
},
|
||||||
|
onStartError: (err) => {
|
||||||
|
(0, channel_feedback_1.logTypingFailure)({
|
||||||
|
log: (message) => log.warn(message),
|
||||||
|
channel: 'feishu',
|
||||||
|
action: 'start',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onStopError: (err) => {
|
||||||
|
(0, channel_feedback_1.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, meta) => {
|
||||||
|
log.debug('deliver called', {
|
||||||
|
textPreview: payload.text?.slice(0, 100),
|
||||||
|
kind: meta?.kind,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// 提取文本和媒体 URL
|
||||||
|
const text = getVisiblePayloadText(payload);
|
||||||
|
const reasoningText = payload.isReasoning === true ? (payload.text ?? '') : '';
|
||||||
|
const payloadMediaUrls = payload.mediaUrls?.length
|
||||||
|
? payload.mediaUrls
|
||||||
|
: payload.mediaUrl
|
||||||
|
? [payload.mediaUrl]
|
||||||
|
: [];
|
||||||
|
if (!text.trim() && !reasoningText.trim() && payloadMediaUrls.length === 0) {
|
||||||
|
log.debug('deliver: empty text and no media, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ---- Streaming card mode ----
|
||||||
|
if (controller) {
|
||||||
|
if (meta?.kind === 'tool' && shouldRouteToolPayloadToCard(payload, toolUseDisplay.showToolUse)) {
|
||||||
|
await controller.onToolPayload(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controllerText = reasoningText.trim() ? reasoningText : text;
|
||||||
|
if (controllerText.trim()) {
|
||||||
|
await controller.ensureCardCreated();
|
||||||
|
if (controller.isTerminated)
|
||||||
|
return;
|
||||||
|
if (controller.cardMessageId) {
|
||||||
|
if (payload.isReasoning === true) {
|
||||||
|
await controller.onReasoningStream({ ...payload, text: controllerText });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await controller.onDeliver({ ...payload, text: controllerText });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Card creation failed — fall through to static delivery
|
||||||
|
log.warn('deliver: card creation failed, falling back to static delivery');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- Static text delivery ----
|
||||||
|
if (text.trim()) {
|
||||||
|
if ((0, reply_mode_1.shouldUseCard)(text)) {
|
||||||
|
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
||||||
|
log.info('deliver: sending card chunks', {
|
||||||
|
count: chunks.length,
|
||||||
|
chatId,
|
||||||
|
});
|
||||||
|
// Runtime fallback: shouldUseCard() 通过但 API 仍拒绝(表格数超限)
|
||||||
|
let cardTableLimitHit = false;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (cardTableLimitHit) {
|
||||||
|
// 已触发降级,后续 chunk 直接走纯文本
|
||||||
|
try {
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg,
|
||||||
|
to: chatId,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
accountId,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (fallbackErr) {
|
||||||
|
if (staticGuard?.terminate('deliver.textFallback', fallbackErr))
|
||||||
|
return;
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await (0, send_1.sendMarkdownCardFeishu)({
|
||||||
|
cfg,
|
||||||
|
to: chatId,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (staticGuard?.terminate('deliver.cardChunk', err))
|
||||||
|
return;
|
||||||
|
// 卡片表格数超出飞书限制 — 降级为纯文本
|
||||||
|
if ((0, card_error_1.isCardTableLimitError)(err)) {
|
||||||
|
log.warn('card table limit exceeded (230099/11310), falling back to text', { chatId });
|
||||||
|
cardTableLimitHit = true;
|
||||||
|
try {
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg,
|
||||||
|
to: chatId,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
accountId,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (fallbackErr) {
|
||||||
|
if (staticGuard?.terminate('deliver.textFallback', fallbackErr))
|
||||||
|
return;
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg,
|
||||||
|
to: chatId,
|
||||||
|
text: chunk,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
accountId,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (staticGuard?.terminate('deliver.textChunk', err))
|
||||||
|
return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- Static media delivery ----
|
||||||
|
for (const mediaUrl of payloadMediaUrls) {
|
||||||
|
if (!mediaUrl?.trim())
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
log.info('deliver: sending media via static path', {
|
||||||
|
mediaUrl: mediaUrl.slice(0, 80),
|
||||||
|
});
|
||||||
|
await (0, deliver_1.sendMediaLark)({
|
||||||
|
cfg,
|
||||||
|
to: chatId,
|
||||||
|
mediaUrl,
|
||||||
|
accountId,
|
||||||
|
replyToMessageId,
|
||||||
|
replyInThread,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (mediaErr) {
|
||||||
|
if (staticGuard?.terminate('deliver.media', mediaErr))
|
||||||
|
return;
|
||||||
|
log.error('deliver: static media send failed', {
|
||||||
|
error: String(mediaErr),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
...(controller
|
||||||
|
? {
|
||||||
|
shouldEmitToolResult: () => false,
|
||||||
|
shouldEmitToolOutput: () => false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
onModelSelected: (ctx) => {
|
||||||
|
prefixContext.onModelSelected(ctx);
|
||||||
|
},
|
||||||
|
disableBlockStreaming: !enableBlockStreaming,
|
||||||
|
...(controller
|
||||||
|
? {
|
||||||
|
onReasoningStream: (payload) => controller.onReasoningStream(payload),
|
||||||
|
onPartialReply: (payload) => controller.onPartialReply(payload),
|
||||||
|
onToolStart: (payload) => controller.onToolStart(payload),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
markDispatchIdle,
|
||||||
|
markFullyComplete: () => {
|
||||||
|
dispatchFullyComplete = true;
|
||||||
|
controller?.markFullyComplete();
|
||||||
|
},
|
||||||
|
abortCard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getVisiblePayloadText(payload) {
|
||||||
|
if (payload.isReasoning === true)
|
||||||
|
return '';
|
||||||
|
const rawText = payload.text ?? '';
|
||||||
|
if (!rawText)
|
||||||
|
return '';
|
||||||
|
const split = (0, builder_1.splitReasoningText)(rawText);
|
||||||
|
if (split.answerText != null) {
|
||||||
|
return split.answerText;
|
||||||
|
}
|
||||||
|
return (0, builder_1.stripReasoningTags)(rawText);
|
||||||
|
}
|
||||||
|
function shouldRouteToolPayloadToCard(payload, showToolUse) {
|
||||||
|
if (!showToolUse)
|
||||||
|
return false;
|
||||||
|
if (!getVisiblePayloadText(payload).trim())
|
||||||
|
return false;
|
||||||
|
if (payload.interactive)
|
||||||
|
return false;
|
||||||
|
if (payload.btw)
|
||||||
|
return false;
|
||||||
|
if (payload.audioAsVoice)
|
||||||
|
return false;
|
||||||
|
if (payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0)
|
||||||
|
return false;
|
||||||
|
const execApproval = payload.channelData && typeof payload.channelData === 'object' && !Array.isArray(payload.channelData)
|
||||||
|
? payload.channelData.execApproval
|
||||||
|
: undefined;
|
||||||
|
if (execApproval && typeof execApproval === 'object' && !Array.isArray(execApproval)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.resolveReplyMode = resolveReplyMode;
|
||||||
|
exports.expandAutoMode = expandAutoMode;
|
||||||
|
exports.shouldUseCard = shouldUseCard;
|
||||||
|
const card_error_1 = require("./card-error.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveReplyMode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Resolve the effective reply mode based on configuration and chat type.
|
||||||
|
*
|
||||||
|
* Priority: replyMode.{scene} > replyMode.default > replyMode (string) > "auto"
|
||||||
|
*/
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
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).
|
||||||
|
*/
|
||||||
|
function shouldUseCard(text) {
|
||||||
|
// Markdown tables NO LONGER force a card. Feishu messages render markdown
|
||||||
|
// tables natively, and wrapping a reply in a card breaks bot-at-bot @
|
||||||
|
// delivery (cards have limited @ support). Only fenced code blocks still
|
||||||
|
// benefit from card rendering.
|
||||||
|
//
|
||||||
|
// The table-count guard is kept as a safety valve: when a reply also
|
||||||
|
// contains an excessive number of markdown tables, skip the card entirely
|
||||||
|
// rather than risk a card-render failure.
|
||||||
|
const tableMatches = (0, card_error_1.findMarkdownTablesOutsideCodeBlocks)(text);
|
||||||
|
if (tableMatches.length > card_error_1.FEISHU_CARD_TABLE_LIMIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Fenced code blocks
|
||||||
|
if (/```[\s\S]*?```/.test(text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* 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, StreamingCardDeps, TerminalReason } from './reply-dispatcher-types';
|
||||||
|
interface TerminalCardTextImageResolver {
|
||||||
|
resolveImages(text: string): string;
|
||||||
|
}
|
||||||
|
interface TerminalCardContentInput {
|
||||||
|
text: string;
|
||||||
|
reasoningText?: string;
|
||||||
|
}
|
||||||
|
export declare class StreamingCardController {
|
||||||
|
private phase;
|
||||||
|
private cardKit;
|
||||||
|
private text;
|
||||||
|
private reasoning;
|
||||||
|
private toolUse;
|
||||||
|
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;
|
||||||
|
private needsFooterMetrics;
|
||||||
|
private getFooterSessionMetrics;
|
||||||
|
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;
|
||||||
|
private get shouldDisplayToolUse();
|
||||||
|
private computeToolUseDisplay;
|
||||||
|
private get visibleToolUseElapsedMs();
|
||||||
|
private computeToolUseTitleSuffix;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
private markToolUseActivity;
|
||||||
|
private captureToolUseElapsed;
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
onToolStart(payload: {
|
||||||
|
name?: string;
|
||||||
|
phase?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
onToolPayload(_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 lastToolUseStatusUpdateTime;
|
||||||
|
private throttledToolUseStatusUpdate;
|
||||||
|
private updateToolUseStatus;
|
||||||
|
private finalizeCard;
|
||||||
|
/**
|
||||||
|
* Close streaming mode then update card content (shared by onError and abortCard).
|
||||||
|
*/
|
||||||
|
private closeStreamingAndUpdate;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 终态卡片的正文和 reasoning 都会被飞书按 markdown 渲染,
|
||||||
|
* 因此两者都要先做图片替换与表格降级,避免再次撞到 230099/11310。
|
||||||
|
*/
|
||||||
|
export declare function prepareTerminalCardContent(content: TerminalCardContentInput, imageResolver: TerminalCardTextImageResolver, tableLimit?: number): TerminalCardContentInput;
|
||||||
|
export {};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Resolution logic for Feishu tool-use display.
|
||||||
|
*
|
||||||
|
* The source of truth is OpenClaw's effective verbose state:
|
||||||
|
* inline `/verbose` override > session store override > config default.
|
||||||
|
* Feishu channel config only retains UI-level detail (`showFullPaths`).
|
||||||
|
*/
|
||||||
|
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||||
|
import type { FeishuConfig } from '../core/types';
|
||||||
|
export type ToolUseMode = 'off' | 'on' | 'full';
|
||||||
|
export interface ToolUseDisplayConfig {
|
||||||
|
mode: ToolUseMode;
|
||||||
|
showToolUse: boolean;
|
||||||
|
showToolResultDetails: boolean;
|
||||||
|
showFullPaths: boolean;
|
||||||
|
}
|
||||||
|
export declare function resolveToolUseDisplayConfig(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
feishuCfg: FeishuConfig | undefined;
|
||||||
|
agentId: string;
|
||||||
|
sessionKey: string;
|
||||||
|
body?: string;
|
||||||
|
}): ToolUseDisplayConfig;
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Resolution logic for Feishu tool-use display.
|
||||||
|
*
|
||||||
|
* The source of truth is OpenClaw's effective verbose state:
|
||||||
|
* inline `/verbose` override > session store override > config default.
|
||||||
|
* Feishu channel config only retains UI-level detail (`showFullPaths`).
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.resolveToolUseDisplayConfig = resolveToolUseDisplayConfig;
|
||||||
|
const agent_runtime_1 = require("openclaw/plugin-sdk/agent-runtime");
|
||||||
|
const config_runtime_1 = require("openclaw/plugin-sdk/config-runtime");
|
||||||
|
function resolveToolUseDisplayConfig(params) {
|
||||||
|
const mode = resolveEffectiveVerboseMode(params);
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
showToolUse: mode !== 'off',
|
||||||
|
showToolResultDetails: mode === 'full',
|
||||||
|
showFullPaths: params.feishuCfg?.toolUseDisplay?.showFullPaths === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resolveEffectiveVerboseMode(params) {
|
||||||
|
return (extractInlineVerboseMode(params.body) ??
|
||||||
|
resolveSessionVerboseMode(params.cfg, params.sessionKey, params.agentId) ??
|
||||||
|
normalizeToolUseMode(params.cfg.agents?.defaults?.verboseDefault) ??
|
||||||
|
'off');
|
||||||
|
}
|
||||||
|
function resolveSessionVerboseMode(cfg, sessionKey, agentId) {
|
||||||
|
try {
|
||||||
|
const cfgWithSession = cfg;
|
||||||
|
const sessionStorePath = cfgWithSession.session?.store ?? cfgWithSession.sessions?.store;
|
||||||
|
const storePath = (0, config_runtime_1.resolveStorePath)(sessionStorePath, { agentId });
|
||||||
|
const store = (0, config_runtime_1.loadSessionStore)(storePath);
|
||||||
|
const candidateKeys = resolveCandidateSessionKeys(cfg, sessionKey);
|
||||||
|
for (const candidateKey of candidateKeys) {
|
||||||
|
const resolved = (0, config_runtime_1.resolveSessionStoreEntry)({ store, sessionKey: candidateKey });
|
||||||
|
const mode = normalizeToolUseMode(resolved.existing?.verboseLevel);
|
||||||
|
if (mode)
|
||||||
|
return mode;
|
||||||
|
if (resolved.existing)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resolveCandidateSessionKeys(cfg, sessionKey) {
|
||||||
|
const key = sessionKey.trim().toLowerCase();
|
||||||
|
const defaultAgentId = (0, agent_runtime_1.resolveDefaultAgentId)(cfg);
|
||||||
|
const fallbackKey = key.replace(/^(agent):[^:]+:/, `$1:${defaultAgentId}:`);
|
||||||
|
return fallbackKey !== key ? [key, fallbackKey] : [key];
|
||||||
|
}
|
||||||
|
function extractInlineVerboseMode(body) {
|
||||||
|
if (!body)
|
||||||
|
return undefined;
|
||||||
|
const matches = body.matchAll(/(?:^|\s)\/(?:verbose|v)(?::|\s+)(on|off|full)\b/gi);
|
||||||
|
let last;
|
||||||
|
for (const match of matches) {
|
||||||
|
last = normalizeToolUseMode(match[1]);
|
||||||
|
}
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
function normalizeToolUseMode(value) {
|
||||||
|
if (typeof value !== 'string')
|
||||||
|
return undefined;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'off' || normalized === 'on' || normalized === 'full') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Structured tool-use display for Lark/Feishu cards.
|
||||||
|
*/
|
||||||
|
import type { ToolUseTraceStep } from './tool-use-trace-store';
|
||||||
|
export type ToolUseStepStatus = ToolUseTraceStep['status'];
|
||||||
|
export interface ToolUseDisplayBlock {
|
||||||
|
language: 'json' | 'text';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface ToolUseDisplayStep {
|
||||||
|
title: string;
|
||||||
|
detail?: string;
|
||||||
|
iconToken: string;
|
||||||
|
status: ToolUseStepStatus;
|
||||||
|
resultBlock?: ToolUseDisplayBlock;
|
||||||
|
errorBlock?: ToolUseDisplayBlock;
|
||||||
|
}
|
||||||
|
export interface ToolUseDisplayResult {
|
||||||
|
content: string;
|
||||||
|
stepCount: number;
|
||||||
|
steps: ToolUseDisplayStep[];
|
||||||
|
}
|
||||||
|
export declare const EMPTY_TOOL_USE_PLACEHOLDER = "No tool steps available";
|
||||||
|
export declare function normalizeToolUseDisplay(params: {
|
||||||
|
traceSteps?: ToolUseTraceStep[];
|
||||||
|
showFullPaths?: boolean;
|
||||||
|
showResultDetails?: boolean;
|
||||||
|
}): ToolUseDisplayResult;
|
||||||
|
export declare function buildToolUseTitleSuffix(params: {
|
||||||
|
stepCount: number;
|
||||||
|
}): {
|
||||||
|
zh: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Structured tool-use display for Lark/Feishu cards.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.EMPTY_TOOL_USE_PLACEHOLDER = void 0;
|
||||||
|
exports.normalizeToolUseDisplay = normalizeToolUseDisplay;
|
||||||
|
exports.buildToolUseTitleSuffix = buildToolUseTitleSuffix;
|
||||||
|
const reasoning_utils_1 = require("./reasoning-utils.js");
|
||||||
|
exports.EMPTY_TOOL_USE_PLACEHOLDER = 'No tool steps available';
|
||||||
|
const DEFAULT_SUMMARY_PREFERENCE = ['matched', 'code', 'quoted', 'url', 'line'];
|
||||||
|
const TOOL_DESCRIPTORS = [
|
||||||
|
{
|
||||||
|
aliases: ['skill'],
|
||||||
|
iconToken: 'app-default_outlined',
|
||||||
|
title: 'Load skill',
|
||||||
|
sanitizer: 'skill',
|
||||||
|
paramKeys: ['skill', 'name'],
|
||||||
|
summaryPatterns: [/^(?:load|use)\s+skill\s+(.+)$/i],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['read', 'open'],
|
||||||
|
iconToken: 'file-link-text_outlined',
|
||||||
|
title: 'Read',
|
||||||
|
sanitizer: 'path',
|
||||||
|
paramKeys: ['file_path', 'path', 'file'],
|
||||||
|
summaryPatterns: [/^(?:read|open)\s+(?:file\s+)?(.+)$/i],
|
||||||
|
summaryPreference: ['code', 'quoted', 'matched', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['write', 'edit'],
|
||||||
|
iconToken: 'edit_outlined',
|
||||||
|
title: 'Edit',
|
||||||
|
sanitizer: 'path',
|
||||||
|
paramKeys: ['file_path', 'path', 'file'],
|
||||||
|
summaryPatterns: [/^(?:edit|write)\s+(?:file\s+)?(.+)$/i],
|
||||||
|
summaryPreference: ['code', 'quoted', 'matched', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['web_search', 'web-search', 'search'],
|
||||||
|
iconToken: 'search_outlined',
|
||||||
|
title: 'Search web',
|
||||||
|
sanitizer: 'search',
|
||||||
|
paramKeys: ['query', 'q'],
|
||||||
|
summaryPatterns: [/^(?:search\s+(?:web\s+)?(?:for|about)|query)\s+(.+)$/i],
|
||||||
|
summaryPreference: ['quoted', 'matched', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['web_fetch', 'web-fetch', 'fetch'],
|
||||||
|
iconToken: 'language_outlined',
|
||||||
|
title: 'Fetch web page',
|
||||||
|
sanitizer: 'url',
|
||||||
|
paramKeys: ['url'],
|
||||||
|
summaryPatterns: [/^(?:fetch|open)\s+(?:web\s+page\s+)?(?:from\s+)?(.+)$/i],
|
||||||
|
summaryPreference: ['url', 'matched', 'quoted', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['grep'],
|
||||||
|
iconToken: 'doc-search_outlined',
|
||||||
|
title: 'Search text',
|
||||||
|
sanitizer: 'generic',
|
||||||
|
detailFromParams: (params) => buildPatternDetail(params, { includeTarget: true }),
|
||||||
|
summaryPatterns: [/^(?:search\s+text(?:\s+by\s+pattern)?|grep)\s+(.+)$/i],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['glob'],
|
||||||
|
iconToken: 'folder_outlined',
|
||||||
|
title: 'Search files',
|
||||||
|
sanitizer: 'generic',
|
||||||
|
paramKeys: ['pattern'],
|
||||||
|
summaryPatterns: [/^(?:search\s+files(?:\s+by\s+pattern)?|glob)\s+(.+)$/i],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['exec', 'bash', 'command', 'run'],
|
||||||
|
iconToken: 'setting_outlined',
|
||||||
|
title: 'Run command',
|
||||||
|
sanitizer: 'command',
|
||||||
|
paramKeys: ['description', 'command', 'script'],
|
||||||
|
summaryPatterns: [/^(?:run|execute)\s+(?:command|script)?\s*(.+)$/i],
|
||||||
|
summaryPreference: ['code', 'quoted', 'matched', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['browser', 'playwright', 'navigate'],
|
||||||
|
iconToken: 'browser-mac_outlined',
|
||||||
|
title: 'Browser',
|
||||||
|
sanitizer: 'url',
|
||||||
|
paramKeys: ['url'],
|
||||||
|
summaryPatterns: [/^(?:open|browse|visit|navigate\s+to)\s+(.+)$/i],
|
||||||
|
summaryPreference: ['url', 'quoted', 'matched', 'line'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['agent', 'task', 'spawn'],
|
||||||
|
iconToken: 'robot_outlined',
|
||||||
|
title: 'Run sub-agent',
|
||||||
|
sanitizer: 'generic',
|
||||||
|
paramKeys: ['task', 'description', 'prompt'],
|
||||||
|
summaryPatterns: [/^(?:run\s+sub-?agent|spawn\s+agent)\s+(.+)$/i],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['check', 'determine', 'verify'],
|
||||||
|
iconToken: 'list-check_outlined',
|
||||||
|
title: 'Check',
|
||||||
|
sanitizer: 'generic',
|
||||||
|
paramKeys: ['target', 'subject', 'description'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aliases: ['summarize', 'analyze', 'prepare'],
|
||||||
|
iconToken: 'report_outlined',
|
||||||
|
title: 'Analyze',
|
||||||
|
sanitizer: 'generic',
|
||||||
|
paramKeys: ['target', 'subject', 'description'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
function normalizeToolUseDisplay(params) {
|
||||||
|
const traceSteps = params.traceSteps ?? [];
|
||||||
|
const showFullPaths = params.showFullPaths === true;
|
||||||
|
const showResultDetails = params.showResultDetails === true;
|
||||||
|
const sources = traceSteps.map(toTraceSource);
|
||||||
|
const steps = sources
|
||||||
|
.map((source) => formatToolStep(source, { showFullPaths, showResultDetails }))
|
||||||
|
.filter((step) => !!step);
|
||||||
|
return {
|
||||||
|
content: steps.map((step) => (step.detail ? `- ${step.title}: ${step.detail}` : `- ${step.title}`)).join('\n'),
|
||||||
|
stepCount: steps.length,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolUseTitleSuffix(params) {
|
||||||
|
const { stepCount } = params;
|
||||||
|
return {
|
||||||
|
zh: `查看 ${stepCount} 个步骤`,
|
||||||
|
en: `Show ${stepCount} step${stepCount === 1 ? '' : 's'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function toTraceSource(step) {
|
||||||
|
return {
|
||||||
|
toolName: step.toolName,
|
||||||
|
params: step.params,
|
||||||
|
result: step.result,
|
||||||
|
error: step.error,
|
||||||
|
durationMs: step.durationMs,
|
||||||
|
status: step.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function formatToolStep(source, options) {
|
||||||
|
const descriptor = resolveToolDescriptor(source.toolName);
|
||||||
|
const rawDetail = (descriptor ? extractDetailFromParams(source.params, descriptor) : undefined) ??
|
||||||
|
(descriptor ? extractDetailFromSummary(source.summaryText, descriptor) : cleanupLine(source.summaryText ?? '')) ??
|
||||||
|
undefined;
|
||||||
|
const detail = rawDetail ? sanitizeToolDetail(descriptor?.sanitizer ?? 'generic', rawDetail, options) : undefined;
|
||||||
|
const title = buildToolTitle(source, descriptor, rawDetail);
|
||||||
|
const status = resolveStepStatus(source);
|
||||||
|
const errorBlock = source.error ? buildErrorBlock(source.error, descriptor) : undefined;
|
||||||
|
const resultBlock = !errorBlock && options.showResultDetails ? buildResultBlock(source, descriptor) : undefined;
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
iconToken: descriptor?.iconToken ?? 'setting-inter_outlined',
|
||||||
|
status,
|
||||||
|
resultBlock,
|
||||||
|
errorBlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function buildToolTitle(source, descriptor, rawDetail) {
|
||||||
|
const baseTitle = descriptor?.title === 'Read' && rawDetail && isSkillPathValue(rawDetail)
|
||||||
|
? 'Skill Read'
|
||||||
|
: (descriptor?.title ?? humanizeToolName(source.toolName ?? 'tool'));
|
||||||
|
const durationLabel = source.durationMs != null ? formatDurationLabel(source.durationMs) : undefined;
|
||||||
|
return durationLabel ? `${baseTitle} (${durationLabel})` : baseTitle;
|
||||||
|
}
|
||||||
|
function resolveToolDescriptor(toolName) {
|
||||||
|
const normalizedName = (0, reasoning_utils_1.normalizeToolName)(toolName);
|
||||||
|
return TOOL_DESCRIPTORS.find((descriptor) => descriptor.aliases.some((alias) => normalizedName === alias || normalizedName.startsWith(`${alias}_`) || normalizedName.startsWith(`${alias}-`)));
|
||||||
|
}
|
||||||
|
function extractDetailFromParams(params, descriptor) {
|
||||||
|
if (!params)
|
||||||
|
return undefined;
|
||||||
|
if (descriptor.detailFromParams)
|
||||||
|
return descriptor.detailFromParams(params);
|
||||||
|
for (const key of descriptor.paramKeys ?? []) {
|
||||||
|
const value = params[key];
|
||||||
|
const text = extractScalarText(value);
|
||||||
|
if (text)
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function extractDetailFromSummary(summaryText, descriptor) {
|
||||||
|
if (!summaryText)
|
||||||
|
return undefined;
|
||||||
|
const lines = summaryText
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => cleanupLine(stripMarkdown(line)))
|
||||||
|
.filter((line) => line && !isNoiseLine(line));
|
||||||
|
for (const line of lines) {
|
||||||
|
const signals = buildSummarySignals(line, descriptor.summaryPatterns ?? []);
|
||||||
|
const detail = pickSummaryDetail(signals, descriptor.summaryPreference ?? DEFAULT_SUMMARY_PREFERENCE);
|
||||||
|
if (detail)
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function buildSummarySignals(line, patterns) {
|
||||||
|
const matched = patterns
|
||||||
|
.map((pattern) => line.match(pattern)?.[1]?.trim())
|
||||||
|
.find((value) => Boolean(value));
|
||||||
|
return {
|
||||||
|
line,
|
||||||
|
matched,
|
||||||
|
code: extractFirstCodeSpan(line),
|
||||||
|
quoted: extractFirstQuotedText(line),
|
||||||
|
url: extractFirstUrl(line),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pickSummaryDetail(signals, preference) {
|
||||||
|
for (const key of preference) {
|
||||||
|
const value = signals[key];
|
||||||
|
if (value)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function buildResultBlock(source, descriptor) {
|
||||||
|
if (source.result == null)
|
||||||
|
return undefined;
|
||||||
|
if (descriptor && ['Read', 'Edit', 'Fetch web page', 'Browser'].includes(descriptor.title)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return buildDisplayBlock(sanitizeDisplayBlockValue(source.result, descriptor));
|
||||||
|
}
|
||||||
|
function buildErrorBlock(error, descriptor) {
|
||||||
|
return buildDisplayBlock(sanitizeDisplayBlockValue(error, descriptor), 'text');
|
||||||
|
}
|
||||||
|
function sanitizeDisplayBlockValue(value, descriptor) {
|
||||||
|
if (descriptor?.sanitizer === 'command' && typeof value === 'string') {
|
||||||
|
return (0, reasoning_utils_1.redactInlineSecrets)(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function buildPatternDetail(params, options) {
|
||||||
|
const pattern = extractScalarText(params.pattern);
|
||||||
|
const target = extractScalarText(params.glob ?? params.path ?? params.file_path);
|
||||||
|
if (pattern && target && options.includeTarget) {
|
||||||
|
return `${pattern} in ${target}`;
|
||||||
|
}
|
||||||
|
return pattern ?? target ?? undefined;
|
||||||
|
}
|
||||||
|
function extractScalarText(value) {
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return value.trim() || undefined;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean')
|
||||||
|
return String(value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function sanitizeToolDetail(kind, value, options) {
|
||||||
|
if (kind === 'command') {
|
||||||
|
const cleaned = normalizeInlineDisplayText(value);
|
||||||
|
if (!cleaned)
|
||||||
|
return undefined;
|
||||||
|
return sanitizeCommandLike(cleaned, options);
|
||||||
|
}
|
||||||
|
const cleaned = sanitizeGenericText(value);
|
||||||
|
if (!cleaned)
|
||||||
|
return undefined;
|
||||||
|
switch (kind) {
|
||||||
|
case 'skill':
|
||||||
|
return (cleaned
|
||||||
|
.replace(/^skill\s+/i, '')
|
||||||
|
.replace(/[-_]+/g, ' ')
|
||||||
|
.trim() || 'skill');
|
||||||
|
case 'path':
|
||||||
|
return sanitizePathLike(cleaned, options);
|
||||||
|
case 'search':
|
||||||
|
return stripQuotes(cleaned);
|
||||||
|
case 'url':
|
||||||
|
return stripQuotes(cleaned).replace(/^from\s+/i, '');
|
||||||
|
case 'generic':
|
||||||
|
default:
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeInlineDisplayText(value) {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
function sanitizePathLike(value, options) {
|
||||||
|
const cleaned = sanitizeGenericText(value)
|
||||||
|
.replace(/^(?:from|file|path)\s+/i, '')
|
||||||
|
.trim();
|
||||||
|
if (options.showFullPaths)
|
||||||
|
return cleaned;
|
||||||
|
const skillMatch = cleaned.match(/(?:^|\/)skills\/([^/]+)\//i);
|
||||||
|
if (skillMatch?.[1]) {
|
||||||
|
return skillMatch[1].replace(/[-_]+/g, ' ').trim() || cleaned;
|
||||||
|
}
|
||||||
|
const segments = cleaned.split(/[\\/]/).filter(Boolean);
|
||||||
|
return segments.at(-1) ?? cleaned;
|
||||||
|
}
|
||||||
|
function sanitizeCommandLike(value, options) {
|
||||||
|
const cleaned = stripQuotes(value)
|
||||||
|
.replace(/^(?:command|script|description)\s+/i, '')
|
||||||
|
.replace(/^.*?\s+->\s+/i, '')
|
||||||
|
.trim();
|
||||||
|
if (!cleaned)
|
||||||
|
return 'command';
|
||||||
|
const redacted = (0, reasoning_utils_1.redactInlineSecrets)(cleaned);
|
||||||
|
return options.showFullPaths ? redacted : redactCommandPaths(redacted);
|
||||||
|
}
|
||||||
|
function resolveStepStatus(source) {
|
||||||
|
if (source.error)
|
||||||
|
return 'error';
|
||||||
|
if (source.status)
|
||||||
|
return source.status;
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
function buildDisplayBlock(value, fallbackLanguage = 'json') {
|
||||||
|
if (value == null)
|
||||||
|
return undefined;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!normalized)
|
||||||
|
return undefined;
|
||||||
|
const parsed = tryParseJson(normalized);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
const prettyJson = stringifyJson(parsed);
|
||||||
|
if (prettyJson) {
|
||||||
|
return { language: 'json', content: prettyJson };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { language: fallbackLanguage === 'json' ? 'text' : fallbackLanguage, content: normalized };
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const prettyJson = stringifyJson(value);
|
||||||
|
if (prettyJson) {
|
||||||
|
return { language: 'json', content: prettyJson };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? { language: 'text', content: normalized } : undefined;
|
||||||
|
}
|
||||||
|
function stringifyJson(value) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tryParseJson(value) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || !/^(?:\{|\[)/.test(trimmed)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function redactCommandPaths(command) {
|
||||||
|
return command
|
||||||
|
.split(/(\s+)/)
|
||||||
|
.map((segment) => {
|
||||||
|
if (!segment || /^\s+$/.test(segment))
|
||||||
|
return segment;
|
||||||
|
return redactCommandToken(segment);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
function redactCommandToken(token) {
|
||||||
|
const match = token.match(/^([("'`]*)(.*?)([)"'`,;:]*)$/);
|
||||||
|
if (!match)
|
||||||
|
return token;
|
||||||
|
const [, prefix, rawCore, suffix] = match;
|
||||||
|
const core = redactPathAssignment(rawCore);
|
||||||
|
return `${prefix}${core}${suffix}`;
|
||||||
|
}
|
||||||
|
function redactPathAssignment(value) {
|
||||||
|
const equalsIndex = value.indexOf('=');
|
||||||
|
if (equalsIndex > 0) {
|
||||||
|
const left = value.slice(0, equalsIndex + 1);
|
||||||
|
const right = value.slice(equalsIndex + 1);
|
||||||
|
return `${left}${redactStandalonePath(right)}`;
|
||||||
|
}
|
||||||
|
return redactStandalonePath(value);
|
||||||
|
}
|
||||||
|
function redactStandalonePath(value) {
|
||||||
|
if (/^https?:\/\//i.test(value))
|
||||||
|
return sanitizeUrlForDisplay(value);
|
||||||
|
if (!looksLikePathToken(value))
|
||||||
|
return value;
|
||||||
|
return basenameFromPath(value);
|
||||||
|
}
|
||||||
|
function sanitizeUrlForDisplay(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
parsed.username = '';
|
||||||
|
parsed.password = '';
|
||||||
|
for (const key of [...parsed.searchParams.keys()]) {
|
||||||
|
if (/(secret|token|password|key|credential|bearer|auth)/i.test(key)) {
|
||||||
|
parsed.searchParams.set(key, '[redacted]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function looksLikePathToken(value) {
|
||||||
|
return (value.startsWith('~/') ||
|
||||||
|
value.startsWith('./') ||
|
||||||
|
value.startsWith('../') ||
|
||||||
|
value.startsWith('/') ||
|
||||||
|
value.includes('/'));
|
||||||
|
}
|
||||||
|
function basenameFromPath(value) {
|
||||||
|
const cleaned = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
|
const segments = cleaned.split('/').filter(Boolean);
|
||||||
|
return segments.at(-1) ?? value;
|
||||||
|
}
|
||||||
|
function isSkillPathValue(value) {
|
||||||
|
return /(?:^|\/)skills\/[^/]+\//i.test(value);
|
||||||
|
}
|
||||||
|
function sanitizeGenericText(value) {
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
function cleanupLine(line) {
|
||||||
|
return line
|
||||||
|
.replace(/^[-*•]\s*/, '')
|
||||||
|
.replace(/^\d+[.)]\s*/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
function stripMarkdown(line) {
|
||||||
|
return line
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||||
|
.replace(/\*([^*]+)\*/g, '$1')
|
||||||
|
.replace(/^>\s*/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
function isNoiseLine(line) {
|
||||||
|
return /^(?:completed|complete|done|success|succeeded|running|started|finished|ok)$/i.test(line);
|
||||||
|
}
|
||||||
|
function humanizeToolName(name) {
|
||||||
|
const cleaned = name.replace(/[-_]+/g, ' ').trim();
|
||||||
|
if (!cleaned)
|
||||||
|
return 'Tool';
|
||||||
|
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||||
|
}
|
||||||
|
function formatDurationLabel(durationMs) {
|
||||||
|
return durationMs < 1000 ? `${durationMs} ms` : `${(durationMs / 1000).toFixed(1)} s`;
|
||||||
|
}
|
||||||
|
function stripQuotes(value) {
|
||||||
|
return value.replace(/^[`'"]+|[`'"]+$/g, '').trim();
|
||||||
|
}
|
||||||
|
function extractFirstCodeSpan(value) {
|
||||||
|
const match = value.match(/`([^`]+)`/);
|
||||||
|
return match?.[1]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
function extractFirstQuotedText(value) {
|
||||||
|
const match = value.match(/["']([^"']+)["']/);
|
||||||
|
return match?.[1]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
function extractFirstUrl(value) {
|
||||||
|
const match = value.match(/\bhttps?:\/\/[^\s"'`]+/i);
|
||||||
|
return match?.[0]?.trim() || undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Runtime store for structured tool-use steps.
|
||||||
|
*
|
||||||
|
* The Feishu card renderer reads from this store by session key so it can
|
||||||
|
* render observable, replayable tool execution without relying purely on
|
||||||
|
* reply payload text.
|
||||||
|
*/
|
||||||
|
export interface ToolUseTraceStep {
|
||||||
|
id: string;
|
||||||
|
seq: number;
|
||||||
|
toolName: string;
|
||||||
|
toolCallId?: string;
|
||||||
|
runId?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
status: 'running' | 'success' | 'error';
|
||||||
|
startedAt: number;
|
||||||
|
finishedAt?: number;
|
||||||
|
}
|
||||||
|
export declare function startToolUseTraceRun(sessionKey: string): void;
|
||||||
|
export declare function clearToolUseTraceRun(sessionKey: string): void;
|
||||||
|
export declare function hasToolUseTraceRun(sessionKey?: string): boolean;
|
||||||
|
export declare function recordToolUseStart(params: {
|
||||||
|
sessionKey?: string;
|
||||||
|
toolName: string;
|
||||||
|
toolParams?: Record<string, unknown>;
|
||||||
|
toolCallId?: string;
|
||||||
|
runId?: string;
|
||||||
|
}): void;
|
||||||
|
export declare function recordToolUseEnd(params: {
|
||||||
|
sessionKey?: string;
|
||||||
|
toolName: string;
|
||||||
|
toolParams?: Record<string, unknown>;
|
||||||
|
toolCallId?: string;
|
||||||
|
runId?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}): void;
|
||||||
|
export declare function getToolUseTraceSteps(sessionKey?: string): ToolUseTraceStep[];
|
||||||
|
export declare function sanitizeTraceValue(value: unknown, depth?: number, context?: {
|
||||||
|
source?: 'params' | 'result' | 'generic';
|
||||||
|
key?: string;
|
||||||
|
}): unknown;
|
||||||
|
/** @internal — test-only helper to reset module-level state between test cases. */
|
||||||
|
export declare function _resetForTesting(): void;
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Runtime store for structured tool-use steps.
|
||||||
|
*
|
||||||
|
* The Feishu card renderer reads from this store by session key so it can
|
||||||
|
* render observable, replayable tool execution without relying purely on
|
||||||
|
* reply payload text.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.startToolUseTraceRun = startToolUseTraceRun;
|
||||||
|
exports.clearToolUseTraceRun = clearToolUseTraceRun;
|
||||||
|
exports.hasToolUseTraceRun = hasToolUseTraceRun;
|
||||||
|
exports.recordToolUseStart = recordToolUseStart;
|
||||||
|
exports.recordToolUseEnd = recordToolUseEnd;
|
||||||
|
exports.getToolUseTraceSteps = getToolUseTraceSteps;
|
||||||
|
exports.sanitizeTraceValue = sanitizeTraceValue;
|
||||||
|
exports._resetForTesting = _resetForTesting;
|
||||||
|
const reasoning_utils_1 = require("./reasoning-utils.js");
|
||||||
|
const TRACE_TTL_MS = 30 * 60 * 1000;
|
||||||
|
const MAX_SESSION_TRACES = 128;
|
||||||
|
const MAX_STEPS_PER_SESSION = 256;
|
||||||
|
const STEP_RUNNING_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const GENERIC_STRING_LIMIT = 512;
|
||||||
|
const RESULT_STRING_LIMIT = 1024;
|
||||||
|
const COMMAND_STRING_LIMIT = 4096;
|
||||||
|
const PATH_STRING_LIMIT = 2048;
|
||||||
|
const sessionTraces = new Map();
|
||||||
|
function startToolUseTraceRun(sessionKey) {
|
||||||
|
if (!sessionKey)
|
||||||
|
return;
|
||||||
|
pruneTraceStore();
|
||||||
|
sessionTraces.set(sessionKey, {
|
||||||
|
nextSeq: 1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
steps: [],
|
||||||
|
currentRunId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function clearToolUseTraceRun(sessionKey) {
|
||||||
|
if (!sessionKey)
|
||||||
|
return;
|
||||||
|
sessionTraces.delete(sessionKey);
|
||||||
|
}
|
||||||
|
function hasToolUseTraceRun(sessionKey) {
|
||||||
|
if (!sessionKey)
|
||||||
|
return false;
|
||||||
|
return sessionTraces.has(sessionKey);
|
||||||
|
}
|
||||||
|
function recordToolUseStart(params) {
|
||||||
|
const { sessionKey, toolName, toolParams, toolCallId, runId } = params;
|
||||||
|
if (!sessionKey || !toolName)
|
||||||
|
return;
|
||||||
|
const state = sessionTraces.get(sessionKey);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
if (runId) {
|
||||||
|
if (state.currentRunId === undefined) {
|
||||||
|
state.currentRunId = runId;
|
||||||
|
}
|
||||||
|
else if (state.currentRunId !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (state.steps.length >= MAX_STEPS_PER_SESSION) {
|
||||||
|
state.steps.splice(0, state.steps.length - MAX_STEPS_PER_SESSION + 1);
|
||||||
|
}
|
||||||
|
state.steps.push({
|
||||||
|
id: `${state.nextSeq}`,
|
||||||
|
seq: state.nextSeq,
|
||||||
|
toolName,
|
||||||
|
toolCallId: toolCallId || undefined,
|
||||||
|
runId: runId || undefined,
|
||||||
|
params: sanitizeTraceValue(toolParams, 0, { source: 'params' }),
|
||||||
|
status: 'running',
|
||||||
|
startedAt: now,
|
||||||
|
});
|
||||||
|
state.nextSeq += 1;
|
||||||
|
state.updatedAt = now;
|
||||||
|
}
|
||||||
|
function recordToolUseEnd(params) {
|
||||||
|
const { sessionKey, toolName, toolParams, toolCallId, runId, result, error, durationMs } = params;
|
||||||
|
if (!sessionKey || !toolName)
|
||||||
|
return;
|
||||||
|
const state = sessionTraces.get(sessionKey);
|
||||||
|
if (!state)
|
||||||
|
return;
|
||||||
|
if (runId && state.currentRunId !== undefined && state.currentRunId !== runId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const sanitizedParams = sanitizeTraceValue(toolParams, 0, { source: 'params' });
|
||||||
|
const pendingIndex = findPendingStepIndex(state.steps, toolName, sanitizedParams, toolCallId);
|
||||||
|
if (pendingIndex >= 0) {
|
||||||
|
const step = state.steps[pendingIndex];
|
||||||
|
if (!step)
|
||||||
|
return;
|
||||||
|
step.status = error ? 'error' : 'success';
|
||||||
|
step.result = sanitizeTraceValue(result, 0, { source: 'result' });
|
||||||
|
step.error = error ? (0, reasoning_utils_1.truncateText)(error, 160) : undefined;
|
||||||
|
step.durationMs = durationMs;
|
||||||
|
step.finishedAt = now;
|
||||||
|
if (!step.params && sanitizedParams) {
|
||||||
|
step.params = sanitizedParams;
|
||||||
|
}
|
||||||
|
state.updatedAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.steps.push({
|
||||||
|
id: `${state.nextSeq}`,
|
||||||
|
seq: state.nextSeq,
|
||||||
|
toolName,
|
||||||
|
toolCallId: toolCallId || undefined,
|
||||||
|
runId: runId || undefined,
|
||||||
|
params: sanitizedParams,
|
||||||
|
result: sanitizeTraceValue(result, 0, { source: 'result' }),
|
||||||
|
error: error ? (0, reasoning_utils_1.truncateText)(error, 160) : undefined,
|
||||||
|
durationMs,
|
||||||
|
status: error ? 'error' : 'success',
|
||||||
|
startedAt: now,
|
||||||
|
finishedAt: now,
|
||||||
|
});
|
||||||
|
state.nextSeq += 1;
|
||||||
|
state.updatedAt = now;
|
||||||
|
}
|
||||||
|
function getToolUseTraceSteps(sessionKey) {
|
||||||
|
if (!sessionKey)
|
||||||
|
return [];
|
||||||
|
const state = sessionTraces.get(sessionKey);
|
||||||
|
if (!state)
|
||||||
|
return [];
|
||||||
|
if (Date.now() - state.updatedAt > TRACE_TTL_MS) {
|
||||||
|
sessionTraces.delete(sessionKey);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
return state.steps.map((step) => {
|
||||||
|
if (step.status === 'running' && now - step.startedAt > STEP_RUNNING_TIMEOUT_MS) {
|
||||||
|
return { ...step, status: 'error', error: 'timed out', finishedAt: now };
|
||||||
|
}
|
||||||
|
return { ...step };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function findPendingStepIndex(steps, toolName, params, toolCallId) {
|
||||||
|
if (toolCallId) {
|
||||||
|
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step || step.status !== 'running')
|
||||||
|
continue;
|
||||||
|
if (step.toolCallId === toolCallId)
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizedToolName = (0, reasoning_utils_1.normalizeToolName)(toolName);
|
||||||
|
const paramsKey = fingerprintTraceValue(params);
|
||||||
|
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step || step.status !== 'running')
|
||||||
|
continue;
|
||||||
|
if ((0, reasoning_utils_1.normalizeToolName)(step.toolName) !== normalizedToolName)
|
||||||
|
continue;
|
||||||
|
if (fingerprintTraceValue(step.params) !== paramsKey)
|
||||||
|
continue;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
||||||
|
const step = steps[index];
|
||||||
|
if (!step || step.status !== 'running')
|
||||||
|
continue;
|
||||||
|
if ((0, reasoning_utils_1.normalizeToolName)(step.toolName) !== normalizedToolName)
|
||||||
|
continue;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
function pruneTraceStore() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionKey, state] of sessionTraces) {
|
||||||
|
if (now - state.updatedAt > TRACE_TTL_MS) {
|
||||||
|
sessionTraces.delete(sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sessionTraces.size <= MAX_SESSION_TRACES)
|
||||||
|
return;
|
||||||
|
const overflow = sessionTraces.size - MAX_SESSION_TRACES;
|
||||||
|
const entries = [...sessionTraces.entries()].sort((a, b) => a[1].updatedAt - b[1].updatedAt);
|
||||||
|
for (const [sessionKey] of entries.slice(0, overflow)) {
|
||||||
|
sessionTraces.delete(sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sanitizeTraceValue(value, depth = 0, context = {}) {
|
||||||
|
if (value == null)
|
||||||
|
return undefined;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const limit = resolveStringLimit(context);
|
||||||
|
return (0, reasoning_utils_1.truncateText)(sanitizeTraceString(value, context), limit);
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean')
|
||||||
|
return value;
|
||||||
|
if (depth >= 2)
|
||||||
|
return '[truncated]';
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.slice(0, 8).map((item) => sanitizeTraceValue(item, depth + 1, { source: context.source }));
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const input = value;
|
||||||
|
const output = {};
|
||||||
|
for (const [key, entryValue] of Object.entries(input).slice(0, 12)) {
|
||||||
|
output[key] = isSensitiveKey(key)
|
||||||
|
? '[redacted]'
|
||||||
|
: sanitizeTraceValue(entryValue, depth + 1, { source: context.source, key });
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
return (0, reasoning_utils_1.truncateText)(String(value), 180);
|
||||||
|
}
|
||||||
|
function sanitizeTraceString(value, context) {
|
||||||
|
const redactedUrl = redactUrlParams(value);
|
||||||
|
if (isCommandLikeKey(context.key)) {
|
||||||
|
return (0, reasoning_utils_1.redactInlineSecrets)(redactedUrl);
|
||||||
|
}
|
||||||
|
return redactedUrl;
|
||||||
|
}
|
||||||
|
function resolveStringLimit(context) {
|
||||||
|
const key = context.key?.toLowerCase() ?? '';
|
||||||
|
if (/(?:^|_)(?:command|script|description|prompt|task)(?:$|_)/.test(key)) {
|
||||||
|
return COMMAND_STRING_LIMIT;
|
||||||
|
}
|
||||||
|
if (/(?:^|_)(?:path|file|url|uri|cwd|folder|dir)(?:$|_)/.test(key)) {
|
||||||
|
return PATH_STRING_LIMIT;
|
||||||
|
}
|
||||||
|
if (context.source === 'result') {
|
||||||
|
return RESULT_STRING_LIMIT;
|
||||||
|
}
|
||||||
|
return GENERIC_STRING_LIMIT;
|
||||||
|
}
|
||||||
|
function isCommandLikeKey(key) {
|
||||||
|
const normalized = key?.toLowerCase() ?? '';
|
||||||
|
return /(?:^|_)(?:command|script)(?:$|_)/.test(normalized);
|
||||||
|
}
|
||||||
|
const SENSITIVE_KEY_RE = /secret|token|password|authorization|cookie|api[-_]?key|credential|private[-_]?key|access[-_]?key|database[-_]?url|connection[-_]?string|bearer|signing[-_]?key|encryption[-_]?key|session[-_]?id|client[-_]?secret|auth[-_]?token/i;
|
||||||
|
function isSensitiveKey(key) {
|
||||||
|
return SENSITIVE_KEY_RE.test(key);
|
||||||
|
}
|
||||||
|
function redactUrlParams(url) {
|
||||||
|
return url.replace(/([?&])(api_key|token|secret|key)=[^&]*/gi, '$1$2=[redacted]');
|
||||||
|
}
|
||||||
|
function fingerprintTraceValue(value) {
|
||||||
|
if (value == null)
|
||||||
|
return '';
|
||||||
|
if (typeof value !== 'object')
|
||||||
|
return String(value);
|
||||||
|
return JSON.stringify(sortTraceValue(value));
|
||||||
|
}
|
||||||
|
function sortTraceValue(value) {
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.map((item) => sortTraceValue(item));
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(Object.entries(value)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, entryValue]) => [key, sortTraceValue(entryValue)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
/** @internal — test-only helper to reset module-level state between test cases. */
|
||||||
|
function _resetForTesting() {
|
||||||
|
sessionTraces.clear();
|
||||||
|
}
|
||||||
@@ -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,87 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.UnavailableGuard = void 0;
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const api_error_1 = require("../core/api-error.js");
|
||||||
|
const message_unavailable_1 = require("../core/message-unavailable.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('card/unavailable-guard');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UnavailableGuard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 (!(0, message_unavailable_1.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 = (0, message_unavailable_1.isMessageUnavailableError)(err) ? err : undefined;
|
||||||
|
const cardMessageId = this.getCardMessageId();
|
||||||
|
const state = (0, message_unavailable_1.getMessageUnavailableState)(this.replyToMessageId) ?? (0, message_unavailable_1.getMessageUnavailableState)(cardMessageId ?? undefined);
|
||||||
|
let apiCode = fromError?.apiCode ?? state?.apiCode;
|
||||||
|
if (!apiCode && err) {
|
||||||
|
const detectedCode = (0, api_error_1.extractLarkApiCode)(err);
|
||||||
|
if ((0, message_unavailable_1.isTerminalMessageApiCode)(detectedCode)) {
|
||||||
|
const fallbackMessageId = this.replyToMessageId ?? cardMessageId ?? undefined;
|
||||||
|
if (fallbackMessageId) {
|
||||||
|
(0, message_unavailable_1.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.UnavailableGuard = UnavailableGuard;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/**
|
||||||
|
* Whether an inbound message expresses intent to stop / interrupt the ongoing
|
||||||
|
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
|
||||||
|
* conversational phrases above.
|
||||||
|
*
|
||||||
|
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
|
||||||
|
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
|
||||||
|
* the in-flight ping-pong drains instead of being re-armed. Substring match —
|
||||||
|
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
|
||||||
|
* the worst case is a missed forced-@ or a self-healing mute (any normal
|
||||||
|
* message lifts it).
|
||||||
|
*/
|
||||||
|
export declare function isConversationStopIntent(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;
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.isAbortTrigger = isAbortTrigger;
|
||||||
|
exports.isLikelyAbortText = isLikelyAbortText;
|
||||||
|
exports.isConversationStopIntent = isConversationStopIntent;
|
||||||
|
exports.extractRawTextFromEvent = extractRawTextFromEvent;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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`). */
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
function isLikelyAbortText(text) {
|
||||||
|
if (!text)
|
||||||
|
return false;
|
||||||
|
const trimmed = text.trim().toLowerCase();
|
||||||
|
if (trimmed === '/stop')
|
||||||
|
return true;
|
||||||
|
return isAbortTrigger(trimmed);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conversation stop-intent (broader than the exact abort triggers)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Conversational "please stop / interrupt this exchange" phrases.
|
||||||
|
*
|
||||||
|
* Deliberately SEPARATE from {@link ABORT_TRIGGERS} (which is synced word-for-
|
||||||
|
* word with OpenClaw core and matched by exact equality, e.g. `/stop`). These
|
||||||
|
* are matched by substring so natural phrasings like "中断对话" or "stop
|
||||||
|
* talking" are caught. The list is intentionally distinctive to avoid false
|
||||||
|
* positives — a false positive only means we skip the deterministic peer-@
|
||||||
|
* backstop for that turn (the model can still @ on its own), which is mild.
|
||||||
|
*/
|
||||||
|
const STOP_INTENT_PHRASES = [
|
||||||
|
// zh — stop / terminate / pause
|
||||||
|
'中断',
|
||||||
|
'中止',
|
||||||
|
'终止',
|
||||||
|
'停止',
|
||||||
|
'停下',
|
||||||
|
'停一下',
|
||||||
|
'暂停',
|
||||||
|
'打住',
|
||||||
|
'停手',
|
||||||
|
'收手',
|
||||||
|
// zh — "don't keep going / replying"
|
||||||
|
'别聊',
|
||||||
|
'别说了',
|
||||||
|
'别回复',
|
||||||
|
'别继续',
|
||||||
|
'别再聊',
|
||||||
|
'别再说',
|
||||||
|
'别吵',
|
||||||
|
'别争',
|
||||||
|
'不要回复',
|
||||||
|
'不要继续',
|
||||||
|
'不用回复',
|
||||||
|
'不用继续',
|
||||||
|
// zh — "wrap up / be quiet"
|
||||||
|
'结束对话',
|
||||||
|
'结束讨论',
|
||||||
|
'结束辩论',
|
||||||
|
'到此为止',
|
||||||
|
'闭嘴',
|
||||||
|
// en
|
||||||
|
'stop talking',
|
||||||
|
'stop chatting',
|
||||||
|
'stop debating',
|
||||||
|
'stop the debate',
|
||||||
|
'stop the conversation',
|
||||||
|
'stop this conversation',
|
||||||
|
'stop responding',
|
||||||
|
'stop replying',
|
||||||
|
'end the conversation',
|
||||||
|
'end conversation',
|
||||||
|
'end the debate',
|
||||||
|
'shut up',
|
||||||
|
'be quiet',
|
||||||
|
'cut it out',
|
||||||
|
'knock it off',
|
||||||
|
'wrap it up',
|
||||||
|
'stand down',
|
||||||
|
];
|
||||||
|
/**
|
||||||
|
* Whether an inbound message expresses intent to stop / interrupt the ongoing
|
||||||
|
* (bot-to-bot) exchange. Superset of {@link isLikelyAbortText} plus the
|
||||||
|
* conversational phrases above.
|
||||||
|
*
|
||||||
|
* Two consumers: (1) suppress the deterministic peer-@ backstop so a stop
|
||||||
|
* acknowledgement doesn't re-wake the peer bot; (2) mute an active bot loop so
|
||||||
|
* the in-flight ping-pong drains instead of being re-armed. Substring match —
|
||||||
|
* keep the list distinctive (no bare "停"/"stop") to limit false positives;
|
||||||
|
* the worst case is a missed forced-@ or a self-healing mute (any normal
|
||||||
|
* message lifts it).
|
||||||
|
*/
|
||||||
|
function isConversationStopIntent(text) {
|
||||||
|
if (!text)
|
||||||
|
return false;
|
||||||
|
// Drop bot mention placeholders so "@Bot 中断对话" → "中断对话".
|
||||||
|
const normalized = text.replace(/@_user_\d+/g, '').trim().toLowerCase();
|
||||||
|
if (!normalized)
|
||||||
|
return false;
|
||||||
|
if (isLikelyAbortText(normalized))
|
||||||
|
return true;
|
||||||
|
return STOP_INTENT_PHRASES.some((p) => normalized.includes(p));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.threadScopedKey = threadScopedKey;
|
||||||
|
exports.buildQueueKey = buildQueueKey;
|
||||||
|
exports.registerActiveDispatcher = registerActiveDispatcher;
|
||||||
|
exports.unregisterActiveDispatcher = unregisterActiveDispatcher;
|
||||||
|
exports.getActiveDispatcher = getActiveDispatcher;
|
||||||
|
exports.hasActiveTask = hasActiveTask;
|
||||||
|
exports.enqueueFeishuChatTask = enqueueFeishuChatTask;
|
||||||
|
exports._resetChatQueueState = _resetChatQueueState;
|
||||||
|
const chatQueues = new Map();
|
||||||
|
const activeDispatchers = new Map();
|
||||||
|
/**
|
||||||
|
* Append `:thread:{threadId}` suffix when threadId is present.
|
||||||
|
* Consistent with the SDK's `:thread:` separator convention.
|
||||||
|
*/
|
||||||
|
function threadScopedKey(base, threadId) {
|
||||||
|
return threadId ? `${base}:thread:${threadId}` : base;
|
||||||
|
}
|
||||||
|
function buildQueueKey(accountId, chatId, threadId) {
|
||||||
|
return threadScopedKey(`${accountId}:${chatId}`, threadId);
|
||||||
|
}
|
||||||
|
function registerActiveDispatcher(key, entry) {
|
||||||
|
activeDispatchers.set(key, entry);
|
||||||
|
}
|
||||||
|
function unregisterActiveDispatcher(key) {
|
||||||
|
activeDispatchers.delete(key);
|
||||||
|
}
|
||||||
|
function getActiveDispatcher(key) {
|
||||||
|
return activeDispatchers.get(key);
|
||||||
|
}
|
||||||
|
/** Check whether the queue has an active task for the given key. */
|
||||||
|
function hasActiveTask(key) {
|
||||||
|
return chatQueues.has(key);
|
||||||
|
}
|
||||||
|
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 taskPromise = prev.then(task, task);
|
||||||
|
chatQueues.set(key, taskPromise);
|
||||||
|
const cleanup = () => {
|
||||||
|
if (chatQueues.get(key) === taskPromise) {
|
||||||
|
chatQueues.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
taskPromise.then(cleanup, cleanup);
|
||||||
|
return { status, promise: taskPromise };
|
||||||
|
}
|
||||||
|
/** @internal Test-only: reset all queue and dispatcher state. */
|
||||||
|
function _resetChatQueueState() {
|
||||||
|
chatQueues.clear();
|
||||||
|
activeDispatchers.clear();
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"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`).
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.setAccountEnabled = setAccountEnabled;
|
||||||
|
exports.applyAccountConfig = applyAccountConfig;
|
||||||
|
exports.deleteAccount = deleteAccount;
|
||||||
|
exports.collectFeishuSecurityWarnings = collectFeishuSecurityWarnings;
|
||||||
|
const account_id_1 = require("openclaw/plugin-sdk/account-id");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const security_check_1 = require("../core/security-check.js");
|
||||||
|
/** Generic Feishu account config merge. */
|
||||||
|
function mergeFeishuAccountConfig(cfg, accountId, patch) {
|
||||||
|
const isDefault = !accountId || accountId === account_id_1.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. */
|
||||||
|
function setAccountEnabled(cfg, accountId, enabled) {
|
||||||
|
return mergeFeishuAccountConfig(cfg, accountId, { enabled });
|
||||||
|
}
|
||||||
|
/** Apply an arbitrary config patch to a Feishu account. */
|
||||||
|
function applyAccountConfig(cfg, accountId, patch) {
|
||||||
|
return mergeFeishuAccountConfig(cfg, accountId, patch);
|
||||||
|
}
|
||||||
|
/** Delete a Feishu account entry from the config. */
|
||||||
|
function deleteAccount(cfg, accountId) {
|
||||||
|
const isDefault = !accountId || accountId === account_id_1.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. */
|
||||||
|
function collectFeishuSecurityWarnings(params) {
|
||||||
|
const { cfg, accountId } = params;
|
||||||
|
const warnings = [];
|
||||||
|
const account = (0, accounts_1.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 = (0, accounts_1.getLarkAccountIds)(cfg);
|
||||||
|
if (allIds.length === 0 || accountId === allIds[0]) {
|
||||||
|
for (const w of (0, security_check_1.collectIsolationWarnings)(cfg)) {
|
||||||
|
warnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
@@ -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 { FeishuDirectoryGroup, FeishuDirectoryPeer } 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[]>;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.listFeishuDirectoryPeers = listFeishuDirectoryPeers;
|
||||||
|
exports.listFeishuDirectoryGroups = listFeishuDirectoryGroups;
|
||||||
|
exports.listFeishuDirectoryPeersLive = listFeishuDirectoryPeersLive;
|
||||||
|
exports.listFeishuDirectoryGroupsLive = listFeishuDirectoryGroupsLive;
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const targets_1 = require("../core/targets.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
async function listFeishuDirectoryPeers(params) {
|
||||||
|
const account = (0, accounts_1.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) => (0, targets_1.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).
|
||||||
|
*/
|
||||||
|
async function listFeishuDirectoryGroups(params) {
|
||||||
|
const account = (0, accounts_1.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.
|
||||||
|
*/
|
||||||
|
async function listFeishuDirectoryPeersLive(params) {
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
|
||||||
|
if (!account.configured) {
|
||||||
|
return listFeishuDirectoryPeers(params);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = lark_client_1.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.
|
||||||
|
*/
|
||||||
|
async function listFeishuDirectoryGroupsLive(params) {
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(params.cfg, params.accountId);
|
||||||
|
if (!account.configured) {
|
||||||
|
return listFeishuDirectoryGroups(params);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = lark_client_1.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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 handleVcMeetingInvitedEvent(ctx: MonitorContext, data: unknown): Promise<void>;
|
||||||
|
export declare function handleCommentEvent(ctx: MonitorContext, data: unknown): Promise<void>;
|
||||||
|
export declare function handleCardActionEvent(ctx: MonitorContext, data: unknown): Promise<unknown>;
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.handleMessageEvent = handleMessageEvent;
|
||||||
|
exports.handleReactionEvent = handleReactionEvent;
|
||||||
|
exports.handleBotMembershipEvent = handleBotMembershipEvent;
|
||||||
|
exports.handleVcMeetingInvitedEvent = handleVcMeetingInvitedEvent;
|
||||||
|
exports.handleCommentEvent = handleCommentEvent;
|
||||||
|
exports.handleCardActionEvent = handleCardActionEvent;
|
||||||
|
const handler_1 = require("../messaging/inbound/handler.js");
|
||||||
|
const reaction_handler_1 = require("../messaging/inbound/reaction-handler.js");
|
||||||
|
const comment_handler_1 = require("../messaging/inbound/comment-handler.js");
|
||||||
|
const vc_meeting_invited_handler_1 = require("../messaging/inbound/vc-meeting-invited-handler.js");
|
||||||
|
const vc_sender_1 = require("../messaging/inbound/vc-sender.js");
|
||||||
|
const comment_context_1 = require("../messaging/inbound/comment-context.js");
|
||||||
|
const dedup_1 = require("../messaging/inbound/dedup.js");
|
||||||
|
const lark_ticket_1 = require("../core/lark-ticket.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const auto_auth_1 = require("../tools/auto-auth.js");
|
||||||
|
const ask_user_question_1 = require("../tools/ask-user-question.js");
|
||||||
|
const chat_queue_1 = require("./chat-queue.js");
|
||||||
|
const abort_detect_1 = require("./abort-detect.js");
|
||||||
|
const interactive_dispatch_1 = require("./interactive-dispatch.js");
|
||||||
|
const elog = (0, lark_logger_1.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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function handleMessageEvent(ctx, data) {
|
||||||
|
if (!isEventOwnershipValid(ctx, data))
|
||||||
|
return;
|
||||||
|
const { accountId, log, error } = ctx;
|
||||||
|
try {
|
||||||
|
const event = data;
|
||||||
|
// Self-echo hard filter — drop messages authored by this very bot before
|
||||||
|
// dedup and enqueue. Prevents self-reply loops; the primary guardrail
|
||||||
|
// against bot-to-bot ping-pong.
|
||||||
|
//
|
||||||
|
// NOTE: if botOpenId is not yet populated (startup race before probe
|
||||||
|
// resolves), this filter is skipped. The downstream bot-sender gate
|
||||||
|
// (checkBotSenderGate) acts as fallback — bot messages default to
|
||||||
|
// `allowBots='mentions'`, so in groups they require an explicit @-mention
|
||||||
|
// of this bot to pass; DMs are pass-through under the default.
|
||||||
|
const senderOpenId = event.sender?.sender_id?.open_id;
|
||||||
|
const botOpenId = ctx.lark.botOpenId;
|
||||||
|
if (botOpenId && senderOpenId && senderOpenId === botOpenId) {
|
||||||
|
log(`feishu[${accountId}]: drop self-echo message ${event.message?.message_id ?? 'unknown'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msgId = event.message?.message_id ?? 'unknown';
|
||||||
|
const chatId = event.message?.chat_id ?? '';
|
||||||
|
// In topic groups, reply events carry root_id but not thread_id.
|
||||||
|
// Use root_id as fallback so different topics get separate queue keys
|
||||||
|
// and can be processed in parallel.
|
||||||
|
const threadId = event.message?.thread_id || event.message?.root_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 ((0, dedup_1.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 = (0, abort_detect_1.extractRawTextFromEvent)(event);
|
||||||
|
if (abortText && (0, abort_detect_1.isLikelyAbortText)(abortText)) {
|
||||||
|
const queueKey = (0, chat_queue_1.buildQueueKey)(accountId, chatId, threadId);
|
||||||
|
if ((0, chat_queue_1.hasActiveTask)(queueKey)) {
|
||||||
|
const active = (0, chat_queue_1.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 } = (0, chat_queue_1.enqueueFeishuChatTask)({
|
||||||
|
accountId,
|
||||||
|
chatId,
|
||||||
|
threadId,
|
||||||
|
task: async () => {
|
||||||
|
try {
|
||||||
|
await (0, lark_ticket_1.withTicket)({
|
||||||
|
messageId: msgId,
|
||||||
|
chatId,
|
||||||
|
accountId,
|
||||||
|
startTime: Date.now(),
|
||||||
|
senderOpenId: event.sender?.sender_id?.open_id || '',
|
||||||
|
chatType: event.message?.chat_type || undefined,
|
||||||
|
threadId,
|
||||||
|
}, () => (0, handler_1.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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 ((0, dedup_1.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 (0, reaction_handler_1.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 } = (0, chat_queue_1.enqueueFeishuChatTask)({
|
||||||
|
accountId,
|
||||||
|
chatId: preResolved.chatId,
|
||||||
|
threadId: preResolved.threadId,
|
||||||
|
task: async () => {
|
||||||
|
try {
|
||||||
|
await (0, lark_ticket_1.withTicket)({
|
||||||
|
messageId: msgId,
|
||||||
|
chatId: preResolved.chatId,
|
||||||
|
accountId,
|
||||||
|
startTime: Date.now(),
|
||||||
|
senderOpenId: operatorOpenId,
|
||||||
|
chatType: preResolved.chatType,
|
||||||
|
threadId: preResolved.threadId,
|
||||||
|
}, () => (0, reaction_handler_1.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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VC meeting invited handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function handleVcMeetingInvitedEvent(ctx, data) {
|
||||||
|
if (!isEventOwnershipValid(ctx, data))
|
||||||
|
return;
|
||||||
|
const { accountId, log, error } = ctx;
|
||||||
|
try {
|
||||||
|
const event = data;
|
||||||
|
const meetingNo = event.meeting?.meeting_no?.trim() ?? '';
|
||||||
|
const eventId = event.event_id?.trim() ?? '';
|
||||||
|
// Resolve the inviter identity through the shared helper so the
|
||||||
|
// diagnostics log and the dispatch handler always agree on the
|
||||||
|
// same sender semantics.
|
||||||
|
const sender = (0, vc_sender_1.resolveVcSender)(event);
|
||||||
|
const senderId = sender.senderId;
|
||||||
|
const invitedBotOpenId = event.bot?.id?.open_id?.trim() ?? '';
|
||||||
|
// VC invited origin/ownership diagnostics:
|
||||||
|
// - This handler is only reachable from the WebSocket monitor path.
|
||||||
|
// - We still log app_id/bot_open_id so operators can confirm the event
|
||||||
|
// is delivered to the expected bot/account, and see which required
|
||||||
|
// fields are missing when we skip.
|
||||||
|
const expectedAppId = ctx.lark.account.appId ?? '';
|
||||||
|
const eventAppId = event.app_id?.trim() ?? '';
|
||||||
|
log(`feishu[${accountId}]: vc invited event received (ingress=websocket)` +
|
||||||
|
`${eventId ? ` event_id=${eventId}` : ''}` +
|
||||||
|
`${eventAppId ? ` app_id=${eventAppId}` : ' app_id=<missing>'}` +
|
||||||
|
`${expectedAppId ? ` expected_app_id=${expectedAppId}` : ''}` +
|
||||||
|
`${invitedBotOpenId ? ` bot_open_id=${invitedBotOpenId}` : ' bot_open_id=<missing>'}` +
|
||||||
|
`${ctx.lark.botOpenId ? ` expected_bot_open_id=${ctx.lark.botOpenId}` : ''}` +
|
||||||
|
`${event.invite_time ? ` invite_time=${event.invite_time}` : ''}` +
|
||||||
|
` meeting_no_present=${meetingNo ? 'true' : 'false'}` +
|
||||||
|
` sender_present=${senderId ? 'true' : 'false'}` +
|
||||||
|
` sender_from=${sender.fromFallback}`);
|
||||||
|
if (!meetingNo) {
|
||||||
|
log(`feishu[${accountId}]: vc invited event missing meeting_no, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!senderId) {
|
||||||
|
log(`feishu[${accountId}]: vc invited event missing inviter identity, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.lark.botOpenId && invitedBotOpenId && invitedBotOpenId !== ctx.lark.botOpenId) {
|
||||||
|
log(`feishu[${accountId}]: vc invited event for another bot, expected=${ctx.lark.botOpenId}, got=${invitedBotOpenId}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prefer event_id when the SDK exposes it: historical raw payload logs
|
||||||
|
// show WebSocket reconnect replays reuse the same event_id, while a real
|
||||||
|
// second invitation yields a new event_id even for the same meeting/bot.
|
||||||
|
// Fallback to (meeting_no, bot) only when event_id is absent so older
|
||||||
|
// payload shapes still remain deduplicated.
|
||||||
|
const dedupBotKey = ctx.lark.botOpenId ?? invitedBotOpenId ?? 'no-bot';
|
||||||
|
const dedupKey = eventId ? `vc-invited:by-event:${eventId}` : `vc-invited:by-meeting:${meetingNo}:${dedupBotKey}`;
|
||||||
|
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
|
||||||
|
log(`feishu[${accountId}]: duplicate vc invited event detected, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`feishu[${accountId}]: vc invited event accepted for synthetic dispatch`);
|
||||||
|
await (0, vc_meeting_invited_handler_1.handleFeishuVcMeetingInvited)({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
event,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
chatHistories: ctx.chatHistories,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
error(`feishu[${accountId}]: error handling vc invited event: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drive comment handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function handleCommentEvent(ctx, data) {
|
||||||
|
if (!isEventOwnershipValid(ctx, data))
|
||||||
|
return;
|
||||||
|
const { accountId, log, error } = ctx;
|
||||||
|
try {
|
||||||
|
const parsed = (0, comment_context_1.parseFeishuDriveCommentNoticeEventPayload)(data);
|
||||||
|
if (!parsed) {
|
||||||
|
log(`feishu[${accountId}]: invalid comment event payload, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commentId = parsed.comment_id ?? '';
|
||||||
|
const replyId = parsed.reply_id ?? '';
|
||||||
|
// Parser has normalized notice_meta fields into canonical top-level fields
|
||||||
|
const _senderOpenId = parsed.user_id?.open_id ?? '';
|
||||||
|
const isMentioned = parsed.is_mention ?? false;
|
||||||
|
const eventTimestamp = parsed.action_time;
|
||||||
|
log(`feishu[${accountId}]: drive comment event: ` +
|
||||||
|
`type=${parsed.file_type}, comment=${commentId}` +
|
||||||
|
`${replyId ? `, reply=${replyId}` : ''}` +
|
||||||
|
`${isMentioned ? ', @bot' : ''}`);
|
||||||
|
// Dedup: build a deterministic key from the comment/reply IDs
|
||||||
|
const dedupKey = replyId ? `comment:${commentId}:reply:${replyId}` : `comment:${commentId}`;
|
||||||
|
if (!ctx.messageDedup.tryRecord(dedupKey, accountId)) {
|
||||||
|
log(`feishu[${accountId}]: duplicate comment event ${dedupKey}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Expiry check
|
||||||
|
if ((0, dedup_1.isMessageExpired)(eventTimestamp)) {
|
||||||
|
log(`feishu[${accountId}]: comment event expired, discarding`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dispatch the comment event (no queue serialization needed for comment threads)
|
||||||
|
await (0, comment_handler_1.handleFeishuCommentEvent)({
|
||||||
|
cfg: ctx.cfg,
|
||||||
|
event: parsed,
|
||||||
|
botOpenId: ctx.lark.botOpenId,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
chatHistories: ctx.chatHistories,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
error(`feishu[${accountId}]: error handling comment event: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card action handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function handleCardActionEvent(ctx, data) {
|
||||||
|
try {
|
||||||
|
// AskUserQuestion:表单卡片交互(宿主内建能力优先)
|
||||||
|
const askResult = (0, ask_user_question_1.handleAskUserAction)(data, ctx.cfg, ctx.accountId);
|
||||||
|
if (askResult !== undefined)
|
||||||
|
return askResult;
|
||||||
|
// auto-auth:授权/权限引导相关卡片交互(宿主内建能力优先)
|
||||||
|
const authResult = await (0, auto_auth_1.handleCardAction)(data, ctx.cfg, ctx.accountId);
|
||||||
|
if (authResult !== undefined)
|
||||||
|
return authResult;
|
||||||
|
// 业务自定义卡片交互:使用 SDK 标准 interactive dispatch 管道转发给业务插件。
|
||||||
|
return await (0, interactive_dispatch_1.dispatchFeishuPluginInteractiveHandler)({ cfg: ctx.cfg, accountId: ctx.accountId, data });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
elog.warn(`card.action.trigger handler error: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Feishu interactive dispatch wrapper.
|
||||||
|
*
|
||||||
|
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
|
||||||
|
* standard interactive dispatch pipeline:
|
||||||
|
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
|
||||||
|
* - Channel forwards via `dispatchPluginInteractiveHandler()`
|
||||||
|
*
|
||||||
|
* We intentionally do NOT maintain any channel-local global registry here.
|
||||||
|
*/
|
||||||
|
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||||
|
export type FeishuInteractiveHandlerResponse = unknown;
|
||||||
|
export interface FeishuInteractiveHandlerContext {
|
||||||
|
channel: 'feishu';
|
||||||
|
accountId: string;
|
||||||
|
senderId?: string;
|
||||||
|
conversationId?: string;
|
||||||
|
messageId?: string;
|
||||||
|
namespace: string;
|
||||||
|
payload: string;
|
||||||
|
action: string;
|
||||||
|
rawEvent: unknown;
|
||||||
|
respond: {
|
||||||
|
reply: (args: {
|
||||||
|
text: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
followUp: (args: {
|
||||||
|
text: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Best-effort "edit current message" mapping.
|
||||||
|
* In Feishu, we prefer updating the original interactive card when possible.
|
||||||
|
*/
|
||||||
|
editMessage: (args: {
|
||||||
|
text?: string;
|
||||||
|
blocks?: unknown[];
|
||||||
|
}) => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Dispatch a Feishu interactive card action to business plugins through
|
||||||
|
* the OpenClaw SDK's standard interactive dispatch pipeline.
|
||||||
|
*
|
||||||
|
* Returns `undefined` when:
|
||||||
|
* - the event does not look like an interactive action we can route, or
|
||||||
|
* - no plugin handler is registered for the derived namespace.
|
||||||
|
*
|
||||||
|
* @param params.cfg - OpenClaw config snapshot.
|
||||||
|
* @param params.accountId - Current Feishu account id.
|
||||||
|
* @param params.data - Raw `card.action.trigger` event payload.
|
||||||
|
*/
|
||||||
|
export declare function dispatchFeishuPluginInteractiveHandler(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId: string;
|
||||||
|
data: unknown;
|
||||||
|
}): Promise<unknown | undefined>;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Feishu interactive dispatch wrapper.
|
||||||
|
*
|
||||||
|
* This module adapts Feishu `card.action.trigger` events into OpenClaw's
|
||||||
|
* standard interactive dispatch pipeline:
|
||||||
|
* - Plugins register via `api.registerInteractiveHandler({ channel, namespace, handler })`
|
||||||
|
* - Channel forwards via `dispatchPluginInteractiveHandler()`
|
||||||
|
*
|
||||||
|
* We intentionally do NOT maintain any channel-local global registry here.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.dispatchFeishuPluginInteractiveHandler = dispatchFeishuPluginInteractiveHandler;
|
||||||
|
// NOTE: This is the SDK-standard interactive pipeline.
|
||||||
|
const plugin_runtime_1 = require("openclaw/plugin-sdk/plugin-runtime");
|
||||||
|
const card_action_operator_1 = require("../core/card-action-operator.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const send_1 = require("../messaging/outbound/send.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('channel/interactive-dispatch');
|
||||||
|
function extractBasics(data) {
|
||||||
|
try {
|
||||||
|
const ev = data;
|
||||||
|
const action = ev.action?.value?.action;
|
||||||
|
if (!action || typeof action !== 'string')
|
||||||
|
return null;
|
||||||
|
const openChatId = ev.open_chat_id ?? ev.context?.open_chat_id;
|
||||||
|
const openMessageId = ev.open_message_id ?? ev.context?.open_message_id;
|
||||||
|
return {
|
||||||
|
action: action.trim(),
|
||||||
|
senderOpenId: (0, card_action_operator_1.resolveCardCallbackOperatorId)(ev.operator),
|
||||||
|
openChatId,
|
||||||
|
openMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildMarkdownCard(text) {
|
||||||
|
return {
|
||||||
|
schema: '2.0',
|
||||||
|
body: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
tag: 'markdown',
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Dispatch a Feishu interactive card action to business plugins through
|
||||||
|
* the OpenClaw SDK's standard interactive dispatch pipeline.
|
||||||
|
*
|
||||||
|
* Returns `undefined` when:
|
||||||
|
* - the event does not look like an interactive action we can route, or
|
||||||
|
* - no plugin handler is registered for the derived namespace.
|
||||||
|
*
|
||||||
|
* @param params.cfg - OpenClaw config snapshot.
|
||||||
|
* @param params.accountId - Current Feishu account id.
|
||||||
|
* @param params.data - Raw `card.action.trigger` event payload.
|
||||||
|
*/
|
||||||
|
async function dispatchFeishuPluginInteractiveHandler(params) {
|
||||||
|
const basics = extractBasics(params.data);
|
||||||
|
if (!basics)
|
||||||
|
return undefined;
|
||||||
|
if (!basics.action)
|
||||||
|
return undefined;
|
||||||
|
const respond = {
|
||||||
|
reply: async (args) => {
|
||||||
|
if (!basics.openChatId || !String(args?.text || '').trim())
|
||||||
|
return;
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
to: basics.openChatId,
|
||||||
|
text: String(args?.text || ''),
|
||||||
|
replyToMessageId: basics.openMessageId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
replyInThread: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
followUp: async (args) => {
|
||||||
|
if (!basics.openChatId || !String(args?.text || '').trim())
|
||||||
|
return;
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
to: basics.openChatId,
|
||||||
|
text: String(args?.text || ''),
|
||||||
|
replyToMessageId: basics.openMessageId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
replyInThread: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
editMessage: async (args) => {
|
||||||
|
if (!basics.openMessageId) {
|
||||||
|
if (Array.isArray(args?.blocks) && args.blocks.length && basics.openChatId) {
|
||||||
|
await (0, send_1.sendCardFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
to: basics.openChatId,
|
||||||
|
card: { schema: '2.0', body: { elements: args.blocks } },
|
||||||
|
replyToMessageId: basics.openMessageId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
replyInThread: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof args?.text === 'string' && args.text.trim() && basics.openChatId) {
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
to: basics.openChatId,
|
||||||
|
text: args.text,
|
||||||
|
replyToMessageId: basics.openMessageId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
replyInThread: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(args?.blocks) && args.blocks.length) {
|
||||||
|
await (0, send_1.updateCardFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
messageId: basics.openMessageId,
|
||||||
|
card: { schema: '2.0', body: { elements: args.blocks } },
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof args?.text === 'string' && args.text.trim()) {
|
||||||
|
await (0, send_1.updateCardFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
messageId: basics.openMessageId,
|
||||||
|
card: buildMarkdownCard(args.text),
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await (0, send_1.updateCardFeishu)({
|
||||||
|
cfg: params.cfg,
|
||||||
|
messageId: basics.openMessageId,
|
||||||
|
card: { schema: '2.0', body: { elements: [] } },
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const dedupeId = `feishu:${params.accountId}:${basics.openChatId ?? '-'}:${basics.openMessageId ?? '-'}:${basics.senderOpenId ?? '-'}:${basics.action}`;
|
||||||
|
let cardResponse;
|
||||||
|
const result = await (0, plugin_runtime_1.dispatchPluginInteractiveHandler)({
|
||||||
|
channel: 'feishu',
|
||||||
|
data: basics.action,
|
||||||
|
dedupeId,
|
||||||
|
invoke: async (match) => {
|
||||||
|
const { registration, namespace, payload } = match;
|
||||||
|
const handlerCtx = {
|
||||||
|
channel: 'feishu',
|
||||||
|
accountId: params.accountId,
|
||||||
|
senderId: basics.senderOpenId,
|
||||||
|
conversationId: basics.openChatId,
|
||||||
|
messageId: basics.openMessageId,
|
||||||
|
namespace,
|
||||||
|
payload,
|
||||||
|
action: basics.action,
|
||||||
|
rawEvent: params.data,
|
||||||
|
respond,
|
||||||
|
};
|
||||||
|
cardResponse = await registration.handler(handlerCtx);
|
||||||
|
// If the handler returns a card response, treat it as handled.
|
||||||
|
return { handled: cardResponse !== undefined };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.matched)
|
||||||
|
return undefined;
|
||||||
|
return cardResponse;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
log.warn(`interactive dispatch failed: ${String(err)}`);
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
type: 'error',
|
||||||
|
content: '交互处理失败,请稍后重试',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.monitorFeishuProvider = monitorFeishuProvider;
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const shutdown_hooks_1 = require("../core/shutdown-hooks.js");
|
||||||
|
const dedup_1 = require("../messaging/inbound/dedup.js");
|
||||||
|
const event_handlers_1 = require("./event-handlers.js");
|
||||||
|
const mlog = (0, lark_logger_1.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 dedup_1.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 = lark_client_1.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 lark_client_1.LarkClient.runtime.config.loadConfig();
|
||||||
|
},
|
||||||
|
lark,
|
||||||
|
accountId,
|
||||||
|
chatHistories,
|
||||||
|
messageDedup,
|
||||||
|
runtime,
|
||||||
|
log,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
await lark.startWS({
|
||||||
|
handlers: {
|
||||||
|
'im.message.receive_v1': (data) => (0, event_handlers_1.handleMessageEvent)(ctx, data),
|
||||||
|
'im.message.message_read_v1': async () => { },
|
||||||
|
'im.message.reaction.created_v1': (data) => (0, event_handlers_1.handleReactionEvent)(ctx, data),
|
||||||
|
// These events are expected in normal usage but do not affect the
|
||||||
|
// plugin's current behavior. Register no-op handlers to avoid SDK
|
||||||
|
// warnings about missing handlers.
|
||||||
|
'im.message.reaction.deleted_v1': async () => { },
|
||||||
|
'im.chat.access_event.bot_p2p_chat_entered_v1': async () => { },
|
||||||
|
'im.chat.member.bot.added_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'added'),
|
||||||
|
'im.chat.member.bot.deleted_v1': (data) => (0, event_handlers_1.handleBotMembershipEvent)(ctx, data, 'removed'),
|
||||||
|
'vc.bot.meeting_invited_v1': (data) => (0, event_handlers_1.handleVcMeetingInvitedEvent)(ctx, data),
|
||||||
|
// Drive comment event — fires when a user adds a comment or reply on a document.
|
||||||
|
'drive.notice.comment_add_v1': (data) => (0, event_handlers_1.handleCommentEvent)(ctx, data),
|
||||||
|
// 飞书 SDK EventDispatcher.register 不支持带返回值的处理器,此处 as any 是 SDK 类型限制的变通
|
||||||
|
'card.action.trigger': ((data) =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(0, event_handlers_1.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).
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
lark_client_1.LarkClient.setGlobalConfig(cfg);
|
||||||
|
const log = opts.runtime?.log ?? ((...args) => mlog.info(args.map(String).join(' ')));
|
||||||
|
// Single-account mode.
|
||||||
|
if (opts.accountId) {
|
||||||
|
const account = (0, accounts_1.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 (0, shutdown_hooks_1.drainShutdownHooks)({ log });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Multi-account mode: start all enabled accounts in parallel.
|
||||||
|
const accounts = (0, accounts_1.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 (0, shutdown_hooks_1.drainShutdownHooks)({ log });
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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 } from 'openclaw/plugin-sdk';
|
||||||
|
import type { DmPolicy } from 'openclaw/plugin-sdk/setup';
|
||||||
|
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,96 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.setFeishuDmPolicy = setFeishuDmPolicy;
|
||||||
|
exports.setFeishuAllowFrom = setFeishuAllowFrom;
|
||||||
|
exports.setFeishuGroupPolicy = setFeishuGroupPolicy;
|
||||||
|
exports.setFeishuGroupAllowFrom = setFeishuGroupAllowFrom;
|
||||||
|
exports.setFeishuGroups = setFeishuGroups;
|
||||||
|
exports.parseAllowFromInput = parseAllowFromInput;
|
||||||
|
const setup_1 = require("openclaw/plugin-sdk/setup");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config mutation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function setFeishuDmPolicy(cfg, dmPolicy) {
|
||||||
|
const allowFrom = dmPolicy === 'open'
|
||||||
|
? (0, setup_1.addWildcardAllowFrom)(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
dmPolicy,
|
||||||
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setFeishuAllowFrom(cfg, allowFrom) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
allowFrom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setFeishuGroupPolicy(cfg, groupPolicy) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
enabled: true,
|
||||||
|
groupPolicy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setFeishuGroupAllowFrom(cfg, groupAllowFrom) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
groupAllowFrom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setFeishuGroups(cfg, groups) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: {
|
||||||
|
...cfg.channels?.feishu,
|
||||||
|
groups,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function parseAllowFromInput(raw) {
|
||||||
|
return raw
|
||||||
|
.split(/[\n,;]+/g)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
@@ -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,70 @@
|
|||||||
|
"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".
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.migrateLegacyGroupAllowFrom = migrateLegacyGroupAllowFrom;
|
||||||
|
const onboarding_config_1 = require("./onboarding-config.js");
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 = (0, onboarding_config_1.setFeishuGroups)(next, migratedGroups);
|
||||||
|
next = (0, onboarding_config_1.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;
|
||||||
|
}
|
||||||
@@ -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 { ChannelSetupWizardAdapter } from 'openclaw/plugin-sdk/setup';
|
||||||
|
export declare const feishuOnboardingAdapter: ChannelSetupWizardAdapter;
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"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.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.feishuOnboardingAdapter = void 0;
|
||||||
|
const account_id_1 = require("openclaw/plugin-sdk/account-id");
|
||||||
|
const setup_1 = require("openclaw/plugin-sdk/setup");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const probe_1 = require("./probe.js");
|
||||||
|
const onboarding_config_1 = require("./onboarding-config.js");
|
||||||
|
const onboarding_migrate_1 = require("./onboarding-migrate.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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: ${(0, setup_1.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 = (0, onboarding_config_1.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 (0, onboarding_config_1.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) => (0, onboarding_config_1.setFeishuDmPolicy)(cfg, policy),
|
||||||
|
promptAllowFrom: promptFeishuAllowFrom,
|
||||||
|
};
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Adapter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
exports.feishuOnboardingAdapter = {
|
||||||
|
channel,
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// getStatus
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
getStatus: async ({ cfg }) => {
|
||||||
|
const feishuCfg = cfg.channels?.feishu;
|
||||||
|
const configured = Boolean((0, accounts_1.getLarkCredentials)(feishuCfg));
|
||||||
|
// Attempt a live probe when credentials are present.
|
||||||
|
let probeResult = null;
|
||||||
|
if (configured && feishuCfg) {
|
||||||
|
try {
|
||||||
|
probeResult = await (0, probe_1.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 = (0, accounts_1.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 (0, probe_1.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 (0, onboarding_migrate_1.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 = (0, onboarding_config_1.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 = (0, onboarding_config_1.parseAllowFromInput)(String(entry));
|
||||||
|
if (parts.length > 0) {
|
||||||
|
next = (0, onboarding_config_1.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: account_id_1.DEFAULT_ACCOUNT_ID };
|
||||||
|
},
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// dmPolicy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
dmPolicy,
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// disable
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
disable: (cfg) => ({
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: { ...cfg.channels?.feishu, enabled: false },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* ChannelPlugin interface implementation for the Lark/Feishu channel.
|
||||||
|
*
|
||||||
|
* This is the top-level entry point that the OpenClaw plugin system uses to
|
||||||
|
* discover capabilities, resolve accounts, obtain outbound adapters, and
|
||||||
|
* start the inbound event gateway.
|
||||||
|
*/
|
||||||
|
import type { ChannelPlugin } from 'openclaw/plugin-sdk';
|
||||||
|
import type { LarkAccount } from '../core/types';
|
||||||
|
export declare const feishuPlugin: ChannelPlugin<LarkAccount>;
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* ChannelPlugin interface implementation for the Lark/Feishu channel.
|
||||||
|
*
|
||||||
|
* This is the top-level entry point that the OpenClaw plugin system uses to
|
||||||
|
* discover capabilities, resolve accounts, obtain outbound adapters, and
|
||||||
|
* start the inbound event gateway.
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.feishuPlugin = void 0;
|
||||||
|
const account_id_1 = require("openclaw/plugin-sdk/account-id");
|
||||||
|
const channel_status_1 = require("openclaw/plugin-sdk/channel-status");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const outbound_1 = require("../messaging/outbound/outbound.js");
|
||||||
|
const actions_1 = require("../messaging/outbound/actions.js");
|
||||||
|
const policy_1 = require("../messaging/inbound/policy.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const send_1 = require("../messaging/outbound/send.js");
|
||||||
|
const targets_1 = require("../core/targets.js");
|
||||||
|
const onboarding_auth_1 = require("../tools/onboarding-auth.js");
|
||||||
|
const lark_logger_1 = require("../core/lark-logger.js");
|
||||||
|
const config_schema_1 = require("../core/config-schema.js");
|
||||||
|
const config_adapter_1 = require("./config-adapter.js");
|
||||||
|
const directory_1 = require("./directory.js");
|
||||||
|
const pluginLog = (0, lark_logger_1.larkLogger)('channel/plugin');
|
||||||
|
/** 状态轮询的探针结果缓存时长(5 分钟)。 */
|
||||||
|
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** Convert nullable SDK params to optional params for directory functions. */
|
||||||
|
function adaptDirectoryParams(params) {
|
||||||
|
return {
|
||||||
|
cfg: params.cfg,
|
||||||
|
query: params.query ?? undefined,
|
||||||
|
limit: params.limit ?? undefined,
|
||||||
|
accountId: params.accountId ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meta
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const meta = {
|
||||||
|
id: 'feishu',
|
||||||
|
label: 'Feishu',
|
||||||
|
selectionLabel: 'Lark/Feishu (\u98DE\u4E66)',
|
||||||
|
docsPath: '/channels/feishu',
|
||||||
|
docsLabel: 'feishu',
|
||||||
|
blurb: '\u98DE\u4E66/Lark enterprise messaging.',
|
||||||
|
aliases: ['lark'],
|
||||||
|
order: 70,
|
||||||
|
};
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Channel plugin definition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
exports.feishuPlugin = {
|
||||||
|
id: 'feishu',
|
||||||
|
meta: {
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Pairing
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
pairing: {
|
||||||
|
idLabel: 'feishuUserId',
|
||||||
|
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ''),
|
||||||
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
|
const accountId = (0, accounts_1.getDefaultLarkAccountId)(cfg);
|
||||||
|
pluginLog.info('notifyApproval called', { id, accountId });
|
||||||
|
// 1. 发送配对成功消息(保持现有行为)
|
||||||
|
await (0, send_1.sendMessageFeishu)({
|
||||||
|
cfg,
|
||||||
|
to: id,
|
||||||
|
text: channel_status_1.PAIRING_APPROVED_MESSAGE,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
// 2. 触发 onboarding
|
||||||
|
try {
|
||||||
|
await (0, onboarding_auth_1.triggerOnboarding)({ cfg, userOpenId: id, accountId });
|
||||||
|
pluginLog.info('onboarding completed', { id });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
pluginLog.warn('onboarding failed', { id, error: String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Capabilities
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
capabilities: {
|
||||||
|
chatTypes: ['direct', 'group'],
|
||||||
|
media: true,
|
||||||
|
reactions: true,
|
||||||
|
threads: true,
|
||||||
|
polls: false,
|
||||||
|
nativeCommands: true,
|
||||||
|
blockStreaming: true,
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Agent prompt
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
agentPrompt: {
|
||||||
|
messageToolHints: () => [
|
||||||
|
'- Feishu targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:open_id` or `chat:chat_id`.',
|
||||||
|
'- Feishu supports interactive cards for rich messages.',
|
||||||
|
'- Feishu reactions use UPPERCASE emoji type names (e.g. `OK`,`THUMBSUP`,`THANKS`,`MUSCLE`,`FINGERHEART`,`APPLAUSE`,`FISTBUMP`,`JIAYI`,`DONE`,`SMILE`,`BLUSH` ), not Unicode emoji characters.',
|
||||||
|
"- Feishu `action=delete`/`action=unsend` only deletes messages sent by the bot. When the user quotes a message and says 'delete this', use the **quoted message's** message_id, not the user's own message_id.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Groups
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
groups: {
|
||||||
|
resolveToolPolicy: policy_1.resolveFeishuGroupToolPolicy,
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reload
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
reload: { configPrefixes: ['channels.feishu'] },
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Config schema (JSON Schema)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
configSchema: {
|
||||||
|
schema: config_schema_1.FEISHU_CONFIG_JSON_SCHEMA,
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Config adapter
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
config: {
|
||||||
|
listAccountIds: (cfg) => (0, accounts_1.getLarkAccountIds)(cfg),
|
||||||
|
resolveAccount: (cfg, accountId) => (0, accounts_1.getLarkAccount)(cfg, accountId),
|
||||||
|
defaultAccountId: (cfg) => (0, accounts_1.getDefaultLarkAccountId)(cfg),
|
||||||
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
|
return (0, config_adapter_1.setAccountEnabled)(cfg, accountId, enabled);
|
||||||
|
},
|
||||||
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
|
return (0, config_adapter_1.deleteAccount)(cfg, accountId);
|
||||||
|
},
|
||||||
|
isConfigured: (account) => account.configured,
|
||||||
|
describeAccount: (account) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
name: account.name,
|
||||||
|
appId: account.appId,
|
||||||
|
brand: account.brand,
|
||||||
|
}),
|
||||||
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(cfg, accountId);
|
||||||
|
return (account.config?.allowFrom ?? []).map((entry) => String(entry));
|
||||||
|
},
|
||||||
|
formatAllowFrom: ({ allowFrom }) => allowFrom
|
||||||
|
.map((entry) => String(entry).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((entry) => entry.toLowerCase()),
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Security
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
security: {
|
||||||
|
collectWarnings: ({ cfg, accountId }) => (0, config_adapter_1.collectFeishuSecurityWarnings)({ cfg, accountId: accountId ?? account_id_1.DEFAULT_ACCOUNT_ID }),
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Setup
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
setup: {
|
||||||
|
resolveAccountId: () => account_id_1.DEFAULT_ACCOUNT_ID,
|
||||||
|
applyAccountConfig: ({ cfg, accountId }) => {
|
||||||
|
return (0, config_adapter_1.applyAccountConfig)(cfg, accountId, { enabled: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Messaging
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
messaging: {
|
||||||
|
normalizeTarget: (raw) => (0, targets_1.normalizeFeishuTarget)(raw) ?? undefined,
|
||||||
|
targetResolver: {
|
||||||
|
looksLikeId: targets_1.looksLikeFeishuId,
|
||||||
|
hint: '<chatId|user:openId|chat:chatId>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Directory
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
directory: {
|
||||||
|
self: async () => null,
|
||||||
|
listPeers: async (p) => (0, directory_1.listFeishuDirectoryPeers)(adaptDirectoryParams(p)),
|
||||||
|
listGroups: async (p) => (0, directory_1.listFeishuDirectoryGroups)(adaptDirectoryParams(p)),
|
||||||
|
listPeersLive: async (p) => (0, directory_1.listFeishuDirectoryPeersLive)(adaptDirectoryParams(p)),
|
||||||
|
listGroupsLive: async (p) => (0, directory_1.listFeishuDirectoryGroupsLive)(adaptDirectoryParams(p)),
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Outbound
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
outbound: outbound_1.feishuOutbound,
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Threading
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
threading: {
|
||||||
|
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||||
|
currentChannelId: (0, targets_1.normalizeFeishuTarget)(context.To ?? '') ?? undefined,
|
||||||
|
currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||||
|
currentMessageId: context.CurrentMessageId,
|
||||||
|
hasRepliedRef,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
actions: actions_1.feishuMessageActions,
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Status
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
status: {
|
||||||
|
defaultRuntime: {
|
||||||
|
accountId: account_id_1.DEFAULT_ACCOUNT_ID,
|
||||||
|
running: false,
|
||||||
|
lastStartAt: null,
|
||||||
|
lastStopAt: null,
|
||||||
|
lastError: null,
|
||||||
|
port: null,
|
||||||
|
},
|
||||||
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
|
configured: snapshot.configured ?? false,
|
||||||
|
running: snapshot.running ?? false,
|
||||||
|
lastStartAt: snapshot.lastStartAt ?? null,
|
||||||
|
lastStopAt: snapshot.lastStopAt ?? null,
|
||||||
|
lastError: snapshot.lastError ?? null,
|
||||||
|
port: snapshot.port ?? null,
|
||||||
|
probe: snapshot.probe,
|
||||||
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
|
}),
|
||||||
|
probeAccount: async ({ account }) => {
|
||||||
|
return await lark_client_1.LarkClient.fromAccount(account).probe({ maxAgeMs: PROBE_CACHE_TTL_MS });
|
||||||
|
},
|
||||||
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
|
accountId: account.accountId,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
name: account.name,
|
||||||
|
appId: account.appId,
|
||||||
|
brand: account.brand,
|
||||||
|
running: runtime?.running ?? false,
|
||||||
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
|
lastError: runtime?.lastError ?? null,
|
||||||
|
port: runtime?.port ?? null,
|
||||||
|
probe,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Gateway
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
gateway: {
|
||||||
|
startAccount: async (ctx) => {
|
||||||
|
const { monitorFeishuProvider } = await Promise.resolve().then(() => __importStar(require('./monitor.js')));
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(ctx.cfg, ctx.accountId);
|
||||||
|
const port = account.config?.webhookPort ?? null;
|
||||||
|
ctx.setStatus({ accountId: ctx.accountId, port });
|
||||||
|
ctx.log?.info(`starting feishu[${ctx.accountId}] (mode: ${account.config?.connectionMode ?? 'websocket'})`);
|
||||||
|
return monitorFeishuProvider({
|
||||||
|
config: ctx.cfg,
|
||||||
|
runtime: ctx.runtime,
|
||||||
|
abortSignal: ctx.abortSignal,
|
||||||
|
accountId: ctx.accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stopAccount: async (ctx) => {
|
||||||
|
ctx.log?.info(`stopping feishu[${ctx.accountId}]`);
|
||||||
|
await lark_client_1.LarkClient.clearCache(ctx.accountId);
|
||||||
|
ctx.log?.info(`stopped feishu[${ctx.accountId}]`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
import { type LarkClientCredentials } from '../core/lark-client';
|
||||||
|
import type { FeishuProbeResult } from './types';
|
||||||
|
/**
|
||||||
|
* Probe the Feishu bot connection by calling the bot/v3/info API.
|
||||||
|
*
|
||||||
|
* Returns a result indicating whether the bot is reachable and its
|
||||||
|
* basic identity (name, open_id). Used by onboarding and status
|
||||||
|
* checks to verify credentials before committing them to config.
|
||||||
|
*/
|
||||||
|
export declare function probeFeishu(credentials?: LarkClientCredentials): Promise<FeishuProbeResult>;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.probeFeishu = probeFeishu;
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
/**
|
||||||
|
* Probe the Feishu bot connection by calling the bot/v3/info API.
|
||||||
|
*
|
||||||
|
* Returns a result indicating whether the bot is reachable and its
|
||||||
|
* basic identity (name, open_id). Used by onboarding and status
|
||||||
|
* checks to verify credentials before committing them to config.
|
||||||
|
*/
|
||||||
|
async function probeFeishu(credentials) {
|
||||||
|
if (!credentials?.appId || !credentials?.appSecret) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'missing credentials (appId, appSecret)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return lark_client_1.LarkClient.fromCredentials(credentials).probe();
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Channel type definitions for the Lark/Feishu channel plugin.
|
||||||
|
*/
|
||||||
|
import type { ClawdbotConfig, RuntimeEnv } from 'openclaw/plugin-sdk';
|
||||||
|
import type { HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
|
||||||
|
import type { LarkClient } from '../core/lark-client';
|
||||||
|
import type { MessageDedup } from '../messaging/inbound/dedup';
|
||||||
|
export type { FeishuProbeResult } from '../core/types';
|
||||||
|
export interface MonitorFeishuOpts {
|
||||||
|
config?: ClawdbotConfig;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
export interface FeishuDirectoryPeer {
|
||||||
|
kind: 'user';
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
export interface FeishuDirectoryGroup {
|
||||||
|
kind: 'group';
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
export interface MonitorContext {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
lark: LarkClient;
|
||||||
|
accountId: string;
|
||||||
|
chatHistories: Map<string, HistoryEntry[]>;
|
||||||
|
messageDedup: MessageDedup;
|
||||||
|
runtime?: RuntimeEnv;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
error: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Channel type definitions for the Lark/Feishu channel plugin.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* feishu_auth command — 飞书用户权限批量授权命令实现
|
||||||
|
*
|
||||||
|
* 直接复用 onboarding-auth.ts 的 triggerOnboarding() 函数。
|
||||||
|
* 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
|
||||||
|
*/
|
||||||
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
||||||
|
import type { FeishuLocale } from './locale';
|
||||||
|
/**
|
||||||
|
* 执行飞书用户权限批量授权命令
|
||||||
|
* 直接调用 triggerOnboarding(),包含 owner 检查
|
||||||
|
*/
|
||||||
|
export declare function runFeishuAuth(config: OpenClawConfig, locale?: FeishuLocale): Promise<string>;
|
||||||
|
/**
|
||||||
|
* 运行飞书授权命令,同时生成中英双语结果。
|
||||||
|
* 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
|
||||||
|
*/
|
||||||
|
export declare function runFeishuAuthI18n(config: OpenClawConfig): Promise<Record<FeishuLocale, string>>;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* feishu_auth command — 飞书用户权限批量授权命令实现
|
||||||
|
*
|
||||||
|
* 直接复用 onboarding-auth.ts 的 triggerOnboarding() 函数。
|
||||||
|
* 注意:此命令仅限应用 owner 执行(与 onboarding 逻辑一致)
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.runFeishuAuth = runFeishuAuth;
|
||||||
|
exports.runFeishuAuthI18n = runFeishuAuthI18n;
|
||||||
|
const onboarding_auth_1 = require("../tools/onboarding-auth.js");
|
||||||
|
const lark_ticket_1 = require("../core/lark-ticket.js");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
const app_scope_checker_1 = require("../core/app-scope-checker.js");
|
||||||
|
const token_store_1 = require("../core/token-store.js");
|
||||||
|
const tool_scopes_1 = require("../core/tool-scopes.js");
|
||||||
|
const owner_policy_1 = require("../core/owner-policy.js");
|
||||||
|
const domains_1 = require("../core/domains.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// I18n text map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const T = {
|
||||||
|
zh_cn: {
|
||||||
|
noIdentity: '❌ 无法获取用户身份,请在飞书对话中使用此命令',
|
||||||
|
accountIncomplete: (accountId) => `❌ 账号 ${accountId} 配置不完整`,
|
||||||
|
missingSelfManage: (link) => `❌ 应用缺少核心权限 application:application:self_manage,无法查询可授权 scope 列表。\n\n请管理员在飞书开放平台开通此权限后重试:[申请权限](${link})`,
|
||||||
|
ownerOnly: '❌ 此命令仅限应用 owner 执行\n\n如需授权,请联系应用管理员。',
|
||||||
|
missingOfflineAccess: (link) => `❌ 应用缺少核心权限 offline_access,无法查询可授权 scope 列表。\n\n请管理员在飞书开放平台开通此权限后重试:[申请权限](${link})`,
|
||||||
|
noUserScopes: '当前应用未开通任何用户级权限,无需授权。',
|
||||||
|
allAuthorized: (count) => `✅ 您已授权所有可用权限(共 ${count} 个),无需重复授权。`,
|
||||||
|
authSent: '✅ 已发送授权请求',
|
||||||
|
},
|
||||||
|
en_us: {
|
||||||
|
noIdentity: '❌ Unable to identify user. Please use this command in a Feishu conversation.',
|
||||||
|
accountIncomplete: (accountId) => `❌ Account ${accountId} configuration is incomplete`,
|
||||||
|
missingSelfManage: (link) => `❌ App is missing the core permission application:application:self_manage and cannot query available scopes.\n\nPlease ask an admin to grant this permission on the Feishu Open Platform: [Apply](${link})`,
|
||||||
|
ownerOnly: '❌ This command is restricted to the app owner.\n\nPlease contact the app admin for authorization.',
|
||||||
|
missingOfflineAccess: (link) => `❌ App is missing the core permission offline_access and cannot query available scopes.\n\nPlease ask an admin to grant this permission on the Feishu Open Platform: [Apply](${link})`,
|
||||||
|
noUserScopes: 'No user-level permissions are enabled for this app. Authorization is not needed.',
|
||||||
|
allAuthorized: (count) => `✅ You have authorized all available permissions (${count} total). No re-authorization needed.`,
|
||||||
|
authSent: '✅ Authorization request sent',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Format an AuthResult into a locale-specific message string.
|
||||||
|
*/
|
||||||
|
function formatAuthResult(result, locale) {
|
||||||
|
const t = T[locale];
|
||||||
|
switch (result.kind) {
|
||||||
|
case 'no_identity':
|
||||||
|
return t.noIdentity;
|
||||||
|
case 'account_incomplete':
|
||||||
|
return t.accountIncomplete(result.accountId);
|
||||||
|
case 'missing_self_manage':
|
||||||
|
return t.missingSelfManage(result.link);
|
||||||
|
case 'owner_only':
|
||||||
|
return t.ownerOnly;
|
||||||
|
case 'missing_offline_access':
|
||||||
|
return t.missingOfflineAccess(result.link);
|
||||||
|
case 'no_user_scopes':
|
||||||
|
return t.noUserScopes;
|
||||||
|
case 'all_authorized':
|
||||||
|
return t.allAuthorized(result.count);
|
||||||
|
case 'auth_sent':
|
||||||
|
return t.authSent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core logic (executes side-effects exactly once)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Execute the auth command logic, including side-effects (triggerOnboarding).
|
||||||
|
* Returns a discriminated result that can be formatted into any locale.
|
||||||
|
*/
|
||||||
|
async function executeFeishuAuth(config) {
|
||||||
|
const ticket = (0, lark_ticket_1.getTicket)();
|
||||||
|
const senderOpenId = ticket?.senderOpenId;
|
||||||
|
if (!senderOpenId) {
|
||||||
|
return { kind: 'no_identity' };
|
||||||
|
}
|
||||||
|
// 提前检查 owner 身份,给出明确提示
|
||||||
|
const acct = (0, accounts_1.getLarkAccount)(config, ticket.accountId);
|
||||||
|
if (!acct.configured) {
|
||||||
|
return { kind: 'account_incomplete', accountId: ticket.accountId };
|
||||||
|
}
|
||||||
|
const sdk = lark_client_1.LarkClient.fromAccount(acct).sdk;
|
||||||
|
const { appId } = acct;
|
||||||
|
const openDomain = (0, domains_1.openPlatformDomain)(acct.brand);
|
||||||
|
try {
|
||||||
|
await (0, app_scope_checker_1.getAppInfo)(sdk, appId);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
const link = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
return { kind: 'missing_self_manage', link };
|
||||||
|
}
|
||||||
|
// Owner 检查(fail-close: 授权命令安全优先)
|
||||||
|
try {
|
||||||
|
await (0, owner_policy_1.assertOwnerAccessStrict)(acct, sdk, senderOpenId);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
if (err instanceof owner_policy_1.OwnerAccessDeniedError) {
|
||||||
|
return { kind: 'owner_only' };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// 预检:是否还有未授权的 scope
|
||||||
|
let appScopes;
|
||||||
|
try {
|
||||||
|
appScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'user');
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
const link = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
return { kind: 'missing_self_manage', link };
|
||||||
|
}
|
||||||
|
// offline_access 预检 — OAuth 必须的前提权限
|
||||||
|
const allScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId);
|
||||||
|
if (allScopes.length > 0 && !allScopes.includes('offline_access')) {
|
||||||
|
const link = `${openDomain}/app/${appId}/auth?q=offline_access&op_from=feishu-openclaw&token_type=user`;
|
||||||
|
return { kind: 'missing_offline_access', link };
|
||||||
|
}
|
||||||
|
appScopes = (0, tool_scopes_1.filterSensitiveScopes)(appScopes);
|
||||||
|
if (appScopes.length === 0) {
|
||||||
|
return { kind: 'no_user_scopes' };
|
||||||
|
}
|
||||||
|
const existing = await (0, token_store_1.getStoredToken)(appId, senderOpenId);
|
||||||
|
const tokenValid = existing && (0, token_store_1.tokenStatus)(existing) !== 'expired';
|
||||||
|
const grantedScopes = new Set(tokenValid ? (existing.scope?.split(/\s+/).filter(Boolean) ?? []) : []);
|
||||||
|
const missingScopes = appScopes.filter((s) => !grantedScopes.has(s));
|
||||||
|
if (missingScopes.length === 0) {
|
||||||
|
return { kind: 'all_authorized', count: appScopes.length };
|
||||||
|
}
|
||||||
|
// 调用 triggerOnboarding 执行批量授权(副作用,只执行一次)
|
||||||
|
await (0, onboarding_auth_1.triggerOnboarding)({
|
||||||
|
cfg: config,
|
||||||
|
userOpenId: senderOpenId,
|
||||||
|
accountId: ticket.accountId,
|
||||||
|
});
|
||||||
|
return { kind: 'auth_sent' };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 执行飞书用户权限批量授权命令
|
||||||
|
* 直接调用 triggerOnboarding(),包含 owner 检查
|
||||||
|
*/
|
||||||
|
async function runFeishuAuth(config, locale = 'zh_cn') {
|
||||||
|
const result = await executeFeishuAuth(config);
|
||||||
|
return formatAuthResult(result, locale);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 运行飞书授权命令,同时生成中英双语结果。
|
||||||
|
* 副作用(triggerOnboarding)只执行一次,结果格式化为双语文本。
|
||||||
|
*/
|
||||||
|
async function runFeishuAuthI18n(config) {
|
||||||
|
const result = await executeFeishuAuth(config);
|
||||||
|
return {
|
||||||
|
zh_cn: formatAuthResult(result, 'zh_cn'),
|
||||||
|
en_us: formatAuthResult(result, 'en_us'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Diagnostic module for the Lark/Feishu plugin.
|
||||||
|
*
|
||||||
|
* Collects environment info, account configuration, API connectivity,
|
||||||
|
* app permissions, tool registration state, and recent error logs into
|
||||||
|
* a structured report that users can share with developers for
|
||||||
|
* remote troubleshooting.
|
||||||
|
*/
|
||||||
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
||||||
|
interface DiagLogger {
|
||||||
|
info: (message: string) => void;
|
||||||
|
warn: (message: string) => void;
|
||||||
|
error: (message: string) => void;
|
||||||
|
}
|
||||||
|
type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip';
|
||||||
|
interface DiagCheckResult {
|
||||||
|
name: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
message: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
interface AccountDiagResult {
|
||||||
|
accountId: string;
|
||||||
|
name?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
configured: boolean;
|
||||||
|
appId?: string;
|
||||||
|
brand: string;
|
||||||
|
checks: DiagCheckResult[];
|
||||||
|
}
|
||||||
|
interface DiagReport {
|
||||||
|
timestamp: string;
|
||||||
|
environment: {
|
||||||
|
nodeVersion: string;
|
||||||
|
platform: string;
|
||||||
|
arch: string;
|
||||||
|
pluginVersion: string;
|
||||||
|
};
|
||||||
|
accounts: AccountDiagResult[];
|
||||||
|
toolsRegistered: string[];
|
||||||
|
recentErrors: string[];
|
||||||
|
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
checks: DiagCheckResult[];
|
||||||
|
}
|
||||||
|
export declare function runDiagnosis(params: {
|
||||||
|
config: OpenClawConfig;
|
||||||
|
logger?: DiagLogger;
|
||||||
|
}): Promise<DiagReport>;
|
||||||
|
export declare function formatDiagReportText(report: DiagReport): string;
|
||||||
|
/**
|
||||||
|
* Extract all log lines tagged with a specific message_id from gateway.log.
|
||||||
|
*
|
||||||
|
* Scans the last 1MB of the log file for lines containing `[msg:{messageId}]`.
|
||||||
|
* Returns matching lines in chronological order.
|
||||||
|
*/
|
||||||
|
export declare function traceByMessageId(messageId: string): Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* Format trace output for CLI display.
|
||||||
|
*/
|
||||||
|
export declare function formatTraceOutput(lines: string[], messageId: string): string;
|
||||||
|
/**
|
||||||
|
* Analyze trace log lines and produce a structured CLI report.
|
||||||
|
*/
|
||||||
|
export declare function analyzeTrace(lines: string[], _messageId: string): string;
|
||||||
|
export declare function formatDiagReportCli(report: DiagReport): string;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Diagnostic module for the Lark/Feishu plugin.
|
||||||
|
*
|
||||||
|
* Collects environment info, account configuration, API connectivity,
|
||||||
|
* app permissions, tool registration state, and recent error logs into
|
||||||
|
* a structured report that users can share with developers for
|
||||||
|
* remote troubleshooting.
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.runDiagnosis = runDiagnosis;
|
||||||
|
exports.formatDiagReportText = formatDiagReportText;
|
||||||
|
exports.traceByMessageId = traceByMessageId;
|
||||||
|
exports.formatTraceOutput = formatTraceOutput;
|
||||||
|
exports.analyzeTrace = analyzeTrace;
|
||||||
|
exports.formatDiagReportCli = formatDiagReportCli;
|
||||||
|
const fs = __importStar(require("node:fs/promises"));
|
||||||
|
const path = __importStar(require("node:path"));
|
||||||
|
const os = __importStar(require("node:os"));
|
||||||
|
const probe_1 = require("../channel/probe.js");
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
/**
|
||||||
|
* Resolve the global config for cross-account operations.
|
||||||
|
* See doctor.ts for rationale.
|
||||||
|
*/
|
||||||
|
function resolveGlobalConfig(config) {
|
||||||
|
return lark_client_1.LarkClient.globalConfig ?? config;
|
||||||
|
}
|
||||||
|
const api_error_1 = require("../core/api-error.js");
|
||||||
|
const tools_config_1 = require("../core/tools-config.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PLUGIN_VERSION = '2026.2.10';
|
||||||
|
const LOG_READ_BYTES = 256 * 1024; // read last 256KB of log
|
||||||
|
const MAX_ERROR_LINES = 20;
|
||||||
|
/** Matches a timestamped log line: 2026-02-13T09:23:35.038Z [level]: ... */
|
||||||
|
const TIMESTAMPED_LINE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||||
|
const ERROR_LEVEL_RE = /\[error\]|\[warn\]/i;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function maskSecret(secret) {
|
||||||
|
if (!secret)
|
||||||
|
return '(未设置)';
|
||||||
|
if (secret.length <= 4)
|
||||||
|
return '****';
|
||||||
|
return secret.slice(0, 4) + '****';
|
||||||
|
}
|
||||||
|
async function extractRecentErrors(logPath) {
|
||||||
|
try {
|
||||||
|
await fs.access(logPath);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(logPath);
|
||||||
|
const readSize = Math.min(stat.size, LOG_READ_BYTES);
|
||||||
|
const fd = await fs.open(logPath, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(readSize);
|
||||||
|
await fd.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
|
||||||
|
const content = buffer.toString('utf-8');
|
||||||
|
const lines = content.split('\n').filter(Boolean);
|
||||||
|
// Only pick timestamped log entries at error/warn level,
|
||||||
|
// ignoring stack trace fragments and other noise.
|
||||||
|
const errorLines = lines.filter((line) => TIMESTAMPED_LINE_RE.test(line) && ERROR_LEVEL_RE.test(line));
|
||||||
|
return errorLines.slice(-MAX_ERROR_LINES);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function checkAppScopes(client) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const res = await client.application.scope.list({});
|
||||||
|
(0, api_error_1.assertLarkOk)(res);
|
||||||
|
const scopes = res.data?.scopes ?? [];
|
||||||
|
const granted = scopes.filter((s) => s.grant_status === 1);
|
||||||
|
const pending = scopes.filter((s) => s.grant_status !== 1);
|
||||||
|
return {
|
||||||
|
granted: granted.length,
|
||||||
|
pending: pending.length,
|
||||||
|
summary: `${granted.length} 已授权, ${pending.length} 待授权`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function detectRegisteredTools(config) {
|
||||||
|
const accounts = (0, accounts_1.getEnabledLarkAccounts)(config);
|
||||||
|
if (accounts.length === 0)
|
||||||
|
return [];
|
||||||
|
const toolsCfg = (0, tools_config_1.resolveAnyEnabledToolsConfig)(accounts);
|
||||||
|
const tools = [];
|
||||||
|
if (toolsCfg.doc)
|
||||||
|
tools.push('feishu_doc');
|
||||||
|
if (toolsCfg.scopes)
|
||||||
|
tools.push('feishu_app_scopes');
|
||||||
|
if (toolsCfg.wiki)
|
||||||
|
tools.push('feishu_wiki');
|
||||||
|
if (toolsCfg.drive)
|
||||||
|
tools.push('feishu_drive');
|
||||||
|
if (toolsCfg.perm)
|
||||||
|
tools.push('feishu_perm');
|
||||||
|
tools.push('feishu_bitable_get_meta', 'feishu_bitable_list_fields', 'feishu_bitable_list_records', 'feishu_bitable_get_record', 'feishu_bitable_create_record', 'feishu_bitable_update_record');
|
||||||
|
tools.push('feishu_task');
|
||||||
|
tools.push('feishu_calendar');
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
async function diagnoseAccount(account) {
|
||||||
|
const checks = [];
|
||||||
|
const result = {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured: account.configured,
|
||||||
|
appId: account.appId ?? '(未设置)',
|
||||||
|
brand: account.brand,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
// A1: Credentials
|
||||||
|
checks.push({
|
||||||
|
name: '凭证完整性',
|
||||||
|
status: account.configured ? 'pass' : 'fail',
|
||||||
|
message: account.configured
|
||||||
|
? `appId: ${account.appId}, appSecret: ${maskSecret(account.appSecret)}`
|
||||||
|
: '缺少 appId 或 appSecret',
|
||||||
|
});
|
||||||
|
// A2: Enabled
|
||||||
|
checks.push({
|
||||||
|
name: '账户启用',
|
||||||
|
status: account.enabled ? 'pass' : 'warn',
|
||||||
|
message: account.enabled ? '已启用' : '已禁用',
|
||||||
|
});
|
||||||
|
if (!account.configured || !account.appId || !account.appSecret) {
|
||||||
|
checks.push({
|
||||||
|
name: 'API 连通性',
|
||||||
|
status: 'skip',
|
||||||
|
message: '凭证未配置,跳过',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// A3: API connectivity via probe
|
||||||
|
try {
|
||||||
|
const probeResult = await (0, probe_1.probeFeishu)({
|
||||||
|
accountId: account.accountId,
|
||||||
|
appId: account.appId,
|
||||||
|
appSecret: account.appSecret,
|
||||||
|
brand: account.brand,
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
name: 'API 连通性',
|
||||||
|
status: probeResult.ok ? 'pass' : 'fail',
|
||||||
|
message: probeResult.ok ? `连接成功` : `连接失败: ${probeResult.error}`,
|
||||||
|
});
|
||||||
|
// A4: Bot info
|
||||||
|
if (probeResult.ok) {
|
||||||
|
checks.push({
|
||||||
|
name: 'Bot 信息',
|
||||||
|
status: probeResult.botName ? 'pass' : 'warn',
|
||||||
|
message: probeResult.botName ? `${probeResult.botName} (${probeResult.botOpenId})` : '未获取到 Bot 名称',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
checks.push({
|
||||||
|
name: 'API 连通性',
|
||||||
|
status: 'fail',
|
||||||
|
message: `探测异常: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A5: App scopes
|
||||||
|
try {
|
||||||
|
const client = lark_client_1.LarkClient.fromAccount(account).sdk;
|
||||||
|
const scopesResult = await checkAppScopes(client);
|
||||||
|
checks.push({
|
||||||
|
name: '应用权限',
|
||||||
|
status: scopesResult.pending > 0 ? 'warn' : 'pass',
|
||||||
|
message: scopesResult.summary,
|
||||||
|
details: scopesResult.pending > 0 ? '存在未授权的权限,可能影响部分功能' : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
checks.push({
|
||||||
|
name: '应用权限',
|
||||||
|
status: 'warn',
|
||||||
|
message: `权限检查失败: ${(0, api_error_1.formatLarkError)(err)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A6: Brand
|
||||||
|
checks.push({
|
||||||
|
name: '品牌配置',
|
||||||
|
status: 'pass',
|
||||||
|
message: `brand: ${account.brand}`,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function runDiagnosis(params) {
|
||||||
|
const { config } = params;
|
||||||
|
// Use the global config to enumerate all accounts — the passed-in
|
||||||
|
// config may be account-scoped (accounts map stripped).
|
||||||
|
const globalCfg = resolveGlobalConfig(config);
|
||||||
|
const globalChecks = [];
|
||||||
|
// -- Environment --
|
||||||
|
const nodeVer = parseInt(process.version.slice(1), 10);
|
||||||
|
globalChecks.push({
|
||||||
|
name: 'Node.js 版本',
|
||||||
|
status: nodeVer >= 18 ? 'pass' : 'warn',
|
||||||
|
message: process.version,
|
||||||
|
details: nodeVer < 18 ? '建议升级到 Node.js 18+' : undefined,
|
||||||
|
});
|
||||||
|
// -- Account count --
|
||||||
|
const accountIds = (0, accounts_1.getLarkAccountIds)(globalCfg);
|
||||||
|
globalChecks.push({
|
||||||
|
name: '飞书账户数量',
|
||||||
|
status: accountIds.length > 0 ? 'pass' : 'fail',
|
||||||
|
message: `${accountIds.length} 个账户`,
|
||||||
|
});
|
||||||
|
// -- Log file --
|
||||||
|
const logPath = path.join(os.homedir(), '.openclaw', 'logs', 'gateway.log');
|
||||||
|
let logExists = false;
|
||||||
|
try {
|
||||||
|
await fs.access(logPath);
|
||||||
|
logExists = true;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
globalChecks.push({
|
||||||
|
name: '日志文件',
|
||||||
|
status: logExists ? 'pass' : 'warn',
|
||||||
|
message: logExists ? logPath : `未找到: ${logPath}`,
|
||||||
|
});
|
||||||
|
// -- Per-account diagnosis (sequential to avoid rate limits) --
|
||||||
|
const accountResults = [];
|
||||||
|
for (const id of accountIds) {
|
||||||
|
const account = (0, accounts_1.getLarkAccount)(globalCfg, id);
|
||||||
|
const result = await diagnoseAccount(account);
|
||||||
|
accountResults.push(result);
|
||||||
|
}
|
||||||
|
// -- Tools --
|
||||||
|
const tools = detectRegisteredTools(globalCfg);
|
||||||
|
// -- Recent errors --
|
||||||
|
const recentErrors = await extractRecentErrors(logPath);
|
||||||
|
globalChecks.push({
|
||||||
|
name: '最近错误日志',
|
||||||
|
status: recentErrors.length > 0 ? 'warn' : 'pass',
|
||||||
|
message: recentErrors.length > 0 ? `发现 ${recentErrors.length} 条错误` : '无最近错误',
|
||||||
|
});
|
||||||
|
// -- Overall status --
|
||||||
|
const allChecks = [...globalChecks, ...accountResults.flatMap((a) => a.checks)];
|
||||||
|
const hasFail = allChecks.some((c) => c.status === 'fail');
|
||||||
|
const hasWarn = allChecks.some((c) => c.status === 'warn');
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: {
|
||||||
|
nodeVersion: process.version,
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
pluginVersion: PLUGIN_VERSION,
|
||||||
|
},
|
||||||
|
accounts: accountResults,
|
||||||
|
toolsRegistered: tools,
|
||||||
|
recentErrors,
|
||||||
|
overallStatus: hasFail ? 'unhealthy' : hasWarn ? 'degraded' : 'healthy',
|
||||||
|
checks: globalChecks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting — plain text (chat command)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
pass: '[PASS]',
|
||||||
|
warn: '[WARN]',
|
||||||
|
fail: '[FAIL]',
|
||||||
|
skip: '[SKIP]',
|
||||||
|
};
|
||||||
|
function formatCheck(c) {
|
||||||
|
let line = ` ${STATUS_LABEL[c.status]} ${c.name}: ${c.message}`;
|
||||||
|
if (c.details) {
|
||||||
|
line += `\n ${c.details}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
function formatDiagReportText(report) {
|
||||||
|
const lines = [];
|
||||||
|
const sep = '====================================';
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push(' 飞书插件诊断报告');
|
||||||
|
lines.push(` ${report.timestamp}`);
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push('');
|
||||||
|
// Environment
|
||||||
|
lines.push('【环境信息】');
|
||||||
|
lines.push(` Node.js: ${report.environment.nodeVersion}`);
|
||||||
|
lines.push(` 插件版本: ${report.environment.pluginVersion}`);
|
||||||
|
lines.push(` 系统: ${report.environment.platform} ${report.environment.arch}`);
|
||||||
|
lines.push('');
|
||||||
|
// Global checks
|
||||||
|
lines.push('【全局检查】');
|
||||||
|
for (const c of report.checks) {
|
||||||
|
lines.push(formatCheck(c));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
// Per-account
|
||||||
|
for (const acct of report.accounts) {
|
||||||
|
lines.push(`【账户: ${acct.accountId}】`);
|
||||||
|
if (acct.name)
|
||||||
|
lines.push(` 名称: ${acct.name}`);
|
||||||
|
lines.push(` App ID: ${acct.appId}`);
|
||||||
|
lines.push(` 品牌: ${acct.brand}`);
|
||||||
|
lines.push('');
|
||||||
|
for (const c of acct.checks) {
|
||||||
|
lines.push(formatCheck(c));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// Tools
|
||||||
|
lines.push('【工具注册】');
|
||||||
|
if (report.toolsRegistered.length > 0) {
|
||||||
|
lines.push(` ${report.toolsRegistered.join(', ')}`);
|
||||||
|
lines.push(` 共 ${report.toolsRegistered.length} 个`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push(' 无工具注册(未找到已配置的账户)');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
// Recent errors
|
||||||
|
if (report.recentErrors.length > 0) {
|
||||||
|
lines.push(`【最近错误】(${report.recentErrors.length} 条)`);
|
||||||
|
for (let i = 0; i < report.recentErrors.length; i++) {
|
||||||
|
lines.push(` ${i + 1}. ${report.recentErrors[i]}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// Overall
|
||||||
|
const statusMap = {
|
||||||
|
healthy: 'HEALTHY',
|
||||||
|
degraded: 'DEGRADED (存在警告)',
|
||||||
|
unhealthy: 'UNHEALTHY (存在失败项)',
|
||||||
|
};
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push(` 总体状态: ${statusMap[report.overallStatus]}`);
|
||||||
|
lines.push(sep);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting — ANSI colored (CLI)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ANSI = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\x1b[1m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
gray: '\x1b[90m',
|
||||||
|
};
|
||||||
|
const STATUS_LABEL_CLI = {
|
||||||
|
pass: `${ANSI.green}[PASS]${ANSI.reset}`,
|
||||||
|
warn: `${ANSI.yellow}[WARN]${ANSI.reset}`,
|
||||||
|
fail: `${ANSI.red}[FAIL]${ANSI.reset}`,
|
||||||
|
skip: `${ANSI.gray}[SKIP]${ANSI.reset}`,
|
||||||
|
};
|
||||||
|
function formatCheckCli(c) {
|
||||||
|
let line = ` ${STATUS_LABEL_CLI[c.status]} ${c.name}: ${c.message}`;
|
||||||
|
if (c.details) {
|
||||||
|
line += `\n ${ANSI.gray}${c.details}${ANSI.reset}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trace by message_id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Extract all log lines tagged with a specific message_id from gateway.log.
|
||||||
|
*
|
||||||
|
* Scans the last 1MB of the log file for lines containing `[msg:{messageId}]`.
|
||||||
|
* Returns matching lines in chronological order.
|
||||||
|
*/
|
||||||
|
async function traceByMessageId(messageId) {
|
||||||
|
const logPath = path.join(os.homedir(), '.openclaw', 'logs', 'gateway.log');
|
||||||
|
try {
|
||||||
|
await fs.access(logPath);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const TRACE_READ_BYTES = 1024 * 1024; // 1MB — more than extractRecentErrors
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(logPath);
|
||||||
|
const readSize = Math.min(stat.size, TRACE_READ_BYTES);
|
||||||
|
const fd = await fs.open(logPath, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(readSize);
|
||||||
|
await fd.read(buffer, 0, readSize, Math.max(0, stat.size - readSize));
|
||||||
|
const content = buffer.toString('utf-8');
|
||||||
|
const needle = `[msg:${messageId}]`;
|
||||||
|
return content.split('\n').filter((line) => line.includes(needle));
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await fd.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Format trace output for CLI display.
|
||||||
|
*/
|
||||||
|
function formatTraceOutput(lines, messageId) {
|
||||||
|
const sep = '────────────────────────────────';
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return [
|
||||||
|
sep,
|
||||||
|
` 未找到 ${messageId} 的追踪日志`,
|
||||||
|
'',
|
||||||
|
' 可能原因:',
|
||||||
|
' 1. 该消息尚未被处理',
|
||||||
|
' 2. 日志已被轮转',
|
||||||
|
' 3. 追踪功能未启用(需要更新插件版本)',
|
||||||
|
sep,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
const header = `追踪 ${messageId} 的处理链路 (${lines.length} 条日志):`;
|
||||||
|
const output = [header, sep];
|
||||||
|
for (const line of lines) {
|
||||||
|
output.push(line);
|
||||||
|
}
|
||||||
|
output.push(sep);
|
||||||
|
return output.join('\n');
|
||||||
|
}
|
||||||
|
function classifyEvent(body) {
|
||||||
|
if (body.startsWith('received from'))
|
||||||
|
return 'received';
|
||||||
|
if (body.startsWith('sender resolved'))
|
||||||
|
return 'sender_resolved';
|
||||||
|
if (body.startsWith('rejected:'))
|
||||||
|
return 'rejected';
|
||||||
|
if (body.startsWith('dispatching to agent'))
|
||||||
|
return 'dispatching';
|
||||||
|
if (body.startsWith('dispatch complete'))
|
||||||
|
return 'dispatch_complete';
|
||||||
|
if (body.startsWith('card entity created'))
|
||||||
|
return 'card_created';
|
||||||
|
if (body.startsWith('card message sent'))
|
||||||
|
return 'card_sent';
|
||||||
|
if (body.startsWith('cardkit cardElement.content:'))
|
||||||
|
return 'card_stream';
|
||||||
|
if (body.startsWith('card stream update failed'))
|
||||||
|
return 'card_stream_fail';
|
||||||
|
if (body.startsWith('cardkit card.settings:'))
|
||||||
|
return 'card_settings';
|
||||||
|
if (body.startsWith('cardkit card.update:'))
|
||||||
|
return 'card_update';
|
||||||
|
if (body.startsWith('card creation failed'))
|
||||||
|
return 'card_fallback';
|
||||||
|
if (body.startsWith('reply completed'))
|
||||||
|
return 'reply_completed';
|
||||||
|
if (body.startsWith('reply error'))
|
||||||
|
return 'reply_error';
|
||||||
|
if (body.startsWith('tool call:'))
|
||||||
|
return 'tool_call';
|
||||||
|
if (body.startsWith('tool done:'))
|
||||||
|
return 'tool_done';
|
||||||
|
if (body.startsWith('tool fail:'))
|
||||||
|
return 'tool_fail';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
const EVENT_LABEL = {
|
||||||
|
received: '消息接收',
|
||||||
|
sender_resolved: 'Sender 解析',
|
||||||
|
rejected: '消息拒绝',
|
||||||
|
dispatching: '分发到 Agent',
|
||||||
|
dispatch_complete: 'Agent 处理完成',
|
||||||
|
card_created: '卡片创建',
|
||||||
|
card_sent: '卡片消息发送',
|
||||||
|
card_stream: '流式更新',
|
||||||
|
card_stream_fail: '流式更新失败',
|
||||||
|
card_settings: '卡片设置',
|
||||||
|
card_update: '卡片最终更新',
|
||||||
|
card_fallback: '卡片降级',
|
||||||
|
reply_completed: '回复完成',
|
||||||
|
reply_error: '回复错误',
|
||||||
|
tool_call: '工具调用',
|
||||||
|
tool_done: '工具完成',
|
||||||
|
tool_fail: '工具失败',
|
||||||
|
};
|
||||||
|
/** Expected stages in a normal message processing flow. */
|
||||||
|
const EXPECTED_STAGES = [
|
||||||
|
{ kind: 'received', label: '消息接收 (received from)' },
|
||||||
|
{ kind: 'dispatching', label: '分发到 Agent (dispatching to agent)' },
|
||||||
|
{ kind: 'card_created', label: '卡片创建 (card entity created)' },
|
||||||
|
{ kind: 'card_sent', label: '卡片消息发送 (card message sent)' },
|
||||||
|
{ kind: 'card_stream', label: '流式输出 (cardElement.content)' },
|
||||||
|
{ kind: 'dispatch_complete', label: '处理完成 (dispatch complete)' },
|
||||||
|
{ kind: 'reply_completed', label: '回复收尾 (reply completed)' },
|
||||||
|
];
|
||||||
|
/** Time gap thresholds (ms) for performance warnings. */
|
||||||
|
const PERF_THRESHOLDS = [
|
||||||
|
{ from: 'received', to: 'dispatching', warnMs: 500, label: '消息接收 → 分发' },
|
||||||
|
{ from: 'dispatching', to: 'card_created', warnMs: 5000, label: '分发 → 卡片创建' },
|
||||||
|
{ from: 'card_created', to: 'card_stream', warnMs: 30000, label: '卡片创建 → 首次流式输出' },
|
||||||
|
];
|
||||||
|
function parseTraceLines(lines) {
|
||||||
|
const events = [];
|
||||||
|
// Match: 2026-02-13T12:42:04.682Z [feishu] feishu[...][msg:...]: <body>
|
||||||
|
const re = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s.*?\]:\s(.+)$/;
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(re);
|
||||||
|
if (m) {
|
||||||
|
events.push({ timestamp: new Date(m[1]), raw: line, body: m[2] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Analyze trace log lines and produce a structured CLI report.
|
||||||
|
*/
|
||||||
|
function analyzeTrace(lines, _messageId) {
|
||||||
|
const events = parseTraceLines(lines);
|
||||||
|
if (events.length === 0) {
|
||||||
|
return `无法解析日志行,请确认日志格式正确。`;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
const sep = '────────────────────────────────';
|
||||||
|
const startTime = events[0].timestamp.getTime();
|
||||||
|
const totalMs = events[events.length - 1].timestamp.getTime() - startTime;
|
||||||
|
// ── Section 1: Timeline ──
|
||||||
|
out.push('');
|
||||||
|
out.push(`${ANSI.bold}【时间线】${ANSI.reset} (${events.length} 条日志,跨度 ${(totalMs / 1000).toFixed(1)}s)`);
|
||||||
|
out.push(sep);
|
||||||
|
let prevMs = startTime;
|
||||||
|
// Collapse consecutive card_stream events
|
||||||
|
let streamCount = 0;
|
||||||
|
let streamFirstSeq = '';
|
||||||
|
let streamLastSeq = '';
|
||||||
|
function flushStream() {
|
||||||
|
if (streamCount > 0) {
|
||||||
|
const label = streamCount === 1
|
||||||
|
? ` ${ANSI.gray}...${ANSI.reset} 流式更新 seq=${streamFirstSeq}`
|
||||||
|
: ` ${ANSI.gray}...${ANSI.reset} 流式更新 x${streamCount} (seq=${streamFirstSeq}~${streamLastSeq})`;
|
||||||
|
out.push(label);
|
||||||
|
streamCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ev of events) {
|
||||||
|
const kind = classifyEvent(ev.body);
|
||||||
|
const deltaMs = ev.timestamp.getTime() - prevMs;
|
||||||
|
prevMs = ev.timestamp.getTime();
|
||||||
|
const offsetMs = ev.timestamp.getTime() - startTime;
|
||||||
|
const offsetStr = `+${offsetMs}ms`.padStart(10);
|
||||||
|
// Collapse card_stream
|
||||||
|
if (kind === 'card_stream') {
|
||||||
|
const seqMatch = ev.body.match(/seq=(\d+)/);
|
||||||
|
const seq = seqMatch ? seqMatch[1] : '?';
|
||||||
|
if (streamCount === 0)
|
||||||
|
streamFirstSeq = seq;
|
||||||
|
streamLastSeq = seq;
|
||||||
|
streamCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushStream();
|
||||||
|
const label = EVENT_LABEL[kind] ?? kind;
|
||||||
|
const gapWarn = deltaMs > 5000 ? ` ${ANSI.yellow}⚠ ${(deltaMs / 1000).toFixed(1)}s${ANSI.reset}` : '';
|
||||||
|
// Marker for errors
|
||||||
|
let marker = ' ';
|
||||||
|
if (kind === 'rejected' ||
|
||||||
|
kind === 'reply_error' ||
|
||||||
|
kind === 'tool_fail' ||
|
||||||
|
kind === 'card_stream_fail' ||
|
||||||
|
kind === 'card_fallback') {
|
||||||
|
marker = `${ANSI.red}✘ ${ANSI.reset}`;
|
||||||
|
}
|
||||||
|
else if (kind === 'tool_call') {
|
||||||
|
marker = '→ ';
|
||||||
|
}
|
||||||
|
// Extract key detail from body
|
||||||
|
let detail = '';
|
||||||
|
if (kind === 'received') {
|
||||||
|
const m = ev.body.match(/from (\S+) in (\S+) \((\w+)\)/);
|
||||||
|
if (m)
|
||||||
|
detail = `sender=${m[1]}, chat=${m[2]} (${m[3]})`;
|
||||||
|
}
|
||||||
|
else if (kind === 'dispatching') {
|
||||||
|
const m = ev.body.match(/session=(\S+)\)/);
|
||||||
|
if (m)
|
||||||
|
detail = `session=${m[1]}`;
|
||||||
|
}
|
||||||
|
else if (kind === 'dispatch_complete') {
|
||||||
|
const m = ev.body.match(/replies=(\d+), elapsed=(\d+)ms/);
|
||||||
|
if (m)
|
||||||
|
detail = `replies=${m[1]}, elapsed=${m[2]}ms`;
|
||||||
|
}
|
||||||
|
else if (kind === 'tool_call') {
|
||||||
|
const m = ev.body.match(/tool call: (\S+)/);
|
||||||
|
if (m)
|
||||||
|
detail = m[1];
|
||||||
|
}
|
||||||
|
else if (kind === 'tool_fail') {
|
||||||
|
detail = ev.body.replace('tool fail: ', '');
|
||||||
|
}
|
||||||
|
else if (kind === 'card_created') {
|
||||||
|
const m = ev.body.match(/card_id=(\S+)\)/);
|
||||||
|
if (m)
|
||||||
|
detail = `card_id=${m[1]}`;
|
||||||
|
}
|
||||||
|
else if (kind === 'reply_completed') {
|
||||||
|
const m = ev.body.match(/elapsed=(\d+)ms/);
|
||||||
|
if (m)
|
||||||
|
detail = `elapsed=${m[1]}ms`;
|
||||||
|
}
|
||||||
|
else if (kind === 'rejected') {
|
||||||
|
detail = ev.body.replace('rejected: ', '');
|
||||||
|
}
|
||||||
|
out.push(`${ANSI.gray}[${offsetStr}]${ANSI.reset} ${marker}${label}${detail ? ` — ${detail}` : ''}${gapWarn}`);
|
||||||
|
}
|
||||||
|
flushStream();
|
||||||
|
out.push('');
|
||||||
|
// ── Section 2: Anomaly detection ──
|
||||||
|
const issues = [];
|
||||||
|
const kindSet = new Set(events.map((e) => classifyEvent(e.body)));
|
||||||
|
// 2.1 Missing stages
|
||||||
|
for (const stage of EXPECTED_STAGES) {
|
||||||
|
if (!kindSet.has(stage.kind)) {
|
||||||
|
// dispatch_complete 和 reply_completed 缺失仅在有 dispatching 时才告警
|
||||||
|
if ((stage.kind === 'dispatch_complete' || stage.kind === 'reply_completed') && !kindSet.has('dispatching'))
|
||||||
|
continue;
|
||||||
|
// card 相关阶段在有 rejected 时不告警
|
||||||
|
if ((stage.kind === 'card_created' || stage.kind === 'card_sent' || stage.kind === 'card_stream') &&
|
||||||
|
kindSet.has('rejected'))
|
||||||
|
continue;
|
||||||
|
issues.push(`缺失阶段: ${stage.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2.2 Errors
|
||||||
|
for (const ev of events) {
|
||||||
|
const kind = classifyEvent(ev.body);
|
||||||
|
if (kind === 'rejected')
|
||||||
|
issues.push(`消息被拒绝: ${ev.body.replace('rejected: ', '')}`);
|
||||||
|
if (kind === 'reply_error')
|
||||||
|
issues.push(`回复错误: ${ev.body}`);
|
||||||
|
if (kind === 'tool_fail')
|
||||||
|
issues.push(`工具失败: ${ev.body}`);
|
||||||
|
if (kind === 'card_stream_fail')
|
||||||
|
issues.push(`流式更新失败: ${ev.body}`);
|
||||||
|
if (kind === 'card_fallback')
|
||||||
|
issues.push(`卡片降级: ${ev.body}`);
|
||||||
|
// CardKit non-zero code
|
||||||
|
if (kind === 'card_stream' || kind === 'card_update' || kind === 'card_settings' || kind === 'card_created') {
|
||||||
|
const codeMatch = ev.body.match(/code=(\d+)/);
|
||||||
|
if (codeMatch && codeMatch[1] !== '0') {
|
||||||
|
issues.push(`API 返回错误码: code=${codeMatch[1]} — ${ev.body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2.3 Performance thresholds
|
||||||
|
const firstByKind = new Map();
|
||||||
|
for (const ev of events) {
|
||||||
|
const kind = classifyEvent(ev.body);
|
||||||
|
if (!firstByKind.has(kind))
|
||||||
|
firstByKind.set(kind, ev);
|
||||||
|
}
|
||||||
|
for (const rule of PERF_THRESHOLDS) {
|
||||||
|
const from = firstByKind.get(rule.from);
|
||||||
|
const to = firstByKind.get(rule.to);
|
||||||
|
if (from && to) {
|
||||||
|
const gap = to.timestamp.getTime() - from.timestamp.getTime();
|
||||||
|
if (gap > rule.warnMs) {
|
||||||
|
issues.push(`性能警告: ${rule.label} 耗时 ${(gap / 1000).toFixed(1)}s(阈值 ${(rule.warnMs / 1000).toFixed(0)}s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2.4 Duplicate delivery
|
||||||
|
const receivedCount = events.filter((e) => classifyEvent(e.body) === 'received').length;
|
||||||
|
if (receivedCount > 1) {
|
||||||
|
issues.push(`重复投递: 同一消息被接收 ${receivedCount} 次(WebSocket 重投递)`);
|
||||||
|
}
|
||||||
|
// 2.5 Card stream continuity
|
||||||
|
const streamSeqs = [];
|
||||||
|
for (const ev of events) {
|
||||||
|
if (classifyEvent(ev.body) === 'card_stream') {
|
||||||
|
const m = ev.body.match(/seq=(\d+)/);
|
||||||
|
if (m)
|
||||||
|
streamSeqs.push(parseInt(m[1], 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (streamSeqs.length > 1) {
|
||||||
|
for (let i = 1; i < streamSeqs.length; i++) {
|
||||||
|
if (streamSeqs[i] !== streamSeqs[i - 1] + 1) {
|
||||||
|
issues.push(`流式 seq 不连续: seq=${streamSeqs[i - 1]} → seq=${streamSeqs[i]}(跳过了 ${streamSeqs[i] - streamSeqs[i - 1] - 1} 个)`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(`${ANSI.bold}【异常检测】${ANSI.reset}`);
|
||||||
|
out.push(sep);
|
||||||
|
if (issues.length === 0) {
|
||||||
|
out.push(` ${ANSI.green}未发现异常${ANSI.reset}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
for (let i = 0; i < issues.length; i++) {
|
||||||
|
const isError = issues[i].startsWith('工具失败') ||
|
||||||
|
issues[i].startsWith('回复错误') ||
|
||||||
|
issues[i].startsWith('API 返回错误码') ||
|
||||||
|
issues[i].startsWith('流式更新失败');
|
||||||
|
const color = isError ? ANSI.red : ANSI.yellow;
|
||||||
|
out.push(` ${color}${i + 1}. ${issues[i]}${ANSI.reset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('');
|
||||||
|
// ── Section 3: Diagnosis ──
|
||||||
|
out.push(`${ANSI.bold}【诊断总结】${ANSI.reset}`);
|
||||||
|
out.push(sep);
|
||||||
|
const hasError = issues.some((i) => i.startsWith('工具失败') ||
|
||||||
|
i.startsWith('回复错误') ||
|
||||||
|
i.startsWith('API 返回错误码') ||
|
||||||
|
i.startsWith('流式更新失败') ||
|
||||||
|
i.startsWith('缺失阶段'));
|
||||||
|
const hasWarn = issues.length > 0;
|
||||||
|
if (!hasWarn) {
|
||||||
|
out.push(` 状态: ${ANSI.green}✓ 正常${ANSI.reset}`);
|
||||||
|
out.push(` 消息处理链路完整,全程耗时 ${(totalMs / 1000).toFixed(1)}s。`);
|
||||||
|
// Break down time
|
||||||
|
const dispatchComplete = events.find((e) => classifyEvent(e.body) === 'dispatch_complete' && e.body.includes('replies=') && !e.body.includes('replies=0'));
|
||||||
|
if (dispatchComplete) {
|
||||||
|
const m = dispatchComplete.body.match(/elapsed=(\d+)ms/);
|
||||||
|
if (m) {
|
||||||
|
out.push(` 其中 Agent 处理耗时 ${(parseInt(m[1], 10) / 1000).toFixed(1)}s(含 AI 推理 + 工具调用)。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (hasError) {
|
||||||
|
out.push(` 状态: ${ANSI.red}✘ 异常${ANSI.reset}`);
|
||||||
|
out.push(` 发现 ${issues.length} 个问题,需要排查。`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
out.push(` 状态: ${ANSI.yellow}⚠ 有警告${ANSI.reset}`);
|
||||||
|
out.push(` 发现 ${issues.length} 个警告,功能可用但需关注。`);
|
||||||
|
}
|
||||||
|
out.push('');
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
function formatDiagReportCli(report) {
|
||||||
|
const lines = [];
|
||||||
|
const sep = '====================================';
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push(` ${ANSI.bold}飞书插件诊断报告${ANSI.reset}`);
|
||||||
|
lines.push(` ${report.timestamp}`);
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push('');
|
||||||
|
// Environment
|
||||||
|
lines.push(`${ANSI.bold}【环境信息】${ANSI.reset}`);
|
||||||
|
lines.push(` Node.js: ${report.environment.nodeVersion}`);
|
||||||
|
lines.push(` 插件版本: ${report.environment.pluginVersion}`);
|
||||||
|
lines.push(` 系统: ${report.environment.platform} ${report.environment.arch}`);
|
||||||
|
lines.push('');
|
||||||
|
// Global checks
|
||||||
|
lines.push(`${ANSI.bold}【全局检查】${ANSI.reset}`);
|
||||||
|
for (const c of report.checks) {
|
||||||
|
lines.push(formatCheckCli(c));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
// Per-account
|
||||||
|
for (const acct of report.accounts) {
|
||||||
|
lines.push(`${ANSI.bold}【账户: ${acct.accountId}】${ANSI.reset}`);
|
||||||
|
if (acct.name)
|
||||||
|
lines.push(` 名称: ${acct.name}`);
|
||||||
|
lines.push(` App ID: ${acct.appId}`);
|
||||||
|
lines.push(` 品牌: ${acct.brand}`);
|
||||||
|
lines.push('');
|
||||||
|
for (const c of acct.checks) {
|
||||||
|
lines.push(formatCheckCli(c));
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// Tools
|
||||||
|
lines.push(`${ANSI.bold}【工具注册】${ANSI.reset}`);
|
||||||
|
if (report.toolsRegistered.length > 0) {
|
||||||
|
lines.push(` ${report.toolsRegistered.join(', ')}`);
|
||||||
|
lines.push(` 共 ${report.toolsRegistered.length} 个`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push(' 无工具注册(未找到已配置的账户)');
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
// Recent errors
|
||||||
|
if (report.recentErrors.length > 0) {
|
||||||
|
lines.push(`${ANSI.bold}【最近错误】${ANSI.reset}(${report.recentErrors.length} 条)`);
|
||||||
|
for (let i = 0; i < report.recentErrors.length; i++) {
|
||||||
|
lines.push(` ${ANSI.gray}${i + 1}. ${report.recentErrors[i]}${ANSI.reset}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// Overall
|
||||||
|
const statusColorMap = {
|
||||||
|
healthy: `${ANSI.green}HEALTHY${ANSI.reset}`,
|
||||||
|
degraded: `${ANSI.yellow}DEGRADED (存在警告)${ANSI.reset}`,
|
||||||
|
unhealthy: `${ANSI.red}UNHEALTHY (存在失败项)${ANSI.reset}`,
|
||||||
|
};
|
||||||
|
lines.push(sep);
|
||||||
|
lines.push(` 总体状态: ${statusColorMap[report.overallStatus]}`);
|
||||||
|
lines.push(sep);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* feishu-doctor 诊断报告 Markdown 格式化(完全重构版)
|
||||||
|
*
|
||||||
|
* 直接生成 Markdown 诊断报告,不依赖 diagnose.ts 的任何架构和代码。
|
||||||
|
* 按照 doctor_template.md 的格式规范实现。
|
||||||
|
*/
|
||||||
|
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
||||||
|
export type { FeishuLocale } from './locale';
|
||||||
|
import type { FeishuLocale } from './locale';
|
||||||
|
/** @deprecated Use FeishuLocale instead */
|
||||||
|
export type DoctorLocale = FeishuLocale;
|
||||||
|
/**
|
||||||
|
* 运行飞书插件诊断,生成 Markdown 格式报告。
|
||||||
|
*
|
||||||
|
* @param config - OpenClaw 配置
|
||||||
|
* @param currentAccountId - 当前发送命令的机器人账号 ID(若有则只诊断该账号)
|
||||||
|
* @param locale - 输出语言,默认 zh_cn
|
||||||
|
*/
|
||||||
|
export declare function runFeishuDoctor(config: OpenClawConfig, currentAccountId?: string, locale?: DoctorLocale): Promise<string>;
|
||||||
|
/**
|
||||||
|
* 运行飞书插件诊断,同时生成中英双语 Markdown 报告。
|
||||||
|
* 用于飞书 channel 的多语言 post 发送。
|
||||||
|
*/
|
||||||
|
export declare function runFeishuDoctorI18n(config: OpenClawConfig, currentAccountId?: string): Promise<Record<DoctorLocale, string>>;
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* feishu-doctor 诊断报告 Markdown 格式化(完全重构版)
|
||||||
|
*
|
||||||
|
* 直接生成 Markdown 诊断报告,不依赖 diagnose.ts 的任何架构和代码。
|
||||||
|
* 按照 doctor_template.md 的格式规范实现。
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.runFeishuDoctor = runFeishuDoctor;
|
||||||
|
exports.runFeishuDoctorI18n = runFeishuDoctorI18n;
|
||||||
|
const accounts_1 = require("../core/accounts.js");
|
||||||
|
const lark_client_1 = require("../core/lark-client.js");
|
||||||
|
/**
|
||||||
|
* Resolve the global config for cross-account operations.
|
||||||
|
*
|
||||||
|
* Plugin commands receive an account-scoped config where `channels.feishu`
|
||||||
|
* has been replaced with the merged per-account config (the `accounts` map
|
||||||
|
* is stripped by `baseConfig()`). Commands that enumerate all accounts
|
||||||
|
* need the original global config to see the full `accounts` map.
|
||||||
|
*/
|
||||||
|
function resolveGlobalConfig(config) {
|
||||||
|
return lark_client_1.LarkClient.globalConfig ?? config;
|
||||||
|
}
|
||||||
|
const app_scope_checker_1 = require("../core/app-scope-checker.js");
|
||||||
|
const app_owner_fallback_1 = require("../core/app-owner-fallback.js");
|
||||||
|
const token_store_1 = require("../core/token-store.js");
|
||||||
|
const tool_scopes_1 = require("../core/tool-scopes.js");
|
||||||
|
const probe_1 = require("../channel/probe.js");
|
||||||
|
const tool_client_1 = require("../core/tool-client.js");
|
||||||
|
const version_1 = require("../core/version.js");
|
||||||
|
const domains_1 = require("../core/domains.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// I18n text map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const T = {
|
||||||
|
zh_cn: {
|
||||||
|
notSet: '(未设置)',
|
||||||
|
legacyNotDisabled: '❌ **旧版插件**: 检测到旧版官方插件未禁用\n' +
|
||||||
|
'👉 请依次运行命令:\n' +
|
||||||
|
'```\n' +
|
||||||
|
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
|
||||||
|
'openclaw gateway restart\n' +
|
||||||
|
'```',
|
||||||
|
legacyRunCmds: '👉 请依次运行命令:',
|
||||||
|
legacyDisabled: '✅ **旧版插件**: 已禁用',
|
||||||
|
credentials: '✅ **凭证完整性**',
|
||||||
|
accountEnabled: '✅ **账户启用**: 已启用',
|
||||||
|
apiOk: '✅ **API 连通性**: 连接成功',
|
||||||
|
apiFail: '❌ **API 连通性**: 连接失败',
|
||||||
|
apiError: '❌ **API 连通性**: 探测异常',
|
||||||
|
toolsOk: '✅ 飞书工具加载暂未发现异常',
|
||||||
|
toolsWarnProfile: (profile) => `⚠️ **工具基础允许列表**: 当前为 \`${profile}\`,飞书工具可能无法加载。可以按需修改配置:`,
|
||||||
|
toolsDocRef: '📖 参考文档',
|
||||||
|
allPermsGranted: (count) => `全部 ${count} 个必需权限已开通`,
|
||||||
|
missingPermsPrefix: '缺少',
|
||||||
|
missingPermsSuffix: '个必需权限。需应用管理员申请开通',
|
||||||
|
cannotQueryPerms: '无法查询应用权限状态。原因:未开通 application:application:self_manage 权限',
|
||||||
|
cannotQueryPermsGeneric: '无法查询应用权限状态。',
|
||||||
|
suggestCheckPerm: '建议检查 application:application:self_manage 权限',
|
||||||
|
adminApply: '需应用管理员申请开通',
|
||||||
|
apply: '申请',
|
||||||
|
permTableHeader: '| 权限名称 | 应用已开通 | 用户已授权 |',
|
||||||
|
authStatusLabel: '**授权状态**',
|
||||||
|
userTotal: '共 1 个用户',
|
||||||
|
valid: '有效',
|
||||||
|
needRefresh: '需刷新',
|
||||||
|
expired: '已过期',
|
||||||
|
tokenRefreshLabel: '**Token 自动刷新**',
|
||||||
|
tokenRefreshOn: '✓ 已开启自动刷新 (1/1 个用户)',
|
||||||
|
tokenRefreshOff: '✗ 未开启自动刷新,Token 将在 2 小时后过期',
|
||||||
|
noUserAuth: '⚠️ **暂无用户授权**',
|
||||||
|
noUserAuthDesc: '尚未有用户通过 OAuth 授权。用户首次使用需以用户身份的功能时,会自动触发授权流程。',
|
||||||
|
permCompareLabel: '**权限对照**',
|
||||||
|
permInsufficient: '**用户身份权限不足**',
|
||||||
|
userCountLabel: '已授权',
|
||||||
|
noAuthLabel: '暂无授权',
|
||||||
|
appMissingUserPerms: (count) => `💡 应用缺少 ${count} 个用户身份权限。需应用管理员申请开通`,
|
||||||
|
permCompareSummary: (appCount, total, userPart) => `应用 **${appCount}/${total}** 已开通,用户 **${userPart}**`,
|
||||||
|
userReauth: '💡 用户需要重新授权以获得完整权限,可以向机器人发送消息 "**/feishu auth**"',
|
||||||
|
userNeedsOAuth: '💡 用户需要进行 OAuth 授权,可以向机器人发送消息 "**/feishu auth**"',
|
||||||
|
userPermFailed: '用户权限检查失败',
|
||||||
|
userPermFailedNoSelfManage: '用户权限检查失败:无法查询应用权限。原因:未开通 application:application:self_manage 权限',
|
||||||
|
reportTitle: '### 飞书插件诊断',
|
||||||
|
pluginVersionLabel: '插件版本',
|
||||||
|
diagTimeLabel: '诊断时间',
|
||||||
|
noAccounts: '❌ **错误**: 未找到已启用的飞书账户\n\n请在 OpenClaw 配置文件中配置飞书账户并启用。',
|
||||||
|
accountNotFoundPrefix: '❌ **错误**: 未找到账户',
|
||||||
|
enabledAccountsLabel: '当前已启用的账户',
|
||||||
|
toolsCheckPass: '#### ✅ 工具配置检查通过',
|
||||||
|
toolsCheckWarn: '#### ⚠️ 工具配置检查异常',
|
||||||
|
accountPrefix: '### 账户',
|
||||||
|
envCheckPass: '#### ✅ 环境信息检查通过',
|
||||||
|
envCheckFail: '#### ❌ 环境信息检查未通过',
|
||||||
|
appPermPass: '#### ✅ 应用身份权限检查通过',
|
||||||
|
appPermFail: '#### ❌ 应用身份权限检查未通过',
|
||||||
|
userPermPass: '#### ✅ 用户身份权限检查通过',
|
||||||
|
userPermFail: '#### ❌ 用户身份权限检查未通过',
|
||||||
|
},
|
||||||
|
en_us: {
|
||||||
|
notSet: '(not set)',
|
||||||
|
legacyNotDisabled: '❌ **Legacy Plugin**: Legacy official plugin is not disabled\n' +
|
||||||
|
'👉 Please run the following commands:\n' +
|
||||||
|
'```\n' +
|
||||||
|
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
|
||||||
|
'openclaw gateway restart\n' +
|
||||||
|
'```',
|
||||||
|
legacyRunCmds: '👉 Please run the following commands:',
|
||||||
|
legacyDisabled: '✅ **Legacy Plugin**: Disabled',
|
||||||
|
credentials: '✅ **Credentials**',
|
||||||
|
accountEnabled: '✅ **Account**: Enabled',
|
||||||
|
apiOk: '✅ **API Connectivity**: Connected',
|
||||||
|
apiFail: '❌ **API Connectivity**: Connection failed',
|
||||||
|
apiError: '❌ **API Connectivity**: Probe error',
|
||||||
|
toolsOk: '✅ Feishu tools loading: No issues found',
|
||||||
|
toolsWarnProfile: (profile) => `⚠️ **Tool Allowlist**: Currently set to \`${profile}\`. Feishu tools may not load properly. Update configuration as needed:`,
|
||||||
|
toolsDocRef: '📖 Documentation',
|
||||||
|
allPermsGranted: (count) => `All ${count} required permissions granted`,
|
||||||
|
missingPermsPrefix: 'Missing',
|
||||||
|
missingPermsSuffix: 'required permissions. Admin needs to apply',
|
||||||
|
cannotQueryPerms: 'Unable to query app permissions. Reason: Missing application:application:self_manage permission',
|
||||||
|
cannotQueryPermsGeneric: 'Unable to query app permissions.',
|
||||||
|
suggestCheckPerm: 'Please check application:application:self_manage permission',
|
||||||
|
adminApply: 'Admin needs to apply',
|
||||||
|
apply: 'Apply',
|
||||||
|
permTableHeader: '| Permission | App Granted | User Authorized |',
|
||||||
|
authStatusLabel: '**Auth Status**',
|
||||||
|
userTotal: '1 user total',
|
||||||
|
valid: 'Valid',
|
||||||
|
needRefresh: 'Needs refresh',
|
||||||
|
expired: 'Expired',
|
||||||
|
tokenRefreshLabel: '**Token Auto-Refresh**',
|
||||||
|
tokenRefreshOn: '✓ Auto-refresh enabled (1/1 users)',
|
||||||
|
tokenRefreshOff: '✗ Auto-refresh not enabled. Token will expire in 2 hours',
|
||||||
|
noUserAuth: '⚠️ **No User Authorization**',
|
||||||
|
noUserAuthDesc: 'No user has authorized via OAuth yet. The authorization flow will be triggered automatically when a user first uses a feature requiring user identity.',
|
||||||
|
permCompareLabel: '**Permission Comparison**',
|
||||||
|
permInsufficient: '**Insufficient User Permissions**',
|
||||||
|
userCountLabel: 'authorized',
|
||||||
|
noAuthLabel: 'not authorized',
|
||||||
|
appMissingUserPerms: (count) => `💡 App is missing ${count} user-identity permissions. Admin needs to apply`,
|
||||||
|
permCompareSummary: (appCount, total, userPart) => `App **${appCount}/${total}** granted, User **${userPart}**`,
|
||||||
|
userReauth: '💡 User needs to re-authorize for full permissions. Send message to bot: "**/feishu auth**"',
|
||||||
|
userNeedsOAuth: '💡 User needs OAuth authorization. Send message to bot: "**/feishu auth**"',
|
||||||
|
userPermFailed: 'User permission check failed',
|
||||||
|
userPermFailedNoSelfManage: 'User permission check failed: Unable to query app permissions. Reason: Missing application:application:self_manage permission',
|
||||||
|
reportTitle: '### Feishu Plugin Diagnostics',
|
||||||
|
pluginVersionLabel: 'Plugin version',
|
||||||
|
diagTimeLabel: 'Diagnosis time',
|
||||||
|
noAccounts: '❌ **Error**: No enabled Feishu accounts found\n\nPlease configure and enable a Feishu account in the OpenClaw configuration.',
|
||||||
|
accountNotFoundPrefix: '❌ **Error**: Account not found',
|
||||||
|
enabledAccountsLabel: 'Currently enabled accounts',
|
||||||
|
toolsCheckPass: '#### ✅ Tool Configuration Check Passed',
|
||||||
|
toolsCheckWarn: '#### ⚠️ Tool Configuration Check Warning',
|
||||||
|
accountPrefix: '### Account',
|
||||||
|
envCheckPass: '#### ✅ Environment Check Passed',
|
||||||
|
envCheckFail: '#### ❌ Environment Check Failed',
|
||||||
|
appPermPass: '#### ✅ App Permission Check Passed',
|
||||||
|
appPermFail: '#### ❌ App Permission Check Failed',
|
||||||
|
userPermPass: '#### ✅ User Permission Check Passed',
|
||||||
|
userPermFail: '#### ❌ User Permission Check Failed',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 格式化时间戳为 "YYYY-MM-DD HH:mm:ss"
|
||||||
|
*/
|
||||||
|
function formatTimestamp(date) {
|
||||||
|
return date.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' ');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取所有工具动作需要的唯一 scope 列表(从 diagnose.ts 复制)
|
||||||
|
*/
|
||||||
|
function getAllToolScopes() {
|
||||||
|
const scopesSet = new Set();
|
||||||
|
for (const scopes of Object.values(tool_scopes_1.TOOL_SCOPES)) {
|
||||||
|
for (const scope of scopes) {
|
||||||
|
scopesSet.add(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(scopesSet).sort();
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 基础信息检查
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 掩码敏感信息(appSecret)
|
||||||
|
*/
|
||||||
|
function maskSecret(secret, locale) {
|
||||||
|
if (!secret)
|
||||||
|
return T[locale].notSet;
|
||||||
|
if (secret.length <= 4)
|
||||||
|
return '****';
|
||||||
|
return secret.slice(0, 4) + '****';
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检查基础信息和账号状态
|
||||||
|
*/
|
||||||
|
async function checkBasicInfo(account, config, locale) {
|
||||||
|
const t = T[locale];
|
||||||
|
const lines = [];
|
||||||
|
let status = 'pass';
|
||||||
|
// 旧版官方插件是否已禁用
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const feishuEntry = config.plugins?.entries?.feishu;
|
||||||
|
if (feishuEntry && feishuEntry.enabled !== false) {
|
||||||
|
status = 'fail';
|
||||||
|
lines.push(t.legacyNotDisabled);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push(t.legacyDisabled);
|
||||||
|
}
|
||||||
|
lines.push(`${t.credentials}: appId: ${account.appId}, appSecret: ${maskSecret(account.appSecret, locale)}`);
|
||||||
|
lines.push(t.accountEnabled);
|
||||||
|
// API 连通性
|
||||||
|
try {
|
||||||
|
const probeResult = await (0, probe_1.probeFeishu)({
|
||||||
|
accountId: account.accountId,
|
||||||
|
appId: account.appId,
|
||||||
|
appSecret: account.appSecret,
|
||||||
|
brand: account.brand,
|
||||||
|
});
|
||||||
|
if (probeResult.ok) {
|
||||||
|
lines.push(t.apiOk);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
status = 'fail';
|
||||||
|
lines.push(`${t.apiFail} - ${probeResult.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
status = 'fail';
|
||||||
|
lines.push(`${t.apiError} - ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
markdown: lines.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 工具配置检查
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const INCOMPLETE_PROFILES = new Set(['minimal', 'coding', 'messaging']);
|
||||||
|
function checkToolsProfile(config, locale) {
|
||||||
|
const t = T[locale];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const tools = config.tools;
|
||||||
|
const profile = tools?.profile;
|
||||||
|
if (!profile) {
|
||||||
|
return {
|
||||||
|
status: 'pass',
|
||||||
|
markdown: t.toolsOk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (INCOMPLETE_PROFILES.has(profile)) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
markdown: `${t.toolsWarnProfile(profile)}\n` +
|
||||||
|
'```\n' +
|
||||||
|
'openclaw config set tools.profile "full"\n' +
|
||||||
|
'openclaw gateway restart\n' +
|
||||||
|
'```\n' +
|
||||||
|
`${t.toolsDocRef}: https://docs.openclaw.ai/zh-CN/tools`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// profile === "full" 或其他未知值
|
||||||
|
return {
|
||||||
|
status: 'pass',
|
||||||
|
markdown: t.toolsOk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 应用权限检查
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 检查应用权限状态
|
||||||
|
*/
|
||||||
|
async function checkAppPermissions(account, sdk, locale) {
|
||||||
|
const t = T[locale];
|
||||||
|
const { appId } = account;
|
||||||
|
const openDomain = (0, domains_1.openPlatformDomain)(account.brand);
|
||||||
|
try {
|
||||||
|
// 获取应用已开通的权限(tenant token)
|
||||||
|
const grantedScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'tenant');
|
||||||
|
// 计算缺失的必需权限
|
||||||
|
const requiredMissing = (0, app_scope_checker_1.missingScopes)(grantedScopes, Array.from(tool_scopes_1.REQUIRED_APP_SCOPES));
|
||||||
|
if (requiredMissing.length === 0) {
|
||||||
|
// 全部权限已开通
|
||||||
|
return {
|
||||||
|
status: 'pass',
|
||||||
|
markdown: t.allPermsGranted(tool_scopes_1.REQUIRED_APP_SCOPES.length),
|
||||||
|
missingScopes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 缺少必需权限
|
||||||
|
const lines = [];
|
||||||
|
let applyUrl = `${openDomain}/app/${appId}/auth?op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
if (requiredMissing.length < 20) {
|
||||||
|
applyUrl = `${openDomain}/app/${appId}/auth?q=${encodeURIComponent(requiredMissing.join(','))}&op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
}
|
||||||
|
lines.push(`${t.missingPermsPrefix} ${requiredMissing.length} ${t.missingPermsSuffix} [${t.apply}](${applyUrl})`);
|
||||||
|
lines.push('');
|
||||||
|
for (const scope of requiredMissing) {
|
||||||
|
lines.push(`- ${scope}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
markdown: lines.join('\n'),
|
||||||
|
missingScopes: requiredMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// API 调用失败(通常是缺少 application:application:self_manage 权限)
|
||||||
|
const applyUrl = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
if (err instanceof tool_client_1.AppScopeCheckFailedError) {
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
markdown: `${t.cannotQueryPerms}\n\n${t.adminApply} [${t.apply}](${applyUrl})`,
|
||||||
|
missingScopes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
markdown: `${t.cannotQueryPermsGeneric}${err instanceof Error ? err.message : String(err)}\n\n${t.suggestCheckPerm} [${t.apply}](${applyUrl})`,
|
||||||
|
missingScopes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 用户权限检查
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 生成权限对照表
|
||||||
|
*/
|
||||||
|
function generatePermissionTable(appGrantedScopes, userGrantedScopes, hasValidUser, locale) {
|
||||||
|
let allScopes = getAllToolScopes();
|
||||||
|
allScopes = (0, tool_scopes_1.filterSensitiveScopes)(allScopes);
|
||||||
|
const appSet = new Set(appGrantedScopes);
|
||||||
|
const userSet = new Set(userGrantedScopes);
|
||||||
|
const lines = [];
|
||||||
|
lines.push(T[locale].permTableHeader);
|
||||||
|
lines.push('|----------|-----------|-----------|');
|
||||||
|
for (const scope of allScopes) {
|
||||||
|
const appGranted = appSet.has(scope) ? '✅' : '❌';
|
||||||
|
// 如果没有有效用户,显示 ➖;否则根据授权情况显示 ✅ 或 ❌
|
||||||
|
const userGranted = !hasValidUser ? '➖' : userSet.has(scope) ? '✅' : '❌';
|
||||||
|
lines.push(`| ${scope} | ${appGranted} | ${userGranted} |`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检查用户权限状态
|
||||||
|
*/
|
||||||
|
async function checkUserPermissions(account, sdk, locale) {
|
||||||
|
const t = T[locale];
|
||||||
|
const { appId } = account;
|
||||||
|
const openDomain = (0, domains_1.openPlatformDomain)(account.brand);
|
||||||
|
const lines = [];
|
||||||
|
try {
|
||||||
|
// 1. 获取应用所有者
|
||||||
|
const ownerId = await (0, app_owner_fallback_1.getAppOwnerFallback)(account, sdk);
|
||||||
|
// 2. 读取 token
|
||||||
|
const token = ownerId ? await (0, token_store_1.getStoredToken)(appId, ownerId) : null;
|
||||||
|
// 判断是否有有效的用户授权
|
||||||
|
const hasUserAuth = !!token;
|
||||||
|
// 变量初始化
|
||||||
|
let authStatus = 'warn';
|
||||||
|
let refreshStatus = 'warn';
|
||||||
|
let validCount = 0;
|
||||||
|
let scopes = [];
|
||||||
|
let userTokenStatus = 'expired';
|
||||||
|
let userMissing = [];
|
||||||
|
// 获取应用开通的支持 user token 的权限
|
||||||
|
const appUserScopes = await (0, app_scope_checker_1.getAppGrantedScopes)(sdk, appId, 'user');
|
||||||
|
let allScopes = getAllToolScopes();
|
||||||
|
allScopes = (0, tool_scopes_1.filterSensitiveScopes)(allScopes);
|
||||||
|
const appGrantedCount = appUserScopes.filter((s) => allScopes.includes(s)).length;
|
||||||
|
if (hasUserAuth) {
|
||||||
|
// 有用户授权 - 检查授权状态
|
||||||
|
const status = (0, token_store_1.tokenStatus)(token);
|
||||||
|
userTokenStatus = status;
|
||||||
|
scopes = token.scope.split(' ').filter(Boolean);
|
||||||
|
validCount = status === 'valid' ? 1 : 0;
|
||||||
|
const needsRefreshCount = status === 'needs_refresh' ? 1 : 0;
|
||||||
|
const expiredCount = status === 'expired' ? 1 : 0;
|
||||||
|
authStatus = expiredCount > 0 ? 'warn' : validCount === 1 ? 'pass' : 'warn';
|
||||||
|
const authEmoji = authStatus === 'pass' ? '✅' : '⚠️';
|
||||||
|
lines.push(`${authEmoji} ${t.authStatusLabel}: ${t.userTotal} | ✓ ${t.valid}: ${validCount}, ⟳ ${t.needRefresh}: ${needsRefreshCount}, ✗ ${t.expired}: ${expiredCount}`);
|
||||||
|
// Token 自动刷新检查
|
||||||
|
const hasOfflineAccess = scopes.includes('offline_access');
|
||||||
|
refreshStatus = hasOfflineAccess ? 'pass' : 'warn';
|
||||||
|
const refreshEmoji = refreshStatus === 'pass' ? '✅' : '⚠️';
|
||||||
|
lines.push(`${refreshEmoji} ${t.tokenRefreshLabel}: ${hasOfflineAccess ? t.tokenRefreshOn : t.tokenRefreshOff}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 没有用户授权
|
||||||
|
lines.push(t.noUserAuth);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(t.noUserAuthDesc);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// 计算用户已授权权限数
|
||||||
|
const userGrantedCount = validCount === 1 ? scopes.filter((s) => allScopes.includes(s)).length : 0;
|
||||||
|
// 计算用户缺失的权限
|
||||||
|
if (hasUserAuth && validCount === 1) {
|
||||||
|
const scopeSet = new Set(scopes);
|
||||||
|
userMissing = allScopes.filter((s) => !scopeSet.has(s));
|
||||||
|
}
|
||||||
|
// 权限对照统计
|
||||||
|
const tableStatus = appGrantedCount < allScopes.length || userGrantedCount < allScopes.length
|
||||||
|
? appGrantedCount < allScopes.length
|
||||||
|
? 'fail'
|
||||||
|
: 'warn'
|
||||||
|
: 'pass';
|
||||||
|
const tableEmoji = tableStatus === 'pass' ? '✅' : tableStatus === 'warn' ? '⚠️' : '❌';
|
||||||
|
if (validCount === 0) {
|
||||||
|
lines.push(`${t.permCompareLabel}: ${t.permCompareSummary(appGrantedCount, allScopes.length, t.noAuthLabel)}`);
|
||||||
|
}
|
||||||
|
else if (userGrantedCount < allScopes.length) {
|
||||||
|
lines.push(`${tableEmoji} ${t.permInsufficient}: ${t.permCompareSummary(appGrantedCount, allScopes.length, `${userGrantedCount}/${allScopes.length} ${t.userCountLabel}`)}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lines.push(`${tableEmoji} ${t.permCompareLabel}: ${t.permCompareSummary(appGrantedCount, allScopes.length, `${userGrantedCount}/${allScopes.length} ${t.userCountLabel}`)}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
// 添加指引信息
|
||||||
|
if (appGrantedCount < allScopes.length) {
|
||||||
|
// 计算缺失的应用权限
|
||||||
|
const appMissingScopes = allScopes.filter((s) => !appUserScopes.includes(s));
|
||||||
|
let appApplyUrl = `${openDomain}/app/${appId}/auth?op_from=feishu-openclaw&token_type=user`;
|
||||||
|
if (appMissingScopes.length < 20) {
|
||||||
|
appApplyUrl = `${openDomain}/app/${appId}/auth?q=${encodeURIComponent(appMissingScopes.join(','))}&op_from=feishu-openclaw&token_type=user`;
|
||||||
|
}
|
||||||
|
lines.push(`${t.appMissingUserPerms(appMissingScopes.length)} [${t.apply}](${appApplyUrl})`);
|
||||||
|
}
|
||||||
|
if (userGrantedCount < allScopes.length && validCount > 0) {
|
||||||
|
lines.push(t.userReauth);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
else if (!hasUserAuth) {
|
||||||
|
lines.push(t.userNeedsOAuth);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// 生成详细权限对照表
|
||||||
|
const table = generatePermissionTable(appUserScopes, validCount === 1 ? scopes : [], validCount === 1, locale);
|
||||||
|
lines.push(table);
|
||||||
|
// 计算总体状态
|
||||||
|
const overallStatus = tableStatus === 'fail'
|
||||||
|
? 'fail'
|
||||||
|
: authStatus === 'warn' || refreshStatus === 'warn' || tableStatus === 'warn'
|
||||||
|
? 'warn'
|
||||||
|
: 'pass';
|
||||||
|
return {
|
||||||
|
status: overallStatus,
|
||||||
|
markdown: lines.join('\n'),
|
||||||
|
hasAuth: hasUserAuth,
|
||||||
|
tokenExpired: userTokenStatus === 'expired',
|
||||||
|
missingUserScopes: userMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const applyUrl = `${openDomain}/app/${appId}/auth?q=application:application:self_manage&op_from=feishu-openclaw&token_type=tenant`;
|
||||||
|
if (err instanceof tool_client_1.AppScopeCheckFailedError) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
markdown: `${t.userPermFailedNoSelfManage}\n\n${t.adminApply} [${t.apply}](${applyUrl})`,
|
||||||
|
hasAuth: false,
|
||||||
|
tokenExpired: false,
|
||||||
|
missingUserScopes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
markdown: `${t.userPermFailed}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
hasAuth: false,
|
||||||
|
tokenExpired: false,
|
||||||
|
missingUserScopes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 主函数
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 运行飞书插件诊断,生成 Markdown 格式报告。
|
||||||
|
*
|
||||||
|
* @param config - OpenClaw 配置
|
||||||
|
* @param currentAccountId - 当前发送命令的机器人账号 ID(若有则只诊断该账号)
|
||||||
|
* @param locale - 输出语言,默认 zh_cn
|
||||||
|
*/
|
||||||
|
async function runFeishuDoctor(config, currentAccountId, locale = 'zh_cn') {
|
||||||
|
const t = T[locale];
|
||||||
|
const lines = [];
|
||||||
|
// 1. 获取目标账户
|
||||||
|
// Use the global config to enumerate all accounts — the passed-in
|
||||||
|
// config may be account-scoped (accounts map stripped).
|
||||||
|
const globalCfg = resolveGlobalConfig(config);
|
||||||
|
const allAccounts = (0, accounts_1.getEnabledLarkAccounts)(globalCfg);
|
||||||
|
if (allAccounts.length === 0) {
|
||||||
|
return t.noAccounts;
|
||||||
|
}
|
||||||
|
// 若指定了 accountId,只诊断该账号
|
||||||
|
const accounts = currentAccountId ? allAccounts.filter((a) => a.accountId === currentAccountId) : allAccounts;
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return `${t.accountNotFoundPrefix} "${currentAccountId}"\n\n${t.enabledAccountsLabel}: ${allAccounts.map((a) => a.accountId).join(', ')}`;
|
||||||
|
}
|
||||||
|
// 2. 生成报告头部
|
||||||
|
lines.push(t.reportTitle);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`${t.pluginVersionLabel}: ${(0, version_1.getPluginVersion)()} | ${t.diagTimeLabel}: ${formatTimestamp(new Date())}`);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
// 3. 工具配置(全局,不区分账户)
|
||||||
|
const toolsResult = checkToolsProfile(config, locale);
|
||||||
|
const toolsTitle = toolsResult.status === 'pass' ? t.toolsCheckPass : t.toolsCheckWarn;
|
||||||
|
lines.push(toolsTitle);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(toolsResult.markdown);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
// 3.5 多账号隔离检查(全局问题,始终展示)
|
||||||
|
// TODO: 暂时注释掉,等产品策略明确后再放开
|
||||||
|
// const isolationStatus = checkMultiAccountIsolation(config);
|
||||||
|
// const isolationWarning = formatIsolationWarning(isolationStatus, config);
|
||||||
|
// if (isolationWarning) {
|
||||||
|
// lines.push(isolationWarning);
|
||||||
|
// lines.push("");
|
||||||
|
// lines.push("---");
|
||||||
|
// lines.push("");
|
||||||
|
// }
|
||||||
|
// 4. 逐账户诊断(仅目标账户)
|
||||||
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
|
const account = accounts[i];
|
||||||
|
const sdk = lark_client_1.LarkClient.fromAccount(account).sdk;
|
||||||
|
const accountLabel = account.accountId || account.appId;
|
||||||
|
if (accounts.length > 1) {
|
||||||
|
lines.push(`${t.accountPrefix} ${i + 1}: ${accountLabel}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
// 4a. 环境信息
|
||||||
|
const basicInfoResult = await checkBasicInfo(account, config, locale);
|
||||||
|
const basicTitle = basicInfoResult.status === 'pass' ? t.envCheckPass : t.envCheckFail;
|
||||||
|
lines.push(basicTitle);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(basicInfoResult.markdown);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
// 4b. 应用权限
|
||||||
|
const appResult = await checkAppPermissions(account, sdk, locale);
|
||||||
|
const appTitle = appResult.status === 'pass' ? t.appPermPass : t.appPermFail;
|
||||||
|
lines.push(appTitle);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(appResult.markdown);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
// 4c. 用户权限
|
||||||
|
const userResult = await checkUserPermissions(account, sdk, locale);
|
||||||
|
const userTitle = userResult.status === 'pass' ? t.userPermPass : t.userPermFail;
|
||||||
|
lines.push(userTitle);
|
||||||
|
lines.push('');
|
||||||
|
lines.push(userResult.markdown);
|
||||||
|
lines.push('');
|
||||||
|
if (i < accounts.length - 1) {
|
||||||
|
lines.push('---');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 运行飞书插件诊断,同时生成中英双语 Markdown 报告。
|
||||||
|
* 用于飞书 channel 的多语言 post 发送。
|
||||||
|
*/
|
||||||
|
async function runFeishuDoctorI18n(config, currentAccountId) {
|
||||||
|
const [zh_cn, en_us] = await Promise.all([
|
||||||
|
runFeishuDoctor(config, currentAccountId, 'zh_cn'),
|
||||||
|
runFeishuDoctor(config, currentAccountId, 'en_us'),
|
||||||
|
]);
|
||||||
|
return { zh_cn, en_us };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Register all chat commands (/feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu).
|
||||||
|
*/
|
||||||
|
import type { OpenClawConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
||||||
|
import type { FeishuLocale } from './locale';
|
||||||
|
/**
|
||||||
|
* 运行 /feishu start 校验,返回 Markdown 格式结果。
|
||||||
|
*/
|
||||||
|
export declare function runFeishuStart(config: OpenClawConfig, locale?: FeishuLocale): string;
|
||||||
|
/**
|
||||||
|
* 运行 /feishu start,同时生成中英双语结果。
|
||||||
|
*/
|
||||||
|
export declare function runFeishuStartI18n(config: OpenClawConfig): Record<FeishuLocale, string>;
|
||||||
|
/**
|
||||||
|
* 生成 /feishu help 帮助文本。
|
||||||
|
*/
|
||||||
|
export declare function getFeishuHelp(locale?: FeishuLocale): string;
|
||||||
|
/**
|
||||||
|
* 生成 /feishu help,同时生成中英双语结果。
|
||||||
|
*/
|
||||||
|
export declare function getFeishuHelpI18n(): Record<FeishuLocale, string>;
|
||||||
|
export declare function registerCommands(api: OpenClawPluginApi): void;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Register all chat commands (/feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu).
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.runFeishuStart = runFeishuStart;
|
||||||
|
exports.runFeishuStartI18n = runFeishuStartI18n;
|
||||||
|
exports.getFeishuHelp = getFeishuHelp;
|
||||||
|
exports.getFeishuHelpI18n = getFeishuHelpI18n;
|
||||||
|
exports.registerCommands = registerCommands;
|
||||||
|
const version_1 = require("../core/version.js");
|
||||||
|
const diagnose_1 = require("./diagnose.js");
|
||||||
|
const doctor_1 = require("./doctor.js");
|
||||||
|
const auth_1 = require("./auth.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// I18n text map for /feishu start, help, and error messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const T = {
|
||||||
|
zh_cn: {
|
||||||
|
legacyNotDisabled: '❌ 检测到旧版插件未禁用。\n' +
|
||||||
|
'👉 请依次运行命令:\n' +
|
||||||
|
'```\n' +
|
||||||
|
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
|
||||||
|
'openclaw gateway restart\n' +
|
||||||
|
'```',
|
||||||
|
toolsProfileWarn: (profile) => `⚠️ 工具 Profile 当前为 \`${profile}\`,飞书工具可能无法加载。请检查配置是否正确。\n`,
|
||||||
|
startFailed: (details) => `❌ 飞书 OpenClaw 插件启动失败:\n\n${details}`,
|
||||||
|
startWithWarnings: (version, details) => `⚠️ 飞书 OpenClaw 插件已启动 v${version}(存在警告)\n\n${details}`,
|
||||||
|
startOk: (version) => `✅ 飞书 OpenClaw 插件已启动 v${version}`,
|
||||||
|
helpTitle: (version) => `飞书OpenClaw插件 v${version}`,
|
||||||
|
helpUsage: '用法:',
|
||||||
|
helpStart: '/feishu start - 校验插件配置',
|
||||||
|
helpAuth: '/feishu auth - 批量授权用户权限',
|
||||||
|
helpDoctor: '/feishu doctor - 运行诊断',
|
||||||
|
helpHelp: '/feishu help - 显示此帮助',
|
||||||
|
diagFailed: (msg) => `诊断执行失败: ${msg}`,
|
||||||
|
authFailed: (msg) => `授权执行失败: ${msg}`,
|
||||||
|
execFailed: (msg) => `执行失败: ${msg}`,
|
||||||
|
},
|
||||||
|
en_us: {
|
||||||
|
legacyNotDisabled: '❌ Legacy plugin is not disabled.\n' +
|
||||||
|
'👉 Please run the following commands:\n' +
|
||||||
|
'```\n' +
|
||||||
|
'openclaw config set plugins.entries.feishu.enabled false --json\n' +
|
||||||
|
'openclaw gateway restart\n' +
|
||||||
|
'```',
|
||||||
|
toolsProfileWarn: (profile) => `⚠️ Tools profile is currently set to \`${profile}\`. Feishu tools may not load properly. Please check your configuration.\n`,
|
||||||
|
startFailed: (details) => `❌ Feishu OpenClaw plugin failed to start:\n\n${details}`,
|
||||||
|
startWithWarnings: (version, details) => `⚠️ Feishu OpenClaw plugin started v${version} (with warnings)\n\n${details}`,
|
||||||
|
startOk: (version) => `✅ Feishu OpenClaw plugin started v${version}`,
|
||||||
|
helpTitle: (version) => `Feishu OpenClaw Plugin v${version}`,
|
||||||
|
helpUsage: 'Usage:',
|
||||||
|
helpStart: '/feishu start - Validate plugin configuration',
|
||||||
|
helpAuth: '/feishu auth - Batch authorize user permissions',
|
||||||
|
helpDoctor: '/feishu doctor - Run diagnostics',
|
||||||
|
helpHelp: '/feishu help - Show this help',
|
||||||
|
diagFailed: (msg) => `Diagnostics failed: ${msg}`,
|
||||||
|
authFailed: (msg) => `Authorization failed: ${msg}`,
|
||||||
|
execFailed: (msg) => `Execution failed: ${msg}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exported i18n functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 运行 /feishu start 校验,返回 Markdown 格式结果。
|
||||||
|
*/
|
||||||
|
function runFeishuStart(config, locale = 'zh_cn') {
|
||||||
|
const t = T[locale];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const cfg = config;
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
// 检查旧版插件是否已禁用 (error)
|
||||||
|
const feishuEntry = cfg.plugins?.entries?.feishu;
|
||||||
|
if (feishuEntry && feishuEntry.enabled !== false) {
|
||||||
|
errors.push(t.legacyNotDisabled);
|
||||||
|
}
|
||||||
|
// 检查 tools.profile (warning)
|
||||||
|
const profile = cfg.tools?.profile;
|
||||||
|
const incompleteProfiles = new Set(['minimal', 'coding', 'messaging']);
|
||||||
|
if (profile && incompleteProfiles.has(profile)) {
|
||||||
|
warnings.push(t.toolsProfileWarn(profile));
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
const all = [...errors, ...warnings];
|
||||||
|
return t.startFailed(all.join('\n\n'));
|
||||||
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
return t.startWithWarnings((0, version_1.getPluginVersion)(), warnings.join('\n\n'));
|
||||||
|
}
|
||||||
|
return t.startOk((0, version_1.getPluginVersion)());
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 运行 /feishu start,同时生成中英双语结果。
|
||||||
|
*/
|
||||||
|
function runFeishuStartI18n(config) {
|
||||||
|
return {
|
||||||
|
zh_cn: runFeishuStart(config, 'zh_cn'),
|
||||||
|
en_us: runFeishuStart(config, 'en_us'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 生成 /feishu help 帮助文本。
|
||||||
|
*/
|
||||||
|
function getFeishuHelp(locale = 'zh_cn') {
|
||||||
|
const t = T[locale];
|
||||||
|
return (`${t.helpTitle((0, version_1.getPluginVersion)())}\n\n` +
|
||||||
|
`${t.helpUsage}\n` +
|
||||||
|
` ${t.helpStart}\n` +
|
||||||
|
` ${t.helpAuth}\n` +
|
||||||
|
` ${t.helpDoctor}\n` +
|
||||||
|
` ${t.helpHelp}`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 生成 /feishu help,同时生成中英双语结果。
|
||||||
|
*/
|
||||||
|
function getFeishuHelpI18n() {
|
||||||
|
return {
|
||||||
|
zh_cn: getFeishuHelp('zh_cn'),
|
||||||
|
en_us: getFeishuHelp('en_us'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command registration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function registerCommands(api) {
|
||||||
|
// /feishu_diagnose
|
||||||
|
api.registerCommand({
|
||||||
|
name: 'feishu_diagnose',
|
||||||
|
description: 'Run Feishu plugin diagnostics to check config, connectivity, and permissions',
|
||||||
|
acceptsArgs: false,
|
||||||
|
requireAuth: true,
|
||||||
|
async handler(ctx) {
|
||||||
|
try {
|
||||||
|
const report = await (0, diagnose_1.runDiagnosis)({ config: ctx.config });
|
||||||
|
return { text: (0, diagnose_1.formatDiagReportText)(report) };
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return {
|
||||||
|
text: T.zh_cn.diagFailed(err instanceof Error ? err.message : String(err)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// /feishu_doctor
|
||||||
|
api.registerCommand({
|
||||||
|
name: 'feishu_doctor',
|
||||||
|
description: 'Run Feishu plugin diagnostics',
|
||||||
|
acceptsArgs: false,
|
||||||
|
requireAuth: true,
|
||||||
|
async handler(ctx) {
|
||||||
|
try {
|
||||||
|
const markdown = await (0, doctor_1.runFeishuDoctor)(ctx.config, ctx.accountId);
|
||||||
|
return { text: markdown };
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return {
|
||||||
|
text: T.zh_cn.diagFailed(err instanceof Error ? err.message : String(err)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// /feishu_auth
|
||||||
|
api.registerCommand({
|
||||||
|
name: 'feishu_auth',
|
||||||
|
description: 'Batch authorize user permissions for Feishu',
|
||||||
|
acceptsArgs: false,
|
||||||
|
requireAuth: true,
|
||||||
|
async handler(ctx) {
|
||||||
|
try {
|
||||||
|
const result = await (0, auth_1.runFeishuAuth)(ctx.config);
|
||||||
|
return { text: result };
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return {
|
||||||
|
text: T.zh_cn.authFailed(err instanceof Error ? err.message : String(err)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// /feishu (统一入口,支持子命令)
|
||||||
|
api.registerCommand({
|
||||||
|
name: 'feishu',
|
||||||
|
description: 'Feishu plugin commands (subcommands: auth, doctor, start)',
|
||||||
|
acceptsArgs: true,
|
||||||
|
requireAuth: true,
|
||||||
|
async handler(ctx) {
|
||||||
|
const args = ctx.args?.trim().split(/\s+/) || [];
|
||||||
|
const subcommand = args[0]?.toLowerCase();
|
||||||
|
try {
|
||||||
|
// /feishu auth 或 /feishu onboarding
|
||||||
|
if (subcommand === 'auth' || subcommand === 'onboarding') {
|
||||||
|
const result = await (0, auth_1.runFeishuAuth)(ctx.config);
|
||||||
|
return { text: result };
|
||||||
|
}
|
||||||
|
// /feishu doctor
|
||||||
|
if (subcommand === 'doctor') {
|
||||||
|
const markdown = await (0, doctor_1.runFeishuDoctor)(ctx.config, ctx.accountId);
|
||||||
|
return { text: markdown };
|
||||||
|
}
|
||||||
|
// /feishu start
|
||||||
|
if (subcommand === 'start') {
|
||||||
|
return { text: runFeishuStart(ctx.config) };
|
||||||
|
}
|
||||||
|
// /feishu help 或无效子命令或无参数
|
||||||
|
return { text: getFeishuHelp() };
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return {
|
||||||
|
text: T.zh_cn.execFailed(err instanceof Error ? err.message : String(err)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared locale type for feishu command i18n.
|
||||||
|
*/
|
||||||
|
export type FeishuLocale = 'zh_cn' | 'en_us';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared locale type for feishu command i18n.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Lark multi-account management.
|
||||||
|
*
|
||||||
|
* Account overrides live under `cfg.channels.feishu.accounts`.
|
||||||
|
* Each account may override any top-level Feishu config field;
|
||||||
|
* unset fields fall back to the top-level defaults.
|
||||||
|
*/
|
||||||
|
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||||
|
import type { ConfiguredLarkAccount, FeishuConfig, LarkAccount, LarkCredentials } from './types';
|
||||||
|
/**
|
||||||
|
* List all account IDs defined in the Lark config.
|
||||||
|
*
|
||||||
|
* Returns `[DEFAULT_ACCOUNT_ID]` when no explicit accounts exist.
|
||||||
|
*/
|
||||||
|
export declare function getLarkAccountIds(cfg: ClawdbotConfig): string[];
|
||||||
|
/** Return the first (default) account ID. */
|
||||||
|
export declare function getDefaultLarkAccountId(cfg: ClawdbotConfig): string;
|
||||||
|
/**
|
||||||
|
* Resolve a single account by merging the top-level config with
|
||||||
|
* account-level overrides. Account fields take precedence.
|
||||||
|
*
|
||||||
|
* Falls back to the default account when `accountId` is omitted or `null`.
|
||||||
|
*/
|
||||||
|
export declare function getLarkAccount(cfg: ClawdbotConfig, accountId?: string | null): LarkAccount;
|
||||||
|
/**
|
||||||
|
* Build an account-scoped config view for downstream helpers that read from
|
||||||
|
* `cfg.channels.feishu`.
|
||||||
|
*
|
||||||
|
* In multi-account mode, many runtime helpers expect the merged account config
|
||||||
|
* to already be exposed at `cfg.channels.feishu`. This mirrors the inbound
|
||||||
|
* path behavior so outbound/tooling code resolves per-account settings
|
||||||
|
* consistently.
|
||||||
|
*
|
||||||
|
* @param cfg - Original top-level plugin config
|
||||||
|
* @param accountId - Optional target account ID
|
||||||
|
* @returns Config with `channels.feishu` replaced by the merged account config
|
||||||
|
*/
|
||||||
|
export declare function createAccountScopedConfig(cfg: ClawdbotConfig, accountId?: string | null): ClawdbotConfig;
|
||||||
|
/** Return all accounts that are both configured and enabled. */
|
||||||
|
export declare function getEnabledLarkAccounts(cfg: ClawdbotConfig): LarkAccount[];
|
||||||
|
/**
|
||||||
|
* Extract API credentials from a Feishu config fragment.
|
||||||
|
*
|
||||||
|
* Returns `null` when `appId` or `appSecret` is missing.
|
||||||
|
*/
|
||||||
|
export declare function getLarkCredentials(feishuCfg?: FeishuConfig): LarkCredentials | null;
|
||||||
|
/** Type guard: narrow `LarkAccount` to `ConfiguredLarkAccount`. */
|
||||||
|
export declare function isConfigured(account: LarkAccount): account is ConfiguredLarkAccount;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Lark multi-account management.
|
||||||
|
*
|
||||||
|
* Account overrides live under `cfg.channels.feishu.accounts`.
|
||||||
|
* Each account may override any top-level Feishu config field;
|
||||||
|
* unset fields fall back to the top-level defaults.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.getLarkAccountIds = getLarkAccountIds;
|
||||||
|
exports.getDefaultLarkAccountId = getDefaultLarkAccountId;
|
||||||
|
exports.getLarkAccount = getLarkAccount;
|
||||||
|
exports.createAccountScopedConfig = createAccountScopedConfig;
|
||||||
|
exports.getEnabledLarkAccounts = getEnabledLarkAccounts;
|
||||||
|
exports.getLarkCredentials = getLarkCredentials;
|
||||||
|
exports.isConfigured = isConfigured;
|
||||||
|
const account_id_1 = require("openclaw/plugin-sdk/account-id");
|
||||||
|
const normalizeAccountId = typeof account_id_1.normalizeAccountId === 'function'
|
||||||
|
? account_id_1.normalizeAccountId
|
||||||
|
: (id) => id?.trim().toLowerCase() || undefined;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** Extract the `channels.feishu` section from the top-level config. */
|
||||||
|
function getLarkConfig(cfg) {
|
||||||
|
return cfg?.channels?.feishu;
|
||||||
|
}
|
||||||
|
/** Return the per-account override map, if present. */
|
||||||
|
function getAccountMap(section) {
|
||||||
|
return section.accounts;
|
||||||
|
}
|
||||||
|
/** Strip the `accounts` key and return the remaining top-level config. */
|
||||||
|
function baseConfig(section) {
|
||||||
|
const { accounts: _ignored, ...rest } = section;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
/** Merge base config with account override (account fields take precedence).
|
||||||
|
* Performs a one-level deep merge for plain-object fields so that partial
|
||||||
|
* account overrides (e.g. `footer: { model: false }`) are merged with
|
||||||
|
* the base instead of replacing the entire object. */
|
||||||
|
function mergeAccountConfig(base, override) {
|
||||||
|
const result = { ...base };
|
||||||
|
for (const [key, value] of Object.entries(override)) {
|
||||||
|
if (value === undefined)
|
||||||
|
continue;
|
||||||
|
const baseVal = base[key];
|
||||||
|
// Deep-merge plain objects one level (footer, tools, heartbeat, etc.)
|
||||||
|
if (value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
baseVal &&
|
||||||
|
typeof baseVal === 'object' &&
|
||||||
|
!Array.isArray(baseVal)) {
|
||||||
|
result[key] = { ...baseVal, ...value };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/** Coerce a domain string to `LarkBrand`, defaulting to `"feishu"`. */
|
||||||
|
function toBrand(domain) {
|
||||||
|
return domain ?? 'feishu';
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* List all account IDs defined in the Lark config.
|
||||||
|
*
|
||||||
|
* Returns `[DEFAULT_ACCOUNT_ID]` when no explicit accounts exist.
|
||||||
|
*/
|
||||||
|
function getLarkAccountIds(cfg) {
|
||||||
|
const section = getLarkConfig(cfg);
|
||||||
|
if (!section)
|
||||||
|
return [account_id_1.DEFAULT_ACCOUNT_ID];
|
||||||
|
const accountMap = getAccountMap(section);
|
||||||
|
if (!accountMap || Object.keys(accountMap).length === 0) {
|
||||||
|
return [account_id_1.DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
const accountIds = Object.keys(accountMap);
|
||||||
|
// 当 accounts 存在时,如果顶层也配置了 appId/appSecret(即默认机器人),
|
||||||
|
// 将 DEFAULT_ACCOUNT_ID 加入列表,确保顶层机器人不会被忽略。
|
||||||
|
// 但如果 accountMap 已经包含 default,则不重复添加。
|
||||||
|
const hasDefault = accountIds.some((id) => id.trim().toLowerCase() === account_id_1.DEFAULT_ACCOUNT_ID);
|
||||||
|
if (!hasDefault) {
|
||||||
|
const base = baseConfig(section);
|
||||||
|
if (base.appId && base.appSecret) {
|
||||||
|
return [account_id_1.DEFAULT_ACCOUNT_ID, ...accountIds];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return accountIds;
|
||||||
|
}
|
||||||
|
/** Return the first (default) account ID. */
|
||||||
|
function getDefaultLarkAccountId(cfg) {
|
||||||
|
return getLarkAccountIds(cfg)[0];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve a single account by merging the top-level config with
|
||||||
|
* account-level overrides. Account fields take precedence.
|
||||||
|
*
|
||||||
|
* Falls back to the default account when `accountId` is omitted or `null`.
|
||||||
|
*/
|
||||||
|
function getLarkAccount(cfg, accountId) {
|
||||||
|
const requestedId = accountId ? (normalizeAccountId(accountId) ?? account_id_1.DEFAULT_ACCOUNT_ID) : account_id_1.DEFAULT_ACCOUNT_ID;
|
||||||
|
const section = getLarkConfig(cfg);
|
||||||
|
if (!section) {
|
||||||
|
return {
|
||||||
|
accountId: requestedId,
|
||||||
|
enabled: false,
|
||||||
|
configured: false,
|
||||||
|
brand: 'feishu',
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const base = baseConfig(section);
|
||||||
|
const accountMap = getAccountMap(section);
|
||||||
|
const accountOverride = accountMap && requestedId !== account_id_1.DEFAULT_ACCOUNT_ID
|
||||||
|
? accountMap[requestedId]
|
||||||
|
: undefined;
|
||||||
|
const merged = accountOverride
|
||||||
|
? mergeAccountConfig(base, accountOverride)
|
||||||
|
: { ...base };
|
||||||
|
const appId = merged.appId;
|
||||||
|
const appSecret = merged.appSecret;
|
||||||
|
const configured = !!(appId && appSecret);
|
||||||
|
// Respect explicit `enabled` when set; otherwise derive from `configured`.
|
||||||
|
const enabled = !!(merged.enabled ?? configured);
|
||||||
|
const brand = toBrand(merged.domain);
|
||||||
|
if (configured) {
|
||||||
|
return {
|
||||||
|
accountId: requestedId,
|
||||||
|
enabled,
|
||||||
|
configured: true,
|
||||||
|
name: merged.name ?? undefined,
|
||||||
|
appId: appId,
|
||||||
|
appSecret: appSecret,
|
||||||
|
encryptKey: merged.encryptKey ?? undefined,
|
||||||
|
verificationToken: merged.verificationToken ?? undefined,
|
||||||
|
brand,
|
||||||
|
config: merged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accountId: requestedId,
|
||||||
|
enabled,
|
||||||
|
configured: false,
|
||||||
|
name: merged.name ?? undefined,
|
||||||
|
appId: appId ?? undefined,
|
||||||
|
appSecret: appSecret ?? undefined,
|
||||||
|
encryptKey: merged.encryptKey ?? undefined,
|
||||||
|
verificationToken: merged.verificationToken ?? undefined,
|
||||||
|
brand,
|
||||||
|
config: merged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build an account-scoped config view for downstream helpers that read from
|
||||||
|
* `cfg.channels.feishu`.
|
||||||
|
*
|
||||||
|
* In multi-account mode, many runtime helpers expect the merged account config
|
||||||
|
* to already be exposed at `cfg.channels.feishu`. This mirrors the inbound
|
||||||
|
* path behavior so outbound/tooling code resolves per-account settings
|
||||||
|
* consistently.
|
||||||
|
*
|
||||||
|
* @param cfg - Original top-level plugin config
|
||||||
|
* @param accountId - Optional target account ID
|
||||||
|
* @returns Config with `channels.feishu` replaced by the merged account config
|
||||||
|
*/
|
||||||
|
function createAccountScopedConfig(cfg, accountId) {
|
||||||
|
const account = getLarkAccount(cfg, accountId);
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
feishu: account.config,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Return all accounts that are both configured and enabled. */
|
||||||
|
function getEnabledLarkAccounts(cfg) {
|
||||||
|
const ids = getLarkAccountIds(cfg);
|
||||||
|
const results = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const account = getLarkAccount(cfg, id);
|
||||||
|
if (account.enabled && account.configured) {
|
||||||
|
results.push(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Extract API credentials from a Feishu config fragment.
|
||||||
|
*
|
||||||
|
* Returns `null` when `appId` or `appSecret` is missing.
|
||||||
|
*/
|
||||||
|
function getLarkCredentials(feishuCfg) {
|
||||||
|
if (!feishuCfg)
|
||||||
|
return null;
|
||||||
|
const appId = feishuCfg.appId;
|
||||||
|
const appSecret = feishuCfg.appSecret;
|
||||||
|
if (!appId || !appSecret)
|
||||||
|
return null;
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
appSecret,
|
||||||
|
encryptKey: feishuCfg.encryptKey ?? undefined,
|
||||||
|
verificationToken: feishuCfg.verificationToken ?? undefined,
|
||||||
|
brand: toBrand(feishuCfg.domain),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Type guard: narrow `LarkAccount` to `ConfiguredLarkAccount`. */
|
||||||
|
function isConfigured(account) {
|
||||||
|
return account.configured;
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Agent configuration helpers for the Lark/Feishu channel plugin.
|
||||||
|
*
|
||||||
|
* Reads agent-level configuration (identity, skills, tools, subagents)
|
||||||
|
* from the top-level `agents.list` in OpenClawConfig. These helpers
|
||||||
|
* bridge the gap between the SDK's agent infrastructure and the Feishu
|
||||||
|
* plugin's dispatch/reply layers.
|
||||||
|
*/
|
||||||
|
import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
|
||||||
|
/** Minimal agent identity fields used by the Feishu plugin. */
|
||||||
|
interface AgentIdentity {
|
||||||
|
name?: string;
|
||||||
|
emoji?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
/** Minimal agent tools policy fields. */
|
||||||
|
interface AgentToolsPolicy {
|
||||||
|
allow?: string[];
|
||||||
|
deny?: string[];
|
||||||
|
}
|
||||||
|
/** Shape of an agent entry in `config.agents.list`. */
|
||||||
|
interface AgentEntry {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
skills?: string[];
|
||||||
|
identity?: AgentIdentity;
|
||||||
|
tools?: AgentToolsPolicy & Record<string, unknown>;
|
||||||
|
subagents?: {
|
||||||
|
allowAgents?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve the full list of configured agents from config.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @returns Array of agent entries, or empty array if none configured.
|
||||||
|
*/
|
||||||
|
export declare function listConfiguredAgents(cfg: ClawdbotConfig): AgentEntry[];
|
||||||
|
/**
|
||||||
|
* Look up a specific agent's configuration by its ID.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID to search for.
|
||||||
|
* @returns The matching agent entry, or `undefined` if not found.
|
||||||
|
*/
|
||||||
|
export declare function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined;
|
||||||
|
/**
|
||||||
|
* Resolve a human-readable display name for an agent.
|
||||||
|
*
|
||||||
|
* Priority: `identity.name` > `name` > `undefined`.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns The display name, or `undefined` if none configured.
|
||||||
|
*/
|
||||||
|
export declare function getAgentDisplayName(cfg: ClawdbotConfig, agentId: string): string | undefined;
|
||||||
|
/**
|
||||||
|
* Resolve the per-agent skills filter.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns Skill allowlist, or `undefined` if no agent-level filter.
|
||||||
|
*/
|
||||||
|
export declare function getAgentSkillsFilter(cfg: ClawdbotConfig, agentId: string): string[] | undefined;
|
||||||
|
/**
|
||||||
|
* Resolve the per-agent tools policy (allow/deny lists).
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns Tools policy object, or `undefined` if none configured.
|
||||||
|
*/
|
||||||
|
export declare function getAgentToolsPolicy(cfg: ClawdbotConfig, agentId: string): AgentToolsPolicy | undefined;
|
||||||
|
/**
|
||||||
|
* Merge agent-level and group-level skill filters.
|
||||||
|
*
|
||||||
|
* When both are present, the effective filter is the intersection:
|
||||||
|
* a skill must appear in both lists to be included. When only one
|
||||||
|
* is present, that list is used as-is.
|
||||||
|
*
|
||||||
|
* @param agentSkills - Per-agent skill allowlist (from AgentConfig.skills).
|
||||||
|
* @param groupSkills - Per-group skill allowlist (from FeishuGroupConfig.skills).
|
||||||
|
* @returns Merged skill filter, or `undefined` if neither is set.
|
||||||
|
*/
|
||||||
|
export declare function mergeSkillFilters(agentSkills: string[] | undefined, groupSkills: string[] | undefined): string[] | undefined;
|
||||||
|
/**
|
||||||
|
* Check whether a tool name is permitted by an agent's tool policy.
|
||||||
|
*
|
||||||
|
* Evaluation order:
|
||||||
|
* 1. If `deny` list exists and tool matches → denied.
|
||||||
|
* 2. If `allow` list exists and tool does NOT match → denied.
|
||||||
|
* 3. Otherwise → allowed.
|
||||||
|
*
|
||||||
|
* Supports glob-like patterns with trailing `*` (e.g. `feishu_calendar_*`).
|
||||||
|
*
|
||||||
|
* @param toolName - The tool name being invoked.
|
||||||
|
* @param policy - The agent's tool policy.
|
||||||
|
* @returns `true` if the tool is allowed, `false` if denied.
|
||||||
|
*/
|
||||||
|
export declare function isToolAllowedByPolicy(toolName: string, policy: AgentToolsPolicy | undefined): boolean;
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"use strict";
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
/**
|
||||||
|
* Agent configuration helpers for the Lark/Feishu channel plugin.
|
||||||
|
*
|
||||||
|
* Reads agent-level configuration (identity, skills, tools, subagents)
|
||||||
|
* from the top-level `agents.list` in OpenClawConfig. These helpers
|
||||||
|
* bridge the gap between the SDK's agent infrastructure and the Feishu
|
||||||
|
* plugin's dispatch/reply layers.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.listConfiguredAgents = listConfiguredAgents;
|
||||||
|
exports.resolveAgentEntry = resolveAgentEntry;
|
||||||
|
exports.getAgentDisplayName = getAgentDisplayName;
|
||||||
|
exports.getAgentSkillsFilter = getAgentSkillsFilter;
|
||||||
|
exports.getAgentToolsPolicy = getAgentToolsPolicy;
|
||||||
|
exports.mergeSkillFilters = mergeSkillFilters;
|
||||||
|
exports.isToolAllowedByPolicy = isToolAllowedByPolicy;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Retrieve the full list of configured agents from config.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @returns Array of agent entries, or empty array if none configured.
|
||||||
|
*/
|
||||||
|
function listConfiguredAgents(cfg) {
|
||||||
|
const agents = cfg.agents;
|
||||||
|
return agents?.list ?? [];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Look up a specific agent's configuration by its ID.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID to search for.
|
||||||
|
* @returns The matching agent entry, or `undefined` if not found.
|
||||||
|
*/
|
||||||
|
function resolveAgentEntry(cfg, agentId) {
|
||||||
|
return listConfiguredAgents(cfg).find((a) => a.id === agentId);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve a human-readable display name for an agent.
|
||||||
|
*
|
||||||
|
* Priority: `identity.name` > `name` > `undefined`.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns The display name, or `undefined` if none configured.
|
||||||
|
*/
|
||||||
|
function getAgentDisplayName(cfg, agentId) {
|
||||||
|
const entry = resolveAgentEntry(cfg, agentId);
|
||||||
|
if (!entry)
|
||||||
|
return undefined;
|
||||||
|
return entry.identity?.name ?? entry.name;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve the per-agent skills filter.
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns Skill allowlist, or `undefined` if no agent-level filter.
|
||||||
|
*/
|
||||||
|
function getAgentSkillsFilter(cfg, agentId) {
|
||||||
|
return resolveAgentEntry(cfg, agentId)?.skills;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Resolve the per-agent tools policy (allow/deny lists).
|
||||||
|
*
|
||||||
|
* @param cfg - The top-level application config.
|
||||||
|
* @param agentId - The agent ID.
|
||||||
|
* @returns Tools policy object, or `undefined` if none configured.
|
||||||
|
*/
|
||||||
|
function getAgentToolsPolicy(cfg, agentId) {
|
||||||
|
const entry = resolveAgentEntry(cfg, agentId);
|
||||||
|
if (!entry?.tools)
|
||||||
|
return undefined;
|
||||||
|
const { allow, deny } = entry.tools;
|
||||||
|
if (!allow && !deny)
|
||||||
|
return undefined;
|
||||||
|
return { allow, deny };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Merge agent-level and group-level skill filters.
|
||||||
|
*
|
||||||
|
* When both are present, the effective filter is the intersection:
|
||||||
|
* a skill must appear in both lists to be included. When only one
|
||||||
|
* is present, that list is used as-is.
|
||||||
|
*
|
||||||
|
* @param agentSkills - Per-agent skill allowlist (from AgentConfig.skills).
|
||||||
|
* @param groupSkills - Per-group skill allowlist (from FeishuGroupConfig.skills).
|
||||||
|
* @returns Merged skill filter, or `undefined` if neither is set.
|
||||||
|
*/
|
||||||
|
function mergeSkillFilters(agentSkills, groupSkills) {
|
||||||
|
if (!agentSkills && !groupSkills)
|
||||||
|
return undefined;
|
||||||
|
if (!agentSkills)
|
||||||
|
return groupSkills;
|
||||||
|
if (!groupSkills)
|
||||||
|
return agentSkills;
|
||||||
|
// Intersection: group filter narrows the agent filter.
|
||||||
|
const agentSet = new Set(agentSkills);
|
||||||
|
return groupSkills.filter((s) => agentSet.has(s));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check whether a tool name is permitted by an agent's tool policy.
|
||||||
|
*
|
||||||
|
* Evaluation order:
|
||||||
|
* 1. If `deny` list exists and tool matches → denied.
|
||||||
|
* 2. If `allow` list exists and tool does NOT match → denied.
|
||||||
|
* 3. Otherwise → allowed.
|
||||||
|
*
|
||||||
|
* Supports glob-like patterns with trailing `*` (e.g. `feishu_calendar_*`).
|
||||||
|
*
|
||||||
|
* @param toolName - The tool name being invoked.
|
||||||
|
* @param policy - The agent's tool policy.
|
||||||
|
* @returns `true` if the tool is allowed, `false` if denied.
|
||||||
|
*/
|
||||||
|
function isToolAllowedByPolicy(toolName, policy) {
|
||||||
|
if (!policy)
|
||||||
|
return true;
|
||||||
|
if (policy.deny && policy.deny.length > 0) {
|
||||||
|
if (matchesAnyPattern(toolName, policy.deny))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (policy.allow && policy.allow.length > 0) {
|
||||||
|
return matchesAnyPattern(toolName, policy.allow);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Check whether a string matches any of the given patterns.
|
||||||
|
* Supports trailing `*` as a simple wildcard.
|
||||||
|
*/
|
||||||
|
function matchesAnyPattern(value, patterns) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern === '*')
|
||||||
|
return true;
|
||||||
|
if (pattern.endsWith('*')) {
|
||||||
|
if (value.startsWith(pattern.slice(0, -1)))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if (value === pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared Lark API error handling utilities.
|
||||||
|
*
|
||||||
|
* Provides unified error handling for two distinct error paths:
|
||||||
|
*
|
||||||
|
* 1. **Response-level errors** — The SDK returns a response object with a
|
||||||
|
* non-zero `code`. Handled by {@link assertLarkOk}.
|
||||||
|
*
|
||||||
|
* 2. **Thrown exceptions** — The SDK throws an Axios-style error (HTTP 4xx)
|
||||||
|
* whose properties include the Feishu error `code` and `msg`.
|
||||||
|
* Handled by {@link formatLarkError}.
|
||||||
|
*
|
||||||
|
* Both paths intercept well-known codes (e.g. LARK_ERROR.APP_SCOPE_MISSING (99991672) — missing API scopes)
|
||||||
|
* and produce user-friendly messages with actionable authorization links.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 从 Lark SDK 抛错对象中提取飞书 API code。
|
||||||
|
*
|
||||||
|
* 支持三种常见结构:
|
||||||
|
* - `{ code }` — SDK 直接挂载
|
||||||
|
* - `{ data: { code } }` — 响应体嵌套
|
||||||
|
* - `{ response: { data: { code } } }` — Axios 风格
|
||||||
|
*/
|
||||||
|
export declare function extractLarkApiCode(err: unknown): number | undefined;
|
||||||
|
/**
|
||||||
|
* Assert that a Lark SDK response is successful (code === 0).
|
||||||
|
*
|
||||||
|
* For permission errors (code LARK_ERROR.APP_SCOPE_MISSING (99991672)), the thrown error includes the
|
||||||
|
* required scope names and a direct authorization URL so the AI can
|
||||||
|
* present it to the end user.
|
||||||
|
*/
|
||||||
|
export declare function assertLarkOk(res: {
|
||||||
|
code?: number;
|
||||||
|
msg?: string;
|
||||||
|
}): void;
|
||||||
|
/**
|
||||||
|
* Extract a meaningful error message from a thrown Lark SDK / Axios error.
|
||||||
|
*
|
||||||
|
* The Lark SDK throws Axios errors whose object carries Feishu-specific
|
||||||
|
* fields (`code`, `msg`) alongside the standard `message`. For permission
|
||||||
|
* errors (LARK_ERROR.APP_SCOPE_MISSING (99991672)) we format a user-friendly string with scopes + auth URL.
|
||||||
|
* For all other errors we try `err.msg` first (the Feishu detail) and fall
|
||||||
|
* back to `err.message` (the generic Axios text).
|
||||||
|
*/
|
||||||
|
export declare function formatLarkError(err: unknown): string;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Shared Lark API error handling utilities.
|
||||||
|
*
|
||||||
|
* Provides unified error handling for two distinct error paths:
|
||||||
|
*
|
||||||
|
* 1. **Response-level errors** — The SDK returns a response object with a
|
||||||
|
* non-zero `code`. Handled by {@link assertLarkOk}.
|
||||||
|
*
|
||||||
|
* 2. **Thrown exceptions** — The SDK throws an Axios-style error (HTTP 4xx)
|
||||||
|
* whose properties include the Feishu error `code` and `msg`.
|
||||||
|
* Handled by {@link formatLarkError}.
|
||||||
|
*
|
||||||
|
* Both paths intercept well-known codes (e.g. LARK_ERROR.APP_SCOPE_MISSING (99991672) — missing API scopes)
|
||||||
|
* and produce user-friendly messages with actionable authorization links.
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.extractLarkApiCode = extractLarkApiCode;
|
||||||
|
exports.assertLarkOk = assertLarkOk;
|
||||||
|
exports.formatLarkError = formatLarkError;
|
||||||
|
const permission_url_1 = require("./permission-url.js");
|
||||||
|
const auth_errors_1 = require("./auth-errors.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Given a Feishu error code and msg, format a user-friendly permission
|
||||||
|
* error string if the code is LARK_ERROR.APP_SCOPE_MISSING (99991672). Returns `null` for other codes.
|
||||||
|
*/
|
||||||
|
function formatPermissionError(code, msg) {
|
||||||
|
if (code !== auth_errors_1.LARK_ERROR.APP_SCOPE_MISSING)
|
||||||
|
return null;
|
||||||
|
const authUrl = (0, permission_url_1.extractPermissionGrantUrl)(msg);
|
||||||
|
const scopes = (0, permission_url_1.extractPermissionScopes)(msg);
|
||||||
|
return `权限不足:应用缺少 [${scopes}] 权限。\n` + `请管理员点击以下链接申请并开通权限:\n${authUrl}`;
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Code extraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function coerceCode(value) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed))
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 从 Lark SDK 抛错对象中提取飞书 API code。
|
||||||
|
*
|
||||||
|
* 支持三种常见结构:
|
||||||
|
* - `{ code }` — SDK 直接挂载
|
||||||
|
* - `{ data: { code } }` — 响应体嵌套
|
||||||
|
* - `{ response: { data: { code } } }` — Axios 风格
|
||||||
|
*/
|
||||||
|
function extractLarkApiCode(err) {
|
||||||
|
if (!err || typeof err !== 'object')
|
||||||
|
return undefined;
|
||||||
|
const e = err;
|
||||||
|
return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Assert that a Lark SDK response is successful (code === 0).
|
||||||
|
*
|
||||||
|
* For permission errors (code LARK_ERROR.APP_SCOPE_MISSING (99991672)), the thrown error includes the
|
||||||
|
* required scope names and a direct authorization URL so the AI can
|
||||||
|
* present it to the end user.
|
||||||
|
*/
|
||||||
|
function assertLarkOk(res) {
|
||||||
|
if (!res.code || res.code === 0)
|
||||||
|
return;
|
||||||
|
const permMsg = formatPermissionError(res.code, res.msg ?? '');
|
||||||
|
if (permMsg)
|
||||||
|
throw new Error(permMsg);
|
||||||
|
throw new Error(res.msg ?? `Feishu API error (code: ${res.code})`);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Extract a meaningful error message from a thrown Lark SDK / Axios error.
|
||||||
|
*
|
||||||
|
* The Lark SDK throws Axios errors whose object carries Feishu-specific
|
||||||
|
* fields (`code`, `msg`) alongside the standard `message`. For permission
|
||||||
|
* errors (LARK_ERROR.APP_SCOPE_MISSING (99991672)) we format a user-friendly string with scopes + auth URL.
|
||||||
|
* For all other errors we try `err.msg` first (the Feishu detail) and fall
|
||||||
|
* back to `err.message` (the generic Axios text).
|
||||||
|
*/
|
||||||
|
function formatLarkError(err) {
|
||||||
|
if (!err || typeof err !== 'object') {
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
const e = err;
|
||||||
|
// Path 1: Lark SDK merges Feishu fields onto the thrown error object.
|
||||||
|
if (typeof e.code === 'number' && e.msg) {
|
||||||
|
const permMsg = formatPermissionError(e.code, e.msg);
|
||||||
|
if (permMsg)
|
||||||
|
return permMsg;
|
||||||
|
return e.msg;
|
||||||
|
}
|
||||||
|
// Path 2: Standard Axios error — dig into response.data.
|
||||||
|
const data = e.response?.data;
|
||||||
|
if (data && typeof data.code === 'number' && data.msg) {
|
||||||
|
const permMsg = formatPermissionError(data.code, data.msg);
|
||||||
|
if (permMsg)
|
||||||
|
return permMsg;
|
||||||
|
return data.msg;
|
||||||
|
}
|
||||||
|
// Fallback.
|
||||||
|
return e.message ?? String(err);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* 应用所有者查询 — 复用 app-scope-checker 的 API 调用和统一 owner 定义。
|
||||||
|
*
|
||||||
|
* 所有 owner 判定统一使用 {@link getAppInfo} 返回的 `effectiveOwnerOpenId`。
|
||||||
|
* 不维护独立缓存,完全依赖 app-scope-checker 的 30s 缓存。
|
||||||
|
*/
|
||||||
|
import type * as Lark from '@larksuiteoapi/node-sdk';
|
||||||
|
import type { ConfiguredLarkAccount } from './types';
|
||||||
|
/**
|
||||||
|
* 获取应用的 effectiveOwnerOpenId。
|
||||||
|
*
|
||||||
|
* 复用 app-scope-checker 的 API 调用、缓存和统一 owner 定义(effectiveOwnerOpenId)。
|
||||||
|
* 查询失败时返回 undefined(fail-open)。
|
||||||
|
*
|
||||||
|
* @param account - 已配置的飞书账号信息
|
||||||
|
* @param sdk - 飞书 SDK 实例(必须已初始化 TAT)
|
||||||
|
* @returns 应用所有者的 open_id,如果查询失败则返回 undefined
|
||||||
|
*/
|
||||||
|
export declare function getAppOwnerFallback(account: ConfiguredLarkAccount, sdk: Lark.Client): Promise<string | undefined>;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* 应用所有者查询 — 复用 app-scope-checker 的 API 调用和统一 owner 定义。
|
||||||
|
*
|
||||||
|
* 所有 owner 判定统一使用 {@link getAppInfo} 返回的 `effectiveOwnerOpenId`。
|
||||||
|
* 不维护独立缓存,完全依赖 app-scope-checker 的 30s 缓存。
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.getAppOwnerFallback = getAppOwnerFallback;
|
||||||
|
const app_scope_checker_1 = require("./app-scope-checker.js");
|
||||||
|
const lark_logger_1 = require("./lark-logger.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('core/app-owner-fallback');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 获取应用的 effectiveOwnerOpenId。
|
||||||
|
*
|
||||||
|
* 复用 app-scope-checker 的 API 调用、缓存和统一 owner 定义(effectiveOwnerOpenId)。
|
||||||
|
* 查询失败时返回 undefined(fail-open)。
|
||||||
|
*
|
||||||
|
* @param account - 已配置的飞书账号信息
|
||||||
|
* @param sdk - 飞书 SDK 实例(必须已初始化 TAT)
|
||||||
|
* @returns 应用所有者的 open_id,如果查询失败则返回 undefined
|
||||||
|
*/
|
||||||
|
async function getAppOwnerFallback(account, sdk) {
|
||||||
|
const { appId } = account;
|
||||||
|
try {
|
||||||
|
const appInfo = await (0, app_scope_checker_1.getAppInfo)(sdk, appId);
|
||||||
|
return appInfo.effectiveOwnerOpenId;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
log.warn(`failed to get owner for ${appId}: ${err instanceof Error ? err.message : err}`);
|
||||||
|
return undefined; // fail-open: 获取失败不阻塞业务
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* App Scope Checker — 查询应用已开通的 scope 列表。
|
||||||
|
*
|
||||||
|
* 通过 `GET /open-apis/application/v6/applications/:app_id` (TAT) 获取
|
||||||
|
* 应用信息,从 `app.scopes` 中提取已开通的 scope 字符串列表。
|
||||||
|
*
|
||||||
|
* 结果带 30 秒内存缓存,避免每次 invoke() 都调远程 API。
|
||||||
|
* scope 检查失败后可调 {@link invalidateAppScopeCache} 清缓存重查。
|
||||||
|
*/
|
||||||
|
import type * as Lark from '@larksuiteoapi/node-sdk';
|
||||||
|
export interface AppInfo {
|
||||||
|
appId: string;
|
||||||
|
creatorId?: string;
|
||||||
|
ownerOpenId?: string;
|
||||||
|
ownerType?: number;
|
||||||
|
/**
|
||||||
|
* 统一的 owner 判定结果。所有需要判定"谁是应用 owner"的场景都应使用此字段。
|
||||||
|
*
|
||||||
|
* 规则:owner_type=2(企业内成员)时取 owner_id,否则回退 creator_id。
|
||||||
|
* 兼容 owner.owner_type 和 owner.type 两种字段名。
|
||||||
|
*/
|
||||||
|
effectiveOwnerOpenId?: string;
|
||||||
|
scopes: Array<{
|
||||||
|
scope: string;
|
||||||
|
token_types?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
/** 清除指定 appId 的缓存。 */
|
||||||
|
export declare function invalidateAppScopeCache(appId: string): void;
|
||||||
|
/**
|
||||||
|
* 获取应用已开通的 scope 列表。
|
||||||
|
*
|
||||||
|
* 需要应用自身有 `application:application:self_manage` 权限。
|
||||||
|
* `appId` 可传 `"me"` 查自己。
|
||||||
|
*
|
||||||
|
* @param sdk - Lark SDK 实例
|
||||||
|
* @param appId - 应用 ID
|
||||||
|
* @param tokenType - token 类型,用于过滤只支持特定 token 类型的 scope
|
||||||
|
* @returns scope 字符串数组,如 `["calendar:calendar", "task:task:write"]`
|
||||||
|
*/
|
||||||
|
export declare function getAppGrantedScopes(sdk: Lark.Client, appId: string, tokenType?: 'user' | 'tenant'): Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* 获取应用信息,包括 owner 信息。
|
||||||
|
*
|
||||||
|
* 复用 getAppGrantedScopes 的 API 调用和缓存。
|
||||||
|
* 如果缓存中已有数据且未过期,直接从缓存提取。
|
||||||
|
*
|
||||||
|
* @param sdk - Lark SDK 实例
|
||||||
|
* @param appId - 应用 ID(可传 "me")
|
||||||
|
*/
|
||||||
|
export declare function getAppInfo(sdk: Lark.Client, appId: string): Promise<AppInfo>;
|
||||||
|
/**
|
||||||
|
* 计算 APP 已有 ∩ OAPI 需要 的交集。
|
||||||
|
*
|
||||||
|
* 用于传给 OAuth 的 scope 参数 — 只请求 APP 已开通且 API 需要的 scope。
|
||||||
|
*
|
||||||
|
* @param appGranted - 应用已开通的 scope 列表
|
||||||
|
* @param apiRequired - OAPI 要求的 scope 列表
|
||||||
|
* @returns 交集 scope 列表
|
||||||
|
*/
|
||||||
|
export declare function intersectScopes(appGranted: string[], apiRequired: string[]): string[];
|
||||||
|
/**
|
||||||
|
* 计算 OAPI 需要但 APP 未开通的 scope(差集)。
|
||||||
|
*
|
||||||
|
* 用于 AppScopeMissingError 的 missingScopes。
|
||||||
|
*
|
||||||
|
* @param appGranted - 应用已开通的 scope 列表
|
||||||
|
* @param apiRequired - OAPI 要求的 scope 列表
|
||||||
|
* @returns 缺失的 scope 列表
|
||||||
|
*/
|
||||||
|
export declare function missingScopes(appGranted: string[], apiRequired: string[]): string[];
|
||||||
|
/**
|
||||||
|
* 校验应用已开通的 scope 是否满足要求。
|
||||||
|
*
|
||||||
|
* 与 tool-client.ts invoke() 的 scope 校验逻辑完全一致,作为唯一真值来源:
|
||||||
|
* - `scopeNeedType === "all"`: appScopes 必须包含 requiredScopes 的全部项
|
||||||
|
* - 其他(默认 "one"): appScopes 与 requiredScopes 的交集非空即可
|
||||||
|
* - appScopes 为空: 视为满足(API 查询失败,退回服务端判断)
|
||||||
|
*
|
||||||
|
* @param appScopes - 应用已开通的 scope 列表(由 getAppGrantedScopes 返回)
|
||||||
|
* @param requiredScopes - 需要的 scope 列表
|
||||||
|
* @param scopeNeedType - "all" 表示全部必须,undefined/"one" 表示任一即可
|
||||||
|
*/
|
||||||
|
export declare function isAppScopeSatisfied(appScopes: string[], requiredScopes: string[], scopeNeedType?: 'one' | 'all'): boolean;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* App Scope Checker — 查询应用已开通的 scope 列表。
|
||||||
|
*
|
||||||
|
* 通过 `GET /open-apis/application/v6/applications/:app_id` (TAT) 获取
|
||||||
|
* 应用信息,从 `app.scopes` 中提取已开通的 scope 字符串列表。
|
||||||
|
*
|
||||||
|
* 结果带 30 秒内存缓存,避免每次 invoke() 都调远程 API。
|
||||||
|
* scope 检查失败后可调 {@link invalidateAppScopeCache} 清缓存重查。
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.invalidateAppScopeCache = invalidateAppScopeCache;
|
||||||
|
exports.getAppGrantedScopes = getAppGrantedScopes;
|
||||||
|
exports.getAppInfo = getAppInfo;
|
||||||
|
exports.intersectScopes = intersectScopes;
|
||||||
|
exports.missingScopes = missingScopes;
|
||||||
|
exports.isAppScopeSatisfied = isAppScopeSatisfied;
|
||||||
|
const lark_logger_1 = require("./lark-logger.js");
|
||||||
|
const log = (0, lark_logger_1.larkLogger)('core/app-scope-checker');
|
||||||
|
const auth_errors_1 = require("./auth-errors.js");
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const cache = new Map();
|
||||||
|
const CACHE_TTL_MS = 30 * 1000; // 30 秒
|
||||||
|
/** 清除指定 appId 的缓存。 */
|
||||||
|
function invalidateAppScopeCache(appId) {
|
||||||
|
cache.delete(appId);
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fetch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 获取应用已开通的 scope 列表。
|
||||||
|
*
|
||||||
|
* 需要应用自身有 `application:application:self_manage` 权限。
|
||||||
|
* `appId` 可传 `"me"` 查自己。
|
||||||
|
*
|
||||||
|
* @param sdk - Lark SDK 实例
|
||||||
|
* @param appId - 应用 ID
|
||||||
|
* @param tokenType - token 类型,用于过滤只支持特定 token 类型的 scope
|
||||||
|
* @returns scope 字符串数组,如 `["calendar:calendar", "task:task:write"]`
|
||||||
|
*/
|
||||||
|
async function getAppGrantedScopes(sdk, appId, tokenType) {
|
||||||
|
// 1. 检查缓存
|
||||||
|
const cached = cache.get(appId);
|
||||||
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
|
// 从缓存中过滤出支持当前 token 类型的 scope
|
||||||
|
return cached.rawScopes
|
||||||
|
.filter((s) => {
|
||||||
|
if (tokenType && s.token_types && Array.isArray(s.token_types)) {
|
||||||
|
return s.token_types.includes(tokenType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((s) => s.scope);
|
||||||
|
}
|
||||||
|
// 2. 调用 API
|
||||||
|
try {
|
||||||
|
const res = await sdk.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/open-apis/application/v6/applications/${appId}`,
|
||||||
|
params: { lang: 'zh_cn' },
|
||||||
|
});
|
||||||
|
if (res.code !== 0) {
|
||||||
|
// 任何 API 错误都认为是应用缺少 application:application:self_manage 权限
|
||||||
|
throw new auth_errors_1.AppScopeCheckFailedError(appId);
|
||||||
|
}
|
||||||
|
// 响应结构: res.data.app.scopes → [{ scope: "xxx", description, level, token_types?: string[] }]
|
||||||
|
// 或者从 app_version 中获取 scopes
|
||||||
|
const app = res.data?.app ?? res.app ?? res.data;
|
||||||
|
const rawScopes = app?.scopes ?? app?.online_version?.scopes ?? [];
|
||||||
|
// 提取并验证 scope 字符串
|
||||||
|
const validScopes = rawScopes
|
||||||
|
.filter((s) => typeof s.scope === 'string' && s.scope.length > 0)
|
||||||
|
.map((s) => ({ scope: s.scope, token_types: s.token_types }));
|
||||||
|
// 3. 写缓存(缓存完整数据,包含 token_types 和原始 app 对象)
|
||||||
|
cache.set(appId, { rawScopes: validScopes, rawApp: app, fetchedAt: Date.now() });
|
||||||
|
log.info(`fetched ${validScopes.length} scopes for app ${appId}`);
|
||||||
|
// 4. 根据 tokenType 过滤
|
||||||
|
const scopes = validScopes
|
||||||
|
.filter((s) => {
|
||||||
|
if (tokenType && s.token_types && Array.isArray(s.token_types)) {
|
||||||
|
return s.token_types.includes(tokenType);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((s) => s.scope);
|
||||||
|
log.info(`returning ${scopes.length} scopes${tokenType ? ` for ${tokenType} token` : ''}`);
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// 如果是 AppScopeCheckFailedError,重新抛出(不吞掉)
|
||||||
|
if (err instanceof auth_errors_1.AppScopeCheckFailedError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// 检查是否是权限相关的 HTTP 错误(400/403)
|
||||||
|
// axios/SDK 异常对象通常包含 response.status 或 status 字段
|
||||||
|
const statusCode = err?.response?.status || err?.status || err?.statusCode;
|
||||||
|
const isPermissionError = statusCode === 400 ||
|
||||||
|
statusCode === 403 ||
|
||||||
|
(err instanceof Error && (err.message.includes('status code 400') || err.message.includes('status code 403')));
|
||||||
|
if (isPermissionError) {
|
||||||
|
throw new auth_errors_1.AppScopeCheckFailedError(appId);
|
||||||
|
}
|
||||||
|
log.warn(`failed to fetch scopes for ${appId}: ${err instanceof Error ? err.message : err}`);
|
||||||
|
// 其他查询失败不阻塞调用,返回空数组(后续 API 调用如果缺 scope 会被服务端拒绝)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// App info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 获取应用信息,包括 owner 信息。
|
||||||
|
*
|
||||||
|
* 复用 getAppGrantedScopes 的 API 调用和缓存。
|
||||||
|
* 如果缓存中已有数据且未过期,直接从缓存提取。
|
||||||
|
*
|
||||||
|
* @param sdk - Lark SDK 实例
|
||||||
|
* @param appId - 应用 ID(可传 "me")
|
||||||
|
*/
|
||||||
|
async function getAppInfo(sdk, appId) {
|
||||||
|
// 先确保缓存已填充(调一次 getAppGrantedScopes 来触发 API + 缓存)
|
||||||
|
await getAppGrantedScopes(sdk, appId);
|
||||||
|
const cached = cache.get(appId);
|
||||||
|
const rawApp = cached?.rawApp;
|
||||||
|
// 提取 owner 信息
|
||||||
|
const owner = rawApp?.owner;
|
||||||
|
const creatorId = rawApp?.creator_id;
|
||||||
|
// 统一 owner 定义:type=2(企业内成员)用 owner_id,否则回退 creator_id
|
||||||
|
// 兼容两种字段名(owner_type 和 type)
|
||||||
|
const ownerTypeValue = owner?.owner_type ?? owner?.type;
|
||||||
|
const effectiveOwnerOpenId = ownerTypeValue === 2 && owner?.owner_id ? owner.owner_id : (creatorId ?? owner?.owner_id);
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
creatorId,
|
||||||
|
ownerOpenId: owner?.owner_id,
|
||||||
|
ownerType: owner?.owner_type,
|
||||||
|
effectiveOwnerOpenId,
|
||||||
|
scopes: cached?.rawScopes ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scope intersection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 计算 APP 已有 ∩ OAPI 需要 的交集。
|
||||||
|
*
|
||||||
|
* 用于传给 OAuth 的 scope 参数 — 只请求 APP 已开通且 API 需要的 scope。
|
||||||
|
*
|
||||||
|
* @param appGranted - 应用已开通的 scope 列表
|
||||||
|
* @param apiRequired - OAPI 要求的 scope 列表
|
||||||
|
* @returns 交集 scope 列表
|
||||||
|
*/
|
||||||
|
function intersectScopes(appGranted, apiRequired) {
|
||||||
|
const grantedSet = new Set(appGranted);
|
||||||
|
return apiRequired.filter((s) => grantedSet.has(s));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 计算 OAPI 需要但 APP 未开通的 scope(差集)。
|
||||||
|
*
|
||||||
|
* 用于 AppScopeMissingError 的 missingScopes。
|
||||||
|
*
|
||||||
|
* @param appGranted - 应用已开通的 scope 列表
|
||||||
|
* @param apiRequired - OAPI 要求的 scope 列表
|
||||||
|
* @returns 缺失的 scope 列表
|
||||||
|
*/
|
||||||
|
function missingScopes(appGranted, apiRequired) {
|
||||||
|
const grantedSet = new Set(appGranted);
|
||||||
|
return apiRequired.filter((s) => !grantedSet.has(s));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 校验应用已开通的 scope 是否满足要求。
|
||||||
|
*
|
||||||
|
* 与 tool-client.ts invoke() 的 scope 校验逻辑完全一致,作为唯一真值来源:
|
||||||
|
* - `scopeNeedType === "all"`: appScopes 必须包含 requiredScopes 的全部项
|
||||||
|
* - 其他(默认 "one"): appScopes 与 requiredScopes 的交集非空即可
|
||||||
|
* - appScopes 为空: 视为满足(API 查询失败,退回服务端判断)
|
||||||
|
*
|
||||||
|
* @param appScopes - 应用已开通的 scope 列表(由 getAppGrantedScopes 返回)
|
||||||
|
* @param requiredScopes - 需要的 scope 列表
|
||||||
|
* @param scopeNeedType - "all" 表示全部必须,undefined/"one" 表示任一即可
|
||||||
|
*/
|
||||||
|
function isAppScopeSatisfied(appScopes, requiredScopes, scopeNeedType) {
|
||||||
|
if (appScopes.length === 0)
|
||||||
|
return true; // API 查询失败 → 退回服务端判断
|
||||||
|
if (requiredScopes.length === 0)
|
||||||
|
return true;
|
||||||
|
if (scopeNeedType === 'all') {
|
||||||
|
return missingScopes(appScopes, requiredScopes).length === 0;
|
||||||
|
}
|
||||||
|
return intersectScopes(appScopes, requiredScopes).length > 0;
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* auth-errors.ts — 统一错误类型定义。
|
||||||
|
*
|
||||||
|
* 所有与认证/授权/scope 相关的错误类型集中在此文件,
|
||||||
|
* 解除 tool-client ↔ app-scope-checker 循环依赖。
|
||||||
|
*
|
||||||
|
* 其他模块应直接 import 此文件,或通过 tool-client / uat-client 的 re-export 使用。
|
||||||
|
*/
|
||||||
|
/** 飞书 OAPI 错误码常量,替代各处硬编码的 magic number。 */
|
||||||
|
export declare const LARK_ERROR: {
|
||||||
|
/** 应用 scope 不足(租户维度) */
|
||||||
|
readonly APP_SCOPE_MISSING: 99991672;
|
||||||
|
/** 用户 token scope 不足 */
|
||||||
|
readonly USER_SCOPE_INSUFFICIENT: 99991679;
|
||||||
|
/** access_token 无效 */
|
||||||
|
readonly TOKEN_INVALID: 99991668;
|
||||||
|
/** access_token 已过期 */
|
||||||
|
readonly TOKEN_EXPIRED: 99991677;
|
||||||
|
/** refresh_token 本身无效(格式非法或来自 v1 API) */
|
||||||
|
readonly REFRESH_TOKEN_INVALID: 20026;
|
||||||
|
/** refresh_token 已过期(超过 365 天) */
|
||||||
|
readonly REFRESH_TOKEN_EXPIRED: 20037;
|
||||||
|
/** refresh_token 已被吊销 */
|
||||||
|
readonly REFRESH_TOKEN_REVOKED: 20064;
|
||||||
|
/** refresh_token 已被使用(单次消费,rotation 场景) */
|
||||||
|
readonly REFRESH_TOKEN_ALREADY_USED: 20073;
|
||||||
|
/** refresh token 端点服务端内部错误,可重试 */
|
||||||
|
readonly REFRESH_SERVER_ERROR: 20050;
|
||||||
|
/** 消息已被撤回 */
|
||||||
|
readonly MESSAGE_RECALLED: 230011;
|
||||||
|
/** 消息已被删除 */
|
||||||
|
readonly MESSAGE_DELETED: 231003;
|
||||||
|
};
|
||||||
|
/** refresh token 端点可重试的错误码集合(服务端瞬时故障)。遇到后重试一次,仍失败则清 token。 */
|
||||||
|
export declare const REFRESH_TOKEN_RETRYABLE: ReadonlySet<number>;
|
||||||
|
/** 消息终止错误码集合(撤回/删除),遇到后应停止对该消息的后续操作。 */
|
||||||
|
export declare const MESSAGE_TERMINAL_CODES: ReadonlySet<number>;
|
||||||
|
/** access_token 失效相关的错误码集合,遇到后可尝试刷新重试。 */
|
||||||
|
export declare const TOKEN_RETRY_CODES: ReadonlySet<number>;
|
||||||
|
/** invoke() 错误共享的 scope 信息。 */
|
||||||
|
export interface ScopeErrorInfo {
|
||||||
|
apiName: string;
|
||||||
|
scopes: string[];
|
||||||
|
/** 应用 scope 是否已验证通过。false 表示 app scope 检查失败,scope 信息可能不准确。 */
|
||||||
|
appScopeVerified?: boolean;
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
appId?: string;
|
||||||
|
}
|
||||||
|
/** OAuth 授权提示信息,与 handleInvokeError 返回的结构一致。 */
|
||||||
|
export interface AuthHint {
|
||||||
|
error: string;
|
||||||
|
api: string;
|
||||||
|
required_scope: string;
|
||||||
|
user_open_id: string;
|
||||||
|
message: string;
|
||||||
|
next_tool_call: {
|
||||||
|
tool: 'feishu_oauth';
|
||||||
|
params: {
|
||||||
|
action: 'authorize';
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** tryInvoke 返回值的判别联合体。 */
|
||||||
|
export type TryInvokeResult<T> = {
|
||||||
|
ok: true;
|
||||||
|
data: T;
|
||||||
|
} | {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
authHint: AuthHint;
|
||||||
|
} | {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
authHint?: undefined;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Thrown when no valid UAT exists and the user needs to (re-)authorise.
|
||||||
|
* Callers should catch this and trigger the OAuth flow.
|
||||||
|
*/
|
||||||
|
export declare class NeedAuthorizationError extends Error {
|
||||||
|
readonly userOpenId: string;
|
||||||
|
constructor(userOpenId: string);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 应用缺少 application:application:self_manage 权限,无法查询应用权限配置。
|
||||||
|
*
|
||||||
|
* 需要管理员在飞书开放平台开通 application:application:self_manage 权限。
|
||||||
|
*/
|
||||||
|
export declare class AppScopeCheckFailedError extends Error {
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
readonly appId?: string;
|
||||||
|
constructor(appId?: string);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 应用未开通 OAPI 所需 scope。
|
||||||
|
*
|
||||||
|
* 需要管理员在飞书开放平台开通权限。
|
||||||
|
*/
|
||||||
|
export declare class AppScopeMissingError extends Error {
|
||||||
|
readonly apiName: string;
|
||||||
|
/** OAPI 需要但 APP 未开通的 scope 列表。 */
|
||||||
|
readonly missingScopes: string[];
|
||||||
|
/** 工具的全部所需 scope(含已开通的),用于应用权限完成后一次性发起用户授权。 */
|
||||||
|
readonly allRequiredScopes?: string[];
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
readonly appId?: string;
|
||||||
|
readonly scopeNeedType?: 'one' | 'all';
|
||||||
|
/** 触发此错误时使用的 token 类型,用于保持 card action 二次校验一致。 */
|
||||||
|
readonly tokenType?: 'user' | 'tenant';
|
||||||
|
constructor(info: ScopeErrorInfo, scopeNeedType?: 'one' | 'all', tokenType?: 'user' | 'tenant', allRequiredScopes?: string[]);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 用户未授权或 scope 不足,需要发起 OAuth 授权。
|
||||||
|
*
|
||||||
|
* `requiredScopes` 为 APP∩OAPI 的有效 scope,可直接传给
|
||||||
|
* `feishu_oauth authorize --scope`。
|
||||||
|
*/
|
||||||
|
export declare class UserAuthRequiredError extends Error {
|
||||||
|
readonly userOpenId: string;
|
||||||
|
readonly apiName: string;
|
||||||
|
/** APP∩OAPI 交集 scope,传给 OAuth authorize。 */
|
||||||
|
readonly requiredScopes: string[];
|
||||||
|
/** 应用 scope 是否已验证通过。false 时 requiredScopes 可能不准确。 */
|
||||||
|
readonly appScopeVerified: boolean;
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
readonly appId?: string;
|
||||||
|
constructor(userOpenId: string, info: ScopeErrorInfo);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 服务端报 99991679 — 用户 token 的 scope 不足。
|
||||||
|
*
|
||||||
|
* 需要增量授权:用缺失的 scope 发起新 Device Flow。
|
||||||
|
*/
|
||||||
|
export declare class UserScopeInsufficientError extends Error {
|
||||||
|
readonly userOpenId: string;
|
||||||
|
readonly apiName: string;
|
||||||
|
/** 缺失的 scope 列表。 */
|
||||||
|
readonly missingScopes: string[];
|
||||||
|
constructor(userOpenId: string, info: ScopeErrorInfo);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* auth-errors.ts — 统一错误类型定义。
|
||||||
|
*
|
||||||
|
* 所有与认证/授权/scope 相关的错误类型集中在此文件,
|
||||||
|
* 解除 tool-client ↔ app-scope-checker 循环依赖。
|
||||||
|
*
|
||||||
|
* 其他模块应直接 import 此文件,或通过 tool-client / uat-client 的 re-export 使用。
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.UserScopeInsufficientError = exports.UserAuthRequiredError = exports.AppScopeMissingError = exports.AppScopeCheckFailedError = exports.NeedAuthorizationError = exports.TOKEN_RETRY_CODES = exports.MESSAGE_TERMINAL_CODES = exports.REFRESH_TOKEN_RETRYABLE = exports.LARK_ERROR = void 0;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Feishu error code constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** 飞书 OAPI 错误码常量,替代各处硬编码的 magic number。 */
|
||||||
|
exports.LARK_ERROR = {
|
||||||
|
/** 应用 scope 不足(租户维度) */
|
||||||
|
APP_SCOPE_MISSING: 99991672,
|
||||||
|
/** 用户 token scope 不足 */
|
||||||
|
USER_SCOPE_INSUFFICIENT: 99991679,
|
||||||
|
/** access_token 无效 */
|
||||||
|
TOKEN_INVALID: 99991668,
|
||||||
|
/** access_token 已过期 */
|
||||||
|
TOKEN_EXPIRED: 99991677,
|
||||||
|
/** refresh_token 本身无效(格式非法或来自 v1 API) */
|
||||||
|
REFRESH_TOKEN_INVALID: 20026,
|
||||||
|
/** refresh_token 已过期(超过 365 天) */
|
||||||
|
REFRESH_TOKEN_EXPIRED: 20037,
|
||||||
|
/** refresh_token 已被吊销 */
|
||||||
|
REFRESH_TOKEN_REVOKED: 20064,
|
||||||
|
/** refresh_token 已被使用(单次消费,rotation 场景) */
|
||||||
|
REFRESH_TOKEN_ALREADY_USED: 20073,
|
||||||
|
/** refresh token 端点服务端内部错误,可重试 */
|
||||||
|
REFRESH_SERVER_ERROR: 20050,
|
||||||
|
/** 消息已被撤回 */
|
||||||
|
MESSAGE_RECALLED: 230011,
|
||||||
|
/** 消息已被删除 */
|
||||||
|
MESSAGE_DELETED: 231003,
|
||||||
|
};
|
||||||
|
/** refresh token 端点可重试的错误码集合(服务端瞬时故障)。遇到后重试一次,仍失败则清 token。 */
|
||||||
|
exports.REFRESH_TOKEN_RETRYABLE = new Set([exports.LARK_ERROR.REFRESH_SERVER_ERROR]);
|
||||||
|
/** 消息终止错误码集合(撤回/删除),遇到后应停止对该消息的后续操作。 */
|
||||||
|
exports.MESSAGE_TERMINAL_CODES = new Set([
|
||||||
|
exports.LARK_ERROR.MESSAGE_RECALLED,
|
||||||
|
exports.LARK_ERROR.MESSAGE_DELETED,
|
||||||
|
]);
|
||||||
|
/** access_token 失效相关的错误码集合,遇到后可尝试刷新重试。 */
|
||||||
|
exports.TOKEN_RETRY_CODES = new Set([exports.LARK_ERROR.TOKEN_INVALID, exports.LARK_ERROR.TOKEN_EXPIRED]);
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error classes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* Thrown when no valid UAT exists and the user needs to (re-)authorise.
|
||||||
|
* Callers should catch this and trigger the OAuth flow.
|
||||||
|
*/
|
||||||
|
class NeedAuthorizationError extends Error {
|
||||||
|
userOpenId;
|
||||||
|
constructor(userOpenId) {
|
||||||
|
super('need_user_authorization');
|
||||||
|
this.name = 'NeedAuthorizationError';
|
||||||
|
this.userOpenId = userOpenId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.NeedAuthorizationError = NeedAuthorizationError;
|
||||||
|
/**
|
||||||
|
* 应用缺少 application:application:self_manage 权限,无法查询应用权限配置。
|
||||||
|
*
|
||||||
|
* 需要管理员在飞书开放平台开通 application:application:self_manage 权限。
|
||||||
|
*/
|
||||||
|
class AppScopeCheckFailedError extends Error {
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
appId;
|
||||||
|
constructor(appId) {
|
||||||
|
super('应用缺少 application:application:self_manage 权限,无法查询应用权限配置。请管理员在开放平台开通该权限。');
|
||||||
|
this.name = 'AppScopeCheckFailedError';
|
||||||
|
this.appId = appId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.AppScopeCheckFailedError = AppScopeCheckFailedError;
|
||||||
|
/**
|
||||||
|
* 应用未开通 OAPI 所需 scope。
|
||||||
|
*
|
||||||
|
* 需要管理员在飞书开放平台开通权限。
|
||||||
|
*/
|
||||||
|
class AppScopeMissingError extends Error {
|
||||||
|
apiName;
|
||||||
|
/** OAPI 需要但 APP 未开通的 scope 列表。 */
|
||||||
|
missingScopes;
|
||||||
|
/** 工具的全部所需 scope(含已开通的),用于应用权限完成后一次性发起用户授权。 */
|
||||||
|
allRequiredScopes;
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
appId;
|
||||||
|
scopeNeedType;
|
||||||
|
/** 触发此错误时使用的 token 类型,用于保持 card action 二次校验一致。 */
|
||||||
|
tokenType;
|
||||||
|
constructor(info, scopeNeedType, tokenType, allRequiredScopes) {
|
||||||
|
if (scopeNeedType === 'one') {
|
||||||
|
super(`应用缺少权限 [${info.scopes.join(', ')}](开启任一权限即可),请管理员在开放平台开通。`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
super(`应用缺少权限 [${info.scopes.join(', ')}],请管理员在开放平台开通。`);
|
||||||
|
}
|
||||||
|
this.name = 'AppScopeMissingError';
|
||||||
|
this.apiName = info.apiName;
|
||||||
|
this.missingScopes = info.scopes;
|
||||||
|
this.allRequiredScopes = allRequiredScopes;
|
||||||
|
this.appId = info.appId;
|
||||||
|
this.scopeNeedType = scopeNeedType;
|
||||||
|
this.tokenType = tokenType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.AppScopeMissingError = AppScopeMissingError;
|
||||||
|
/**
|
||||||
|
* 用户未授权或 scope 不足,需要发起 OAuth 授权。
|
||||||
|
*
|
||||||
|
* `requiredScopes` 为 APP∩OAPI 的有效 scope,可直接传给
|
||||||
|
* `feishu_oauth authorize --scope`。
|
||||||
|
*/
|
||||||
|
class UserAuthRequiredError extends Error {
|
||||||
|
userOpenId;
|
||||||
|
apiName;
|
||||||
|
/** APP∩OAPI 交集 scope,传给 OAuth authorize。 */
|
||||||
|
requiredScopes;
|
||||||
|
/** 应用 scope 是否已验证通过。false 时 requiredScopes 可能不准确。 */
|
||||||
|
appScopeVerified;
|
||||||
|
/** 应用 ID,用于生成开放平台权限管理链接。 */
|
||||||
|
appId;
|
||||||
|
constructor(userOpenId, info) {
|
||||||
|
super('need_user_authorization');
|
||||||
|
this.name = 'UserAuthRequiredError';
|
||||||
|
this.userOpenId = userOpenId;
|
||||||
|
this.apiName = info.apiName;
|
||||||
|
this.requiredScopes = info.scopes;
|
||||||
|
this.appId = info.appId;
|
||||||
|
this.appScopeVerified = info.appScopeVerified ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.UserAuthRequiredError = UserAuthRequiredError;
|
||||||
|
/**
|
||||||
|
* 服务端报 99991679 — 用户 token 的 scope 不足。
|
||||||
|
*
|
||||||
|
* 需要增量授权:用缺失的 scope 发起新 Device Flow。
|
||||||
|
*/
|
||||||
|
class UserScopeInsufficientError extends Error {
|
||||||
|
userOpenId;
|
||||||
|
apiName;
|
||||||
|
/** 缺失的 scope 列表。 */
|
||||||
|
missingScopes;
|
||||||
|
constructor(userOpenId, info) {
|
||||||
|
super('user_scope_insufficient');
|
||||||
|
this.name = 'UserScopeInsufficientError';
|
||||||
|
this.userOpenId = userOpenId;
|
||||||
|
this.apiName = info.apiName;
|
||||||
|
this.missingScopes = info.scopes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.UserScopeInsufficientError = UserScopeInsufficientError;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user