evolcore 0.0.1 → 0.0.3
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 +64 -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/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 +2565 -0
- package/dist/agents/codex-app-server-client.js +448 -0
- package/dist/agents/codex-runner.js +2682 -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 +168 -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 +4164 -0
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/daemon.js +422 -0
- package/dist/channels/dingtalk.js +1479 -0
- package/dist/channels/feishu.js +1865 -0
- package/dist/channels/qqbot.js +409 -0
- package/dist/channels/wechat.js +817 -0
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +1157 -0
- package/dist/cli/agent-command.js +642 -0
- package/dist/cli/agent.js +1059 -0
- package/dist/cli/aun-commands.js +2003 -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/contact.js +71 -0
- package/dist/cli/ctl-command.js +62 -0
- package/dist/cli/daemon-commands.js +2750 -0
- package/dist/cli/fs-command.js +1447 -0
- package/dist/cli/handoff-command.js +302 -0
- package/dist/cli/help.js +35 -0
- package/dist/cli/index.js +374 -0
- package/dist/cli/init-channel.js +1372 -0
- package/dist/cli/init.js +590 -0
- package/dist/cli/link-rules.js +240 -0
- package/dist/cli/model.js +591 -0
- package/dist/cli/net-check.js +723 -0
- package/dist/cli/queue-command.js +150 -0
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +344 -0
- package/dist/cli/restart-monitor.js +480 -0
- package/dist/cli/stats.js +609 -0
- package/dist/cli/task-context.js +80 -0
- package/dist/cli/trigger-command.js +545 -0
- package/dist/cli/version.js +93 -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 +42 -0
- package/dist/config/builtin-roles.js +91 -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 +1120 -0
- package/dist/config/config-operation-service.js +384 -0
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +224 -0
- package/dist/config/contact-operation-service.js +110 -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 +218 -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-ranks.js +18 -0
- package/dist/config/role-schema.js +105 -0
- package/dist/config/role-service.js +156 -0
- package/dist/config/role-store.js +215 -0
- package/dist/config/roles.js +64 -0
- package/dist/config/schema-registry.js +154 -0
- package/dist/config/snapshot.js +598 -0
- package/dist/config-store.js +501 -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 +119 -0
- package/dist/core/auth/operation-authorizer.js +720 -0
- package/dist/core/auth/operation-catalog.js +731 -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 +1733 -0
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +3452 -0
- package/dist/core/command/menu-protocol.js +247 -0
- package/dist/core/command/role-menu.js +1623 -0
- package/dist/core/command/slash-gate.js +148 -0
- package/dist/core/command/slash-handler.js +3066 -0
- package/dist/core/daemon-file-cache.js +222 -0
- package/dist/core/event-bus.js +32 -0
- package/dist/core/event-catalog.js +810 -0
- package/dist/core/evolagent-registry.js +545 -0
- package/dist/core/evolagent.js +342 -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 +659 -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 +874 -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 +1446 -0
- package/dist/core/message/message-utils.js +76 -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 +4020 -0
- package/dist/core/message/response-snapshot.js +83 -0
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +139 -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 +1019 -0
- package/dist/core/permission/unix-socket-policy.js +99 -0
- package/dist/core/protected-paths.js +332 -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 +273 -0
- package/dist/core/session/session-key.js +24 -0
- package/dist/core/session/session-manager.js +1643 -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 +2926 -4
- package/dist/ipc.js +777 -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 +163 -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 +294 -0
- package/dist/trigger/parser.js +595 -0
- package/dist/trigger/patch.js +155 -0
- package/dist/trigger/scheduler.js +1602 -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 +634 -0
- package/dist/types.js +12 -0
- package/dist/utils/aid-bind.js +313 -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 +444 -0
- package/dist/utils/locale.js +21 -0
- package/dist/utils/log-writer.js +270 -0
- package/dist/utils/logger.js +89 -0
- package/dist/utils/markdown-to-plain-text.js +20 -0
- package/dist/utils/media-cache.js +274 -0
- package/dist/utils/model-prices.jsonl +20 -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/restart-safety.js +31 -0
- package/dist/utils/stats.js +410 -0
- package/dist/utils/system-memory.js +62 -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 +66 -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 +68 -0
- package/kits/docs/evolcore/agent.md +77 -0
- package/kits/docs/evolcore/aid.md +52 -0
- package/kits/docs/evolcore/config.md +149 -0
- package/kits/docs/evolcore/contact.md +57 -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 +110 -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 +150 -0
- package/kits/docs/evolcore/model.md +50 -0
- package/kits/docs/evolcore/msg.md +136 -0
- package/kits/docs/evolcore/response.md +75 -0
- package/kits/docs/evolcore/rpc.md +37 -0
- package/kits/docs/evolcore/self-summary.md +29 -0
- package/kits/docs/evolcore/stats.md +83 -0
- package/kits/docs/evolcore/storage.md +50 -0
- package/kits/docs/evolcore/trigger.md +539 -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 +203 -0
- package/kits/eck_message_manifest.json +63 -0
- package/kits/migrations/README-role-config-v4.md +32 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -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 +32 -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/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.1.json +36 -0
- package/kits/schemas/contact-book.schema.2.json +43 -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/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +201 -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 +9 -0
- package/kits/templates/roles/member.json +41 -0
- package/kits/templates/roles/owner.json +9 -0
- package/kits/templates/roles/visitor.json +40 -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 +28 -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,1157 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { logger } from '../utils/logger.js';
|
|
3
|
+
import { requireOptional } from '../utils/npm-ops.js';
|
|
4
|
+
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
5
|
+
import { formatItemsAsText } from '../core/message/items-formatter.js';
|
|
6
|
+
import { initWelcomeManager, sendWelcomeIfNeeded } from '../utils/welcome.js';
|
|
7
|
+
import { bufferToInboundImage, sanitizeFileName, saveToUploads, validateUrl } from '../utils/media-cache.js';
|
|
8
|
+
import { PersistentWecomCardStore, PersistentWecomDeduper, createWecomCardTaskId, wecomChannelDataPaths, } from './wecom-state.js';
|
|
9
|
+
import { buildWecomInteractionCard, buildWecomTerminalCard, buildWecomUnknownCard, parseWecomCardResponse, } from './wecom-card.js';
|
|
10
|
+
import { ContactBindCodeRegistry, } from './contact-bind-code.js';
|
|
11
|
+
function wecomErrorCode(error) {
|
|
12
|
+
if (!error || typeof error !== 'object')
|
|
13
|
+
return undefined;
|
|
14
|
+
const direct = error.errcode;
|
|
15
|
+
if (typeof direct === 'number')
|
|
16
|
+
return direct;
|
|
17
|
+
const responseCode = error.response?.data?.errcode;
|
|
18
|
+
return typeof responseCode === 'number' ? responseCode : undefined;
|
|
19
|
+
}
|
|
20
|
+
function isWecomAuthError(error) {
|
|
21
|
+
return error.code === 'WS_AUTH_FAILURE_EXHAUSTED'
|
|
22
|
+
|| /^Authentication failed:/i.test(error.message);
|
|
23
|
+
}
|
|
24
|
+
function sanitizeWecomSdkLog(message) {
|
|
25
|
+
return message
|
|
26
|
+
.replace(/("(?:aeskey|secret)"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
27
|
+
.replace(/("url"\s*:\s*")[^"]*(")/gi, '$1<redacted>$2')
|
|
28
|
+
.replace(/(url=)https?:\/\/\S+/gi, '$1<redacted>');
|
|
29
|
+
}
|
|
30
|
+
function validateWecomMediaUrl(url) {
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = new URL(url);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('WeCom media URL is invalid');
|
|
37
|
+
}
|
|
38
|
+
if (parsed.protocol !== 'https:')
|
|
39
|
+
throw new Error(`WeCom media URL must use HTTPS: ${parsed.protocol}`);
|
|
40
|
+
if (parsed.username || parsed.password)
|
|
41
|
+
throw new Error('WeCom media URL must not contain credentials');
|
|
42
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
43
|
+
if (host === 'localhost'
|
|
44
|
+
|| host.endsWith('.localhost')
|
|
45
|
+
|| host.endsWith('.local')
|
|
46
|
+
|| host === '::1'
|
|
47
|
+
|| /^(?:fc|fd|fe8|fe9|fea|feb)/i.test(host)) {
|
|
48
|
+
throw new Error(`WeCom media URL rejected: local host ${host}`);
|
|
49
|
+
}
|
|
50
|
+
const validation = validateUrl(url, { allowedHosts: new Set() });
|
|
51
|
+
if (!validation.ok)
|
|
52
|
+
throw new Error(`WeCom media URL rejected: ${validation.reason}`);
|
|
53
|
+
}
|
|
54
|
+
function truncateUtf8Bytes(value, maxBytes) {
|
|
55
|
+
const input = Buffer.from(value, 'utf8');
|
|
56
|
+
if (input.length <= maxBytes)
|
|
57
|
+
return value;
|
|
58
|
+
let end = maxBytes;
|
|
59
|
+
while (end > 0 && (input[end] & 0xc0) === 0x80)
|
|
60
|
+
end--;
|
|
61
|
+
return input.subarray(0, end).toString('utf8');
|
|
62
|
+
}
|
|
63
|
+
function splitUtf8Bytes(value, maxBytes) {
|
|
64
|
+
if (!value)
|
|
65
|
+
return [];
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let remaining = value;
|
|
68
|
+
while (Buffer.byteLength(remaining, 'utf8') > maxBytes) {
|
|
69
|
+
let chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
70
|
+
const paragraphBreak = chunk.lastIndexOf('\n\n');
|
|
71
|
+
const lineBreak = chunk.lastIndexOf('\n');
|
|
72
|
+
const splitAt = paragraphBreak > Math.floor(chunk.length / 2)
|
|
73
|
+
? paragraphBreak
|
|
74
|
+
: lineBreak > Math.floor(chunk.length / 2)
|
|
75
|
+
? lineBreak
|
|
76
|
+
: chunk.length;
|
|
77
|
+
chunk = chunk.slice(0, splitAt);
|
|
78
|
+
if (!chunk)
|
|
79
|
+
chunk = truncateUtf8Bytes(remaining, maxBytes);
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
remaining = remaining.slice(chunk.length);
|
|
82
|
+
}
|
|
83
|
+
if (remaining)
|
|
84
|
+
chunks.push(remaining);
|
|
85
|
+
return chunks;
|
|
86
|
+
}
|
|
87
|
+
const wecomContactBinds = new ContactBindCodeRegistry({
|
|
88
|
+
channelType: 'wecom',
|
|
89
|
+
displayName: '企微',
|
|
90
|
+
initCommand: 'ec init wecom',
|
|
91
|
+
});
|
|
92
|
+
export function registerPendingWecomContactBind(req) {
|
|
93
|
+
return wecomContactBinds.register(req);
|
|
94
|
+
}
|
|
95
|
+
export function handlePendingWecomContactBindMessage(ctx) {
|
|
96
|
+
return wecomContactBinds.handle(ctx);
|
|
97
|
+
}
|
|
98
|
+
export function getPendingWecomContactBind(selfAid, channelName) {
|
|
99
|
+
return wecomContactBinds.get(selfAid, channelName);
|
|
100
|
+
}
|
|
101
|
+
export function clearPendingWecomContactBinds() {
|
|
102
|
+
wecomContactBinds.clear();
|
|
103
|
+
}
|
|
104
|
+
// ── WecomChannel ───────────────────────────────────────────────────────────────
|
|
105
|
+
export class WecomChannel {
|
|
106
|
+
agentAid;
|
|
107
|
+
channelName;
|
|
108
|
+
config;
|
|
109
|
+
client = null;
|
|
110
|
+
connectionState = 'idle';
|
|
111
|
+
lastConnectionError;
|
|
112
|
+
messageHandler = null;
|
|
113
|
+
seenMessages = new Map();
|
|
114
|
+
persistentDeduper;
|
|
115
|
+
cardStore;
|
|
116
|
+
cleanupInterval = null;
|
|
117
|
+
projectPathProvider = null;
|
|
118
|
+
interactionCallback;
|
|
119
|
+
interactionInvalidationCallback;
|
|
120
|
+
channelKey;
|
|
121
|
+
cardCallbacksInFlight = new Set();
|
|
122
|
+
interactionSendTails = new Map();
|
|
123
|
+
connectPromise;
|
|
124
|
+
activeStreams = new Map();
|
|
125
|
+
// Welcome message manager
|
|
126
|
+
welcomeManager;
|
|
127
|
+
constructor(config, agentAid, channelName) {
|
|
128
|
+
this.agentAid = agentAid;
|
|
129
|
+
this.channelName = channelName;
|
|
130
|
+
this.config = config;
|
|
131
|
+
this.channelKey = channelName || `wecom#${agentAid || 'standalone'}#main`;
|
|
132
|
+
this.cardStore = new PersistentWecomCardStore(undefined, this.channelKey);
|
|
133
|
+
if (agentAid && channelName) {
|
|
134
|
+
const paths = wecomChannelDataPaths(agentAid, channelName);
|
|
135
|
+
this.persistentDeduper = new PersistentWecomDeduper(paths.dedupFile, channelName);
|
|
136
|
+
this.cardStore = new PersistentWecomCardStore(paths.cardFile, channelName);
|
|
137
|
+
}
|
|
138
|
+
// 初始化 welcomeManager(使用共享帮助函数)
|
|
139
|
+
if (agentAid && channelName) {
|
|
140
|
+
this.welcomeManager = initWelcomeManager('wecom', agentAid, channelName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ── Public helpers (testable) ─────────────────────────────────────────────
|
|
144
|
+
isDuplicate(msgId) {
|
|
145
|
+
if (this.persistentDeduper)
|
|
146
|
+
return this.persistentDeduper.checkAndMark(msgId);
|
|
147
|
+
if (this.seenMessages.has(msgId))
|
|
148
|
+
return true;
|
|
149
|
+
this.seenMessages.set(msgId, Date.now());
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
resolveChatId(chattype, chatid, userid) {
|
|
153
|
+
return chattype === 'group' ? (chatid || '') : userid;
|
|
154
|
+
}
|
|
155
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
156
|
+
async connect() {
|
|
157
|
+
if (this.connectionState === 'authenticated' && this.client)
|
|
158
|
+
return;
|
|
159
|
+
if (this.connectPromise)
|
|
160
|
+
return this.connectPromise;
|
|
161
|
+
if (this.client)
|
|
162
|
+
return this.waitForAuthentication(this.client, 20_000);
|
|
163
|
+
const pending = this.connectOnce();
|
|
164
|
+
this.connectPromise = pending;
|
|
165
|
+
try {
|
|
166
|
+
await pending;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (this.connectPromise === pending)
|
|
170
|
+
this.connectPromise = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async connectOnce() {
|
|
174
|
+
const { botId, secret } = this.config;
|
|
175
|
+
if (!botId || !secret) {
|
|
176
|
+
throw new Error('WeCom botId/secret not configured');
|
|
177
|
+
}
|
|
178
|
+
const sdk = await requireOptional('@wecom/aibot-node-sdk');
|
|
179
|
+
this.connectionState = 'connecting';
|
|
180
|
+
this.lastConnectionError = undefined;
|
|
181
|
+
const client = new sdk.WSClient({
|
|
182
|
+
botId,
|
|
183
|
+
secret,
|
|
184
|
+
heartbeatInterval: 30_000,
|
|
185
|
+
maxReconnectAttempts: 10,
|
|
186
|
+
maxAuthFailureAttempts: 5,
|
|
187
|
+
requestTimeout: 15_000,
|
|
188
|
+
logger: {
|
|
189
|
+
debug: (message) => logger.debug(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`),
|
|
190
|
+
info: (message, ...args) => logger.info(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
191
|
+
warn: (message, ...args) => logger.warn(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
192
|
+
error: (message, ...args) => logger.error(`[WeCom SDK] ${sanitizeWecomSdkLog(message)}`, ...args),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
this.client = client;
|
|
196
|
+
// Message events
|
|
197
|
+
client.on('message', (frame) => {
|
|
198
|
+
if (this.client !== client)
|
|
199
|
+
return;
|
|
200
|
+
this.handleIncoming(frame).catch((err) => {
|
|
201
|
+
logger.error('[WeCom] Failed to process incoming message:', err);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// Event callbacks (enter_chat, etc.)
|
|
205
|
+
client.on('event.enter_chat', (frame) => {
|
|
206
|
+
if (this.client !== client)
|
|
207
|
+
return;
|
|
208
|
+
const body = frame?.body;
|
|
209
|
+
if (body) {
|
|
210
|
+
logger.debug(`[WeCom] User entered chat: userid=${body.from?.userid} chattype=${body.chattype}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
// Lifecycle events
|
|
214
|
+
client.on('authenticated', () => {
|
|
215
|
+
if (this.client !== client)
|
|
216
|
+
return;
|
|
217
|
+
this.connectionState = 'authenticated';
|
|
218
|
+
this.lastConnectionError = undefined;
|
|
219
|
+
logger.info('[WeCom] WebSocket authenticated');
|
|
220
|
+
});
|
|
221
|
+
client.on('disconnected', (reason) => {
|
|
222
|
+
if (this.client !== client)
|
|
223
|
+
return;
|
|
224
|
+
logger.warn(`[WeCom] WebSocket disconnected: ${reason}`);
|
|
225
|
+
if (this.connectionState !== 'kicked')
|
|
226
|
+
this.connectionState = 'disconnected';
|
|
227
|
+
this.lastConnectionError = reason;
|
|
228
|
+
this.expireActiveStreams();
|
|
229
|
+
});
|
|
230
|
+
client.on('reconnecting', (attempt) => {
|
|
231
|
+
if (this.client !== client)
|
|
232
|
+
return;
|
|
233
|
+
this.connectionState = 'reconnecting';
|
|
234
|
+
this.expireActiveStreams();
|
|
235
|
+
logger.info(`[WeCom] Reconnecting (attempt ${attempt})...`);
|
|
236
|
+
});
|
|
237
|
+
client.on('error', (error) => {
|
|
238
|
+
if (this.client !== client)
|
|
239
|
+
return;
|
|
240
|
+
this.lastConnectionError = error.message;
|
|
241
|
+
if (isWecomAuthError(error))
|
|
242
|
+
this.connectionState = 'auth_failed';
|
|
243
|
+
else if (error.code === 'WS_RECONNECT_EXHAUSTED')
|
|
244
|
+
this.connectionState = 'disconnected';
|
|
245
|
+
logger.error('[WeCom] WebSocket error:', error);
|
|
246
|
+
});
|
|
247
|
+
client.on('event.template_card_event', (frame) => {
|
|
248
|
+
if (this.client !== client)
|
|
249
|
+
return;
|
|
250
|
+
this.handleTemplateCardEvent(frame).catch(error => {
|
|
251
|
+
logger.error('[WeCom] Failed to process template card event:', error);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
client.on('event.disconnected_event', () => {
|
|
255
|
+
if (this.client !== client)
|
|
256
|
+
return;
|
|
257
|
+
this.connectionState = 'kicked';
|
|
258
|
+
this.lastConnectionError = 'A newer connection subscribed with the same Bot ID';
|
|
259
|
+
this.expireActiveStreams();
|
|
260
|
+
logger.error(`[WeCom] Connection kicked for channel ${this.channelKey}; another client is using this Bot ID`);
|
|
261
|
+
});
|
|
262
|
+
client.connect();
|
|
263
|
+
try {
|
|
264
|
+
await this.waitForAuthentication(client, 20_000);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
const wasCurrentClient = this.client === client;
|
|
268
|
+
if (wasCurrentClient)
|
|
269
|
+
this.client = null;
|
|
270
|
+
try {
|
|
271
|
+
client.disconnect();
|
|
272
|
+
}
|
|
273
|
+
catch { /* ignore */ }
|
|
274
|
+
if (wasCurrentClient) {
|
|
275
|
+
const failedState = this.connectionState;
|
|
276
|
+
if (failedState !== 'auth_failed')
|
|
277
|
+
this.connectionState = 'disconnected';
|
|
278
|
+
this.lastConnectionError = error instanceof Error ? error.message : String(error);
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
// Hourly cleanup of old dedup entries
|
|
283
|
+
this.cleanupInterval = setInterval(() => {
|
|
284
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
285
|
+
for (const [id, ts] of this.seenMessages) {
|
|
286
|
+
if (ts < cutoff)
|
|
287
|
+
this.seenMessages.delete(id);
|
|
288
|
+
}
|
|
289
|
+
this.cleanupStreams();
|
|
290
|
+
}, 60 * 60 * 1000);
|
|
291
|
+
logger.info('[WeCom] Channel connected');
|
|
292
|
+
}
|
|
293
|
+
async disconnect() {
|
|
294
|
+
this.connectionState = 'idle';
|
|
295
|
+
if (this.cleanupInterval) {
|
|
296
|
+
clearInterval(this.cleanupInterval);
|
|
297
|
+
this.cleanupInterval = null;
|
|
298
|
+
}
|
|
299
|
+
const client = this.client;
|
|
300
|
+
this.client = null;
|
|
301
|
+
if (client) {
|
|
302
|
+
try {
|
|
303
|
+
client.disconnect();
|
|
304
|
+
}
|
|
305
|
+
catch { /* ignore */ }
|
|
306
|
+
}
|
|
307
|
+
this.activeStreams.clear();
|
|
308
|
+
logger.info('[WeCom] Channel disconnected');
|
|
309
|
+
}
|
|
310
|
+
onMessage(handler) {
|
|
311
|
+
this.messageHandler = handler;
|
|
312
|
+
}
|
|
313
|
+
onProjectPathRequest(provider) {
|
|
314
|
+
this.projectPathProvider = provider;
|
|
315
|
+
}
|
|
316
|
+
getStatus() {
|
|
317
|
+
return {
|
|
318
|
+
connected: this.connectionState === 'authenticated',
|
|
319
|
+
state: this.connectionState,
|
|
320
|
+
error: this.lastConnectionError,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
async reconnect() {
|
|
324
|
+
await this.disconnect();
|
|
325
|
+
try {
|
|
326
|
+
await this.connect();
|
|
327
|
+
return '重连成功';
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
onInteraction(callback) {
|
|
334
|
+
this.interactionCallback = callback;
|
|
335
|
+
}
|
|
336
|
+
onInteractionInvalidated(callback) {
|
|
337
|
+
this.interactionInvalidationCallback = callback;
|
|
338
|
+
}
|
|
339
|
+
invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
340
|
+
this.cardStore?.invalidateByInteractionId(interactionId, reason);
|
|
341
|
+
}
|
|
342
|
+
async withInteractionSendLock(chatId, run) {
|
|
343
|
+
const previous = this.interactionSendTails.get(chatId) ?? Promise.resolve();
|
|
344
|
+
let release;
|
|
345
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
346
|
+
this.interactionSendTails.set(chatId, current);
|
|
347
|
+
await previous.catch(() => undefined);
|
|
348
|
+
try {
|
|
349
|
+
return await run();
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
release();
|
|
353
|
+
if (this.interactionSendTails.get(chatId) === current)
|
|
354
|
+
this.interactionSendTails.delete(chatId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async invalidateSupersededCards(records) {
|
|
358
|
+
for (const record of records) {
|
|
359
|
+
if (this.interactionInvalidationCallback) {
|
|
360
|
+
try {
|
|
361
|
+
await this.interactionInvalidationCallback(record.interaction.id, 'superseded');
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
logger.warn(`[WeCom] Failed to cancel superseded interaction ${record.interaction.id}: ${String(error)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.cardStore?.invalidateByInteractionId(record.interaction.id, 'superseded');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async waitForAuthentication(client, timeoutMs) {
|
|
371
|
+
if (this.connectionState === 'authenticated')
|
|
372
|
+
return;
|
|
373
|
+
await new Promise((resolve, reject) => {
|
|
374
|
+
let settled = false;
|
|
375
|
+
const finish = (error) => {
|
|
376
|
+
if (settled)
|
|
377
|
+
return;
|
|
378
|
+
settled = true;
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
client.off('authenticated', onAuthenticated);
|
|
381
|
+
client.off('error', onError);
|
|
382
|
+
client.off('disconnected', onDisconnected);
|
|
383
|
+
if (error)
|
|
384
|
+
reject(error);
|
|
385
|
+
else
|
|
386
|
+
resolve();
|
|
387
|
+
};
|
|
388
|
+
const onAuthenticated = () => finish();
|
|
389
|
+
const onError = (error) => {
|
|
390
|
+
if (isWecomAuthError(error))
|
|
391
|
+
finish(error);
|
|
392
|
+
};
|
|
393
|
+
const onDisconnected = (reason) => finish(new Error(`WeCom disconnected before authentication: ${reason}`));
|
|
394
|
+
const timer = setTimeout(() => {
|
|
395
|
+
finish(new Error(`WeCom authentication timed out after ${timeoutMs}ms`));
|
|
396
|
+
}, timeoutMs);
|
|
397
|
+
client.on('authenticated', onAuthenticated);
|
|
398
|
+
client.on('error', onError);
|
|
399
|
+
client.on('disconnected', onDisconnected);
|
|
400
|
+
if (this.connectionState === 'authenticated')
|
|
401
|
+
finish();
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
cleanupStreams(now = Date.now()) {
|
|
405
|
+
const cutoff = now - 30 * 60 * 1000;
|
|
406
|
+
for (const [reqId, stream] of this.activeStreams) {
|
|
407
|
+
if (stream.finished || stream.lastUpdatedAt < cutoff)
|
|
408
|
+
this.activeStreams.delete(reqId);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
expireActiveStreams() {
|
|
412
|
+
for (const stream of this.activeStreams.values())
|
|
413
|
+
stream.expired = true;
|
|
414
|
+
}
|
|
415
|
+
createReplyContext(frame, chatId, messageId) {
|
|
416
|
+
const reqId = String(frame.headers?.req_id ?? '').trim();
|
|
417
|
+
const streamId = crypto.randomUUID();
|
|
418
|
+
if (reqId) {
|
|
419
|
+
this.activeStreams.set(reqId, {
|
|
420
|
+
reqId,
|
|
421
|
+
streamId,
|
|
422
|
+
frame: { headers: { ...frame.headers, req_id: reqId } },
|
|
423
|
+
chatId,
|
|
424
|
+
messageId,
|
|
425
|
+
accumulatedText: '',
|
|
426
|
+
createdAt: Date.now(),
|
|
427
|
+
lastUpdatedAt: Date.now(),
|
|
428
|
+
started: false,
|
|
429
|
+
finished: false,
|
|
430
|
+
expired: false,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
replyToMessageId: messageId,
|
|
435
|
+
metadata: {
|
|
436
|
+
wecomReqId: reqId,
|
|
437
|
+
wecomStreamId: streamId,
|
|
438
|
+
wecomMessageId: messageId,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
streamFromReplyContext(replyContext) {
|
|
443
|
+
const reqId = replyContext?.metadata?.wecomReqId;
|
|
444
|
+
if (typeof reqId !== 'string' || !reqId)
|
|
445
|
+
return undefined;
|
|
446
|
+
return this.activeStreams.get(reqId);
|
|
447
|
+
}
|
|
448
|
+
requireClient() {
|
|
449
|
+
if (!this.client)
|
|
450
|
+
throw new Error('WeCom client is not connected');
|
|
451
|
+
return this.client;
|
|
452
|
+
}
|
|
453
|
+
async sendProactiveMarkdown(chatId, content) {
|
|
454
|
+
const client = this.requireClient();
|
|
455
|
+
for (const chunk of splitUtf8Bytes(content, 20_480)) {
|
|
456
|
+
await client.sendMessage(chatId, {
|
|
457
|
+
msgtype: 'markdown',
|
|
458
|
+
markdown: { content: chunk },
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// ── Inbound message handling ──────────────────────────────────────────────
|
|
463
|
+
async handleIncoming(frame) {
|
|
464
|
+
const body = frame?.body;
|
|
465
|
+
if (!body)
|
|
466
|
+
return;
|
|
467
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
468
|
+
logger.warn(`[WeCom] Ignoring callback for unexpected Bot ID: ${body.aibotid}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const msgId = body.msgid;
|
|
472
|
+
const chattype = body.chattype || 'single';
|
|
473
|
+
const chatid = body.chatid;
|
|
474
|
+
const userid = body.from?.userid || '';
|
|
475
|
+
const msgtype = body.msgtype;
|
|
476
|
+
// Dedup
|
|
477
|
+
if (msgId && this.isDuplicate(msgId)) {
|
|
478
|
+
logger.debug(`[WeCom] Duplicate message skipped: ${msgId}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const channelId = this.resolveChatId(chattype, chatid, userid);
|
|
482
|
+
if (!channelId) {
|
|
483
|
+
logger.warn(`[WeCom] Ignoring ${chattype} message without a routable ${chattype === 'group' ? 'chatid' : 'userid'}`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const chatTypeNorm = chattype === 'group' ? 'group' : 'private';
|
|
487
|
+
const replyContext = this.createReplyContext(frame, channelId, msgId);
|
|
488
|
+
if (!this.messageHandler)
|
|
489
|
+
return;
|
|
490
|
+
// 首次交互欢迎消息(使用共享帮助函数)
|
|
491
|
+
await sendWelcomeIfNeeded(this.welcomeManager, userid, channelId, (id, text) => this.sendMessage(id, text), 'WeCom');
|
|
492
|
+
if (msgtype === 'text') {
|
|
493
|
+
const text = body.text?.content?.trim();
|
|
494
|
+
if (!text)
|
|
495
|
+
return;
|
|
496
|
+
const quote = await this.processQuote(body.quote, channelId);
|
|
497
|
+
const content = quote.prefix ? `${quote.prefix}\n${text}` : text;
|
|
498
|
+
await this.messageHandler({
|
|
499
|
+
channelId, content, chatType: chatTypeNorm,
|
|
500
|
+
peerId: userid, messageId: msgId,
|
|
501
|
+
images: quote.images.length > 0 ? quote.images : undefined,
|
|
502
|
+
replyContext,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else if (msgtype === 'image') {
|
|
506
|
+
await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
507
|
+
}
|
|
508
|
+
else if (msgtype === 'voice') {
|
|
509
|
+
const voiceText = body.voice?.content?.trim();
|
|
510
|
+
if (voiceText) {
|
|
511
|
+
await this.messageHandler({
|
|
512
|
+
channelId, content: voiceText, chatType: chatTypeNorm,
|
|
513
|
+
peerId: userid, messageId: msgId, replyContext,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else if (msgtype === 'file') {
|
|
518
|
+
await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
519
|
+
}
|
|
520
|
+
else if (msgtype === 'video') {
|
|
521
|
+
await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
522
|
+
}
|
|
523
|
+
else if (msgtype === 'mixed') {
|
|
524
|
+
await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId, replyContext);
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
await this.messageHandler({
|
|
528
|
+
channelId,
|
|
529
|
+
content: `[不支持的消息类型: ${msgtype}]`,
|
|
530
|
+
chatType: chatTypeNorm, peerId: userid, messageId: msgId, replyContext,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// ── Inbound media handling ────────────────────────────────────────────────
|
|
535
|
+
async downloadInboundMedia(url, aesKey) {
|
|
536
|
+
if (!this.client)
|
|
537
|
+
throw new Error('WeCom client is unavailable');
|
|
538
|
+
validateWecomMediaUrl(url);
|
|
539
|
+
const result = await this.client.downloadFile(url, aesKey);
|
|
540
|
+
const maxBytes = 20 * 1024 * 1024;
|
|
541
|
+
if (result.buffer.length > maxBytes) {
|
|
542
|
+
throw new Error(`WeCom media exceeds ${maxBytes} bytes`);
|
|
543
|
+
}
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
async processQuote(quote, channelId) {
|
|
547
|
+
const result = { prefix: '', images: [] };
|
|
548
|
+
if (!quote?.msgtype)
|
|
549
|
+
return result;
|
|
550
|
+
try {
|
|
551
|
+
if (quote.msgtype === 'text') {
|
|
552
|
+
const text = String(quote.text?.content ?? '').trim();
|
|
553
|
+
if (text)
|
|
554
|
+
result.prefix = `[引用文本]\n${text}`;
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
if (quote.msgtype === 'voice') {
|
|
558
|
+
const text = String(quote.voice?.content ?? '').trim();
|
|
559
|
+
if (text)
|
|
560
|
+
result.prefix = `[引用语音转写]\n${text}`;
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
if (quote.msgtype === 'image' && quote.image?.url) {
|
|
564
|
+
const media = await this.downloadInboundMedia(quote.image.url, quote.image.aeskey);
|
|
565
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
566
|
+
if (!image)
|
|
567
|
+
throw new Error('引用图片格式不受支持');
|
|
568
|
+
result.images.push(image);
|
|
569
|
+
result.prefix = '[引用图片]';
|
|
570
|
+
return result;
|
|
571
|
+
}
|
|
572
|
+
if (quote.msgtype === 'file' && quote.file?.url) {
|
|
573
|
+
const media = await this.downloadInboundMedia(quote.file.url, quote.file.aeskey);
|
|
574
|
+
const fileName = sanitizeFileName(quote.file.filename || media.filename || 'quoted-file');
|
|
575
|
+
const projectPath = this.projectPathProvider
|
|
576
|
+
? await this.projectPathProvider(channelId)
|
|
577
|
+
: process.cwd();
|
|
578
|
+
const saved = saveToUploads(media.buffer, fileName, projectPath);
|
|
579
|
+
result.prefix = `[引用文件: ${saved.fileName}]\n文件已保存到:${saved.filePath}\n请使用 Read 工具读取并分析文件内容。`;
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
if (quote.msgtype === 'mixed' && Array.isArray(quote.mixed?.msg_item)) {
|
|
583
|
+
const textParts = [];
|
|
584
|
+
for (const item of quote.mixed.msg_item) {
|
|
585
|
+
if (item.msgtype === 'text' && item.text?.content) {
|
|
586
|
+
textParts.push(String(item.text.content));
|
|
587
|
+
}
|
|
588
|
+
else if (item.msgtype === 'image' && item.image?.url) {
|
|
589
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
590
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
591
|
+
if (image)
|
|
592
|
+
result.images.push(image);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
result.prefix = `[引用图文]\n${textParts.join('')}`.trim();
|
|
596
|
+
return result;
|
|
597
|
+
}
|
|
598
|
+
result.prefix = `[引用了暂不支持的消息类型: ${quote.msgtype}]`;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
logger.warn(`[WeCom] Failed to process quote (${quote.msgtype}): ${String(error)}`);
|
|
602
|
+
result.prefix = `[引用内容读取失败: ${quote.msgtype}]`;
|
|
603
|
+
}
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
async handleImageMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
607
|
+
const imageUrl = body.image?.url;
|
|
608
|
+
const aeskey = body.image?.aeskey;
|
|
609
|
+
if (!imageUrl) {
|
|
610
|
+
logger.warn('[WeCom] Image message without url');
|
|
611
|
+
await this.messageHandler({
|
|
612
|
+
channelId, content: '[图片下载失败:缺少下载链接]',
|
|
613
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const media = await this.downloadInboundMedia(imageUrl, aeskey);
|
|
619
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
620
|
+
if (image) {
|
|
621
|
+
await this.messageHandler({
|
|
622
|
+
channelId,
|
|
623
|
+
content: '用户发送了一张图片,请分析这张图片的内容。',
|
|
624
|
+
chatType, peerId, messageId: msgId,
|
|
625
|
+
images: [image], replyContext,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
logger.warn(`[WeCom] Image validation failed`);
|
|
630
|
+
await this.messageHandler({
|
|
631
|
+
channelId, content: '[图片验证失败]',
|
|
632
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
logger.error('[WeCom] Failed to download image:', error);
|
|
638
|
+
await this.messageHandler({
|
|
639
|
+
channelId, content: '[图片下载失败]',
|
|
640
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async handleFileMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
645
|
+
const fileUrl = body.file?.url;
|
|
646
|
+
const aeskey = body.file?.aeskey;
|
|
647
|
+
const callbackFileName = body.file?.filename
|
|
648
|
+
? sanitizeFileName(body.file.filename)
|
|
649
|
+
: undefined;
|
|
650
|
+
const displayFileName = callbackFileName || 'file';
|
|
651
|
+
if (!fileUrl) {
|
|
652
|
+
logger.warn('[WeCom] File message without url');
|
|
653
|
+
await this.messageHandler({
|
|
654
|
+
channelId, content: `[文件下载失败:缺少下载链接] ${displayFileName}`,
|
|
655
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
const media = await this.downloadInboundMedia(fileUrl, aeskey);
|
|
661
|
+
const fileName = sanitizeFileName(callbackFileName || media.filename || 'file');
|
|
662
|
+
const projectPath = this.projectPathProvider
|
|
663
|
+
? await this.projectPathProvider(channelId)
|
|
664
|
+
: process.cwd();
|
|
665
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
666
|
+
await this.messageHandler({
|
|
667
|
+
channelId,
|
|
668
|
+
content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
|
|
669
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
logger.error('[WeCom] Failed to download file:', error);
|
|
674
|
+
await this.messageHandler({
|
|
675
|
+
channelId, content: `[文件下载失败] ${displayFileName}`,
|
|
676
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async handleVideoMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
681
|
+
const videoUrl = body.video?.url;
|
|
682
|
+
const aeskey = body.video?.aeskey;
|
|
683
|
+
if (!videoUrl) {
|
|
684
|
+
await this.messageHandler({
|
|
685
|
+
channelId, content: '[视频下载失败:缺少下载链接]',
|
|
686
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
const media = await this.downloadInboundMedia(videoUrl, aeskey);
|
|
692
|
+
const projectPath = this.projectPathProvider
|
|
693
|
+
? await this.projectPathProvider(channelId)
|
|
694
|
+
: process.cwd();
|
|
695
|
+
const fileName = `video_${Date.now()}.mp4`;
|
|
696
|
+
const { filePath } = saveToUploads(media.buffer, fileName, projectPath);
|
|
697
|
+
await this.messageHandler({
|
|
698
|
+
channelId,
|
|
699
|
+
content: `用户发送了视频:${fileName}\n文件已保存到:${filePath}`,
|
|
700
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
logger.error('[WeCom] Failed to download video:', error);
|
|
705
|
+
await this.messageHandler({
|
|
706
|
+
channelId, content: '[视频下载失败]',
|
|
707
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async handleMixedMessage(body, channelId, chatType, peerId, msgId, replyContext) {
|
|
712
|
+
const msgItems = body.mixed?.msg_item;
|
|
713
|
+
if (!Array.isArray(msgItems)) {
|
|
714
|
+
await this.messageHandler({
|
|
715
|
+
channelId, content: '[不支持的图文混排格式]',
|
|
716
|
+
chatType, peerId, messageId: msgId, replyContext,
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
let text = '';
|
|
721
|
+
const images = [];
|
|
722
|
+
for (const item of msgItems) {
|
|
723
|
+
if (item.msgtype === 'text' && item.text?.content) {
|
|
724
|
+
text += item.text.content;
|
|
725
|
+
}
|
|
726
|
+
else if (item.msgtype === 'image' && item.image?.url) {
|
|
727
|
+
try {
|
|
728
|
+
const media = await this.downloadInboundMedia(item.image.url, item.image.aeskey);
|
|
729
|
+
const image = await bufferToInboundImage(media.buffer, { filename: media.filename });
|
|
730
|
+
if (image)
|
|
731
|
+
images.push(image);
|
|
732
|
+
}
|
|
733
|
+
catch (error) {
|
|
734
|
+
logger.warn('[WeCom] Failed to download mixed image:', error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const prompt = text.trim() || (images.length > 0 ? '用户发送了一张图片,请分析这张图片的内容。' : '[空消息]');
|
|
739
|
+
await this.messageHandler({
|
|
740
|
+
channelId, content: prompt, chatType,
|
|
741
|
+
peerId, messageId: msgId,
|
|
742
|
+
images: images.length > 0 ? images : undefined,
|
|
743
|
+
replyContext,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
// ── Outbound: text ────────────────────────────────────────────────────────
|
|
747
|
+
async sendMessage(chatId, content, replyContext) {
|
|
748
|
+
return this.sendStreamText(chatId, content, true, replyContext);
|
|
749
|
+
}
|
|
750
|
+
async sendStreamText(chatId, content, isFinal, replyContext) {
|
|
751
|
+
if (!content || content.trim() === '') {
|
|
752
|
+
logger.warn('[WeCom] Attempted to send empty message, skipping');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const client = this.requireClient();
|
|
756
|
+
try {
|
|
757
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
758
|
+
if (stream && !stream.finished) {
|
|
759
|
+
stream.accumulatedText += content;
|
|
760
|
+
stream.lastUpdatedAt = Date.now();
|
|
761
|
+
const visibleText = truncateUtf8Bytes(stream.accumulatedText, 20_480);
|
|
762
|
+
if (!stream.expired) {
|
|
763
|
+
try {
|
|
764
|
+
if (isFinal) {
|
|
765
|
+
await client.replyStream(stream.frame, stream.streamId, visibleText, true);
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
const result = await client.replyStreamNonBlocking(stream.frame, stream.streamId, visibleText, false);
|
|
769
|
+
if (result === 'skipped')
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
stream.started = true;
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
if (wecomErrorCode(error) === 846608) {
|
|
776
|
+
stream.expired = true;
|
|
777
|
+
logger.warn(`[WeCom] Stream expired for reqId=${stream.reqId}; final reply will use proactive send`);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (isFinal) {
|
|
785
|
+
stream.finished = true;
|
|
786
|
+
if (stream.expired) {
|
|
787
|
+
await this.sendProactiveMarkdown(chatId, stream.accumulatedText);
|
|
788
|
+
}
|
|
789
|
+
else {
|
|
790
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
791
|
+
for (const continuation of chunks.slice(1))
|
|
792
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
793
|
+
}
|
|
794
|
+
logger.debug(`[WeCom] Finished stream reply reqId=${stream.reqId} chatId=${chatId}`);
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
await this.sendProactiveMarkdown(chatId, content);
|
|
799
|
+
logger.debug(`[WeCom] Sent proactive message to ${chatId}`);
|
|
800
|
+
}
|
|
801
|
+
catch (error) {
|
|
802
|
+
logger.error(`[WeCom] sendMessage failed for ${chatId}:`, error.message);
|
|
803
|
+
throw error;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async finishStream(replyContext, fallbackText = '已完成') {
|
|
807
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
808
|
+
if (!stream || stream.finished)
|
|
809
|
+
return;
|
|
810
|
+
const client = this.requireClient();
|
|
811
|
+
const content = stream.accumulatedText || fallbackText;
|
|
812
|
+
if (stream.expired) {
|
|
813
|
+
stream.finished = true;
|
|
814
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
await client.replyStream(stream.frame, stream.streamId, truncateUtf8Bytes(content, 20_480), true);
|
|
819
|
+
stream.finished = true;
|
|
820
|
+
stream.lastUpdatedAt = Date.now();
|
|
821
|
+
const chunks = splitUtf8Bytes(content, 20_480);
|
|
822
|
+
for (const continuation of chunks.slice(1))
|
|
823
|
+
await this.sendProactiveMarkdown(stream.chatId, continuation);
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (wecomErrorCode(error) !== 846608)
|
|
827
|
+
throw error;
|
|
828
|
+
stream.expired = true;
|
|
829
|
+
stream.finished = true;
|
|
830
|
+
await this.sendProactiveMarkdown(stream.chatId, content);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// ── Outbound: image ───────────────────────────────────────────────────────
|
|
834
|
+
async sendImage(chatId, png, replyContext) {
|
|
835
|
+
const client = this.requireClient();
|
|
836
|
+
try {
|
|
837
|
+
const result = await client.uploadMedia(png, {
|
|
838
|
+
type: 'image',
|
|
839
|
+
filename: 'image.png',
|
|
840
|
+
});
|
|
841
|
+
const mediaId = result?.media_id;
|
|
842
|
+
if (!mediaId) {
|
|
843
|
+
throw new Error('WeCom media upload failed: no media_id');
|
|
844
|
+
}
|
|
845
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
846
|
+
if (stream && !stream.finished && !stream.started) {
|
|
847
|
+
await client.replyMedia(stream.frame, 'image', mediaId);
|
|
848
|
+
stream.finished = true;
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
await this.finishStream(replyContext);
|
|
852
|
+
await client.sendMediaMessage(chatId, 'image', mediaId);
|
|
853
|
+
}
|
|
854
|
+
logger.debug(`[WeCom] Sent image to ${chatId}`);
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
logger.error(`[WeCom] sendImage failed for ${chatId}:`, error.message);
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// ── Outbound: file ────────────────────────────────────────────────────────
|
|
862
|
+
async sendFile(chatId, filePath, replyContext) {
|
|
863
|
+
const client = this.requireClient();
|
|
864
|
+
try {
|
|
865
|
+
const fs = await import('fs');
|
|
866
|
+
const path = await import('path');
|
|
867
|
+
if (!fs.existsSync(filePath)) {
|
|
868
|
+
throw new Error(`WeCom file not found: ${filePath}`);
|
|
869
|
+
}
|
|
870
|
+
// Detect image files → route to sendImage
|
|
871
|
+
const header = Buffer.alloc(12);
|
|
872
|
+
const fd = fs.openSync(filePath, 'r');
|
|
873
|
+
fs.readSync(fd, header, 0, 12, 0);
|
|
874
|
+
fs.closeSync(fd);
|
|
875
|
+
const { fileTypeFromBuffer } = await import('file-type');
|
|
876
|
+
const ftype = await fileTypeFromBuffer(header);
|
|
877
|
+
if (ftype && ftype.mime.startsWith('image/')) {
|
|
878
|
+
const buf = fs.readFileSync(filePath);
|
|
879
|
+
return this.sendImage(chatId, buf, replyContext);
|
|
880
|
+
}
|
|
881
|
+
const buf = fs.readFileSync(filePath);
|
|
882
|
+
const fileName = path.basename(filePath);
|
|
883
|
+
const result = await client.uploadMedia(buf, {
|
|
884
|
+
type: 'file',
|
|
885
|
+
filename: fileName,
|
|
886
|
+
});
|
|
887
|
+
const mediaId = result?.media_id;
|
|
888
|
+
if (!mediaId) {
|
|
889
|
+
throw new Error('WeCom file upload failed: no media_id');
|
|
890
|
+
}
|
|
891
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
892
|
+
if (stream && !stream.finished && !stream.started) {
|
|
893
|
+
await client.replyMedia(stream.frame, 'file', mediaId);
|
|
894
|
+
stream.finished = true;
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
await this.finishStream(replyContext);
|
|
898
|
+
await client.sendMediaMessage(chatId, 'file', mediaId);
|
|
899
|
+
}
|
|
900
|
+
logger.debug(`[WeCom] Sent file ${fileName} to ${chatId}`);
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
logger.error(`[WeCom] sendFile failed for ${chatId}:`, error.message);
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
async sendInteraction(chatId, interaction, replyContext) {
|
|
908
|
+
if (!this.cardStore)
|
|
909
|
+
return false;
|
|
910
|
+
return this.withInteractionSendLock(chatId, async () => {
|
|
911
|
+
const client = this.requireClient();
|
|
912
|
+
const superseded = this.cardStore.listPendingByChat(chatId)
|
|
913
|
+
.filter(record => record.interaction.id !== interaction.id);
|
|
914
|
+
const taskId = createWecomCardTaskId(this.channelKey, interaction.id);
|
|
915
|
+
const card = buildWecomInteractionCard(interaction, taskId);
|
|
916
|
+
const record = {
|
|
917
|
+
taskId,
|
|
918
|
+
interaction,
|
|
919
|
+
chatId,
|
|
920
|
+
createdAt: Date.now(),
|
|
921
|
+
updatedAt: Date.now(),
|
|
922
|
+
expiresAt: interaction.expiresAt,
|
|
923
|
+
status: 'pending',
|
|
924
|
+
};
|
|
925
|
+
const stream = this.streamFromReplyContext(replyContext);
|
|
926
|
+
try {
|
|
927
|
+
if (stream && !stream.finished) {
|
|
928
|
+
if (stream.started) {
|
|
929
|
+
await client.replyStreamWithCard(stream.frame, stream.streamId, truncateUtf8Bytes(stream.accumulatedText, 20_480), true, { templateCard: card });
|
|
930
|
+
const chunks = splitUtf8Bytes(stream.accumulatedText, 20_480);
|
|
931
|
+
for (const continuation of chunks.slice(1))
|
|
932
|
+
await this.sendProactiveMarkdown(chatId, continuation);
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
await client.replyTemplateCard(stream.frame, card);
|
|
936
|
+
}
|
|
937
|
+
stream.finished = true;
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
await client.sendMessage(chatId, {
|
|
941
|
+
msgtype: 'template_card',
|
|
942
|
+
template_card: card,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
this.cardStore.set(record);
|
|
946
|
+
await this.invalidateSupersededCards(superseded);
|
|
947
|
+
logger.info(`[WeCom] Sent interaction card interaction=${interaction.id} taskId=${taskId}`);
|
|
948
|
+
return taskId;
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
logger.error(`[WeCom] Failed to send interaction card ${interaction.id}: ${String(error)}`);
|
|
952
|
+
throw error;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
async handleTemplateCardEvent(frame) {
|
|
957
|
+
const body = frame.body;
|
|
958
|
+
const client = this.client;
|
|
959
|
+
if (!body || !client || !this.cardStore)
|
|
960
|
+
return;
|
|
961
|
+
if (body.aibotid && body.aibotid !== this.config.botId) {
|
|
962
|
+
logger.warn(`[WeCom] Ignoring card callback for unexpected Bot ID: ${body.aibotid}`);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const duplicate = body.msgid ? this.isDuplicate(body.msgid) : false;
|
|
966
|
+
if (duplicate)
|
|
967
|
+
logger.debug(`[WeCom] Replaying terminal response for duplicate card callback: ${body.msgid}`);
|
|
968
|
+
const rawEvent = body.event;
|
|
969
|
+
const event = (rawEvent?.template_card_event ?? rawEvent);
|
|
970
|
+
const taskId = String(event?.task_id ?? '').trim();
|
|
971
|
+
if (!taskId)
|
|
972
|
+
return;
|
|
973
|
+
const record = this.cardStore.get(taskId);
|
|
974
|
+
if (!record) {
|
|
975
|
+
logger.warn(`[WeCom] Card callback has no local record: taskId=${taskId}`);
|
|
976
|
+
await client.updateTemplateCard(frame, buildWecomUnknownCard(taskId), body.from?.userid ? [body.from.userid] : undefined);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
const operatorId = body.from?.userid;
|
|
980
|
+
if (record.interaction.initiatorId && operatorId !== record.interaction.initiatorId) {
|
|
981
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'rejected'), operatorId ? [operatorId] : undefined);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (record.status !== 'pending' || (record.expiresAt && Date.now() > record.expiresAt)) {
|
|
985
|
+
const reason = record.invalidationReason || (record.status === 'resolved' ? '已处理' : '已过期');
|
|
986
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', reason), operatorId ? [operatorId] : undefined);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (this.cardCallbacksInFlight.has(taskId)) {
|
|
990
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable', '正在处理'), operatorId ? [operatorId] : undefined);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.cardCallbacksInFlight.add(taskId);
|
|
994
|
+
try {
|
|
995
|
+
const parsed = parseWecomCardResponse(record, event, operatorId);
|
|
996
|
+
if (!parsed) {
|
|
997
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'invalidated', '无法识别操作'), operatorId ? [operatorId] : undefined);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
if (parsed.command) {
|
|
1001
|
+
if (!this.messageHandler) {
|
|
1002
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
await this.messageHandler({
|
|
1006
|
+
channelId: record.chatId,
|
|
1007
|
+
content: parsed.command,
|
|
1008
|
+
chatType: body.chattype === 'group' ? 'group' : 'private',
|
|
1009
|
+
peerId: operatorId || '',
|
|
1010
|
+
messageId: `card-trigger-${body.msgid || Date.now()}`,
|
|
1011
|
+
source: 'card-trigger',
|
|
1012
|
+
});
|
|
1013
|
+
this.cardStore.resolve(taskId, parsed.command);
|
|
1014
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.command));
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
if (!parsed.response || !this.interactionCallback) {
|
|
1018
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'unavailable'), operatorId ? [operatorId] : undefined);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const accepted = (await this.interactionCallback(parsed.response)) !== false;
|
|
1022
|
+
if (!accepted) {
|
|
1023
|
+
const invalidated = this.cardStore.invalidateByInteractionId(record.interaction.id, 'backend_rejected') || record;
|
|
1024
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(invalidated, 'invalidated', '请求已失效'));
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
this.cardStore.resolve(taskId, parsed.response.action);
|
|
1028
|
+
await client.updateTemplateCard(frame, buildWecomTerminalCard(record, 'resolved', parsed.response.action));
|
|
1029
|
+
}
|
|
1030
|
+
finally {
|
|
1031
|
+
this.cardCallbacksInFlight.delete(taskId);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// ── Plugin ─────────────────────────────────────────────────────────────────────
|
|
1036
|
+
function isValidCredential(value) {
|
|
1037
|
+
return !!value && !value.includes('your-') && !value.includes('placeholder');
|
|
1038
|
+
}
|
|
1039
|
+
export class WecomChannelPlugin {
|
|
1040
|
+
name = 'wecom';
|
|
1041
|
+
async createInstance(inst, ctx) {
|
|
1042
|
+
if (inst.enabled === false)
|
|
1043
|
+
return null;
|
|
1044
|
+
if (!isValidCredential(inst.botId) || !isValidCredential(inst.secret))
|
|
1045
|
+
return null;
|
|
1046
|
+
const channel = new WecomChannel({
|
|
1047
|
+
botId: inst.botId,
|
|
1048
|
+
secret: inst.secret,
|
|
1049
|
+
}, ctx.agentName, inst.name);
|
|
1050
|
+
const mode = resolveShowActivities(inst);
|
|
1051
|
+
const adapter = {
|
|
1052
|
+
channelName: inst.name,
|
|
1053
|
+
channelKey: inst.name,
|
|
1054
|
+
capabilities: { file: true, image: true, interaction: true, markdown: true, thought: false, status: true, thread: false, authenticatedApproval: true },
|
|
1055
|
+
send: async (envelope, payload) => {
|
|
1056
|
+
const channelId = envelope.channelId;
|
|
1057
|
+
switch (payload.kind) {
|
|
1058
|
+
case 'result.text':
|
|
1059
|
+
await channel.sendStreamText(channelId, payload.text, payload.isFinal, envelope.replyContext);
|
|
1060
|
+
return;
|
|
1061
|
+
case 'command.result':
|
|
1062
|
+
case 'command.error':
|
|
1063
|
+
case 'system.notice':
|
|
1064
|
+
case 'system.error':
|
|
1065
|
+
case 'result.error':
|
|
1066
|
+
await channel.sendMessage(channelId, payload.text, envelope.replyContext);
|
|
1067
|
+
return;
|
|
1068
|
+
case 'result.file':
|
|
1069
|
+
await channel.sendFile(channelId, payload.filePath, envelope.replyContext);
|
|
1070
|
+
return;
|
|
1071
|
+
case 'result.image':
|
|
1072
|
+
await channel.sendImage(channelId, payload.data, envelope.replyContext);
|
|
1073
|
+
return;
|
|
1074
|
+
case 'activity.batch': {
|
|
1075
|
+
const filtered = payload.items.filter((i) => !(i.kind === 'tool_result' && i.ok));
|
|
1076
|
+
const text = formatItemsAsText(filtered);
|
|
1077
|
+
if (text)
|
|
1078
|
+
await channel.sendStreamText(channelId, text, false, envelope.replyContext);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
case 'status.completed':
|
|
1082
|
+
await channel.finishStream(envelope.replyContext);
|
|
1083
|
+
return;
|
|
1084
|
+
case 'status.interrupted':
|
|
1085
|
+
case 'status.timeout':
|
|
1086
|
+
await channel.finishStream(envelope.replyContext, '任务已中止');
|
|
1087
|
+
return;
|
|
1088
|
+
case 'status.started':
|
|
1089
|
+
case 'status.queued':
|
|
1090
|
+
case 'status.progress': return;
|
|
1091
|
+
case 'status.requires_action':
|
|
1092
|
+
await channel.sendMessage(channelId, '等待 owner 审批', envelope.replyContext);
|
|
1093
|
+
return;
|
|
1094
|
+
case 'status.error':
|
|
1095
|
+
if (payload.metadata?.message) {
|
|
1096
|
+
await channel.sendMessage(channelId, payload.metadata.message, envelope.replyContext);
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
await channel.finishStream(envelope.replyContext, '任务执行失败');
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
case 'interaction': {
|
|
1103
|
+
try {
|
|
1104
|
+
const taskId = await channel.sendInteraction(channelId, payload.interaction, envelope.replyContext);
|
|
1105
|
+
if (taskId)
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
logger.warn(`[WeCom] Interaction card delivery failed, using text fallback: ${error instanceof Error ? error.message : String(error)}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (payload.fallbackText) {
|
|
1112
|
+
await channel.sendMessage(channelId, payload.fallbackText, envelope.replyContext);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
throw new Error('WeCom interaction delivery failed and no fallback text was provided');
|
|
1116
|
+
}
|
|
1117
|
+
default: return;
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
onInteraction: (callback) => channel.onInteraction(callback),
|
|
1121
|
+
onInteractionInvalidated: (callback) => channel.onInteractionInvalidated(callback),
|
|
1122
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
1123
|
+
};
|
|
1124
|
+
const policy = {
|
|
1125
|
+
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1126
|
+
canListProjects: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1127
|
+
canCreateSession: () => true,
|
|
1128
|
+
canDeleteSession: () => true,
|
|
1129
|
+
canImportCliSession: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
1130
|
+
messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
|
|
1131
|
+
showMiddleResult: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
1132
|
+
middleOutputMode: (chatType, identity) => middleOutputModePolicy(mode, chatType, identity),
|
|
1133
|
+
showIdleMonitor: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
1134
|
+
accumulateErrors: () => true,
|
|
1135
|
+
};
|
|
1136
|
+
return {
|
|
1137
|
+
channelType: 'wecom', adapter, channel,
|
|
1138
|
+
policy,
|
|
1139
|
+
options: { fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g, supportsImages: true, flushDelay: inst.flushDelay },
|
|
1140
|
+
connect: () => channel.connect(),
|
|
1141
|
+
disconnect: () => channel.disconnect(),
|
|
1142
|
+
onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
|
|
1143
|
+
registerBridge(bridge, channelType) {
|
|
1144
|
+
bridge.register(adapter.channelName, (handler) => channel.onMessage(async (event) => {
|
|
1145
|
+
await handler({
|
|
1146
|
+
channel: adapter.channelName, channelType, channelId: event.channelId,
|
|
1147
|
+
selfAID: ctx.agentName, content: event.content, images: event.images,
|
|
1148
|
+
chatType: event.chatType || 'private', peerId: event.peerId || '',
|
|
1149
|
+
peerName: event.peerName, messageId: event.messageId,
|
|
1150
|
+
replyContext: event.replyContext,
|
|
1151
|
+
source: event.source,
|
|
1152
|
+
});
|
|
1153
|
+
}), (channelId, text, replyContext) => channel.sendMessage(channelId, text, replyContext), adapter, channelType);
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
}
|