evolcore 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +813 -0
- package/LICENSE +21 -0
- package/MIGRATION-0.5.0.md +378 -0
- package/README.md +318 -14
- package/ROLE_ACCESS_CONTROL.md +174 -0
- package/assets/.env.template +4 -0
- package/assets/brand/evolcore/README.md +19 -0
- package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
- package/assets/brand/evolcore/evolcore-app-icon.svg +13 -0
- package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
- package/assets/brand/evolcore/evolcore-brand-board.svg +126 -0
- package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.svg +14 -0
- package/assets/brand/evolcore/evolcore-logo.png +0 -0
- package/assets/brand/evolcore/evolcore-logo.svg +14 -0
- package/assets/brand/evolcore/evolcore-mark.png +0 -0
- package/assets/brand/evolcore/evolcore-mark.svg +10 -0
- package/bin/ec-safe-output.js +161 -0
- package/bin/ec.js +29 -0
- package/dist/agents/baseagent.js +163 -0
- package/dist/agents/claude-runner.js +2385 -0
- package/dist/agents/codex-app-server-client.js +448 -0
- package/dist/agents/codex-runner.js +2639 -0
- package/dist/agents/gemini-runner.js +666 -0
- package/dist/agents/runner-types.js +75 -0
- package/dist/aun/aid/agentmd.js +216 -0
- package/dist/aun/aid/client.js +132 -0
- package/dist/aun/aid/control-aid.js +91 -0
- package/dist/aun/aid/identity.js +518 -0
- package/dist/aun/aid/index.js +4 -0
- package/dist/aun/aid/store.js +74 -0
- package/dist/aun/aid/types.js +1 -0
- package/dist/aun/aid/validation.js +21 -0
- package/dist/aun/group-identity.js +10 -0
- package/dist/aun/msg/group-index.js +6 -0
- package/dist/aun/msg/group.js +1231 -0
- package/dist/aun/msg/history.js +123 -0
- package/dist/aun/msg/index.js +5 -0
- package/dist/aun/msg/p2p.js +393 -0
- package/dist/aun/msg/payload-type.js +27 -0
- package/dist/aun/msg/upload.js +137 -0
- package/dist/aun/outbox.js +160 -0
- package/dist/aun/rpc/caller.js +42 -0
- package/dist/aun/rpc/connection.js +25 -0
- package/dist/aun/rpc/index.js +2 -0
- package/dist/aun/service-proxy.js +225 -0
- package/dist/aun/storage/download.js +29 -0
- package/dist/aun/storage/index.js +3 -0
- package/dist/aun/storage/manage.js +10 -0
- package/dist/aun/storage/upload.js +68 -0
- package/dist/channels/aun.js +4147 -0
- package/dist/channels/daemon.js +422 -0
- package/dist/channels/dingtalk.js +649 -0
- package/dist/channels/feishu.js +1789 -0
- package/dist/channels/qqbot.js +409 -0
- package/dist/channels/wechat.js +817 -0
- package/dist/channels/wecom.js +565 -0
- package/dist/cli/agent-command.js +641 -0
- package/dist/cli/agent.js +1059 -0
- package/dist/cli/aun-commands.js +1948 -0
- package/dist/cli/bench.js +1228 -0
- package/dist/cli/cli-argv.js +66 -0
- package/dist/cli/code-stats.js +329 -0
- package/dist/cli/command-log.js +82 -0
- package/dist/cli/config-selector.js +69 -0
- package/dist/cli/config.js +261 -0
- package/dist/cli/ctl-command.js +62 -0
- package/dist/cli/daemon-commands.js +2887 -0
- package/dist/cli/fs-command.js +1447 -0
- package/dist/cli/handoff-command.js +302 -0
- package/dist/cli/help.js +31 -0
- package/dist/cli/index.js +358 -0
- package/dist/cli/init-channel.js +1377 -0
- package/dist/cli/init.js +553 -0
- package/dist/cli/link-rules.js +240 -0
- package/dist/cli/model.js +590 -0
- package/dist/cli/net-check.js +723 -0
- package/dist/cli/queue-command.js +126 -0
- package/dist/cli/response.js +345 -0
- package/dist/cli/restart-monitor.js +456 -0
- package/dist/cli/stats.js +607 -0
- package/dist/cli/task-context.js +80 -0
- package/dist/cli/trigger-command.js +505 -0
- package/dist/cli/version.js +88 -0
- package/dist/cli/watch-logs.js +33 -0
- package/dist/cli/watch-msg.js +673 -0
- package/dist/config/boot-log.js +266 -0
- package/dist/config/builtin-role-templates.js +30 -0
- package/dist/config/builtin-roles.js +85 -0
- package/dist/config/config-batch-get.js +11 -0
- package/dist/config/config-field-policy.js +261 -0
- package/dist/config/config-manager.js +916 -0
- package/dist/config/config-operation-service.js +384 -0
- package/dist/config/contact-book.js +371 -0
- package/dist/config/gateway-config.js +858 -0
- package/dist/config/lifecycle.js +17 -0
- package/dist/config/mention-mode.js +27 -0
- package/dist/config/merge.js +161 -0
- package/dist/config/owner-policy.js +4 -0
- package/dist/config/peer-role-resolver.js +139 -0
- package/dist/config/resolved-config-op.js +483 -0
- package/dist/config/role-config-v4-startup.js +32 -0
- package/dist/config/role-config-v5-startup.js +27 -0
- package/dist/config/role-schema.js +105 -0
- package/dist/config/role-service.js +159 -0
- package/dist/config/role-store.js +204 -0
- package/dist/config/roles.js +55 -0
- package/dist/config/schema-registry.js +154 -0
- package/dist/config/snapshot.js +598 -0
- package/dist/config-store.js +503 -0
- package/dist/core/auth/agent-delegation.js +111 -0
- package/dist/core/auth/auth-gateway.js +166 -0
- package/dist/core/auth/authenticated-actor.js +6 -0
- package/dist/core/auth/authorization-audit.js +110 -0
- package/dist/core/auth/operation-authorizer.js +718 -0
- package/dist/core/auth/operation-catalog.js +675 -0
- package/dist/core/baseagent-loader.js +54 -0
- package/dist/core/bootstrap-service.js +175 -0
- package/dist/core/capability/capability-manager.js +316 -0
- package/dist/core/capability/providers/claude-capability-provider.js +176 -0
- package/dist/core/capability/providers/codex-capability-provider.js +148 -0
- package/dist/core/capability/providers/gemini-capability-provider.js +10 -0
- package/dist/core/capability/types.js +27 -0
- package/dist/core/causation/audit.js +103 -0
- package/dist/core/causation/aun-association.js +111 -0
- package/dist/core/causation/context.js +93 -0
- package/dist/core/causation/index.js +4 -0
- package/dist/core/causation/types.js +2 -0
- package/dist/core/channel-loader.js +277 -0
- package/dist/core/command/agent-control.js +616 -0
- package/dist/core/command/cli-intent-parser.js +225 -0
- package/dist/core/command/command-handler.js +1638 -0
- package/dist/core/command/menu-handler.js +3354 -0
- package/dist/core/command/menu-protocol.js +247 -0
- package/dist/core/command/role-menu.js +1524 -0
- package/dist/core/command/slash-gate.js +148 -0
- package/dist/core/command/slash-handler.js +3056 -0
- package/dist/core/daemon-file-cache.js +216 -0
- package/dist/core/event-bus.js +32 -0
- package/dist/core/event-catalog.js +740 -0
- package/dist/core/evolagent-registry.js +546 -0
- package/dist/core/evolagent.js +331 -0
- package/dist/core/handoff/dispatcher.js +229 -0
- package/dist/core/handoff/mutex.js +46 -0
- package/dist/core/handoff/runtime.js +324 -0
- package/dist/core/handoff/store.js +537 -0
- package/dist/core/handoff/types.js +12 -0
- package/dist/core/inference/text-inference.js +173 -0
- package/dist/core/interaction-registration.js +10 -0
- package/dist/core/interaction-router.js +278 -0
- package/dist/core/message/create-status.js +67 -0
- package/dist/core/message/im-renderer.js +657 -0
- package/dist/core/message/items-formatter.js +76 -0
- package/dist/core/message/logical-queue-bridge.js +123 -0
- package/dist/core/message/message-bridge.js +803 -0
- package/dist/core/message/message-cache.js +56 -0
- package/dist/core/message/message-log.js +339 -0
- package/dist/core/message/message-processor.js +4 -0
- package/dist/core/message/message-queue.js +1436 -0
- package/dist/core/message/message-utils.js +70 -0
- package/dist/core/message/peer-mode.js +105 -0
- package/dist/core/message/pending-hints.js +232 -0
- package/dist/core/message/response-depth.js +33 -0
- package/dist/core/message/response-engine.js +3776 -0
- package/dist/core/message/response-snapshot.js +83 -0
- package/dist/core/message/stream-debouncer.js +130 -0
- package/dist/core/message/stream-idle-monitor.js +124 -0
- package/dist/core/model/config-scope.js +162 -0
- package/dist/core/model/field-scope.js +78 -0
- package/dist/core/model/model-catalog.js +227 -0
- package/dist/core/model/model-diagnostics.js +182 -0
- package/dist/core/model/model-permission.js +90 -0
- package/dist/core/permission/approval-gateway.js +1017 -0
- package/dist/core/permission/ec-command-parser.js +347 -0
- package/dist/core/permission/execution-sandbox.js +16 -0
- package/dist/core/permission/index.js +6 -0
- package/dist/core/permission/mode.js +24 -0
- package/dist/core/permission/sandbox-runtime.js +265 -0
- package/dist/core/permission/tool-policy.js +987 -0
- package/dist/core/permission/unix-socket-policy.js +99 -0
- package/dist/core/protected-paths.js +330 -0
- package/dist/core/relation/peer-identity.js +222 -0
- package/dist/core/relation/peer-key.js +1 -0
- package/dist/core/role/runtime-policy.js +141 -0
- package/dist/core/session/adapters/claude-session-file-adapter.js +218 -0
- package/dist/core/session/adapters/codex-session-file-adapter.js +333 -0
- package/dist/core/session/adapters/gemini-session-file-adapter.js +181 -0
- package/dist/core/session/session-file-adapter.js +7 -0
- package/dist/core/session/session-file-health.js +45 -0
- package/dist/core/session/session-fs-store.js +232 -0
- package/dist/core/session/session-key.js +24 -0
- package/dist/core/session/session-manager.js +1587 -0
- package/dist/core/session/session-mapper.js +100 -0
- package/dist/core/session/session-renew.js +314 -0
- package/dist/core/session/session-title.js +128 -0
- package/dist/core/session/session-turn-coordinator.js +205 -0
- package/dist/core/session/session-turns.js +67 -0
- package/dist/core/system-channels.js +29 -0
- package/dist/eck/baseagent-caps.js +18 -0
- package/dist/eck/detect.js +47 -0
- package/dist/eck/group-rules-sync.js +345 -0
- package/dist/eck/init.js +77 -0
- package/dist/eck/kit-renderer.js +359 -0
- package/dist/eck/manifest-engine.js +446 -0
- package/dist/eck/message-renderer.js +199 -0
- package/dist/eck/rules-loader.js +28 -0
- package/dist/index.js +2870 -4
- package/dist/ipc.js +748 -0
- package/dist/paths.js +262 -0
- package/dist/product.js +18 -0
- package/dist/response-system/context-builder.js +71 -0
- package/dist/response-system/coordinator.js +117 -0
- package/dist/response-system/decision-executor.js +86 -0
- package/dist/response-system/engines/v1/index.js +21 -0
- package/dist/response-system/engines/v1/interactive-flow.js +27 -0
- package/dist/response-system/engines/v1/proactive-flow.js +137 -0
- package/dist/response-system/engines/v1/types.js +1 -0
- package/dist/response-system/extensions.js +41 -0
- package/dist/response-system/index.js +6 -0
- package/dist/response-system/modes/index.js +7 -0
- package/dist/response-system/modes/single-session/index.js +72 -0
- package/dist/response-system/queues/fifo-queue.js +44 -0
- package/dist/response-system/queues/index.js +6 -0
- package/dist/response-system/queues/lifo-queue.js +42 -0
- package/dist/response-system/queues/priority-queue.js +63 -0
- package/dist/response-system/registry.js +97 -0
- package/dist/response-system/resolver.js +37 -0
- package/dist/response-system/selector.js +23 -0
- package/dist/response-system/types.js +7 -0
- package/dist/stats/billing.js +151 -0
- package/dist/stats/budget.js +93 -0
- package/dist/stats/db.js +403 -0
- package/dist/stats/eck-vars.js +89 -0
- package/dist/stats/index.js +11 -0
- package/dist/stats/normalizer.js +80 -0
- package/dist/stats/price-resolver.js +138 -0
- package/dist/stats/query.js +763 -0
- package/dist/stats/role-budget.js +168 -0
- package/dist/stats/writer.js +151 -0
- package/dist/trigger/anomaly-store.js +258 -0
- package/dist/trigger/audit.js +152 -0
- package/dist/trigger/event-source.js +119 -0
- package/dist/trigger/feedback.js +685 -0
- package/dist/trigger/history.js +290 -0
- package/dist/trigger/manager.js +291 -0
- package/dist/trigger/parser.js +520 -0
- package/dist/trigger/patch.js +148 -0
- package/dist/trigger/scheduler.js +1600 -0
- package/dist/trigger/script-executor.js +155 -0
- package/dist/trigger/state.js +145 -0
- package/dist/trigger/types.js +1 -0
- package/dist/trigger/validation.js +621 -0
- package/dist/types.js +12 -0
- package/dist/utils/aid-bind.js +299 -0
- package/dist/utils/atomic-write.js +95 -0
- package/dist/utils/avatar-upload.js +123 -0
- package/dist/utils/cross-platform.js +297 -0
- package/dist/utils/ecweb-utils.js +73 -0
- package/dist/utils/error-dict.json +153 -0
- package/dist/utils/error-utils.js +349 -0
- package/dist/utils/instance-registry.js +437 -0
- package/dist/utils/locale.js +21 -0
- package/dist/utils/log-writer.js +224 -0
- package/dist/utils/logger.js +89 -0
- package/dist/utils/markdown-to-plain-text.js +20 -0
- package/dist/utils/media-cache.js +271 -0
- package/dist/utils/model-prices.jsonl +17 -0
- package/dist/utils/npm-ops.js +210 -0
- package/dist/utils/process-introspect.js +133 -0
- package/dist/utils/process-tree-stats.js +271 -0
- package/dist/utils/project-path.js +74 -0
- package/dist/utils/stats.js +410 -0
- package/dist/utils/tool-summary.js +284 -0
- package/dist/utils/welcome.js +268 -0
- package/kits/docs/GUIDE.md +20 -0
- package/kits/docs/INDEX.md +65 -0
- package/kits/docs/aun/CHEATSHEET.md +19 -0
- package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
- package/kits/docs/channels/aun.md +65 -0
- package/kits/docs/channels/feishu.md +56 -0
- package/kits/docs/context-assembly.md +366 -0
- package/kits/docs/eck_templates/GUIDE.template.md +22 -0
- package/kits/docs/eck_templates/INDEX.template.md +28 -0
- package/kits/docs/eck_templates/path-registry.template.md +33 -0
- package/kits/docs/eck_templates/runtime.template.md +19 -0
- package/kits/docs/evolcore/INDEX.md +66 -0
- package/kits/docs/evolcore/agent.md +69 -0
- package/kits/docs/evolcore/aid.md +49 -0
- package/kits/docs/evolcore/config.md +149 -0
- package/kits/docs/evolcore/ctl.md +46 -0
- package/kits/docs/evolcore/event.md +216 -0
- package/kits/docs/evolcore/fs-architecture.md +1215 -0
- package/kits/docs/evolcore/fs.md +101 -0
- package/kits/docs/evolcore/group-fs.md +17 -0
- package/kits/docs/evolcore/group-rules.md +226 -0
- package/kits/docs/evolcore/group.md +141 -0
- package/kits/docs/evolcore/model.md +47 -0
- package/kits/docs/evolcore/msg.md +130 -0
- package/kits/docs/evolcore/response.md +80 -0
- package/kits/docs/evolcore/rpc.md +35 -0
- package/kits/docs/evolcore/self-summary.md +29 -0
- package/kits/docs/evolcore/stats.md +70 -0
- package/kits/docs/evolcore/storage.md +49 -0
- package/kits/docs/evolcore/trigger.md +524 -0
- package/kits/docs/identity/AID_PROFILE_SPEC.md +26 -0
- package/kits/docs/identity/PATH_OPS.md +16 -0
- package/kits/docs/identity/ROLE_DETAIL.md +23 -0
- package/kits/docs/identity/identity-tools.md +26 -0
- package/kits/docs/path-registry.md +43 -0
- package/kits/docs/prompt-loading-architecture.md +266 -0
- package/kits/docs/venues/aun-group.md +45 -0
- package/kits/docs/venues/aun-private.md +10 -0
- package/kits/docs/venues/client-desktop.md +10 -0
- package/kits/docs/venues/client-mobile.md +10 -0
- package/kits/docs/venues/feishu-group.md +13 -0
- package/kits/docs/venues/feishu-private.md +9 -0
- package/kits/docs/venues/group.md +25 -0
- package/kits/docs/venues/private.md +10 -0
- package/kits/eck_manifest.auxiliary.json +43 -0
- package/kits/eck_manifest.json +191 -0
- package/kits/eck_message_manifest.json +63 -0
- package/kits/migrations/README-role-config-v4.md +32 -0
- package/kits/migrations/migrate-role-config-v4.mjs +623 -0
- package/kits/migrations/migrate-role-config-v5.mjs +346 -0
- package/kits/migrations/rename-config-file.mjs +99 -0
- package/kits/rules/01-overview.md +142 -0
- package/kits/rules/02-navigation.md +76 -0
- package/kits/rules/03-identity.md +34 -0
- package/kits/rules/04-relation.md +59 -0
- package/kits/rules/05-venue.md +44 -0
- package/kits/rules/06-channel.md +59 -0
- package/kits/schemas/_meta.json +29 -0
- package/kits/schemas/agent-config.schema.1.json +177 -0
- package/kits/schemas/agent-config.schema.2.json +239 -0
- package/kits/schemas/agent-config.schema.3.json +119 -0
- package/kits/schemas/agent-config.schema.4.json +208 -0
- package/kits/schemas/agent-config.schema.5.json +326 -0
- package/kits/schemas/contact-book.schema.1.json +36 -0
- package/kits/schemas/daemon.schema.1.json +90 -0
- package/kits/schemas/defaults.schema.1.json +81 -0
- package/kits/schemas/menu-exec-schema-commands.md +208 -0
- package/kits/schemas/migrations/README.md +28 -0
- package/kits/schemas/relation-config.schema.1.json +158 -0
- package/kits/schemas/relation-config.schema.2.json +73 -0
- package/kits/schemas/relation-config.schema.3.json +50 -0
- package/kits/schemas/relation-config.schema.4.json +47 -0
- package/kits/schemas/role-config.schema.1.json +200 -0
- package/kits/schemas/role-registry.schema.1.json +35 -0
- package/kits/schemas/single-session.schema.1.json +31 -0
- package/kits/templates/bootstrap-welcome.md +15 -0
- package/kits/templates/message-fragments/handoff-request-to-target.md +13 -0
- package/kits/templates/message-fragments/handoff-response-to-origin.md +10 -0
- package/kits/templates/message-fragments/inject-default.md +2 -0
- package/kits/templates/message-fragments/item.md +2 -0
- package/kits/templates/roles/admin.json +8 -0
- package/kits/templates/roles/member.json +40 -0
- package/kits/templates/roles/owner.json +8 -0
- package/kits/templates/roles/visitor.json +39 -0
- package/kits/templates/system-fragments/baseagent.md +14 -0
- package/kits/templates/system-fragments/bootstrap.md +16 -0
- package/kits/templates/system-fragments/channel.md +48 -0
- package/kits/templates/system-fragments/commands.md +26 -0
- package/kits/templates/system-fragments/identity.md +11 -0
- package/kits/templates/system-fragments/relation.md +19 -0
- package/kits/templates/system-fragments/session.md +53 -0
- package/kits/templates/system-fragments/venue.md +31 -0
- package/package.json +50 -15
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import imageType from 'image-type';
|
|
4
|
+
import { sanitizeFileName, saveToUploads, bufferToInboundImage } from '../utils/media-cache.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { formatItemsAsText } from '../core/message/items-formatter.js';
|
|
7
|
+
import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
|
|
8
|
+
import { chatDirPath, decodeDirSegment } from '../core/session/session-fs-store.js';
|
|
9
|
+
export class FeishuChannel {
|
|
10
|
+
config;
|
|
11
|
+
agentAid;
|
|
12
|
+
channelName;
|
|
13
|
+
client = null;
|
|
14
|
+
wsClient = null;
|
|
15
|
+
messageHandler;
|
|
16
|
+
projectPathProvider;
|
|
17
|
+
cleanupInterval;
|
|
18
|
+
seenMessages = new Map(); // chatId -> (messageId -> timestamp)
|
|
19
|
+
seenThreads = new Set(); // 已见的 thread_id,用于判断话题创建消息
|
|
20
|
+
userNameCache = new Map(); // userId -> userName
|
|
21
|
+
recallHandler;
|
|
22
|
+
interactionCallback;
|
|
23
|
+
interactionInvalidationCallback;
|
|
24
|
+
cardActionsInFlight = new Set();
|
|
25
|
+
/** Serialize interaction replacement per chat so concurrent sends cannot both survive. */
|
|
26
|
+
interactionSendTails = new Map();
|
|
27
|
+
connected = false;
|
|
28
|
+
// chatId → 该会话内仍 pending 的交互卡片 messageId 集合,用于作废
|
|
29
|
+
pendingCardsByChat = new Map();
|
|
30
|
+
// 撤权可能先于卡片实际投递完成;保留短期失效标记,避免迟到的卡片
|
|
31
|
+
// 在发送完成后又恢复为可操作状态。
|
|
32
|
+
invalidatedInteractions = new Map();
|
|
33
|
+
cardMetaStore = new CardMetaStore();
|
|
34
|
+
// Welcome message manager(首次交互欢迎消息,与其他 IM 渠道统一)
|
|
35
|
+
welcomeManager;
|
|
36
|
+
constructor(config, agentAid, channelName) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.agentAid = agentAid;
|
|
39
|
+
this.channelName = channelName;
|
|
40
|
+
// 初始化 welcomeManager(使用共享帮助函数)
|
|
41
|
+
if (agentAid && channelName) {
|
|
42
|
+
this.welcomeManager = initWelcomeManager('feishu', agentAid, channelName);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 预填充已知的 thread_id(重启后从数据库恢复,避免误判话题创建)
|
|
47
|
+
*/
|
|
48
|
+
preloadThreads(threadIds) {
|
|
49
|
+
for (const id of threadIds)
|
|
50
|
+
this.seenThreads.add(id);
|
|
51
|
+
if (threadIds.length > 0) {
|
|
52
|
+
logger.info(`[Feishu] Preloaded ${threadIds.length} known thread(s)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async connect() {
|
|
56
|
+
// 检查配置有效性
|
|
57
|
+
if (!this.config.appId || !this.config.appSecret) {
|
|
58
|
+
throw new Error('Feishu credentials missing (appId or appSecret is empty)');
|
|
59
|
+
}
|
|
60
|
+
if (this.config.appId.startsWith('YOUR_') || this.config.appSecret.startsWith('YOUR_')) {
|
|
61
|
+
throw new Error('Feishu credentials not configured (placeholder values detected)');
|
|
62
|
+
}
|
|
63
|
+
// 加载持久化的已处理消息 ID,防止重启后 Feishu 重推同一条消息
|
|
64
|
+
this.loadSeenMessages();
|
|
65
|
+
const { requireOptional } = await import('../utils/npm-ops.js');
|
|
66
|
+
const lark = await requireOptional('@larksuiteoapi/node-sdk');
|
|
67
|
+
try {
|
|
68
|
+
this.client = new lark.Client({
|
|
69
|
+
appId: this.config.appId,
|
|
70
|
+
appSecret: this.config.appSecret,
|
|
71
|
+
});
|
|
72
|
+
const eventDispatcher = new lark.EventDispatcher({}).register({
|
|
73
|
+
'im.message.receive_v1': async (data) => {
|
|
74
|
+
const msg = data.message;
|
|
75
|
+
logger.debug('[Feishu] Received message, message_id:', msg.message_id, 'type:', msg.message_type);
|
|
76
|
+
logger.debug('[Feishu] Full data object:', JSON.stringify(data, null, 2));
|
|
77
|
+
if (!msg.message_id || this.isDuplicate(msg.chat_id, msg.message_id)) {
|
|
78
|
+
logger.debug('[Feishu] Duplicate message ignored:', msg.message_id);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.markSeen(msg.chat_id, msg.message_id);
|
|
82
|
+
// 丢弃飞书服务端积压超过 5 分钟才下发的消息:上游观察到 65 分钟级延迟下发的
|
|
83
|
+
// 历史消息(含 /restart 这类破坏性命令),无差别接收会导致非预期重启。
|
|
84
|
+
// create_time 是 ms 字符串。
|
|
85
|
+
{
|
|
86
|
+
const createTimeMs = Number(msg.create_time ?? 0);
|
|
87
|
+
const ageMs = Date.now() - createTimeMs;
|
|
88
|
+
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
89
|
+
if (createTimeMs > 0 && ageMs > STALE_THRESHOLD_MS) {
|
|
90
|
+
logger.warn(`[Feishu] Dropping stale message: id=${msg.message_id} type=${msg.message_type} age=${Math.round(ageMs / 1000)}s create_time=${createTimeMs}`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!this.messageHandler)
|
|
95
|
+
return;
|
|
96
|
+
// 提取 chatType(从 SDK 事件直接获取)
|
|
97
|
+
const chatType = msg.chat_type === 'group' ? 'group' : 'private';
|
|
98
|
+
// 话题消息检测日志
|
|
99
|
+
if (msg.thread_id) {
|
|
100
|
+
logger.info('[Feishu] Thread message, thread_id:', msg.thread_id, 'root_id:', msg.root_id);
|
|
101
|
+
}
|
|
102
|
+
// 提取 @ 提及列表(排除机器人自身)
|
|
103
|
+
const mentions = (msg.mentions || []).map((m) => ({
|
|
104
|
+
userId: m.id?.open_id || '',
|
|
105
|
+
name: m.name,
|
|
106
|
+
key: m.key
|
|
107
|
+
})).filter((m) => m.userId && m.userId !== this.config.appId);
|
|
108
|
+
// 提取发送者信息
|
|
109
|
+
const peerId = data.sender?.sender_id?.open_id;
|
|
110
|
+
// 尝试从 mentions 中查找发送者名字(群聊中可能包含)
|
|
111
|
+
let peerName;
|
|
112
|
+
if (mentions.length > 0) {
|
|
113
|
+
const senderMention = mentions.find((m) => m.userId === peerId);
|
|
114
|
+
peerName = senderMention?.name;
|
|
115
|
+
}
|
|
116
|
+
// 如果 mentions 中没有,尝试调用 API 获取
|
|
117
|
+
if (!peerName && peerId) {
|
|
118
|
+
try {
|
|
119
|
+
peerName = await this.getUserName(peerId);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
logger.debug('[Feishu] getUserName error:', err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
// 首次交互欢迎消息(使用共享帮助函数)
|
|
127
|
+
if (peerId) {
|
|
128
|
+
await sendWelcomeIfNeeded(this.welcomeManager, peerId, msg.chat_id, (id, text) => this.sendMessage(id, text), 'Feishu');
|
|
129
|
+
}
|
|
130
|
+
// 提取话题信息
|
|
131
|
+
const threadId = msg.thread_id || undefined;
|
|
132
|
+
const rootId = msg.root_id || undefined;
|
|
133
|
+
// 处理引用消息(话题内后续消息跳过,避免每条都拼接引用前缀)
|
|
134
|
+
let quotedText = '';
|
|
135
|
+
let quotedImages = [];
|
|
136
|
+
// 引用消息处理:
|
|
137
|
+
// - 非话题:直接回复某条消息时,拉取被引用的消息内容
|
|
138
|
+
// - 话题首条:创建话题时,拉取根消息内容作为上下文
|
|
139
|
+
// - 话题后续:不拉取(上下文由 session 维护)
|
|
140
|
+
const isThreadCreation = !!(msg.thread_id && msg.parent_id && !this.seenThreads.has(msg.thread_id));
|
|
141
|
+
if (msg.thread_id)
|
|
142
|
+
this.seenThreads.add(msg.thread_id);
|
|
143
|
+
if (msg.parent_id && (!msg.thread_id || isThreadCreation) && this.client) {
|
|
144
|
+
try {
|
|
145
|
+
const res = await this.client.im.message.get({
|
|
146
|
+
path: { message_id: msg.parent_id }
|
|
147
|
+
});
|
|
148
|
+
if (!res.data?.items?.[0]?.body) {
|
|
149
|
+
throw new Error('Invalid response');
|
|
150
|
+
}
|
|
151
|
+
const quotedMsgType = res.data.items[0].msg_type;
|
|
152
|
+
const quotedContent = res.data.items[0].body.content;
|
|
153
|
+
if (quotedMsgType === 'text') {
|
|
154
|
+
const parsed = JSON.parse(quotedContent);
|
|
155
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> ${parsed.text}\n> ================\n\n`;
|
|
156
|
+
}
|
|
157
|
+
else if (quotedMsgType === 'post') {
|
|
158
|
+
const parsed = JSON.parse(quotedContent);
|
|
159
|
+
logger.info('[Feishu] Post message structure:', JSON.stringify(parsed, null, 2));
|
|
160
|
+
let text = '';
|
|
161
|
+
const content = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
|
|
162
|
+
if (content) {
|
|
163
|
+
for (const line of content) {
|
|
164
|
+
for (const elem of line) {
|
|
165
|
+
if (elem.text)
|
|
166
|
+
text += elem.text;
|
|
167
|
+
}
|
|
168
|
+
text += '\n';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> ${text.trim()}\n> ================\n\n`;
|
|
172
|
+
}
|
|
173
|
+
else if (quotedMsgType === 'image') {
|
|
174
|
+
const parsed = JSON.parse(quotedContent);
|
|
175
|
+
const imageKey = parsed.image_key;
|
|
176
|
+
const projectPath = this.projectPathProvider
|
|
177
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
178
|
+
: process.cwd();
|
|
179
|
+
const imageData = await this.downloadAndSaveImage(imageKey, msg.chat_id, msg.parent_id, projectPath);
|
|
180
|
+
if (imageData) {
|
|
181
|
+
quotedImages.push(imageData);
|
|
182
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [引用的图片]\n> ================\n\n`;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [图片消息]\n> ================\n\n`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (quotedMsgType === 'file') {
|
|
189
|
+
const parsedFile = JSON.parse(quotedContent);
|
|
190
|
+
const quotedFileKey = parsedFile.file_key;
|
|
191
|
+
const quotedFileName = parsedFile.file_name || 'unknown';
|
|
192
|
+
const projectPath = this.projectPathProvider
|
|
193
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
194
|
+
: process.cwd();
|
|
195
|
+
const quotedFilePath = await this.downloadFile(quotedFileKey, quotedFileName, msg.parent_id, projectPath);
|
|
196
|
+
if (quotedFilePath) {
|
|
197
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [引用的文件:${quotedFileName}]\n> 文件已保存到:${quotedFilePath}\n> ================\n\n`;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [文件消息]\n> ================\n\n`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (quotedMsgType === 'merge_forward') {
|
|
204
|
+
const { text: mergedText, images: mergedImages } = await this.extractMergeForwardContent(msg.parent_id, msg.chat_id);
|
|
205
|
+
if (mergedText) {
|
|
206
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [合并转发消息]\n> ================\n\n${mergedText}\n\n`;
|
|
207
|
+
quotedImages.push(...mergedImages);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [合并转发消息]\n> ================\n\n`;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
quotedText = `> 以下是引用的原消息\n> ================\n> [${quotedMsgType}消息]\n> ================\n\n`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
logger.warn({ err }, '[Feishu] Failed to fetch quoted message');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
logger.info(`[Feishu] Incoming message_type=${msg.message_type} content=${msg.content?.substring(0, 200)}`);
|
|
222
|
+
// 处理文本消息
|
|
223
|
+
if (msg.message_type === 'text') {
|
|
224
|
+
const parsed = JSON.parse(msg.content);
|
|
225
|
+
// 优先使用 text_without_at_bot(去除机器人 @),否则使用 text
|
|
226
|
+
let content = (parsed.text_without_at_bot || parsed.text || '').trim();
|
|
227
|
+
// 清理残留的 mention 占位符(@_user_N 代表机器人)
|
|
228
|
+
content = content.replace(/@_user_\d+/g, '').trim();
|
|
229
|
+
const finalContent = quotedText + content;
|
|
230
|
+
await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, mentions: mentions.length > 0 ? mentions : undefined, mentionAids: mentions.length > 0 ? mentions.map((m) => m.userId) : undefined, threadId, rootId, chatType });
|
|
231
|
+
}
|
|
232
|
+
// 处理图片消息
|
|
233
|
+
else if (msg.message_type === 'image') {
|
|
234
|
+
const imageContent = JSON.parse(msg.content);
|
|
235
|
+
const imageKey = imageContent.image_key;
|
|
236
|
+
logger.debug('[Feishu] Received image message, image_key:', imageKey, 'message_id:', msg.message_id);
|
|
237
|
+
const projectPath = this.projectPathProvider
|
|
238
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
239
|
+
: process.cwd();
|
|
240
|
+
const imageData = await this.downloadAndSaveImage(imageKey, msg.chat_id, msg.message_id, projectPath);
|
|
241
|
+
if (imageData) {
|
|
242
|
+
const allImages = [...quotedImages, imageData];
|
|
243
|
+
const prompt = quotedText + '用户发送了一张图片,请结合上下文理解用户意图并回应。';
|
|
244
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: allImages, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const prompt = quotedText + '[图片下载失败] 应用可能缺少 im:message 或 im:message:readonly 权限';
|
|
248
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// 处理文件消息
|
|
252
|
+
else if (msg.message_type === 'file') {
|
|
253
|
+
const fileContent = JSON.parse(msg.content);
|
|
254
|
+
const fileKey = fileContent.file_key;
|
|
255
|
+
const fileName = fileContent.file_name || 'unknown';
|
|
256
|
+
logger.debug('[Feishu] Received file message, file_key:', fileKey, 'file_name:', fileName);
|
|
257
|
+
const projectPath = this.projectPathProvider
|
|
258
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
259
|
+
: process.cwd();
|
|
260
|
+
const filePath = await this.downloadFile(fileKey, fileName, msg.message_id, projectPath);
|
|
261
|
+
if (filePath) {
|
|
262
|
+
const prompt = quotedText + `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`;
|
|
263
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const prompt = quotedText + '[文件下载失败] 应用可能缺少 im:resource 权限';
|
|
267
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// 处理富文本消息
|
|
271
|
+
else if (msg.message_type === 'post') {
|
|
272
|
+
const parsed = JSON.parse(msg.content);
|
|
273
|
+
let text = '';
|
|
274
|
+
const postImages = [];
|
|
275
|
+
const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
|
|
276
|
+
const content = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
|
|
277
|
+
if (content) {
|
|
278
|
+
const projectPath = this.projectPathProvider
|
|
279
|
+
? await this.projectPathProvider(msg.chat_id)
|
|
280
|
+
: process.cwd();
|
|
281
|
+
for (const line of content) {
|
|
282
|
+
for (const elem of line) {
|
|
283
|
+
if (elem.tag === 'img' && elem.image_key) {
|
|
284
|
+
const imageData = await this.downloadAndSaveImage(elem.image_key, msg.chat_id, msg.message_id, projectPath);
|
|
285
|
+
if (imageData)
|
|
286
|
+
postImages.push(imageData);
|
|
287
|
+
}
|
|
288
|
+
else if (elem.text) {
|
|
289
|
+
text += elem.text;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
text += '\n';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let finalContent = text.trim();
|
|
296
|
+
if (title)
|
|
297
|
+
finalContent = `${title}\n${finalContent}`;
|
|
298
|
+
finalContent = quotedText + finalContent;
|
|
299
|
+
const allImages = [...quotedImages, ...postImages];
|
|
300
|
+
await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: allImages.length > 0 ? allImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
301
|
+
}
|
|
302
|
+
// 处理合并转发消息
|
|
303
|
+
else if (msg.message_type === 'merge_forward') {
|
|
304
|
+
const { text: mergedText, images: mergedImages } = await this.extractMergeForwardContent(msg.message_id, msg.chat_id);
|
|
305
|
+
if (mergedText) {
|
|
306
|
+
// 直接发送合并转发时,parent_id 指向自己,引用解析会把相同内容填入 quotedText 导致重复,丢弃
|
|
307
|
+
const finalContent = mergedText;
|
|
308
|
+
const allImages = [...quotedImages, ...mergedImages];
|
|
309
|
+
await this.messageHandler({ channelId: msg.chat_id, content: finalContent, images: allImages.length > 0 ? allImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const prompt = quotedText + '[合并转发消息解析失败]';
|
|
313
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// 处理其他类型消息
|
|
317
|
+
else {
|
|
318
|
+
logger.debug('[Feishu] Unsupported message type:', msg.message_type);
|
|
319
|
+
const prompt = quotedText + `[不支持的消息类型: ${msg.message_type}]`;
|
|
320
|
+
await this.messageHandler({ channelId: msg.chat_id, content: prompt, images: quotedImages.length > 0 ? quotedImages : undefined, peerId, peerName, messageId: msg.message_id, threadId, rootId, chatType });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
logger.error('[Feishu] Failed to process message:', error);
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
'im.message.recalled_v1': async (data) => {
|
|
328
|
+
const messageId = data?.message_id;
|
|
329
|
+
if (messageId) {
|
|
330
|
+
logger.info('[Feishu] Message recalled:', messageId);
|
|
331
|
+
this.recallHandler?.(messageId);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
'im.message.message_read_v1': async () => { },
|
|
335
|
+
'im.message.reaction.created_v1': async () => { },
|
|
336
|
+
'card.action.trigger': (data) => this.handleCardAction(data),
|
|
337
|
+
});
|
|
338
|
+
this.wsClient = new lark.WSClient({
|
|
339
|
+
appId: this.config.appId,
|
|
340
|
+
appSecret: this.config.appSecret,
|
|
341
|
+
});
|
|
342
|
+
await this.wsClient.start({ eventDispatcher });
|
|
343
|
+
this.connected = true;
|
|
344
|
+
this.startCleanupTask();
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error instanceof Error) {
|
|
348
|
+
throw new Error(`Feishu connection failed: ${error.message}`);
|
|
349
|
+
}
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
onMessage(handler) {
|
|
354
|
+
this.messageHandler = handler;
|
|
355
|
+
}
|
|
356
|
+
onRecall(handler) {
|
|
357
|
+
this.recallHandler = handler;
|
|
358
|
+
}
|
|
359
|
+
onInteraction(callback) {
|
|
360
|
+
this.interactionCallback = callback;
|
|
361
|
+
}
|
|
362
|
+
onInteractionInvalidated(callback) {
|
|
363
|
+
this.interactionInvalidationCallback = callback;
|
|
364
|
+
}
|
|
365
|
+
/** Process a card callback and wait for the authoritative backend result. */
|
|
366
|
+
async handleCardAction(data) {
|
|
367
|
+
try {
|
|
368
|
+
const action = data?.action;
|
|
369
|
+
if (!action?.value)
|
|
370
|
+
return undefined;
|
|
371
|
+
const value = action.value;
|
|
372
|
+
const operatorId = data.operator?.open_id;
|
|
373
|
+
const chatId = data.context?.open_chat_id || data.open_chat_id;
|
|
374
|
+
const cardMessageId = data.open_message_id || data.context?.open_message_id;
|
|
375
|
+
const formValues = action.form_value || {};
|
|
376
|
+
const decision = routeCardAction({ value, formValues, operatorId }, this.cardMetaStore);
|
|
377
|
+
switch (decision.kind) {
|
|
378
|
+
case 'ignore':
|
|
379
|
+
return undefined;
|
|
380
|
+
case 'reject':
|
|
381
|
+
return { toast: { type: 'warning', content: '⚠️ 仅卡片发起者可操作' } };
|
|
382
|
+
case 'expired':
|
|
383
|
+
return { toast: { type: 'warning', content: '⚠️ 卡片已失效,请重新发起' } };
|
|
384
|
+
case 'show-input':
|
|
385
|
+
return decision.card;
|
|
386
|
+
case 'command': {
|
|
387
|
+
logger.info(`[Feishu] CommandCard trigger: command=${decision.command}, operator=${operatorId}`);
|
|
388
|
+
if (this.messageHandler) {
|
|
389
|
+
// 卡片回调不传 chatType——oc_ 前缀不区分群聊/单聊,
|
|
390
|
+
// 由 ensureSession 从已有 session 中继承正确的 chatType
|
|
391
|
+
await this.messageHandler({
|
|
392
|
+
channelId: chatId,
|
|
393
|
+
content: decision.command,
|
|
394
|
+
peerId: operatorId,
|
|
395
|
+
messageId: `card-trigger-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
396
|
+
source: 'card-trigger',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (chatId && cardMessageId)
|
|
400
|
+
this.untrackPendingCard(chatId, cardMessageId);
|
|
401
|
+
this.cardMetaStore.markResolved(value._id);
|
|
402
|
+
this.cardMetaStore.cleanup(value._id);
|
|
403
|
+
return decision.card;
|
|
404
|
+
}
|
|
405
|
+
case 'respond': {
|
|
406
|
+
logger.info(`[Feishu] Card action: id=${value._id}, action=${decision.response.action}`);
|
|
407
|
+
if (this.cardActionsInFlight.has(value._id)) {
|
|
408
|
+
return { toast: { type: 'info', content: '⏳ 操作正在处理,请勿重复点击' } };
|
|
409
|
+
}
|
|
410
|
+
if (!this.interactionCallback) {
|
|
411
|
+
return this.invalidatedCardResponse('handler_unavailable');
|
|
412
|
+
}
|
|
413
|
+
this.cardActionsInFlight.add(value._id);
|
|
414
|
+
let accepted = false;
|
|
415
|
+
try {
|
|
416
|
+
accepted = (await this.interactionCallback(decision.response)) !== false;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
this.cardActionsInFlight.delete(value._id);
|
|
420
|
+
}
|
|
421
|
+
if (!accepted) {
|
|
422
|
+
const entry = this.cardMetaStore.get(value._id);
|
|
423
|
+
const invalidationReason = entry?.invalidatedReason ?? 'backend_rejected';
|
|
424
|
+
if (entry && !entry.invalidated) {
|
|
425
|
+
entry.invalidated = true;
|
|
426
|
+
entry.invalidatedReason = invalidationReason;
|
|
427
|
+
}
|
|
428
|
+
if (chatId && cardMessageId)
|
|
429
|
+
this.untrackPendingCard(chatId, cardMessageId);
|
|
430
|
+
return this.invalidatedCardResponse(invalidationReason);
|
|
431
|
+
}
|
|
432
|
+
if (chatId && cardMessageId)
|
|
433
|
+
this.untrackPendingCard(chatId, cardMessageId);
|
|
434
|
+
this.cardMetaStore.markResolved(value._id);
|
|
435
|
+
this.cardMetaStore.cleanup(value._id);
|
|
436
|
+
return decision.card;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
logger.error('[Feishu] Failed to handle card action:', err);
|
|
442
|
+
const detail = err instanceof Error && err.message ? err.message : String(err || '未知错误');
|
|
443
|
+
return { toast: { type: 'error', content: `❌ 操作失败: ${detail}` } };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
onProjectPathRequest(provider) {
|
|
447
|
+
this.projectPathProvider = provider;
|
|
448
|
+
}
|
|
449
|
+
async getUserName(userId) {
|
|
450
|
+
if (!userId || !this.client)
|
|
451
|
+
return undefined;
|
|
452
|
+
// 检查缓存
|
|
453
|
+
if (this.userNameCache.has(userId)) {
|
|
454
|
+
return this.userNameCache.get(userId);
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const res = await this.client.contact.user.get({
|
|
458
|
+
path: { user_id: userId },
|
|
459
|
+
params: { user_id_type: 'open_id' }
|
|
460
|
+
});
|
|
461
|
+
const userName = res.data?.user?.name;
|
|
462
|
+
if (userName) {
|
|
463
|
+
this.userNameCache.set(userId, userName);
|
|
464
|
+
return userName;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
logger.debug('[Feishu] Failed to get user name, code:', err?.code, 'msg:', err?.message);
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
async sendMessage(chatId, content, options) {
|
|
473
|
+
if (!this.client)
|
|
474
|
+
return;
|
|
475
|
+
content = exposeLocalMarkdownLinkTargetsForFeishu(content);
|
|
476
|
+
if (!content || content.trim() === '') {
|
|
477
|
+
logger.warn('[Feishu] Attempted to send empty message, skipping');
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
// 飞书消息内容限制约 30KB(text)/ 150KB(post),安全阈值 28000 字符
|
|
481
|
+
// 超长消息自动拆分,按段落边界分割
|
|
482
|
+
const MAX_CONTENT_LENGTH = 28000;
|
|
483
|
+
if (content.length > MAX_CONTENT_LENGTH) {
|
|
484
|
+
logger.info(`[Feishu] Message too long (${content.length} chars), splitting into parts`);
|
|
485
|
+
const parts = splitLongMessage(content, MAX_CONTENT_LENGTH);
|
|
486
|
+
for (let i = 0; i < parts.length; i++) {
|
|
487
|
+
// 首条消息保留 reply 选项,后续消息不再 reply
|
|
488
|
+
const partOptions = i === 0 ? options : { ...options, replyToMessageId: undefined };
|
|
489
|
+
await this.sendMessage(chatId, parts[i], partOptions);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
logger.debug(`[Feishu] sendMessage called, chatId: ${chatId}, content length: ${content.length}`);
|
|
494
|
+
try {
|
|
495
|
+
const useMarkdown = !options?.forceText && hasMarkdownSyntax(content);
|
|
496
|
+
const hasMention = !!(options?.mentionUserIds && options.mentionUserIds.length > 0);
|
|
497
|
+
// 消息类型决策:有 Markdown / @ → post,否则 text
|
|
498
|
+
const msgType = (useMarkdown || hasMention) ? 'post' : 'text';
|
|
499
|
+
let msgContent;
|
|
500
|
+
if (msgType === 'post') {
|
|
501
|
+
const postData = useMarkdown
|
|
502
|
+
? markdownToFeishuPost(content, options?.title)
|
|
503
|
+
: { zh_cn: { title: options?.title || '', content: [[{ tag: 'text', text: content }]] } };
|
|
504
|
+
// 在第一行开头插入所有 @ 标签
|
|
505
|
+
if (hasMention && postData.zh_cn.content.length > 0) {
|
|
506
|
+
const atTags = options.mentionUserIds.map(uid => ({ tag: 'at', user_id: uid }));
|
|
507
|
+
postData.zh_cn.content[0].unshift(...atTags);
|
|
508
|
+
}
|
|
509
|
+
msgContent = JSON.stringify(postData);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
msgContent = JSON.stringify({ text: content });
|
|
513
|
+
}
|
|
514
|
+
if (options?.replyToMessageId) {
|
|
515
|
+
const replyData = { msg_type: msgType, content: msgContent };
|
|
516
|
+
if (options.replyInThread) {
|
|
517
|
+
replyData.reply_in_thread = true;
|
|
518
|
+
}
|
|
519
|
+
const replyRes = await this.client.im.message.reply({
|
|
520
|
+
path: { message_id: options.replyToMessageId },
|
|
521
|
+
data: replyData
|
|
522
|
+
});
|
|
523
|
+
if (options.replyInThread && options.onThreadCreated) {
|
|
524
|
+
const newThreadId = replyRes?.data?.thread_id;
|
|
525
|
+
if (newThreadId)
|
|
526
|
+
options.onThreadCreated(newThreadId);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
await this.client.im.message.create({
|
|
531
|
+
params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
|
|
532
|
+
data: { receive_id: chatId, msg_type: msgType, content: msgContent }
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
logger.debug(`[Feishu] Sent message as ${useMarkdown ? 'post (Markdown)' : 'text'}`);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
// 230011: 消息已被撤回,降级为普通消息重试
|
|
539
|
+
// 99992354: message_id 不存在(合成 ID 或已过期),降级为普通消息
|
|
540
|
+
const errCode = error.response?.data?.code;
|
|
541
|
+
if ((errCode === 230011 || errCode === 99992354) && options?.replyToMessageId) {
|
|
542
|
+
logger.warn(`[Feishu] Reply target invalid (${errCode}), retrying without reply`);
|
|
543
|
+
return this.sendMessage(chatId, content, { ...options, replyToMessageId: undefined });
|
|
544
|
+
}
|
|
545
|
+
// 230025: 消息内容超长,截断后重试
|
|
546
|
+
if (errCode === 230025) {
|
|
547
|
+
logger.warn(`[Feishu] Message too long (230025, ${content.length} chars), truncating`);
|
|
548
|
+
const truncated = content.slice(0, 28000) + '\n\n⚠️ 消息过长,已截断';
|
|
549
|
+
return this.sendMessage(chatId, truncated, options);
|
|
550
|
+
}
|
|
551
|
+
const respData = error?.response?.data;
|
|
552
|
+
logger.error('[Feishu] Failed to send message:', respData ? JSON.stringify(respData) : error?.message ?? error);
|
|
553
|
+
// post 格式相关错误(400/230001):降级为纯文本重试
|
|
554
|
+
if (!options?.forceText && (error?.response?.status === 400 || errCode === 230001)) {
|
|
555
|
+
logger.warn('[Feishu] Retrying as plain text (forceText)');
|
|
556
|
+
return this.sendMessage(chatId, content, { ...options, forceText: true });
|
|
557
|
+
}
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async sendFile(chatId, filePath, options) {
|
|
562
|
+
if (!this.client)
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
// 检测是否为图片,是则走 sendImage(内联预览)而非文件卡片
|
|
566
|
+
// 读取足够字节供 file-type 解析(ZIP-based 格式如 PPTX 需要更多字节)
|
|
567
|
+
const header = Buffer.alloc(4100);
|
|
568
|
+
const fd = fs.openSync(filePath, 'r');
|
|
569
|
+
const bytesRead = fs.readSync(fd, header, 0, 4100, 0);
|
|
570
|
+
fs.closeSync(fd);
|
|
571
|
+
const imgType = await imageType(header.subarray(0, bytesRead)).catch(() => undefined);
|
|
572
|
+
if (imgType) {
|
|
573
|
+
logger.info(`[Feishu] Detected image (${imgType.mime}), sending as inline image:`, filePath);
|
|
574
|
+
const buf = fs.readFileSync(filePath);
|
|
575
|
+
return this.sendImage(chatId, buf, options);
|
|
576
|
+
}
|
|
577
|
+
logger.info('[Feishu] Uploading file:', filePath);
|
|
578
|
+
const fileStream = fs.createReadStream(filePath);
|
|
579
|
+
const fileName = path.basename(filePath);
|
|
580
|
+
const uploadResponse = await this.client.im.file.create({
|
|
581
|
+
data: {
|
|
582
|
+
file_type: 'stream',
|
|
583
|
+
file_name: fileName,
|
|
584
|
+
file: fileStream
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
if (!uploadResponse || !uploadResponse.file_key) {
|
|
588
|
+
logger.error('[Feishu] File upload failed: no file_key returned');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const fileKey = uploadResponse.file_key;
|
|
592
|
+
const msgContent = JSON.stringify({ file_key: fileKey });
|
|
593
|
+
logger.info('[Feishu] File uploaded, file_key:', fileKey);
|
|
594
|
+
if (options?.replyToMessageId) {
|
|
595
|
+
const replyData = { msg_type: 'file', content: msgContent };
|
|
596
|
+
if (options.replyInThread) {
|
|
597
|
+
replyData.reply_in_thread = true;
|
|
598
|
+
}
|
|
599
|
+
await this.client.im.message.reply({
|
|
600
|
+
path: { message_id: options.replyToMessageId },
|
|
601
|
+
data: replyData
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
await this.client.im.message.create({
|
|
606
|
+
params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
|
|
607
|
+
data: {
|
|
608
|
+
receive_id: chatId,
|
|
609
|
+
msg_type: 'file',
|
|
610
|
+
content: msgContent
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
logger.info('[Feishu] File message sent successfully');
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
// 230011/99992354: reply target invalid, retry without reply
|
|
618
|
+
const errCode = error.response?.data?.code;
|
|
619
|
+
if ((errCode === 230011 || errCode === 99992354) && options?.replyToMessageId) {
|
|
620
|
+
logger.warn(`[Feishu] Reply target invalid (${errCode}), retrying file send without reply`);
|
|
621
|
+
return this.sendFile(chatId, filePath);
|
|
622
|
+
}
|
|
623
|
+
logger.error('[Feishu] Failed to send file:', error);
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async sendImage(chatId, png, options) {
|
|
628
|
+
if (!this.client)
|
|
629
|
+
return;
|
|
630
|
+
try {
|
|
631
|
+
const uploadResponse = await this.client.im.image.create({
|
|
632
|
+
data: {
|
|
633
|
+
image_type: 'message',
|
|
634
|
+
image: Buffer.from(png),
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
const imageKey = uploadResponse?.image_key;
|
|
638
|
+
if (!imageKey) {
|
|
639
|
+
logger.error('[Feishu] Image upload failed: no image_key returned');
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
logger.debug('[Feishu] Image uploaded, image_key:', imageKey);
|
|
643
|
+
const msgContent = JSON.stringify({ image_key: imageKey });
|
|
644
|
+
if (options?.replyToMessageId) {
|
|
645
|
+
const replyData = { msg_type: 'image', content: msgContent };
|
|
646
|
+
if (options.replyInThread)
|
|
647
|
+
replyData.reply_in_thread = true;
|
|
648
|
+
await this.client.im.message.reply({
|
|
649
|
+
path: { message_id: options.replyToMessageId },
|
|
650
|
+
data: replyData
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
await this.client.im.message.create({
|
|
655
|
+
params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
|
|
656
|
+
data: { receive_id: chatId, msg_type: 'image', content: msgContent }
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
logger.debug('[Feishu] Image message sent successfully');
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
// 99992354: reply target invalid — image cannot easily retry, just log
|
|
663
|
+
logger.error('[Feishu] Failed to send image:', error);
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
isDuplicate(chatId, msgId) {
|
|
668
|
+
return !!chatId && this.seenMessages.get(chatId)?.has(msgId) === true;
|
|
669
|
+
}
|
|
670
|
+
markSeen(chatId, msgId) {
|
|
671
|
+
if (!chatId)
|
|
672
|
+
return;
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
let chatMessages = this.seenMessages.get(chatId);
|
|
675
|
+
if (!chatMessages) {
|
|
676
|
+
chatMessages = new Map();
|
|
677
|
+
this.seenMessages.set(chatId, chatMessages);
|
|
678
|
+
}
|
|
679
|
+
chatMessages.set(msgId, now);
|
|
680
|
+
const seenFile = this.seenMessageFile(chatId);
|
|
681
|
+
if (!seenFile)
|
|
682
|
+
return;
|
|
683
|
+
try {
|
|
684
|
+
fs.mkdirSync(path.dirname(seenFile), { recursive: true });
|
|
685
|
+
fs.appendFileSync(seenFile, JSON.stringify({ id: msgId, ts: now, channel: this.channelName }) + '\n');
|
|
686
|
+
}
|
|
687
|
+
catch { }
|
|
688
|
+
}
|
|
689
|
+
seenMessageFile(chatId) {
|
|
690
|
+
if (!this.config.sessionsDir)
|
|
691
|
+
return undefined;
|
|
692
|
+
return path.join(chatDirPath(this.config.sessionsDir, 'feishu', chatId), 'seen-messages.jsonl');
|
|
693
|
+
}
|
|
694
|
+
loadSeenMessages() {
|
|
695
|
+
this.migrateLegacySeenMessages();
|
|
696
|
+
if (!this.config.sessionsDir)
|
|
697
|
+
return;
|
|
698
|
+
const feishuDir = path.join(this.config.sessionsDir, 'feishu');
|
|
699
|
+
let entries;
|
|
700
|
+
try {
|
|
701
|
+
entries = fs.readdirSync(feishuDir, { withFileTypes: true });
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
707
|
+
let loaded = 0;
|
|
708
|
+
for (const entry of entries) {
|
|
709
|
+
if (!entry.isDirectory())
|
|
710
|
+
continue;
|
|
711
|
+
const chatId = decodeDirSegment(entry.name);
|
|
712
|
+
const seenFile = path.join(feishuDir, entry.name, 'seen-messages.jsonl');
|
|
713
|
+
let lines;
|
|
714
|
+
try {
|
|
715
|
+
lines = fs.readFileSync(seenFile, 'utf-8').split('\n').filter(Boolean);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
let needsRewrite = false;
|
|
721
|
+
for (const line of lines) {
|
|
722
|
+
try {
|
|
723
|
+
const { id, ts, channel } = JSON.parse(line);
|
|
724
|
+
if (typeof id !== 'string' || typeof ts !== 'number') {
|
|
725
|
+
needsRewrite = true;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (channel && this.channelName && channel !== this.channelName)
|
|
729
|
+
continue;
|
|
730
|
+
if (ts <= cutoff) {
|
|
731
|
+
needsRewrite = true;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
let chatMessages = this.seenMessages.get(chatId);
|
|
735
|
+
if (!chatMessages) {
|
|
736
|
+
chatMessages = new Map();
|
|
737
|
+
this.seenMessages.set(chatId, chatMessages);
|
|
738
|
+
}
|
|
739
|
+
chatMessages.set(id, ts);
|
|
740
|
+
loaded++;
|
|
741
|
+
if (!channel && this.channelName)
|
|
742
|
+
needsRewrite = true;
|
|
743
|
+
}
|
|
744
|
+
catch { }
|
|
745
|
+
}
|
|
746
|
+
if (needsRewrite)
|
|
747
|
+
this.rewriteSeenMessageFile(chatId, this.seenMessages.get(chatId) ?? new Map());
|
|
748
|
+
}
|
|
749
|
+
if (loaded > 0)
|
|
750
|
+
logger.info(`[Feishu] Loaded ${loaded} seen message ID(s) from chat directories`);
|
|
751
|
+
}
|
|
752
|
+
migrateLegacySeenMessages() {
|
|
753
|
+
const legacyFile = this.config.seenMsgFile;
|
|
754
|
+
if (!legacyFile || !this.config.sessionsDir || !fs.existsSync(legacyFile))
|
|
755
|
+
return;
|
|
756
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
757
|
+
const records = [];
|
|
758
|
+
try {
|
|
759
|
+
for (const line of fs.readFileSync(legacyFile, 'utf-8').split('\n')) {
|
|
760
|
+
if (!line)
|
|
761
|
+
continue;
|
|
762
|
+
try {
|
|
763
|
+
const { id, ts } = JSON.parse(line);
|
|
764
|
+
if (typeof id === 'string' && typeof ts === 'number' && ts > cutoff) {
|
|
765
|
+
records.push({ id, ts, channel: this.channelName });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
catch { }
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const chatIds = this.findChannelChatIds();
|
|
775
|
+
if (chatIds.length === 0)
|
|
776
|
+
return;
|
|
777
|
+
try {
|
|
778
|
+
for (const chatId of chatIds) {
|
|
779
|
+
const seenFile = this.seenMessageFile(chatId);
|
|
780
|
+
if (!seenFile)
|
|
781
|
+
continue;
|
|
782
|
+
fs.mkdirSync(path.dirname(seenFile), { recursive: true });
|
|
783
|
+
if (records.length > 0) {
|
|
784
|
+
fs.appendFileSync(seenFile, records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
fs.unlinkSync(legacyFile);
|
|
788
|
+
logger.info(`[Feishu] Migrated ${records.length} seen message ID(s) into ${chatIds.length} chat director${chatIds.length === 1 ? 'y' : 'ies'}`);
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
logger.warn('[Feishu] Failed to migrate legacy seen-message file:', error);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
findChannelChatIds() {
|
|
795
|
+
if (!this.config.sessionsDir || !this.channelName)
|
|
796
|
+
return [];
|
|
797
|
+
const feishuDir = path.join(this.config.sessionsDir, 'feishu');
|
|
798
|
+
let entries;
|
|
799
|
+
try {
|
|
800
|
+
entries = fs.readdirSync(feishuDir, { withFileTypes: true });
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return [];
|
|
804
|
+
}
|
|
805
|
+
const chatIds = [];
|
|
806
|
+
for (const entry of entries) {
|
|
807
|
+
if (!entry.isDirectory())
|
|
808
|
+
continue;
|
|
809
|
+
const chatDir = path.join(feishuDir, entry.name);
|
|
810
|
+
let candidates;
|
|
811
|
+
try {
|
|
812
|
+
candidates = ['active.json', ...fs.readdirSync(chatDir).filter(name => /^meta_.*\.jsonl$/.test(name))];
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
let matched = false;
|
|
818
|
+
for (const candidate of candidates) {
|
|
819
|
+
try {
|
|
820
|
+
const firstLine = fs.readFileSync(path.join(chatDir, candidate), 'utf-8').split('\n').find(Boolean);
|
|
821
|
+
if (firstLine && JSON.parse(firstLine).channel === this.channelName) {
|
|
822
|
+
matched = true;
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
catch { }
|
|
827
|
+
}
|
|
828
|
+
if (matched)
|
|
829
|
+
chatIds.push(decodeDirSegment(entry.name));
|
|
830
|
+
}
|
|
831
|
+
return chatIds;
|
|
832
|
+
}
|
|
833
|
+
cleanupSeenMessages() {
|
|
834
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
835
|
+
let cleaned = 0;
|
|
836
|
+
for (const [chatId, chatMessages] of this.seenMessages) {
|
|
837
|
+
let chatCleaned = false;
|
|
838
|
+
for (const [id, ts] of chatMessages) {
|
|
839
|
+
if (ts < cutoff) {
|
|
840
|
+
chatMessages.delete(id);
|
|
841
|
+
cleaned++;
|
|
842
|
+
chatCleaned = true;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (chatMessages.size === 0)
|
|
846
|
+
this.seenMessages.delete(chatId);
|
|
847
|
+
if (chatCleaned)
|
|
848
|
+
this.rewriteSeenMessageFile(chatId, chatMessages);
|
|
849
|
+
}
|
|
850
|
+
if (cleaned > 0)
|
|
851
|
+
logger.info(`[Feishu] Cleaned ${cleaned} old message IDs`);
|
|
852
|
+
}
|
|
853
|
+
rewriteSeenMessageFile(chatId, chatMessages) {
|
|
854
|
+
const seenFile = this.seenMessageFile(chatId);
|
|
855
|
+
if (!seenFile)
|
|
856
|
+
return;
|
|
857
|
+
try {
|
|
858
|
+
const foreignRecords = [];
|
|
859
|
+
if (fs.existsSync(seenFile)) {
|
|
860
|
+
for (const line of fs.readFileSync(seenFile, 'utf-8').split('\n')) {
|
|
861
|
+
if (!line)
|
|
862
|
+
continue;
|
|
863
|
+
try {
|
|
864
|
+
const record = JSON.parse(line);
|
|
865
|
+
if (record.channel && this.channelName && record.channel !== this.channelName)
|
|
866
|
+
foreignRecords.push(record);
|
|
867
|
+
}
|
|
868
|
+
catch { }
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const ownRecords = [...chatMessages.entries()].map(([id, ts]) => ({ id, ts, channel: this.channelName }));
|
|
872
|
+
const records = [...foreignRecords, ...ownRecords];
|
|
873
|
+
if (records.length === 0) {
|
|
874
|
+
fs.rmSync(seenFile, { force: true });
|
|
875
|
+
}
|
|
876
|
+
else {
|
|
877
|
+
fs.writeFileSync(seenFile, records.map(record => JSON.stringify(record)).join('\n') + '\n');
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch { }
|
|
881
|
+
}
|
|
882
|
+
startCleanupTask() {
|
|
883
|
+
this.cleanupInterval = setInterval(() => {
|
|
884
|
+
this.cleanupSeenMessages();
|
|
885
|
+
// seenThreads 无时间戳,仅限容量(话题持久存在,不按时间清理)
|
|
886
|
+
if (this.seenThreads.size > 1000)
|
|
887
|
+
this.seenThreads.clear();
|
|
888
|
+
}, 60 * 60 * 1000);
|
|
889
|
+
}
|
|
890
|
+
async disconnect() {
|
|
891
|
+
this.connected = false;
|
|
892
|
+
if (this.cleanupInterval) {
|
|
893
|
+
clearInterval(this.cleanupInterval);
|
|
894
|
+
this.cleanupInterval = undefined;
|
|
895
|
+
}
|
|
896
|
+
if (this.wsClient) {
|
|
897
|
+
await this.wsClient.close();
|
|
898
|
+
this.wsClient = null;
|
|
899
|
+
}
|
|
900
|
+
this.client = null;
|
|
901
|
+
}
|
|
902
|
+
/** Get current connection status */
|
|
903
|
+
getStatus() {
|
|
904
|
+
return { connected: this.connected };
|
|
905
|
+
}
|
|
906
|
+
/** Reconnect: disconnect then connect again */
|
|
907
|
+
async reconnect() {
|
|
908
|
+
if (this.connected) {
|
|
909
|
+
await this.disconnect();
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
await this.connect();
|
|
913
|
+
return '重连成功';
|
|
914
|
+
}
|
|
915
|
+
catch (err) {
|
|
916
|
+
return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
async downloadAndSaveImage(imageKey, chatId, messageId, projectPath) {
|
|
920
|
+
if (!this.client)
|
|
921
|
+
return null;
|
|
922
|
+
try {
|
|
923
|
+
logger.debug('[Feishu] Downloading image, image_key:', imageKey);
|
|
924
|
+
// 使用 message-resource API 下载用户发送的图片
|
|
925
|
+
const response = await this.client.im.messageResource.get({
|
|
926
|
+
path: {
|
|
927
|
+
message_id: messageId,
|
|
928
|
+
file_key: imageKey
|
|
929
|
+
},
|
|
930
|
+
params: {
|
|
931
|
+
type: 'image'
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
// 读取图片数据流并转换为 base64
|
|
935
|
+
if (response && typeof response.getReadableStream === 'function') {
|
|
936
|
+
const stream = response.getReadableStream();
|
|
937
|
+
const chunks = [];
|
|
938
|
+
for await (const chunk of stream) {
|
|
939
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
940
|
+
}
|
|
941
|
+
const buffer = Buffer.concat(chunks);
|
|
942
|
+
if (buffer.length === 0) {
|
|
943
|
+
logger.warn('[Feishu] Empty response from image download');
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
// 统一图片识别 + base64 注入(magic bytes → 元数据 → 后缀)
|
|
947
|
+
const img = await bufferToInboundImage(buffer);
|
|
948
|
+
if (!img) {
|
|
949
|
+
logger.warn('[Feishu] Image validation failed (not a supported image)');
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
logger.debug('[Feishu] Image downloaded successfully, type:', img.mimeType, 'size:', img.data.length);
|
|
953
|
+
return img;
|
|
954
|
+
}
|
|
955
|
+
logger.error('[Feishu] Image download failed: no valid method');
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
logger.error('[Feishu] Failed to download image:', error);
|
|
960
|
+
if (error && typeof error === 'object' && 'response' in error) {
|
|
961
|
+
const axiosError = error;
|
|
962
|
+
logger.error('[Feishu] Response status:', axiosError.response?.status);
|
|
963
|
+
logger.error('[Feishu] Response data:', JSON.stringify(axiosError.response?.data));
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* 提取合并转发消息的子消息内容。
|
|
970
|
+
* 调用 im.message.get 获取子消息列表,逐条解析 text/image/post/file 类型。
|
|
971
|
+
*/
|
|
972
|
+
async extractMergeForwardContent(messageId, chatId) {
|
|
973
|
+
const empty = { text: '', images: [] };
|
|
974
|
+
if (!this.client)
|
|
975
|
+
return empty;
|
|
976
|
+
try {
|
|
977
|
+
const res = await this.client.im.message.get({
|
|
978
|
+
path: { message_id: messageId }
|
|
979
|
+
});
|
|
980
|
+
const items = res.data?.items;
|
|
981
|
+
if (!items || items.length === 0) {
|
|
982
|
+
logger.warn('[Feishu] merge_forward: no sub-messages found');
|
|
983
|
+
return empty;
|
|
984
|
+
}
|
|
985
|
+
logger.info(`[Feishu] merge_forward: ${items.length} sub-messages`);
|
|
986
|
+
const projectPath = this.projectPathProvider
|
|
987
|
+
? await this.projectPathProvider(chatId)
|
|
988
|
+
: process.cwd();
|
|
989
|
+
const textParts = [];
|
|
990
|
+
const images = [];
|
|
991
|
+
const MAX_IMAGES = 10;
|
|
992
|
+
textParts.push('以下是用户转发的合并消息:\n---');
|
|
993
|
+
for (const item of items) {
|
|
994
|
+
const msgType = item.msg_type;
|
|
995
|
+
const content = item.body?.content;
|
|
996
|
+
if (!content)
|
|
997
|
+
continue;
|
|
998
|
+
try {
|
|
999
|
+
if (msgType === 'text') {
|
|
1000
|
+
const parsed = JSON.parse(content);
|
|
1001
|
+
textParts.push(parsed.text || '');
|
|
1002
|
+
}
|
|
1003
|
+
else if (msgType === 'post') {
|
|
1004
|
+
const parsed = JSON.parse(content);
|
|
1005
|
+
let text = '';
|
|
1006
|
+
const postContent = parsed.zh_cn?.content || parsed.en_us?.content || parsed.content;
|
|
1007
|
+
if (postContent) {
|
|
1008
|
+
for (const line of postContent) {
|
|
1009
|
+
for (const elem of line) {
|
|
1010
|
+
if (elem.tag === 'img' && elem.image_key && item.message_id && images.length < MAX_IMAGES) {
|
|
1011
|
+
const imageData = await this.downloadAndSaveImage(elem.image_key, chatId, item.message_id, projectPath);
|
|
1012
|
+
if (imageData)
|
|
1013
|
+
images.push(imageData);
|
|
1014
|
+
}
|
|
1015
|
+
else if (elem.text) {
|
|
1016
|
+
text += elem.text;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
text += '\n';
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const title = parsed.zh_cn?.title || parsed.en_us?.title || parsed.title;
|
|
1023
|
+
textParts.push(title ? `${title}\n${text.trim()}` : text.trim());
|
|
1024
|
+
}
|
|
1025
|
+
else if (msgType === 'image' && item.message_id) {
|
|
1026
|
+
const parsed = JSON.parse(content);
|
|
1027
|
+
if (parsed.image_key && images.length < MAX_IMAGES) {
|
|
1028
|
+
const imageData = await this.downloadAndSaveImage(parsed.image_key, chatId, item.message_id, projectPath);
|
|
1029
|
+
if (imageData) {
|
|
1030
|
+
images.push(imageData);
|
|
1031
|
+
textParts.push('[图片]');
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
else if (msgType === 'file') {
|
|
1036
|
+
const parsed = JSON.parse(content);
|
|
1037
|
+
textParts.push(`[文件: ${parsed.file_name || 'unknown'}]`);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
textParts.push(`[${msgType}]`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch (parseErr) {
|
|
1044
|
+
logger.debug('[Feishu] merge_forward: failed to parse sub-message:', parseErr);
|
|
1045
|
+
textParts.push(`[${msgType}: 解析失败]`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
textParts.push('---');
|
|
1049
|
+
return { text: textParts.join('\n'), images };
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
logger.error('[Feishu] Failed to extract merge_forward content:', error);
|
|
1053
|
+
return empty;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async downloadFile(fileKey, fileName, messageId, projectPath) {
|
|
1057
|
+
if (!this.client)
|
|
1058
|
+
return null;
|
|
1059
|
+
try {
|
|
1060
|
+
logger.debug('[Feishu] Downloading file, file_key:', fileKey, 'file_name:', fileName);
|
|
1061
|
+
const response = await this.client.im.messageResource.get({
|
|
1062
|
+
path: {
|
|
1063
|
+
message_id: messageId,
|
|
1064
|
+
file_key: fileKey
|
|
1065
|
+
},
|
|
1066
|
+
params: {
|
|
1067
|
+
type: 'file'
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
if (response && typeof response.getReadableStream === 'function') {
|
|
1071
|
+
const stream = response.getReadableStream();
|
|
1072
|
+
const chunks = [];
|
|
1073
|
+
for await (const chunk of stream) {
|
|
1074
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1075
|
+
}
|
|
1076
|
+
const buffer = Buffer.concat(chunks);
|
|
1077
|
+
if (buffer.length === 0) {
|
|
1078
|
+
logger.warn('[Feishu] Empty response from file download');
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
|
|
1082
|
+
return filePath;
|
|
1083
|
+
}
|
|
1084
|
+
logger.error('[Feishu] File download failed: no valid method');
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
logger.error('[Feishu] Failed to download file:', error);
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** 跟踪 pending 交互卡片,等待后续作废 */
|
|
1093
|
+
trackPendingCard(chatId, messageId) {
|
|
1094
|
+
let set = this.pendingCardsByChat.get(chatId);
|
|
1095
|
+
if (!set) {
|
|
1096
|
+
set = new Set();
|
|
1097
|
+
this.pendingCardsByChat.set(chatId, set);
|
|
1098
|
+
}
|
|
1099
|
+
set.add(messageId);
|
|
1100
|
+
}
|
|
1101
|
+
/** 卡片已 resolved(用户点击了按钮,飞书已用回调返回值替换卡片),从作废集合移除 */
|
|
1102
|
+
untrackPendingCard(chatId, messageId) {
|
|
1103
|
+
const set = this.pendingCardsByChat.get(chatId);
|
|
1104
|
+
if (!set)
|
|
1105
|
+
return;
|
|
1106
|
+
set.delete(messageId);
|
|
1107
|
+
if (set.size === 0)
|
|
1108
|
+
this.pendingCardsByChat.delete(chatId);
|
|
1109
|
+
}
|
|
1110
|
+
rememberInvalidatedInteraction(interactionId, reason) {
|
|
1111
|
+
this.invalidatedInteractions.set(interactionId, reason);
|
|
1112
|
+
const timer = setTimeout(() => this.invalidatedInteractions.delete(interactionId), 24 * 60 * 60 * 1000);
|
|
1113
|
+
timer.unref?.();
|
|
1114
|
+
}
|
|
1115
|
+
invalidatedCard(reason) {
|
|
1116
|
+
const detail = reason === 'expired'
|
|
1117
|
+
? '审批已超时,无法继续操作。'
|
|
1118
|
+
: reason === 'superseded'
|
|
1119
|
+
? '已有新的交互请求,本卡片已失效。'
|
|
1120
|
+
: '审批身份或权限已变更,此卡片无法继续操作。';
|
|
1121
|
+
return {
|
|
1122
|
+
schema: '2.0',
|
|
1123
|
+
config: { update_multi: true, streaming_mode: false },
|
|
1124
|
+
header: {
|
|
1125
|
+
template: 'grey',
|
|
1126
|
+
title: { tag: 'plain_text', content: '已失效' },
|
|
1127
|
+
},
|
|
1128
|
+
body: { elements: [{ tag: 'markdown', content: detail }] },
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
invalidatedCardResponse(reason) {
|
|
1132
|
+
return {
|
|
1133
|
+
toast: { type: 'warning', content: '⚠️ 卡片已失效,请重新发起' },
|
|
1134
|
+
card: { type: 'raw', data: this.invalidatedCard(reason) },
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
async patchInvalidatedCard(messageId, reason) {
|
|
1138
|
+
if (!this.client)
|
|
1139
|
+
return;
|
|
1140
|
+
try {
|
|
1141
|
+
await this.client.im.message.patch({
|
|
1142
|
+
path: { message_id: messageId },
|
|
1143
|
+
data: { content: JSON.stringify(this.invalidatedCard(reason)) },
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
catch (err) {
|
|
1147
|
+
const detail = err?.response?.data ?? err?.message ?? err;
|
|
1148
|
+
logger.debug(`[Feishu] Patch invalidated card failed (msgId=${messageId}): ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Best-effort visual invalidation for a pending interaction. The router's
|
|
1153
|
+
* authorization check remains the source of truth if the platform delivers
|
|
1154
|
+
* a click concurrently with this patch.
|
|
1155
|
+
*/
|
|
1156
|
+
async invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
1157
|
+
this.rememberInvalidatedInteraction(interactionId, reason);
|
|
1158
|
+
const entry = this.cardMetaStore.get(interactionId);
|
|
1159
|
+
if (!entry)
|
|
1160
|
+
return;
|
|
1161
|
+
entry.invalidated = true;
|
|
1162
|
+
entry.invalidatedReason = reason;
|
|
1163
|
+
this.untrackPendingCard(entry.chatId, entry.messageId);
|
|
1164
|
+
await this.patchInvalidatedCard(entry.messageId, reason);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* 作废 chatId 下所有未被点击的旧卡片:PATCH 为"已过期"灰色卡片。
|
|
1168
|
+
* 卡片需在 config 中声明 update_multi: true 才能被 PATCH。
|
|
1169
|
+
*/
|
|
1170
|
+
async invalidatePendingCards(chatId) {
|
|
1171
|
+
const set = this.pendingCardsByChat.get(chatId);
|
|
1172
|
+
if (!set || set.size === 0)
|
|
1173
|
+
return;
|
|
1174
|
+
const ids = Array.from(set);
|
|
1175
|
+
this.pendingCardsByChat.delete(chatId);
|
|
1176
|
+
await Promise.all(ids.map(async (msgId) => {
|
|
1177
|
+
const entry = this.cardMetaStore.findByMessageId(msgId);
|
|
1178
|
+
if (entry) {
|
|
1179
|
+
if (this.interactionInvalidationCallback) {
|
|
1180
|
+
try {
|
|
1181
|
+
await this.interactionInvalidationCallback(entry.interaction.id, 'superseded');
|
|
1182
|
+
}
|
|
1183
|
+
catch (err) {
|
|
1184
|
+
logger.error(`[Feishu] Failed to cancel superseded interaction ${entry.interaction.id}:`, err);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (!entry.invalidated)
|
|
1188
|
+
await this.invalidateInteraction(entry.interaction.id, 'superseded');
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
await this.patchInvalidatedCard(msgId, 'superseded');
|
|
1192
|
+
}
|
|
1193
|
+
}));
|
|
1194
|
+
}
|
|
1195
|
+
async withInteractionSendLock(chatId, run) {
|
|
1196
|
+
const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
|
|
1197
|
+
let release;
|
|
1198
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
1199
|
+
this.interactionSendTails.set(chatId, current);
|
|
1200
|
+
await previous.catch(() => undefined);
|
|
1201
|
+
try {
|
|
1202
|
+
return await run();
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
release();
|
|
1206
|
+
if (this.interactionSendTails.get(chatId) === current) {
|
|
1207
|
+
this.interactionSendTails.delete(chatId);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
async sendInteraction(chatId, interaction, options) {
|
|
1212
|
+
return this.withInteractionSendLock(chatId, () => this.sendInteractionLocked(chatId, interaction, options));
|
|
1213
|
+
}
|
|
1214
|
+
async sendInteractionLocked(chatId, interaction, options) {
|
|
1215
|
+
if (!this.client)
|
|
1216
|
+
return false;
|
|
1217
|
+
// 统一路径:schema 2.0 内联发送(im.message),不走 cardkit 实体
|
|
1218
|
+
const card = buildCardV2(interaction);
|
|
1219
|
+
await this.invalidatePendingCards(chatId);
|
|
1220
|
+
try {
|
|
1221
|
+
let messageId;
|
|
1222
|
+
if (options?.replyToMessageId) {
|
|
1223
|
+
const replyData = {
|
|
1224
|
+
msg_type: 'interactive',
|
|
1225
|
+
content: JSON.stringify(card),
|
|
1226
|
+
};
|
|
1227
|
+
if (options.replyInThread)
|
|
1228
|
+
replyData.reply_in_thread = true;
|
|
1229
|
+
const res = await this.client.im.message.reply({
|
|
1230
|
+
path: { message_id: options.replyToMessageId },
|
|
1231
|
+
data: replyData,
|
|
1232
|
+
});
|
|
1233
|
+
messageId = res?.data?.message_id;
|
|
1234
|
+
}
|
|
1235
|
+
else {
|
|
1236
|
+
const res = await this.client.im.message.create({
|
|
1237
|
+
params: { receive_id_type: chatId.startsWith('ou_') ? 'open_id' : chatId.startsWith('on_') ? 'union_id' : 'chat_id' },
|
|
1238
|
+
data: {
|
|
1239
|
+
receive_id: chatId,
|
|
1240
|
+
msg_type: 'interactive',
|
|
1241
|
+
content: JSON.stringify(card),
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
messageId = res?.data?.message_id;
|
|
1245
|
+
}
|
|
1246
|
+
logger.info(`[Feishu] Sent interaction card: ${interaction.id}, messageId=${messageId}`);
|
|
1247
|
+
if (messageId) {
|
|
1248
|
+
this.trackPendingCard(chatId, messageId);
|
|
1249
|
+
const invalidationReason = this.invalidatedInteractions.get(interaction.id);
|
|
1250
|
+
this.cardMetaStore.set(interaction.id, {
|
|
1251
|
+
interaction, chatId, messageId, resolved: false,
|
|
1252
|
+
invalidated: !!invalidationReason,
|
|
1253
|
+
invalidatedReason: invalidationReason,
|
|
1254
|
+
});
|
|
1255
|
+
if (invalidationReason) {
|
|
1256
|
+
this.untrackPendingCard(chatId, messageId);
|
|
1257
|
+
await this.patchInvalidatedCard(messageId, invalidationReason);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return messageId || false;
|
|
1261
|
+
}
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
const respData = error?.response?.data;
|
|
1264
|
+
const detail = respData
|
|
1265
|
+
? JSON.stringify(respData)
|
|
1266
|
+
: error?.message && error.message !== String(error)
|
|
1267
|
+
? error.message
|
|
1268
|
+
: JSON.stringify(error, Object.getOwnPropertyNames(error));
|
|
1269
|
+
logger.error(`[Feishu] Failed to send interaction card (id=${interaction.id}, replyTo=${options?.replyToMessageId || 'none'}): ${detail}`);
|
|
1270
|
+
logger.debug(`[Feishu] Card payload for ${interaction.id}: ${JSON.stringify(card)}`);
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// messageId → Pin reaction create promise(等待 reaction_id 落地,供 promoteAck 删除)
|
|
1275
|
+
pinReactions = new Map();
|
|
1276
|
+
/** 收到消息时添加 Pin 表情(表示"已收到,排队中") */
|
|
1277
|
+
addPinReaction(messageId) {
|
|
1278
|
+
if (!this.client)
|
|
1279
|
+
return;
|
|
1280
|
+
const p = this.client.im.messageReaction.create({
|
|
1281
|
+
path: { message_id: messageId },
|
|
1282
|
+
data: { reaction_type: { emoji_type: 'Pin' } },
|
|
1283
|
+
}).then((res) => res?.data?.reaction_id)
|
|
1284
|
+
.catch(() => undefined);
|
|
1285
|
+
this.pinReactions.set(messageId, p);
|
|
1286
|
+
}
|
|
1287
|
+
/** Runner 开始执行时:添加 CheckMark,然后异步移除 Pin(视觉无闪烁) */
|
|
1288
|
+
async promoteAckReaction(messageId) {
|
|
1289
|
+
if (!this.client)
|
|
1290
|
+
return;
|
|
1291
|
+
// 先加 CheckMark,再删 Pin——用户看到的是 Pin→Pin+CheckMark→CheckMark,无空窗
|
|
1292
|
+
this.client.im.messageReaction.create({
|
|
1293
|
+
path: { message_id: messageId },
|
|
1294
|
+
data: { reaction_type: { emoji_type: 'CheckMark' } },
|
|
1295
|
+
}).catch(() => { });
|
|
1296
|
+
const pending = this.pinReactions.get(messageId);
|
|
1297
|
+
this.pinReactions.delete(messageId);
|
|
1298
|
+
const reactionId = await pending;
|
|
1299
|
+
if (reactionId) {
|
|
1300
|
+
this.client.im.messageReaction.delete({
|
|
1301
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
1302
|
+
}).catch(() => { });
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
export class CardMetaStore {
|
|
1307
|
+
map = new Map();
|
|
1308
|
+
set(id, entry) {
|
|
1309
|
+
this.map.set(id, entry);
|
|
1310
|
+
}
|
|
1311
|
+
get(id) {
|
|
1312
|
+
return this.map.get(id);
|
|
1313
|
+
}
|
|
1314
|
+
findByMessageId(messageId) {
|
|
1315
|
+
for (const entry of this.map.values()) {
|
|
1316
|
+
if (entry.messageId === messageId)
|
|
1317
|
+
return entry;
|
|
1318
|
+
}
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
markResolved(id) {
|
|
1322
|
+
const entry = this.map.get(id);
|
|
1323
|
+
if (entry)
|
|
1324
|
+
entry.resolved = true;
|
|
1325
|
+
}
|
|
1326
|
+
markInputShown(id) {
|
|
1327
|
+
const entry = this.map.get(id);
|
|
1328
|
+
if (entry)
|
|
1329
|
+
entry.inputShown = true;
|
|
1330
|
+
}
|
|
1331
|
+
cleanup(id) {
|
|
1332
|
+
this.map.delete(id);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
// ── 统一卡片构建器(schema 2.0 内联)──
|
|
1336
|
+
/**
|
|
1337
|
+
* 唯一卡片构建器。输入协议层 InteractionRequest,输出 schema 2.0 内联卡片 JSON。
|
|
1338
|
+
* - command-card: 按钮 value 带 { _id, _command, _initiator }
|
|
1339
|
+
* - action: 按钮 value 带 { _id, _action, _initiator };checkers → form+checker;
|
|
1340
|
+
* allowCustomInput → 「手动输入」按钮(form 容器外)
|
|
1341
|
+
* @param opts.showInput 展开自定义输入框(用于 _show_input 回调返回整卡)
|
|
1342
|
+
*/
|
|
1343
|
+
export function buildCardV2(interaction, opts) {
|
|
1344
|
+
const { kind } = interaction;
|
|
1345
|
+
const id = interaction.id;
|
|
1346
|
+
const initiatorId = interaction.initiatorId;
|
|
1347
|
+
const title = kind.title;
|
|
1348
|
+
const body = kind.body;
|
|
1349
|
+
const formElements = [];
|
|
1350
|
+
// Body markdown
|
|
1351
|
+
if (body) {
|
|
1352
|
+
formElements.push({ tag: 'markdown', content: body, element_id: 'body_md' });
|
|
1353
|
+
}
|
|
1354
|
+
// Checkers (action only)
|
|
1355
|
+
if (kind.kind === 'action' && kind.checkers?.length) {
|
|
1356
|
+
kind.checkers.forEach((chk, idx) => {
|
|
1357
|
+
const text = chk.description ? `${chk.label} — ${chk.description}` : chk.label;
|
|
1358
|
+
formElements.push({
|
|
1359
|
+
tag: 'checker',
|
|
1360
|
+
name: `opt_${idx}`,
|
|
1361
|
+
checked: false,
|
|
1362
|
+
text: { tag: 'plain_text', content: text },
|
|
1363
|
+
element_id: `chk_${idx}`,
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
formElements.push({ tag: 'hr', element_id: 'hr_btns' });
|
|
1367
|
+
}
|
|
1368
|
+
// Buttons
|
|
1369
|
+
const buttons = kind.buttons;
|
|
1370
|
+
buttons.forEach((btn, idx) => {
|
|
1371
|
+
const buttonEl = {
|
|
1372
|
+
tag: 'button',
|
|
1373
|
+
text: { tag: 'plain_text', content: btn.label },
|
|
1374
|
+
type: btn.style === 'danger' ? 'danger' : btn.style === 'primary' ? 'primary' : 'default',
|
|
1375
|
+
action_type: 'form_submit',
|
|
1376
|
+
name: `btn_${idx}`,
|
|
1377
|
+
element_id: `btn_${idx}`,
|
|
1378
|
+
value: kind.kind === 'command-card'
|
|
1379
|
+
? { _id: id, _command: btn.command, _initiator: initiatorId }
|
|
1380
|
+
: { _id: id, _action: btn.key, _initiator: initiatorId },
|
|
1381
|
+
};
|
|
1382
|
+
if (btn.disabled)
|
|
1383
|
+
buttonEl.disabled = true;
|
|
1384
|
+
if (btn.confirm) {
|
|
1385
|
+
buttonEl.confirm = {
|
|
1386
|
+
title: { tag: 'plain_text', content: btn.confirm.title },
|
|
1387
|
+
text: { tag: 'plain_text', content: btn.confirm.body },
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
formElements.push(buttonEl);
|
|
1391
|
+
});
|
|
1392
|
+
// Custom input (action only)
|
|
1393
|
+
const allowCustomInput = kind.kind === 'action' && kind.allowCustomInput;
|
|
1394
|
+
const outerElements = [];
|
|
1395
|
+
if (allowCustomInput && opts?.showInput) {
|
|
1396
|
+
// 展开态:输入框 + 提交按钮内联进 form(整卡作为回调返回值替换,规避 200810)
|
|
1397
|
+
formElements.push({ tag: 'hr', element_id: 'hr_input' }, {
|
|
1398
|
+
tag: 'input',
|
|
1399
|
+
name: 'custom_text',
|
|
1400
|
+
element_id: 'input_custom',
|
|
1401
|
+
placeholder: { tag: 'plain_text', content: '输入自定义回复...' },
|
|
1402
|
+
}, {
|
|
1403
|
+
tag: 'button',
|
|
1404
|
+
text: { tag: 'plain_text', content: '✅ 提交输入' },
|
|
1405
|
+
type: 'primary',
|
|
1406
|
+
action_type: 'form_submit',
|
|
1407
|
+
name: 'btn_submit_custom',
|
|
1408
|
+
element_id: 'btn_submit_custom',
|
|
1409
|
+
value: { _id: id, _action: '_custom_input', _initiator: initiatorId },
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
else if (allowCustomInput) {
|
|
1413
|
+
// 初始态:「手动输入」按钮放在 form 容器外(form 内按钮须 form_submit,11310)
|
|
1414
|
+
outerElements.push({
|
|
1415
|
+
tag: 'button',
|
|
1416
|
+
text: { tag: 'plain_text', content: '✏️ 手动输入' },
|
|
1417
|
+
type: 'default',
|
|
1418
|
+
element_id: 'btn_show_input',
|
|
1419
|
+
value: { _id: id, _action: '_show_input', _initiator: initiatorId },
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
return {
|
|
1423
|
+
schema: '2.0',
|
|
1424
|
+
config: { update_multi: true, streaming_mode: false },
|
|
1425
|
+
header: { title: { tag: 'plain_text', content: title }, template: 'blue' },
|
|
1426
|
+
body: {
|
|
1427
|
+
elements: [
|
|
1428
|
+
{
|
|
1429
|
+
tag: 'form',
|
|
1430
|
+
name: 'action_form',
|
|
1431
|
+
element_id: 'action_form',
|
|
1432
|
+
elements: formElements,
|
|
1433
|
+
},
|
|
1434
|
+
...outerElements,
|
|
1435
|
+
],
|
|
1436
|
+
},
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* 唯一 resolved 终态构建器(按钮禁用 + 结果展示 + checker 勾选汇总)。
|
|
1441
|
+
* 作为飞书卡片回调的返回值下发,替换原卡片内容。
|
|
1442
|
+
*/
|
|
1443
|
+
export function buildResolvedV2(interaction, response) {
|
|
1444
|
+
const action = response.action;
|
|
1445
|
+
const kind = interaction.kind;
|
|
1446
|
+
const labelMap = {
|
|
1447
|
+
'allow': '✅ 已允许',
|
|
1448
|
+
'always': '⏱ 已授权同操作 30 分钟',
|
|
1449
|
+
'deny': '❌ 已拒绝',
|
|
1450
|
+
'cancel': '取消',
|
|
1451
|
+
};
|
|
1452
|
+
const statusText = labelMap[action] || (/^\p{Emoji}/u.test(action) ? action : `✅ ${action}`);
|
|
1453
|
+
const headerTemplate = action === 'deny' ? 'red' : 'green';
|
|
1454
|
+
const headerTitle = `${kind.title} — ${statusText}`;
|
|
1455
|
+
const bodyElements = [];
|
|
1456
|
+
if (kind.body) {
|
|
1457
|
+
bodyElements.push({ tag: 'markdown', content: kind.body });
|
|
1458
|
+
}
|
|
1459
|
+
// Checker summary from interaction.kind.checkers + response.values
|
|
1460
|
+
if (kind.kind === 'action' && kind.checkers?.length && response.values) {
|
|
1461
|
+
const lines = kind.checkers.map((chk, idx) => {
|
|
1462
|
+
const checked = !!response.values[`opt_${idx}`];
|
|
1463
|
+
return `${checked ? '☑' : '☐'} ${chk.label}`;
|
|
1464
|
+
});
|
|
1465
|
+
bodyElements.push({ tag: 'markdown', content: lines.join('\n') });
|
|
1466
|
+
}
|
|
1467
|
+
// CommandCard: 显示原有按钮列表(保留上下文)
|
|
1468
|
+
if (kind.kind === 'command-card' && kind.buttons?.length) {
|
|
1469
|
+
const lines = kind.buttons.map(btn => {
|
|
1470
|
+
const prefix = btn.command === action ? '✓' : '•';
|
|
1471
|
+
const cleanLabel = btn.label.replace(/^✓\s*/, '');
|
|
1472
|
+
return `${prefix} ${cleanLabel}`;
|
|
1473
|
+
});
|
|
1474
|
+
bodyElements.push({ tag: 'markdown', content: lines.join('\n') });
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
toast: { type: 'success', content: statusText },
|
|
1478
|
+
card: {
|
|
1479
|
+
type: 'raw',
|
|
1480
|
+
data: {
|
|
1481
|
+
schema: '2.0',
|
|
1482
|
+
config: { update_multi: true, streaming_mode: false },
|
|
1483
|
+
header: {
|
|
1484
|
+
template: headerTemplate,
|
|
1485
|
+
title: { tag: 'plain_text', content: headerTitle },
|
|
1486
|
+
},
|
|
1487
|
+
body: { elements: bodyElements },
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* 卡片回调的纯路由决策。不产生副作用(除 store.markInputShown),返回决策对象供
|
|
1494
|
+
* WS 回调执行器消费。元数据从 CardMetaStore 反查,value 只读 _id / _action / _command。
|
|
1495
|
+
*/
|
|
1496
|
+
export function routeCardAction(input, store) {
|
|
1497
|
+
const { value, formValues, operatorId } = input;
|
|
1498
|
+
const id = value._id;
|
|
1499
|
+
if (!id)
|
|
1500
|
+
return { kind: 'ignore' };
|
|
1501
|
+
const entry = store.get(id);
|
|
1502
|
+
const interaction = entry?.interaction;
|
|
1503
|
+
const initiatorId = interaction?.initiatorId ?? value._initiator;
|
|
1504
|
+
// initiator 校验。缺失渠道认证 operator 也必须拒绝;否则 command-card
|
|
1505
|
+
// 会继续进入 MessageBridge,并可能把会话 ID误当成操作者身份。
|
|
1506
|
+
if (initiatorId && (!operatorId || operatorId !== initiatorId)) {
|
|
1507
|
+
return { kind: 'reject' };
|
|
1508
|
+
}
|
|
1509
|
+
// A card may remain in a user's client after its router handler was
|
|
1510
|
+
// cancelled. Never turn a missing/stale metadata entry into a response:
|
|
1511
|
+
// the platform callback must receive an explicit expired decision instead.
|
|
1512
|
+
if (!interaction || entry?.invalidated)
|
|
1513
|
+
return { kind: 'expired' };
|
|
1514
|
+
// command-card:按钮直接触发命令
|
|
1515
|
+
if (value._command) {
|
|
1516
|
+
const synthetic = {
|
|
1517
|
+
type: 'interaction.response', id, action: value._command, operatorId,
|
|
1518
|
+
};
|
|
1519
|
+
const card = interaction
|
|
1520
|
+
? buildResolvedV2(interaction, synthetic)
|
|
1521
|
+
: buildResolvedV2({ type: 'interaction', id, channelId: '', sessionId: '',
|
|
1522
|
+
kind: { kind: 'command-card', title: '操作', buttons: [] } }, synthetic);
|
|
1523
|
+
return { kind: 'command', command: value._command, card };
|
|
1524
|
+
}
|
|
1525
|
+
// _show_input:点击「手动输入」→ 整卡替换为带输入框的版本(规避 200810)
|
|
1526
|
+
if (value._action === '_show_input') {
|
|
1527
|
+
if (!interaction)
|
|
1528
|
+
return { kind: 'expired' };
|
|
1529
|
+
store.markInputShown(id);
|
|
1530
|
+
return {
|
|
1531
|
+
kind: 'show-input',
|
|
1532
|
+
card: { toast: { type: 'info', content: '请在下方输入' },
|
|
1533
|
+
card: { type: 'raw', data: buildCardV2(interaction, { showInput: true }) } },
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
// 普通提交 / 自定义输入提交
|
|
1537
|
+
const values = { ...formValues, ...value };
|
|
1538
|
+
for (const k of Object.keys(values)) {
|
|
1539
|
+
if (k.startsWith('_'))
|
|
1540
|
+
delete values[k];
|
|
1541
|
+
}
|
|
1542
|
+
const response = {
|
|
1543
|
+
type: 'interaction.response', id, action: value._action || 'submit', values, operatorId,
|
|
1544
|
+
};
|
|
1545
|
+
// _custom_input:把用户输入追加到 resolved 卡片正文
|
|
1546
|
+
let resolvedInteraction = interaction;
|
|
1547
|
+
if (interaction && response.action === '_custom_input' && formValues.custom_text) {
|
|
1548
|
+
const newBody = [interaction.kind.body, `**输入内容:** ${formValues.custom_text}`]
|
|
1549
|
+
.filter(Boolean).join('\n\n');
|
|
1550
|
+
resolvedInteraction = { ...interaction, kind: { ...interaction.kind, body: newBody } };
|
|
1551
|
+
}
|
|
1552
|
+
const card = resolvedInteraction
|
|
1553
|
+
? buildResolvedV2(resolvedInteraction, response)
|
|
1554
|
+
: buildResolvedV2({ type: 'interaction', id, channelId: '', sessionId: '',
|
|
1555
|
+
kind: { kind: 'action', title: '操作', buttons: [] } }, response);
|
|
1556
|
+
return { kind: 'respond', response, card };
|
|
1557
|
+
}
|
|
1558
|
+
function displayWidth(str) {
|
|
1559
|
+
let width = 0;
|
|
1560
|
+
for (const ch of str) {
|
|
1561
|
+
const code = ch.codePointAt(0);
|
|
1562
|
+
if ((code >= 0x4E00 && code <= 0x9FFF) ||
|
|
1563
|
+
(code >= 0x3400 && code <= 0x4DBF) ||
|
|
1564
|
+
(code >= 0xF900 && code <= 0xFAFF) ||
|
|
1565
|
+
(code >= 0xFF01 && code <= 0xFF60) ||
|
|
1566
|
+
(code >= 0x3000 && code <= 0x303F)) {
|
|
1567
|
+
width += 2;
|
|
1568
|
+
}
|
|
1569
|
+
else {
|
|
1570
|
+
width += 1;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return width;
|
|
1574
|
+
}
|
|
1575
|
+
function padToWidth(str, targetWidth) {
|
|
1576
|
+
const current = displayWidth(str);
|
|
1577
|
+
const padding = Math.max(0, targetWidth - current);
|
|
1578
|
+
return str + ' '.repeat(padding);
|
|
1579
|
+
}
|
|
1580
|
+
function convertTablesToText(text) {
|
|
1581
|
+
const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm;
|
|
1582
|
+
return text.replace(tableRegex, (_match, headerLine, _sep, bodyBlock) => {
|
|
1583
|
+
const parseRow = (line) => line.split('|').slice(1, -1).map((c) => c.trim());
|
|
1584
|
+
const headers = parseRow(headerLine);
|
|
1585
|
+
const rows = bodyBlock.trim().split('\n').map(parseRow);
|
|
1586
|
+
const colWidths = headers.map((h, i) => {
|
|
1587
|
+
const cellWidths = rows.map(r => displayWidth(r[i] || ''));
|
|
1588
|
+
return Math.max(displayWidth(h), ...cellWidths);
|
|
1589
|
+
});
|
|
1590
|
+
const headerStr = headers.map((h, i) => padToWidth(h, colWidths[i])).join(' ');
|
|
1591
|
+
const sepStr = colWidths.map(w => '-'.repeat(w)).join(' ');
|
|
1592
|
+
const rowStrs = rows.map(r => headers.map((_, i) => padToWidth(r[i] || '', colWidths[i])).join(' '));
|
|
1593
|
+
return '```\n' + [headerStr, sepStr, ...rowStrs].join('\n') + '\n```';
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
function isLocalMarkdownLinkTarget(target) {
|
|
1597
|
+
if (/^[A-Za-z]:[\\/]/.test(target))
|
|
1598
|
+
return true;
|
|
1599
|
+
return !/^[a-z][a-z0-9+.-]*:/i.test(target)
|
|
1600
|
+
&& !target.startsWith('#')
|
|
1601
|
+
&& (target.startsWith('/') ||
|
|
1602
|
+
target.startsWith('./') ||
|
|
1603
|
+
target.startsWith('../'));
|
|
1604
|
+
}
|
|
1605
|
+
function exposeLocalMarkdownLinksInPlainText(text) {
|
|
1606
|
+
return text.replace(/(?<!!)\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, _label, target) => {
|
|
1607
|
+
return isLocalMarkdownLinkTarget(target) ? `[${target}](${target})` : match;
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
function exposeLocalMarkdownLinkTargetsForFeishu(markdown) {
|
|
1611
|
+
const parts = markdown.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
|
|
1612
|
+
return parts.map(part => part.startsWith('`') ? part : exposeLocalMarkdownLinksInPlainText(part)).join('');
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* 按段落边界拆分超长消息
|
|
1616
|
+
* 优先在 \n\n 处分割,其次 \n,最后强制截断
|
|
1617
|
+
*/
|
|
1618
|
+
function splitLongMessage(content, maxLength) {
|
|
1619
|
+
const parts = [];
|
|
1620
|
+
let remaining = content;
|
|
1621
|
+
while (remaining.length > maxLength) {
|
|
1622
|
+
let splitAt = remaining.lastIndexOf('\n\n', maxLength);
|
|
1623
|
+
if (splitAt <= 0)
|
|
1624
|
+
splitAt = remaining.lastIndexOf('\n', maxLength);
|
|
1625
|
+
if (splitAt <= 0)
|
|
1626
|
+
splitAt = maxLength;
|
|
1627
|
+
parts.push(remaining.slice(0, splitAt).trimEnd());
|
|
1628
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
1629
|
+
}
|
|
1630
|
+
if (remaining)
|
|
1631
|
+
parts.push(remaining);
|
|
1632
|
+
return parts;
|
|
1633
|
+
}
|
|
1634
|
+
export function markdownToFeishuPost(markdown, defaultTitle) {
|
|
1635
|
+
const match = markdown.match(/^# (.+)$/m);
|
|
1636
|
+
const title = match?.[1] ?? defaultTitle ?? '';
|
|
1637
|
+
let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
|
|
1638
|
+
body = convertTablesToText(body);
|
|
1639
|
+
return {
|
|
1640
|
+
zh_cn: {
|
|
1641
|
+
title,
|
|
1642
|
+
content: [[{ tag: 'md', text: body.trim() }]]
|
|
1643
|
+
}
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* 将 Markdown 内容转为飞书消息卡片格式(interactive msg_type)
|
|
1648
|
+
* 飞书卡片的 markdown 组件支持完整 Markdown 渲染(代码块、表格、列表等)
|
|
1649
|
+
* 当前消息类型决策统一走 post + md tag,此函数为 interactive 卡片场景预留。
|
|
1650
|
+
*/
|
|
1651
|
+
export function markdownToFeishuCard(markdown, defaultTitle) {
|
|
1652
|
+
const match = markdown.match(/^# (.+)$/m);
|
|
1653
|
+
const title = match?.[1] ?? defaultTitle;
|
|
1654
|
+
let body = match ? markdown.replace(/^# .+\n?/, '') : markdown;
|
|
1655
|
+
body = convertTablesToText(body).trim();
|
|
1656
|
+
const card = {
|
|
1657
|
+
config: { wide_screen_mode: true },
|
|
1658
|
+
elements: [
|
|
1659
|
+
{ tag: 'markdown', content: body }
|
|
1660
|
+
]
|
|
1661
|
+
};
|
|
1662
|
+
if (title) {
|
|
1663
|
+
card.header = {
|
|
1664
|
+
title: { tag: 'plain_text', content: title }
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
return card;
|
|
1668
|
+
}
|
|
1669
|
+
export function hasMarkdownSyntax(text) {
|
|
1670
|
+
const markdownPatterns = [
|
|
1671
|
+
/^#{1,6}\s/m, /\*\*.*?\*\*/, /\*.*?\*/, /__.*?__/, /_.*?_/, /~~.*?~~/,
|
|
1672
|
+
/`.*?`/, /```[\s\S]*?```/, /\[.*?\]\(.*?\)/, /^[\s]*[-*+]\s/m,
|
|
1673
|
+
/^[\s]*\d+\.\s/m, /^\|.+\|$/m
|
|
1674
|
+
];
|
|
1675
|
+
return markdownPatterns.some(pattern => pattern.test(text));
|
|
1676
|
+
}
|
|
1677
|
+
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
1678
|
+
import { resolvePaths } from '../paths.js';
|
|
1679
|
+
export class FeishuChannelPlugin {
|
|
1680
|
+
name = 'feishu';
|
|
1681
|
+
async createInstance(inst, ctx) {
|
|
1682
|
+
if (inst.enabled === false || !inst.appId || !inst.appSecret)
|
|
1683
|
+
return null;
|
|
1684
|
+
const channel = new FeishuChannel({
|
|
1685
|
+
appId: inst.appId,
|
|
1686
|
+
appSecret: inst.appSecret,
|
|
1687
|
+
sessionsDir: resolvePaths().sessionsDir,
|
|
1688
|
+
seenMsgFile: path.join(resolvePaths().dataDir, `feishu-seen-${inst.name}.jsonl`),
|
|
1689
|
+
}, ctx.agentName, inst.name);
|
|
1690
|
+
const mode = resolveShowActivities(inst);
|
|
1691
|
+
const adapter = {
|
|
1692
|
+
channelName: inst.name,
|
|
1693
|
+
channelKey: inst.name,
|
|
1694
|
+
capabilities: { file: true, image: true, interaction: true, markdown: true, thought: false, status: true, thread: true, authenticatedApproval: true },
|
|
1695
|
+
send: async (envelope, payload) => {
|
|
1696
|
+
const replyCtx = envelope.replyContext;
|
|
1697
|
+
const channelId = envelope.channelId;
|
|
1698
|
+
switch (payload.kind) {
|
|
1699
|
+
case 'result.text':
|
|
1700
|
+
case 'command.result':
|
|
1701
|
+
case 'command.error':
|
|
1702
|
+
case 'system.notice':
|
|
1703
|
+
case 'system.error':
|
|
1704
|
+
case 'result.error': {
|
|
1705
|
+
const sendCtx = { ...(replyCtx ?? {}) };
|
|
1706
|
+
if (payload.kind === 'result.text' && payload.isFinal)
|
|
1707
|
+
sendCtx.title = '✅ 最终回复:';
|
|
1708
|
+
if (replyCtx?.metadata?.onThreadCreated)
|
|
1709
|
+
sendCtx.onThreadCreated = replyCtx.metadata.onThreadCreated;
|
|
1710
|
+
await channel.sendMessage(channelId, payload.text, sendCtx);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
case 'result.file':
|
|
1714
|
+
await channel.sendFile(channelId, payload.filePath, replyCtx);
|
|
1715
|
+
return;
|
|
1716
|
+
case 'result.image':
|
|
1717
|
+
await channel.sendImage(channelId, payload.data, replyCtx);
|
|
1718
|
+
return;
|
|
1719
|
+
case 'activity.batch': {
|
|
1720
|
+
const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
|
|
1721
|
+
const text = formatItemsAsText(filtered);
|
|
1722
|
+
if (text)
|
|
1723
|
+
await channel.sendMessage(channelId, text, replyCtx);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
case 'status.started':
|
|
1727
|
+
case 'status.completed':
|
|
1728
|
+
case 'status.interrupted':
|
|
1729
|
+
case 'status.timeout':
|
|
1730
|
+
case 'status.progress': return;
|
|
1731
|
+
case 'status.requires_action':
|
|
1732
|
+
await channel.sendMessage(channelId, '等待 owner 审批', replyCtx);
|
|
1733
|
+
return;
|
|
1734
|
+
case 'status.error':
|
|
1735
|
+
if (payload.metadata?.message)
|
|
1736
|
+
await channel.sendMessage(channelId, payload.metadata.message, replyCtx);
|
|
1737
|
+
return;
|
|
1738
|
+
case 'interaction': {
|
|
1739
|
+
const sent = await channel.sendInteraction(channelId, payload.interaction, replyCtx);
|
|
1740
|
+
if (!sent)
|
|
1741
|
+
throw new Error('sendInteraction returned false');
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
case 'custom': return;
|
|
1745
|
+
default: logger.warn(`[Feishu] Unhandled payload kind: ${payload.kind}`);
|
|
1746
|
+
}
|
|
1747
|
+
},
|
|
1748
|
+
acknowledge: (messageId) => { channel.addPinReaction(messageId); return Promise.resolve(); },
|
|
1749
|
+
promoteAck: (messageId) => channel.promoteAckReaction(messageId),
|
|
1750
|
+
onInteraction: (callback) => channel.onInteraction(callback),
|
|
1751
|
+
onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
|
|
1752
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
1753
|
+
};
|
|
1754
|
+
const policy = {
|
|
1755
|
+
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1756
|
+
canListProjects: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1757
|
+
canCreateSession: () => true,
|
|
1758
|
+
canDeleteSession: () => true,
|
|
1759
|
+
canImportCliSession: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1760
|
+
messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
|
|
1761
|
+
showMiddleResult: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
1762
|
+
middleOutputMode: (chatType, identity) => middleOutputModePolicy(mode, chatType, identity),
|
|
1763
|
+
showIdleMonitor: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
1764
|
+
accumulateErrors: () => true,
|
|
1765
|
+
};
|
|
1766
|
+
return {
|
|
1767
|
+
channelType: 'feishu', adapter, channel,
|
|
1768
|
+
policy,
|
|
1769
|
+
options: { fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g, supportsImages: true, flushDelay: inst.flushDelay },
|
|
1770
|
+
connect: () => channel.connect(),
|
|
1771
|
+
disconnect: () => channel.disconnect(),
|
|
1772
|
+
onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
|
|
1773
|
+
registerBridge(bridge, channelType) {
|
|
1774
|
+
bridge.register(adapter.channelName, (handler) => channel.onMessage(async ({ channelId: chatId, content, images, peerId, peerName, messageId, mentions, mentionAids, threadId, rootId, chatType, source }) => {
|
|
1775
|
+
await handler({
|
|
1776
|
+
channel: adapter.channelName, channelType, channelId: chatId, content, images,
|
|
1777
|
+
selfAID: ctx.agentName, chatType: chatType || 'private',
|
|
1778
|
+
peerId: peerId || '', peerName, messageId, mentions, mentionAids, threadId,
|
|
1779
|
+
replyContext: threadId ? { threadId, replyToMessageId: rootId ?? threadId, replyInThread: true } : undefined,
|
|
1780
|
+
source,
|
|
1781
|
+
});
|
|
1782
|
+
}), (channelId, text, replyContext) => channel.sendMessage(channelId, text, {
|
|
1783
|
+
replyToMessageId: replyContext?.replyToMessageId,
|
|
1784
|
+
replyInThread: replyContext?.replyInThread,
|
|
1785
|
+
}), adapter, channelType);
|
|
1786
|
+
},
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
}
|