elanous 0.2.7 → 0.2.9
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/bFI5EEwg7T6aG88ibX4Zq/_buildManifest.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7032-f4ef698518fba61c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8067-d976b441a104ab74.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8519-5632377cce994f86.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8630-cdfc61e82c653ffc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8894-e263b332208e3957.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9454-139040d37c79dbc9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9740-8f5ff96c522b5f55.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-fb7c2d458f32fcad.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-35e2713597dd24d1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/board/page-918b656f064577ba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-624f7a2f89b60c8f.js → page-f972fc1c00a62e88.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-5a5fb385a4df0ffe.js → page-5f30e00693527ebd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-80497e93a878ddc5.js → page-7fbae173b3c103a7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-7457f52683562161.js → page-3b4edcce364062ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-c5794f484c251f44.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-1130d8eefcd87e76.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-973996d584f008c1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-b7dcf5d48a854b76.js → page-016e2bda04eecb1f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-51480757e7d68b92.js → page-b4d6d9a3b56f2940.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-ec49a466121233ba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/layout-f72db356744ceade.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-a6fd9b50d1b85378.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-8b0402a46a749a3d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-07267f1391b96aba.js → page-037f83e2a1edc8e2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-55d50e9b0006a91d.js → page-adfbd2ba9598e59a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-46fedff57d4bec67.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-0cf318e9dcee2bbf.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-899b327cb8b6845c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-6c1e6ba743a29dfe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/{page-a7e37af7d0399cb8.js → page-1a8e4807292f6b17.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-28deba6bcb834916.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-de68e2205794037d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-15e5a15db84c56aa.js → page-c71c33a573230672.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-a2e572e4511f2a16.js → page-0df30e0c7e50148a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-4fc56790fe0d2682.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-59df2aebb38a7c1e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-4647be5dbcbd0795.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/page-e025892bda74b743.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a0955de73b89f078.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-c1c95739c0bff3b8.js → page-369846d2921af584.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-72cbc1c8cdd0f65d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-71d18eec33b9c667.js → page-77b28bfb0996d5ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-3220c4934f49c6c2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-2c4e59079ee357ac.js → page-90670589b87abea8.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-7df9243ed021a4cc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-5d422f38c052967e.js → page-4498c3853f4f688d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-35bedde6d885f08f.js +1 -0
- package/apps/pwa/out/_next/static/css/f92f63a5df0ba229.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +30 -29
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +30 -29
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +30 -29
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +27 -26
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +27 -26
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +27 -26
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +30 -29
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +30 -29
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +30 -29
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +30 -29
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +30 -29
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +30 -29
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +27 -26
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +30 -29
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +30 -29
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +27 -26
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +27 -26
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +30 -29
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +30 -29
- package/apps/pwa/out/ops/checklist/index.html +1 -0
- package/apps/pwa/out/ops/checklist/index.txt +30 -0
- package/apps/pwa/out/ops/release/index.html +1 -0
- package/apps/pwa/out/ops/release/index.txt +30 -0
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +28 -27
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +30 -29
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +27 -26
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +30 -29
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +30 -29
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +30 -29
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +30 -29
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +30 -29
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +27 -26
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +30 -29
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +30 -29
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +30 -29
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +30 -29
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +30 -29
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +30 -29
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +30 -29
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +30 -29
- package/graphs/release/recipes.yaml +3 -0
- package/graphs/release/release-loop.yaml +3 -1
- package/graphs/test-diet/nightly-audit.yaml +18 -0
- package/graphs/test-diet/recipes.yaml +12 -0
- package/graphs/test-diet/test-diet.yaml +22 -0
- package/package.json +2 -1
- package/persona-presets/_index.yaml +10 -0
- package/persona-presets/_schema.json +146 -0
- package/persona-presets/dana.yaml +26 -0
- package/persona-presets/haru.yaml +27 -0
- package/persona-presets/kai.yaml +27 -0
- package/persona-presets/leo.yaml +27 -0
- package/persona-presets/mira.yaml +30 -0
- package/persona-presets/nari.yaml +27 -0
- package/persona-presets/sora.yaml +27 -0
- package/persona-presets/yuna.yaml +27 -0
- package/plugins/doc-draft/graphs/run-step.ts +1 -1
- package/plugins/geo-check/graphs/run-step.ts +34 -10
- package/scripts/field-feed/deliver-node.ts +8 -3
- package/scripts/field-feed/lib.ts +2 -0
- package/scripts/install.sh +20 -11
- package/scripts/persona-presets/lint.ts +79 -0
- package/scripts/release-loop/checklist-gate-node.ts +2 -2
- package/scripts/release-loop/cutoff.ts +20 -2
- package/scripts/release-loop/export-check-node.ts +85 -0
- package/scripts/release-loop/gate-node.ts +45 -7
- package/scripts/release-loop/node-verdict.ts +8 -1
- package/scripts/release-loop/npm-publish-node.ts +10 -4
- package/scripts/release-loop/ops-upgrade-node.ts +11 -3
- package/scripts/release-loop/publish-node.ts +23 -1
- package/scripts/release-loop/shard-plan.ts +17 -0
- package/scripts/storyboard/storyboard.ts +167 -0
- package/scripts/test-diet/lib.ts +89 -0
- package/scripts/test-diet/node.ts +144 -0
- package/src/acp/auth-methods.ts +30 -0
- package/src/acp/server.ts +5 -5
- package/src/auth/setup-link-tokens.fixture.ts +19 -0
- package/src/auth/setup-link-tokens.ts +153 -0
- package/src/budget/unified-usage.ts +75 -0
- package/src/channel-bot-token.ts +37 -10
- package/src/channel-import/detect.ts +191 -0
- package/src/channel-import/run.ts +111 -0
- package/src/chat/index.ts +9 -9
- package/src/cli/config-test-sync.ts +4 -0
- package/src/cli/decisions-cli.ts +13 -3
- package/src/cli/doctor-bundle.ts +128 -0
- package/src/cli/doctor-cli.ts +36 -1
- package/src/cli/doctor-local-llm.ts +95 -0
- package/src/cli/import-cli.ts +60 -0
- package/src/cli/mcp-login.ts +13 -4
- package/src/cli/op-proxy-sync.ts +83 -0
- package/src/cli/persona-cli.ts +116 -0
- package/src/cli/pr-cli.ts +6 -2
- package/src/cli/release-cli.ts +49 -8
- package/src/cli/skills-cli.ts +173 -0
- package/src/cli/start-cli.ts +13 -4
- package/src/codex/setup.ts +15 -13
- package/src/dashboard/dashboard-status-lines.ts +45 -0
- package/src/dashboard/first-screen-band.ts +35 -0
- package/src/dashboard/index.ts +87 -14
- package/src/dashboard/provider-slash-runtime.ts +28 -11
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +12 -2
- package/src/dashboard/slash-runtime/help-from-registry.ts +4 -2
- package/src/dashboard/turn-stream-formatter.ts +1 -1
- package/src/decisions/decision-cards.ts +291 -0
- package/src/decisions/decision-ledger.ts +5 -2
- package/src/decisions/discord-decision-cards.ts +133 -0
- package/src/decisions/telegram-decision-cards.ts +100 -0
- package/src/directives/version-at.ts +77 -0
- package/src/discord.ts +17 -0
- package/src/domains/outbound-alert.ts +44 -1
- package/src/domains/repeated-failure.ts +1 -1
- package/src/error-report/report.ts +200 -0
- package/src/exec-requests/planner.ts +8 -2
- package/src/exec-requests/runner.ts +64 -24
- package/src/exec-requests/store.ts +2 -0
- package/src/field/field-feed-auto.ts +48 -0
- package/src/field/field-reel.ts +8 -1
- package/src/hooks/error-report.ts +210 -0
- package/src/hooks/receiver.ts +41 -1
- package/src/import/apply.ts +128 -0
- package/src/import/plan.ts +197 -0
- package/src/index.ts +98 -11
- package/src/input-core/text-entry.ts +13 -5
- package/src/intake-plane/seat-answer.ts +96 -0
- package/src/intake-plane/seat-doc-route.ts +17 -3
- package/src/maturity/feature-maturity.ts +2 -0
- package/src/nexus/api/consult-requests.ts +19 -8
- package/src/nexus/api/error-reports.ts +108 -0
- package/src/nexus/api/http-server.ts +41 -1
- package/src/nexus/api/meta-api.ts +27 -0
- package/src/nexus/api/operator.ts +61 -0
- package/src/nexus/api/ops-api.ts +139 -0
- package/src/nexus/api/ops-seats-sources.ts +65 -0
- package/src/nexus/api/ops-seats.ts +114 -0
- package/src/nexus/api/outbound-report.ts +7 -0
- package/src/nexus/api/public-routes.ts +14 -1
- package/src/nexus/api/running-runs-background.ts +68 -0
- package/src/nexus/api/running-runs-child.ts +6 -0
- package/src/nexus/api/setup-claim.ts +21 -0
- package/src/nexus/api/share-captures.ts +91 -0
- package/src/nexus/api/skill-problems.ts +56 -0
- package/src/nexus/api/terminals.ts +24 -8
- package/src/nexus/config/secrets/file-backend.ts +56 -18
- package/src/nexus/index.ts +25 -1
- package/src/nexus/setup-status.ts +7 -2
- package/src/oauth/browser-availability.ts +14 -0
- package/src/oauth/codex-quota-policy.ts +16 -0
- package/src/onboarding/continue-here.ts +13 -8
- package/src/onboarding/first-run-doctor.ts +54 -0
- package/src/onboarding/web-first.ts +8 -1
- package/src/onboarding.ts +66 -12
- package/src/persona/presets.ts +105 -0
- package/src/release-loop/checklist.ts +33 -69
- package/src/release-loop/feature-store.ts +327 -0
- package/src/release-loop/manifest.ts +59 -5
- package/src/seat-dispatch/ceo-commands.ts +65 -0
- package/src/self-dev/orchestrate.ts +18 -11
- package/src/shell-env-bootstrap.ts +44 -7
- package/src/skills/connect-claude-plugin.ts +142 -0
- package/src/skills/connect.ts +70 -0
- package/src/skills/index.ts +29 -1
- package/src/skills/repair.ts +124 -0
- package/src/skills/runner.ts +11 -2
- package/src/skills/tools/usage-slash.ts +8 -7
- package/src/skills/trigger-extract.ts +1 -1
- package/src/task-orchestrator/surfaces/pod-command-job.ts +16 -8
- package/src/telegram-commands.ts +29 -0
- package/src/telegram-run.ts +83 -13
- package/src/telegram.ts +34 -0
- package/src/thinking-line.ts +28 -20
- package/src/tui/chat/execution-badge.ts +1 -1
- package/src/user-config.ts +111 -13
- package/src/ux-sim/turn-states.ts +1 -1
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/page-10ed24f413ed66e9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-211810b6c93ab66a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +0 -1
- package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +0 -1
- package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{keajW7DDyghEs1y8oJe6T → bFI5EEwg7T6aG88ibX4Zq}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// DEC-TG — 디스코드 운반(운영 봇 · 대표 DM). 판단은 decision-cards.ts. 버튼 = MESSAGE_COMPONENT · 메모 = 모달.
|
|
2
|
+
import type { DiscordBot } from '../discord.js';
|
|
3
|
+
import type { UserConfig } from '../user-config.js';
|
|
4
|
+
import { debug } from '../debug/log.js';
|
|
5
|
+
import { DECISION_DATA_PREFIX, DecisionCardService, ghPrReplier, type CardTransport, type CardView } from './decision-cards.js';
|
|
6
|
+
|
|
7
|
+
const TICK_MS = 20_000;
|
|
8
|
+
const MEMO_PREFIX = 'decmemo:';
|
|
9
|
+
const EPHEMERAL = 64;
|
|
10
|
+
let current: DecisionCardService | null = null;
|
|
11
|
+
|
|
12
|
+
export function discordDecisionService(): DecisionCardService | null { return current; }
|
|
13
|
+
|
|
14
|
+
export function discordDecisionOwner(cfg: UserConfig): string | null {
|
|
15
|
+
const raw = (cfg.raw?.decisions as { discordOwnerId?: unknown } | undefined)?.discordOwnerId;
|
|
16
|
+
if (typeof raw === 'string' && /^\d{5,}$/.test(raw)) {
|
|
17
|
+
debug.log('decisions.telegram', 'owner-source', { platform: 'discord', source: 'config' });
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
debug.log('decisions.telegram', 'owner-source', { platform: 'discord', source: 'default-first', allowlist: cfg.discord.allowedUsers.length }, cfg.discord.allowedUsers.length > 1 ? { level: 'warn' } : undefined);
|
|
21
|
+
return cfg.discord.allowedUsers[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `/decisions` typed in Discord — owner only, and only in a DM (a guild channel would show the cards to everyone). */
|
|
25
|
+
export async function discordDecisionsCommand(service: DecisionCardService, ctx: { userId: string; channelId: string; isDm: boolean }): Promise<string> {
|
|
26
|
+
if (!service.isOwner(ctx.userId)) return '결정은 소유자만 볼 수 있습니다.';
|
|
27
|
+
if (!ctx.isDm) return '결정은 개인 대화(DM)에서만 볼 수 있습니다.';
|
|
28
|
+
return service.listOpen(ctx.channelId);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Card buttons → Discord action rows (choices primary · memo secondary · confirm success). */
|
|
32
|
+
export function discordComponents(view: CardView): Array<Record<string, unknown>> {
|
|
33
|
+
return view.buttons.map((row) => ({
|
|
34
|
+
type: 1,
|
|
35
|
+
components: row.map((b) => ({
|
|
36
|
+
type: 2,
|
|
37
|
+
style: b.data.endsWith(':ok') ? 3 : b.data.endsWith(':memo') || b.data.endsWith(':-') ? 2 : 1,
|
|
38
|
+
label: Array.from(b.label).slice(0, 80).join(''),
|
|
39
|
+
custom_id: b.data.slice(0, 100),
|
|
40
|
+
})),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Bot = Pick<DiscordBot, 'openDmChannel' | 'sendMessageWithComponents' | 'editMessageWithComponents' | 'sendMessage' | 'respondToInteraction'>;
|
|
45
|
+
|
|
46
|
+
export function discordTransport(bot: Bot, owner: string): CardTransport {
|
|
47
|
+
let dm: string | null = null;
|
|
48
|
+
return {
|
|
49
|
+
platform: 'discord',
|
|
50
|
+
ownerChats: async () => {
|
|
51
|
+
dm ??= await bot.openDmChannel(owner);
|
|
52
|
+
return dm ? [dm] : [];
|
|
53
|
+
},
|
|
54
|
+
send: async (chat, view) => {
|
|
55
|
+
const sent = await bot.sendMessageWithComponents(chat, view.text, discordComponents(view));
|
|
56
|
+
return sent ? { chat, message: sent.id } : null;
|
|
57
|
+
},
|
|
58
|
+
edit: async (ref, view) => { await bot.editMessageWithComponents(ref.chat, ref.message, view.text, discordComponents(view)); },
|
|
59
|
+
notify: async (chat, text) => { await bot.sendMessage(chat, text); },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function userOf(raw: Record<string, unknown>): string {
|
|
64
|
+
const member = raw.member as { user?: { id?: string } } | undefined;
|
|
65
|
+
const user = raw.user as { id?: string } | undefined;
|
|
66
|
+
return member?.user?.id ?? user?.id ?? '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Handle a decision button or memo modal. Returns true when the interaction was ours (so nothing else answers it). */
|
|
70
|
+
export async function handleDiscordDecisionInteraction(bot: Bot, service: DecisionCardService, raw: Record<string, unknown>): Promise<boolean> {
|
|
71
|
+
const data = raw.data as { custom_id?: string; components?: Array<{ components?: Array<{ value?: string }> }> } | undefined;
|
|
72
|
+
const customId = data?.custom_id ?? '';
|
|
73
|
+
const id = String(raw.id ?? '');
|
|
74
|
+
const token = String(raw.token ?? '');
|
|
75
|
+
const reply = (content: string) => bot.respondToInteraction(id, token, { type: 4, data: { content, flags: EPHEMERAL } });
|
|
76
|
+
if (raw.type === 5 && customId.startsWith(MEMO_PREFIX)) {
|
|
77
|
+
const value = data?.components?.[0]?.components?.[0]?.value ?? '';
|
|
78
|
+
const view = await service.setNote(userOf(raw), customId.slice(MEMO_PREFIX.length), value);
|
|
79
|
+
await reply(view ? '메모를 달았습니다 — 카드에서 선택지를 누르면 함께 기록됩니다.' : '메모를 달지 못했습니다(권한 또는 이미 정해진 결정).');
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (raw.type !== 3 || !customId.startsWith(DECISION_DATA_PREFIX)) return false;
|
|
83
|
+
const outcome = await service.tap(userOf(raw), customId);
|
|
84
|
+
switch (outcome.kind) {
|
|
85
|
+
case 'refused': await reply('권한이 없습니다.'); return true;
|
|
86
|
+
case 'ignored': case 'unknown': await reply('알 수 없는 결정입니다.'); return true;
|
|
87
|
+
case 'memo-requested': {
|
|
88
|
+
const decisionId = /^dec:([^:]+):memo$/.exec(customId)?.[1] ?? '';
|
|
89
|
+
await bot.respondToInteraction(id, token, { type: 9, data: {
|
|
90
|
+
custom_id: `${MEMO_PREFIX}${decisionId}`.slice(0, 100), title: Array.from(`메모 달기 · ${decisionId}`).slice(0, 45).join(''),
|
|
91
|
+
components: [{ type: 1, components: [{ type: 4, custom_id: 'note', style: 2, label: '메모', max_length: 300, required: true }] }],
|
|
92
|
+
} });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
// UPDATE_MESSAGE on the tapped card (decided: the service already edited every card it sent — same content).
|
|
97
|
+
await bot.respondToInteraction(id, token, { type: 7, data: { content: outcome.view.text, components: discordComponents(outcome.view) } });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DiscordDecisionWire {
|
|
103
|
+
service: DecisionCardService;
|
|
104
|
+
onInteraction: (raw: Record<string, unknown>) => Promise<boolean>;
|
|
105
|
+
stop: () => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function attachDiscordDecisionCards(bot: Bot, cfg: UserConfig, opts: { tickMs?: number } = {}): DiscordDecisionWire | null {
|
|
109
|
+
const owner = discordDecisionOwner(cfg);
|
|
110
|
+
if (!owner) {
|
|
111
|
+
debug.log('decisions.telegram', 'not-attached', { platform: 'discord', reason: 'no-owner' });
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const replyTarget = (cfg.raw?.decisions as { replyGhPr?: unknown } | undefined)?.replyGhPr;
|
|
115
|
+
const service = new DecisionCardService({
|
|
116
|
+
transport: discordTransport(bot, owner),
|
|
117
|
+
ownerIds: [owner],
|
|
118
|
+
...(typeof replyTarget === 'string' && replyTarget ? { replyToRaiser: ghPrReplier(replyTarget) } : {}),
|
|
119
|
+
});
|
|
120
|
+
current = service;
|
|
121
|
+
const timer = setInterval(() => {
|
|
122
|
+
void service.tick().catch((error: unknown) => {
|
|
123
|
+
debug.log('decisions.telegram', 'tick-failed', { platform: 'discord', reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
|
|
124
|
+
});
|
|
125
|
+
}, opts.tickMs ?? TICK_MS);
|
|
126
|
+
(timer as { unref?: () => void }).unref?.();
|
|
127
|
+
debug.log('decisions.telegram', 'attached', { platform: 'discord', reply: typeof replyTarget === 'string' && Boolean(replyTarget) });
|
|
128
|
+
return {
|
|
129
|
+
service,
|
|
130
|
+
onInteraction: (raw) => handleDiscordDecisionInteraction(bot, service, raw),
|
|
131
|
+
stop: () => { clearInterval(timer); if (current === service) current = null; },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// DEC-TG — 텔레그램 운반(운영 봇 DM). 판단은 decision-cards.ts 에 있고 여기는 «보내고·고치고·누름을 넘긴다»만 한다.
|
|
2
|
+
// ⛔ 카드는 «폴링하는» 본 봇만 보낸다(넥서스가 폴링 없는 봇을 따로 세울 수 있다 — 두 번 가면 안 된다).
|
|
3
|
+
import type { TelegramBot, TgCallbackQuery } from '../telegram.js';
|
|
4
|
+
import type { UserConfig } from '../user-config.js';
|
|
5
|
+
import { debug } from '../debug/log.js';
|
|
6
|
+
import { DECISION_DATA_PREFIX, DecisionCardService, ghPrReplier, type CardTransport, type CardView } from './decision-cards.js';
|
|
7
|
+
|
|
8
|
+
const TICK_MS = 20_000;
|
|
9
|
+
let current: DecisionCardService | null = null;
|
|
10
|
+
|
|
11
|
+
/** The running Telegram card service (for the `/decisions` command), or null when none is attached. */
|
|
12
|
+
export function telegramDecisionService(): DecisionCardService | null { return current; }
|
|
13
|
+
|
|
14
|
+
function buttons(view: CardView): Array<Array<{ text: string; data: string }>> {
|
|
15
|
+
return view.buttons.map((row) => row.map((b) => ({ text: b.label, data: b.data })));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Owner = `decisions.telegramOwnerId` when set, else the first allowlisted user (the bot owner). */
|
|
19
|
+
export function telegramDecisionOwner(cfg: UserConfig): string | null {
|
|
20
|
+
const raw = (cfg.raw?.decisions as { telegramOwnerId?: unknown } | undefined)?.telegramOwnerId;
|
|
21
|
+
if (typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw))) {
|
|
22
|
+
debug.log('decisions.telegram', 'owner-source', { platform: 'telegram', source: 'config' });
|
|
23
|
+
return String(raw);
|
|
24
|
+
}
|
|
25
|
+
const first = cfg.telegram.allowedUsers[0];
|
|
26
|
+
debug.log('decisions.telegram', 'owner-source', { platform: 'telegram', source: 'default-first', allowlist: cfg.telegram.allowedUsers.length }, cfg.telegram.allowedUsers.length > 1 ? { level: 'warn' } : undefined);
|
|
27
|
+
return typeof first === 'number' ? String(first) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function telegramTransport(bot: Pick<TelegramBot, 'sendInlineKeyboard' | 'editMessageWithKeyboard' | 'sendMessage'>, owner: string): CardTransport {
|
|
31
|
+
return {
|
|
32
|
+
platform: 'telegram',
|
|
33
|
+
ownerChats: async () => [owner],
|
|
34
|
+
send: async (chat, view) => {
|
|
35
|
+
const sent = await bot.sendInlineKeyboard(Number(chat), view.text, buttons(view));
|
|
36
|
+
return sent ? { chat, message: String(sent.messageId) } : null;
|
|
37
|
+
},
|
|
38
|
+
edit: async (ref, view) => { await bot.editMessageWithKeyboard(Number(ref.chat), Number(ref.message), view.text, buttons(view)); },
|
|
39
|
+
notify: async (chat, text) => { await bot.sendMessage(Number(chat), text); },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AttachOptions { tickMs?: number; service?: DecisionCardService }
|
|
44
|
+
|
|
45
|
+
/** Wire cards onto a bot: callback taps, memo capture, and a ticker that runs only while this bot polls. Returns a stop fn. */
|
|
46
|
+
export function attachTelegramDecisionCards(bot: TelegramBot, cfg: UserConfig, opts: AttachOptions = {}): () => void {
|
|
47
|
+
const owner = telegramDecisionOwner(cfg);
|
|
48
|
+
if (!owner) {
|
|
49
|
+
debug.log('decisions.telegram', 'not-attached', { platform: 'telegram', reason: 'no-owner' });
|
|
50
|
+
return () => undefined;
|
|
51
|
+
}
|
|
52
|
+
const replyTarget = (cfg.raw?.decisions as { replyGhPr?: unknown } | undefined)?.replyGhPr;
|
|
53
|
+
const service = opts.service ?? new DecisionCardService({
|
|
54
|
+
transport: telegramTransport(bot, owner),
|
|
55
|
+
ownerIds: [owner],
|
|
56
|
+
...(typeof replyTarget === 'string' && replyTarget ? { replyToRaiser: ghPrReplier(replyTarget) } : {}),
|
|
57
|
+
});
|
|
58
|
+
current = service;
|
|
59
|
+
const unsubscribe = bot.onCallbackQuery(async (q: TgCallbackQuery) => {
|
|
60
|
+
if (!q.data.startsWith(DECISION_DATA_PREFIX)) return;
|
|
61
|
+
const outcome = await service.tap(String(q.userId), q.data);
|
|
62
|
+
const where = q.chatId !== undefined && q.messageId !== undefined ? { chat: q.chatId, message: q.messageId } : null;
|
|
63
|
+
switch (outcome.kind) {
|
|
64
|
+
case 'refused': await bot.answerCallbackQuery(q.id, { text: '권한이 없습니다' }); return;
|
|
65
|
+
case 'ignored': case 'unknown': await bot.answerCallbackQuery(q.id, { text: '알 수 없는 결정입니다' }); return;
|
|
66
|
+
case 'memo-requested': {
|
|
67
|
+
// The next text in a GROUP could come from another allow-listed member — capture only in the owner's private chat.
|
|
68
|
+
if (q.chatId === undefined || q.chatId !== q.userId) {
|
|
69
|
+
debug.log('decisions.telegram', 'memo-refused', { platform: 'telegram', reason: 'not-private-chat' });
|
|
70
|
+
await bot.answerCallbackQuery(q.id, { text: '메모는 개인 대화에서만 달 수 있습니다' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await bot.answerCallbackQuery(q.id, { text: outcome.toast });
|
|
74
|
+
{
|
|
75
|
+
const id = /^dec:([^:]+):memo$/.exec(q.data)?.[1];
|
|
76
|
+
await bot.sendMessage(q.chatId, `📝 ${id} 메모를 한 줄로 보내 주세요(취소: /cancel).`);
|
|
77
|
+
bot.captureNextText(q.chatId, undefined, (text) => {
|
|
78
|
+
if (text && id) void service.setNote(String(q.userId), id, text).then((view) => {
|
|
79
|
+
if (view && q.chatId !== undefined) void bot.sendMessage(q.chatId, '메모를 달았습니다 — 카드에서 선택지를 누르면 함께 기록됩니다.');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
await bot.answerCallbackQuery(q.id, { text: outcome.toast, ...(outcome.kind === 'confirm' ? { alert: true } : {}) });
|
|
87
|
+
// decided: the service already edited every card it sent; confirm/cancel/closed: edit the tapped one.
|
|
88
|
+
if (outcome.kind !== 'decided' && where) await bot.editMessageWithKeyboard(where.chat, where.message, outcome.view.text, buttons(outcome.view)).catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
const timer = setInterval(() => {
|
|
92
|
+
if (!bot.isPolling()) return;
|
|
93
|
+
void service.tick().catch((error: unknown) => {
|
|
94
|
+
debug.log('decisions.telegram', 'tick-failed', { reason: error instanceof Error ? error.message.slice(0, 80) : 'unknown' }, { level: 'warn' });
|
|
95
|
+
});
|
|
96
|
+
}, opts.tickMs ?? TICK_MS);
|
|
97
|
+
(timer as { unref?: () => void }).unref?.();
|
|
98
|
+
debug.log('decisions.telegram', 'attached', { platform: 'telegram', reply: typeof replyTarget === 'string' && Boolean(replyTarget) });
|
|
99
|
+
return () => { clearInterval(timer); unsubscribe(); if (current === service) current = null; };
|
|
100
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { elanousStateRoot } from '../autopilot/state-paths.js';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export interface Versions { released: string | null; dev: string | null; codename: string | null }
|
|
7
|
+
export interface VersionOptions { releaseRoot?: string; repoRoot?: string; codenames?: Record<string, string> }
|
|
8
|
+
|
|
9
|
+
/** SemVer order for release ledgers: numeric core, then a prerelease sorts before its release. */
|
|
10
|
+
export function compareVersions(a: string, b: string): number {
|
|
11
|
+
const parse = (v: string) => { const [core, pre] = v.split('-', 2); return { nums: core!.split('.').map(n => Number(n) || 0), pre: pre ?? null }; };
|
|
12
|
+
const x = parse(a); const y = parse(b);
|
|
13
|
+
for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
|
|
14
|
+
const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
|
|
15
|
+
if (d) return d;
|
|
16
|
+
}
|
|
17
|
+
if (x.pre === y.pre) return 0;
|
|
18
|
+
if (x.pre === null) return 1;
|
|
19
|
+
if (y.pre === null) return -1;
|
|
20
|
+
return x.pre.localeCompare(y.pre, undefined, { numeric: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createVersionResolver(opts: VersionOptions = {}): (ts: string) => Versions {
|
|
24
|
+
const releases: Array<{ at: number; version: string }> = [];
|
|
25
|
+
const root = opts.releaseRoot ?? join(elanousStateRoot(), 'release');
|
|
26
|
+
if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
27
|
+
if (!dir.isDirectory()) continue;
|
|
28
|
+
const path = join(root, dir.name, 'release.json');
|
|
29
|
+
if (!existsSync(path)) continue;
|
|
30
|
+
try {
|
|
31
|
+
const record = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
32
|
+
const stamp = record.publishedAt ?? record.published_at ?? record.releasedAt;
|
|
33
|
+
const at = Date.parse(String(stamp ?? ''));
|
|
34
|
+
if (typeof record.version === 'string' && Number.isFinite(at)) releases.push({ at, version: record.version });
|
|
35
|
+
} catch { /* a malformed ledger cannot supply a release boundary */ }
|
|
36
|
+
}
|
|
37
|
+
releases.sort((a, b) => a.at - b.at);
|
|
38
|
+
const repo = opts.repoRoot ?? process.cwd();
|
|
39
|
+
// A current-branch commit date is not proof of when that commit entered origin/main.
|
|
40
|
+
// Reflog updates are observed branch snapshots; before the first retained update the answer is unknown.
|
|
41
|
+
const reflog = spawnSync('git', ['reflog', 'show', '--date=unix', '--format=%H%x09%gd', 'refs/remotes/origin/main'], { cwd: repo, encoding: 'utf8', timeout: 10000 });
|
|
42
|
+
const snapshots: Array<{ at: number; commit: string; order: number }> = [];
|
|
43
|
+
if (reflog.status === 0) for (const line of reflog.stdout.split('\n')) {
|
|
44
|
+
const match = /^([0-9a-f]+)\torigin\/main@\{(\d+)\}$/.exec(line.trim());
|
|
45
|
+
// Reflog lists newest first; keep that order so two updates in the same second resolve to the later one.
|
|
46
|
+
if (match) snapshots.push({ at: Number(match[2]) * 1000, commit: match[1]!, order: snapshots.length });
|
|
47
|
+
}
|
|
48
|
+
snapshots.sort((a, b) => a.at - b.at || b.order - a.order);
|
|
49
|
+
const versions = new Map<string, string | null>();
|
|
50
|
+
for (const { commit } of snapshots) {
|
|
51
|
+
if (versions.has(commit)) continue;
|
|
52
|
+
const show = spawnSync('git', ['show', `${commit}:package.json`], { cwd: repo, encoding: 'utf8', timeout: 5000 });
|
|
53
|
+
let version: string | null = null;
|
|
54
|
+
if (show.status === 0) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(show.stdout).version;
|
|
57
|
+
if (typeof parsed === 'string') version = parsed;
|
|
58
|
+
} catch { /* unreadable package at this snapshot */ }
|
|
59
|
+
}
|
|
60
|
+
versions.set(commit, version);
|
|
61
|
+
}
|
|
62
|
+
const cache = new Map<string, Versions>();
|
|
63
|
+
return ts => {
|
|
64
|
+
const existing = cache.get(ts);
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
const time = Date.parse(ts);
|
|
67
|
+
// The public version at a moment = the highest version published by then (a re-written old record must not win by date).
|
|
68
|
+
const released = Number.isFinite(time) ? releases.filter(x => x.at <= time).map(x => x.version).sort(compareVersions).at(-1) ?? null : null;
|
|
69
|
+
const snapshot = Number.isFinite(time) ? [...snapshots].reverse().find(x => x.at <= time) : undefined;
|
|
70
|
+
const dev = snapshot ? versions.get(snapshot.commit) ?? null : null;
|
|
71
|
+
const codenames = opts.codenames ?? {};
|
|
72
|
+
const codename = codenames[dev?.replace(/-.*/, '') ?? ''] ?? codenames[released ?? ''] ?? null;
|
|
73
|
+
const value = { released, dev, codename };
|
|
74
|
+
cache.set(ts, value);
|
|
75
|
+
return value;
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/discord.ts
CHANGED
|
@@ -498,6 +498,23 @@ export class DiscordBot {
|
|
|
498
498
|
return r?.id ? { id: r.id } : null;
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
/** DEC-TG — edit a message's text AND components (an empty list removes the buttons). */
|
|
502
|
+
async editMessageWithComponents(
|
|
503
|
+
channelId: string,
|
|
504
|
+
messageId: string,
|
|
505
|
+
text: string,
|
|
506
|
+
components: ReadonlyArray<Record<string, unknown>>,
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
const content = text.length > this.maxChars ? text.slice(0, this.maxChars - 4) + ' …' : text;
|
|
509
|
+
await this.restCall('PATCH', `/channels/${channelId}/messages/${messageId}`, { content, components });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** DEC-TG — the DM channel id with one user (Discord returns the existing one when it already exists). */
|
|
513
|
+
async openDmChannel(userId: string): Promise<string | null> {
|
|
514
|
+
const r = await this.restCall<{ id?: string }>('POST', '/users/@me/channels', { recipient_id: userId });
|
|
515
|
+
return r?.id ?? null;
|
|
516
|
+
}
|
|
517
|
+
|
|
501
518
|
// ── Gateway: connect + run forever ──────────────────────────
|
|
502
519
|
|
|
503
520
|
/** Connect to the Gateway, identify, and process events until
|
|
@@ -18,6 +18,7 @@ import { resolveChannelBotToken } from '../channel-bot-token.js';
|
|
|
18
18
|
import { kindRouteTarget } from './telegram-kind-route.js';
|
|
19
19
|
import { debug } from '../debug/log.js';
|
|
20
20
|
import { resolveDaemonEndpoint } from '../nexus/daemon-endpoint.js';
|
|
21
|
+
import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js';
|
|
21
22
|
// ★ origin 되돌림(대표 2026-07-12) — 미션 알림을 발신 채널(메인 Q&A 봇)로 되돌린다. type-only
|
|
22
23
|
// import 라 런타임 순환 없음(발송 로직은 이 파일에 self-contained). origin 없으면 report 폴백.
|
|
23
24
|
import type { MissionOrigin } from '../autopilot/mission-origin.js';
|
|
@@ -317,9 +318,31 @@ function logDaemonPath(classification: DaemonPathClass, kind: string, extra: Rec
|
|
|
317
318
|
try { console.error(`[outbound] daemon-path ${classification}`); } catch { /* fail-soft */ }
|
|
318
319
|
}
|
|
319
320
|
|
|
321
|
+
/** OB8 — the daemon registers its own in-process sender at startup. Inside the daemon, deliver() must never curl
|
|
322
|
+
* its own `/v1/outbound`: the curl is synchronous, the event loop that would answer it is the one it blocks, and the
|
|
323
|
+
* whole daemon freezes until the curl times out (~25 s · `unreachable`) — the message is lost (CS1 · 10-01). */
|
|
324
|
+
export type InProcessOutbound = (text: string, kind: string) => Promise<boolean>;
|
|
325
|
+
let inProcessOutbound: InProcessOutbound | null = null;
|
|
326
|
+
export function setInProcessOutbound(send: InProcessOutbound | null): void { inProcessOutbound = send; }
|
|
327
|
+
|
|
320
328
|
/** elanous `/v1/outbound` 우선 → 실패 시 텔레그램 직접. 성공 경로 반환(원장 중복방지용). */
|
|
321
329
|
export function deliver(text: string, kind = 'alert'): 'daemon' | 'direct' | false {
|
|
330
|
+
// 0) inside the daemon — route in-process, asynchronously; the caller's synchronous answer is «accepted».
|
|
331
|
+
if (inProcessOutbound && process.env.SEND_VIA_ELANOUS !== '0') {
|
|
332
|
+
const send = inProcessOutbound;
|
|
333
|
+
void send(text, kind)
|
|
334
|
+
.then((ok) => {
|
|
335
|
+
logDaemonPath(ok ? 'ok' : 'rejected', kind, { inProcess: true });
|
|
336
|
+
if (!ok && sendTelegramDirect(text, kind)) recordOutbound(text, kind);
|
|
337
|
+
})
|
|
338
|
+
.catch((error: unknown) => {
|
|
339
|
+
logDaemonPath('rejected', kind, { inProcess: true, error: error instanceof Error ? error.message : String(error) });
|
|
340
|
+
if (sendTelegramDirect(text, kind)) recordOutbound(text, kind);
|
|
341
|
+
});
|
|
342
|
+
return 'daemon';
|
|
343
|
+
}
|
|
322
344
|
// 1) elanous 단일 발송 지점(/v1/outbound) — 데몬이 팬아웃 + 원장 기록.
|
|
345
|
+
let daemonPath: DaemonPathClass | 'not-found' | 'disabled' = 'disabled';
|
|
323
346
|
if (process.env.SEND_VIA_ELANOUS !== '0') {
|
|
324
347
|
let token = '';
|
|
325
348
|
try { if (existsSync(ACP_TOKEN_PATH)) token = readFileSync(ACP_TOKEN_PATH, 'utf-8').trim(); } catch { /* no token */ }
|
|
@@ -330,12 +353,32 @@ export function deliver(text: string, kind = 'alert'): 'daemon' | 'direct' | fal
|
|
|
330
353
|
: null;
|
|
331
354
|
const classification = classifyDaemonResponse(j);
|
|
332
355
|
if (classification === 'ok') return 'daemon';
|
|
356
|
+
daemonPath = nexus ? classification : 'not-found';
|
|
333
357
|
const extra: Record<string, unknown> = { hasToken: token.length > 0 };
|
|
334
358
|
if (j && typeof j === 'object' && 'error' in (j as object)) extra.error = (j as { error?: unknown }).error;
|
|
335
359
|
logDaemonPath(classification, kind, extra);
|
|
336
360
|
}
|
|
337
361
|
// 2) fallback: 텔레그램 sendMessage 직접(3900자 분할) — 데몬 미경유라 클라가 원장 기록.
|
|
338
|
-
|
|
362
|
+
if (sendTelegramDirect(text, kind)) return 'direct';
|
|
363
|
+
reportUndeliverable(kind, daemonPath);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** OB8b — both paths failed: say so loudly, with the universe this process resolved, instead of a silent false.
|
|
368
|
+
* The usual cause (10-01 · MK): an ad-hoc `bun -e` or a script from a source tree resolves a cwd-derived test
|
|
369
|
+
* universe, so it finds neither the production daemon nor the production bot token. */
|
|
370
|
+
function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-found' | 'disabled'): void {
|
|
371
|
+
let root = '?';
|
|
372
|
+
let universe: 'prod' | 'test' | '?' = '?';
|
|
373
|
+
try {
|
|
374
|
+
root = effectiveInstanceRoot();
|
|
375
|
+
universe = root === prodInstanceRoot() ? 'prod' : 'test';
|
|
376
|
+
} catch { /* the report still goes out */ }
|
|
377
|
+
try { debug.log('outbound.send', 'undeliverable', { kind, daemonPath, universe, root }); } catch { /* fail-soft */ }
|
|
378
|
+
const hint = universe === 'prod'
|
|
379
|
+
? '운영 데몬이 떠 있는지 확인: elanous nexus show'
|
|
380
|
+
: '운영으로 보내려면 설치본 elanous 로 실행하거나 ELANOUS_STATE_DIR=~/.elanous ELANOUS_CONFIG_DIR=~/.elanous 를 준다';
|
|
381
|
+
try { console.error(`[outbound] ⛔ 못 보냄(${kind}) — 데몬 ${daemonPath} · 직접 발송도 실패(토큰 없음 또는 전송 실패) · 이 프로세스의 우주 ${universe} (${root}) · ${hint}`); } catch { /* fail-soft */ }
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
/** 텔레그램 raw 발송(토큰·chatId 명시) — spill + 3900자 분할(줄 경계). thread 지원. */
|
|
@@ -79,7 +79,7 @@ export function explainFailure(group: RepeatedFailureGroup): string {
|
|
|
79
79
|
// the first live tick saw it as `webterm.tabs` (list errors re-raise the socket close) and sent the generic line.
|
|
80
80
|
if (group.area.startsWith('webterm.') && /^socket closed: #: auth_failed$/i.test(group.reason)) {
|
|
81
81
|
// Same words as the PWA banners (🅞 #21591·#21602 · 🅕 #21599) so the alert and the screen say one thing.
|
|
82
|
-
return '이 기기(브라우저)에 소유자 토큰이 없거나 틀리거나 만료돼 데몬이 연결을 거절한다 — 이미 연결된 기기의 설정 ›
|
|
82
|
+
return '이 기기(브라우저)에 소유자 토큰이 없거나 틀리거나 만료돼 데몬이 연결을 거절한다 — 이미 연결된 기기의 설정 › 연결 토큰 만들기 에서 토큰을 만들어, 이 기기의 설정 › 데몬 연결 › 연결 토큰 칸(/app/settings/#bearer-token)에 붙인다';
|
|
83
83
|
}
|
|
84
84
|
const span = Date.parse(group.lastTs) - Date.parse(group.firstTs);
|
|
85
85
|
const minutes = Number.isFinite(span) ? Math.max(1, Math.ceil(span / 60_000)) : 10;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ER1 — 액티브 덤퍼 «보내는 쪽». 사용자 기기에서 오류가 나면 운영이 알게 한다(대표 10-01 22:0x).
|
|
2
|
+
// 계약 v1 = TC 10-01 22:16(채널 #20798 · ER2): POST https://hooks.elanous.ai/v1/reports · ≤64KiB · consent:true 필수 ·
|
|
3
|
+
// installId = 무작위 UUID · email/telegramUser 는 «신원도 함께» 동의 때만 · nexus.instance 는 해시 12자 · dedupe 는 수신기가 만든다.
|
|
4
|
+
// 규칙: 비밀·홈 경로를 지운 뒤에만 보낸다 · 실패하면 한 부만 남겨 다음 시작 때 한 번(하루 최대 한 번) · 절대 던지지 않는다.
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { arch, homedir, platform } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { debug } from '../debug/log.js';
|
|
10
|
+
import { getElanousConfigDir } from '../elanous-config-dir.js';
|
|
11
|
+
import { codeRevision, packageVersion } from '../version/code-revision.js';
|
|
12
|
+
import { getUserConfig } from '../user-config.js';
|
|
13
|
+
|
|
14
|
+
export const ERROR_REPORT_URL = 'https://hooks.elanous.ai/v1/reports';
|
|
15
|
+
const MAX_BYTES = 64 * 1024;
|
|
16
|
+
const MAX_MESSAGE = 2_000;
|
|
17
|
+
const MAX_STACK = 8_000;
|
|
18
|
+
const SEND_TIMEOUT_MS = 5_000;
|
|
19
|
+
const RETRY_GAP_MS = 24 * 60 * 60_000;
|
|
20
|
+
const SAME_ERROR_GAP_MS = 60 * 60_000;
|
|
21
|
+
|
|
22
|
+
export type ReportSurface = 'tui' | 'pwa' | 'cli' | 'daemon';
|
|
23
|
+
|
|
24
|
+
export interface ErrorReportInput { code: string; message: string; stack?: string; surface: ReportSurface }
|
|
25
|
+
|
|
26
|
+
export interface ErrorReport {
|
|
27
|
+
kind: 'error-report'; v: 1; code: string; message: string; stack?: string;
|
|
28
|
+
app: { version: string; sha: string; surface: ReportSurface; os: string; arch: string };
|
|
29
|
+
nexus: { alive: boolean; version?: string; instance?: string };
|
|
30
|
+
who: { installId: string; email?: string; telegramUser?: string };
|
|
31
|
+
at: string; consent: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ErrorReportSettings { enabled: boolean; identity: boolean; email?: string; telegramUser?: string }
|
|
35
|
+
|
|
36
|
+
export interface ErrorReportDeps {
|
|
37
|
+
configDir?: () => string;
|
|
38
|
+
settings?: () => ErrorReportSettings;
|
|
39
|
+
nexusMeta?: () => Promise<ErrorReport['nexus']>;
|
|
40
|
+
fetchImpl?: typeof fetch;
|
|
41
|
+
now?: () => number;
|
|
42
|
+
/** Tests and the isolated test universe never reach the network. */
|
|
43
|
+
enabledByEnvironment?: () => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Secrets and the home directory out, before anything leaves the machine. */
|
|
47
|
+
export function scrubText(text: string, home = homedir()): string {
|
|
48
|
+
let out = text;
|
|
49
|
+
if (home && home.length > 1) out = out.split(home).join('~');
|
|
50
|
+
return out
|
|
51
|
+
.replace(/\/(Users|home)\/[^/\s'"`]+/g, '/$1/~')
|
|
52
|
+
.replace(/\b(?:elt|els|ghp|gho|ghs|github_pat|xox[abpr]|sk(?:-proj|-ant)?)[-_][A-Za-z0-9_\-]{8,}/g, '[redacted]')
|
|
53
|
+
.replace(/\b(Bearer|token|apiKey|api_key|password|secret)\b(\s*[:=]\s*|\s+)["']?[^\s"',;]{6,}/gi, '$1$2[redacted]')
|
|
54
|
+
.replace(/\b\d{6,}:[A-Za-z0-9_-]{30,}\b/g, '[redacted]') // telegram bot token shape
|
|
55
|
+
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[email]');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Frames keep only `file:line:col` — never the directory. */
|
|
59
|
+
export function stripStackPaths(stack: string): string {
|
|
60
|
+
return stack.split('\n').map((line) => line.replace(/\(?((?:file:\/\/)?\/?[^\s()]*[/\\])([^/\\\s()]+:\d+(?::\d+)?)\)?/g, (_m, _dir: string, tail: string) => `(${tail})`)).join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One random id per install (never derived from the machine or account). */
|
|
64
|
+
export function installId(configDir: string): string {
|
|
65
|
+
const file = join(configDir, 'install-id');
|
|
66
|
+
try {
|
|
67
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
68
|
+
if (/^[0-9a-f-]{36}$/.test(existing)) return existing;
|
|
69
|
+
} catch { /* first time */ }
|
|
70
|
+
const id = randomUUID();
|
|
71
|
+
try { mkdirSync(configDir, { recursive: true }); writeFileSync(file, `${id}\n`, { mode: 0o600 }); } catch { /* id still usable this run */ }
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function hash12(value: string): string {
|
|
76
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cut(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text; }
|
|
80
|
+
|
|
81
|
+
export function buildErrorReport(input: ErrorReportInput, ctx: { installId: string; settings: ErrorReportSettings; nexus: ErrorReport['nexus']; at: string }): ErrorReport {
|
|
82
|
+
const code = input.code.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 64) || 'unknown';
|
|
83
|
+
const report: ErrorReport = {
|
|
84
|
+
kind: 'error-report', v: 1, code,
|
|
85
|
+
message: cut(scrubText(input.message), MAX_MESSAGE),
|
|
86
|
+
...(input.stack ? { stack: cut(scrubText(stripStackPaths(input.stack)), MAX_STACK) } : {}),
|
|
87
|
+
app: { version: packageVersion(), sha: codeRevision() ?? 'unknown', surface: input.surface, os: platform(), arch: arch() },
|
|
88
|
+
nexus: ctx.nexus,
|
|
89
|
+
who: {
|
|
90
|
+
installId: ctx.installId,
|
|
91
|
+
...(ctx.settings.identity && ctx.settings.email ? { email: ctx.settings.email } : {}),
|
|
92
|
+
...(ctx.settings.identity && ctx.settings.telegramUser ? { telegramUser: ctx.settings.telegramUser } : {}),
|
|
93
|
+
},
|
|
94
|
+
at: ctx.at,
|
|
95
|
+
consent: true,
|
|
96
|
+
};
|
|
97
|
+
// Hard cap: drop the stack first, then shorten the message.
|
|
98
|
+
if (Buffer.byteLength(JSON.stringify(report)) > MAX_BYTES) delete report.stack;
|
|
99
|
+
if (Buffer.byteLength(JSON.stringify(report)) > MAX_BYTES) report.message = cut(report.message, 500);
|
|
100
|
+
return report;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defaultSettings(): ErrorReportSettings {
|
|
104
|
+
try {
|
|
105
|
+
const er = getUserConfig().errorReports;
|
|
106
|
+
return { enabled: er?.enabled !== false, identity: er?.identity === true, ...(er?.email ? { email: er.email } : {}), ...(er?.telegramUser ? { telegramUser: er.telegramUser } : {}) };
|
|
107
|
+
} catch { return { enabled: false, identity: false }; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function defaultNexusMeta(): Promise<ErrorReport['nexus']> {
|
|
111
|
+
try {
|
|
112
|
+
const { resolveDaemonEndpoint } = await import('../nexus/daemon-endpoint.js');
|
|
113
|
+
const base = resolveDaemonEndpoint({ purpose: 'watch' })?.baseUrl;
|
|
114
|
+
const instance = hash12(getElanousConfigDir());
|
|
115
|
+
if (!base) return { alive: false, instance };
|
|
116
|
+
const response = await fetch(`${base}/v1/health`, { signal: AbortSignal.timeout(1_000) });
|
|
117
|
+
const body = await response.json().catch(() => ({})) as { version?: unknown };
|
|
118
|
+
return { alive: response.ok, ...(typeof body.version === 'string' ? { version: body.version } : {}), instance };
|
|
119
|
+
} catch { return { alive: false }; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Never from `bun test`, and never from the isolated test universe (its config dir lives under a test root). */
|
|
123
|
+
function defaultEnabledByEnvironment(): boolean {
|
|
124
|
+
if (process.env.NODE_ENV === 'test' || process.env.BUN_TEST) return false;
|
|
125
|
+
try { return !/[/\\](?:test|\.elanous-test)[/\\]?/.test(getElanousConfigDir()); } catch { return false; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const lastSent = new Map<string, number>();
|
|
129
|
+
|
|
130
|
+
export type ReportOutcome = { sent: true; reportId?: string } | { sent: false; reason: string };
|
|
131
|
+
|
|
132
|
+
/** Send one report — never throws, never blocks the caller beyond the send timeout. */
|
|
133
|
+
export async function reportError(input: ErrorReportInput, deps: ErrorReportDeps = {}): Promise<ReportOutcome> {
|
|
134
|
+
try {
|
|
135
|
+
if (!(deps.enabledByEnvironment ?? defaultEnabledByEnvironment)()) return { sent: false, reason: 'environment' };
|
|
136
|
+
const settings = (deps.settings ?? defaultSettings)();
|
|
137
|
+
if (!settings.enabled) return { sent: false, reason: 'disabled' };
|
|
138
|
+
const now = (deps.now ?? Date.now)();
|
|
139
|
+
const key = `${input.code}\u0000${input.message.slice(0, 200)}`;
|
|
140
|
+
const previous = lastSent.get(key);
|
|
141
|
+
if (previous !== undefined && now - previous < SAME_ERROR_GAP_MS) return { sent: false, reason: 'recently-sent' };
|
|
142
|
+
lastSent.set(key, now);
|
|
143
|
+
const configDir = (deps.configDir ?? getElanousConfigDir)();
|
|
144
|
+
const report = buildErrorReport(input, {
|
|
145
|
+
installId: installId(configDir), settings, nexus: await (deps.nexusMeta ?? defaultNexusMeta)(), at: new Date(now).toISOString(),
|
|
146
|
+
});
|
|
147
|
+
return await post(report, configDir, deps);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
debug.log('error-report', 'failed', { reason: error instanceof Error ? error.message.slice(0, 120) : 'unknown' });
|
|
150
|
+
return { sent: false, reason: 'internal' };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function post(report: ErrorReport, configDir: string, deps: ErrorReportDeps): Promise<ReportOutcome> {
|
|
155
|
+
try {
|
|
156
|
+
const response = await (deps.fetchImpl ?? fetch)(ERROR_REPORT_URL, {
|
|
157
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report), signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
|
158
|
+
});
|
|
159
|
+
if (response.status >= 200 && response.status < 300) {
|
|
160
|
+
const body = await response.json().catch(() => ({})) as { reportId?: unknown };
|
|
161
|
+
const reportId = typeof body.reportId === 'string' ? body.reportId : undefined;
|
|
162
|
+
debug.log('error-report', 'sent', { code: report.code, surface: report.app.surface, ...(reportId ? { reportId } : {}) });
|
|
163
|
+
return { sent: true, ...(reportId ? { reportId } : {}) };
|
|
164
|
+
}
|
|
165
|
+
keepPending(report, configDir, deps);
|
|
166
|
+
debug.log('error-report', 'not-sent', { code: report.code, status: response.status });
|
|
167
|
+
return { sent: false, reason: `http-${response.status}` };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
keepPending(report, configDir, deps);
|
|
170
|
+
debug.log('error-report', 'not-sent', { code: report.code, reason: error instanceof Error ? error.name : 'network' });
|
|
171
|
+
return { sent: false, reason: 'network' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pendingFile(configDir: string): string { return join(configDir, 'error-reports', 'pending.json'); }
|
|
176
|
+
|
|
177
|
+
/** Keep exactly one unsent report (the newest) for a later retry. */
|
|
178
|
+
function keepPending(report: ErrorReport, configDir: string, deps: ErrorReportDeps): void {
|
|
179
|
+
try {
|
|
180
|
+
mkdirSync(join(configDir, 'error-reports'), { recursive: true, mode: 0o700 });
|
|
181
|
+
writeFileSync(pendingFile(configDir), JSON.stringify({ report, lastTry: (deps.now ?? Date.now)() }), { mode: 0o600 });
|
|
182
|
+
} catch { /* nothing else to do */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** At start-up: retry the one kept report, at most once a day. */
|
|
186
|
+
export async function retryPendingErrorReport(deps: ErrorReportDeps = {}): Promise<ReportOutcome> {
|
|
187
|
+
try {
|
|
188
|
+
if (!(deps.enabledByEnvironment ?? defaultEnabledByEnvironment)()) return { sent: false, reason: 'environment' };
|
|
189
|
+
if (!(deps.settings ?? defaultSettings)().enabled) return { sent: false, reason: 'disabled' };
|
|
190
|
+
const configDir = (deps.configDir ?? getElanousConfigDir)();
|
|
191
|
+
const file = pendingFile(configDir);
|
|
192
|
+
if (!existsSync(file)) return { sent: false, reason: 'none' };
|
|
193
|
+
const kept = JSON.parse(readFileSync(file, 'utf8')) as { report: ErrorReport; lastTry: number };
|
|
194
|
+
if ((deps.now ?? Date.now)() - kept.lastTry < RETRY_GAP_MS) return { sent: false, reason: 'too-soon' };
|
|
195
|
+
const outcome = await post(kept.report, configDir, deps);
|
|
196
|
+
if (outcome.sent) rmSync(file, { force: true });
|
|
197
|
+
return outcome;
|
|
198
|
+
} catch { return { sent: false, reason: 'internal' }; }
|
|
199
|
+
}
|
|
200
|
+
|