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,4147 @@
|
|
|
1
|
+
import { E2EEError } from '@agentunion/fastaun';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { logger, localTimestamp } from '../utils/logger.js';
|
|
7
|
+
import { LogWriter } from '../utils/log-writer.js';
|
|
8
|
+
import { middleOutputModePolicy, resolveShowActivities, showActivitiesPolicy } from '../core/channel-loader.js';
|
|
9
|
+
import { DEFAULT_FLUSH_DELAY_SECONDS } from '../types.js';
|
|
10
|
+
import { resolvePaths, getPackageRoot, agentMdPath as agentMdPathFn, agentDir as agentDirPath, resolveRoot } from '../paths.js';
|
|
11
|
+
import { saveToUploads, sanitizeFileName, bufferToInboundImage, safeFetch } from '../utils/media-cache.js';
|
|
12
|
+
import { appendAidEvent } from '../utils/instance-registry.js';
|
|
13
|
+
import { appendMessageLog, buildOutboundEntry, buildInboundEntry, classifyAunPayloadForLog } from '../core/message/message-log.js';
|
|
14
|
+
import { chatDirPath } from '../core/session/session-fs-store.js';
|
|
15
|
+
import { appendHintAdd, appendHintRemove, parseInjectRequest } from '../core/message/pending-hints.js';
|
|
16
|
+
import { appendAidLifecycle } from '../aun/aid/identity.js';
|
|
17
|
+
import { getAidStore, loadClient, SLOT } from '../aun/aid/store.js';
|
|
18
|
+
import { uploadFileAndBuildPayload } from '../aun/msg/upload.js';
|
|
19
|
+
import { loadAgent, saveAgent } from '../config-store.js';
|
|
20
|
+
import { activeBaseagent } from '../core/model/config-scope.js';
|
|
21
|
+
import { resolveEffective } from '../config/config-manager.js';
|
|
22
|
+
import { isManagementRole } from '../config/builtin-roles.js';
|
|
23
|
+
import { mentionModeToDispatch } from '../config/mention-mode.js';
|
|
24
|
+
import { getProcessStartTime } from '../utils/process-introspect.js';
|
|
25
|
+
import * as outbox from '../aun/outbox.js';
|
|
26
|
+
import { guessMime, formatSize } from '../utils/media-cache.js';
|
|
27
|
+
import { formatPeerKey, PeerIdentityCache } from '../core/relation/peer-identity.js';
|
|
28
|
+
import { getFirstStaticAgentOwner } from '../config/peer-role-resolver.js';
|
|
29
|
+
import { isHClassPath } from '../core/protected-paths.js';
|
|
30
|
+
import { consumeAunCausation, registerAunCausation } from '../core/causation/aun-association.js';
|
|
31
|
+
import { deriveCausation, normalizeCausation } from '../core/causation/context.js';
|
|
32
|
+
import { recordCausationSpan } from '../core/causation/audit.js';
|
|
33
|
+
import { isExplicitGroupId } from '../aun/group-identity.js';
|
|
34
|
+
export const AUN_HANDOFF_MARKER_FIELD = '_evolcore_handoff_id';
|
|
35
|
+
/**
|
|
36
|
+
* 构造 connect extra_info:自描述本进程身份。
|
|
37
|
+
*
|
|
38
|
+
* 用途:另一个进程踢掉本连接时,本进程会从 SDK 'gateway.disconnect' 事件的
|
|
39
|
+
* detail.new_extra_info 里看到对方的 extra_info;同时 detail.self_extra_info
|
|
40
|
+
* 是本进程当时连接时上报的内容。把双方信息打到日志便于诊断"谁踢了谁"。
|
|
41
|
+
*
|
|
42
|
+
* 字段需保持稳定(被踢方靠它分辨对方身份)。
|
|
43
|
+
*/
|
|
44
|
+
function buildConnectExtraInfo(opts) {
|
|
45
|
+
const startedAt = getProcessStartTime(process.pid) ?? Date.now();
|
|
46
|
+
return {
|
|
47
|
+
app: 'evolcore',
|
|
48
|
+
version: getEvolcoreVersion(),
|
|
49
|
+
pid: process.pid,
|
|
50
|
+
started_at: startedAt,
|
|
51
|
+
started_at_iso: new Date(startedAt).toISOString(),
|
|
52
|
+
hostname: os.hostname(),
|
|
53
|
+
platform: process.platform,
|
|
54
|
+
node_version: process.version,
|
|
55
|
+
evolcore_home: process.env.EVOLCORE_HOME || '',
|
|
56
|
+
launched_by: process.env.EVOLCORE_LAUNCHED_BY || '',
|
|
57
|
+
aid: opts.aid,
|
|
58
|
+
agent_name: opts.agentName ?? '',
|
|
59
|
+
channel_name: opts.channelName ?? '',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let _cachedVersion = null;
|
|
63
|
+
function getEvolcoreVersion() {
|
|
64
|
+
if (_cachedVersion !== null)
|
|
65
|
+
return _cachedVersion;
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(getPackageRoot(), 'package.json'), 'utf-8'));
|
|
68
|
+
_cachedVersion = String(pkg.version ?? '');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
_cachedVersion = '';
|
|
72
|
+
}
|
|
73
|
+
return _cachedVersion;
|
|
74
|
+
}
|
|
75
|
+
export function buildAunFilePayload(params) {
|
|
76
|
+
const correlationId = params.context?.metadata?.correlationId;
|
|
77
|
+
const payload = {
|
|
78
|
+
type: correlationId ? 'result.file' : 'file',
|
|
79
|
+
text: `📎 ${params.filename} (${formatSize(params.size)})`,
|
|
80
|
+
attachments: [params.attachment],
|
|
81
|
+
};
|
|
82
|
+
if (params.context?.threadId)
|
|
83
|
+
payload.thread_id = params.context.threadId;
|
|
84
|
+
if (params.context?.replyToMessageId)
|
|
85
|
+
payload.ref_message_id = params.context.replyToMessageId;
|
|
86
|
+
if (params.context?.metadata?.taskId)
|
|
87
|
+
payload.task_id = params.context.metadata.taskId;
|
|
88
|
+
if (params.context?.metadata?.chatmode)
|
|
89
|
+
payload.chatmode = params.context.metadata.chatmode;
|
|
90
|
+
if (correlationId) {
|
|
91
|
+
payload.correlation_id = correlationId;
|
|
92
|
+
payload.name = params.filename;
|
|
93
|
+
payload.content_type = params.contentType;
|
|
94
|
+
}
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
function setIfDefined(target, key, value) {
|
|
98
|
+
if (value !== undefined)
|
|
99
|
+
target[key] = value;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 把 AUNChannel 投递的 opts 映射成渠道无关的 InboundMessage。
|
|
103
|
+
*
|
|
104
|
+
* registerBridge 适配回调用它替代手抄字段——历史上手抄漏掉了
|
|
105
|
+
* sameDevice/sameNetwork/sameEgressIp,proximity 在此被吞,eck-debug 永远 false。
|
|
106
|
+
* 抽成纯函数后可单测锁字段,杜绝再漏。
|
|
107
|
+
*/
|
|
108
|
+
export function aunOptsToInbound(opts, channel, channelType) {
|
|
109
|
+
return {
|
|
110
|
+
channel,
|
|
111
|
+
channelType,
|
|
112
|
+
channelId: opts.channelId,
|
|
113
|
+
selfAID: opts.selfAID,
|
|
114
|
+
groupId: opts.groupId,
|
|
115
|
+
content: opts.content,
|
|
116
|
+
chatType: opts.chatType || 'private',
|
|
117
|
+
peerId: opts.peerId || '',
|
|
118
|
+
peerName: opts.peerName,
|
|
119
|
+
peerType: opts.peerType,
|
|
120
|
+
sameDevice: opts.sameDevice,
|
|
121
|
+
sameNetwork: opts.sameNetwork,
|
|
122
|
+
sameEgressIp: opts.sameEgressIp,
|
|
123
|
+
encrypted: opts.encrypted,
|
|
124
|
+
messageId: opts.messageId,
|
|
125
|
+
causation: opts.causation,
|
|
126
|
+
mentions: opts.mentions,
|
|
127
|
+
mentionAids: opts.mentionAids,
|
|
128
|
+
isMentioned: opts.isMentioned,
|
|
129
|
+
threadId: opts.threadId,
|
|
130
|
+
replyContext: opts.replyContext,
|
|
131
|
+
source: opts.source,
|
|
132
|
+
images: opts.images,
|
|
133
|
+
dispatchMode: opts.dispatchMode,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export class AUNChannel {
|
|
137
|
+
config;
|
|
138
|
+
client = null;
|
|
139
|
+
store = null;
|
|
140
|
+
/** 实际连接的网关 URL(来自 authenticate() 返回值 / connection.state 事件),替代旧 (client as any)._gatewayUrl。 */
|
|
141
|
+
gatewayUrl = '';
|
|
142
|
+
projectPathProvider;
|
|
143
|
+
messageHandler;
|
|
144
|
+
recallHandler;
|
|
145
|
+
connected = false;
|
|
146
|
+
traceWriter = null;
|
|
147
|
+
eventBus = null;
|
|
148
|
+
ownerBoundHandler = null;
|
|
149
|
+
queuedHandler = null;
|
|
150
|
+
pendingEchoMessages = new Map();
|
|
151
|
+
isEchoSending = false;
|
|
152
|
+
agentDir;
|
|
153
|
+
trace(dir, event, data) {
|
|
154
|
+
if (!this.config.aunTrace)
|
|
155
|
+
return;
|
|
156
|
+
if (!this.traceWriter)
|
|
157
|
+
return;
|
|
158
|
+
// 自动从 data 推断顶层字段(self_aid / peer_aid / group_id / task_id / chatmode),
|
|
159
|
+
// 便于 jq 过滤:`jq 'select(.task_id == "task-xxx")'`
|
|
160
|
+
const d = (data && typeof data === 'object') ? data : {};
|
|
161
|
+
const payload = d.payload ?? {};
|
|
162
|
+
// 入站事件(IN)路由字段在 d.envelope.*(SDK 0.5.*);出站 params(OUT)字段在顶层。两者兼顾。
|
|
163
|
+
const env = (d.envelope && typeof d.envelope === 'object') ? d.envelope : {};
|
|
164
|
+
const topContext = {
|
|
165
|
+
self_aid: this._aid ?? this.config.aid,
|
|
166
|
+
};
|
|
167
|
+
// peer / group 识别
|
|
168
|
+
const peerAid = env.to ?? env.from ?? d.to ?? d.from ?? d.sender_aid ?? payload.to;
|
|
169
|
+
if (peerAid)
|
|
170
|
+
topContext.peer_aid = peerAid;
|
|
171
|
+
const groupId = env.group_id ?? d.group_id ?? payload.group_id;
|
|
172
|
+
if (groupId)
|
|
173
|
+
topContext.group_id = groupId;
|
|
174
|
+
// task_id / chatmode(message.send / thought.put / status 都可能有)
|
|
175
|
+
const taskId = payload.task_id ?? d.context?.id ?? d.data?.task_id;
|
|
176
|
+
if (taskId)
|
|
177
|
+
topContext.task_id = taskId;
|
|
178
|
+
const chatmode = payload.chatmode;
|
|
179
|
+
if (chatmode)
|
|
180
|
+
topContext.chatmode = chatmode;
|
|
181
|
+
const line = JSON.stringify({ ts: localTimestamp(), dir, event, ...topContext, data });
|
|
182
|
+
this.traceWriter.write(line);
|
|
183
|
+
}
|
|
184
|
+
/** 日志前缀(含 self aid 简称,多实例可识别) */
|
|
185
|
+
logPrefix() {
|
|
186
|
+
const aid = this._aid ?? this.config.aid;
|
|
187
|
+
if (!aid)
|
|
188
|
+
return '[AUN]';
|
|
189
|
+
const short = aid.split('.')[0] || aid;
|
|
190
|
+
return `[AUN ${short}]`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 统一的 RPC 调用包装:自动记录 OUT 发送、.ok 结果、.error 错误(含 trace + daemon.log 失败日志)。
|
|
194
|
+
* 所有 client.call() 都应通过此方法调用,保证 aun-trace 里每个 OUT 调用都有"发+收/错"成对记录。
|
|
195
|
+
*/
|
|
196
|
+
async callAndTrace(method, params, opts) {
|
|
197
|
+
this.trace('OUT', method, params);
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.client.call(method, params);
|
|
200
|
+
if (!opts?.silentOk) {
|
|
201
|
+
const r = result;
|
|
202
|
+
const snap = r && typeof r === 'object'
|
|
203
|
+
? { message_id: r.message_id, ok: r.ok, thought_id: r.thought_id }
|
|
204
|
+
: undefined;
|
|
205
|
+
this.trace('OUT', `${method}.ok`, snap ?? {});
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
this.trace('OUT', `${method}.error`, {
|
|
211
|
+
error: e?.message ?? String(e),
|
|
212
|
+
code: e?.code,
|
|
213
|
+
name: e?.name,
|
|
214
|
+
});
|
|
215
|
+
if (!opts?.silentError) {
|
|
216
|
+
logger.warn(`${this.logPrefix()} rpc ${method} failed: ${e?.name ?? ''}(${e?.code ?? ''}) ${e?.message ?? e}`);
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** 判断 channelId 是否为群组 ID
|
|
222
|
+
* - 新格式:group.{issuer}/{group_no|group_name}
|
|
223
|
+
* - 数字群号:{group_no}.{issuer}(如 11117.agentid.pub)
|
|
224
|
+
* - 兼容旧格式:grp_xxx、g-xxx.agentid.pub
|
|
225
|
+
*/
|
|
226
|
+
/** 判断 channelId 是否群组 ID(public:plugin adapter 闭包需调用) */
|
|
227
|
+
isGroupId(id) {
|
|
228
|
+
return isExplicitGroupId(id);
|
|
229
|
+
}
|
|
230
|
+
getShortAid(aid) {
|
|
231
|
+
if (!aid)
|
|
232
|
+
return undefined;
|
|
233
|
+
const trimmed = aid.trim();
|
|
234
|
+
if (!trimmed)
|
|
235
|
+
return undefined;
|
|
236
|
+
return trimmed.split('.')[0] || trimmed;
|
|
237
|
+
}
|
|
238
|
+
/** 同步获取对端显示名(仅从缓存,不触发网络请求)。用于日志中补充对端标识。
|
|
239
|
+
* 返回 `shortAid(displayName)` 或 `shortAid`,若 aid 为空返回 '?'
|
|
240
|
+
*/
|
|
241
|
+
peerLabel(aid) {
|
|
242
|
+
if (!aid)
|
|
243
|
+
return '?';
|
|
244
|
+
const bareAid = aid.includes(':') ? aid.substring(0, aid.indexOf(':')) : aid;
|
|
245
|
+
const short = this.getShortAid(bareAid) ?? bareAid;
|
|
246
|
+
const cached = this.peerInfoCache.get(bareAid);
|
|
247
|
+
const name = cached?.name;
|
|
248
|
+
return name && name !== short ? `${short}(${name})` : short;
|
|
249
|
+
}
|
|
250
|
+
notifyCardActionFailure(channelId, text, operatorId, threadId) {
|
|
251
|
+
if (!channelId) {
|
|
252
|
+
logger.warn(`${this.logPrefix()} Card action failure without channelId: ${text}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const context = {};
|
|
256
|
+
if (threadId)
|
|
257
|
+
context.threadId = threadId;
|
|
258
|
+
if (operatorId && this.isGroupId(channelId))
|
|
259
|
+
context.peerId = operatorId;
|
|
260
|
+
void this.sendMessage(channelId, text, context).catch((err) => {
|
|
261
|
+
logger.error(`${this.logPrefix()} Failed to send card action error:`, err);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
extractTextPayload(payload, channelId, senderAid) {
|
|
265
|
+
if (typeof payload === 'string')
|
|
266
|
+
return payload;
|
|
267
|
+
if (payload && typeof payload === 'object') {
|
|
268
|
+
const obj = payload;
|
|
269
|
+
const text = typeof obj.text === 'string' ? obj.text : '';
|
|
270
|
+
// action_card_reply:卡片交互回复,触发 interactionCallback,不分发给 agent
|
|
271
|
+
if (obj.type === 'action_card_reply') {
|
|
272
|
+
const cardMsgId = typeof obj.ref_message_id === 'string' ? obj.ref_message_id
|
|
273
|
+
: typeof obj.card_message_id === 'string' ? obj.card_message_id : '';
|
|
274
|
+
const cardInfo = cardMsgId ? this.cardMessageIdMap.get(cardMsgId) : undefined;
|
|
275
|
+
if (cardInfo) {
|
|
276
|
+
const actionValue = typeof obj.value === 'string' ? obj.value
|
|
277
|
+
: typeof obj.action_value === 'string' ? obj.action_value : text;
|
|
278
|
+
const threadId = typeof obj.thread_id === 'string' ? obj.thread_id : undefined;
|
|
279
|
+
// 卡片点击者身份:只信认证信封(senderAid 参数,由调用方从 msg.from / msg.sender_aid 提取)。
|
|
280
|
+
// payload 自报字段(from / sender_aid / user_id)不可信,可被客户端伪造,不读取。
|
|
281
|
+
// 两类卡片共用:CommandCard → 伪入站消息的 peerId,ActionInteraction → operatorId。
|
|
282
|
+
// In private chats the authenticated envelope's channelId is the
|
|
283
|
+
// peer AID, so it is a safe compatibility fallback when older SDK
|
|
284
|
+
// payloads omit senderAid. A group channelId is only a conversation
|
|
285
|
+
// identifier and must never be treated as the clicking operator.
|
|
286
|
+
const cardClickerAid = senderAid
|
|
287
|
+
|| (channelId && !this.isGroupId(channelId) ? channelId : '')
|
|
288
|
+
|| '';
|
|
289
|
+
if (cardInfo.isCommandCard) {
|
|
290
|
+
// CommandCard:action_value 是完整 slash 命令,构造伪入站消息
|
|
291
|
+
this.cardMessageIdMap.delete(cardMsgId);
|
|
292
|
+
if (this.messageHandler && actionValue.startsWith('/')) {
|
|
293
|
+
const chatType = channelId ? (this.isGroupId(channelId) ? 'group' : 'private') : 'private';
|
|
294
|
+
// Initiator 校验:仅群聊需要(私聊信道一对一,点击者恒为对端 = initiator)。
|
|
295
|
+
// 身份只信认证信封提取的 cardClickerAid,非 payload 自报。
|
|
296
|
+
if (chatType === 'group' && !cardClickerAid) {
|
|
297
|
+
logger.info(`${this.logPrefix()} CommandCard rejected: missing authenticated clicker mid=${cardMsgId}`);
|
|
298
|
+
this.notifyCardActionFailure(channelId, '⚠️ 无法验证卡片操作者身份', undefined, threadId);
|
|
299
|
+
return '';
|
|
300
|
+
}
|
|
301
|
+
if (chatType === 'group' && cardInfo.initiatorAid
|
|
302
|
+
&& cardClickerAid !== cardInfo.initiatorAid) {
|
|
303
|
+
logger.info(`${this.logPrefix()} CommandCard rejected: clicker=${cardClickerAid} initiator=${cardInfo.initiatorAid} mid=${cardMsgId}`);
|
|
304
|
+
this.notifyCardActionFailure(channelId, '⚠️ 仅卡片发起者可操作', cardClickerAid, threadId);
|
|
305
|
+
return '';
|
|
306
|
+
}
|
|
307
|
+
Promise.resolve(this.messageHandler({
|
|
308
|
+
channelId: channelId || '',
|
|
309
|
+
channelType: 'aun',
|
|
310
|
+
chatType,
|
|
311
|
+
content: actionValue,
|
|
312
|
+
selfAID: this._aid,
|
|
313
|
+
peerId: cardClickerAid,
|
|
314
|
+
peerName: typeof obj.label === 'string' ? obj.label : typeof obj.action_label === 'string' ? obj.action_label : undefined,
|
|
315
|
+
messageId: `card-trigger-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
316
|
+
source: 'card-trigger',
|
|
317
|
+
})).catch((err) => {
|
|
318
|
+
logger.error(`${this.logPrefix()} CommandCard handler failed: action=${actionValue} mid=${cardMsgId}`, err);
|
|
319
|
+
const message = err instanceof Error && err.message ? err.message : String(err || '未知错误');
|
|
320
|
+
this.notifyCardActionFailure(channelId, `❌ 操作失败: ${message}`, cardClickerAid, threadId);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
else if (!this.messageHandler) {
|
|
324
|
+
this.notifyCardActionFailure(channelId, '❌ 操作失败:命令处理器未就绪', cardClickerAid, threadId);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
this.notifyCardActionFailure(channelId, '❌ 操作失败:无效的卡片命令', cardClickerAid, threadId);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
// ActionInteraction:后端明确接受后才消费 mapping。撤权、过期或
|
|
332
|
+
// 重复点击返回 false 时保持 fail-closed,并提示用户卡片已失效。
|
|
333
|
+
if (channelId && this.isGroupId(channelId) && !cardClickerAid) {
|
|
334
|
+
this.notifyCardActionFailure(channelId, '⚠️ 无法验证卡片操作者身份', undefined, threadId);
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
if (channelId && this.isGroupId(channelId) && cardInfo.initiatorAid
|
|
338
|
+
&& cardClickerAid !== cardInfo.initiatorAid) {
|
|
339
|
+
this.notifyCardActionFailure(channelId, '⚠️ 仅卡片发起者可操作', cardClickerAid, threadId);
|
|
340
|
+
return '';
|
|
341
|
+
}
|
|
342
|
+
if (this.interactionCallback) {
|
|
343
|
+
if (this.interactionActionsInFlight.has(cardMsgId)) {
|
|
344
|
+
this.notifyCardActionFailure(channelId, '⏳ 操作正在处理,请勿重复点击', cardClickerAid, threadId);
|
|
345
|
+
return '';
|
|
346
|
+
}
|
|
347
|
+
this.interactionActionsInFlight.add(cardMsgId);
|
|
348
|
+
const response = {
|
|
349
|
+
type: 'interaction.response',
|
|
350
|
+
id: cardInfo.requestId,
|
|
351
|
+
action: actionValue,
|
|
352
|
+
values: { text, action_label: obj.label ?? obj.action_label, behavior: obj.behavior, card_message_id: cardMsgId },
|
|
353
|
+
operatorId: cardClickerAid || undefined,
|
|
354
|
+
};
|
|
355
|
+
const settle = (accepted) => {
|
|
356
|
+
this.interactionActionsInFlight.delete(cardMsgId);
|
|
357
|
+
if (accepted) {
|
|
358
|
+
this.forgetInteractionCard(cardMsgId, cardInfo.requestId);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!this.invalidatedInteractions.has(cardInfo.requestId)) {
|
|
362
|
+
void this.invalidateInteraction(cardInfo.requestId, 'backend_rejected')
|
|
363
|
+
.finally(() => this.forgetInteractionCard(cardMsgId, cardInfo.requestId));
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// Gateway-driven revocation has already started recall and
|
|
367
|
+
// captured the message id before the callback returned.
|
|
368
|
+
this.forgetInteractionCard(cardMsgId, cardInfo.requestId);
|
|
369
|
+
}
|
|
370
|
+
this.notifyCardActionFailure(channelId, '⚠️ 卡片已失效,请重新发起', cardClickerAid, threadId);
|
|
371
|
+
};
|
|
372
|
+
try {
|
|
373
|
+
const result = this.interactionCallback(response);
|
|
374
|
+
if (result && typeof result.then === 'function') {
|
|
375
|
+
Promise.resolve(result).then(accepted => settle(accepted !== false), err => {
|
|
376
|
+
this.interactionActionsInFlight.delete(cardMsgId);
|
|
377
|
+
logger.error(`${this.logPrefix()} ActionInteraction callback failed: request=${cardInfo.requestId}`, err);
|
|
378
|
+
this.notifyCardActionFailure(channelId, '❌ 操作失败,请稍后重试', cardClickerAid, threadId);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
settle(result !== false);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
this.interactionActionsInFlight.delete(cardMsgId);
|
|
387
|
+
logger.error(`${this.logPrefix()} ActionInteraction callback failed: request=${cardInfo.requestId}`, err);
|
|
388
|
+
this.notifyCardActionFailure(channelId, '❌ 操作失败,请稍后重试', cardClickerAid, threadId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
this.notifyCardActionFailure(channelId, '❌ 操作失败:交互处理器未就绪', cardClickerAid, threadId);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (this.ownedCardMsgIds.has(cardMsgId)) {
|
|
397
|
+
// 本 agent 发出的卡片,但 entry 已过期(20min TTL)
|
|
398
|
+
logger.debug(`${this.logPrefix()} action_card_reply expired: cardMsgId=${cardMsgId}`);
|
|
399
|
+
this.notifyCardActionFailure(channelId, '⚠️ 卡片已失效,请重新发起');
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// 非本 agent 发出的卡片(broadcast 模式下其他 agent 的卡)→ 静默忽略
|
|
403
|
+
logger.debug(`${this.logPrefix()} action_card_reply ignored (not owned): cardMsgId=${cardMsgId}`);
|
|
404
|
+
}
|
|
405
|
+
// 始终返回空字符串,阻止消息分发给 agent
|
|
406
|
+
return '';
|
|
407
|
+
}
|
|
408
|
+
// quote 类型:拼接被引用内容(支持 text / image / file attachments)
|
|
409
|
+
if (obj.type === 'quote' && obj.quote && typeof obj.quote === 'object') {
|
|
410
|
+
const q = obj.quote;
|
|
411
|
+
const quotedText = typeof q.text === 'string' ? q.text : '';
|
|
412
|
+
const sender = typeof q.sender_display === 'string' ? q.sender_display : '';
|
|
413
|
+
const prefix = sender ? `${sender}: ` : '';
|
|
414
|
+
// 构建引用内容:文本 + 附件描述
|
|
415
|
+
const quoteParts = [];
|
|
416
|
+
if (quotedText)
|
|
417
|
+
quoteParts.push(quotedText);
|
|
418
|
+
if (Array.isArray(q.attachments)) {
|
|
419
|
+
for (const att of q.attachments) {
|
|
420
|
+
if (att && typeof att === 'object') {
|
|
421
|
+
const ct = typeof att.content_type === 'string' ? att.content_type : '';
|
|
422
|
+
const fn = typeof att.filename === 'string' ? att.filename : '';
|
|
423
|
+
if (ct.startsWith('image/')) {
|
|
424
|
+
quoteParts.push(fn ? `[图片: ${fn}]` : '[图片]');
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
quoteParts.push(fn ? `[文件: ${fn}]` : '[文件]');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (quoteParts.length > 0) {
|
|
433
|
+
const lines = quoteParts.join('\n').split('\n');
|
|
434
|
+
const quoted = lines.map((line, i) => `> ${i === 0 ? prefix : ''}${line}`).join('\n');
|
|
435
|
+
return text ? `${quoted}\n\n${text}` : quoted;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// merge 类型:合并转发消息,展开子消息为可读文本
|
|
439
|
+
if (obj.type === 'merge') {
|
|
440
|
+
const title = typeof obj.title === 'string' ? obj.title : '合并转发消息';
|
|
441
|
+
const parts = [`以下是转发的合并消息「${title}」:\n---`];
|
|
442
|
+
if (Array.isArray(obj.items)) {
|
|
443
|
+
for (const item of obj.items) {
|
|
444
|
+
if (item && typeof item === 'object') {
|
|
445
|
+
const sender = typeof item.sender_display === 'string' ? item.sender_display : '';
|
|
446
|
+
const itemText = typeof item.text === 'string' ? item.text : '';
|
|
447
|
+
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
448
|
+
// 根据子消息类型构建展示
|
|
449
|
+
const lineParts = [];
|
|
450
|
+
if (itemText)
|
|
451
|
+
lineParts.push(itemText);
|
|
452
|
+
// 子消息附件(image/file)
|
|
453
|
+
if (Array.isArray(item.attachments)) {
|
|
454
|
+
for (const att of item.attachments) {
|
|
455
|
+
if (att && typeof att === 'object') {
|
|
456
|
+
const ct = typeof att.content_type === 'string' ? att.content_type : '';
|
|
457
|
+
const fn = typeof att.filename === 'string' ? att.filename : '';
|
|
458
|
+
if (ct.startsWith('image/') || itemType === 'image') {
|
|
459
|
+
lineParts.push(fn ? `[图片: ${fn}]` : '[图片]');
|
|
460
|
+
}
|
|
461
|
+
else if (ct.startsWith('video/') || itemType === 'video') {
|
|
462
|
+
lineParts.push(fn ? `[视频: ${fn}]` : '[视频]');
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
lineParts.push(fn ? `[文件: ${fn}]` : '[文件]');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const content = lineParts.join(' ') || `[${itemType || '未知类型'}]`;
|
|
471
|
+
parts.push(sender ? `${sender}: ${content}` : content);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (typeof obj.summary === 'string' && obj.summary) {
|
|
476
|
+
parts.push(`\n[摘要] ${obj.summary}`);
|
|
477
|
+
}
|
|
478
|
+
parts.push('---');
|
|
479
|
+
return parts.join('\n');
|
|
480
|
+
}
|
|
481
|
+
if (typeof obj.text === 'string')
|
|
482
|
+
return text;
|
|
483
|
+
return JSON.stringify(payload);
|
|
484
|
+
}
|
|
485
|
+
return '';
|
|
486
|
+
}
|
|
487
|
+
/** 收集 payload 中所有需要下载的 attachments(顶层 + merge.items + quote.quote),按 url 去重 */
|
|
488
|
+
collectAllAttachments(payload) {
|
|
489
|
+
if (!payload || typeof payload !== 'object')
|
|
490
|
+
return [];
|
|
491
|
+
const obj = payload;
|
|
492
|
+
const result = [];
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const add = (att) => {
|
|
495
|
+
if (!att || typeof att !== 'object')
|
|
496
|
+
return;
|
|
497
|
+
const key = att.url || att.object_key || '';
|
|
498
|
+
if (key && seen.has(key))
|
|
499
|
+
return;
|
|
500
|
+
if (key)
|
|
501
|
+
seen.add(key);
|
|
502
|
+
result.push(att);
|
|
503
|
+
};
|
|
504
|
+
// 顶层 attachments
|
|
505
|
+
if (Array.isArray(obj.attachments)) {
|
|
506
|
+
for (const att of obj.attachments)
|
|
507
|
+
add(att);
|
|
508
|
+
}
|
|
509
|
+
// merge.items 中的子消息 attachments
|
|
510
|
+
if (obj.type === 'merge' && Array.isArray(obj.items)) {
|
|
511
|
+
for (const item of obj.items) {
|
|
512
|
+
if (item && typeof item === 'object' && Array.isArray(item.attachments)) {
|
|
513
|
+
for (const att of item.attachments)
|
|
514
|
+
add(att);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// quote.quote 中的 attachments
|
|
519
|
+
if (obj.type === 'quote' && obj.quote && typeof obj.quote === 'object') {
|
|
520
|
+
const q = obj.quote;
|
|
521
|
+
if (Array.isArray(q.attachments)) {
|
|
522
|
+
for (const att of q.attachments)
|
|
523
|
+
add(att);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
hasExplicitMention(text, target) {
|
|
529
|
+
const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
530
|
+
return new RegExp(`(^|\\s)@${escaped}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`).test(text);
|
|
531
|
+
}
|
|
532
|
+
stripSelfMentionIfOnly(text, selfAid) {
|
|
533
|
+
if (!selfAid)
|
|
534
|
+
return text;
|
|
535
|
+
const mentions = text.match(/@[\w.-]+/g) || [];
|
|
536
|
+
if (mentions.length !== 1)
|
|
537
|
+
return text;
|
|
538
|
+
const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
539
|
+
return text
|
|
540
|
+
.replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1')
|
|
541
|
+
.replace(/[ \t]+/g, ' ')
|
|
542
|
+
.trim();
|
|
543
|
+
}
|
|
544
|
+
/** 剥离正文中所有 @aid(用于命令判定 + 命令消息进 agent 前的清理)。 */
|
|
545
|
+
stripAllMentions(text) {
|
|
546
|
+
return text
|
|
547
|
+
.replace(/(^|\s)@[\w.-]+(?=$|\s|[.,!?;:,。!?;:]|[\u4e00-\u9fff])/g, '$1')
|
|
548
|
+
.replace(/[ \t]+/g, ' ')
|
|
549
|
+
.trim();
|
|
550
|
+
}
|
|
551
|
+
extractMentionAids(mentions) {
|
|
552
|
+
const aids = [];
|
|
553
|
+
for (const m of mentions) {
|
|
554
|
+
if (typeof m === 'string')
|
|
555
|
+
aids.push(m);
|
|
556
|
+
else if (m && typeof m === 'object' && typeof m.aid === 'string')
|
|
557
|
+
aids.push(m.aid);
|
|
558
|
+
}
|
|
559
|
+
return aids;
|
|
560
|
+
}
|
|
561
|
+
hasMentionAll(mentions) {
|
|
562
|
+
for (const m of mentions) {
|
|
563
|
+
if (m === 'all')
|
|
564
|
+
return true;
|
|
565
|
+
if (m && typeof m === 'object' && m.scope === 'all')
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
/** 从正文提取 @aid(AID 格式:至少三段域名),用于出站填充 payload.mentions */
|
|
571
|
+
static MENTION_AID_RE = /@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?){2,})/g;
|
|
572
|
+
extractMentionAidsFromText(text) {
|
|
573
|
+
const out = [];
|
|
574
|
+
const seen = new Set();
|
|
575
|
+
for (const m of text.matchAll(AUNChannel.MENTION_AID_RE)) {
|
|
576
|
+
const aid = m[1];
|
|
577
|
+
if (!seen.has(aid)) {
|
|
578
|
+
seen.add(aid);
|
|
579
|
+
out.push(aid);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return out;
|
|
583
|
+
}
|
|
584
|
+
/** Sanitize a raw topicName from untrusted payload: trim, strip control chars, enforce length. */
|
|
585
|
+
sanitizeTopicName(raw) {
|
|
586
|
+
if (typeof raw !== 'string')
|
|
587
|
+
return undefined;
|
|
588
|
+
const sanitized = raw.trim().replace(/[\x00-\x1F\x7F]/g, '');
|
|
589
|
+
if (sanitized.length > 0 && sanitized.length <= 200)
|
|
590
|
+
return sanitized;
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
buildGroupReplyContext(threadId, senderAid, encrypted, messageId, chatmode, topicName) {
|
|
594
|
+
const replyContext = { metadata: { encrypted, chatmode } };
|
|
595
|
+
if (threadId)
|
|
596
|
+
replyContext.threadId = threadId;
|
|
597
|
+
replyContext.peerId = senderAid;
|
|
598
|
+
if (messageId)
|
|
599
|
+
replyContext.replyToMessageId = messageId;
|
|
600
|
+
// 协议 v2.4 §6.4/§12.1: 话题会话创建时传入 topicName
|
|
601
|
+
if (topicName && threadId) {
|
|
602
|
+
if (!replyContext.metadata)
|
|
603
|
+
replyContext.metadata = {};
|
|
604
|
+
replyContext.metadata.topicName = topicName;
|
|
605
|
+
}
|
|
606
|
+
return replyContext;
|
|
607
|
+
}
|
|
608
|
+
acknowledgeImmediately(messageId, _seq) {
|
|
609
|
+
// SDK internally manages seq tracking and ack — do not call message.ack RPC directly,
|
|
610
|
+
// as it corrupts the SDK's seqTracker state and breaks V2 e2ee message pull.
|
|
611
|
+
if (messageId)
|
|
612
|
+
this.messageSeqMap.delete(messageId);
|
|
613
|
+
}
|
|
614
|
+
shouldEncrypt(_peerId) {
|
|
615
|
+
// Default to plaintext; only encrypt when session is explicitly marked encrypted
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
_aid;
|
|
619
|
+
_selfName; // 本地 agent.md 中的 name,首次 connect 时读取
|
|
620
|
+
_chatId = ''; // aid:device_id:slot_id — 多实例回声过滤
|
|
621
|
+
seenMessages = new Map();
|
|
622
|
+
groupNameCache = new Map(); // groupId → 群显示名(进程内缓存,群名极少变)
|
|
623
|
+
peerInfoCache = new Map();
|
|
624
|
+
messageSeqMap = new Map(); // messageId → seq (for ack)
|
|
625
|
+
sentCount = new Map(); // channelId → 已发消息计数(用于判断最终回复)
|
|
626
|
+
peerE2ee = new Map();
|
|
627
|
+
static E2EE_PROBE_TTL = 10 * 60 * 1000; // 10min
|
|
628
|
+
plaintextRecv = 0;
|
|
629
|
+
interactionCallback;
|
|
630
|
+
interactionActionsInFlight = new Set();
|
|
631
|
+
// action_card message_id → { requestId, isCommandCard }(用于关联 action_card_reply)
|
|
632
|
+
cardMessageIdMap = new Map();
|
|
633
|
+
/** 本 agent 曾发出过的卡片 msgId(只增不删,用于区分"过期失效"vs"他人发的卡") */
|
|
634
|
+
ownedCardMsgIds = new Set();
|
|
635
|
+
/** requestId → 已投递的卡片 message_id 集合(撤权/超时时撤回) */
|
|
636
|
+
interactionCardMessageIds = new Map();
|
|
637
|
+
/** 撤权可能先于 outbox 卡片投递完成;迟到卡片注册后立即撤回。 */
|
|
638
|
+
invalidatedInteractions = new Map();
|
|
639
|
+
dispatchModeResolver;
|
|
640
|
+
static PROACTIVE_ALLOW_TYPES = new Set([
|
|
641
|
+
'text', 'quote', 'image', 'video', 'voice', 'file', 'json',
|
|
642
|
+
'merge', 'link', 'location', 'personal_card',
|
|
643
|
+
]);
|
|
644
|
+
/** Menu protocol 请求类型:自定义消息快速路径,绕过白名单直接分发到 bridge */
|
|
645
|
+
static MENU_REQUEST_TYPES = new Set([
|
|
646
|
+
'menu.list', 'menu.query', 'menu.options', 'menu.update', 'menu.action',
|
|
647
|
+
]);
|
|
648
|
+
/** 观察者插话请求类型(owner → agent.AID)。详见 docs/observer-insert-design.md。 */
|
|
649
|
+
static INJECT_REQUEST_TYPE = 'observer.inject';
|
|
650
|
+
/** 绑定请求类型(Evol App → 控制 AID):快速路径,绕过白名单透传原始 payload JSON 给 bridge。 */
|
|
651
|
+
static BIND_REQUEST_TYPE = 'bind.request';
|
|
652
|
+
// Reconnect state
|
|
653
|
+
// SDK 自己跑无限指数退避(1s → 5min);TS 层只在 SDK 够不到的两类场景下接管:
|
|
654
|
+
// 1. flap:短命 connected 反复出现(SDK 不记忆跨轮 base delay,会从 1s 重新开始)
|
|
655
|
+
// 2. terminal_failed with kick reason:直接 5min 后重试,不刷屏
|
|
656
|
+
intentionalDisconnect = false;
|
|
657
|
+
reconnectTimer = null;
|
|
658
|
+
reconnectGeneration = 0; // 防止并发 initClient:每次 takeoverReconnect 递增,回调中校验
|
|
659
|
+
onChannelDown;
|
|
660
|
+
// initClient concurrency guard: 防止多个 initClient 并发执行
|
|
661
|
+
initInProgress = false;
|
|
662
|
+
// Flap detection: 连接寿命 < FLAP_WINDOW_MS 累计 FLAP_THRESHOLD 次,判定为持续被踢
|
|
663
|
+
connectedAt = 0;
|
|
664
|
+
flapCount = 0;
|
|
665
|
+
static FLAP_WINDOW_MS = 30_000;
|
|
666
|
+
static FLAP_THRESHOLD = 3;
|
|
667
|
+
// 接管后的统一退避时间(kicked / flap / terminal_failed)
|
|
668
|
+
static TAKEOVER_DELAY_MS = 5 * 60 * 1000; // 5min
|
|
669
|
+
// 一般 terminal_failed(非 kick)的兜底退避
|
|
670
|
+
static FALLBACK_DELAY_MS = 60 * 1000; // 1min
|
|
671
|
+
// SDK reconnect logging throttle(避免 attempt 自增刷屏)
|
|
672
|
+
lastReconnectLogTime = 0;
|
|
673
|
+
lastReconnectLogAttempt = 0;
|
|
674
|
+
static RECONNECT_LOG_INTERVAL = 60_000;
|
|
675
|
+
static RECONNECT_LOG_STEP = 100;
|
|
676
|
+
// AID 连接状态(供 status 命令聚合展示)
|
|
677
|
+
aidState;
|
|
678
|
+
aidStatsCollector;
|
|
679
|
+
outboxInFlight = new Set();
|
|
680
|
+
constructor(config) {
|
|
681
|
+
this.config = config;
|
|
682
|
+
this.agentDir = agentDirPath(config.aid);
|
|
683
|
+
if (config.aunTrace) {
|
|
684
|
+
this.traceWriter = new LogWriter({
|
|
685
|
+
baseName: 'aun',
|
|
686
|
+
logDir: resolvePaths().logs,
|
|
687
|
+
rotation: 'hourly',
|
|
688
|
+
retention: { hours: 12 },
|
|
689
|
+
});
|
|
690
|
+
logger.info(`${this.logPrefix()} Trace logging enabled (hourly rotation, 12h retention): ${this.traceWriter.activePath()}`);
|
|
691
|
+
}
|
|
692
|
+
this.aidState = {
|
|
693
|
+
aid: config.aid,
|
|
694
|
+
agentName: config.agentName ?? '<unknown>',
|
|
695
|
+
channelName: config.channelName ?? 'aun',
|
|
696
|
+
status: 'disabled',
|
|
697
|
+
reconnectCount: 0,
|
|
698
|
+
flapCount: 0,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/** Snapshot of AID connection state for status / IPC aggregation */
|
|
702
|
+
getAidState() {
|
|
703
|
+
return { ...this.aidState, flapCount: this.flapCount };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* 当前底层 AUN SDK client(可能为 null:未连接 / 重连销毁重建期间)。
|
|
707
|
+
* 注意:重连会销毁重建 client,调用方不可缓存引用,每次动态读取。
|
|
708
|
+
*/
|
|
709
|
+
getClient() {
|
|
710
|
+
return this.client;
|
|
711
|
+
}
|
|
712
|
+
/** 已连接的 provider AID(连接成功后才有值)。 */
|
|
713
|
+
getAid() {
|
|
714
|
+
return this._aid ?? this.config.aid;
|
|
715
|
+
}
|
|
716
|
+
/** 是否已连接(client 存在且认证完成)。 */
|
|
717
|
+
isConnected() {
|
|
718
|
+
return this.connected && this.client !== null;
|
|
719
|
+
}
|
|
720
|
+
setAidStatsCollector(collector) {
|
|
721
|
+
this.aidStatsCollector = collector;
|
|
722
|
+
}
|
|
723
|
+
setAidStatus(status, extra) {
|
|
724
|
+
this.aidState.status = status;
|
|
725
|
+
if (extra)
|
|
726
|
+
Object.assign(this.aidState, extra);
|
|
727
|
+
}
|
|
728
|
+
async connect() {
|
|
729
|
+
this.intentionalDisconnect = false;
|
|
730
|
+
this.flapCount = 0;
|
|
731
|
+
this.connectedAt = 0;
|
|
732
|
+
this.setAidStatus('reconnecting', { lastAttemptAt: Date.now() });
|
|
733
|
+
await this.initClient();
|
|
734
|
+
this.startOutboxTimer();
|
|
735
|
+
}
|
|
736
|
+
async initClient() {
|
|
737
|
+
// 防止并发 initClient(sendMessage 触发 + timer 触发同时进入)
|
|
738
|
+
if (this.initInProgress) {
|
|
739
|
+
logger.info(`${this.logPrefix()} initClient already in progress, skipping`);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
this.initInProgress = true;
|
|
743
|
+
try {
|
|
744
|
+
await this._initClientInner();
|
|
745
|
+
}
|
|
746
|
+
finally {
|
|
747
|
+
this.initInProgress = false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async _initClientInner() {
|
|
751
|
+
// Clean up existing client if any
|
|
752
|
+
if (this.client) {
|
|
753
|
+
this.trace('OUT', 'client.close', { reason: 'initClient' });
|
|
754
|
+
try {
|
|
755
|
+
await this.client.close();
|
|
756
|
+
this.trace('OUT', 'client.close.ok', { reason: 'initClient' });
|
|
757
|
+
}
|
|
758
|
+
catch (e) {
|
|
759
|
+
this.trace('OUT', 'client.close.error', { reason: 'initClient', error: String(e) });
|
|
760
|
+
}
|
|
761
|
+
this.client = null;
|
|
762
|
+
}
|
|
763
|
+
if (this.store) {
|
|
764
|
+
try {
|
|
765
|
+
this.store.close();
|
|
766
|
+
}
|
|
767
|
+
catch { /* ignore */ }
|
|
768
|
+
this.store = null;
|
|
769
|
+
}
|
|
770
|
+
this.connected = false;
|
|
771
|
+
const aunPath = this.config.keystorePath || resolveRoot();
|
|
772
|
+
const aidName = this.config.aid;
|
|
773
|
+
// encryptionSeed 由 getAidStore 内部解析(config / env / 'evol')
|
|
774
|
+
// Migration from ~/.aun is handled by ensureDataDirs() at startup with a marker file.
|
|
775
|
+
// Gateway discovery/cache is owned by the SDK. Do not do a blocking
|
|
776
|
+
// well-known probe here: it prevents the SDK from using its token-store
|
|
777
|
+
// gateway metadata cache when well-known is temporarily unavailable.
|
|
778
|
+
const configuredGateway = this.config.gatewayUrl || '';
|
|
779
|
+
logger.info(`${this.logPrefix()} Initializing: aid=${aidName}, gateway=${configuredGateway || '<sdk-discovery>'}, aun_path=${aunPath}`);
|
|
780
|
+
// 构造 AIDStore(slot=evolcore daemon,与 cli/netcheck 共享 evolcore 隔离键)
|
|
781
|
+
// encryptionSeed / rootCaPath 由 getAidStore 内部注入
|
|
782
|
+
const store = await getAidStore({
|
|
783
|
+
slotId: SLOT.daemon,
|
|
784
|
+
aunPath,
|
|
785
|
+
debug: this.config.aunSdkLog ?? false,
|
|
786
|
+
});
|
|
787
|
+
this.store = store;
|
|
788
|
+
const client = await loadClient(store, aidName);
|
|
789
|
+
this.client = client;
|
|
790
|
+
// 仅作为配置/状态展示。当前 SDK public API 不接受 authenticate gateway override;
|
|
791
|
+
// 实际 gateway 由 SDK discovery + token-store metadata cache 解析。
|
|
792
|
+
this.gatewayUrl = configuredGateway;
|
|
793
|
+
// Register event handlers before connecting
|
|
794
|
+
client.on('message.received', (data) => {
|
|
795
|
+
this.trace('IN', 'message.received', data);
|
|
796
|
+
const kind = (data && typeof data === 'object') ? data.kind ?? '' : '';
|
|
797
|
+
const keys = (data && typeof data === 'object') ? Object.keys(data).join(',') : typeof data;
|
|
798
|
+
logger.debug(`${this.logPrefix()}[DIAG] message.received: kind=${kind} keys=${keys}`);
|
|
799
|
+
this.handleIncomingPrivateMessage(data);
|
|
800
|
+
});
|
|
801
|
+
// pureIdentity(控制 AID):协议层不接群消息,不注册 group 创建监听
|
|
802
|
+
if (!this.config.pureIdentity) {
|
|
803
|
+
client.on('group.message_created', (data) => {
|
|
804
|
+
this.trace('IN', 'group.message_created', data);
|
|
805
|
+
const env = (data && typeof data === 'object') ? data.envelope ?? {} : {};
|
|
806
|
+
const gid = env.group_id ?? '';
|
|
807
|
+
const sender = env.from ?? '';
|
|
808
|
+
logger.debug(`${this.logPrefix()}[DIAG] group.message_created: group_id=${gid} sender=${sender}`);
|
|
809
|
+
this.handleIncomingGroupMessage(data);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
client.on('connection.state', (data) => {
|
|
813
|
+
// trace is handled inside handleConnectionState with throttling
|
|
814
|
+
this.handleConnectionState(data);
|
|
815
|
+
});
|
|
816
|
+
// gateway 被踢/服务端主动断开(含同槽位互踢的 self/new extra_info)
|
|
817
|
+
client.on('gateway.disconnect', (data) => {
|
|
818
|
+
this.trace('IN', 'gateway.disconnect', data);
|
|
819
|
+
this.handleGatewayDisconnect(data);
|
|
820
|
+
});
|
|
821
|
+
client.on('message.recalled', (data) => {
|
|
822
|
+
this.trace('IN', 'message.recalled', data);
|
|
823
|
+
if (data && typeof data === 'object') {
|
|
824
|
+
const ids = data.message_ids;
|
|
825
|
+
if (Array.isArray(ids)) {
|
|
826
|
+
for (const id of ids) {
|
|
827
|
+
if (typeof id === 'string') {
|
|
828
|
+
logger.info(`${this.logPrefix()} Message recalled: ${id}`);
|
|
829
|
+
this.recallHandler?.(id);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
client.on('message.undecryptable', (data) => {
|
|
836
|
+
this.trace('IN', 'message.undecryptable', data);
|
|
837
|
+
const d = data;
|
|
838
|
+
const env = (d.envelope && typeof d.envelope === 'object') ? d.envelope : {};
|
|
839
|
+
logger.warn(`${this.logPrefix()} Message undecryptable: from=${env.from} mid=${d.message_id} err=${d._decrypt_error}`);
|
|
840
|
+
});
|
|
841
|
+
// pureIdentity(控制 AID):不注册 group 解密失败监听
|
|
842
|
+
if (!this.config.pureIdentity) {
|
|
843
|
+
client.on('group.message_undecryptable', (data) => {
|
|
844
|
+
this.trace('IN', 'group.message_undecryptable', data);
|
|
845
|
+
const d = data;
|
|
846
|
+
const env = (d.envelope && typeof d.envelope === 'object') ? d.envelope : {};
|
|
847
|
+
logger.warn(`${this.logPrefix()} Group message undecryptable: group=${env.group_id} from=${env.from} mid=${d.message_id} err=${d._decrypt_error}`);
|
|
848
|
+
});
|
|
849
|
+
// 群消息撤回(SDK 0.4.10 在线 push 通道):与私聊 message.recalled 同构,
|
|
850
|
+
// 逐个 message_id 交给 recallHandler → msgBridge.cancel(排队中删除 / 处理中中断)。
|
|
851
|
+
client.on('group.message_recalled', (data) => {
|
|
852
|
+
this.trace('IN', 'group.message_recalled', data);
|
|
853
|
+
if (data && typeof data === 'object') {
|
|
854
|
+
const d = data;
|
|
855
|
+
const env = (d.envelope && typeof d.envelope === 'object') ? d.envelope : {};
|
|
856
|
+
const ids = d.message_ids;
|
|
857
|
+
if (Array.isArray(ids)) {
|
|
858
|
+
for (const id of ids) {
|
|
859
|
+
if (typeof id === 'string') {
|
|
860
|
+
logger.info(`${this.logPrefix()} Group message recalled: group=${env.group_id ?? ''} mid=${id}`);
|
|
861
|
+
this.recallHandler?.(id);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
// Authenticate(拿权威 gateway 用于日志/状态;connect 内部也会复用 token)
|
|
869
|
+
try {
|
|
870
|
+
logger.info(`${this.logPrefix()} Authenticating as ${aidName}...`);
|
|
871
|
+
this.trace('OUT', 'auth.authenticate', { aid: aidName });
|
|
872
|
+
const auth = await client.authenticate();
|
|
873
|
+
this.trace('OUT', 'auth.authenticate.ok', { aid: client.aid, gateway: auth?.gateway, hasToken: !!auth?.access_token });
|
|
874
|
+
this.trace('IN', 'auth.result', { aid: client.aid, gateway: auth?.gateway, hasToken: !!auth?.access_token });
|
|
875
|
+
const resolvedGateway = String(auth?.gateway ?? this.gatewayUrl);
|
|
876
|
+
this.gatewayUrl = resolvedGateway;
|
|
877
|
+
logger.info(`${this.logPrefix()} Authenticated as ${client.aid ?? '?'}, gateway=${resolvedGateway}`);
|
|
878
|
+
}
|
|
879
|
+
catch (e) {
|
|
880
|
+
const errMsg = e.message || String(e);
|
|
881
|
+
const errName = e.constructor?.name || 'Error';
|
|
882
|
+
this.trace('OUT', 'auth.authenticate.error', { error: errMsg, name: errName });
|
|
883
|
+
logger.error(`${this.logPrefix()} Authentication failed (${errName}): ${errMsg}`);
|
|
884
|
+
if (e.stack)
|
|
885
|
+
logger.debug(`${this.logPrefix()} Auth stack: ${e.stack}`);
|
|
886
|
+
this.setAidStatus('failed', { lastError: `${errName}: ${errMsg}`.slice(0, 80) });
|
|
887
|
+
this.scheduleReconnect();
|
|
888
|
+
throw new Error(`Authentication failed: ${errName}: ${errMsg}`);
|
|
889
|
+
}
|
|
890
|
+
// Connect (SDK auto_reconnect handles transient failures)
|
|
891
|
+
try {
|
|
892
|
+
const extraInfo = buildConnectExtraInfo({
|
|
893
|
+
aid: this.config.aid,
|
|
894
|
+
agentName: this.config.agentName,
|
|
895
|
+
channelName: this.config.channelName,
|
|
896
|
+
});
|
|
897
|
+
this.trace('OUT', 'client.connect', { gateway: this.gatewayUrl, extra_info: extraInfo });
|
|
898
|
+
await client.connect({
|
|
899
|
+
// connection_kind 默认 long;slot 已由 AID 携带(evolcore daemon)
|
|
900
|
+
// extra_info:互踢诊断名片(0.4.3 公开 connect 已支持透传)
|
|
901
|
+
extra_info: extraInfo,
|
|
902
|
+
// max_attempts=0 = 无限重试(与 Go/Python 对齐),交由 SDK 自己跑指数退避
|
|
903
|
+
// initial_delay=1s,max_delay=300s(5min 封顶)
|
|
904
|
+
auto_reconnect: true,
|
|
905
|
+
retry_max_attempts: 0,
|
|
906
|
+
retry_initial_delay: 1.0,
|
|
907
|
+
retry_max_delay: 300.0,
|
|
908
|
+
});
|
|
909
|
+
this.trace('OUT', 'client.connect.ok', { aid: client.aid });
|
|
910
|
+
this._aid = this.client.aid ?? undefined;
|
|
911
|
+
const deviceId = this.client._device_id ?? '';
|
|
912
|
+
this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
|
|
913
|
+
// pureIdentity(控制 AID):无 agent.md,跳过自身 agent.md 拉取,省一次 404
|
|
914
|
+
this._selfName = this.config.pureIdentity ? undefined : this.loadSelfName(aidName);
|
|
915
|
+
if (this._selfName && this.aidStatsCollector)
|
|
916
|
+
this.aidStatsCollector.setSelfName(this.config.aid, this._selfName);
|
|
917
|
+
this.connected = true;
|
|
918
|
+
this.connectedAt = Date.now();
|
|
919
|
+
this.setAidStatus('connected', { lastConnectedAt: Date.now(), lastError: undefined, gatewayUrl: this.gatewayUrl });
|
|
920
|
+
// Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
|
|
921
|
+
// if cert is missing, it falls back to public key SPKI fingerprint which
|
|
922
|
+
// causes peer cert lookup failures. Backfill from keystore if needed.
|
|
923
|
+
const clientAny = this.client;
|
|
924
|
+
if (clientAny._identity && !clientAny._identity.cert) {
|
|
925
|
+
const cert = clientAny._keystore?.loadCert?.(aidName);
|
|
926
|
+
if (cert) {
|
|
927
|
+
clientAny._identity.cert = cert;
|
|
928
|
+
logger.info(`${this.logPrefix()} Backfilled identity.cert from keystore for e2ee fingerprint`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
logger.info(`${this.logPrefix()} Connected as ${this._aid}`);
|
|
932
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'connected', aid: this.config.aid, gateway: this.gatewayUrl });
|
|
933
|
+
appendAidLifecycle({ ts: Date.now(), iso: new Date().toISOString(), event: 'connected', aid: this.config.aid, gateway: this.gatewayUrl });
|
|
934
|
+
// Send welcome message to owner after first connection
|
|
935
|
+
// pureIdentity(控制 AID):跳过 evolagent onboarding(根除 warn 噪声 + 永不 agentmdPut)
|
|
936
|
+
if (!this.config.pureIdentity) {
|
|
937
|
+
await this.sendWelcomeMessage();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
catch (e) {
|
|
941
|
+
this.trace('OUT', 'client.connect.error', { error: String(e) });
|
|
942
|
+
logger.error(`${this.logPrefix()} Connection failed: ${e}`);
|
|
943
|
+
this.setAidStatus('failed', { lastError: String(e).slice(0, 80) });
|
|
944
|
+
this.scheduleReconnect();
|
|
945
|
+
throw e;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async sendWelcomeMessage() {
|
|
949
|
+
try {
|
|
950
|
+
const aid = this.config.aid;
|
|
951
|
+
const aidName = aid.startsWith('@') ? aid.slice(1) : aid;
|
|
952
|
+
// Read initialized + owners from per-agent config.json
|
|
953
|
+
// (config.json 是 owner 的真相来源——auto-bind 后会更新这里,但 this.config 是
|
|
954
|
+
// channel 启动时的快照,不会自动同步)
|
|
955
|
+
const agentConfig = loadAgent(aidName);
|
|
956
|
+
if (!agentConfig) {
|
|
957
|
+
logger.warn(`${this.logPrefix()} agent config not found for ${aidName}, skipping welcome message`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (agentConfig.initialized === true) {
|
|
961
|
+
logger.info(`${this.logPrefix()} Agent already initialized, skipping welcome message`);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const owner = getFirstStaticAgentOwner(aidName);
|
|
965
|
+
if (!owner) {
|
|
966
|
+
logger.info(`${this.logPrefix()} No owner configured, skipping welcome message (will retry after auto-bind)`);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const agentMdLocalPath = agentMdPathFn(aidName);
|
|
970
|
+
const existingAgentMd = fs.existsSync(agentMdLocalPath) ? fs.readFileSync(agentMdLocalPath, 'utf-8') : '';
|
|
971
|
+
const existingFrontmatterMatch = existingAgentMd.match(/^---\n([\s\S]*?)\n---/);
|
|
972
|
+
const existingFrontmatter = existingFrontmatterMatch?.[1] ?? '';
|
|
973
|
+
// Fetch owner's agent.md to derive name and validate type
|
|
974
|
+
const ownerInfo = await this.fetchPeerInfo(owner);
|
|
975
|
+
if (ownerInfo.type !== null && ownerInfo.type !== 'human') {
|
|
976
|
+
logger.warn(`${this.logPrefix()} Owner ${owner} type is "${ownerInfo.type}" (not human). Consider using a human AID as owner.`);
|
|
977
|
+
}
|
|
978
|
+
// Name: prefer existing agent.md name if user has customized it,
|
|
979
|
+
// otherwise generate "{ownerName}的Evol助手 ({aidLabel})" for disambiguation
|
|
980
|
+
const ownerAidClean = owner.startsWith('@') ? owner.slice(1) : owner;
|
|
981
|
+
const ownerDisplayName = (ownerInfo.name || ownerAidClean.split('.')[0]).slice(0, 12);
|
|
982
|
+
const currentNameMatch = existingFrontmatter.match(/^name:\s*"?([^"\n]+)/m);
|
|
983
|
+
const currentName = currentNameMatch?.[1]?.trim().replace(/"$/, '');
|
|
984
|
+
const aidLabel = aidName.split('.')[0];
|
|
985
|
+
let agentDisplayName;
|
|
986
|
+
if (currentName && currentName !== aidLabel) {
|
|
987
|
+
agentDisplayName = currentName;
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
agentDisplayName = `${ownerDisplayName}的Evol助手 (${aidLabel})`;
|
|
991
|
+
}
|
|
992
|
+
// Preserve user-provided description (from `agent new --description`), fallback to default
|
|
993
|
+
const currentDescMatch = existingFrontmatter.match(/^description:\s*"?([^"\n]*)/m);
|
|
994
|
+
const currentDesc = currentDescMatch?.[1]?.trim().replace(/"$/, '');
|
|
995
|
+
const agentDescription = currentDesc
|
|
996
|
+
? currentDesc
|
|
997
|
+
: 'EvolCore AI Agent Gateway - 连接 Claude/Codex 到消息通道';
|
|
998
|
+
// Generate new agent.md (no `initialized` frontmatter — that's now in config.json)
|
|
999
|
+
const newAgentMd = `---
|
|
1000
|
+
aid: "${aid}"
|
|
1001
|
+
name: "${agentDisplayName}"
|
|
1002
|
+
type: "codeagent"
|
|
1003
|
+
version: "1.0.0"
|
|
1004
|
+
description: "${agentDescription}"
|
|
1005
|
+
tags:
|
|
1006
|
+
- evolcore
|
|
1007
|
+
- ai-agent
|
|
1008
|
+
- gateway
|
|
1009
|
+
---
|
|
1010
|
+
|
|
1011
|
+
# ${agentDisplayName}
|
|
1012
|
+
|
|
1013
|
+
EvolCore AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
|
|
1014
|
+
`;
|
|
1015
|
+
// Write locally and publish to AUN network (auto-sign)
|
|
1016
|
+
try {
|
|
1017
|
+
const { agentmdPut } = await import('../aun/aid/agentmd.js');
|
|
1018
|
+
await agentmdPut(newAgentMd, { aid: aidName, store: this.store });
|
|
1019
|
+
logger.info(`${this.logPrefix()} Published agent.md to AUN network`);
|
|
1020
|
+
}
|
|
1021
|
+
catch (e) {
|
|
1022
|
+
logger.warn(`${this.logPrefix()} Failed to publish agent.md: ${e}`);
|
|
1023
|
+
}
|
|
1024
|
+
// Send welcome message
|
|
1025
|
+
const { generateWelcomeMessage } = await import('../utils/welcome.js');
|
|
1026
|
+
const welcomeText = generateWelcomeMessage({
|
|
1027
|
+
channelType: 'aun',
|
|
1028
|
+
agentName: agentDisplayName,
|
|
1029
|
+
ownerName: ownerDisplayName,
|
|
1030
|
+
includeBindingNote: true,
|
|
1031
|
+
});
|
|
1032
|
+
// First contact with Owner races against Owner's async cert fetch from
|
|
1033
|
+
// gateway PKI; a 3s pause lets the cert propagate. persist_required asks
|
|
1034
|
+
// the gateway to durably store the message so Owner can recover it via
|
|
1035
|
+
// pull if the initial E2EE push still arrives before the cert resolves.
|
|
1036
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
1037
|
+
if (!this.client) {
|
|
1038
|
+
logger.warn(`${this.logPrefix()} Client disconnected before welcome message could be sent`);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
await this.callAndTrace('message.send', {
|
|
1042
|
+
to: owner,
|
|
1043
|
+
payload: { type: 'text', text: welcomeText },
|
|
1044
|
+
encrypt: true,
|
|
1045
|
+
persist_required: true,
|
|
1046
|
+
});
|
|
1047
|
+
logger.info(`${this.logPrefix()} Welcome message sent to owner: ${owner}`);
|
|
1048
|
+
// Send binding credential for Evol App to persist locally
|
|
1049
|
+
// active_baseagent 经 config-scope 解析。
|
|
1050
|
+
await this.sendBindingCredential(owner, agentDisplayName, activeBaseagent(aidName)).catch(e => logger.warn(`${this.logPrefix()} Binding credential failed: ${e}`));
|
|
1051
|
+
// Mark agent as initialized in config.json (replaces old agent.md frontmatter flag)
|
|
1052
|
+
try {
|
|
1053
|
+
const fresh = loadAgent(aidName);
|
|
1054
|
+
if (fresh) {
|
|
1055
|
+
fresh.initialized = true;
|
|
1056
|
+
saveAgent(fresh);
|
|
1057
|
+
logger.info(`${this.logPrefix()} Marked ${aidName} as initialized in config.json`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (e) {
|
|
1061
|
+
logger.warn(`${this.logPrefix()} Failed to update initialized flag in config.json: ${e}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch (e) {
|
|
1065
|
+
logger.warn(`${this.logPrefix()} Failed to send welcome message: ${e}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async sendBindingCredential(owner, name, baseagent) {
|
|
1069
|
+
if (!this.client)
|
|
1070
|
+
return;
|
|
1071
|
+
await this.callAndTrace('message.send', {
|
|
1072
|
+
to: owner,
|
|
1073
|
+
payload: { type: 'binding', aid: this.config.aid, name, owner, baseagent },
|
|
1074
|
+
encrypt: true,
|
|
1075
|
+
persist_required: true,
|
|
1076
|
+
});
|
|
1077
|
+
logger.info(`${this.logPrefix()} Binding credential sent to owner: ${owner}`);
|
|
1078
|
+
}
|
|
1079
|
+
// ── Event handlers ──────────────────────────────────────────
|
|
1080
|
+
/**
|
|
1081
|
+
* 统一处理入站附件:下载 → 图片识别+base64 注入 → 拼接文本。
|
|
1082
|
+
*
|
|
1083
|
+
* - 图片:base64 注入视觉通道(不再追加 [文件: …] 文本行,避免冗余)
|
|
1084
|
+
* - 非图片:拼 [文件: name → path],并提示用 Read 工具读取
|
|
1085
|
+
*
|
|
1086
|
+
* @param baseText 已解析的正文(私聊 text / 群聊 strippedText)
|
|
1087
|
+
* @param channelId 下载归属(私聊 fromAid / 群聊 groupId)
|
|
1088
|
+
* @param preCollected 已收集的附件(群聊路径会提前 collect,避免重复)
|
|
1089
|
+
*/
|
|
1090
|
+
async processAttachments(payload, baseText, channelId, preCollected) {
|
|
1091
|
+
const rawAttachments = preCollected ?? this.collectAllAttachments(payload);
|
|
1092
|
+
const images = [];
|
|
1093
|
+
let finalText = baseText;
|
|
1094
|
+
if (rawAttachments.length === 0 || !this.client) {
|
|
1095
|
+
return { finalText, images };
|
|
1096
|
+
}
|
|
1097
|
+
const fileParts = [];
|
|
1098
|
+
for (const att of rawAttachments) {
|
|
1099
|
+
const filePath = await this.downloadAttachment(att, channelId);
|
|
1100
|
+
if (!filePath)
|
|
1101
|
+
continue;
|
|
1102
|
+
const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
|
|
1103
|
+
let img = null;
|
|
1104
|
+
try {
|
|
1105
|
+
const { readFileSync } = await import('node:fs');
|
|
1106
|
+
img = await bufferToInboundImage(readFileSync(filePath), {
|
|
1107
|
+
contentType: att.content_type, mimeType: att.mime_type, filename: name,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
catch { /* read failed, treat as non-image file */ }
|
|
1111
|
+
if (img) {
|
|
1112
|
+
images.push(img);
|
|
1113
|
+
// 图片已注入视觉通道,不追加 [文件: …] 文本行
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
fileParts.push(`[文件: ${name} → ${filePath}]`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const parts = [];
|
|
1120
|
+
if (baseText)
|
|
1121
|
+
parts.push(baseText);
|
|
1122
|
+
if (fileParts.length > 0) {
|
|
1123
|
+
parts.push(...fileParts);
|
|
1124
|
+
parts.push('请使用 Read 工具读取文件内容。');
|
|
1125
|
+
}
|
|
1126
|
+
if (parts.length > 0)
|
|
1127
|
+
finalText = parts.join('\n\n');
|
|
1128
|
+
logger.info(`${this.logPrefix()} [attachments] count=${rawAttachments.length} images=${images.length} files=${fileParts.length}`);
|
|
1129
|
+
return { finalText, images };
|
|
1130
|
+
}
|
|
1131
|
+
async downloadAttachment(att, channelId) {
|
|
1132
|
+
const ownerAid = att.owner_aid || (!this.isGroupId(channelId) ? channelId : '') || this._aid || '';
|
|
1133
|
+
const objectKey = att.object_key;
|
|
1134
|
+
if (!objectKey) {
|
|
1135
|
+
logger.warn(`${this.logPrefix()} Attachment missing object_key, skipping`);
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
const filename = att.filename || objectKey.split('/').pop() || 'unknown';
|
|
1139
|
+
const fallbackUrl = this.trustedAttachmentUrl(att, ownerAid, objectKey);
|
|
1140
|
+
const token = fallbackUrl ? this.extractAttachmentUrlToken(fallbackUrl) : '';
|
|
1141
|
+
let downloadUrl = '';
|
|
1142
|
+
try {
|
|
1143
|
+
const ticketParams = {
|
|
1144
|
+
owner_aid: ownerAid,
|
|
1145
|
+
object_key: objectKey,
|
|
1146
|
+
};
|
|
1147
|
+
if (att.bucket)
|
|
1148
|
+
ticketParams.bucket = att.bucket;
|
|
1149
|
+
if (token)
|
|
1150
|
+
ticketParams.token = token;
|
|
1151
|
+
const ticket = await this.callAndTrace('storage.create_download_ticket', ticketParams, { silentError: !!fallbackUrl });
|
|
1152
|
+
downloadUrl = ticket.download_url || '';
|
|
1153
|
+
if (!downloadUrl) {
|
|
1154
|
+
if (fallbackUrl) {
|
|
1155
|
+
logger.debug(`${this.logPrefix()} No download_url for attachment: ${filename}, using payload URL fallback`);
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
logger.warn(`${this.logPrefix()} No download_url for attachment: ${filename}`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
catch (e) {
|
|
1163
|
+
if (fallbackUrl) {
|
|
1164
|
+
logger.debug(`${this.logPrefix()} create_download_ticket failed for ${filename}, using payload URL fallback: ${e}`);
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
logger.warn(`${this.logPrefix()} create_download_ticket failed for ${filename}: ${e}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
let buffer;
|
|
1171
|
+
if (downloadUrl) {
|
|
1172
|
+
try {
|
|
1173
|
+
const res = await fetch(downloadUrl);
|
|
1174
|
+
if (!res.ok) {
|
|
1175
|
+
logger.warn(`${this.logPrefix()} Download failed for ${filename}: HTTP ${res.status}`);
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
buffer = Buffer.from(await res.arrayBuffer());
|
|
1179
|
+
}
|
|
1180
|
+
catch (e) {
|
|
1181
|
+
logger.warn(`${this.logPrefix()} Download error for ${filename}: ${e}`);
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
if (!fallbackUrl)
|
|
1187
|
+
return null;
|
|
1188
|
+
try {
|
|
1189
|
+
const host = new URL(fallbackUrl).hostname;
|
|
1190
|
+
buffer = await safeFetch(fallbackUrl, { allowedHosts: new Set([host]) });
|
|
1191
|
+
logger.info(`${this.logPrefix()} Downloaded attachment via payload URL fallback: ${filename}`);
|
|
1192
|
+
}
|
|
1193
|
+
catch (e) {
|
|
1194
|
+
logger.warn(`${this.logPrefix()} Payload URL fallback failed for ${filename}: ${e}`);
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (att.sha256) {
|
|
1199
|
+
const { createHash } = await import('node:crypto');
|
|
1200
|
+
const actual = createHash('sha256').update(buffer).digest('hex');
|
|
1201
|
+
if (actual !== att.sha256) {
|
|
1202
|
+
logger.warn(`${this.logPrefix()} SHA256 mismatch for ${filename}: expected ${att.sha256.slice(0, 8)}… got ${actual.slice(0, 8)}…`);
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const projectPath = this.projectPathProvider
|
|
1207
|
+
? await this.projectPathProvider(channelId)
|
|
1208
|
+
: process.cwd();
|
|
1209
|
+
try {
|
|
1210
|
+
const result = saveToUploads(buffer, filename, projectPath);
|
|
1211
|
+
logger.info(`${this.logPrefix()} Saved attachment: ${result.filePath} (${result.size} bytes)`);
|
|
1212
|
+
return result.filePath;
|
|
1213
|
+
}
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
logger.warn(`${this.logPrefix()} saveToUploads failed for ${filename}: ${e}`);
|
|
1216
|
+
return null;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
extractAttachmentUrlToken(url) {
|
|
1220
|
+
try {
|
|
1221
|
+
return new URL(url).searchParams.get('t') || '';
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
return '';
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
trustedAttachmentUrl(att, ownerAid, objectKey) {
|
|
1228
|
+
const rawUrl = typeof att.url === 'string' ? att.url.trim() : '';
|
|
1229
|
+
if (!rawUrl)
|
|
1230
|
+
return null;
|
|
1231
|
+
let parsed;
|
|
1232
|
+
try {
|
|
1233
|
+
parsed = new URL(rawUrl);
|
|
1234
|
+
}
|
|
1235
|
+
catch {
|
|
1236
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: invalid URL`);
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
if (parsed.protocol !== 'https:') {
|
|
1240
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: non-https protocol ${parsed.protocol}`);
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
const host = parsed.hostname.toLowerCase();
|
|
1244
|
+
if (host === 'localhost' || host.endsWith('.localhost')) {
|
|
1245
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: local host ${host}`);
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
const normalizedOwner = ownerAid.trim().toLowerCase();
|
|
1249
|
+
const normalizedObjectKey = objectKey.replace(/^\/+/, '');
|
|
1250
|
+
const decodedPath = this.decodeUrlPath(parsed.pathname).replace(/^\/+/, '');
|
|
1251
|
+
if (normalizedOwner && host === normalizedOwner) {
|
|
1252
|
+
if (decodedPath === normalizedObjectKey)
|
|
1253
|
+
return rawUrl;
|
|
1254
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: path does not match object_key`);
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
if (this.isTrustedStorageHost(host, normalizedOwner)) {
|
|
1258
|
+
const key = parsed.searchParams.get('key')?.replace(/^\/+/, '') ?? '';
|
|
1259
|
+
if (key && key === normalizedObjectKey)
|
|
1260
|
+
return rawUrl;
|
|
1261
|
+
if (!key && decodedPath.endsWith(`/${normalizedObjectKey}`))
|
|
1262
|
+
return rawUrl;
|
|
1263
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: storage URL key does not match object_key`);
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
logger.warn(`${this.logPrefix()} Reject attachment payload URL fallback: host ${host} does not match owner ${normalizedOwner || '<empty>'}`);
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
isTrustedStorageHost(host, normalizedOwner) {
|
|
1270
|
+
const trustedIssuers = new Set();
|
|
1271
|
+
// 对端 issuer(文件所有者 owner_aid)
|
|
1272
|
+
const peerIssuer = this.extractIssuer(normalizedOwner);
|
|
1273
|
+
if (peerIssuer)
|
|
1274
|
+
trustedIssuers.add(peerIssuer);
|
|
1275
|
+
// 本端 issuer(自己的 AID)
|
|
1276
|
+
const selfIssuer = this.extractIssuer((this.getAid() || '').toLowerCase());
|
|
1277
|
+
if (selfIssuer)
|
|
1278
|
+
trustedIssuers.add(selfIssuer);
|
|
1279
|
+
// 校验 host 是否为任一可信 issuer 的存储域名
|
|
1280
|
+
for (const issuer of trustedIssuers) {
|
|
1281
|
+
if (host === `storage.${issuer}`)
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
/** 从 AID 提取 issuer(去掉首段 label)。`mybot.agentid.pub` → `agentid.pub` */
|
|
1287
|
+
extractIssuer(aid) {
|
|
1288
|
+
if (!aid || !aid.includes('.'))
|
|
1289
|
+
return '';
|
|
1290
|
+
return aid.split('.').slice(1).join('.');
|
|
1291
|
+
}
|
|
1292
|
+
decodeUrlPath(pathname) {
|
|
1293
|
+
try {
|
|
1294
|
+
return decodeURIComponent(pathname);
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
return pathname;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async handleIncomingPrivateMessage(data) {
|
|
1301
|
+
if (!data || typeof data !== 'object')
|
|
1302
|
+
return;
|
|
1303
|
+
const msg = data;
|
|
1304
|
+
// SDK 0.5.* 移除了顶层 from/to/group_id/encrypted 等别名,统一从 msg.envelope.* 读取。
|
|
1305
|
+
// message_id / seq / payload / same_* 等仍是顶层独立字段,不在 envelope 内。
|
|
1306
|
+
const env = (msg.envelope && typeof msg.envelope === 'object') ? msg.envelope : {};
|
|
1307
|
+
const fromAid = env.from ?? '';
|
|
1308
|
+
const payload = msg.payload ?? '';
|
|
1309
|
+
const causationRef = payload && typeof payload === 'object' && typeof payload._evolcore_causation_ref === 'string'
|
|
1310
|
+
? payload._evolcore_causation_ref
|
|
1311
|
+
: undefined;
|
|
1312
|
+
const logDescriptor = classifyAunPayloadForLog(payload);
|
|
1313
|
+
const text = this.extractTextPayload(payload, fromAid, fromAid);
|
|
1314
|
+
const threadId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
|
|
1315
|
+
const refMessageId = typeof payload === 'object' && payload !== null && typeof payload.ref_message_id === 'string'
|
|
1316
|
+
? payload.ref_message_id
|
|
1317
|
+
: undefined;
|
|
1318
|
+
// Extract and validate topicName from untrusted payload
|
|
1319
|
+
const topicName = (typeof payload === 'object' && payload !== null)
|
|
1320
|
+
? this.sanitizeTopicName(payload.topicName)
|
|
1321
|
+
: undefined;
|
|
1322
|
+
const messageId = msg.message_id ?? '';
|
|
1323
|
+
const causation = consumeAunCausation(causationRef || messageId, fromAid, this._aid || this.config.aid)
|
|
1324
|
+
?? (causationRef ? consumeAunCausation(messageId, fromAid, this._aid || this.config.aid) : undefined);
|
|
1325
|
+
const seq = msg.seq;
|
|
1326
|
+
// Observer forward (inbound):在所有过滤之前转发原始明文 payload。
|
|
1327
|
+
// forwardInbound 内部排除 self-echo 与 from-owner。
|
|
1328
|
+
// 显式排除 observer.inject:它是 owner 对本 agent 的控制消息,不应镜像给观察者
|
|
1329
|
+
// (即便日后 from-owner 排除规则调整,也不会泄漏)。
|
|
1330
|
+
const inboundType = (payload && typeof payload === 'object') ? payload.type : undefined;
|
|
1331
|
+
if (inboundType !== AUNChannel.INJECT_REQUEST_TYPE) {
|
|
1332
|
+
this.forwardInbound(msg);
|
|
1333
|
+
}
|
|
1334
|
+
// 回声过滤:自己发出的消息会被 gateway fanout 回来,
|
|
1335
|
+
// 只有 from_aid == self 且 chat_id 不匹配时才丢弃(说明是其它实例发的)
|
|
1336
|
+
const msgChatId = typeof payload === 'object' && payload !== null && payload.chat_id;
|
|
1337
|
+
if (this._aid && fromAid === this._aid && (!msgChatId || !this._chatId || msgChatId !== this._chatId)) {
|
|
1338
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1339
|
+
logger.debug(`${this.logPrefix()} P2P dropped: echo from self (from=${fromAid} mid=${messageId})`);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
// 记录入站消息加密状态,透传到出站 ReplyContext
|
|
1343
|
+
const msgEncrypted = !!env.encrypted;
|
|
1344
|
+
if (!msgEncrypted)
|
|
1345
|
+
this.plaintextRecv++;
|
|
1346
|
+
// Detect @mentions
|
|
1347
|
+
const mentions = [];
|
|
1348
|
+
if (this._aid && text.includes(`@${this._aid}`)) {
|
|
1349
|
+
mentions.push(this._aid);
|
|
1350
|
+
}
|
|
1351
|
+
// Process attachments (顶层 + 嵌套在 merge.items / quote.quote 中的)
|
|
1352
|
+
const { finalText, images: inboundImages } = await this.processAttachments(payload, text, fromAid);
|
|
1353
|
+
// 私聊 channelId = 对端 AID(不再读 payload.chat_id 含 device 三段式)
|
|
1354
|
+
// device_id 仅 SDK 内部多实例去重用,evolcore session 层面跨端共享会话
|
|
1355
|
+
const chatId = fromAid;
|
|
1356
|
+
// 解析对端身份(30天缓存)
|
|
1357
|
+
const selfAgentDir = path.join(resolvePaths().agentsDir, this.config.aid);
|
|
1358
|
+
const peerIdentity = await PeerIdentityCache.resolve('aun', fromAid, selfAgentDir, this.store, false);
|
|
1359
|
+
const shortAid = this.getShortAid(fromAid);
|
|
1360
|
+
const displayName = peerIdentity.name || shortAid;
|
|
1361
|
+
// 详细 dispatch 决策日志:记录消息为何被路由到 agent
|
|
1362
|
+
const p2pPayloadType = (payload && typeof payload === 'object') ? payload.type ?? '' : '';
|
|
1363
|
+
logger.info(`${this.logPrefix()} P2P dispatch decision: mid=${messageId} from=${shortAid}(${displayName}) peerType=${peerIdentity.type} payloadType=${p2pPayloadType} chatId=${chatId} encrypt=${msgEncrypted} textPreview=${JSON.stringify(text.slice(0, 80))}`);
|
|
1364
|
+
// action_card_reply 已在 extractTextPayload 中消费,不分发给 agent
|
|
1365
|
+
if (p2pPayloadType === 'action_card_reply') {
|
|
1366
|
+
this.appendInboundPayloadJsonl(chatId, fromAid, messageId, msgEncrypted, false, logDescriptor, threadId);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
// menu.* 协议:自定义消息快速路径,需要原始 payload JSON 传递给 bridge
|
|
1370
|
+
if (AUNChannel.MENU_REQUEST_TYPES.has(p2pPayloadType)) {
|
|
1371
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1372
|
+
this.appendInboundPayloadJsonl(chatId, fromAid, messageId, msgEncrypted, false, logDescriptor, threadId);
|
|
1373
|
+
this.dispatchMessage({
|
|
1374
|
+
channelId: chatId, userId: fromAid,
|
|
1375
|
+
text: JSON.stringify(payload),
|
|
1376
|
+
chatType: 'private', messageId, seq,
|
|
1377
|
+
peerName: displayName || undefined,
|
|
1378
|
+
peerType: peerIdentity.type,
|
|
1379
|
+
msgType: logDescriptor.msgType,
|
|
1380
|
+
payloadType: logDescriptor.payloadType,
|
|
1381
|
+
payloadSummary: logDescriptor.payloadSummary,
|
|
1382
|
+
messageLogContent: logDescriptor.content,
|
|
1383
|
+
});
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
// observer.inject:owner 插话。鉴权 from∈owners 后,以 target.channel_id 选 agent↔对端 会话,
|
|
1387
|
+
// observer 插话(v0.3):只落盘到 pending-hints,不进 Agent、不回 owner。详见 docs/observer-insert-design.md。
|
|
1388
|
+
if (p2pPayloadType === AUNChannel.INJECT_REQUEST_TYPE) {
|
|
1389
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1390
|
+
this.handleObserverInject(fromAid, payload, displayName, peerIdentity.type);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
// bind.request:Evol App 绑定请求。绕过白名单透传原始 payload JSON 给 bridge,
|
|
1394
|
+
// 由控制 AID 的 onMessage 处理器(BindService.handleRequest)解析并回 bind.response。
|
|
1395
|
+
// 透传 encrypted:bind.response 须与请求的加密/明文对称(见 onMessage 处理)。
|
|
1396
|
+
if (p2pPayloadType === AUNChannel.BIND_REQUEST_TYPE) {
|
|
1397
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1398
|
+
this.appendInboundPayloadJsonl(chatId, fromAid, messageId, msgEncrypted, false, logDescriptor, threadId);
|
|
1399
|
+
this.dispatchMessage({
|
|
1400
|
+
channelId: chatId, userId: fromAid,
|
|
1401
|
+
text: JSON.stringify(payload),
|
|
1402
|
+
chatType: 'private', messageId, seq,
|
|
1403
|
+
peerName: displayName || undefined,
|
|
1404
|
+
peerType: peerIdentity.type,
|
|
1405
|
+
encrypted: msgEncrypted,
|
|
1406
|
+
msgType: logDescriptor.msgType,
|
|
1407
|
+
payloadType: logDescriptor.payloadType,
|
|
1408
|
+
payloadSummary: logDescriptor.payloadSummary,
|
|
1409
|
+
messageLogContent: logDescriptor.content,
|
|
1410
|
+
});
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
// payload 类型白名单:信号类消息(status / event / thought 等)不进 Agent
|
|
1414
|
+
if (p2pPayloadType && !AUNChannel.PROACTIVE_ALLOW_TYPES.has(p2pPayloadType)) {
|
|
1415
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1416
|
+
this.appendInboundPayloadJsonl(chatId, fromAid, messageId, msgEncrypted, false, logDescriptor, threadId);
|
|
1417
|
+
logger.info(`${this.logPrefix()} P2P dropped (type deny): type=${p2pPayloadType} from=${shortAid}(${displayName}) mid=${messageId}`);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const msgChatmode = (payload && typeof payload === 'object') ? payload.chatmode : undefined;
|
|
1421
|
+
logger.info(`${this.logPrefix()} P2P dispatched: from=${shortAid}(${displayName}) mid=${messageId} encrypt=${msgEncrypted} chatmode=${msgChatmode ?? 'none'} text=${finalText.slice(0, 60)}`);
|
|
1422
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: fromAid, msgId: messageId, kind: 'text', len: finalText.length });
|
|
1423
|
+
const isSystemP2P = p2pPayloadType === 'event';
|
|
1424
|
+
this.aidStatsCollector?.recordInbound(this.config.aid, fromAid, Buffer.byteLength(finalText, 'utf-8'), finalText, isSystemP2P, msgEncrypted, msgChatmode, isSystemP2P ? 'notify' : 'send');
|
|
1425
|
+
const replyContext = { metadata: { encrypted: msgEncrypted, chatmode: msgChatmode, refMessageId } };
|
|
1426
|
+
if (threadId)
|
|
1427
|
+
replyContext.threadId = threadId;
|
|
1428
|
+
replyContext.peerId = fromAid;
|
|
1429
|
+
if (messageId)
|
|
1430
|
+
replyContext.replyToMessageId = messageId;
|
|
1431
|
+
// 协议 v2.4 §6.4/§12.1: 话题会话创建时传入 topicName
|
|
1432
|
+
if (topicName && threadId) {
|
|
1433
|
+
if (!replyContext.metadata)
|
|
1434
|
+
replyContext.metadata = {};
|
|
1435
|
+
replyContext.metadata.topicName = topicName;
|
|
1436
|
+
}
|
|
1437
|
+
this.dispatchMessage({
|
|
1438
|
+
channelId: chatId,
|
|
1439
|
+
userId: fromAid,
|
|
1440
|
+
text: finalText,
|
|
1441
|
+
chatType: 'private',
|
|
1442
|
+
messageId,
|
|
1443
|
+
causation,
|
|
1444
|
+
seq,
|
|
1445
|
+
threadId,
|
|
1446
|
+
mentions,
|
|
1447
|
+
peerName: displayName || undefined,
|
|
1448
|
+
peerType: peerIdentity.type,
|
|
1449
|
+
sameDevice: msg.same_device === true || undefined,
|
|
1450
|
+
sameNetwork: msg.same_network === true || undefined,
|
|
1451
|
+
sameEgressIp: msg.same_egress_ip === true || undefined,
|
|
1452
|
+
encrypted: msgEncrypted,
|
|
1453
|
+
replyContext,
|
|
1454
|
+
images: inboundImages.length > 0 ? inboundImages : undefined,
|
|
1455
|
+
msgType: logDescriptor.msgType,
|
|
1456
|
+
payloadType: logDescriptor.payloadType,
|
|
1457
|
+
payloadSummary: logDescriptor.payloadSummary,
|
|
1458
|
+
messageLogContent: logDescriptor.msgType === 'text' ? finalText : logDescriptor.content,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
async handleIncomingGroupMessage(data) {
|
|
1462
|
+
if (!data || typeof data !== 'object')
|
|
1463
|
+
return;
|
|
1464
|
+
const msg = data;
|
|
1465
|
+
// SDK 0.5.* 移除了顶层 from/sender_aid/group_id/encrypted 等别名,统一从 msg.envelope.* 读取。
|
|
1466
|
+
// message_id / seq / payload / same_* / dispatch_mode 等仍是顶层独立字段,不在 envelope 内。
|
|
1467
|
+
const env = (msg.envelope && typeof msg.envelope === 'object') ? msg.envelope : {};
|
|
1468
|
+
const groupId = env.group_id ?? '';
|
|
1469
|
+
const senderAid = env.from ?? '';
|
|
1470
|
+
const payload = msg.payload ?? '';
|
|
1471
|
+
const logDescriptor = classifyAunPayloadForLog(payload);
|
|
1472
|
+
const text = this.extractTextPayload(payload, groupId, senderAid);
|
|
1473
|
+
const threadId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
|
|
1474
|
+
const topicName = (typeof payload === 'object' && payload !== null)
|
|
1475
|
+
? this.sanitizeTopicName(payload.topicName)
|
|
1476
|
+
: undefined;
|
|
1477
|
+
const messageId = msg.message_id ?? '';
|
|
1478
|
+
const seq = msg.seq;
|
|
1479
|
+
// Observer forward (inbound):群聊消息在所有过滤之前转发原始明文 payload。
|
|
1480
|
+
// forwardInbound 内部排除 self-echo 与 from-owner。
|
|
1481
|
+
this.forwardInbound(msg);
|
|
1482
|
+
// Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
|
|
1483
|
+
const payloadMentions = Array.isArray(payload?.mentions)
|
|
1484
|
+
? payload.mentions.filter((m) => typeof m === 'string')
|
|
1485
|
+
: [];
|
|
1486
|
+
logger.debug(`${this.logPrefix()}[DIAG-GRP] full_msg=${JSON.stringify(msg).substring(0, 500)}`);
|
|
1487
|
+
if (!groupId || !senderAid) {
|
|
1488
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1489
|
+
logger.debug(`${this.logPrefix()} Group dropped: missing groupId or senderAid (mid=${messageId})`);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const handoffMarker = payload && typeof payload === 'object'
|
|
1493
|
+
? payload[AUN_HANDOFF_MARKER_FIELD]
|
|
1494
|
+
: undefined;
|
|
1495
|
+
const isOwnHandoff = !!this._aid
|
|
1496
|
+
&& senderAid === this._aid
|
|
1497
|
+
&& typeof handoffMarker === 'string'
|
|
1498
|
+
&& /^h-[A-Za-z0-9._-]+$/.test(handoffMarker);
|
|
1499
|
+
if (this._aid && senderAid === this._aid && !isOwnHandoff) {
|
|
1500
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1501
|
+
logger.debug(`${this.logPrefix()} Group dropped: own message (group=${groupId} mid=${messageId})`);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
// 短 echo 快速通道:连通性测试要尽量低延迟,命中后绕过所有 await(后续 mention 过滤)
|
|
1505
|
+
{
|
|
1506
|
+
const firstLineFast = text.split('\n')[0] || '';
|
|
1507
|
+
const hasEvolCoreTrace = /\[EvolCore\.(receive|reply|agent)\]/.test(text);
|
|
1508
|
+
if (/echo/i.test(firstLineFast) && firstLineFast.trim().length <= 10 && !hasEvolCoreTrace) {
|
|
1509
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1510
|
+
const msgEncryptedFast = !!env.encrypted;
|
|
1511
|
+
const msgChatmodeFast = (payload && typeof payload === 'object') ? payload.chatmode : undefined;
|
|
1512
|
+
const peerInfo = this.peerInfoCached(senderAid);
|
|
1513
|
+
const shortAid = this.getShortAid(senderAid);
|
|
1514
|
+
const displayName = peerInfo?.name || shortAid;
|
|
1515
|
+
const createdAt = data.created_at;
|
|
1516
|
+
if (!peerInfo)
|
|
1517
|
+
this.prefetchPeerInfo(senderAid);
|
|
1518
|
+
this.handleEcho({
|
|
1519
|
+
channelId: groupId,
|
|
1520
|
+
userId: senderAid,
|
|
1521
|
+
text,
|
|
1522
|
+
chatType: 'group',
|
|
1523
|
+
messageId,
|
|
1524
|
+
peerName: displayName,
|
|
1525
|
+
peerType: peerInfo?.type || 'unknown',
|
|
1526
|
+
seq,
|
|
1527
|
+
replyContext: this.buildGroupReplyContext(threadId, senderAid, msgEncryptedFast, messageId, msgChatmodeFast, topicName),
|
|
1528
|
+
createdAt,
|
|
1529
|
+
});
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
// action_card_reply 已在 extractTextPayload 中消费,不分发给 agent
|
|
1534
|
+
{
|
|
1535
|
+
const payloadType = (payload && typeof payload === 'object') ? payload.type ?? '' : '';
|
|
1536
|
+
if (payloadType === 'action_card_reply') {
|
|
1537
|
+
this.appendInboundPayloadJsonl(groupId, senderAid, messageId, !!env.encrypted, true, logDescriptor, threadId);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
// ── payload 类型白名单(所有模式生效) ──
|
|
1542
|
+
// 信号类消息(status / event / thought / task.update 等)不进 Agent
|
|
1543
|
+
{
|
|
1544
|
+
const payloadObj = (payload && typeof payload === 'object') ? payload : null;
|
|
1545
|
+
const payloadType = payloadObj?.type ?? '';
|
|
1546
|
+
// menu.* 协议:自定义消息快速路径
|
|
1547
|
+
if (AUNChannel.MENU_REQUEST_TYPES.has(payloadType)) {
|
|
1548
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1549
|
+
this.appendInboundPayloadJsonl(groupId, senderAid, messageId, !!env.encrypted, true, logDescriptor, threadId);
|
|
1550
|
+
this.dispatchMessage({
|
|
1551
|
+
channelId: groupId, userId: senderAid,
|
|
1552
|
+
text: JSON.stringify(payload),
|
|
1553
|
+
chatType: 'group', messageId, seq, groupId,
|
|
1554
|
+
msgType: logDescriptor.msgType,
|
|
1555
|
+
payloadType: logDescriptor.payloadType,
|
|
1556
|
+
payloadSummary: logDescriptor.payloadSummary,
|
|
1557
|
+
messageLogContent: logDescriptor.content,
|
|
1558
|
+
});
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (payloadType && !AUNChannel.PROACTIVE_ALLOW_TYPES.has(payloadType)) {
|
|
1562
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1563
|
+
this.appendInboundPayloadJsonl(groupId, senderAid, messageId, !!env.encrypted, true, logDescriptor, threadId);
|
|
1564
|
+
logger.info(`${this.logPrefix()} Group dropped (type deny): type=${payloadType} group=${groupId} sender=${senderAid} mid=${messageId}`);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
// 记录入站消息加密状态,透传到出站 ReplyContext
|
|
1569
|
+
const msgEncrypted = !!env.encrypted;
|
|
1570
|
+
if (!msgEncrypted)
|
|
1571
|
+
this.plaintextRecv++;
|
|
1572
|
+
// dispatch_mode: 本地设置优先,fallback 到服务器参数
|
|
1573
|
+
const serverDispatchMode = msg.dispatch_mode ?? payload?.dispatch_mode ?? 'mention';
|
|
1574
|
+
const localDispatchMode = this.dispatchModeResolver
|
|
1575
|
+
? await this.dispatchModeResolver(groupId).catch(() => undefined)
|
|
1576
|
+
: undefined;
|
|
1577
|
+
const dispatchMode = localDispatchMode || serverDispatchMode;
|
|
1578
|
+
const mentionedSelf = this._aid
|
|
1579
|
+
? (this.hasExplicitMention(text, this._aid) || payloadMentions.includes(this._aid))
|
|
1580
|
+
: false;
|
|
1581
|
+
// @all 仅认结构化 mentions(payload.mentions),不扫描正文 — 避免引述性 "@all" 误判
|
|
1582
|
+
const mentionedAll = payloadMentions.includes('all');
|
|
1583
|
+
// Echo 机制优先于 mention 过滤:消息第一行包含 echo 时触发
|
|
1584
|
+
// 包含 [EvolCore.xxx] trace 说明已被本系统处理过,是回声的回声,丢弃防止链式爆炸
|
|
1585
|
+
const firstLineGroup = text.split('\n')[0] || '';
|
|
1586
|
+
const hasEvolCoreTraceGroup = /\[EvolCore\.(receive|reply|agent)\]/.test(text);
|
|
1587
|
+
const isEchoMsg = /echo/i.test(firstLineGroup) && !hasEvolCoreTraceGroup;
|
|
1588
|
+
// 命令判定:剥离所有 @ 后看是否 / 开头(多 @ 场景如 @a @b /status 也能正确识别)。
|
|
1589
|
+
// echo 消息走独立的 trace 流程,不参与命令语义判定。
|
|
1590
|
+
const isCommandMsg = !isEchoMsg && this.stripAllMentions(text).startsWith('/');
|
|
1591
|
+
if (isEchoMsg) {
|
|
1592
|
+
// 短 echo(≤10 字符)已在前面的快速通道命中并 return,这里只处理长 echo
|
|
1593
|
+
// >10 字符:追加 trace,存 pending echo,跳过 mention 过滤继续走 Agent 流程
|
|
1594
|
+
const echoTs = () => {
|
|
1595
|
+
const d = new Date();
|
|
1596
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
1597
|
+
};
|
|
1598
|
+
let echoText = text;
|
|
1599
|
+
echoText += `\n${echoTs()} [EvolCore.receive] from=${senderAid} mid=${messageId} chat=group self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
|
|
1600
|
+
const msgChatmodeEcho = (payload && typeof payload === 'object') ? payload.chatmode : undefined;
|
|
1601
|
+
this.pendingEchoMessages.set(messageId, {
|
|
1602
|
+
text: echoText,
|
|
1603
|
+
channelId: groupId,
|
|
1604
|
+
context: this.buildGroupReplyContext(undefined, senderAid, msgEncrypted, messageId, msgChatmodeEcho, undefined),
|
|
1605
|
+
receiveTs: Date.now(),
|
|
1606
|
+
});
|
|
1607
|
+
// 继续走正常 Agent 流程(下面的代码会 dispatch)
|
|
1608
|
+
}
|
|
1609
|
+
else if (/echo/i.test(firstLineGroup) && hasEvolCoreTraceGroup) {
|
|
1610
|
+
// 回声炸弹:已被任何 EvolCore 节点 trace 过的 echo,直接丢弃
|
|
1611
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1612
|
+
logger.info(`${this.logPrefix()} Group dropped: echo bomb (already-traced group=${groupId} sender=${senderAid} mid=${messageId})`);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
// 非 echo 消息:mention 标记(不再过滤,交给响应模式决定)
|
|
1617
|
+
// 命令豁免 broadcast:slash 命令在任何 dispatchMode 下都强制走 mention 语义,
|
|
1618
|
+
// 即必须 @ 本 agent(或 @all)才处理,避免广播群里一条命令被全部 agent 各自执行。
|
|
1619
|
+
const enforceMention = dispatchMode === 'mention' || isCommandMsg;
|
|
1620
|
+
const isMentioned = mentionedSelf || mentionedAll;
|
|
1621
|
+
// 过滤逻辑下移到响应层:这里只标记,不过滤
|
|
1622
|
+
// 但为了保持现有行为兼容(避免大量未 @ 消息涌入),暂时保留过滤
|
|
1623
|
+
// TODO: Phase 6 实施 selective-response 模式后,移除此过滤,只保留 isMentioned 标记
|
|
1624
|
+
if (enforceMention && !isMentioned && !isOwnHandoff) {
|
|
1625
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1626
|
+
logger.info(`${this.logPrefix()} Group dropped: unmentioned (group=${groupId} sender=${senderAid} mid=${messageId} mode=${dispatchMode} isCommand=${isCommandMsg} textPreview=${JSON.stringify(text.slice(0, 80))})`);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// 命令消息:剥离所有 @(多 agent 被 @ 时各自拿到干净的 /status 各自执行);
|
|
1631
|
+
// 普通消息:仅在唯一 @ 是自己时剥离,保留其他 @ 供 agent 感知。
|
|
1632
|
+
const strippedText = isCommandMsg
|
|
1633
|
+
? this.stripAllMentions(text)
|
|
1634
|
+
: this.stripSelfMentionIfOnly(text, this._aid);
|
|
1635
|
+
// Detect attachments before the empty-text guard (顶层 + 嵌套)
|
|
1636
|
+
const rawAttachments = this.collectAllAttachments(payload);
|
|
1637
|
+
const hasAttachments = rawAttachments.length > 0;
|
|
1638
|
+
// Allow through if there's text OR attachments; both-empty messages are silently dropped
|
|
1639
|
+
if (!strippedText && !hasAttachments) {
|
|
1640
|
+
this.acknowledgeImmediately(messageId, seq);
|
|
1641
|
+
logger.debug(`${this.logPrefix()} Group dropped: empty text and no attachments (group=${groupId} sender=${senderAid} mid=${messageId})`);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const mentions = mentionedAll
|
|
1645
|
+
? ['all']
|
|
1646
|
+
: mentionedSelf && this._aid ? [this._aid] : [];
|
|
1647
|
+
// Process attachments
|
|
1648
|
+
const { finalText, images: inboundImages } = await this.processAttachments(payload, strippedText, groupId, rawAttachments);
|
|
1649
|
+
const selfAgentDir = path.join(resolvePaths().agentsDir, this.config.aid);
|
|
1650
|
+
const peerIdentity = await PeerIdentityCache.resolve('aun', senderAid, selfAgentDir, this.store, false);
|
|
1651
|
+
const shortAid = this.getShortAid(senderAid);
|
|
1652
|
+
const displayName = peerIdentity.name || shortAid;
|
|
1653
|
+
// 详细 dispatch 决策日志:记录消息为何被路由到 agent
|
|
1654
|
+
const payloadType = (payload && typeof payload === 'object') ? payload.type ?? '' : '';
|
|
1655
|
+
const textMentionSelf = this._aid ? this.hasExplicitMention(text, this._aid) : false;
|
|
1656
|
+
const textMentionAll = this.hasExplicitMention(text, 'all');
|
|
1657
|
+
const structMentionSelf = this._aid ? payloadMentions.includes(this._aid) : false;
|
|
1658
|
+
const structMentionAll = payloadMentions.includes('all');
|
|
1659
|
+
const reason = isOwnHandoff
|
|
1660
|
+
? 'handoff.self'
|
|
1661
|
+
: mentionedAll
|
|
1662
|
+
? 'mention.all(struct)'
|
|
1663
|
+
: mentionedSelf
|
|
1664
|
+
? (structMentionSelf ? 'mention.self(struct)' : 'mention.self(text)')
|
|
1665
|
+
: `${dispatchMode}.no-mention`;
|
|
1666
|
+
logger.info(`${this.logPrefix()} Group dispatch decision: mid=${messageId} group=${groupId} sender=${shortAid}(${displayName}) peerType=${peerIdentity.type} payloadType=${payloadType} dispatchMode=${dispatchMode} reason=${reason} structMentions=${JSON.stringify(payloadMentions)} textMentionSelf=${textMentionSelf} textMentionAll=${textMentionAll} structMentionSelf=${structMentionSelf} structMentionAll=${structMentionAll} encrypt=${msgEncrypted} textPreview=${JSON.stringify(text.slice(0, 80))}`);
|
|
1667
|
+
// action_card_reply 已在 extractTextPayload 中消费,不分发给 agent
|
|
1668
|
+
if (payloadType === 'action_card_reply')
|
|
1669
|
+
return;
|
|
1670
|
+
const msgChatmode = (payload && typeof payload === 'object') ? payload.chatmode : undefined;
|
|
1671
|
+
logger.info(`${this.logPrefix()} Group dispatched: group=${groupId} sender=${shortAid}(${displayName}) mode=${dispatchMode} mid=${messageId} chatmode=${msgChatmode ?? 'none'} text=${finalText.slice(0, 60)}`);
|
|
1672
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: senderAid, msgId: messageId, kind: 'text', len: finalText.length, groupId });
|
|
1673
|
+
this.aidStatsCollector?.recordInbound(this.config.aid, senderAid, Buffer.byteLength(finalText, 'utf-8'), finalText, payloadType === 'event', msgEncrypted, msgChatmode, payloadType === 'event' ? 'notify' : 'send');
|
|
1674
|
+
// 渲染用完整 @ 列表:结构化 payload.mentions + 正文 @aid 兜底,去重(含 self / "all")。
|
|
1675
|
+
// 与上面用于过滤/回复的精简 mentions 独立——这份不丢任何被 @ 的 AID。
|
|
1676
|
+
const renderMentionAids = Array.from(new Set([
|
|
1677
|
+
...payloadMentions,
|
|
1678
|
+
...this.extractMentionAidsFromText(text),
|
|
1679
|
+
]));
|
|
1680
|
+
this.dispatchMessage({
|
|
1681
|
+
channelId: groupId,
|
|
1682
|
+
groupId,
|
|
1683
|
+
userId: senderAid,
|
|
1684
|
+
peerName: displayName || undefined,
|
|
1685
|
+
peerType: peerIdentity.type,
|
|
1686
|
+
sameDevice: msg.same_device === true || undefined,
|
|
1687
|
+
sameNetwork: msg.same_network === true || undefined,
|
|
1688
|
+
sameEgressIp: msg.same_egress_ip === true || undefined,
|
|
1689
|
+
text: finalText,
|
|
1690
|
+
chatType: 'group',
|
|
1691
|
+
messageId,
|
|
1692
|
+
seq,
|
|
1693
|
+
threadId,
|
|
1694
|
+
mentions,
|
|
1695
|
+
mentionAids: renderMentionAids.length > 0 ? renderMentionAids : undefined,
|
|
1696
|
+
isMentioned: isOwnHandoff || mentionedSelf || mentionedAll,
|
|
1697
|
+
source: isOwnHandoff ? 'handoff' : undefined,
|
|
1698
|
+
encrypted: msgEncrypted,
|
|
1699
|
+
replyContext: this.buildGroupReplyContext(threadId, senderAid, msgEncrypted, messageId, msgChatmode, topicName),
|
|
1700
|
+
dispatchMode: serverDispatchMode,
|
|
1701
|
+
images: inboundImages.length > 0 ? inboundImages : undefined,
|
|
1702
|
+
msgType: logDescriptor.msgType,
|
|
1703
|
+
payloadType: logDescriptor.payloadType,
|
|
1704
|
+
payloadSummary: logDescriptor.payloadSummary,
|
|
1705
|
+
messageLogContent: logDescriptor.msgType === 'text' ? finalText : logDescriptor.content,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
dispatchMessage(event) {
|
|
1709
|
+
// Dedup
|
|
1710
|
+
if (event.messageId) {
|
|
1711
|
+
if (this.seenMessages.has(event.messageId))
|
|
1712
|
+
return;
|
|
1713
|
+
this.seenMessages.set(event.messageId, Date.now());
|
|
1714
|
+
setTimeout(() => this.seenMessages.delete(event.messageId), 5 * 60 * 1000);
|
|
1715
|
+
// Track seq for acknowledge
|
|
1716
|
+
if (event.seq != null) {
|
|
1717
|
+
this.messageSeqMap.set(event.messageId, event.seq);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
// Echo 机制:消息第一行包含 "echo"(不区分大小写)且原始内容 ≤10 字符时,直接回声
|
|
1721
|
+
// 包含 [EvolCore.xxx] trace 说明已被本系统处理过,是回声的回声,丢弃防止链式爆炸
|
|
1722
|
+
const firstLine = event.text.split('\n')[0] || '';
|
|
1723
|
+
const hasEvolCoreTracePrivate = /\[EvolCore\.(receive|reply|agent)\]/.test(event.text);
|
|
1724
|
+
if (/echo/i.test(firstLine) && firstLine.trim().length <= 10 && !hasEvolCoreTracePrivate) {
|
|
1725
|
+
this.handleEcho(event);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
// 回声炸弹:已被任何 EvolCore 节点 trace 过的 echo,直接丢弃(防止多 agent 间无限回声)
|
|
1729
|
+
if (/echo/i.test(firstLine) && hasEvolCoreTracePrivate) {
|
|
1730
|
+
logger.info(`${this.logPrefix()} Dropped: echo bomb (already-traced mid=${event.messageId} chat=${event.chatType})`);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
// 长 echo(>10 字符):存 pending,继续交给 agent 处理
|
|
1734
|
+
if (/echo/i.test(firstLine) && firstLine.trim().length > 10 && !hasEvolCoreTracePrivate) {
|
|
1735
|
+
const echoTs = () => {
|
|
1736
|
+
const d = new Date();
|
|
1737
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
1738
|
+
};
|
|
1739
|
+
let echoText = event.text;
|
|
1740
|
+
echoText += `\n${echoTs()} [EvolCore.receive] from=${event.userId}${event.peerName ? `(${event.peerName})` : ''} mid=${event.messageId} chat=${event.chatType} self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
|
|
1741
|
+
this.pendingEchoMessages.set(event.messageId, {
|
|
1742
|
+
text: echoText,
|
|
1743
|
+
channelId: event.channelId,
|
|
1744
|
+
context: event.replyContext ? { metadata: event.replyContext.metadata } : undefined,
|
|
1745
|
+
receiveTs: Date.now(),
|
|
1746
|
+
});
|
|
1747
|
+
logger.info(`${this.logPrefix()} [Echo] long echo stored: mid=${event.messageId} channelId=${event.channelId}`);
|
|
1748
|
+
}
|
|
1749
|
+
if (!this.messageHandler)
|
|
1750
|
+
return;
|
|
1751
|
+
const mentionObjects = event.mentions?.map(aid => ({ userId: aid }));
|
|
1752
|
+
// Use caller-supplied replyContext (group path builds mentionUserIds);
|
|
1753
|
+
// fall back to simple threadId-only context for private messages
|
|
1754
|
+
let replyContext = event.replyContext;
|
|
1755
|
+
if (!replyContext && event.threadId) {
|
|
1756
|
+
replyContext = { threadId: event.threadId };
|
|
1757
|
+
}
|
|
1758
|
+
this.messageHandler({
|
|
1759
|
+
channelId: event.channelId || '',
|
|
1760
|
+
channelType: 'aun',
|
|
1761
|
+
content: event.text || '',
|
|
1762
|
+
selfAID: this._aid,
|
|
1763
|
+
groupId: event.groupId,
|
|
1764
|
+
chatType: event.chatType,
|
|
1765
|
+
peerId: event.userId || event.channelId || '',
|
|
1766
|
+
peerName: event.peerName,
|
|
1767
|
+
peerType: event.peerType,
|
|
1768
|
+
sameDevice: event.sameDevice,
|
|
1769
|
+
sameNetwork: event.sameNetwork,
|
|
1770
|
+
sameEgressIp: event.sameEgressIp,
|
|
1771
|
+
encrypted: event.encrypted,
|
|
1772
|
+
messageId: event.messageId,
|
|
1773
|
+
causation: event.causation,
|
|
1774
|
+
threadId: event.threadId,
|
|
1775
|
+
mentions: mentionObjects,
|
|
1776
|
+
mentionAids: event.mentionAids,
|
|
1777
|
+
isMentioned: event.isMentioned, // ← 新增:传递 isMentioned 标记
|
|
1778
|
+
replyContext,
|
|
1779
|
+
source: event.source,
|
|
1780
|
+
dispatchMode: event.dispatchMode,
|
|
1781
|
+
images: event.images,
|
|
1782
|
+
msgType: event.msgType,
|
|
1783
|
+
payloadType: event.payloadType,
|
|
1784
|
+
payloadSummary: event.payloadSummary,
|
|
1785
|
+
messageLogContent: event.messageLogContent,
|
|
1786
|
+
}).catch(err => {
|
|
1787
|
+
logger.error(`${this.logPrefix()} Message handler error:`, err);
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
appendInboundPayloadJsonl(channelId, fromAid, msgId, encrypt, isGroup, descriptor, threadId) {
|
|
1791
|
+
try {
|
|
1792
|
+
const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', channelId, this.config.aid);
|
|
1793
|
+
appendMessageLog(chatDir, buildInboundEntry({
|
|
1794
|
+
from: fromAid,
|
|
1795
|
+
to: this.config.aid,
|
|
1796
|
+
threadId: threadId || null,
|
|
1797
|
+
chatType: isGroup ? 'group' : 'private',
|
|
1798
|
+
groupId: isGroup ? channelId : null,
|
|
1799
|
+
msgId,
|
|
1800
|
+
content: descriptor.content,
|
|
1801
|
+
encrypt,
|
|
1802
|
+
msgType: descriptor.msgType,
|
|
1803
|
+
payloadType: descriptor.payloadType,
|
|
1804
|
+
payloadSummary: descriptor.payloadSummary,
|
|
1805
|
+
}));
|
|
1806
|
+
}
|
|
1807
|
+
catch (e) {
|
|
1808
|
+
logger.debug(`${this.logPrefix()} appendInboundPayloadJsonl failed: ${e}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// ── 观察者模式(Observer Mode) ──────────────────────────────
|
|
1812
|
+
//
|
|
1813
|
+
// observable=true 时,Agent 收发的每条 AUN 消息(原始信封 + 解密后明文
|
|
1814
|
+
// payload)镜像一份给 owners[]。入站在所有过滤之前转发;出站在真实发送
|
|
1815
|
+
// 成功后转发。外层一律明文。详见 docs/observer-mode-design.md。
|
|
1816
|
+
// observable / owners 不在此处缓存——由 daemon 注入 resolver,从 EvolAgent 的
|
|
1817
|
+
// in-memory merged config(启动/重启/热重载时统一更新的唯一缓存)读取,避免重复缓存。
|
|
1818
|
+
observerConfigResolver;
|
|
1819
|
+
/** 注入观察者配置读取器(daemon 侧从 EvolAgent merged config 读)。 */
|
|
1820
|
+
setObserverConfigResolver(fn) {
|
|
1821
|
+
this.observerConfigResolver = fn;
|
|
1822
|
+
}
|
|
1823
|
+
/** 读取 observable 开关 + owners;无 resolver(如未接入 daemon)时视为关闭。 */
|
|
1824
|
+
getObserverConfig() {
|
|
1825
|
+
return this.observerConfigResolver?.() ?? { observable: false, owners: [] };
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* 入站转发:到达本 AID 的消息全部转发,排除 self-echo。
|
|
1829
|
+
* 若消息发送方本身是 owner,则不转发给该 owner,但仍转发给其他 owner。
|
|
1830
|
+
* 调用点须在所有过滤逻辑之前,payload 为 SDK 解密后的明文。
|
|
1831
|
+
*/
|
|
1832
|
+
/**
|
|
1833
|
+
* 入站转发:把对端发来的消息原样转发给 owner。
|
|
1834
|
+
* data 为 SDK message.received / group.message_created 回调的整个对象,
|
|
1835
|
+
* 不拆解、不重组——SDK 信封结构变化不影响此处。
|
|
1836
|
+
*/
|
|
1837
|
+
forwardInbound(data) {
|
|
1838
|
+
if (!this.connected || !this.client)
|
|
1839
|
+
return;
|
|
1840
|
+
const { observable, owners } = this.getObserverConfig();
|
|
1841
|
+
if (!observable || owners.length === 0)
|
|
1842
|
+
return;
|
|
1843
|
+
const env = (data?.envelope && typeof data.envelope === 'object') ? data.envelope : {};
|
|
1844
|
+
const from = env.from ?? '';
|
|
1845
|
+
if (this._aid && from === this._aid)
|
|
1846
|
+
return; // self-echo:已在出站转过
|
|
1847
|
+
// 排除来源 owner(不把"owner A 发来的"再转回 A),但仍转给其他 owner。
|
|
1848
|
+
const recipientOwners = owners.filter(o => o !== from);
|
|
1849
|
+
if (recipientOwners.length === 0)
|
|
1850
|
+
return;
|
|
1851
|
+
this.emitForward('inbound', data, recipientOwners);
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* 出站转发:Agent 经 AUN 真实发出的消息原样转发给 owner。
|
|
1855
|
+
* result 为 SDK message.send / group.send 的 SendResult(已 attach envelope + payload)。
|
|
1856
|
+
* 若对端本身是 owner,排除该 owner(不把"回复 A"转发给 A 自己)。
|
|
1857
|
+
*/
|
|
1858
|
+
forwardOutbound(result) {
|
|
1859
|
+
if (!this.connected || !this.client)
|
|
1860
|
+
return;
|
|
1861
|
+
const { observable, owners } = this.getObserverConfig();
|
|
1862
|
+
if (!observable || owners.length === 0)
|
|
1863
|
+
return;
|
|
1864
|
+
const env = (result?.envelope && typeof result.envelope === 'object') ? result.envelope : {};
|
|
1865
|
+
const to = env.to ?? env.group_id ?? '';
|
|
1866
|
+
// 过滤:若对端本身是 owner,不转发给该 owner(避免"回复你"转给你自己);
|
|
1867
|
+
// 但仍转发给其他 owner。
|
|
1868
|
+
const recipientOwners = owners.filter(o => o !== to);
|
|
1869
|
+
if (recipientOwners.length === 0)
|
|
1870
|
+
return;
|
|
1871
|
+
this.emitForward('outbound', result, recipientOwners);
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* 实际投递 observer.forward 给每个 owner,外层一律明文。
|
|
1875
|
+
* original 为 SDK 给到的原始对象(入站回调 data / 出站 SendResult),整体透传,
|
|
1876
|
+
* 不挑字段、不改字段——SDK 加任何字段都会自动一并转发给 owner。
|
|
1877
|
+
*/
|
|
1878
|
+
emitForward(direction, original, owners) {
|
|
1879
|
+
const forwardPayload = {
|
|
1880
|
+
type: 'observer.forward',
|
|
1881
|
+
direction,
|
|
1882
|
+
agent_aid: this.config.aid,
|
|
1883
|
+
original,
|
|
1884
|
+
};
|
|
1885
|
+
for (const ownerAid of owners) {
|
|
1886
|
+
this.callAndTrace('message.send', { to: ownerAid, payload: forwardPayload, encrypt: false })
|
|
1887
|
+
.catch(e => logger.debug(`${this.logPrefix()} observer.forward to ${ownerAid} failed: ${e}`));
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
// ── 观察者插话(Observer Insert,v0.3 待用上下文提示) ──────────────
|
|
1891
|
+
//
|
|
1892
|
+
// owner 经 message.send 给 agent 自身 AID 发 observer.inject(payload 为对象)。
|
|
1893
|
+
// 鉴权 from∈owners 后,把提示【只落盘】到 agent↔对端 会话的 pending-hints.jsonl
|
|
1894
|
+
// (不 dispatch、不跑 LLM、不回 owner);下一条对端消息到达时由 message-processor
|
|
1895
|
+
// 回放消费、注入渲染层。action=add 加提示 / remove 撤销。
|
|
1896
|
+
// 详见 docs/observer-insert-design.md 第一部分。
|
|
1897
|
+
/** 回 observer.inject.ack 给 owner(明文)。accepted 在成功写盘之后发出。 */
|
|
1898
|
+
emitInjectAck(ownerAid, injectId, data, error) {
|
|
1899
|
+
if (!this.connected || !this.client)
|
|
1900
|
+
return;
|
|
1901
|
+
const ackPayload = { type: 'observer.inject.ack' };
|
|
1902
|
+
if (injectId)
|
|
1903
|
+
ackPayload.id = injectId;
|
|
1904
|
+
if (data)
|
|
1905
|
+
ackPayload.data = data;
|
|
1906
|
+
if (error)
|
|
1907
|
+
ackPayload.error = error;
|
|
1908
|
+
this.callAndTrace('message.send', { to: ownerAid, payload: ackPayload, encrypt: false })
|
|
1909
|
+
.catch(e => logger.debug(`${this.logPrefix()} observer.inject.ack to ${ownerAid} failed: ${e}`));
|
|
1910
|
+
}
|
|
1911
|
+
/** 处理 observer.inject:鉴权 + 校验 + 只落盘到 pending-hints(不触发处理、不回 owner)。 */
|
|
1912
|
+
handleObserverInject(fromAid, payload, displayName, peerType) {
|
|
1913
|
+
void peerType;
|
|
1914
|
+
const { owners } = this.getObserverConfig();
|
|
1915
|
+
const ts = Date.now();
|
|
1916
|
+
const req = parseInjectRequest(payload, fromAid, owners, ts);
|
|
1917
|
+
if (req.kind === 'reject') {
|
|
1918
|
+
logger.warn(`${this.logPrefix()} observer.inject rejected: ${this.getShortAid(fromAid)} ${req.code}`);
|
|
1919
|
+
this.emitInjectAck(fromAid, req.injectId, { status: 'rejected', action: req.action }, { code: req.code, message: req.message });
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const selfAID = this.config.aid;
|
|
1923
|
+
const sessionsDir = resolvePaths().sessionsDir;
|
|
1924
|
+
let ok;
|
|
1925
|
+
if (req.kind === 'remove') {
|
|
1926
|
+
ok = appendHintRemove(sessionsDir, 'aun', req.channelId, selfAID, { targetId: req.targetId, threadId: req.threadId, ts });
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
ok = appendHintAdd(sessionsDir, 'aun', req.channelId, selfAID, { id: req.id, text: req.text, threadId: req.threadId, ownerAid: req.ownerAid, ts });
|
|
1930
|
+
}
|
|
1931
|
+
if (!ok) {
|
|
1932
|
+
this.emitInjectAck(fromAid, req.injectId, { status: 'rejected', action: req.kind }, { code: 'STORE_FAILED', message: '提示落盘失败' });
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
logger.info(`${this.logPrefix()} observer.inject ${req.kind} stored: from=${this.getShortAid(fromAid)}(${displayName}) target=${req.channelId} chatType=${req.chatType} thread=${req.threadId ?? 'main'}${req.kind === 'add' ? ` textLen=${req.text.length}` : `${req.targetId ? ` targetId=${req.targetId}` : ' (clear-all)'}`}`);
|
|
1936
|
+
this.emitInjectAck(fromAid, req.injectId, { status: 'accepted', action: req.kind });
|
|
1937
|
+
// 记录到 watch(被观察的 agent↔对端 会话),带 owner-inject 标记区分对端真实消息。
|
|
1938
|
+
// v0.3:只记"提示已添加/已撤销",不触发处理、不产生 agent→owner 回应。
|
|
1939
|
+
const watchText = req.kind === 'remove' ? `[撤销提示]${req.targetId ? ` id=${req.targetId}` : '(全部)'}` : req.text;
|
|
1940
|
+
const synthId = `inject-${req.injectId || ts}`;
|
|
1941
|
+
this.recordInjectWatch('in', fromAid, req.channelId, req.chatType, synthId, watchText, req.threadId);
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* 把 observer 插话 / 对插话的回应记录到 watch(被观察的 agent↔对端 会话),
|
|
1945
|
+
* 带 source='owner-inject' 标记,与对端真实消息区分。
|
|
1946
|
+
* 写三处:messages.jsonl(watch msg)、appendAidEvent(watch aid 事件流)、aidStatsCollector(统计)。
|
|
1947
|
+
* @param dir 'in'=owner→agent 插话;'out'=agent→owner 对插话的回应
|
|
1948
|
+
* @param peerChannelId 被观察会话的对端(agent↔对端),日志落点 = sessions/aun/<self>/<peerChannelId>/
|
|
1949
|
+
*/
|
|
1950
|
+
recordInjectWatch(dir, ownerAid, peerChannelId, chatType, msgId, text, threadId) {
|
|
1951
|
+
try {
|
|
1952
|
+
const selfAID = this.config.aid;
|
|
1953
|
+
const isGroup = chatType === 'group';
|
|
1954
|
+
const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', peerChannelId, selfAID);
|
|
1955
|
+
const entry = dir === 'in'
|
|
1956
|
+
? buildInboundEntry({
|
|
1957
|
+
from: ownerAid, to: selfAID, chatType,
|
|
1958
|
+
threadId: threadId || null,
|
|
1959
|
+
groupId: isGroup ? peerChannelId : null, msgId, content: text,
|
|
1960
|
+
permMode: 'owner', source: 'owner-inject',
|
|
1961
|
+
})
|
|
1962
|
+
: buildOutboundEntry({
|
|
1963
|
+
from: selfAID, to: ownerAid, chatType,
|
|
1964
|
+
threadId: threadId || null,
|
|
1965
|
+
groupId: isGroup ? peerChannelId : null, msgId, content: text,
|
|
1966
|
+
source: 'owner-inject',
|
|
1967
|
+
});
|
|
1968
|
+
appendMessageLog(chatDir, entry);
|
|
1969
|
+
}
|
|
1970
|
+
catch (e) {
|
|
1971
|
+
logger.debug(`${this.logPrefix()} recordInjectWatch(msg) failed: ${e}`);
|
|
1972
|
+
}
|
|
1973
|
+
// watch aid:事件流 + 统计(标 inject,便于过滤)
|
|
1974
|
+
try {
|
|
1975
|
+
const len = Buffer.byteLength(text, 'utf-8');
|
|
1976
|
+
if (dir === 'in') {
|
|
1977
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_in', aid: this.config.aid, from: ownerAid, msgId, kind: 'text', len, inject: true });
|
|
1978
|
+
this.aidStatsCollector?.recordInbound(this.config.aid, ownerAid, len, text, false, false, 'inject', 'inject');
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: ownerAid, msgId, kind: 'text', len, inject: true });
|
|
1982
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, ownerAid, len, text, false, false, 'inject', 'inject');
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
catch (e) {
|
|
1986
|
+
logger.debug(`${this.logPrefix()} recordInjectWatch(aid) failed: ${e}`);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
handleEcho(event) {
|
|
1990
|
+
const ts = () => {
|
|
1991
|
+
const d = new Date();
|
|
1992
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
1993
|
+
};
|
|
1994
|
+
// 在收到的消息文本末尾追加 trace 行(只追加,不修改原文)
|
|
1995
|
+
let echoText = event.text;
|
|
1996
|
+
echoText += `\n${ts()} [EvolCore.receive] from=${event.userId}${event.peerName ? `(${event.peerName})` : ''} mid=${event.messageId} chat=${event.chatType} self=${this._aid || 'unknown'} conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
|
|
1997
|
+
echoText += `\n${ts()} [EvolCore.reply] echo回声发出 conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
|
|
1998
|
+
if (Buffer.byteLength(echoText, 'utf-8') > 4096) {
|
|
1999
|
+
echoText += `\n[TRUNCATED]`;
|
|
2000
|
+
}
|
|
2001
|
+
const replyTarget = event.channelId;
|
|
2002
|
+
// 不传 peerId,避免 sendMessage 在头部追加 @peer
|
|
2003
|
+
const context = { threadId: event.threadId, metadata: event.replyContext?.metadata };
|
|
2004
|
+
const sendStart = Date.now();
|
|
2005
|
+
this.isEchoSending = true;
|
|
2006
|
+
this.sendMessage(replyTarget, echoText, context).then(() => {
|
|
2007
|
+
this.isEchoSending = false;
|
|
2008
|
+
const elapsed = Date.now() - sendStart;
|
|
2009
|
+
logger.info(`${this.logPrefix()} Echo reply sent to ${replyTarget} (${elapsed}ms)`);
|
|
2010
|
+
}).catch(e => {
|
|
2011
|
+
this.isEchoSending = false;
|
|
2012
|
+
logger.error(`${this.logPrefix()} Echo reply failed: ${e}`);
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* 处理 SDK 'gateway.disconnect' 事件 — 服务端主动断开(含同槽位互踢)。
|
|
2017
|
+
*
|
|
2018
|
+
* 当另一个进程用同 AID + 同 slot 抢连接时,老连接会被踢,detail 字段含:
|
|
2019
|
+
* - self_extra_info: 本进程当时 connect 上报的内容(可证明"是我自己被踢了")
|
|
2020
|
+
* - new_extra_info: 挤掉本连接的新进程上报的内容(指出"谁踢的我")
|
|
2021
|
+
*
|
|
2022
|
+
* 把这些信息打到 logger.warn,便于通过 ec watch / 日志查看。
|
|
2023
|
+
*/
|
|
2024
|
+
handleGatewayDisconnect(data) {
|
|
2025
|
+
if (!data || typeof data !== 'object')
|
|
2026
|
+
return;
|
|
2027
|
+
const d = data;
|
|
2028
|
+
const code = d.code;
|
|
2029
|
+
const reason = d.reason ?? '';
|
|
2030
|
+
const detail = (d.detail && typeof d.detail === 'object' && !Array.isArray(d.detail)) ? d.detail : {};
|
|
2031
|
+
const selfExtra = detail.self_extra_info;
|
|
2032
|
+
const newExtra = detail.new_extra_info;
|
|
2033
|
+
const fmtExtra = (e) => {
|
|
2034
|
+
if (!e || typeof e !== 'object')
|
|
2035
|
+
return '<none>';
|
|
2036
|
+
const obj = e;
|
|
2037
|
+
const parts = [];
|
|
2038
|
+
const keys = ['app', 'version', 'pid', 'started_at_iso', 'hostname', 'launched_by', 'evolcore_home', 'agent_name', 'channel_name'];
|
|
2039
|
+
for (const k of keys) {
|
|
2040
|
+
if (obj[k] !== undefined && obj[k] !== '')
|
|
2041
|
+
parts.push(`${k}=${String(obj[k])}`);
|
|
2042
|
+
}
|
|
2043
|
+
return parts.length ? parts.join(' ') : JSON.stringify(obj);
|
|
2044
|
+
};
|
|
2045
|
+
if (selfExtra || newExtra) {
|
|
2046
|
+
logger.warn(`${this.logPrefix()} 🥊 Kicked by another connection (code=${code} reason=${reason})`);
|
|
2047
|
+
logger.warn(`${this.logPrefix()} ↳ me : ${fmtExtra(selfExtra)}`);
|
|
2048
|
+
logger.warn(`${this.logPrefix()} ↳ them: ${fmtExtra(newExtra)}`);
|
|
2049
|
+
}
|
|
2050
|
+
else {
|
|
2051
|
+
logger.warn(`${this.logPrefix()} Server-initiated disconnect: code=${code} reason=${reason} detail=${JSON.stringify(detail)}`);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
handleConnectionState(data) {
|
|
2055
|
+
if (!data || typeof data !== 'object')
|
|
2056
|
+
return;
|
|
2057
|
+
const state = data.state ?? '';
|
|
2058
|
+
if (state === 'connected') {
|
|
2059
|
+
this.connected = true;
|
|
2060
|
+
this.connectedAt = Date.now();
|
|
2061
|
+
this.lastReconnectLogTime = 0;
|
|
2062
|
+
this.lastReconnectLogAttempt = 0;
|
|
2063
|
+
// connection.state 事件 payload 带实际连接的 gateway,更新本地缓存
|
|
2064
|
+
const evtGateway = data.gateway;
|
|
2065
|
+
if (typeof evtGateway === 'string' && evtGateway)
|
|
2066
|
+
this.gatewayUrl = evtGateway;
|
|
2067
|
+
this.setAidStatus('connected', { lastConnectedAt: Date.now(), lastError: undefined, gatewayUrl: this.gatewayUrl });
|
|
2068
|
+
this.trace('IN', 'connection.state', data);
|
|
2069
|
+
logger.info(`${this.logPrefix()} Connected`);
|
|
2070
|
+
// 不在这里清 flapCount —— 短命连接一上来就会触发本分支,
|
|
2071
|
+
// 必须等 disconnected 时根据 lifetime 决定是否清零
|
|
2072
|
+
this.drainOutbox();
|
|
2073
|
+
}
|
|
2074
|
+
else if (state === 'disconnected' || state === 'reconnecting') {
|
|
2075
|
+
const wasConnected = this.connected;
|
|
2076
|
+
this.connected = false;
|
|
2077
|
+
this.aidState.reconnectCount++;
|
|
2078
|
+
this.aidState.lastAttemptAt = Date.now();
|
|
2079
|
+
this.setAidStatus('reconnecting');
|
|
2080
|
+
// Flap 检测:仅在从 connected 状态过渡时统计
|
|
2081
|
+
if (wasConnected && this.connectedAt > 0) {
|
|
2082
|
+
const lifetime = Date.now() - this.connectedAt;
|
|
2083
|
+
this.connectedAt = 0;
|
|
2084
|
+
if (lifetime < AUNChannel.FLAP_WINDOW_MS) {
|
|
2085
|
+
this.flapCount++;
|
|
2086
|
+
logger.warn(`${this.logPrefix()} Flap #${this.flapCount}/${AUNChannel.FLAP_THRESHOLD}: connection lived ${lifetime}ms (< ${AUNChannel.FLAP_WINDOW_MS}ms)`);
|
|
2087
|
+
if (this.flapCount >= AUNChannel.FLAP_THRESHOLD && !this.intentionalDisconnect) {
|
|
2088
|
+
logger.error(`${this.logPrefix()} Persistent kick detected (${this.flapCount} flaps), taking over from SDK with ${AUNChannel.TAKEOVER_DELAY_MS / 1000}s backoff`);
|
|
2089
|
+
this.flapCount = 0;
|
|
2090
|
+
this.takeoverReconnect(AUNChannel.TAKEOVER_DELAY_MS, 'flap');
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
else {
|
|
2095
|
+
// 连接稳定过 ≥ FLAP_WINDOW_MS,重置 flap 计数
|
|
2096
|
+
if (this.flapCount > 0) {
|
|
2097
|
+
logger.info(`${this.logPrefix()} Stable connection (lived ${lifetime}ms), resetting flap counter`);
|
|
2098
|
+
}
|
|
2099
|
+
this.flapCount = 0;
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
if (state === 'disconnected') {
|
|
2103
|
+
this.trace('IN', 'connection.state', data);
|
|
2104
|
+
logger.warn(`${this.logPrefix()} Disconnected: ${data.error ?? 'unknown'}`);
|
|
2105
|
+
}
|
|
2106
|
+
else {
|
|
2107
|
+
// reconnecting:节流日志(SDK 自己已经在跑指数退避,不刷屏)
|
|
2108
|
+
const attempt = data.attempt ?? 0;
|
|
2109
|
+
const now = Date.now();
|
|
2110
|
+
const isFirst = attempt <= 1;
|
|
2111
|
+
const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
|
|
2112
|
+
const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
|
|
2113
|
+
if (isFirst || isStep || isInterval) {
|
|
2114
|
+
const suppressed = attempt - this.lastReconnectLogAttempt - 1;
|
|
2115
|
+
const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
|
|
2116
|
+
logger.info(`${this.logPrefix()} SDK reconnecting (attempt ${attempt}${suffix})`);
|
|
2117
|
+
this.lastReconnectLogTime = now;
|
|
2118
|
+
this.lastReconnectLogAttempt = attempt;
|
|
2119
|
+
this.trace('IN', 'connection.state', data);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
else if (state === 'terminal_failed') {
|
|
2124
|
+
this.connected = false;
|
|
2125
|
+
this.connectedAt = 0;
|
|
2126
|
+
this.trace('IN', 'connection.state', data);
|
|
2127
|
+
const d = data;
|
|
2128
|
+
const reason = d.reason ?? '';
|
|
2129
|
+
const error = d.error ?? 'unknown';
|
|
2130
|
+
const code = d.code ?? d.detail?.code ?? 0;
|
|
2131
|
+
const detail = (d.detail && typeof d.detail === 'object') ? d.detail : {};
|
|
2132
|
+
if (this.intentionalDisconnect)
|
|
2133
|
+
return;
|
|
2134
|
+
if (this.isKickReason(reason) || code >= 4001) {
|
|
2135
|
+
// @ts-ignore — methods defined below in same class
|
|
2136
|
+
const kickDetail = this.buildKickDetail(code, reason, detail);
|
|
2137
|
+
// @ts-ignore — methods defined below in same class
|
|
2138
|
+
const action = this.classifyKickAction(code);
|
|
2139
|
+
appendAidEvent({
|
|
2140
|
+
ts: Date.now(), iso: new Date().toISOString(),
|
|
2141
|
+
event: 'kicked', aid: this.config.aid,
|
|
2142
|
+
code, reason, action,
|
|
2143
|
+
evictedBy: kickDetail.evictedBy,
|
|
2144
|
+
quotaKind: kickDetail.quotaKind,
|
|
2145
|
+
});
|
|
2146
|
+
appendAidLifecycle({
|
|
2147
|
+
ts: Date.now(), iso: new Date().toISOString(),
|
|
2148
|
+
event: 'kicked', aid: this.config.aid,
|
|
2149
|
+
code, reason, action,
|
|
2150
|
+
evictedBy: kickDetail.evictedBy,
|
|
2151
|
+
newExtra: kickDetail.newExtra,
|
|
2152
|
+
quotaKind: kickDetail.quotaKind,
|
|
2153
|
+
});
|
|
2154
|
+
if (action === 'no_retry') {
|
|
2155
|
+
logger.error(`${this.logPrefix()} Kicked (code=${code}): ${reason} — will NOT retry`);
|
|
2156
|
+
this.setAidStatus('kicked_no_retry', { lastError: `kicked(${code}): ${reason}`.slice(0, 80), kickDetail });
|
|
2157
|
+
}
|
|
2158
|
+
else if (action === 'retry_once') {
|
|
2159
|
+
logger.warn(`${this.logPrefix()} Kicked (code=${code}): ${reason} — retrying once after ${AUNChannel.FALLBACK_DELAY_MS / 1000}s`);
|
|
2160
|
+
this.setAidStatus('kicked', { lastError: `kicked(${code}): ${reason}`.slice(0, 80), kickDetail });
|
|
2161
|
+
this.takeoverReconnect(AUNChannel.FALLBACK_DELAY_MS, 'kicked');
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
logger.warn(`${this.logPrefix()} Kicked (code=${code}): ${reason} — retrying after ${AUNChannel.TAKEOVER_DELAY_MS / 1000}s`);
|
|
2165
|
+
this.setAidStatus('kicked', { lastError: `kicked(${code}): ${reason}`.slice(0, 80), kickDetail });
|
|
2166
|
+
this.takeoverReconnect(AUNChannel.TAKEOVER_DELAY_MS, 'kicked');
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
else {
|
|
2170
|
+
logger.error(`${this.logPrefix()} Terminal failure: ${error}${reason ? ` (${reason})` : ''}, retrying in ${AUNChannel.FALLBACK_DELAY_MS / 1000}s`);
|
|
2171
|
+
this.setAidStatus('failed', { lastError: `${error}`.slice(0, 80) });
|
|
2172
|
+
this.takeoverReconnect(AUNChannel.FALLBACK_DELAY_MS, 'terminal');
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
/** 判断 terminal_failed 的 reason 是否属于"被踢"类 */
|
|
2177
|
+
isKickReason(reason) {
|
|
2178
|
+
if (!reason)
|
|
2179
|
+
return false;
|
|
2180
|
+
const r = reason.toLowerCase();
|
|
2181
|
+
if (r.includes('kicked') || r.includes('kick'))
|
|
2182
|
+
return true;
|
|
2183
|
+
if (/close code 40\d{2}/.test(r))
|
|
2184
|
+
return true;
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* 根据 close code 决定重试策略:
|
|
2189
|
+
* - 'no_retry': 不重试(被挤掉、AID 无效、ACL 拒绝、长连接已存在、配额超限)
|
|
2190
|
+
* - 'retry_once': 重试一次(auth 失败可能 token 刚过期、nonce 无效)
|
|
2191
|
+
* - 'retry_delay': 延迟重试(短连接容量超限、空闲超时)
|
|
2192
|
+
*/
|
|
2193
|
+
classifyKickAction(code) {
|
|
2194
|
+
switch (code) {
|
|
2195
|
+
case 4003: // AID 无效
|
|
2196
|
+
case 4009: // 服务端主动踢
|
|
2197
|
+
case 4011: // ACL 拒绝
|
|
2198
|
+
case 4012: // 长连接已存在(自己另一个实例在线)
|
|
2199
|
+
case 4015: // 被新连接挤掉
|
|
2200
|
+
return 'no_retry';
|
|
2201
|
+
case 4001: // auth 失败(token 可能刚过期)
|
|
2202
|
+
case 4010: // nonce 无效
|
|
2203
|
+
return 'retry_once';
|
|
2204
|
+
case 4008: // auth 超时
|
|
2205
|
+
case 4013: // 短连接容量超限
|
|
2206
|
+
case 4014: // 短连接空闲超时
|
|
2207
|
+
return 'retry_delay';
|
|
2208
|
+
default:
|
|
2209
|
+
return 'retry_delay';
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
buildKickDetail(code, reason, detail) {
|
|
2213
|
+
const evictedByRaw = detail.evicted_by || detail.new_extra_info;
|
|
2214
|
+
let evictedBy;
|
|
2215
|
+
if (evictedByRaw && typeof evictedByRaw === 'object') {
|
|
2216
|
+
evictedBy = {
|
|
2217
|
+
aid: evictedByRaw.aid,
|
|
2218
|
+
deviceId: evictedByRaw.device_id,
|
|
2219
|
+
slotId: evictedByRaw.slot_id,
|
|
2220
|
+
app: evictedByRaw.app,
|
|
2221
|
+
hostname: evictedByRaw.hostname,
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
return {
|
|
2225
|
+
code,
|
|
2226
|
+
reason,
|
|
2227
|
+
ts: Date.now(),
|
|
2228
|
+
evictedBy,
|
|
2229
|
+
quotaKind: detail.quota_kind,
|
|
2230
|
+
limit: detail.limit,
|
|
2231
|
+
selfExtra: detail.self_extra_info,
|
|
2232
|
+
newExtra: detail.new_extra_info,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* TS 层接管重连:force close 当前 SDK client,安排 delayMs 后重新 initClient。
|
|
2237
|
+
* 用于 flap / kicked / terminal_failed 三类场景,统一退避路径。
|
|
2238
|
+
*/
|
|
2239
|
+
takeoverReconnect(delayMs, reason) {
|
|
2240
|
+
if (this.intentionalDisconnect)
|
|
2241
|
+
return;
|
|
2242
|
+
// 递增 generation,使任何正在进行的旧 initClient 回调失效
|
|
2243
|
+
const gen = ++this.reconnectGeneration;
|
|
2244
|
+
// 清掉已有 timer,避免叠加
|
|
2245
|
+
if (this.reconnectTimer) {
|
|
2246
|
+
clearTimeout(this.reconnectTimer);
|
|
2247
|
+
this.reconnectTimer = null;
|
|
2248
|
+
}
|
|
2249
|
+
// Force close SDK client,中断它内部的重连循环
|
|
2250
|
+
if (this.client) {
|
|
2251
|
+
this.trace('OUT', 'client.close', { reason: `takeover_${reason}` });
|
|
2252
|
+
this.client.close().catch(() => { });
|
|
2253
|
+
this.client = null;
|
|
2254
|
+
}
|
|
2255
|
+
this.connected = false;
|
|
2256
|
+
const delaySec = Math.round(delayMs / 1000);
|
|
2257
|
+
logger.info(`${this.logPrefix()} Scheduling TS-layer reconnect (${reason}) in ${delaySec}s`);
|
|
2258
|
+
this.trace('OUT', 'reconnect.scheduled', { reason, delayMs, generation: gen });
|
|
2259
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
2260
|
+
this.reconnectTimer = null;
|
|
2261
|
+
// 如果在等待期间又触发了新的 takeoverReconnect,本次作废
|
|
2262
|
+
if (gen !== this.reconnectGeneration) {
|
|
2263
|
+
logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) cancelled: generation stale (${gen} vs ${this.reconnectGeneration})`);
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
try {
|
|
2267
|
+
logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) starting...`);
|
|
2268
|
+
this.trace('OUT', 'reconnect.start', { reason, generation: gen });
|
|
2269
|
+
await this.initClient();
|
|
2270
|
+
// initClient 完成后再次校验 generation,防止 initClient 期间被新的 takeover 取代
|
|
2271
|
+
if (gen !== this.reconnectGeneration) {
|
|
2272
|
+
logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) succeeded but generation stale, closing stale client`);
|
|
2273
|
+
if (this.client) {
|
|
2274
|
+
this.client.close().catch(() => { });
|
|
2275
|
+
this.client = null;
|
|
2276
|
+
}
|
|
2277
|
+
this.connected = false;
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
this.trace('OUT', 'reconnect.ok', { reason, generation: gen });
|
|
2281
|
+
logger.info(`${this.logPrefix()} TS-layer reconnect (${reason}) succeeded`);
|
|
2282
|
+
}
|
|
2283
|
+
catch (err) {
|
|
2284
|
+
this.trace('OUT', 'reconnect.error', { reason, error: String(err), generation: gen });
|
|
2285
|
+
logger.error(`${this.logPrefix()} TS-layer reconnect (${reason}) failed: ${err}`);
|
|
2286
|
+
// initClient 内部已经在失败路径触发 scheduleReconnect 了,这里不重复
|
|
2287
|
+
}
|
|
2288
|
+
}, delayMs);
|
|
2289
|
+
}
|
|
2290
|
+
// ── Public API (same interface as before) ───────────────────
|
|
2291
|
+
setEventBus(bus) {
|
|
2292
|
+
// 重新订阅前先解掉旧的——避免 reload/重连后 listener 累积
|
|
2293
|
+
if (this.eventBus && this.ownerBoundHandler && typeof this.eventBus.unsubscribe === 'function') {
|
|
2294
|
+
this.eventBus.unsubscribe('channel:owner-bound', this.ownerBoundHandler);
|
|
2295
|
+
}
|
|
2296
|
+
if (this.eventBus && this.queuedHandler && typeof this.eventBus.unsubscribe === 'function') {
|
|
2297
|
+
this.eventBus.unsubscribe('task:queued', this.queuedHandler);
|
|
2298
|
+
}
|
|
2299
|
+
this.ownerBoundHandler = null;
|
|
2300
|
+
this.queuedHandler = null;
|
|
2301
|
+
this.eventBus = bus;
|
|
2302
|
+
if (bus && typeof bus.subscribe === 'function') {
|
|
2303
|
+
const handler = (event) => {
|
|
2304
|
+
if (event.channelName !== this.config.channelName)
|
|
2305
|
+
return;
|
|
2306
|
+
// sendWelcomeMessage 内部读 config.json 中最新的 owners[0],并幂等检查 initialized
|
|
2307
|
+
// 自身做 client 健康检查后再发
|
|
2308
|
+
if (!this.client) {
|
|
2309
|
+
logger.info(`${this.logPrefix()} owner-bound event received but client not connected; skip welcome retry`);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
this.sendWelcomeMessage().catch(e => {
|
|
2313
|
+
logger.warn(`${this.logPrefix()} owner-bound welcome retry failed: ${e}`);
|
|
2314
|
+
});
|
|
2315
|
+
};
|
|
2316
|
+
bus.subscribe('channel:owner-bound', handler);
|
|
2317
|
+
this.ownerBoundHandler = handler;
|
|
2318
|
+
const queuedHandler = (event) => {
|
|
2319
|
+
if (event.channel !== this.config.channelName)
|
|
2320
|
+
return;
|
|
2321
|
+
this.sendProcessingStatus(event.channelId, 'queued', '', '', event.replyContext);
|
|
2322
|
+
};
|
|
2323
|
+
bus.subscribe('task:queued', queuedHandler);
|
|
2324
|
+
this.queuedHandler = queuedHandler;
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
onProjectPathRequest(provider) {
|
|
2328
|
+
this.projectPathProvider = provider;
|
|
2329
|
+
}
|
|
2330
|
+
onMessage(handler) {
|
|
2331
|
+
this.messageHandler = handler;
|
|
2332
|
+
}
|
|
2333
|
+
setDispatchModeResolver(resolver) {
|
|
2334
|
+
this.dispatchModeResolver = resolver;
|
|
2335
|
+
}
|
|
2336
|
+
onRecall(handler) {
|
|
2337
|
+
this.recallHandler = handler;
|
|
2338
|
+
}
|
|
2339
|
+
async withOutboxInFlight(entry, run, busyValue) {
|
|
2340
|
+
if (this.outboxInFlight.has(entry.id))
|
|
2341
|
+
return busyValue;
|
|
2342
|
+
this.outboxInFlight.add(entry.id);
|
|
2343
|
+
try {
|
|
2344
|
+
return await run();
|
|
2345
|
+
}
|
|
2346
|
+
finally {
|
|
2347
|
+
this.outboxInFlight.delete(entry.id);
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
messageIdFromSendResult(result) {
|
|
2351
|
+
return result?.message?.message_id ?? result?.message_id ?? null;
|
|
2352
|
+
}
|
|
2353
|
+
stripUndefinedDeep(value) {
|
|
2354
|
+
if (Array.isArray(value)) {
|
|
2355
|
+
return value
|
|
2356
|
+
.filter((item) => item !== undefined)
|
|
2357
|
+
.map((item) => this.stripUndefinedDeep(item));
|
|
2358
|
+
}
|
|
2359
|
+
if (!value || typeof value !== 'object')
|
|
2360
|
+
return value;
|
|
2361
|
+
const cleaned = {};
|
|
2362
|
+
for (const [key, item] of Object.entries(value)) {
|
|
2363
|
+
if (item === undefined)
|
|
2364
|
+
continue;
|
|
2365
|
+
cleaned[key] = this.stripUndefinedDeep(item);
|
|
2366
|
+
}
|
|
2367
|
+
return cleaned;
|
|
2368
|
+
}
|
|
2369
|
+
payloadLogText(payload, contentKind) {
|
|
2370
|
+
if (typeof payload.text === 'string' && payload.text)
|
|
2371
|
+
return payload.text;
|
|
2372
|
+
switch (contentKind ?? payload.type) {
|
|
2373
|
+
case 'image':
|
|
2374
|
+
return payload.alt ? `[image] ${payload.alt}` : '[image]';
|
|
2375
|
+
case 'card':
|
|
2376
|
+
case 'action_card':
|
|
2377
|
+
return payload.title ? `[card] ${payload.title}` : '[card]';
|
|
2378
|
+
case 'file':
|
|
2379
|
+
return payload.filename ? `[file] ${payload.filename}` : '[file]';
|
|
2380
|
+
default:
|
|
2381
|
+
return `[${String(payload.type ?? contentKind ?? 'payload')}]`;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
payloadByteLength(payload, logText) {
|
|
2385
|
+
if (payload.type === 'image' && typeof payload.data_base64 === 'string') {
|
|
2386
|
+
return Buffer.byteLength(payload.data_base64, 'utf-8');
|
|
2387
|
+
}
|
|
2388
|
+
return Buffer.byteLength(logText, 'utf-8');
|
|
2389
|
+
}
|
|
2390
|
+
applyReplyContextToPayload(payload, context) {
|
|
2391
|
+
const finalPayload = this.stripUndefinedDeep({ ...payload });
|
|
2392
|
+
if (context?.threadId && !finalPayload.thread_id)
|
|
2393
|
+
finalPayload.thread_id = context.threadId;
|
|
2394
|
+
if (context?.replyToMessageId && !finalPayload.ref_message_id)
|
|
2395
|
+
finalPayload.ref_message_id = context.replyToMessageId;
|
|
2396
|
+
if (context?.metadata?.taskId && !finalPayload.task_id)
|
|
2397
|
+
finalPayload.task_id = context.metadata.taskId;
|
|
2398
|
+
if (context?.metadata?.chatmode && !finalPayload.chatmode)
|
|
2399
|
+
finalPayload.chatmode = context.metadata.chatmode;
|
|
2400
|
+
return finalPayload;
|
|
2401
|
+
}
|
|
2402
|
+
rememberInvalidatedInteraction(interactionId, reason) {
|
|
2403
|
+
this.invalidatedInteractions.set(interactionId, reason);
|
|
2404
|
+
const timer = setTimeout(() => this.invalidatedInteractions.delete(interactionId), 24 * 60 * 60 * 1000);
|
|
2405
|
+
timer.unref?.();
|
|
2406
|
+
}
|
|
2407
|
+
async recallInteractionCard(messageId) {
|
|
2408
|
+
if (!this.client || !this.connected)
|
|
2409
|
+
return false;
|
|
2410
|
+
try {
|
|
2411
|
+
await this.callAndTrace('message.recall', { message_ids: [messageId] }, { silentError: true });
|
|
2412
|
+
return true;
|
|
2413
|
+
}
|
|
2414
|
+
catch (error) {
|
|
2415
|
+
logger.debug(`${this.logPrefix()} Failed to recall invalidated interaction card mid=${messageId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2416
|
+
return false;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* Best-effort visual invalidation for AUN action cards. AUN has no card
|
|
2421
|
+
* PATCH primitive, so recall the original message; the response router still
|
|
2422
|
+
* rejects any concurrent/late callback independently.
|
|
2423
|
+
*/
|
|
2424
|
+
async invalidateInteraction(interactionId, reason = 'cancelled') {
|
|
2425
|
+
this.rememberInvalidatedInteraction(interactionId, reason);
|
|
2426
|
+
const removedQueued = outbox.removeInteractionCards(this.config.aid, interactionId);
|
|
2427
|
+
if (removedQueued > 0) {
|
|
2428
|
+
logger.info(`${this.logPrefix()} Removed ${removedQueued} cancelled interaction card(s) from durable outbox: request=${interactionId}`);
|
|
2429
|
+
}
|
|
2430
|
+
const messageIds = [...(this.interactionCardMessageIds.get(interactionId) ?? [])];
|
|
2431
|
+
for (const messageId of messageIds) {
|
|
2432
|
+
this.forgetInteractionCard(messageId, interactionId);
|
|
2433
|
+
await this.recallInteractionCard(messageId);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
forgetInteractionCard(messageId, interactionId) {
|
|
2437
|
+
this.cardMessageIdMap.delete(messageId);
|
|
2438
|
+
const ids = this.interactionCardMessageIds.get(interactionId);
|
|
2439
|
+
ids?.delete(messageId);
|
|
2440
|
+
if (ids && ids.size === 0)
|
|
2441
|
+
this.interactionCardMessageIds.delete(interactionId);
|
|
2442
|
+
}
|
|
2443
|
+
registerCardPostSend(messageId, action) {
|
|
2444
|
+
if (action.type !== 'register_interaction_card')
|
|
2445
|
+
return;
|
|
2446
|
+
this.cardMessageIdMap.set(messageId, {
|
|
2447
|
+
requestId: action.requestId,
|
|
2448
|
+
isCommandCard: action.isCommandCard,
|
|
2449
|
+
initiatorAid: action.initiatorAid,
|
|
2450
|
+
});
|
|
2451
|
+
this.ownedCardMsgIds.add(messageId);
|
|
2452
|
+
let messageIds = this.interactionCardMessageIds.get(action.requestId);
|
|
2453
|
+
if (!messageIds) {
|
|
2454
|
+
messageIds = new Set();
|
|
2455
|
+
this.interactionCardMessageIds.set(action.requestId, messageIds);
|
|
2456
|
+
}
|
|
2457
|
+
messageIds.add(messageId);
|
|
2458
|
+
const now = Date.now();
|
|
2459
|
+
const mapTtl = action.expiresAt && action.expiresAt > now
|
|
2460
|
+
? action.expiresAt - now
|
|
2461
|
+
: 20 * 60 * 1000;
|
|
2462
|
+
const mapTimer = setTimeout(() => {
|
|
2463
|
+
this.cardMessageIdMap.delete(messageId);
|
|
2464
|
+
const ids = this.interactionCardMessageIds.get(action.requestId);
|
|
2465
|
+
ids?.delete(messageId);
|
|
2466
|
+
if (ids && ids.size === 0)
|
|
2467
|
+
this.interactionCardMessageIds.delete(action.requestId);
|
|
2468
|
+
}, mapTtl);
|
|
2469
|
+
mapTimer.unref?.();
|
|
2470
|
+
const ownedTimer = setTimeout(() => this.ownedCardMsgIds.delete(messageId), 24 * 60 * 60 * 1000);
|
|
2471
|
+
ownedTimer.unref?.();
|
|
2472
|
+
const invalidationReason = this.invalidatedInteractions.get(action.requestId);
|
|
2473
|
+
if (invalidationReason) {
|
|
2474
|
+
this.cardMessageIdMap.delete(messageId);
|
|
2475
|
+
void this.recallInteractionCard(messageId);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
runPostSend(entry, messageId) {
|
|
2479
|
+
if (!entry.postSend)
|
|
2480
|
+
return;
|
|
2481
|
+
if (entry.postSend.type === 'register_interaction_card') {
|
|
2482
|
+
this.registerCardPostSend(messageId, entry.postSend);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
async sendAunPayload(channelId, payload, context, label) {
|
|
2486
|
+
if (!this.client || !this.connected)
|
|
2487
|
+
return { ok: false };
|
|
2488
|
+
const isGroup = this.isGroupId(channelId);
|
|
2489
|
+
const targetAid = channelId;
|
|
2490
|
+
const encryptTarget = isGroup ? channelId : targetAid;
|
|
2491
|
+
const encrypt = context?.metadata?.encrypted != null
|
|
2492
|
+
? !!(context.metadata.encrypted)
|
|
2493
|
+
: this.shouldEncrypt(encryptTarget);
|
|
2494
|
+
const method = isGroup ? 'group.send' : 'message.send';
|
|
2495
|
+
const causation = normalizeCausation(context?.metadata?.causation);
|
|
2496
|
+
const causationRef = !isGroup && causation ? `caus_${crypto.randomUUID().replace(/-/g, '')}` : undefined;
|
|
2497
|
+
if (causationRef && causation)
|
|
2498
|
+
registerAunCausation(causationRef, this.config.aid, targetAid, causation);
|
|
2499
|
+
const payloadWithCausationRef = causationRef
|
|
2500
|
+
? { ...payload, _evolcore_causation_ref: causationRef }
|
|
2501
|
+
: payload;
|
|
2502
|
+
// Truncate payload if too large (AUN SDK limit: 1MB)
|
|
2503
|
+
const truncatedPayload = this.truncatePayloadIfNeeded(payloadWithCausationRef, label);
|
|
2504
|
+
const params = { payload: truncatedPayload, encrypt };
|
|
2505
|
+
if (isGroup)
|
|
2506
|
+
params.group_id = channelId;
|
|
2507
|
+
else
|
|
2508
|
+
params.to = targetAid;
|
|
2509
|
+
const callOnce = async (sendParams, fallback) => {
|
|
2510
|
+
const result = fallback
|
|
2511
|
+
? await this.client.call(method, sendParams)
|
|
2512
|
+
: await this.callAndTrace(method, sendParams);
|
|
2513
|
+
const mid = this.messageIdFromSendResult(result);
|
|
2514
|
+
if (!mid) {
|
|
2515
|
+
logger.warn(`${this.logPrefix()} ${method}${fallback ? ' fallback' : ''} (${label}) returned no message_id: ${JSON.stringify(result)}`);
|
|
2516
|
+
return { ok: false, result, encrypt: !!sendParams.encrypt };
|
|
2517
|
+
}
|
|
2518
|
+
return { ok: true, messageId: mid, result, encrypt: !!sendParams.encrypt };
|
|
2519
|
+
};
|
|
2520
|
+
try {
|
|
2521
|
+
return await callOnce(params, false);
|
|
2522
|
+
}
|
|
2523
|
+
catch (e) {
|
|
2524
|
+
if (encrypt && e instanceof E2EEError) {
|
|
2525
|
+
this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
|
|
2526
|
+
logger.warn(`${this.logPrefix()} E2EE ${label} send failed to ${channelId}, retrying plaintext: ${e}`);
|
|
2527
|
+
const fallbackParams = { ...params, encrypt: false };
|
|
2528
|
+
try {
|
|
2529
|
+
this.trace('OUT', `${method}.${label}.fallback`, fallbackParams);
|
|
2530
|
+
const sent = await callOnce(fallbackParams, true);
|
|
2531
|
+
this.trace('OUT', `${method}.${label}.fallback.${sent.ok ? 'ok' : 'missing_id'}`, { message_id: sent.messageId });
|
|
2532
|
+
return sent;
|
|
2533
|
+
}
|
|
2534
|
+
catch (e2) {
|
|
2535
|
+
this.trace('OUT', `${method}.${label}.fallback.error`, { channelId, error: String(e2) });
|
|
2536
|
+
logger.error(`${this.logPrefix()} Plaintext ${label} fallback also failed to ${channelId}: ${e2}`);
|
|
2537
|
+
return { ok: false };
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
this.trace('OUT', `${method}.${label}.error`, { channelId, error: String(e) });
|
|
2541
|
+
logger.error(`${this.logPrefix()} ${label} send failed to ${channelId}: ${e}`);
|
|
2542
|
+
return { ok: false };
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
recordDurableOutbound(channelId, payload, messageId, encrypt, context, isGroup, contentKind, logText, result) {
|
|
2546
|
+
const causation = normalizeCausation(context?.metadata?.causation);
|
|
2547
|
+
if (!isGroup && causation)
|
|
2548
|
+
registerAunCausation(messageId, this.config.aid, channelId, causation);
|
|
2549
|
+
if (causation) {
|
|
2550
|
+
recordCausationSpan(causation, 'message.outbound', {
|
|
2551
|
+
status: 'completed',
|
|
2552
|
+
refs: { messageId, taskId: context?.metadata?.taskId },
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
const kind = contentKind ?? payload.type ?? 'custom';
|
|
2556
|
+
appendAidEvent({
|
|
2557
|
+
ts: Date.now(),
|
|
2558
|
+
iso: new Date().toISOString(),
|
|
2559
|
+
event: 'message_out',
|
|
2560
|
+
aid: this.config.aid,
|
|
2561
|
+
to: channelId,
|
|
2562
|
+
msgId: messageId,
|
|
2563
|
+
kind,
|
|
2564
|
+
len: Buffer.byteLength(logText, 'utf-8'),
|
|
2565
|
+
...(isGroup && { groupId: channelId }),
|
|
2566
|
+
});
|
|
2567
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, this.payloadByteLength(payload, logText), logText, false, encrypt, context?.metadata?.chatmode);
|
|
2568
|
+
const source = context?.metadata?.source ?? 'daemon';
|
|
2569
|
+
this.appendOutboundJsonl(channelId, {
|
|
2570
|
+
...classifyAunPayloadForLog(payload), msgId: messageId, encrypt, context, isGroup, source,
|
|
2571
|
+
});
|
|
2572
|
+
this.forwardOutbound(result);
|
|
2573
|
+
}
|
|
2574
|
+
async sendContentPayload(channelId, payload, opts) {
|
|
2575
|
+
const interactionId = opts.postSend?.type === 'register_interaction_card'
|
|
2576
|
+
? opts.postSend.requestId
|
|
2577
|
+
: undefined;
|
|
2578
|
+
if (interactionId && this.invalidatedInteractions.has(interactionId)) {
|
|
2579
|
+
logger.info(`${this.logPrefix()} Dropped invalidated interaction before durable enqueue: request=${interactionId}`);
|
|
2580
|
+
return {};
|
|
2581
|
+
}
|
|
2582
|
+
const finalPayload = this.applyReplyContextToPayload(payload, opts.context);
|
|
2583
|
+
const logText = opts.logText ?? this.payloadLogText(finalPayload, opts.contentKind);
|
|
2584
|
+
const entry = outbox.enqueue(this.config.aid, {
|
|
2585
|
+
channelId,
|
|
2586
|
+
type: 'payload',
|
|
2587
|
+
contentKind: opts.contentKind,
|
|
2588
|
+
payload: finalPayload,
|
|
2589
|
+
context: opts.context,
|
|
2590
|
+
logText,
|
|
2591
|
+
ttl: opts.ttl,
|
|
2592
|
+
postSend: opts.postSend,
|
|
2593
|
+
});
|
|
2594
|
+
logger.debug(`${this.logPrefix()} Outbox enqueued payload: id=${entry.id} kind=${opts.contentKind} channel=${channelId} text=${logText.slice(0, 40)}`);
|
|
2595
|
+
if (!this.connected || !this.client) {
|
|
2596
|
+
logger.warn(`${this.logPrefix()} Not connected, payload queued in outbox (id=${entry.id}, kind=${opts.contentKind}). Triggering reconnect.`);
|
|
2597
|
+
if (!this.reconnectTimer && !this.client) {
|
|
2598
|
+
this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendContentPayload failed: ${e}`));
|
|
2599
|
+
}
|
|
2600
|
+
return { queued: true };
|
|
2601
|
+
}
|
|
2602
|
+
const result = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
|
|
2603
|
+
if (result.ok) {
|
|
2604
|
+
outbox.remove(this.config.aid, entry.id);
|
|
2605
|
+
return { messageId: result.messageId };
|
|
2606
|
+
}
|
|
2607
|
+
return { queued: true };
|
|
2608
|
+
}
|
|
2609
|
+
buildTaskPayloadBase(envelope, context) {
|
|
2610
|
+
const base = {};
|
|
2611
|
+
if (envelope.taskId)
|
|
2612
|
+
base.task_id = envelope.taskId;
|
|
2613
|
+
if (envelope.sessionId)
|
|
2614
|
+
base.session_id = envelope.sessionId;
|
|
2615
|
+
if (envelope.agentName)
|
|
2616
|
+
base.agent_name = envelope.agentName;
|
|
2617
|
+
if (envelope.chatmode)
|
|
2618
|
+
base.chatmode = envelope.chatmode;
|
|
2619
|
+
if (context?.threadId)
|
|
2620
|
+
base.thread_id = context.threadId;
|
|
2621
|
+
if (context?.peerId)
|
|
2622
|
+
base.initiator = context.peerId;
|
|
2623
|
+
if (context?.replyToMessageId)
|
|
2624
|
+
base.ref_message_id = context.replyToMessageId;
|
|
2625
|
+
return base;
|
|
2626
|
+
}
|
|
2627
|
+
activityLogText(raw) {
|
|
2628
|
+
// 兼容两种入参:原始 ThoughtItem(顶层字段),或已构建的 activity payload(字段收在 .item 里)
|
|
2629
|
+
const item = raw?.item && typeof raw.item === 'object' ? raw.item : raw;
|
|
2630
|
+
if (item?.kind === 'started')
|
|
2631
|
+
return '任务开始';
|
|
2632
|
+
if (item?.kind === 'completed') {
|
|
2633
|
+
const ms = item?.metadata && typeof item.metadata === 'object'
|
|
2634
|
+
? item.metadata.durationMs
|
|
2635
|
+
: undefined;
|
|
2636
|
+
return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? `任务完成 (${ms}ms)` : '任务完成';
|
|
2637
|
+
}
|
|
2638
|
+
if (typeof item?.text === 'string' && item.text)
|
|
2639
|
+
return item.text;
|
|
2640
|
+
if (item?.kind === 'tool_call')
|
|
2641
|
+
return `[tool_call] ${item.name ?? ''}`.trim();
|
|
2642
|
+
if (item?.kind === 'tool_result')
|
|
2643
|
+
return `[tool_result] ${item.name ?? ''} ${item.ok === false ? 'failed' : 'ok'}`.trim();
|
|
2644
|
+
if (item?.kind)
|
|
2645
|
+
return `[activity:${item.kind}]`;
|
|
2646
|
+
return '[activity]';
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Truncate payload if it exceeds AUN protocol limits.
|
|
2650
|
+
* AUN SDK enforces MAX_WS_PAYLOAD_SIZE = 1_000_000 bytes (1MB) for the entire JSON-RPC message.
|
|
2651
|
+
* We use a conservative 800KB limit for the payload itself to leave room for RPC envelope.
|
|
2652
|
+
*/
|
|
2653
|
+
truncatePayloadIfNeeded(payload, label) {
|
|
2654
|
+
const MAX_PAYLOAD_SIZE = 800 * 1024; // 800KB conservative limit
|
|
2655
|
+
const MAX_FIELD_SIZE = 256 * 1024; // 256KB per field
|
|
2656
|
+
// First check total size
|
|
2657
|
+
const payloadJson = JSON.stringify(payload);
|
|
2658
|
+
const totalSize = Buffer.byteLength(payloadJson, 'utf-8');
|
|
2659
|
+
if (totalSize <= MAX_PAYLOAD_SIZE) {
|
|
2660
|
+
return payload; // No truncation needed
|
|
2661
|
+
}
|
|
2662
|
+
logger.warn(`${this.logPrefix()} Payload too large (${formatSize(totalSize)}), truncating... label=${label}`);
|
|
2663
|
+
// Deep clone to avoid mutating original
|
|
2664
|
+
const truncated = JSON.parse(payloadJson);
|
|
2665
|
+
// Truncate large string fields recursively
|
|
2666
|
+
const truncateObject = (obj, path) => {
|
|
2667
|
+
if (!obj || typeof obj !== 'object')
|
|
2668
|
+
return false;
|
|
2669
|
+
let changed = false;
|
|
2670
|
+
for (const key of Object.keys(obj)) {
|
|
2671
|
+
const value = obj[key];
|
|
2672
|
+
const fieldPath = path ? `${path}.${key}` : key;
|
|
2673
|
+
if (typeof value === 'string') {
|
|
2674
|
+
const fieldSize = Buffer.byteLength(value, 'utf-8');
|
|
2675
|
+
if (fieldSize > MAX_FIELD_SIZE) {
|
|
2676
|
+
const truncatedValue = value.substring(0, MAX_FIELD_SIZE / 4); // Rough estimate, UTF-8 safe
|
|
2677
|
+
const actualTruncatedSize = Buffer.byteLength(truncatedValue, 'utf-8');
|
|
2678
|
+
obj[key] = truncatedValue + `\n\n[截断:原始输出 ${formatSize(fieldSize)},仅显示前 ${formatSize(actualTruncatedSize)}]`;
|
|
2679
|
+
obj[`${key}_truncated`] = true;
|
|
2680
|
+
logger.info(`${this.logPrefix()} Truncated field ${fieldPath}: ${formatSize(fieldSize)} → ${formatSize(actualTruncatedSize)}`);
|
|
2681
|
+
changed = true;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
else if (Array.isArray(value)) {
|
|
2685
|
+
for (let i = 0; i < value.length; i++) {
|
|
2686
|
+
if (truncateObject(value[i], `${fieldPath}[${i}]`)) {
|
|
2687
|
+
changed = true;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
else if (typeof value === 'object' && value !== null) {
|
|
2692
|
+
if (truncateObject(value, fieldPath)) {
|
|
2693
|
+
changed = true;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
return changed;
|
|
2698
|
+
};
|
|
2699
|
+
truncateObject(truncated, '');
|
|
2700
|
+
// Check size again after truncation
|
|
2701
|
+
const newJson = JSON.stringify(truncated);
|
|
2702
|
+
const newSize = Buffer.byteLength(newJson, 'utf-8');
|
|
2703
|
+
if (newSize > MAX_PAYLOAD_SIZE) {
|
|
2704
|
+
// Still too large - this shouldn't happen with 256KB field limit, but log it
|
|
2705
|
+
logger.error(`${this.logPrefix()} Payload still too large after truncation: ${formatSize(newSize)}, dropping payload`);
|
|
2706
|
+
return {
|
|
2707
|
+
type: 'text',
|
|
2708
|
+
text: '消息内容过大,无法完整发送,请缩小输出后重试',
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
logger.info(`${this.logPrefix()} Payload truncated: ${formatSize(totalSize)} → ${formatSize(newSize)}`);
|
|
2712
|
+
return truncated;
|
|
2713
|
+
}
|
|
2714
|
+
buildActivityPayload(envelope, context, item) {
|
|
2715
|
+
const activityItem = item && typeof item === 'object' && !Array.isArray(item)
|
|
2716
|
+
? { ...item }
|
|
2717
|
+
: { kind: 'unknown', text: String(item ?? '') };
|
|
2718
|
+
return {
|
|
2719
|
+
...this.buildTaskPayloadBase(envelope, context),
|
|
2720
|
+
type: 'activity',
|
|
2721
|
+
item: this.stripUndefinedDeep(activityItem),
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
async sendReliableStructured(channelId, payload, context, logText) {
|
|
2725
|
+
await this.sendContentPayload(channelId, payload, {
|
|
2726
|
+
contentKind: 'custom',
|
|
2727
|
+
context,
|
|
2728
|
+
logText: logText ?? this.payloadLogText(payload, 'custom'),
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
async sendMessage(channelId, text, context) {
|
|
2732
|
+
if (!text?.trim()) {
|
|
2733
|
+
logger.warn(`${this.logPrefix()} Attempted to send empty message, skipping`);
|
|
2734
|
+
return;
|
|
2735
|
+
}
|
|
2736
|
+
// 长 echo: agent 首次回复前先发 echo trace(echo 自身发送时跳过)
|
|
2737
|
+
if (!this.isEchoSending) {
|
|
2738
|
+
await this.flushPendingEcho(channelId);
|
|
2739
|
+
}
|
|
2740
|
+
let finalText = text;
|
|
2741
|
+
this.sentCount.set(channelId, (this.sentCount.get(channelId) || 0) + 1);
|
|
2742
|
+
if (this.isGroupId(channelId) && context?.peerId) {
|
|
2743
|
+
if (!finalText.includes(`@${context.peerId}`)) {
|
|
2744
|
+
finalText = `@${context.peerId} ` + finalText;
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
// Write-ahead: persist to outbox before attempting send
|
|
2748
|
+
const entry = outbox.enqueue(this.config.aid, {
|
|
2749
|
+
channelId,
|
|
2750
|
+
type: 'text',
|
|
2751
|
+
text: finalText,
|
|
2752
|
+
context,
|
|
2753
|
+
});
|
|
2754
|
+
logger.debug(`${this.logPrefix()} Outbox enqueued: id=${entry.id} channel=${channelId} text=${finalText.slice(0, 40)}`);
|
|
2755
|
+
if (!this.connected || !this.client) {
|
|
2756
|
+
logger.warn(`${this.logPrefix()} Not connected, message queued in outbox (id=${entry.id}). Triggering reconnect.`);
|
|
2757
|
+
if (!this.reconnectTimer && !this.client) {
|
|
2758
|
+
this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendMessage failed: ${e}`));
|
|
2759
|
+
}
|
|
2760
|
+
return;
|
|
2761
|
+
}
|
|
2762
|
+
// Attempt immediate delivery
|
|
2763
|
+
const ok = await this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
|
|
2764
|
+
if (ok) {
|
|
2765
|
+
outbox.remove(this.config.aid, entry.id);
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
/** Daemon-side transport for `ec msg send` running inside an agent task. */
|
|
2769
|
+
async sendDaemonMsg(args) {
|
|
2770
|
+
if (!this.connected || !this.client) {
|
|
2771
|
+
return { ok: false, error: 'AUN channel is not connected', code: 'AUN_NOT_CONNECTED' };
|
|
2772
|
+
}
|
|
2773
|
+
if (!args.to || this.isGroupId(args.to)) {
|
|
2774
|
+
return { ok: false, error: 'aun-msg-send only supports private AID targets', code: 'UNSUPPORTED_TARGET' };
|
|
2775
|
+
}
|
|
2776
|
+
if (!args.payload || typeof args.payload !== 'object' || Array.isArray(args.payload)) {
|
|
2777
|
+
return { ok: false, error: 'payload must be an object', code: 'INVALID_PAYLOAD' };
|
|
2778
|
+
}
|
|
2779
|
+
const finalPayload = this.stripUndefinedDeep({ ...args.payload });
|
|
2780
|
+
const info = await this.fetchPeerInfo(args.to);
|
|
2781
|
+
const chatmode = info.type === 'human' ? 'interactive' : 'proactive';
|
|
2782
|
+
if (!finalPayload.chatmode)
|
|
2783
|
+
finalPayload.chatmode = chatmode;
|
|
2784
|
+
const parentCausation = normalizeCausation(args.causation);
|
|
2785
|
+
const outboundCausation = parentCausation ? deriveCausation(parentCausation) : undefined;
|
|
2786
|
+
const causationRef = outboundCausation ? `caus_${crypto.randomUUID().replace(/-/g, '')}` : undefined;
|
|
2787
|
+
if (causationRef && outboundCausation) {
|
|
2788
|
+
finalPayload._evolcore_causation_ref = causationRef;
|
|
2789
|
+
registerAunCausation(causationRef, this.config.aid, args.to, outboundCausation);
|
|
2790
|
+
}
|
|
2791
|
+
const encrypt = args.encrypt === true;
|
|
2792
|
+
const sendParams = {
|
|
2793
|
+
to: args.to,
|
|
2794
|
+
payload: this.truncatePayloadIfNeeded(finalPayload, 'msg-send-ipc'),
|
|
2795
|
+
encrypt,
|
|
2796
|
+
};
|
|
2797
|
+
try {
|
|
2798
|
+
const result = await this.callAndTrace('message.send', sendParams);
|
|
2799
|
+
const messageId = this.messageIdFromSendResult(result);
|
|
2800
|
+
if (!messageId) {
|
|
2801
|
+
return { ok: false, error: `message.send returned no message_id: ${JSON.stringify(result)}`, code: 'MISSING_MESSAGE_ID' };
|
|
2802
|
+
}
|
|
2803
|
+
logger.info(`${this.logPrefix()} daemon msg.send ok: to=${this.peerLabel(args.to)} mid=${messageId} encrypt=${encrypt} chatmode=${chatmode}`);
|
|
2804
|
+
if (outboundCausation) {
|
|
2805
|
+
registerAunCausation(messageId, this.config.aid, args.to, outboundCausation);
|
|
2806
|
+
recordCausationSpan(outboundCausation, 'message.outbound', {
|
|
2807
|
+
status: 'completed',
|
|
2808
|
+
refs: { messageId, sessionId: args.log?.sessionId },
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
this.forwardOutbound(result);
|
|
2812
|
+
let logWritten = false;
|
|
2813
|
+
if (args.log?.content !== undefined) {
|
|
2814
|
+
const classified = classifyAunPayloadForLog(finalPayload);
|
|
2815
|
+
const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', args.to, this.config.aid);
|
|
2816
|
+
fs.mkdirSync(chatDir, { recursive: true });
|
|
2817
|
+
appendMessageLog(chatDir, buildOutboundEntry({
|
|
2818
|
+
from: this.config.aid,
|
|
2819
|
+
to: args.to,
|
|
2820
|
+
sessionId: args.log.sessionId,
|
|
2821
|
+
threadId: args.log.threadId ?? (typeof finalPayload.thread_id === 'string' ? finalPayload.thread_id : null),
|
|
2822
|
+
chatType: 'private',
|
|
2823
|
+
msgId: messageId,
|
|
2824
|
+
content: classified.content || args.log.content,
|
|
2825
|
+
encrypt,
|
|
2826
|
+
chatmode,
|
|
2827
|
+
msgType: classified.msgType ?? args.log.msgType,
|
|
2828
|
+
payloadType: classified.payloadType ?? args.log.payloadType,
|
|
2829
|
+
payloadSummary: classified.payloadSummary ?? args.log.payloadSummary,
|
|
2830
|
+
source: args.log.source ?? 'msg',
|
|
2831
|
+
handoff_trace: args.log.handoffTrace,
|
|
2832
|
+
}));
|
|
2833
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, args.to, Buffer.byteLength(classified.content || args.log.content, 'utf-8'), classified.content || args.log.content, false, encrypt, chatmode, 'send');
|
|
2834
|
+
logWritten = true;
|
|
2835
|
+
}
|
|
2836
|
+
return {
|
|
2837
|
+
ok: true,
|
|
2838
|
+
message_id: messageId,
|
|
2839
|
+
seq: result?.seq,
|
|
2840
|
+
timestamp: result?.timestamp,
|
|
2841
|
+
status: result?.status,
|
|
2842
|
+
delivery_mode: result?.delivery_mode,
|
|
2843
|
+
encrypt,
|
|
2844
|
+
chatmode,
|
|
2845
|
+
log_written: logWritten,
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
catch (e) {
|
|
2849
|
+
return {
|
|
2850
|
+
ok: false,
|
|
2851
|
+
error: e?.message || String(e),
|
|
2852
|
+
code: e?.code,
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
/** Build an attachment payload with the daemon's long-lived AUN connection. */
|
|
2857
|
+
async buildDaemonFilePayload(file) {
|
|
2858
|
+
if (!this.connected || !this.client) {
|
|
2859
|
+
throw Object.assign(new Error('AUN channel is not connected'), { code: 'AUN_NOT_CONNECTED' });
|
|
2860
|
+
}
|
|
2861
|
+
if (!file.filePath?.trim()) {
|
|
2862
|
+
throw Object.assign(new Error('file path is required'), { code: 'INVALID_FILE' });
|
|
2863
|
+
}
|
|
2864
|
+
if (isHClassPath(file.filePath)) {
|
|
2865
|
+
throw Object.assign(new Error('H-class protected files cannot be uploaded by an agent task'), {
|
|
2866
|
+
code: 'H_CLASS_PROTECTED',
|
|
2867
|
+
});
|
|
2868
|
+
}
|
|
2869
|
+
const connection = {
|
|
2870
|
+
call: (method, params) => this.callAndTrace(method, params),
|
|
2871
|
+
close: async () => { },
|
|
2872
|
+
};
|
|
2873
|
+
const built = await uploadFileAndBuildPayload(connection, this.config.aid, file.filePath, {
|
|
2874
|
+
as: file.as,
|
|
2875
|
+
contentType: file.contentType,
|
|
2876
|
+
text: file.text,
|
|
2877
|
+
transcript: file.transcript,
|
|
2878
|
+
});
|
|
2879
|
+
return built.payload;
|
|
2880
|
+
}
|
|
2881
|
+
/** Daemon-side transport for `ec group send` running inside an agent task. */
|
|
2882
|
+
async sendDaemonGroupMsg(args) {
|
|
2883
|
+
if (!this.connected || !this.client) {
|
|
2884
|
+
return { ok: false, error: 'AUN channel is not connected', code: 'AUN_NOT_CONNECTED' };
|
|
2885
|
+
}
|
|
2886
|
+
if (!args.groupId || !this.isGroupId(args.groupId)) {
|
|
2887
|
+
return { ok: false, error: 'aun-msg-send requires a valid group target', code: 'UNSUPPORTED_TARGET' };
|
|
2888
|
+
}
|
|
2889
|
+
if (!args.payload || typeof args.payload !== 'object' || Array.isArray(args.payload)) {
|
|
2890
|
+
return { ok: false, error: 'payload must be an object', code: 'INVALID_PAYLOAD' };
|
|
2891
|
+
}
|
|
2892
|
+
const finalPayload = this.stripUndefinedDeep({ ...args.payload });
|
|
2893
|
+
if (args.mentions?.length)
|
|
2894
|
+
finalPayload.mentions = args.mentions;
|
|
2895
|
+
const encrypt = args.encrypt === true;
|
|
2896
|
+
try {
|
|
2897
|
+
const result = await this.callAndTrace('group.send', {
|
|
2898
|
+
group_id: args.groupId,
|
|
2899
|
+
payload: this.truncatePayloadIfNeeded(finalPayload, 'group-send-ipc'),
|
|
2900
|
+
encrypt,
|
|
2901
|
+
});
|
|
2902
|
+
const messageId = this.messageIdFromSendResult(result);
|
|
2903
|
+
if (!messageId) {
|
|
2904
|
+
return { ok: false, error: `group.send returned no message_id: ${JSON.stringify(result)}`, code: 'MISSING_MESSAGE_ID' };
|
|
2905
|
+
}
|
|
2906
|
+
logger.info(`${this.logPrefix()} daemon group.send ok: group=${args.groupId} mid=${messageId} encrypt=${encrypt}`);
|
|
2907
|
+
this.forwardOutbound(result);
|
|
2908
|
+
let logWritten = false;
|
|
2909
|
+
if (args.log?.content !== undefined) {
|
|
2910
|
+
const classified = classifyAunPayloadForLog(finalPayload);
|
|
2911
|
+
const content = classified.content || args.log.content;
|
|
2912
|
+
const chatDir = chatDirPath(resolvePaths().sessionsDir, 'aun', args.groupId, this.config.aid);
|
|
2913
|
+
fs.mkdirSync(chatDir, { recursive: true });
|
|
2914
|
+
appendMessageLog(chatDir, buildOutboundEntry({
|
|
2915
|
+
from: this.config.aid,
|
|
2916
|
+
to: args.groupId,
|
|
2917
|
+
sessionId: args.log.sessionId,
|
|
2918
|
+
threadId: args.log.threadId ?? (typeof finalPayload.thread_id === 'string' ? finalPayload.thread_id : null),
|
|
2919
|
+
chatType: 'group',
|
|
2920
|
+
groupId: args.groupId,
|
|
2921
|
+
msgId: messageId,
|
|
2922
|
+
content,
|
|
2923
|
+
encrypt,
|
|
2924
|
+
chatmode: typeof finalPayload.chatmode === 'string' ? finalPayload.chatmode : undefined,
|
|
2925
|
+
msgType: classified.msgType ?? args.log.msgType,
|
|
2926
|
+
payloadType: classified.payloadType ?? args.log.payloadType,
|
|
2927
|
+
payloadSummary: classified.payloadSummary ?? args.log.payloadSummary,
|
|
2928
|
+
source: args.log.source ?? 'msg',
|
|
2929
|
+
handoff_trace: args.log.handoffTrace,
|
|
2930
|
+
}));
|
|
2931
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, args.groupId, Buffer.byteLength(content, 'utf-8'), content, false, encrypt, typeof finalPayload.chatmode === 'string' ? finalPayload.chatmode : undefined, 'send');
|
|
2932
|
+
logWritten = true;
|
|
2933
|
+
}
|
|
2934
|
+
return {
|
|
2935
|
+
ok: true,
|
|
2936
|
+
message_id: messageId,
|
|
2937
|
+
group_id: result?.group_id ?? args.groupId,
|
|
2938
|
+
message: result?.message,
|
|
2939
|
+
event: result?.event,
|
|
2940
|
+
encrypt,
|
|
2941
|
+
log_written: logWritten,
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
catch (e) {
|
|
2945
|
+
return { ok: false, error: e?.message || String(e), code: e?.code };
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
async flushPendingEcho(channelId) {
|
|
2949
|
+
// 查找该 channelId 是否有 pending echo(长 echo 等待 agent 首次回复)
|
|
2950
|
+
for (const [key, echo] of this.pendingEchoMessages) {
|
|
2951
|
+
if (echo.channelId === channelId) {
|
|
2952
|
+
this.pendingEchoMessages.delete(key);
|
|
2953
|
+
logger.info(`${this.logPrefix()} [Echo] flushPendingEcho triggered: key=${key} channelId=${channelId} pendingCount=${this.pendingEchoMessages.size}`);
|
|
2954
|
+
const ts = () => {
|
|
2955
|
+
const d = new Date();
|
|
2956
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
2957
|
+
};
|
|
2958
|
+
const agentDuration = Date.now() - echo.receiveTs;
|
|
2959
|
+
let echoText = echo.text;
|
|
2960
|
+
echoText += `\n${ts()} [EvolCore.agent] duration=${agentDuration}ms`;
|
|
2961
|
+
echoText += `\n${ts()} [EvolCore.reply] echo回声发出 conn_uptime=${this.connectedAt ? Math.round((Date.now() - this.connectedAt) / 1000) + 's' : 'unknown'}`;
|
|
2962
|
+
if (Buffer.byteLength(echoText, 'utf-8') > 4096) {
|
|
2963
|
+
echoText += `\n[TRUNCATED]`;
|
|
2964
|
+
}
|
|
2965
|
+
// 直接投递 echo trace(不经过 sendMessage 避免 @peer 前缀和递归)
|
|
2966
|
+
if (this.connected && this.client) {
|
|
2967
|
+
const echoEntry = {
|
|
2968
|
+
id: `echo-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`,
|
|
2969
|
+
ts: Date.now(),
|
|
2970
|
+
aid: this.config.aid,
|
|
2971
|
+
channelId,
|
|
2972
|
+
type: 'text',
|
|
2973
|
+
text: echoText,
|
|
2974
|
+
ttl: 300_000,
|
|
2975
|
+
};
|
|
2976
|
+
const ok = await this.deliverTextEntry(echoEntry);
|
|
2977
|
+
if (!ok) {
|
|
2978
|
+
outbox.enqueue(this.config.aid, { channelId, type: 'text', text: echoText });
|
|
2979
|
+
}
|
|
2980
|
+
logger.info(`${this.logPrefix()} [Echo] long echo trace delivered=${ok} to ${channelId} (agent ${agentDuration}ms)`);
|
|
2981
|
+
}
|
|
2982
|
+
else {
|
|
2983
|
+
outbox.enqueue(this.config.aid, { channelId, type: 'text', text: echoText });
|
|
2984
|
+
logger.warn(`${this.logPrefix()} [Echo] not connected, echo trace queued in outbox`);
|
|
2985
|
+
}
|
|
2986
|
+
break;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
async deliverTextEntry(entry) {
|
|
2991
|
+
const channelId = entry.channelId;
|
|
2992
|
+
const finalText = entry.text;
|
|
2993
|
+
const context = entry.context;
|
|
2994
|
+
// 从 context.metadata.source 读取 source,默认为 'daemon'
|
|
2995
|
+
const source = context?.metadata?.source ?? 'daemon';
|
|
2996
|
+
const payload = { type: 'text', text: finalText };
|
|
2997
|
+
if (this.isGroupId(channelId)) {
|
|
2998
|
+
const extracted = this.extractMentionAidsFromText(finalText);
|
|
2999
|
+
if (extracted.length > 0)
|
|
3000
|
+
payload.mentions = extracted;
|
|
3001
|
+
}
|
|
3002
|
+
if (context?.threadId)
|
|
3003
|
+
payload.thread_id = context.threadId;
|
|
3004
|
+
if (context?.replyToMessageId)
|
|
3005
|
+
payload.ref_message_id = context.replyToMessageId;
|
|
3006
|
+
if (context?.metadata?.taskId)
|
|
3007
|
+
payload.task_id = context.metadata.taskId;
|
|
3008
|
+
if (context?.metadata?.chatmode)
|
|
3009
|
+
payload.chatmode = context.metadata.chatmode;
|
|
3010
|
+
const causation = normalizeCausation(context?.metadata?.causation);
|
|
3011
|
+
const causationRef = !this.isGroupId(channelId) && causation
|
|
3012
|
+
? `caus_${crypto.randomUUID().replace(/-/g, '')}`
|
|
3013
|
+
: undefined;
|
|
3014
|
+
if (causationRef && causation) {
|
|
3015
|
+
payload._evolcore_causation_ref = causationRef;
|
|
3016
|
+
registerAunCausation(causationRef, this.config.aid, channelId, causation);
|
|
3017
|
+
}
|
|
3018
|
+
// 诊断日志:记录 payload 构造结果(含 task_id / thread_id / chatmode)
|
|
3019
|
+
logger.info(`${this.logPrefix()} deliverTextEntry: channelId=${channelId} thread_id=${payload.thread_id ?? 'none'} task_id=${payload.task_id ?? 'none'} chatmode=${payload.chatmode ?? 'none'} source=${source} textLen=${finalText.length}`);
|
|
3020
|
+
const isGroup = this.isGroupId(channelId);
|
|
3021
|
+
const targetAid = channelId;
|
|
3022
|
+
const encryptTarget = isGroup ? channelId : targetAid;
|
|
3023
|
+
const encrypt = context?.metadata?.encrypted != null
|
|
3024
|
+
? !!(context.metadata.encrypted)
|
|
3025
|
+
: this.shouldEncrypt(encryptTarget);
|
|
3026
|
+
const params = { payload, encrypt };
|
|
3027
|
+
try {
|
|
3028
|
+
if (isGroup) {
|
|
3029
|
+
params.group_id = channelId;
|
|
3030
|
+
const result = await this.callAndTrace('group.send', params);
|
|
3031
|
+
const mid = result?.message?.message_id ?? result?.message_id ?? null;
|
|
3032
|
+
if (!mid) {
|
|
3033
|
+
const dispatchStatus = result?.message_dispatch?.status;
|
|
3034
|
+
if (dispatchStatus === 'debounced' || dispatchStatus === 'dispatched') {
|
|
3035
|
+
logger.warn(`${this.logPrefix()} group.send returned ${dispatchStatus} without message_id; keeping outbox entry: group=${channelId} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
|
|
3036
|
+
}
|
|
3037
|
+
else {
|
|
3038
|
+
logger.warn(`${this.logPrefix()} group.send returned no message_id: ${JSON.stringify(result)}`);
|
|
3039
|
+
}
|
|
3040
|
+
return false;
|
|
3041
|
+
}
|
|
3042
|
+
else {
|
|
3043
|
+
logger.info(`${this.logPrefix()} group.send ok: group=${channelId} mid=${mid} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
|
|
3044
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
|
|
3045
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode, 'send');
|
|
3046
|
+
this.appendOutboundJsonl(channelId, {
|
|
3047
|
+
...classifyAunPayloadForLog(payload), msgId: mid, encrypt, context, isGroup: true, source,
|
|
3048
|
+
});
|
|
3049
|
+
// Observer forward: outbound (group) — 原样转发 SDK SendResult(含 envelope + payload)
|
|
3050
|
+
this.forwardOutbound(result);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
else {
|
|
3054
|
+
params.to = targetAid;
|
|
3055
|
+
const result = await this.callAndTrace('message.send', params);
|
|
3056
|
+
if (!result || !result.message_id) {
|
|
3057
|
+
logger.warn(`${this.logPrefix()} message.send returned no message_id: ${JSON.stringify(result)}`);
|
|
3058
|
+
return false;
|
|
3059
|
+
}
|
|
3060
|
+
else {
|
|
3061
|
+
logger.info(`${this.logPrefix()} message.send ok: to=${this.peerLabel(targetAid)} mid=${result.message_id} encrypt=${encrypt} text=${finalText.slice(0, 60)}`);
|
|
3062
|
+
const causation = normalizeCausation(context?.metadata?.causation);
|
|
3063
|
+
if (causation) {
|
|
3064
|
+
registerAunCausation(result.message_id, this.config.aid, targetAid, causation);
|
|
3065
|
+
recordCausationSpan(causation, 'message.outbound', {
|
|
3066
|
+
status: 'completed',
|
|
3067
|
+
refs: { messageId: result.message_id, taskId: context?.metadata?.taskId },
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: result.message_id, kind: 'text', len: finalText.length });
|
|
3071
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, encrypt, context?.metadata?.chatmode, 'send');
|
|
3072
|
+
this.appendOutboundJsonl(targetAid, {
|
|
3073
|
+
...classifyAunPayloadForLog(payload), msgId: result.message_id, encrypt, context, isGroup: false, source,
|
|
3074
|
+
});
|
|
3075
|
+
// Observer forward: outbound (private) — 原样转发 SDK SendResult(含 envelope + payload)
|
|
3076
|
+
this.forwardOutbound(result);
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
return true;
|
|
3080
|
+
}
|
|
3081
|
+
catch (e) {
|
|
3082
|
+
if (encrypt && e instanceof E2EEError) {
|
|
3083
|
+
this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
|
|
3084
|
+
logger.warn(`${this.logPrefix()} E2EE send failed to ${channelId}, retrying plaintext: ${e}`);
|
|
3085
|
+
params.encrypt = false;
|
|
3086
|
+
try {
|
|
3087
|
+
if (isGroup) {
|
|
3088
|
+
this.trace('OUT', 'group.send.fallback', params);
|
|
3089
|
+
const result = await this.client.call('group.send', params);
|
|
3090
|
+
const mid = this.messageIdFromSendResult(result);
|
|
3091
|
+
this.trace('OUT', 'group.send.fallback.ok', { message_id: mid });
|
|
3092
|
+
if (!mid) {
|
|
3093
|
+
logger.warn(`${this.logPrefix()} group.send fallback returned no message_id: ${JSON.stringify(result)}`);
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: mid, kind: 'text', len: finalText.length, groupId: channelId });
|
|
3097
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(finalText, 'utf-8'), finalText, false, false, context?.metadata?.chatmode);
|
|
3098
|
+
this.appendOutboundJsonl(channelId, {
|
|
3099
|
+
...classifyAunPayloadForLog(payload), msgId: mid, encrypt: false, context, isGroup: true, source,
|
|
3100
|
+
});
|
|
3101
|
+
this.forwardOutbound(result);
|
|
3102
|
+
}
|
|
3103
|
+
else {
|
|
3104
|
+
this.trace('OUT', 'message.send.fallback', params);
|
|
3105
|
+
const result = await this.client.call('message.send', params);
|
|
3106
|
+
const mid = result?.message_id;
|
|
3107
|
+
this.trace('OUT', 'message.send.fallback.ok', { message_id: mid });
|
|
3108
|
+
if (!result || !mid) {
|
|
3109
|
+
logger.warn(`${this.logPrefix()} message.send fallback returned no message_id: ${JSON.stringify(result)}`);
|
|
3110
|
+
return false;
|
|
3111
|
+
}
|
|
3112
|
+
const causation = normalizeCausation(context?.metadata?.causation);
|
|
3113
|
+
if (causation) {
|
|
3114
|
+
registerAunCausation(mid, this.config.aid, targetAid, causation);
|
|
3115
|
+
recordCausationSpan(causation, 'message.outbound', {
|
|
3116
|
+
status: 'completed',
|
|
3117
|
+
refs: { messageId: mid, taskId: context?.metadata?.taskId },
|
|
3118
|
+
});
|
|
3119
|
+
}
|
|
3120
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: targetAid, msgId: mid, kind: 'text', len: finalText.length });
|
|
3121
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, targetAid, Buffer.byteLength(finalText, 'utf-8'), finalText, false, false, context?.metadata?.chatmode);
|
|
3122
|
+
this.appendOutboundJsonl(targetAid, {
|
|
3123
|
+
...classifyAunPayloadForLog(payload), msgId: mid, encrypt: false, context, isGroup: false, source,
|
|
3124
|
+
});
|
|
3125
|
+
this.forwardOutbound(result);
|
|
3126
|
+
}
|
|
3127
|
+
return true;
|
|
3128
|
+
}
|
|
3129
|
+
catch (e2) {
|
|
3130
|
+
this.trace('OUT', 'send.fallback.error', { channelId, error: String(e2) });
|
|
3131
|
+
logger.error(`${this.logPrefix()} Plaintext fallback also failed to ${channelId}: ${e2}`);
|
|
3132
|
+
return false;
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
else {
|
|
3136
|
+
this.trace('OUT', 'send.error', { channelId, error: String(e) });
|
|
3137
|
+
logger.error(`${this.logPrefix()} Send failed to ${channelId} (outbox id=${entry.id}): ${e}`);
|
|
3138
|
+
return false;
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
async deliverPayloadEntry(entry) {
|
|
3143
|
+
const interactionId = entry.postSend?.type === 'register_interaction_card'
|
|
3144
|
+
? entry.postSend.requestId
|
|
3145
|
+
: undefined;
|
|
3146
|
+
if (interactionId && this.invalidatedInteractions.has(interactionId)) {
|
|
3147
|
+
logger.info(`${this.logPrefix()} Discarded invalidated interaction from durable outbox: request=${interactionId} entry=${entry.id}`);
|
|
3148
|
+
return { ok: true };
|
|
3149
|
+
}
|
|
3150
|
+
const channelId = entry.channelId;
|
|
3151
|
+
const payload = entry.payload;
|
|
3152
|
+
if (!payload) {
|
|
3153
|
+
logger.warn(`${this.logPrefix()} deliverPayloadEntry: missing payload (outbox id=${entry.id})`);
|
|
3154
|
+
return { ok: true };
|
|
3155
|
+
}
|
|
3156
|
+
const contentKind = entry.contentKind;
|
|
3157
|
+
const logText = entry.logText ?? this.payloadLogText(payload, contentKind);
|
|
3158
|
+
const context = entry.context;
|
|
3159
|
+
logger.info(`${this.logPrefix()} deliverPayloadEntry: id=${entry.id} kind=${contentKind ?? payload.type ?? 'payload'} channelId=${channelId} thread_id=${payload.thread_id ?? 'none'} task_id=${payload.task_id ?? 'none'} textLen=${logText.length}`);
|
|
3160
|
+
const sent = await this.sendAunPayload(channelId, payload, context, `${contentKind ?? payload.type ?? 'payload'}`);
|
|
3161
|
+
if (!sent.ok || !sent.messageId)
|
|
3162
|
+
return sent;
|
|
3163
|
+
const isGroup = this.isGroupId(channelId);
|
|
3164
|
+
logger.info(`${this.logPrefix()} durable payload sent: kind=${contentKind ?? payload.type ?? 'payload'} target=${isGroup ? channelId : this.peerLabel(channelId)} mid=${sent.messageId} encrypt=${sent.encrypt} text=${logText.slice(0, 60)}`);
|
|
3165
|
+
this.recordDurableOutbound(channelId, payload, sent.messageId, !!sent.encrypt, context, isGroup, contentKind, logText, sent.result);
|
|
3166
|
+
this.runPostSend(entry, sent.messageId);
|
|
3167
|
+
return sent;
|
|
3168
|
+
}
|
|
3169
|
+
/** 有效会话正文写入 messages.jsonl(message.send/group.send 成功后调用)。 */
|
|
3170
|
+
appendOutboundJsonl(channelId, descriptor) {
|
|
3171
|
+
try {
|
|
3172
|
+
const { content, msgType, payloadType, payloadSummary, msgId, encrypt, context, isGroup, source = 'daemon' } = descriptor;
|
|
3173
|
+
const sessionsDir = resolvePaths().sessionsDir;
|
|
3174
|
+
const selfAID = this.config.aid;
|
|
3175
|
+
const chatDir = chatDirPath(sessionsDir, 'aun', channelId, selfAID);
|
|
3176
|
+
const chatmode = context?.metadata?.chatmode;
|
|
3177
|
+
appendMessageLog(chatDir, buildOutboundEntry({
|
|
3178
|
+
from: selfAID,
|
|
3179
|
+
to: channelId,
|
|
3180
|
+
sessionId: context?.sessionId,
|
|
3181
|
+
threadId: context?.threadId || null,
|
|
3182
|
+
chatType: isGroup ? 'group' : 'private',
|
|
3183
|
+
groupId: isGroup ? channelId : null,
|
|
3184
|
+
msgId,
|
|
3185
|
+
content,
|
|
3186
|
+
replyTo: null,
|
|
3187
|
+
agent: null,
|
|
3188
|
+
model: null,
|
|
3189
|
+
durationMs: null,
|
|
3190
|
+
encrypt,
|
|
3191
|
+
chatmode,
|
|
3192
|
+
msgType,
|
|
3193
|
+
payloadType,
|
|
3194
|
+
payloadSummary,
|
|
3195
|
+
source,
|
|
3196
|
+
}));
|
|
3197
|
+
}
|
|
3198
|
+
catch (e) {
|
|
3199
|
+
logger.debug(`${this.logPrefix()} appendOutboundJsonl failed: ${e}`);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* 发送 thought 内容(Proactive 模式可观测)
|
|
3204
|
+
* 群聊:调用 group.thought.put
|
|
3205
|
+
* 单聊:调用 message.thought.put
|
|
3206
|
+
*
|
|
3207
|
+
* selector 使用 context: { type: 'task', id: taskId }
|
|
3208
|
+
* 存储键:group_id/peer_aid + sender_aid + context.type + context.id
|
|
3209
|
+
*/
|
|
3210
|
+
async sendThought(channelId, taskId, payload, context) {
|
|
3211
|
+
if (!this.connected || !this.client)
|
|
3212
|
+
return;
|
|
3213
|
+
if (!taskId)
|
|
3214
|
+
return;
|
|
3215
|
+
// 私聊 channelId = 对端 AID(不含 device_id)
|
|
3216
|
+
const targetId = channelId;
|
|
3217
|
+
const finalPayload = this.stripUndefinedDeep(payload);
|
|
3218
|
+
const encrypt = context?.metadata?.encrypted != null
|
|
3219
|
+
? !!(context.metadata.encrypted)
|
|
3220
|
+
: this.shouldEncrypt(targetId);
|
|
3221
|
+
const params = {
|
|
3222
|
+
context: { type: 'task', id: taskId },
|
|
3223
|
+
payload: finalPayload,
|
|
3224
|
+
encrypt,
|
|
3225
|
+
};
|
|
3226
|
+
// 补齐 chatmode(与 deliverTextEntry/applyReplyContextToPayload 对齐)
|
|
3227
|
+
// 前端强依赖此字段用于消息过滤(proactive 模式的 thought 不显示在聊天流)
|
|
3228
|
+
if (context?.metadata?.chatmode && !params.payload.chatmode) {
|
|
3229
|
+
params.payload.chatmode = context.metadata.chatmode;
|
|
3230
|
+
}
|
|
3231
|
+
try {
|
|
3232
|
+
const items = finalPayload?.items;
|
|
3233
|
+
const itemCount = Array.isArray(items) ? items.length : 1;
|
|
3234
|
+
const stage = finalPayload?.stage ?? (finalPayload?.kind ? `kind=${finalPayload.kind}` : `items=${itemCount}`);
|
|
3235
|
+
// 提取 thought 文本:兼容旧 items[] 和新扁平 activity payload。
|
|
3236
|
+
let thoughtText;
|
|
3237
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
3238
|
+
const lastItem = items[items.length - 1];
|
|
3239
|
+
// 优先 text 字段(kind=text 的 item),否则 content
|
|
3240
|
+
if (lastItem?.kind === 'text' && lastItem.text) {
|
|
3241
|
+
thoughtText = lastItem.text;
|
|
3242
|
+
}
|
|
3243
|
+
else if (lastItem?.text) {
|
|
3244
|
+
thoughtText = lastItem.text;
|
|
3245
|
+
}
|
|
3246
|
+
else if (lastItem?.content) {
|
|
3247
|
+
thoughtText = lastItem.content;
|
|
3248
|
+
}
|
|
3249
|
+
else if (typeof lastItem === 'string') {
|
|
3250
|
+
thoughtText = lastItem;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
else {
|
|
3254
|
+
thoughtText = this.activityLogText(finalPayload);
|
|
3255
|
+
}
|
|
3256
|
+
if (this.isGroupId(channelId)) {
|
|
3257
|
+
params.group_id = targetId;
|
|
3258
|
+
const putRes = await this.callAndTrace('group.thought.put', params);
|
|
3259
|
+
const tid = putRes?.thought_id;
|
|
3260
|
+
logger.info(`${this.logPrefix()} thought.put ok group=${targetId} task=${taskId} stage=${stage} encrypt=${encrypt} tid=${tid ?? '?'}`);
|
|
3261
|
+
this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
|
|
3262
|
+
this.forwardOutbound(putRes);
|
|
3263
|
+
if (thoughtText) {
|
|
3264
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive', 'thought');
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
else {
|
|
3268
|
+
params.to = targetId;
|
|
3269
|
+
const putRes = await this.callAndTrace('message.thought.put', params);
|
|
3270
|
+
const tid = putRes?.thought_id;
|
|
3271
|
+
logger.info(`${this.logPrefix()} thought.put ok p2p=${this.peerLabel(targetId)} task=${taskId} stage=${stage} encrypt=${encrypt} tid=${tid ?? '?'}`);
|
|
3272
|
+
this.eventBus?.publish?.({ type: 'message:thought-put', agentName: this.config.aid, channelId, taskId, text: thoughtText });
|
|
3273
|
+
this.forwardOutbound(putRes);
|
|
3274
|
+
if (thoughtText) {
|
|
3275
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, targetId, Buffer.byteLength(thoughtText, 'utf-8'), thoughtText, false, encrypt, context?.metadata?.chatmode ?? 'proactive', 'thought');
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
catch (e) {
|
|
3280
|
+
const err = e;
|
|
3281
|
+
logger.debug(`${this.logPrefix()} thought.put failed to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
/** 发送结构化 AUN payload;transient 协议载荷不写本地 messages.jsonl。 */
|
|
3285
|
+
async sendStructured(channelId, payload, context) {
|
|
3286
|
+
if (!this.connected || !this.client)
|
|
3287
|
+
return null;
|
|
3288
|
+
const isGroup = this.isGroupId(channelId);
|
|
3289
|
+
const targetAid = channelId;
|
|
3290
|
+
const encryptTarget = isGroup ? channelId : targetAid;
|
|
3291
|
+
const encrypt = context?.metadata?.encrypted != null
|
|
3292
|
+
? !!(context.metadata.encrypted)
|
|
3293
|
+
: this.shouldEncrypt(encryptTarget);
|
|
3294
|
+
const finalPayload = this.stripUndefinedDeep({ ...payload });
|
|
3295
|
+
if (context?.threadId && !finalPayload.thread_id)
|
|
3296
|
+
finalPayload.thread_id = context.threadId;
|
|
3297
|
+
if (context?.replyToMessageId && !finalPayload.ref_message_id)
|
|
3298
|
+
finalPayload.ref_message_id = context.replyToMessageId;
|
|
3299
|
+
const params = { payload: finalPayload, encrypt };
|
|
3300
|
+
try {
|
|
3301
|
+
if (isGroup) {
|
|
3302
|
+
params.group_id = channelId;
|
|
3303
|
+
const result = await this.callAndTrace('group.send', params);
|
|
3304
|
+
const mid = result?.message?.message_id ?? result?.message_id ?? null;
|
|
3305
|
+
logger.info(`${this.logPrefix()} group.send (${payload.type}) ok: group=${channelId} mid=${mid} encrypt=${encrypt}`);
|
|
3306
|
+
this.forwardOutbound(result);
|
|
3307
|
+
return mid;
|
|
3308
|
+
}
|
|
3309
|
+
else {
|
|
3310
|
+
params.to = targetAid;
|
|
3311
|
+
const result = await this.callAndTrace('message.send', params);
|
|
3312
|
+
logger.info(`${this.logPrefix()} message.send (${payload.type}) ok: to=${this.peerLabel(targetAid)} mid=${result?.message_id} encrypt=${encrypt}`);
|
|
3313
|
+
this.forwardOutbound(result);
|
|
3314
|
+
return result?.message_id ?? null;
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
catch (e) {
|
|
3318
|
+
const err = e;
|
|
3319
|
+
logger.warn(`${this.logPrefix()} sendStructured failed (${payload.type}) to ${channelId}: ${err?.name}(${err?.code})=${err?.message}`);
|
|
3320
|
+
return null;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
async sendFile(channelId, filePath, context) {
|
|
3324
|
+
const absPath = path.resolve(filePath);
|
|
3325
|
+
if (!fs.existsSync(absPath)) {
|
|
3326
|
+
logger.warn(`${this.logPrefix()} sendFile: file not found: ${absPath}`);
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
const stat = fs.statSync(absPath);
|
|
3330
|
+
if (stat.size === 0) {
|
|
3331
|
+
logger.warn(`${this.logPrefix()} sendFile: file is empty`);
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
3335
|
+
logger.warn(`${this.logPrefix()} sendFile: file too large (${formatSize(stat.size)}, max 10 MB)`);
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
// Write-ahead: persist to outbox
|
|
3339
|
+
const entry = outbox.enqueue(this.config.aid, {
|
|
3340
|
+
channelId,
|
|
3341
|
+
type: 'file',
|
|
3342
|
+
filePath: absPath,
|
|
3343
|
+
context,
|
|
3344
|
+
});
|
|
3345
|
+
logger.debug(`${this.logPrefix()} Outbox enqueued file: id=${entry.id} channel=${channelId} file=${absPath}`);
|
|
3346
|
+
if (!this.connected || !this.client) {
|
|
3347
|
+
logger.warn(`${this.logPrefix()} Not connected, file send queued in outbox (id=${entry.id}). Triggering reconnect.`);
|
|
3348
|
+
if (!this.reconnectTimer && !this.client) {
|
|
3349
|
+
this.initClient().catch(e => logger.error(`${this.logPrefix()} Reconnect from sendFile failed: ${e}`));
|
|
3350
|
+
}
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
const ok = await this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
|
|
3354
|
+
if (ok) {
|
|
3355
|
+
outbox.remove(this.config.aid, entry.id);
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
async deliverFileEntry(entry) {
|
|
3359
|
+
const channelId = entry.channelId;
|
|
3360
|
+
const absPath = entry.filePath;
|
|
3361
|
+
const context = entry.context;
|
|
3362
|
+
if (!fs.existsSync(absPath)) {
|
|
3363
|
+
logger.warn(`${this.logPrefix()} deliverFileEntry: file gone: ${absPath}`);
|
|
3364
|
+
return true; // remove from outbox, file no longer exists
|
|
3365
|
+
}
|
|
3366
|
+
const filename = path.basename(absPath);
|
|
3367
|
+
const fileData = fs.readFileSync(absPath);
|
|
3368
|
+
const stat = fs.statSync(absPath);
|
|
3369
|
+
const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
|
|
3370
|
+
const contentType = guessMime(filename);
|
|
3371
|
+
const objectKey = `shared/${crypto.randomUUID()}/${filename}`;
|
|
3372
|
+
try {
|
|
3373
|
+
if (stat.size <= 64 * 1024) {
|
|
3374
|
+
await this.callAndTrace('storage.put_object', {
|
|
3375
|
+
object_key: objectKey,
|
|
3376
|
+
content: fileData.toString('base64'),
|
|
3377
|
+
content_type: contentType,
|
|
3378
|
+
is_private: false,
|
|
3379
|
+
overwrite: true,
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
else {
|
|
3383
|
+
const session = await this.callAndTrace('storage.create_upload_session', {
|
|
3384
|
+
object_key: objectKey,
|
|
3385
|
+
size_bytes: stat.size,
|
|
3386
|
+
content_type: contentType,
|
|
3387
|
+
});
|
|
3388
|
+
const uploadUrl = session.upload_url;
|
|
3389
|
+
if (!uploadUrl)
|
|
3390
|
+
throw new Error('No upload_url in session response');
|
|
3391
|
+
this.trace('OUT', 'http.put.upload_url', { object_key: objectKey, size: stat.size });
|
|
3392
|
+
const uploadResp = await fetch(uploadUrl, { method: 'PUT', body: fileData });
|
|
3393
|
+
this.trace('OUT', uploadResp.ok ? 'http.put.upload_url.ok' : 'http.put.upload_url.error', { status: uploadResp.status });
|
|
3394
|
+
if (!uploadResp.ok)
|
|
3395
|
+
throw new Error(`HTTP upload failed: ${uploadResp.status}`);
|
|
3396
|
+
await this.callAndTrace('storage.complete_upload', {
|
|
3397
|
+
object_key: objectKey,
|
|
3398
|
+
sha256,
|
|
3399
|
+
content_type: contentType,
|
|
3400
|
+
is_private: false,
|
|
3401
|
+
size_bytes: stat.size,
|
|
3402
|
+
});
|
|
3403
|
+
}
|
|
3404
|
+
const attachment = {
|
|
3405
|
+
owner_aid: this._aid || '',
|
|
3406
|
+
object_key: objectKey,
|
|
3407
|
+
filename,
|
|
3408
|
+
size_bytes: stat.size,
|
|
3409
|
+
sha256,
|
|
3410
|
+
content_type: contentType,
|
|
3411
|
+
};
|
|
3412
|
+
const filePayload = buildAunFilePayload({
|
|
3413
|
+
filename,
|
|
3414
|
+
size: stat.size,
|
|
3415
|
+
contentType,
|
|
3416
|
+
attachment,
|
|
3417
|
+
context,
|
|
3418
|
+
});
|
|
3419
|
+
const isGroup = this.isGroupId(channelId);
|
|
3420
|
+
const fileTargetAid = channelId;
|
|
3421
|
+
const encryptTarget = isGroup ? channelId : fileTargetAid;
|
|
3422
|
+
const encrypt = context?.metadata?.encrypted != null
|
|
3423
|
+
? !!(context.metadata.encrypted)
|
|
3424
|
+
: this.shouldEncrypt(encryptTarget);
|
|
3425
|
+
const params = { payload: filePayload, encrypt };
|
|
3426
|
+
let sendResult = null;
|
|
3427
|
+
let sentMid = null;
|
|
3428
|
+
try {
|
|
3429
|
+
if (isGroup) {
|
|
3430
|
+
params.group_id = channelId;
|
|
3431
|
+
this.trace('OUT', 'group.send.file', params);
|
|
3432
|
+
const result = await this.client.call('group.send', params);
|
|
3433
|
+
sendResult = result;
|
|
3434
|
+
const fileMid = result?.message?.message_id ?? result?.message_id;
|
|
3435
|
+
sentMid = fileMid ?? null;
|
|
3436
|
+
this.trace('OUT', 'group.send.file.ok', { message_id: fileMid });
|
|
3437
|
+
if (!fileMid) {
|
|
3438
|
+
logger.warn(`${this.logPrefix()} group.send.file returned no message_id: ${JSON.stringify(result)}`);
|
|
3439
|
+
return false;
|
|
3440
|
+
}
|
|
3441
|
+
}
|
|
3442
|
+
else {
|
|
3443
|
+
params.to = fileTargetAid;
|
|
3444
|
+
this.trace('OUT', 'message.send.file', params);
|
|
3445
|
+
const result = await this.client.call('message.send', params);
|
|
3446
|
+
sendResult = result;
|
|
3447
|
+
sentMid = result?.message_id ?? null;
|
|
3448
|
+
this.trace('OUT', 'message.send.file.ok', { message_id: sentMid });
|
|
3449
|
+
if (!result || !sentMid) {
|
|
3450
|
+
logger.warn(`${this.logPrefix()} message.send.file returned no message_id: ${JSON.stringify(result)}`);
|
|
3451
|
+
return false;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
catch (sendErr) {
|
|
3456
|
+
this.trace('OUT', isGroup ? 'group.send.file.error' : 'message.send.file.error', {
|
|
3457
|
+
error: sendErr?.message ?? String(sendErr),
|
|
3458
|
+
code: sendErr?.code,
|
|
3459
|
+
});
|
|
3460
|
+
if (encrypt && sendErr instanceof E2EEError) {
|
|
3461
|
+
this.peerE2ee.set(encryptTarget, { ok: false, ts: Date.now() });
|
|
3462
|
+
logger.warn(`${this.logPrefix()} E2EE sendFile failed to ${channelId}, retrying plaintext: ${sendErr}`);
|
|
3463
|
+
params.encrypt = false;
|
|
3464
|
+
if (isGroup) {
|
|
3465
|
+
this.trace('OUT', 'group.send.file.fallback', params);
|
|
3466
|
+
const result = await this.client.call('group.send', params);
|
|
3467
|
+
sendResult = result;
|
|
3468
|
+
const fbMid = result?.message?.message_id ?? result?.message_id;
|
|
3469
|
+
sentMid = fbMid ?? null;
|
|
3470
|
+
this.trace('OUT', 'group.send.file.fallback.ok', { message_id: fbMid });
|
|
3471
|
+
if (!fbMid) {
|
|
3472
|
+
logger.warn(`${this.logPrefix()} group.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
|
|
3473
|
+
return false;
|
|
3474
|
+
}
|
|
3475
|
+
}
|
|
3476
|
+
else {
|
|
3477
|
+
this.trace('OUT', 'message.send.file.fallback', params);
|
|
3478
|
+
const result = await this.client.call('message.send', params);
|
|
3479
|
+
sendResult = result;
|
|
3480
|
+
sentMid = result?.message_id ?? null;
|
|
3481
|
+
this.trace('OUT', 'message.send.file.fallback.ok', { message_id: sentMid });
|
|
3482
|
+
if (!result || !sentMid) {
|
|
3483
|
+
logger.warn(`${this.logPrefix()} message.send.file fallback returned no message_id: ${JSON.stringify(result)}`);
|
|
3484
|
+
return false;
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
}
|
|
3488
|
+
else {
|
|
3489
|
+
throw sendErr;
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
logger.info(`${this.logPrefix()} File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
|
|
3493
|
+
if (sentMid) {
|
|
3494
|
+
const fileText = filePayload.text;
|
|
3495
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'message_out', aid: this.config.aid, to: channelId, msgId: sentMid, kind: 'file', len: fileText.length, ...(isGroup && { groupId: channelId }) });
|
|
3496
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, Buffer.byteLength(fileText, 'utf-8'), fileText, false, !!params.encrypt, context?.metadata?.chatmode);
|
|
3497
|
+
const source = context?.metadata?.source ?? 'daemon';
|
|
3498
|
+
this.appendOutboundJsonl(channelId, {
|
|
3499
|
+
...classifyAunPayloadForLog(filePayload), msgId: sentMid, encrypt: !!params.encrypt,
|
|
3500
|
+
context, isGroup, source,
|
|
3501
|
+
});
|
|
3502
|
+
}
|
|
3503
|
+
if (sendResult)
|
|
3504
|
+
this.forwardOutbound(sendResult);
|
|
3505
|
+
return true;
|
|
3506
|
+
}
|
|
3507
|
+
catch (e) {
|
|
3508
|
+
this.trace('OUT', 'sendFile.error', { channelId, filePath: absPath, error: String(e) });
|
|
3509
|
+
logger.error(`${this.logPrefix()} sendFile failed for ${channelId} (outbox id=${entry.id}): ${e}`);
|
|
3510
|
+
return false;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
// ── Outbox drain ───────────────────────────────────────────
|
|
3514
|
+
outboxTimer = null;
|
|
3515
|
+
startOutboxTimer() {
|
|
3516
|
+
if (this.outboxTimer)
|
|
3517
|
+
return;
|
|
3518
|
+
this.outboxTimer = setInterval(() => {
|
|
3519
|
+
if (this.connected && this.client && outbox.hasPending(this.config.aid)) {
|
|
3520
|
+
this.drainOutbox();
|
|
3521
|
+
}
|
|
3522
|
+
}, 30_000);
|
|
3523
|
+
}
|
|
3524
|
+
stopOutboxTimer() {
|
|
3525
|
+
if (this.outboxTimer) {
|
|
3526
|
+
clearInterval(this.outboxTimer);
|
|
3527
|
+
this.outboxTimer = null;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
async drainOutbox() {
|
|
3531
|
+
if (!this.connected || !this.client)
|
|
3532
|
+
return;
|
|
3533
|
+
if (!outbox.hasPending(this.config.aid))
|
|
3534
|
+
return;
|
|
3535
|
+
logger.info(`${this.logPrefix()} Draining outbox...`);
|
|
3536
|
+
const result = await outbox.drain(this.config.aid, async (entry) => {
|
|
3537
|
+
if (entry.type === 'text') {
|
|
3538
|
+
return this.withOutboxInFlight(entry, () => this.deliverTextEntry(entry), false);
|
|
3539
|
+
}
|
|
3540
|
+
else if (entry.type === 'file') {
|
|
3541
|
+
return this.withOutboxInFlight(entry, () => this.deliverFileEntry(entry), false);
|
|
3542
|
+
}
|
|
3543
|
+
else if (entry.type === 'payload') {
|
|
3544
|
+
const sent = await this.withOutboxInFlight(entry, () => this.deliverPayloadEntry(entry), { ok: false });
|
|
3545
|
+
return sent.ok;
|
|
3546
|
+
}
|
|
3547
|
+
return true; // unknown type, discard
|
|
3548
|
+
});
|
|
3549
|
+
if (result.sent > 0 || result.expired > 0) {
|
|
3550
|
+
logger.info(`${this.logPrefix()} Outbox drained: sent=${result.sent} expired=${result.expired} failed=${result.failed}`);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
acknowledge(messageId) {
|
|
3554
|
+
// Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
|
|
3555
|
+
// to avoid duplicate "已送达" at the sender CLI
|
|
3556
|
+
this.messageSeqMap.delete(messageId);
|
|
3557
|
+
}
|
|
3558
|
+
sendProcessingStatus(channelId, status, sessionId, taskId, context, extraMeta) {
|
|
3559
|
+
if (status === 'start')
|
|
3560
|
+
this.sentCount.delete(channelId); // 新任务开始,重置计数
|
|
3561
|
+
if (!this.client || !this.connected)
|
|
3562
|
+
return;
|
|
3563
|
+
const severity = status === 'error' || status === 'timeout' ? 'error' : 'info';
|
|
3564
|
+
const stateMap = {
|
|
3565
|
+
start: 'started',
|
|
3566
|
+
done: 'completed',
|
|
3567
|
+
interrupted: 'interrupted',
|
|
3568
|
+
error: 'error',
|
|
3569
|
+
timeout: 'timeout',
|
|
3570
|
+
queued: 'queued',
|
|
3571
|
+
progress: 'progress',
|
|
3572
|
+
requires_action: 'requires_action',
|
|
3573
|
+
};
|
|
3574
|
+
const statusPayload = {
|
|
3575
|
+
type: 'task.status',
|
|
3576
|
+
status: stateMap[status] ?? status,
|
|
3577
|
+
task_id: taskId,
|
|
3578
|
+
session_id: sessionId,
|
|
3579
|
+
severity,
|
|
3580
|
+
terminal: ['done', 'interrupted', 'error', 'timeout'].includes(status),
|
|
3581
|
+
};
|
|
3582
|
+
const metadata = extraMeta ? this.stripUndefinedDeep(extraMeta) : undefined;
|
|
3583
|
+
if (metadata && Object.keys(metadata).length > 0)
|
|
3584
|
+
statusPayload.metadata = metadata;
|
|
3585
|
+
if (context?.threadId)
|
|
3586
|
+
statusPayload.thread_id = context.threadId;
|
|
3587
|
+
if (context?.peerId)
|
|
3588
|
+
statusPayload.initiator = context.peerId;
|
|
3589
|
+
if (context?.metadata?.chatmode)
|
|
3590
|
+
statusPayload.chatmode = context.metadata.chatmode;
|
|
3591
|
+
if (context?.replyToMessageId)
|
|
3592
|
+
statusPayload.ref_message_id = context.replyToMessageId;
|
|
3593
|
+
const notifyOptions = { ttlMs: 60_000 };
|
|
3594
|
+
if (this.isGroupId(channelId))
|
|
3595
|
+
notifyOptions.groupId = channelId;
|
|
3596
|
+
else
|
|
3597
|
+
notifyOptions.to = channelId;
|
|
3598
|
+
this.trace('OUT', 'notify.task_status', {
|
|
3599
|
+
method: 'event/app.task.status',
|
|
3600
|
+
params: statusPayload,
|
|
3601
|
+
options: notifyOptions,
|
|
3602
|
+
});
|
|
3603
|
+
const notify = this.client.notify;
|
|
3604
|
+
if (typeof notify !== 'function') {
|
|
3605
|
+
logger.warn(`${this.logPrefix()} task.${status} notify skipped: client.notify unavailable`);
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
Promise.resolve(notify.call(this.client, 'event/app.task.status', statusPayload, notifyOptions)).then(() => {
|
|
3609
|
+
this.trace('OUT', 'notify.task_status.ok', { task_id: taskId, status: statusPayload.status });
|
|
3610
|
+
}).catch((e) => {
|
|
3611
|
+
this.trace('OUT', 'notify.task_status.error', { task_id: taskId, status: statusPayload.status, error: String(e) });
|
|
3612
|
+
logger.debug(`${this.logPrefix()} task_status notify failed: ${e}`);
|
|
3613
|
+
}).catch(() => { });
|
|
3614
|
+
this.aidStatsCollector?.recordOutbound(this.config.aid, channelId, JSON.stringify(statusPayload).length, undefined, true, undefined, undefined, 'notify');
|
|
3615
|
+
// 群聊显示 group id 简称,P2P 显示 peer label;从 context.metadata 读取 chatmode
|
|
3616
|
+
const targetLabel = this.isGroupId(channelId) ? channelId : this.peerLabel(channelId);
|
|
3617
|
+
const chatmode = context?.metadata?.chatmode ?? '?';
|
|
3618
|
+
const initiator = statusPayload.initiator ?? '';
|
|
3619
|
+
const refMsgId = statusPayload.ref_message_id ?? '';
|
|
3620
|
+
const metaStr = statusPayload.metadata ? ` meta=${JSON.stringify(statusPayload.metadata)}` : '';
|
|
3621
|
+
logger.info(`${this.logPrefix()} task.${status} task=${taskId} session=${sessionId} chatmode=${chatmode} target=${targetLabel} initiator=${initiator} ref_msg=${refMsgId}${metaStr}`);
|
|
3622
|
+
}
|
|
3623
|
+
sendCustomPayload(channelId, payload) {
|
|
3624
|
+
if (!this.client || !this.connected)
|
|
3625
|
+
return;
|
|
3626
|
+
// SDK 0.3.0 E2EE requires payload to be an object
|
|
3627
|
+
let payloadObj;
|
|
3628
|
+
try {
|
|
3629
|
+
const parsed = JSON.parse(payload);
|
|
3630
|
+
payloadObj = (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
3631
|
+
? parsed : { text: payload };
|
|
3632
|
+
}
|
|
3633
|
+
catch {
|
|
3634
|
+
payloadObj = { text: payload };
|
|
3635
|
+
}
|
|
3636
|
+
// 私聊 channelId = 对端 AID(不含 device_id)
|
|
3637
|
+
const customTargetAid = channelId;
|
|
3638
|
+
const sendParams = {
|
|
3639
|
+
to: customTargetAid, payload: payloadObj,
|
|
3640
|
+
encrypt: true,
|
|
3641
|
+
};
|
|
3642
|
+
this.trace('OUT', 'message.send.custom', sendParams);
|
|
3643
|
+
this.client.call('message.send', sendParams).then((result) => {
|
|
3644
|
+
this.trace('OUT', 'message.send.custom.ok', { message_id: result?.message_id });
|
|
3645
|
+
}).catch(e => {
|
|
3646
|
+
this.trace('OUT', 'message.send.custom.error', { error: String(e) });
|
|
3647
|
+
logger.warn(`${this.logPrefix()} Custom payload failed: ${e}`);
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
async disconnect() {
|
|
3651
|
+
this.intentionalDisconnect = true;
|
|
3652
|
+
if (this.reconnectTimer) {
|
|
3653
|
+
clearTimeout(this.reconnectTimer);
|
|
3654
|
+
this.reconnectTimer = null;
|
|
3655
|
+
}
|
|
3656
|
+
if (this.client) {
|
|
3657
|
+
this.trace('OUT', 'client.close', { reason: 'disconnect' });
|
|
3658
|
+
try {
|
|
3659
|
+
await this.client.close();
|
|
3660
|
+
this.trace('OUT', 'client.close.ok', { reason: 'disconnect' });
|
|
3661
|
+
}
|
|
3662
|
+
catch (e) {
|
|
3663
|
+
this.trace('OUT', 'client.close.error', { reason: 'disconnect', error: String(e) });
|
|
3664
|
+
}
|
|
3665
|
+
this.client = null;
|
|
3666
|
+
}
|
|
3667
|
+
if (this.store) {
|
|
3668
|
+
try {
|
|
3669
|
+
this.store.close();
|
|
3670
|
+
}
|
|
3671
|
+
catch { /* ignore */ }
|
|
3672
|
+
this.store = null;
|
|
3673
|
+
}
|
|
3674
|
+
this.connected = false;
|
|
3675
|
+
appendAidEvent({ ts: Date.now(), iso: new Date().toISOString(), event: 'disconnected', aid: this.config.aid, reason: 'intentional' });
|
|
3676
|
+
appendAidLifecycle({ ts: Date.now(), iso: new Date().toISOString(), event: 'disconnected', aid: this.config.aid, reason: 'intentional' });
|
|
3677
|
+
this.setAidStatus('disabled');
|
|
3678
|
+
if (this.traceWriter) {
|
|
3679
|
+
this.traceWriter.close();
|
|
3680
|
+
this.traceWriter = null;
|
|
3681
|
+
}
|
|
3682
|
+
logger.info(`${this.logPrefix()} Disconnected`);
|
|
3683
|
+
}
|
|
3684
|
+
// ── TS-layer reconnect ─────────────────────────────────────
|
|
3685
|
+
// SDK 内部已经跑无限指数退避(max_attempts=0, max_delay=300s),
|
|
3686
|
+
// TS 层只负责:(1) initClient 失败时安排兜底重试;(2) flap/kicked 接管路径见 takeoverReconnect。
|
|
3687
|
+
scheduleReconnect() {
|
|
3688
|
+
// initClient 早期失败(auth / connect 阶段)走这里:用 fallback 延迟兜底
|
|
3689
|
+
this.takeoverReconnect(AUNChannel.FALLBACK_DELAY_MS, 'terminal');
|
|
3690
|
+
}
|
|
3691
|
+
/** Manually trigger reconnect (e.g. from /check reconnect command) */
|
|
3692
|
+
async reconnect() {
|
|
3693
|
+
if (this.connected)
|
|
3694
|
+
return '已连接,无需重连';
|
|
3695
|
+
if (this.reconnectTimer) {
|
|
3696
|
+
clearTimeout(this.reconnectTimer);
|
|
3697
|
+
this.reconnectTimer = null;
|
|
3698
|
+
}
|
|
3699
|
+
this.flapCount = 0;
|
|
3700
|
+
try {
|
|
3701
|
+
await this.initClient();
|
|
3702
|
+
return `重连成功 (${this._aid})`;
|
|
3703
|
+
}
|
|
3704
|
+
catch (err) {
|
|
3705
|
+
this.scheduleReconnect();
|
|
3706
|
+
return `重连失败: ${err},已安排自动重试`;
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
/** Set callback for when all reconnect attempts are exhausted (deprecated: 现在无限重试,不会触发) */
|
|
3710
|
+
setOnChannelDown(callback) {
|
|
3711
|
+
this.onChannelDown = callback;
|
|
3712
|
+
}
|
|
3713
|
+
/** Get current connection status */
|
|
3714
|
+
getStatus() {
|
|
3715
|
+
return {
|
|
3716
|
+
connected: this.connected,
|
|
3717
|
+
aid: this._aid,
|
|
3718
|
+
flapCount: this.flapCount,
|
|
3719
|
+
plaintextRecv: this.plaintextRecv,
|
|
3720
|
+
};
|
|
3721
|
+
}
|
|
3722
|
+
/** 读取本地 agent.md 中的 name(用于身份上下文展示),若本地不存在则尝试远程拉取 */
|
|
3723
|
+
loadSelfName(aid) {
|
|
3724
|
+
try {
|
|
3725
|
+
const aidName = aid.startsWith('@') ? aid.slice(1) : aid;
|
|
3726
|
+
const mdPath = agentMdPathFn(aidName);
|
|
3727
|
+
if (!fs.existsSync(mdPath)) {
|
|
3728
|
+
// 异步拉取,不阻塞连接流程
|
|
3729
|
+
this.fetchAndCacheSelfName(aidName);
|
|
3730
|
+
return undefined;
|
|
3731
|
+
}
|
|
3732
|
+
const content = fs.readFileSync(mdPath, 'utf-8');
|
|
3733
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
3734
|
+
if (!fmMatch)
|
|
3735
|
+
return undefined;
|
|
3736
|
+
const nameMatch = fmMatch[1].match(/^name:\s*["']?(.+?)["']?\s*$/m);
|
|
3737
|
+
return nameMatch?.[1]?.trim() || undefined;
|
|
3738
|
+
}
|
|
3739
|
+
catch {
|
|
3740
|
+
return undefined;
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
async fetchAndCacheSelfName(aidName) {
|
|
3744
|
+
try {
|
|
3745
|
+
const { agentmdGet } = await import('../aun/aid/index.js');
|
|
3746
|
+
const content = await agentmdGet(aidName);
|
|
3747
|
+
if (content) {
|
|
3748
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
3749
|
+
if (fmMatch) {
|
|
3750
|
+
const nameMatch = fmMatch[1].match(/^name:\s*["']?(.+?)["']?\s*$/m);
|
|
3751
|
+
const name = nameMatch?.[1]?.trim();
|
|
3752
|
+
if (name) {
|
|
3753
|
+
this._selfName = name;
|
|
3754
|
+
if (this.aidStatsCollector)
|
|
3755
|
+
this.aidStatsCollector.setSelfName(this.config.aid, name);
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
catch {
|
|
3761
|
+
// ignore — name will remain undefined
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
getSelfName() {
|
|
3765
|
+
return this._selfName;
|
|
3766
|
+
}
|
|
3767
|
+
async fetchPeerInfo(aid) {
|
|
3768
|
+
const cached = this.peerInfoCache.get(aid);
|
|
3769
|
+
if (cached !== undefined)
|
|
3770
|
+
return cached;
|
|
3771
|
+
if (!this.client)
|
|
3772
|
+
return { type: null };
|
|
3773
|
+
try {
|
|
3774
|
+
const selfAgentDir = path.join(resolvePaths().agentsDir, this.config.aid);
|
|
3775
|
+
const identity = await PeerIdentityCache.resolve('aun', aid, selfAgentDir, this.store, false);
|
|
3776
|
+
const type = identity.type === 'human' ? 'human' : 'ai';
|
|
3777
|
+
const name = identity.name || undefined;
|
|
3778
|
+
const info = { type, name };
|
|
3779
|
+
this.peerInfoCache.set(aid, info);
|
|
3780
|
+
setTimeout(() => this.peerInfoCache.delete(aid), 30 * 60 * 1000);
|
|
3781
|
+
return info;
|
|
3782
|
+
}
|
|
3783
|
+
catch (e) {
|
|
3784
|
+
logger.debug(`${this.logPrefix()} fetchPeerInfo failed for ${aid}: ${e}`);
|
|
3785
|
+
return { type: null };
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
/** 同步取 peerInfo 缓存,未命中返回 undefined,不发起任何网络请求。 */
|
|
3789
|
+
peerInfoCached(aid) {
|
|
3790
|
+
return this.peerInfoCache.get(aid);
|
|
3791
|
+
}
|
|
3792
|
+
/** 后台预取 peerInfo(下次需要时缓存已就绪),任何错误吞掉。 */
|
|
3793
|
+
prefetchPeerInfo(aid) {
|
|
3794
|
+
if (this.peerInfoCache.has(aid))
|
|
3795
|
+
return;
|
|
3796
|
+
void this.fetchPeerInfo(aid).catch(() => { });
|
|
3797
|
+
}
|
|
3798
|
+
async uploadAgentMd(content) {
|
|
3799
|
+
if (!this.store)
|
|
3800
|
+
throw new Error('not connected');
|
|
3801
|
+
const { agentmdPut } = await import('../aun/aid/agentmd.js');
|
|
3802
|
+
await agentmdPut(content, { aid: this.config.aid, store: this.store });
|
|
3803
|
+
}
|
|
3804
|
+
async downloadAgentMd(aid) {
|
|
3805
|
+
if (!this.store)
|
|
3806
|
+
throw new Error('not connected');
|
|
3807
|
+
const { agentmdSync } = await import('../aun/aid/agentmd.js');
|
|
3808
|
+
const result = await agentmdSync(aid, { store: this.store ?? undefined });
|
|
3809
|
+
return result.content ?? '';
|
|
3810
|
+
}
|
|
3811
|
+
/**
|
|
3812
|
+
* 取群显示名(group.get → group.name),进程内缓存。
|
|
3813
|
+
* 走长连接 callAndTrace,失败/未连接返回 undefined —— 绝不抛出阻塞消息处理。
|
|
3814
|
+
*/
|
|
3815
|
+
async getGroupName(groupId) {
|
|
3816
|
+
if (!groupId)
|
|
3817
|
+
return undefined;
|
|
3818
|
+
const cached = this.groupNameCache.get(groupId);
|
|
3819
|
+
if (cached !== undefined)
|
|
3820
|
+
return cached || undefined;
|
|
3821
|
+
if (!this.client)
|
|
3822
|
+
return undefined;
|
|
3823
|
+
try {
|
|
3824
|
+
const result = await this.callAndTrace('group.get', { group_id: groupId });
|
|
3825
|
+
const name = result?.group?.name;
|
|
3826
|
+
if (typeof name === 'string' && name) {
|
|
3827
|
+
this.groupNameCache.set(groupId, name);
|
|
3828
|
+
return name;
|
|
3829
|
+
}
|
|
3830
|
+
this.groupNameCache.set(groupId, ''); // 负缓存:避免反复 RPC(空串视为无名)
|
|
3831
|
+
return undefined;
|
|
3832
|
+
}
|
|
3833
|
+
catch {
|
|
3834
|
+
return undefined; // 不写缓存,下次仍可重试
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
/**
|
|
3838
|
+
* 查询某成员在群里的角色(经 group.get_admins)。
|
|
3839
|
+
* 仅用于话题创建权限校验(稀有事件),故不缓存:每次查权威源,结果天然最新。
|
|
3840
|
+
* 命中 admins 列表(owner+admin)返回其 role;不在列表(含普通 member / observer)返回 'none';
|
|
3841
|
+
* 未连接 / 异常返回 undefined —— 绝不抛出,由调用方按 fail-closed 处理。
|
|
3842
|
+
*/
|
|
3843
|
+
async getGroupMemberRole(groupId, aid) {
|
|
3844
|
+
if (!groupId || !aid)
|
|
3845
|
+
return undefined;
|
|
3846
|
+
if (!this.client)
|
|
3847
|
+
return undefined;
|
|
3848
|
+
try {
|
|
3849
|
+
const result = await this.callAndTrace('group.get_admins', { group_id: groupId });
|
|
3850
|
+
const admins = Array.isArray(result?.admins) ? result.admins : [];
|
|
3851
|
+
const hit = admins.find((m) => m?.aid === aid);
|
|
3852
|
+
if (hit) {
|
|
3853
|
+
const role = hit.role;
|
|
3854
|
+
return isManagementRole(role) ? role : 'admin';
|
|
3855
|
+
}
|
|
3856
|
+
return 'none'; // 不在 owner/admin 列表 → 普通 member / observer / 非成员
|
|
3857
|
+
}
|
|
3858
|
+
catch {
|
|
3859
|
+
return undefined; // 查询失败,调用方 fail-closed
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
// Plugin implementation
|
|
3864
|
+
export class AUNChannelPlugin {
|
|
3865
|
+
name = 'aun';
|
|
3866
|
+
async createInstance(inst, ctx) {
|
|
3867
|
+
// AUN aid is the agent's own AID; loader injects it as inst.aid, ctx.agentName is the source of truth.
|
|
3868
|
+
const aid = inst.aid ?? ctx.agentName;
|
|
3869
|
+
if (inst.enabled === false || !aid)
|
|
3870
|
+
return null;
|
|
3871
|
+
const channel = new AUNChannel({
|
|
3872
|
+
aid,
|
|
3873
|
+
keystorePath: inst.keystorePath,
|
|
3874
|
+
gatewayUrl: inst.gatewayUrl,
|
|
3875
|
+
accessToken: inst.accessToken,
|
|
3876
|
+
flushDelay: inst.flushDelay,
|
|
3877
|
+
agentName: ctx.agentName,
|
|
3878
|
+
channelName: inst.name,
|
|
3879
|
+
aunTrace: ctx.debug?.aunTrace,
|
|
3880
|
+
aunSdkLog: ctx.debug?.aunSdkLog,
|
|
3881
|
+
});
|
|
3882
|
+
const mode = resolveShowActivities(inst);
|
|
3883
|
+
const adapter = {
|
|
3884
|
+
channelName: inst.name,
|
|
3885
|
+
channelKey: inst.name, // channelName 实际上就是 channelKey
|
|
3886
|
+
capabilities: { file: true, image: true, interaction: true, markdown: true, thought: true, status: true, thread: true, authenticatedApproval: true },
|
|
3887
|
+
send: async (envelope, payload) => {
|
|
3888
|
+
const parentCausation = normalizeCausation(envelope.causation ?? envelope.replyContext?.metadata?.causation);
|
|
3889
|
+
const outboundCausation = parentCausation ? deriveCausation(parentCausation) : undefined;
|
|
3890
|
+
const replyCtx = outboundCausation
|
|
3891
|
+
? {
|
|
3892
|
+
...(envelope.replyContext ?? {}),
|
|
3893
|
+
metadata: { ...(envelope.replyContext?.metadata ?? {}), causation: outboundCausation },
|
|
3894
|
+
}
|
|
3895
|
+
: envelope.replyContext;
|
|
3896
|
+
if (outboundCausation) {
|
|
3897
|
+
recordCausationSpan(outboundCausation, 'message.outbound', {
|
|
3898
|
+
status: 'started',
|
|
3899
|
+
refs: { taskId: envelope.taskId, sessionId: envelope.sessionId },
|
|
3900
|
+
});
|
|
3901
|
+
}
|
|
3902
|
+
const channelId = envelope.channelId;
|
|
3903
|
+
const taskBase = () => channel.buildTaskPayloadBase(envelope, replyCtx);
|
|
3904
|
+
switch (payload.kind) {
|
|
3905
|
+
case 'result.text':
|
|
3906
|
+
case 'command.result':
|
|
3907
|
+
case 'command.error': {
|
|
3908
|
+
const sendCtx = { ...(replyCtx ?? {}) };
|
|
3909
|
+
if (payload.kind === 'result.text' && payload.isFinal)
|
|
3910
|
+
sendCtx.title = '✅ 最终回复:';
|
|
3911
|
+
await channel.sendMessage(channelId, payload.text, sendCtx);
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
case 'system.notice': {
|
|
3915
|
+
const noticePayload = {
|
|
3916
|
+
type: 'notice',
|
|
3917
|
+
...taskBase(),
|
|
3918
|
+
text: payload.text,
|
|
3919
|
+
severity: 'info',
|
|
3920
|
+
};
|
|
3921
|
+
setIfDefined(noticePayload, 'subtype', payload.subtype);
|
|
3922
|
+
await channel.sendReliableStructured(channelId, noticePayload, replyCtx, payload.text);
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
case 'system.error': {
|
|
3926
|
+
await channel.sendMessage(channelId, payload.text, replyCtx);
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
case 'result.error': {
|
|
3930
|
+
await channel.sendMessage(channelId, payload.text, replyCtx);
|
|
3931
|
+
return;
|
|
3932
|
+
}
|
|
3933
|
+
case 'result.file': {
|
|
3934
|
+
const fileCtx = payload.correlationId
|
|
3935
|
+
? { ...(replyCtx ?? {}), metadata: { ...(replyCtx?.metadata ?? {}), correlationId: payload.correlationId } }
|
|
3936
|
+
: replyCtx;
|
|
3937
|
+
await channel.sendFile(channelId, payload.filePath, fileCtx);
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
case 'result.image': {
|
|
3941
|
+
const buf = payload.data;
|
|
3942
|
+
const b64 = buf.toString('base64');
|
|
3943
|
+
const imagePayload = { type: 'image', data_base64: b64 };
|
|
3944
|
+
setIfDefined(imagePayload, 'alt', payload.alt);
|
|
3945
|
+
setIfDefined(imagePayload, 'mime_type', payload.mimeType);
|
|
3946
|
+
await channel.sendContentPayload(channelId, imagePayload, {
|
|
3947
|
+
contentKind: 'image',
|
|
3948
|
+
context: replyCtx,
|
|
3949
|
+
logText: payload.alt ? `[image] ${payload.alt}` : '[image]',
|
|
3950
|
+
});
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
case 'activity.batch': {
|
|
3954
|
+
const items = Array.isArray(payload.items) ? payload.items : [];
|
|
3955
|
+
for (const item of items) {
|
|
3956
|
+
if (item?.kind === 'progress') {
|
|
3957
|
+
const metadata = { activityType: 'progress' };
|
|
3958
|
+
setIfDefined(metadata, 'text', item.text);
|
|
3959
|
+
setIfDefined(metadata, 'state', item.state);
|
|
3960
|
+
setIfDefined(metadata, 'toolUses', item.tool_uses);
|
|
3961
|
+
setIfDefined(metadata, 'durationMs', item.duration_ms);
|
|
3962
|
+
channel.sendProcessingStatus(channelId, 'progress', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, metadata);
|
|
3963
|
+
continue;
|
|
3964
|
+
}
|
|
3965
|
+
const aunPayload = channel.buildActivityPayload(envelope, replyCtx, item);
|
|
3966
|
+
if (envelope.chatmode === 'proactive') {
|
|
3967
|
+
await channel.sendThought(channelId, envelope.taskId, aunPayload, replyCtx);
|
|
3968
|
+
}
|
|
3969
|
+
else {
|
|
3970
|
+
await channel.sendReliableStructured(channelId, aunPayload, replyCtx, channel.activityLogText(item));
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
case 'status.progress':
|
|
3976
|
+
channel.sendProcessingStatus(channelId, 'progress', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3977
|
+
return;
|
|
3978
|
+
case 'status.requires_action':
|
|
3979
|
+
channel.sendProcessingStatus(channelId, 'requires_action', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3980
|
+
return;
|
|
3981
|
+
case 'status.started':
|
|
3982
|
+
channel.sendProcessingStatus(channelId, 'start', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3983
|
+
return;
|
|
3984
|
+
case 'status.queued':
|
|
3985
|
+
channel.sendProcessingStatus(channelId, 'queued', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3986
|
+
return;
|
|
3987
|
+
case 'status.completed':
|
|
3988
|
+
channel.sendProcessingStatus(channelId, 'done', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3989
|
+
return;
|
|
3990
|
+
case 'status.interrupted':
|
|
3991
|
+
channel.sendProcessingStatus(channelId, 'interrupted', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3992
|
+
return;
|
|
3993
|
+
case 'status.error':
|
|
3994
|
+
channel.sendProcessingStatus(channelId, 'error', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3995
|
+
return;
|
|
3996
|
+
case 'status.timeout':
|
|
3997
|
+
channel.sendProcessingStatus(channelId, 'timeout', envelope.sessionId ?? envelope.taskId, envelope.taskId, replyCtx, payload.metadata);
|
|
3998
|
+
return;
|
|
3999
|
+
case 'interaction': {
|
|
4000
|
+
const req = payload.interaction;
|
|
4001
|
+
if (req.kind.kind === 'action') {
|
|
4002
|
+
const action = req.kind;
|
|
4003
|
+
const aunCard = {
|
|
4004
|
+
type: 'action_card',
|
|
4005
|
+
title: action.title,
|
|
4006
|
+
actions: action.buttons.map(btn => ({
|
|
4007
|
+
label: btn.label, value: btn.key, style: btn.style ?? 'default', behavior: 'reply',
|
|
4008
|
+
})),
|
|
4009
|
+
};
|
|
4010
|
+
// AUN action_card 无法内嵌输入框(只有按钮),手动输入降级为「✏️ 手动输入」按钮:
|
|
4011
|
+
// 点击回传 _show_input → claude-runner 用 interceptNextMessage 拦截下一条消息作为答案。
|
|
4012
|
+
if (action.allowCustomInput) {
|
|
4013
|
+
aunCard.actions.push({ label: '✏️ 手动输入', value: '_show_input', style: 'default', behavior: 'reply' });
|
|
4014
|
+
}
|
|
4015
|
+
if (action.body) {
|
|
4016
|
+
aunCard.text = action.body;
|
|
4017
|
+
setIfDefined(aunCard, 'format', action.bodyFormat);
|
|
4018
|
+
}
|
|
4019
|
+
if (req.initiatorId && channel.isGroupId(channelId))
|
|
4020
|
+
aunCard.initiator = req.initiatorId;
|
|
4021
|
+
if (replyCtx?.threadId)
|
|
4022
|
+
aunCard.thread_id = replyCtx.threadId;
|
|
4023
|
+
await channel.sendContentPayload(channelId, aunCard, {
|
|
4024
|
+
contentKind: 'card',
|
|
4025
|
+
context: replyCtx,
|
|
4026
|
+
logText: action.title ? `[card] ${action.title}` : '[card]',
|
|
4027
|
+
postSend: {
|
|
4028
|
+
type: 'register_interaction_card',
|
|
4029
|
+
requestId: req.id,
|
|
4030
|
+
isCommandCard: false,
|
|
4031
|
+
initiatorAid: req.initiatorId,
|
|
4032
|
+
expiresAt: Date.now() + 20 * 60 * 1000,
|
|
4033
|
+
},
|
|
4034
|
+
});
|
|
4035
|
+
}
|
|
4036
|
+
else if (req.kind.kind === 'command-card') {
|
|
4037
|
+
const card = req.kind;
|
|
4038
|
+
const aunCard = {
|
|
4039
|
+
type: 'action_card',
|
|
4040
|
+
title: card.title,
|
|
4041
|
+
actions: card.buttons.map(btn => {
|
|
4042
|
+
const action = {
|
|
4043
|
+
label: btn.label,
|
|
4044
|
+
value: btn.command,
|
|
4045
|
+
style: btn.style ?? 'default',
|
|
4046
|
+
behavior: 'reply',
|
|
4047
|
+
};
|
|
4048
|
+
setIfDefined(action, 'disabled', btn.disabled);
|
|
4049
|
+
return action;
|
|
4050
|
+
}),
|
|
4051
|
+
};
|
|
4052
|
+
if (card.body) {
|
|
4053
|
+
aunCard.text = card.body;
|
|
4054
|
+
setIfDefined(aunCard, 'format', card.bodyFormat);
|
|
4055
|
+
}
|
|
4056
|
+
if (replyCtx?.threadId)
|
|
4057
|
+
aunCard.thread_id = replyCtx.threadId;
|
|
4058
|
+
await channel.sendContentPayload(channelId, aunCard, {
|
|
4059
|
+
contentKind: 'card',
|
|
4060
|
+
context: replyCtx,
|
|
4061
|
+
logText: card.title ? `[card] ${card.title}` : '[card]',
|
|
4062
|
+
postSend: {
|
|
4063
|
+
type: 'register_interaction_card',
|
|
4064
|
+
requestId: req.id,
|
|
4065
|
+
isCommandCard: true,
|
|
4066
|
+
initiatorAid: req.initiatorId,
|
|
4067
|
+
expiresAt: Date.now() + 20 * 60 * 1000,
|
|
4068
|
+
},
|
|
4069
|
+
});
|
|
4070
|
+
}
|
|
4071
|
+
else if (payload.fallbackText) {
|
|
4072
|
+
await channel.sendMessage(channelId, payload.fallbackText, replyCtx);
|
|
4073
|
+
}
|
|
4074
|
+
return;
|
|
4075
|
+
}
|
|
4076
|
+
case 'custom': {
|
|
4077
|
+
const text = typeof payload.payload === 'string' ? payload.payload : JSON.stringify(payload.payload);
|
|
4078
|
+
channel.sendCustomPayload(channelId, text);
|
|
4079
|
+
return;
|
|
4080
|
+
}
|
|
4081
|
+
default:
|
|
4082
|
+
logger.warn(`[AUN] Unhandled payload kind: ${payload.kind}`);
|
|
4083
|
+
}
|
|
4084
|
+
},
|
|
4085
|
+
acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
|
|
4086
|
+
onInteraction: (cb) => { channel.interactionCallback = cb; },
|
|
4087
|
+
invalidateInteraction: (interactionId, reason) => channel.invalidateInteraction(interactionId, reason),
|
|
4088
|
+
uploadAgentMd: (content) => channel.uploadAgentMd(content),
|
|
4089
|
+
downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
|
|
4090
|
+
getGroupName: (groupId) => channel.getGroupName(groupId),
|
|
4091
|
+
getGroupMemberRole: (groupId, aid) => channel.getGroupMemberRole(groupId, aid),
|
|
4092
|
+
_selfAid: () => channel.getStatus().aid,
|
|
4093
|
+
_selfName: () => channel.getSelfName(),
|
|
4094
|
+
};
|
|
4095
|
+
const policy = {
|
|
4096
|
+
canSwitchProject: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
4097
|
+
canListProjects: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
4098
|
+
canCreateSession: () => true,
|
|
4099
|
+
canDeleteSession: () => true,
|
|
4100
|
+
canImportCliSession: (_, identity) => identity === 'owner' || identity === 'admin',
|
|
4101
|
+
messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
|
|
4102
|
+
showMiddleResult: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
4103
|
+
middleOutputMode: (chatType, identity) => middleOutputModePolicy(mode, chatType, identity),
|
|
4104
|
+
showIdleMonitor: (chatType, identity) => showActivitiesPolicy(mode, chatType, identity),
|
|
4105
|
+
accumulateErrors: () => true,
|
|
4106
|
+
};
|
|
4107
|
+
return {
|
|
4108
|
+
channelType: 'aun', adapter, channel,
|
|
4109
|
+
policy,
|
|
4110
|
+
options: { flushDelay: inst.flushDelay ?? DEFAULT_FLUSH_DELAY_SECONDS, fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g },
|
|
4111
|
+
connect: () => channel.connect(),
|
|
4112
|
+
disconnect: () => channel.disconnect(),
|
|
4113
|
+
onProjectPathRequest: () => Promise.resolve(ctx.defaultProjectPath),
|
|
4114
|
+
registerBridge(bridge, channelType) {
|
|
4115
|
+
bridge.register(adapter.channelName, (handler) => channel.onMessage(async (opts) => {
|
|
4116
|
+
handler(aunOptsToInbound(opts, adapter.channelName, channelType));
|
|
4117
|
+
}), (channelId, text, replyContext) => channel.sendMessage(channelId, text, replyContext), adapter, channelType);
|
|
4118
|
+
},
|
|
4119
|
+
registerHooks(hookCtx) {
|
|
4120
|
+
channel.setEventBus(hookCtx.eventBus);
|
|
4121
|
+
if (channel.setOnChannelDown) {
|
|
4122
|
+
channel.setOnChannelDown(() => {
|
|
4123
|
+
hookCtx.eventBus.publish({
|
|
4124
|
+
type: 'channel:error',
|
|
4125
|
+
channel: 'aun',
|
|
4126
|
+
channelName: adapter.channelName,
|
|
4127
|
+
status: 'auth_error',
|
|
4128
|
+
message: `⚠️ AUN 渠道 ${adapter.channelName} 断连,自动重试已用尽。\n使用 /check rty aun 手动重连`,
|
|
4129
|
+
timestamp: Date.now(),
|
|
4130
|
+
});
|
|
4131
|
+
});
|
|
4132
|
+
}
|
|
4133
|
+
if (typeof channel.setDispatchModeResolver === 'function') {
|
|
4134
|
+
channel.setDispatchModeResolver(async (channelId) => {
|
|
4135
|
+
// 配置层用 mentionMode(mention-only/disabled),翻译成 AUN 协议
|
|
4136
|
+
// dispatch_mode(mention/broadcast);未设值返回 undefined,回退服务端下发值。
|
|
4137
|
+
const mentionMode = resolveEffective({
|
|
4138
|
+
self: aid,
|
|
4139
|
+
peerKey: formatPeerKey('aun', channelId),
|
|
4140
|
+
}, { cache: true }).mentionMode;
|
|
4141
|
+
return mentionModeToDispatch(mentionMode);
|
|
4142
|
+
});
|
|
4143
|
+
}
|
|
4144
|
+
},
|
|
4145
|
+
};
|
|
4146
|
+
}
|
|
4147
|
+
}
|