diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe75fb8..d037700d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,33 +2,36 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: branches: [main] -jobs: - build: - runs-on: ubuntu-latest +# Cancel superseded runs on the same ref (rapid pushes / PR force-pushes). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true - strategy: - matrix: - node-version: [20, 22] +# Least privilege. The notify job authenticates to Telegram via its own secrets, +# not GITHUB_TOKEN, so read-only repo access is enough everywhere. +permissions: + contents: read +jobs: + # Node-version-independent static analysis β€” run once, not per matrix entry. + checks: + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 20 cache: npm - name: Install dependencies run: npm ci - - name: Install WebUI dependencies - run: cd web && npm ci - - name: Build SDK workspace run: npm run build -w packages/sdk @@ -50,14 +53,128 @@ jobs: - name: Duplicate code check run: npm run dupcheck + - name: Security audit + run: npm run audit:ci + + # Build + test on each supported Node version. + test: + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Install WebUI dependencies + run: cd web && npm ci + + - name: Build SDK workspace + run: npm run build -w packages/sdk + - name: Build run: npm run build - name: Test with coverage run: npm run test:coverage - - name: Security audit - run: npm run audit:ci - - name: CLI smoke test run: node dist/cli/index.js --help + + # ---- Telegram notification (after the full CI passes, pushes only) ---- + # dev : one message PER COMMIT, prefixed [DEV], paced 1/sec. + # main : one clean summary of how many stable commits were merged. + # Both walk the push range (before..after), not just the HEAD commit. + notify: + needs: [checks, test] + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Notify Telegram + env: + TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TG_CHAT: ${{ secrets.TELEGRAM_CHAT_ID }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.sha }} + run: | + set -uo pipefail + ZERO=0000000000000000000000000000000000000000 + + # Resolve the commits in this push, oldest -> newest (merge commits excluded). + # Fallbacks (first push / force-push / unknown before) -> HEAD only. + if [ "$BEFORE" = "$ZERO" ] \ + || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null \ + || ! git merge-base --is-ancestor "$BEFORE" "$AFTER" 2>/dev/null; then + SHAS=$(git log -1 --format=%H "$AFTER") + RANGE_OK=0 + else + SHAS=$(git log --reverse --no-merges --format=%H "${BEFORE}..${AFTER}") + RANGE_OK=1 + fi + + COUNT=$(printf '%s\n' "$SHAS" | grep -c . || true) + if [ "$COUNT" -eq 0 ]; then echo "Nothing to notify"; exit 0; fi + + esc() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's//\>/g'; } + # Send one message, retrying until Telegram accepts it. IMPORTANT: no + # curl --fail here β€” on a 429 we must READ the body to get retry_after; + # --fail discards it, which silently drops rate-limited messages. + send() { + local text="$1" attempt resp ra + for attempt in 1 2 3 4 5 6; do + resp=$(curl -sS -m 25 -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \ + -d chat_id="${TG_CHAT}" -d parse_mode=HTML -d disable_web_page_preview=true \ + --data-urlencode "text=${text}" 2>/dev/null || true) + case "$resp" in + *'"ok":true'*) return 0 ;; + esac + # 429 -> honor retry_after; other transient error -> small backoff. + ra=$(printf '%s' "$resp" | grep -o '"retry_after":[0-9]*' | grep -o '[0-9]*' || true) + sleep "$(( ${ra:-2} + 1 ))" + done + echo "WARN: gave up sending after retries: ${text%%$'\n'*}" + return 1 + } + + if [ "$BRANCH" = "main" ]; then + # Clean summary: number of stable commits merged to main. + noun="commits"; [ "$COUNT" -eq 1 ] && noun="commit" + if [ "$RANGE_OK" = 1 ]; then + link="https://github.com/${REPO}/compare/${BEFORE}...${AFTER}" + else + link="https://github.com/${REPO}/commit/${AFTER}" + fi + send "🟒 ${COUNT} stable ${noun} merged to main"$'\n'"View changes" + else + # Per-commit feed for dev (and any other non-main branch), [DEV] prefixed. + tag="[${BRANCH^^}]" + if [ "$COUNT" -gt 1 ]; then + send "πŸ“¦ ${COUNT} commits pushed to ${BRANCH}" + sleep 1 + fi + for sha in $SHAS; do + title=$(esc "$(git log -1 --format=%s "$sha")") + url="https://github.com/${REPO}/commit/${sha}" + send "${tag} ${title}"$'\n'"${sha:0:7}" + sleep 1 + done + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5a40e443..069611e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,14 +4,15 @@ on: push: tags: ["v*"] +# Least privilege by default; jobs opt into the extra scopes they need. permissions: - contents: write - packages: write + contents: read jobs: # ---- Build & Verify ---- build: runs-on: ubuntu-latest + timeout-minutes: 25 steps: - uses: actions/checkout@v4 @@ -19,6 +20,9 @@ jobs: with: node-version: 20 cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json - run: npm ci - run: cd web && npm ci @@ -39,6 +43,10 @@ jobs: publish-npm: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # npm provenance steps: - uses: actions/checkout@v4 @@ -47,6 +55,9 @@ jobs: node-version: 20 registry-url: https://registry.npmjs.org cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json - name: Check if version already published id: check @@ -71,7 +82,7 @@ jobs: name: dist path: dist/ - - run: npm publish --access public + - run: npm publish --provenance --access public if: steps.check.outputs.publish == 'true' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -80,6 +91,10 @@ jobs: publish-sdk: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # npm provenance steps: - uses: actions/checkout@v4 @@ -108,7 +123,7 @@ jobs: - name: Build and publish SDK if: steps.check.outputs.publish == 'true' - run: npm run build -w packages/sdk && cd packages/sdk && npm publish --access public + run: npm run build -w packages/sdk && cd packages/sdk && npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -116,6 +131,10 @@ jobs: publish-docker: needs: build runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write # push to ghcr.io steps: - uses: actions/checkout@v4 @@ -137,6 +156,8 @@ jobs: with: context: . push: true + cache-from: type=gha + cache-to: type=gha,mode=max tags: | ghcr.io/${{ steps.meta.outputs.repo }}:${{ steps.meta.outputs.version }} ghcr.io/${{ steps.meta.outputs.repo }}:latest @@ -145,6 +166,9 @@ jobs: create-release: needs: [publish-npm] runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # create the GitHub release steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml index 69e6ebf0..24086253 100644 --- a/.github/workflows/telegram-notify.yml +++ b/.github/workflows/telegram-notify.yml @@ -2,46 +2,13 @@ name: Telegram Notify on: workflow_run: - workflows: ["CI", "Release"] + workflows: ["Release"] types: [completed] -jobs: - # ---- Commit notification (only after CI passes) ---- - commit: - if: >- - github.event.workflow_run.name == 'CI' - && github.event.workflow_run.conclusion == 'success' - && github.event.workflow_run.head_branch == 'main' - runs-on: ubuntu-latest - steps: - - name: Build message file - env: - FULL: ${{ github.event.workflow_run.head_commit.message }} - COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} - REPO: ${{ github.repository }} - run: | - TITLE=$(echo "$FULL" | head -1) - BODY=$(echo "$FULL" | tail -n +3) - COMMIT_URL="https://github.com/${REPO}/commit/${COMMIT_SHA}" - { - echo "${TITLE}" - if [ -n "$BODY" ]; then - echo "" - echo "$BODY" - fi - echo "" - echo "View commit" - } > "$GITHUB_WORKSPACE/.tg_message.txt" - - - name: Send commit to Telegram - uses: appleboy/telegram-action@master - with: - to: ${{ secrets.TELEGRAM_CHAT_ID }} - token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - format: html - disable_web_page_preview: true - message_file: .tg_message.txt +permissions: + contents: read +jobs: # ---- Release notification (only after Release workflow passes) ---- release: if: >- @@ -68,7 +35,7 @@ jobs: - name: Send release to Telegram if: steps.ver.outputs.found == 'true' - uses: appleboy/telegram-action@master + uses: appleboy/telegram-action@v1.0.1 with: to: ${{ secrets.TELEGRAM_CHAT_ID }} token: ${{ secrets.TELEGRAM_BOT_TOKEN }} diff --git a/.gitignore b/.gitignore index 1bf3186a..75cb2f98 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ TODOS/ # TTS voice models (large binaries) piper-voices/ + +# Claude Code session logs +.claude-session/ diff --git a/README.md b/README.md index 2f59a58a..63b25bd7 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ ## Prerequisites - **Node.js 20.0.0+** - [Download](https://nodejs.org/) -- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) β€” or keyless: Claude Code (auto-detect), Cocoon (TON), Local (Ollama/vLLM) +- **LLM API Key** - One of: [Anthropic](https://console.anthropic.com/) (recommended), [OpenAI](https://platform.openai.com/), [Google](https://aistudio.google.com/), [xAI](https://console.x.ai/), [Groq](https://console.groq.com/), [OpenRouter](https://openrouter.ai/), [Moonshot](https://platform.moonshot.ai/), [Mistral](https://console.mistral.ai/), [Cerebras](https://cloud.cerebras.ai/), [ZAI](https://open.bigmodel.cn/), [MiniMax](https://platform.minimaxi.com/), [Hugging Face](https://huggingface.co/settings/tokens) β€” or keyless: Codex (auto-detect), Cocoon (TON), Local (Ollama/vLLM) - **Telegram Account** - Dedicated account recommended for security - **Telegram API Credentials** - From [my.telegram.org/apps](https://my.telegram.org/apps) - **Your Telegram User ID** - Message [@userinfobot](https://t.me/userinfobot) @@ -172,7 +172,7 @@ The `teleton setup` wizard generates a fully configured `~/.teleton/config.yaml` ```yaml agent: - provider: "anthropic" # anthropic | claude-code | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | cocoon | local + provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter | moonshot | mistral | cerebras | zai | minimax | huggingface | cocoon | local api_key: "sk-ant-api03-..." model: "claude-haiku-4-5-20251001" utility_model: "claude-haiku-4-5-20251001" # for summarization, compaction, vision @@ -219,7 +219,7 @@ ton_proxy: # Optional: .ton domain proxy - + @@ -475,7 +475,7 @@ The SDK provides namespaced access to core services: | Layer | Technology | |-------|------------| -| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (15 providers: Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Cocoon, Local) | +| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (15 providers: Anthropic, Codex, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cerebras, ZAI, MiniMax, Hugging Face, Cocoon, Local) | | Telegram Userbot | [GramJS](https://gram.js.org/) Layer 223 fork (MTProto) | | Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) | | Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) | diff --git a/docs/configuration.md b/docs/configuration.md index 943e6ce9..60e3364f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ LLM provider and agentic loop configuration. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `claude-code`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `cocoon`, `local`. | +| `agent.provider` | `enum` | `"anthropic"` | LLM provider. One of: `anthropic`, `codex`, `openai`, `google`, `xai`, `groq`, `openrouter`, `moonshot`, `mistral`, `cerebras`, `zai`, `minimax`, `huggingface`, `cocoon`, `local`. | | `agent.api_key` | `string` | `""` | API key for the chosen provider. Can be overridden with `TELETON_API_KEY` env var. | | `agent.model` | `string` | `"claude-haiku-4-5-20251001"` | Primary model ID. Auto-detected from provider if not set (only for non-Anthropic providers). | | `agent.utility_model` | `string` | *auto-detected* | Cheap/fast model used for summarization and compaction. If omitted, the platform selects one based on the provider (e.g., `claude-haiku-4-5-20251001` for Anthropic, `gpt-4o-mini` for OpenAI). | @@ -84,7 +84,6 @@ When you change the `provider` and omit `model`, the platform auto-selects: | Provider | Default Model | Default Utility Model | |----------|--------------|----------------------| | `anthropic` | `claude-haiku-4-5-20251001` | `claude-haiku-4-5-20251001` | -| `claude-code` | `claude-haiku-4-5-20251001` | `claude-haiku-4-5-20251001` | | `codex` | `gpt-5.5` | `gpt-5.1-codex-mini` | | `openai` | `gpt-5.5` | `gpt-4o-mini` | | `google` | `gemini-2.5-flash` | `gemini-2.0-flash-lite` | diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index 3675c486..6d8de95e 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -11,6 +11,7 @@ This guide covers obtaining Telegram API credentials, configuring the agent's me - [First-Time Authentication](#first-time-authentication) - [2FA Handling](#2fa-handling) - [Bot Token (Optional)](#bot-token-optional) +- [Guest Mode](#guest-mode) - [DM Policies](#dm-policies) - [Group Policies](#group-policies) - [Admin IDs](#admin-ids) @@ -153,6 +154,33 @@ The bot must be added to any groups where you want inline buttons to work. --- +## Guest Mode + +Guest Mode is a Telegram Bot API 10.0 feature that lets the bot answer **guest queries** -- messages from users in chats the bot is **not a member of**. It applies to bot mode only. + +### Prerequisite: enable it in BotFather + +Guest Mode must be enabled for your bot in [@BotFather](https://t.me/BotFather) first. Without this, Telegram never delivers guest queries and the agent has nothing to answer. + +### Configuration + +```yaml +telegram: + guest_mode: false # Default. Set to true to answer guest queries. +``` + +`guest_mode` can be toggled three ways, all writing the same config key: + +- The config file directly. +- The WebUI **Config** page (Telegram category). +- The `/guest on` / `/guest off` admin command. + +### Behaviour + +Guest queries are handled with **group-mode capabilities**: agent memory and trading strategy are not exposed, and the agent replies once per query. Because the bot is not a member of the chat, message-sending tools are disabled on this path -- the answer is delivered through the guest-query reply channel. + +--- + ## DM Policies The `dm_policy` setting controls who can interact with the agent via direct messages. @@ -313,12 +341,13 @@ All admin commands require the sender's Telegram user ID to be listed in `admin_ | 7 | `/plugin` | `/plugin ...` | Manage plugin secrets (API keys, tokens). | | 8 | `/wallet` | `/wallet` | Check TON wallet balance and address. | | 9 | `/verbose` | `/verbose` | Toggle verbose debug logging on/off. | -| 10 | `/pause` | `/pause` | Pause the agent (ignores non-admin messages). | -| 11 | `/resume` | `/resume` | Resume the agent after pause. | -| 12 | `/stop` | `/stop` | Emergency shutdown (terminates process). | -| 13 | `/clear` | `/clear [chat_id]` | Clear conversation history for a chat. | -| 14 | `/ping` | `/ping` | Health check (returns "Pong!"). | -| 15 | `/help` | `/help` | Display all available commands. | +| 10 | `/guest` | `/guest [on\|off]` | View or toggle guest mode. | +| 11 | `/pause` | `/pause` | Pause the agent (ignores non-admin messages). | +| 12 | `/resume` | `/resume` | Resume the agent after pause. | +| 13 | `/stop` | `/stop` | Emergency shutdown (terminates process). | +| 14 | `/clear` | `/clear [chat_id]` | Clear conversation history for a chat. | +| 15 | `/ping` | `/ping` | Health check (returns "Pong!"). | +| 16 | `/help` | `/help` | Display all available commands. | > `/task ` and `/boot` are also available but handled by the message handler layer, not AdminHandler directly. diff --git a/knip.json b/knip.json index 0658b5fa..cc4af4a3 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", + "$schema": "https://unpkg.com/knip@6/schema.json", "workspaces": { ".": { "entry": [ @@ -19,8 +19,6 @@ } }, "ignoreDependencies": [ - "pino-pretty", - "@teleton-agent/sdk", "@grammyjs/types" ], "ignoreExportsUsedInFile": true, diff --git a/package-lock.json b/package-lock.json index 0c544e52..21165f17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ ], "dependencies": { "@dedust/sdk": "^0.8.7", - "@hono/node-server": "^1.19.11", + "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", "@mariozechner/pi-ai": "^0.73.1", @@ -43,7 +43,7 @@ "teleton": "bin/teleton.js" }, "devDependencies": { - "@ston-fi/api": "^0.31.0", + "@ston-fi/api": "^0.32.0", "@ston-fi/sdk": "^2.7.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", @@ -54,13 +54,13 @@ "eslint": "^10.1.0", "husky": "^9.1.7", "jscpd": "^4.0.8", - "knip": "^5.85.0", + "knip": "^6.14.2", "lint-staged": "^16.4.0", "madge": "^8.0.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", - "typescript": "^5.7.0", + "typescript": "^5.9.3", "vitest": "^4.1.1" }, "engines": { @@ -1213,12 +1213,12 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.4.tgz", + "integrity": "sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1777,24 +1777,24 @@ } }, "node_modules/@inquirer/ansi": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", - "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.6.tgz", + "integrity": "sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/checkbox": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.5.tgz", - "integrity": "sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.0.tgz", + "integrity": "sha512-1HJt+3fqxblp/GQjdntSyoSHYBc0e3CzXVgjFpKA6qFLd9FHBBqwN8Co0xYH6t2JVUZrtFwZ4bBiwptkiLxyOg==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1809,13 +1809,13 @@ } }, "node_modules/@inquirer/confirm": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.13.tgz", - "integrity": "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.0.tgz", + "integrity": "sha512-USpeB76eqK7yGricDlGAupxWlp4a59qpeZOoNWaxO/nJln7agpJveyNkQ1d5u8YXG6TOqxZtQpKPORQQDrdVsA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1830,17 +1830,17 @@ } }, "node_modules/@inquirer/core": { - "version": "11.1.10", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.10.tgz", - "integrity": "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.0.tgz", + "integrity": "sha512-joR1YS2sI0us+9d0I8ViqFbrRLONO8CFTuyvBX4ZVBSch+VsZiugUABdrhBXXJR1VyEzvpz5SQCix3keETQ58g==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5", + "@inquirer/ansi": "^2.0.6", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", + "mute-stream": "^4.0.0", "signal-exit": "^4.1.0" }, "engines": { @@ -1856,14 +1856,14 @@ } }, "node_modules/@inquirer/editor": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.2.tgz", - "integrity": "sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.0.tgz", + "integrity": "sha512-/m+sgRmzSdK6HDtVnl3PmI6MnZC4O+LLezedoJcrX7mINhTjjb0hlC7aEDGZXkFTB4b5uQ0q59AhYTah88KbNg==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/external-editor": "^3.0.0", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/external-editor": "^3.0.1", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1878,13 +1878,13 @@ } }, "node_modules/@inquirer/expand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.14.tgz", - "integrity": "sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.0.tgz", + "integrity": "sha512-fR7g4BVnIcs+4NApF6C5byflNM/EULxSxsv/2Jvg+gmop0R6eBIPvZqE6RYnTy1tQTFnf9wyHkwNoQSZbofaGA==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1899,9 +1899,9 @@ } }, "node_modules/@inquirer/external-editor": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", - "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.1.tgz", + "integrity": "sha512-tam+Gwjsxg2sx3iUVPkAnhKT/yrk2rd2NAa7XJU/J8OYpU0ifXsnp12xlvzp/DCpWBXVv+vLQsqnpAWwUcWD5Q==", "license": "MIT", "dependencies": { "chardet": "^2.1.1", @@ -1920,22 +1920,22 @@ } }, "node_modules/@inquirer/figures": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", - "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.6.tgz", + "integrity": "sha512-dsZgQtH2t5Q6ah3aPbZbeEZAxsD9qQu0DXf01AltuEfRTm+NoLN6+rLVbr+4edeEbNCp/wBNM6mALRWtsQpfkw==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" } }, "node_modules/@inquirer/input": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.13.tgz", - "integrity": "sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.0.tgz", + "integrity": "sha512-sVZCz6P6e8tW5g2bSFel1oLpa6jK/u7BexFfrgTqR8syIdnHqy+iopnlSbYBZMsCK52chLjhGNBxt0eRqhsghw==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1950,13 +1950,13 @@ } }, "node_modules/@inquirer/number": { - "version": "4.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.13.tgz", - "integrity": "sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.0.tgz", + "integrity": "sha512-VMXB/XejCbaSTf9Xucl7dqjzzsaGsrs6XwSYXPbGZ2QbSuq/Gz8XamhSi9ClRubNXZlGry9xVg1tKkJdTDgCtQ==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1971,14 +1971,14 @@ } }, "node_modules/@inquirer/password": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.13.tgz", - "integrity": "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.0.tgz", + "integrity": "sha512-5tqRuKCDIUxdPxTI/CuLnh914kz+WMPmURHKnZgui9gk43ebudEsdu4EwSn1CPSi5R+17YpBG+ba/YqTnRAcJA==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -1993,21 +1993,21 @@ } }, "node_modules/@inquirer/prompts": { - "version": "8.4.3", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.3.tgz", - "integrity": "sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.0.tgz", + "integrity": "sha512-pLjXOnY4y3R1mgyHP3pXD/8eXejp+L/dde/0N2NLKgKfMstqhNZrpvs7Wkzbl9FYFQh10LRQ7QZwq+cz9rrhyw==", "license": "MIT", "dependencies": { - "@inquirer/checkbox": "^5.1.5", - "@inquirer/confirm": "^6.0.13", - "@inquirer/editor": "^5.1.2", - "@inquirer/expand": "^5.0.14", - "@inquirer/input": "^5.0.13", - "@inquirer/number": "^4.0.13", - "@inquirer/password": "^5.0.13", - "@inquirer/rawlist": "^5.2.9", - "@inquirer/search": "^4.1.9", - "@inquirer/select": "^5.1.5" + "@inquirer/checkbox": "^5.2.0", + "@inquirer/confirm": "^6.1.0", + "@inquirer/editor": "^5.2.0", + "@inquirer/expand": "^5.1.0", + "@inquirer/input": "^5.1.0", + "@inquirer/number": "^4.1.0", + "@inquirer/password": "^5.1.0", + "@inquirer/rawlist": "^5.3.0", + "@inquirer/search": "^4.2.0", + "@inquirer/select": "^5.2.0" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2022,13 +2022,13 @@ } }, "node_modules/@inquirer/rawlist": { - "version": "5.2.9", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.9.tgz", - "integrity": "sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.0.tgz", + "integrity": "sha512-p+vAeTAD+cGXjGleP1F5LXrX2ISxNDZm+lqeBpnJausNLSZskZZkcggwhomqP8Igx9oIjnoeOrw98xvdFvdm2w==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2043,14 +2043,14 @@ } }, "node_modules/@inquirer/search": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.9.tgz", - "integrity": "sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.0.tgz", + "integrity": "sha512-ByURoSGIaSl5O5Q0AmYmVmUsXbMUcBGNoA3FRL7TOyiA22IeFHymJKRkuILbOIlJwqnBk7AnPpseodyFUBzg+g==", "license": "MIT", "dependencies": { - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2065,15 +2065,15 @@ } }, "node_modules/@inquirer/select": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.5.tgz", - "integrity": "sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.0.tgz", + "integrity": "sha512-6IzkcmEbEXfgVbxZ2d1UyJFbCBoc6dTofulFmrYuomIp88HXiVqRbqbg4/mbfZhvnNo6xYmnYo2AEmDof6fQkg==", "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/figures": "^2.0.5", - "@inquirer/type": "^4.0.5" + "@inquirer/ansi": "^2.0.6", + "@inquirer/core": "^11.2.0", + "@inquirer/figures": "^2.0.6", + "@inquirer/type": "^4.0.6" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2088,9 +2088,9 @@ } }, "node_modules/@inquirer/type": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", - "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.6.tgz", + "integrity": "sha512-J+9tdxOskuYuGjsvGaq00AamhDgjR7anhEW2dP4QdQpFCMPngCeC/bCYWQ5NsMWZRdsy53is7kAHb/+7cwDk2g==", "license": "MIT", "engines": { "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" @@ -2156,9 +2156,9 @@ } }, "node_modules/@jscpd/badge-reporter": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.2.3.tgz", - "integrity": "sha512-yNvbwWl/NwogHT5XrHyqXgF9yVZeLWA2QOhGqYTopvgi7LsSbDumpOqOcJMHP9Z4RalhMfahh+dVXFSI7tMcaA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.2.4.tgz", + "integrity": "sha512-g5vu05u0lX9rcHA0k3CptLfpOiuMzxh5+mUe2iYRAznTwH3ks6JAVAf9aPi5mBFttMCRiJh2zSt3xnSadHtMGg==", "dev": true, "license": "MIT", "dependencies": { @@ -2168,9 +2168,9 @@ } }, "node_modules/@jscpd/core": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.2.3.tgz", - "integrity": "sha512-VQ2gH+tiI51ty3PBRD4HClNNgyX/VH9cs0dcFKuywxDzLQ64jYp7vhJPcqnyiVX9tVEIAa12sucRHQP/VHwugA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.2.4.tgz", + "integrity": "sha512-9V9YzmmhYg9682kFqi+n0KGOhXNSoqxHbuIP3i/l/oSd6upBOnnSeBWDZMGOenQRQnyKEtCIbnS9YFz+3B+siQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2178,14 +2178,14 @@ } }, "node_modules/@jscpd/finder": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.2.3.tgz", - "integrity": "sha512-ZpjviFAg6zLojQHS+owvrn8DG1OY1d4835Je4LUKzbMurndmQDhvRRFDkN9V6xPn6gvRaMVkJHN2tyljsnUjWA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.2.4.tgz", + "integrity": "sha512-4LLEuAAmAraud/TAAlB5BByVdWfy7SYiPKacj5yEggpkNs0qsw2kiZ5EyU3LonB+/vntJJEDDpJMmvOeS58e0A==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/core": "4.2.3", - "@jscpd/tokenizer": "4.2.3", + "@jscpd/core": "4.2.4", + "@jscpd/tokenizer": "4.2.4", "blamer": "^1.0.6", "bytes": "^3.1.2", "cli-table3": "^0.6.5", @@ -2197,9 +2197,9 @@ } }, "node_modules/@jscpd/html-reporter": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.2.3.tgz", - "integrity": "sha512-kp1pqJXCKwyRu5mJK5IvXdFQEDHWQDb7svLFlbVXGI0dVH1y1XNl8mrIrSoRw+0AySxhDkuSyIlQOSDC2GRwQg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.2.4.tgz", + "integrity": "sha512-6UljCTVGf7O+o6D6fs1zNBG+vR1PTn47W2mSgb5hzSrvNw60rLrVoAMZMnr/TeIEdd/OEgAu+icbdvvVBfnvJw==", "dev": true, "license": "MIT", "dependencies": { @@ -2209,13 +2209,13 @@ } }, "node_modules/@jscpd/tokenizer": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.3.tgz", - "integrity": "sha512-RvjD7/hwqtcQC9MWOl31odTti6kGCFxZ77DKEhwyMn+r6oVEUFbXgcGvzn0GC/wuTl7f3j5MF9JNMeTneOFwYA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.4.tgz", + "integrity": "sha512-nM4kGyDvpcevt8t0zOsMQ82ShSc65c3LIQUHClTYwraiOGOmWgUQyen+JIiFCNF8eDCGR2Qa5iI5XBfGWYQzIg==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/core": "4.2.3", + "@jscpd/core": "4.2.4", "spark-md5": "^3.0.2" } }, @@ -2296,6 +2296,18 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2377,6 +2389,348 @@ "node": ">= 8" } }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", + "integrity": "sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.130.0.tgz", + "integrity": "sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.130.0.tgz", + "integrity": "sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.130.0.tgz", + "integrity": "sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.130.0.tgz", + "integrity": "sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.130.0.tgz", + "integrity": "sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.130.0.tgz", + "integrity": "sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.130.0.tgz", + "integrity": "sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.130.0.tgz", + "integrity": "sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.130.0.tgz", + "integrity": "sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.130.0.tgz", + "integrity": "sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.130.0.tgz", + "integrity": "sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.130.0.tgz", + "integrity": "sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.130.0.tgz", + "integrity": "sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.130.0.tgz", + "integrity": "sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.130.0.tgz", + "integrity": "sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.130.0.tgz", + "integrity": "sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.130.0.tgz", + "integrity": "sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.130.0.tgz", + "integrity": "sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.130.0.tgz", + "integrity": "sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", @@ -3644,11 +3998,10 @@ "license": "MIT" }, "node_modules/@ston-fi/api": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@ston-fi/api/-/api-0.31.0.tgz", - "integrity": "sha512-ct06MzR8Sk7GkMMleGO3G651Kyv15KKuHlm5xYgzIPCLe8s6f0gXVQW1yRvpHwNOwbkyIdx4m1k+9V+8abTojQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@ston-fi/api/-/api-0.32.0.tgz", + "integrity": "sha512-1Z9++yFaDQIDaoIX5Vz+LnqFe+Wzaq6P65TW38KTqOyymvztvHj7yLI1bMGDoFB3EjZ8HPR4z2LUMstyrBT0dQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { "camelcase-keys": "9.1.3", @@ -3967,17 +4320,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", - "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/type-utils": "8.59.3", - "@typescript-eslint/utils": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -3990,22 +4343,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.3", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", - "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4021,14 +4374,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", - "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.3", - "@typescript-eslint/types": "^8.59.3", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -4043,14 +4396,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", - "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4061,9 +4414,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", - "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -4078,15 +4431,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", - "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3", - "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -4103,9 +4456,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", - "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -4117,16 +4470,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", - "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.3", - "@typescript-eslint/tsconfig-utils": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/visitor-keys": "8.59.3", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -4145,16 +4498,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", - "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.3", - "@typescript-eslint/types": "8.59.3", - "@typescript-eslint/typescript-estree": "8.59.3" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4169,13 +4522,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.3", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", - "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6772,9 +7125,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-wrap-ansi": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", - "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "license": "MIT", "dependencies": { "fast-string-width": "^3.0.2" @@ -7329,6 +7682,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -7544,9 +7910,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.19", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", - "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -8054,30 +8420,30 @@ "license": "MIT" }, "node_modules/jscpd": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.2.3.tgz", - "integrity": "sha512-/1BEga1E1cY56/sdQOzU/PFtnea+n1beqG8/Xx4HopG9c5rkUO8ptnu9En8Xf1ILGW6KSWidV4vLQTm2FGYvpw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.2.4.tgz", + "integrity": "sha512-PSo2U0G8OxULayGyQMv7T/0ZQ+c3PPltdMOz/57v9Xnmq5xSIhh4cnZ0oYZPKqejy10aFwAbMVxqAlo24+PQ3g==", "dev": true, "license": "MIT", "dependencies": { - "@jscpd/badge-reporter": "4.2.3", - "@jscpd/core": "4.2.3", - "@jscpd/finder": "4.2.3", - "@jscpd/html-reporter": "4.2.3", - "@jscpd/tokenizer": "4.2.3", + "@jscpd/badge-reporter": "4.2.4", + "@jscpd/core": "4.2.4", + "@jscpd/finder": "4.2.4", + "@jscpd/html-reporter": "4.2.4", + "@jscpd/tokenizer": "4.2.4", "colors": "^1.4.0", "commander": "^5.0.0", "fs-extra": "^11.2.0", - "jscpd-sarif-reporter": "4.2.3" + "jscpd-sarif-reporter": "4.2.4" }, "bin": { "jscpd": "bin/jscpd" } }, "node_modules/jscpd-sarif-reporter": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.2.3.tgz", - "integrity": "sha512-rM0LM5S0kdASLCtDsr1s51rJOPf8nubaxaWQUTWVVPda1UMPymXbELG+A3Rgpoa4D4QFUFfXqz60Jn/W+vlFtA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.2.4.tgz", + "integrity": "sha512-JtX79kFSyAhqJh5TdLUcvtYJtJd1F8UW8b4Miaga+EIgUn2/nR0N2zWL9mH5cRXgbzLuQbbsw9kReUVIECApwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8255,9 +8621,9 @@ } }, "node_modules/knip": { - "version": "5.88.1", - "resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz", - "integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.14.2.tgz", + "integrity": "sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==", "dev": true, "funding": [ { @@ -8271,18 +8637,19 @@ ], "license": "ISC", "dependencies": { - "@nodelib/fs.walk": "^1.2.3", - "fast-glob": "^3.3.3", + "fdir": "^6.5.0", "formatly": "^0.3.0", - "jiti": "^2.6.0", + "get-tsconfig": "4.14.0", + "jiti": "^2.7.0", "minimist": "^1.2.8", + "oxc-parser": "^0.130.0", "oxc-resolver": "^11.19.1", - "picocolors": "^1.1.1", - "picomatch": "^4.0.1", - "smol-toml": "^1.5.2", + "picomatch": "^4.0.4", + "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", - "unbash": "^2.2.0", - "yaml": "^2.8.2", + "tinyglobby": "^0.2.16", + "unbash": "^3.0.0", + "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { @@ -8290,11 +8657,7 @@ "knip-bun": "bin/knip-bun.js" }, "engines": { - "node": ">=18.18.0" - }, - "peerDependencies": { - "@types/node": ">=18", - "typescript": ">=5.0.4 <7" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/levn": { @@ -9290,12 +9653,12 @@ "license": "MIT" }, "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-4.0.0.tgz", + "integrity": "sha512-gSrprq0fJ3EiOErzjdIZrjysVVmJ4uu1QWfCDss5LypA5OXvrMje5Ym5z6V6RLyJ2eF87lasX7t6a0AnFvZblg==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/mz": { @@ -9707,6 +10070,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oxc-parser": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", + "integrity": "sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.130.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.130.0", + "@oxc-parser/binding-android-arm64": "0.130.0", + "@oxc-parser/binding-darwin-arm64": "0.130.0", + "@oxc-parser/binding-darwin-x64": "0.130.0", + "@oxc-parser/binding-freebsd-x64": "0.130.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.130.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.130.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.130.0", + "@oxc-parser/binding-linux-arm64-musl": "0.130.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.130.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.130.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-gnu": "0.130.0", + "@oxc-parser/binding-linux-x64-musl": "0.130.0", + "@oxc-parser/binding-openharmony-arm64": "0.130.0", + "@oxc-parser/binding-wasm32-wasi": "0.130.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.130.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.130.0", + "@oxc-parser/binding-win32-x64-msvc": "0.130.0" + } + }, "node_modules/oxc-resolver": { "version": "11.19.1", "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", @@ -10806,6 +11207,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -12351,9 +12762,9 @@ "license": "MIT" }, "node_modules/tsx": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", - "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", "dev": true, "license": "MIT", "dependencies": { @@ -12996,9 +13407,9 @@ "license": "MIT" }, "node_modules/unbash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", - "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-3.0.0.tgz", + "integrity": "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==", "dev": true, "license": "ISC", "engines": { diff --git a/package.json b/package.json index db33bea3..4f3d3f42 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ }, "dependencies": { "@dedust/sdk": "^0.8.7", - "@hono/node-server": "^1.19.11", + "@hono/node-server": "^2.0.4", "@huggingface/transformers": "^3.8.1", "@inquirer/prompts": "^8.3.2", "@mariozechner/pi-ai": "^0.73.1", @@ -96,7 +96,7 @@ "axios": ">=1.15.2" }, "devDependencies": { - "@ston-fi/api": "^0.31.0", + "@ston-fi/api": "^0.32.0", "@ston-fi/sdk": "^2.7.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", @@ -107,13 +107,13 @@ "eslint": "^10.1.0", "husky": "^9.1.7", "jscpd": "^4.0.8", - "knip": "^5.85.0", + "knip": "^6.14.2", "lint-staged": "^16.4.0", "madge": "^8.0.0", "prettier": "^3.8.1", "tsup": "^8.5.1", "tsx": "^4.21.0", - "typescript": "^5.7.0", + "typescript": "^5.9.3", "vitest": "^4.1.1" }, "engines": { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c0eef69c..6f7064a6 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -47,7 +47,7 @@ export interface TonPrice { /** Result of a TON send operation */ export interface TonSendResult { - /** Transaction reference (format: seqno_timestamp_amount) */ + /** On-chain transaction hash (hex), verifiable on TON explorers */ txRef: string; /** Amount sent in TON */ amount: number; @@ -133,6 +133,8 @@ export interface JettonSendResult { success: boolean; /** Wallet sequence number used */ seqno: number; + /** On-chain transaction hash (hex), verifiable on TON explorers */ + txRef?: string; } /** @@ -339,6 +341,8 @@ export interface DexSwapResult { minOutput: string; /** Slippage used */ slippage: string; + /** On-chain transaction hash (hex), verifiable on TON explorers */ + txRef?: string; } /** DEX sub-namespace on TonSDK */ diff --git a/src/agent/client.ts b/src/agent/client.ts index a5dfb4f1..89ceafe3 100644 --- a/src/agent/client.ts +++ b/src/agent/client.ts @@ -1,9 +1,6 @@ import { complete, stream, - getModel, - type Model, - type Api, type Context, type AssistantMessage, type Message, @@ -12,20 +9,37 @@ import { } from "@mariozechner/pi-ai"; import type { AgentConfig } from "../config/schema.js"; import { appendToTranscript, readTranscript } from "../session/transcript.js"; -import { getProviderMetadata, type SupportedProvider } from "../config/providers.js"; +import type { SupportedProvider } from "../config/providers.js"; import { sanitizeToolsForGemini } from "./schema-sanitizer.js"; import { createLogger } from "../utils/logger.js"; -import { fetchWithTimeout } from "../utils/fetch.js"; -import { - getClaudeCodeApiKey, - refreshClaudeCodeApiKey, -} from "../providers/claude-code-credentials.js"; import { getCodexApiKey, refreshCodexApiKey } from "../providers/codex-credentials.js"; +import { getProviderModel } from "../providers/model-resolver.js"; + +// Model resolution + provider model registration live in the neutral providers/ +// layer so non-agent consumers (e.g. memory) can resolve models without importing +// from agent/. Re-exported here for backward compatibility with existing importers. +export { + registerCocoonModels, + registerLocalModels, + getProviderModel, + getUtilityModel, +} from "../providers/model-resolver.js"; const log = createLogger("LLM"); +/** 401/Unauthorized detection for the one-shot credential-refresh retry. */ +function isUnauthorizedError(errorMessage?: string): boolean { + if (!errorMessage) return false; + return errorMessage.includes("401") || errorMessage.toLowerCase().includes("unauthorized"); +} + +/** Providers whose credentials can be refreshed once on a 401, then the call retried. */ +const RETRY_401_PROVIDERS: { provider: string; refresh: () => Promise }[] = [ + { provider: "codex", refresh: refreshCodexApiKey }, +]; + export function isOAuthToken(apiKey: string, provider?: string): boolean { - if (provider && provider !== "anthropic" && provider !== "claude-code") return false; + if (provider && provider !== "anthropic") return false; return apiKey.startsWith("sk-ant-oat01-"); } @@ -33,191 +47,10 @@ export function isOAuthToken(apiKey: string, provider?: string): boolean { export function getEffectiveApiKey(provider: string, rawKey: string): string { if (provider === "local") return "local"; if (provider === "cocoon") return ""; - if (provider === "claude-code") return getClaudeCodeApiKey(rawKey); if (provider === "codex") return getCodexApiKey(rawKey); return rawKey; } -const modelCache = new Map>(); - -const COCOON_MODELS: Record> = {}; - -/** Register models discovered from a running Cocoon client */ -export async function registerCocoonModels(httpPort: number): Promise { - try { - const res = await fetchWithTimeout(`http://localhost:${httpPort}/v1/models`, { - timeoutMs: 3000, - }); - if (!res.ok) return []; - const body = (await res.json()) as { - data?: { id?: string; name?: string }[]; - models?: { id?: string; name?: string }[]; - }; - const models = body.data || body.models || []; - if (!Array.isArray(models)) return []; - const ids: string[] = []; - for (const m of models) { - const id = m.id || m.name || String(m); - COCOON_MODELS[id] = { - id, - name: id, - api: "openai-completions", - provider: "cocoon", - baseUrl: `http://localhost:${httpPort}/v1`, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 4096, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - }, - }; - ids.push(id); - } - return ids; - } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { - log.warn({ port: httpPort }, "Cocoon /v1/models timed out after 3s, returning empty list"); - } - return []; - } -} - -const LOCAL_MODELS: Record> = {}; - -/** Register models discovered from a local OpenAI-compatible server */ -export async function registerLocalModels(baseUrl: string): Promise { - try { - const parsed = new URL(baseUrl); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - log.warn(`Local LLM base_url must use http or https (got ${parsed.protocol})`); - return []; - } - const url = baseUrl.replace(/\/+$/, ""); - const res = await fetchWithTimeout(`${url}/models`, { timeoutMs: 10_000 }); - if (!res.ok) return []; - const body = (await res.json()) as { - data?: { id?: string; name?: string }[]; - models?: { id?: string; name?: string }[]; - }; - const rawModels = body.data || body.models || []; - if (!Array.isArray(rawModels)) return []; - const models = rawModels.slice(0, 500); - const ids: string[] = []; - for (const m of models) { - const id = m.id || m.name || String(m); - LOCAL_MODELS[id] = { - id, - name: id, - api: "openai-completions", - provider: "local", - baseUrl: url, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 4096, - compat: { - supportsStore: false, - supportsDeveloperRole: false, - supportsReasoningEffort: false, - supportsStrictMode: false, - maxTokensField: "max_tokens", - }, - }; - ids.push(id); - } - return ids; - } catch { - return []; - } -} - -/** Moonshot backward-compat: old model IDs β†’ kimi-coding IDs */ -const MOONSHOT_MODEL_ALIASES: Record = { - "kimi-k2.5": "kimi-for-coding", - k2p6: "kimi-for-coding", -}; - -export function getProviderModel(provider: SupportedProvider, modelId: string): Model { - const cacheKey = `${provider}:${modelId}`; - const cached = modelCache.get(cacheKey); - if (cached) return cached; - - const meta = getProviderMetadata(provider); - - if (meta.piAiProvider === "cocoon") { - let model = COCOON_MODELS[modelId]; - if (!model) { - model = Object.values(COCOON_MODELS)[0]; - if (model) log.warn(`Cocoon model "${modelId}" not found, using "${model.id}"`); - } - if (model) { - modelCache.set(cacheKey, model); - return model; - } - throw new Error("No Cocoon models available. Is the cocoon client running?"); - } - - if (meta.piAiProvider === "local") { - let model = LOCAL_MODELS[modelId]; - if (!model) { - model = Object.values(LOCAL_MODELS)[0]; - if (model) log.warn(`Local model "${modelId}" not found, using "${model.id}"`); - } - if (model) { - modelCache.set(cacheKey, model); - return model; - } - throw new Error("No local models available. Is the LLM server running?"); - } - - // Moonshot backward-compat: remap old model IDs to kimi-coding IDs - if (provider === "moonshot" && MOONSHOT_MODEL_ALIASES[modelId]) { - modelId = MOONSHOT_MODEL_ALIASES[modelId]; - } - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- getModel requires literal provider+model types; dynamic strings need casts - const model = getModel(meta.piAiProvider as any, modelId as any); - if (!model) { - throw new Error(`getModel returned undefined for ${provider}/${modelId}`); - } - modelCache.set(cacheKey, model); - return model; - } catch { - log.warn(`Model ${modelId} not found for ${provider}, falling back to ${meta.defaultModel}`); - const fallbackKey = `${provider}:${meta.defaultModel}`; - const fallbackCached = modelCache.get(fallbackKey); - if (fallbackCached) return fallbackCached; - - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- same as above: dynamic strings - const model = getModel(meta.piAiProvider as any, meta.defaultModel as any); - if (!model) { - throw new Error( - `Fallback model ${meta.defaultModel} also returned undefined for ${provider}` - ); - } - modelCache.set(fallbackKey, model); - return model; - } catch { - throw new Error( - `Could not find model ${modelId} or fallback ${meta.defaultModel} for ${provider}` - ); - } - } -} - -export function getUtilityModel(provider: SupportedProvider, overrideModel?: string): Model { - const meta = getProviderMetadata(provider); - const modelId = overrideModel || meta.utilityModel; - return getProviderModel(provider, modelId); -} - export interface ChatOptions { systemPrompt?: string; context: Context; @@ -234,6 +67,39 @@ export interface ChatResponse { context: Context; } +const THINK_RE = /[\s\S]*?<\/think>/g; + +/** + * Shared post-processing for both complete() and stream() responses: strip + * blocks (Cocoon, Mistral, etc.), persist the transcript, extract the + * text content, and append the response to the context. + */ +function finalizeResponse( + response: AssistantMessage, + context: Context, + options: ChatOptions +): ChatResponse { + for (const block of response.content) { + if (block.type === "text" && block.text.includes("")) { + block.text = block.text.replace(THINK_RE, "").trim(); + } + } + + if (options.persistTranscript && options.sessionId) { + appendToTranscript(options.sessionId, response); + } + + const textContent = response.content.find((block) => block.type === "text"); + const text = textContent?.type === "text" ? textContent.text : ""; + + const updatedContext: Context = { + ...context, + messages: [...context.messages, response], + }; + + return { message: response, text, context: updatedContext }; +} + export async function chatWithContext( config: AgentConfig, options: ChatOptions @@ -280,32 +146,11 @@ export async function chatWithContext( let response = await complete(model, context, completeOptions as ProviderStreamOptions); - // Claude Code provider: retry once on 401/Unauthorized by refreshing credentials - if ( - provider === "claude-code" && - response.stopReason === "error" && - response.errorMessage && - (response.errorMessage.includes("401") || - response.errorMessage.toLowerCase().includes("unauthorized")) - ) { - log.warn("Claude Code token rejected (401), refreshing credentials and retrying..."); - const refreshedKey = await refreshClaudeCodeApiKey(); - if (refreshedKey) { - completeOptions.apiKey = refreshedKey; - response = await complete(model, context, completeOptions as ProviderStreamOptions); - } - } - - // Codex provider: retry once on 401/Unauthorized by re-reading credentials - if ( - provider === "codex" && - response.stopReason === "error" && - response.errorMessage && - (response.errorMessage.includes("401") || - response.errorMessage.toLowerCase().includes("unauthorized")) - ) { - log.warn("Codex token rejected (401), re-reading credentials and retrying..."); - const refreshedKey = await refreshCodexApiKey(); + // Refreshable providers: retry once on 401/Unauthorized by refreshing credentials + const retry401 = RETRY_401_PROVIDERS.find((e) => e.provider === provider); + if (retry401 && response.stopReason === "error" && isUnauthorizedError(response.errorMessage)) { + log.warn(`${provider} token rejected (401), refreshing credentials and retrying...`); + const refreshedKey = await retry401.refresh(); if (refreshedKey) { completeOptions.apiKey = refreshedKey; response = await complete(model, context, completeOptions as ProviderStreamOptions); @@ -330,31 +175,7 @@ export async function chatWithContext( } } - // Strip blocks from all providers (Cocoon, Mistral, etc.) - const thinkRe = /[\s\S]*?<\/think>/g; - for (const block of response.content) { - if (block.type === "text" && block.text.includes("")) { - block.text = block.text.replace(thinkRe, "").trim(); - } - } - - if (options.persistTranscript && options.sessionId) { - appendToTranscript(options.sessionId, response); - } - - const textContent = response.content.find((block) => block.type === "text"); - const text = textContent?.type === "text" ? textContent.text : ""; - - const updatedContext: Context = { - ...context, - messages: [...context.messages, response], - }; - - return { - message: response, - text, - context: updatedContext, - }; + return finalizeResponse(response, context, options); } export interface StreamResult { @@ -405,28 +226,7 @@ export function streamWithContext(config: AgentConfig, options: ChatOptions): St // Result promise: wait for the stream to complete and build ChatResponse const resultPromise = (async (): Promise => { const response = await eventStream.result(); - - // Strip blocks - const thinkRe = /[\s\S]*?<\/think>/g; - for (const block of response.content) { - if (block.type === "text" && block.text.includes("")) { - block.text = block.text.replace(thinkRe, "").trim(); - } - } - - if (options.persistTranscript && options.sessionId) { - appendToTranscript(options.sessionId, response); - } - - const textContent = response.content.find((block) => block.type === "text"); - const text = textContent?.type === "text" ? textContent.text : ""; - - const updatedContext: Context = { - ...context, - messages: [...context.messages, response], - }; - - return { message: response, text, context: updatedContext }; + return finalizeResponse(response, context, options); })(); return { textStream: textDeltas(), result: resultPromise }; diff --git a/src/agent/runtime-utils.ts b/src/agent/runtime-utils.ts index e16e89dc..a4374a5a 100644 --- a/src/agent/runtime-utils.ts +++ b/src/agent/runtime-utils.ts @@ -1,4 +1,27 @@ -import type { Context, TextContent, ToolCall } from "@mariozechner/pi-ai"; +import type { Context, TextContent, Usage } from "@mariozechner/pi-ai"; +import { extractToolNames, stripEnvelopePrefix, truncate } from "../utils/pi-message.js"; + +/** Per-turn usage accumulator (input/output/cache + cost), summed across loop iterations. */ +export interface UsageAccumulator { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalCost: number; +} + +/** + * Accumulate a single iteration's usage into the running per-turn accumulator. + * Mutates and returns `acc`. Missing cache/cost fields default to 0. + */ +export function addUsage(acc: UsageAccumulator, usage: Usage): UsageAccumulator { + acc.input += usage.input; + acc.output += usage.output; + acc.cacheRead += usage.cacheRead ?? 0; + acc.cacheWrite += usage.cacheWrite ?? 0; + acc.totalCost += usage.cost?.total ?? 0; + return acc; +} export function isContextOverflowError(errorMessage?: string): boolean { if (!errorMessage) return false; @@ -14,6 +37,62 @@ export function isContextOverflowError(errorMessage?: string): boolean { ); } +export function isRateLimitError(errorMessage?: string): boolean { + if (!errorMessage) return false; + return errorMessage.includes("429") || errorMessage.toLowerCase().includes("rate"); +} + +export function isServerError(errorMessage?: string): boolean { + if (!errorMessage) return false; + return ( + errorMessage.includes("500") || + errorMessage.includes("502") || + errorMessage.includes("503") || + errorMessage.includes("529") || + errorMessage.includes("overloaded") || + errorMessage.includes("Internal server error") || + errorMessage.includes("api_error") || + // Transient streaming-transport drops are retryable: an LLM provider WebSocket + // closing mid-stream (1012 service restart, 1006 abnormal, 1011 server error) + // or a dropped socket is not a terminal error β€” back off and retry. + errorMessage.includes("WebSocket closed") || + errorMessage.includes("ECONNRESET") || + errorMessage.includes("socket hang up") + ); +} + +export type LlmErrorKind = "context_overflow" | "rate_limit" | "server_error" | "unknown"; + +/** + * Single source of truth for LLM error classification. Both the response:error + * hook (errorCode) and the retry-dispatch switch consume this, so a 529/overloaded + * can no longer be reported as UNKNOWN to the hook while being retried internally. + * Precedence matches the control-flow dispatch: overflow β†’ rate-limit β†’ server. + */ +export function classifyLlmError(errorMessage?: string): { + kind: LlmErrorKind; + code: "CONTEXT_OVERFLOW" | "RATE_LIMIT" | "PROVIDER_ERROR" | "UNKNOWN"; +} { + if (isContextOverflowError(errorMessage)) + return { kind: "context_overflow", code: "CONTEXT_OVERFLOW" }; + if (isRateLimitError(errorMessage)) return { kind: "rate_limit", code: "RATE_LIMIT" }; + if (isServerError(errorMessage)) return { kind: "server_error", code: "PROVIDER_ERROR" }; + return { kind: "unknown", code: "UNKNOWN" }; +} + +/** + * Parse a Retry-After hint (in seconds) from a provider error string, if present. + * Mirrors the precedent in flood-retry.ts. Returns milliseconds (capped at 60s), or null. + */ +export function parseRetryAfterMs(errorMessage?: string): number | null { + if (!errorMessage) return null; + const match = errorMessage.match(/retry[-\s]?after[:\s]+(\d+)/i); + if (!match) return null; + const seconds = Number(match[1]); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, 60_000); +} + export function isTrivialMessage(text: string): boolean { const stripped = text.trim(); if (!stripped) return true; @@ -23,6 +102,51 @@ export function isTrivialMessage(text: string): boolean { return trivial.test(stripped); } +/** Compact summary of tool params for the iteration log line. */ +export function summarizeToolParams(toolName: string, params: Record): string { + const MAX = 60; + let hint = ""; + + if (toolName === "exec_run" && typeof params.command === "string") { + hint = params.command; + } else if (toolName === "web_fetch" && typeof params.url === "string") { + hint = params.url; + } else if (toolName.startsWith("telegram_") && typeof params.message === "string") { + hint = params.message; + } else if (typeof params.query === "string") { + hint = params.query; + } else if (typeof params.section === "string") { + hint = params.section; + } + + if (!hint) return ""; + if (hint.length > MAX) hint = hint.slice(0, MAX) + "…"; + return `(${hint})`; +} + +export function enrichRAGQuery(query: string): string { + if (!query) return query; + const tags: string[] = []; + const lower = query.toLowerCase(); + + if (/\.ton\b/i.test(query)) tags.push("TON blockchain domain DNS resolution"); + if (/\b(EQ|UQ)[A-Za-z0-9_-]{46}\b/.test(query)) tags.push("TON wallet address blockchain"); + if (/\bt\.me\/nft\/\S+/i.test(query)) tags.push("Telegram unique gift collectible NFT"); + if (/\b(swap|trade|exchange)\b/i.test(query) && /\b(token|jetton|ton|usdt|usdc)\b/i.test(lower)) { + tags.push("DEX swap jetton trade"); + } + if (/\b(holdings?|portfolio|balances?|net\s*worth|avoirs?|solde)\b/i.test(query)) { + tags.push("jetton token balance holdings portfolio wallet TON net worth value"); + } + if (/\bsticker\b/i.test(query)) tags.push("Telegram sticker pack send"); + if (/\b(invoice|deal|payment|escrow)\b/i.test(query)) tags.push("trade deal invoice"); + if (/\b(gift|collectible)\b/i.test(query) && /\b(buy|send|transfer|resale)\b/i.test(query)) { + tags.push("Telegram gift NFT collectible"); + } + + return tags.length > 0 ? `${query} ${tags.join(" ")}` : query; +} + export function extractContextSummary(context: Context, maxMessages: number = 10): string { const recentMessages = context.messages.slice(-maxMessages); const summaryParts: string[] = []; @@ -32,23 +156,19 @@ export function extractContextSummary(context: Context, maxMessages: number = 10 for (const msg of recentMessages) { if (msg.role === "user") { const content = typeof msg.content === "string" ? msg.content : "[complex]"; - const bodyMatch = content.match(/\] (.+)/s); - const body = bodyMatch ? bodyMatch[1] : content; - summaryParts.push(`- **User**: ${body.substring(0, 150)}${body.length > 150 ? "..." : ""}`); + const body = stripEnvelopePrefix(content); + summaryParts.push(`- **User**: ${truncate(body, 150)}`); } else if (msg.role === "assistant") { const textBlocks = msg.content.filter((b): b is TextContent => b.type === "text"); - const toolBlocks = msg.content.filter((b): b is ToolCall => b.type === "toolCall"); + const toolNames = extractToolNames(msg); if (textBlocks.length > 0) { const text = textBlocks[0].text || ""; - summaryParts.push( - `- **Agent**: ${text.substring(0, 150)}${text.length > 150 ? "..." : ""}` - ); + summaryParts.push(`- **Agent**: ${truncate(text, 150)}`); } - if (toolBlocks.length > 0) { - const toolNames = toolBlocks.map((b) => b.name).join(", "); - summaryParts.push(` - *Tools used: ${toolNames}*`); + if (toolNames.length > 0) { + summaryParts.push(` - *Tools used: ${toolNames.join(", ")}*`); } } else if (msg.role === "toolResult") { const status = msg.isError ? "ERROR" : "OK"; diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts index 87d3b396..40e92f54 100644 --- a/src/agent/runtime.ts +++ b/src/agent/runtime.ts @@ -41,8 +41,9 @@ import type { Context, Tool as PiAiTool, UserMessage, - ToolResultMessage, ToolCall, + AssistantMessage, + Message, } from "@mariozechner/pi-ai"; import { CompactionManager, DEFAULT_COMPACTION_CONFIG } from "../memory/compaction.js"; import { maskOldToolResults } from "../memory/observation-masking.js"; @@ -68,10 +69,16 @@ import type { PromptAfterEvent, } from "../sdk/hooks/types.js"; import { - isContextOverflowError, isTrivialMessage, extractContextSummary, + parseRetryAfterMs, + summarizeToolParams, + enrichRAGQuery, + classifyLlmError, + addUsage, } from "./runtime-utils.js"; +import type { UsageAccumulator } from "./runtime-utils.js"; +import { isBotBridge } from "../telegram/bridge-guards.js"; import { truncateToolResult } from "./tool-result-truncator.js"; import { accumulateTokenUsage } from "./token-usage.js"; @@ -95,6 +102,7 @@ export interface ProcessMessageOptions { messageId?: number; replyContext?: { senderName?: string; text: string; isAgent?: boolean }; isHeartbeat?: boolean; + isGuest?: boolean; streamToChat?: { chatId: string; bridge: ITelegramBridge; mode: "all" | "replace" | "off" }; } @@ -129,13 +137,7 @@ interface LoopResult { context: Context; totalToolCalls: Array<{ name: string; input: Record }>; accumulatedTexts: string[]; - accumulatedUsage: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - totalCost: number; - }; + accumulatedUsage: UsageAccumulator; wasStreamed: boolean; } @@ -152,48 +154,6 @@ interface ToolExecResult { execError?: { message: string; stack?: string }; } -/** Compact summary of tool params for the iteration log line. */ -function summarizeToolParams(toolName: string, params: Record): string { - const MAX = 60; - let hint = ""; - - if (toolName === "exec_run" && typeof params.command === "string") { - hint = params.command; - } else if (toolName === "web_fetch" && typeof params.url === "string") { - hint = params.url; - } else if (toolName.startsWith("telegram_") && typeof params.message === "string") { - hint = params.message; - } else if (typeof params.query === "string") { - hint = params.query; - } else if (typeof params.section === "string") { - hint = params.section; - } - - if (!hint) return ""; - if (hint.length > MAX) hint = hint.slice(0, MAX) + "…"; - return `(${hint})`; -} - -function enrichRAGQuery(query: string): string { - if (!query) return query; - const tags: string[] = []; - const lower = query.toLowerCase(); - - if (/\.ton\b/i.test(query)) tags.push("TON blockchain domain DNS resolution"); - if (/\b(EQ|UQ)[A-Za-z0-9_-]{46}\b/.test(query)) tags.push("TON wallet address blockchain"); - if (/\bt\.me\/nft\/\S+/i.test(query)) tags.push("Telegram unique gift collectible NFT"); - if (/\b(swap|trade|exchange)\b/i.test(query) && /\b(token|jetton|ton|usdt|usdc)\b/i.test(lower)) { - tags.push("DEX swap jetton trade"); - } - if (/\bsticker\b/i.test(query)) tags.push("Telegram sticker pack send"); - if (/\b(invoice|deal|payment|escrow)\b/i.test(query)) tags.push("trade deal invoice"); - if (/\b(gift|collectible)\b/i.test(query) && /\b(buy|send|transfer|resale)\b/i.test(query)) { - tags.push("Telegram gift NFT collectible"); - } - - return tags.length > 0 ? `${query} ${tags.join(" ")}` : query; -} - export class AgentRuntime { private config: Config; private soul: string; @@ -271,6 +231,100 @@ export class AgentRuntime { } } + /** + * Compute the RAG query embedding for the turn, concurrently with other prep work. + * Returns a pending embedding promise, or `undefined` when embedding is disabled or + * the message is trivial β€” preserving the caller's concurrent Promise.all wiring. + */ + private computeRagEmbedding( + effectiveMessage: string, + context: Context + ): Promise | undefined { + const isNonTrivial = !isTrivialMessage(effectiveMessage); + if (!this.embedder || !isNonTrivial) return undefined; + + return (async () => { + let searchQuery = effectiveMessage; + const recentUserMsgs = context.messages + .filter((m) => m.role === "user" && typeof m.content === "string") + .slice(-3) + .map((m) => { + const text = m.content as string; + const bodyMatch = text.match(/\] (.+)/s); + return (bodyMatch ? bodyMatch[1] : text).trim(); + }) + .filter((t) => t.length > 0); + if (recentUserMsgs.length > 0) { + searchQuery = recentUserMsgs.join(" ") + " " + effectiveMessage; + } + const enrichedQuery = enrichRAGQuery(searchQuery); + if (enrichedQuery !== searchQuery) { + log.debug({ original: searchQuery, enriched: enrichedQuery }, "RAG query enriched"); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded above + return this.embedder!.embedQuery(enrichedQuery.slice(0, EMBEDDING_QUERY_MAX_CHARS)); + })(); + } + + /** + * Select the tool set for the turn: ToolSearch core-only, RAG pre-selection (+ the + * tool_search escape hatch), or the plain context-filtered set. The guest-mode filter + * is applied by the caller, not here. + */ + private async selectTools( + effectiveMessage: string, + effectiveIsGroup: boolean, + chatId: string, + isAdmin: boolean, + senderId: number | undefined, + toolLimit: number | null, + queryEmbedding: number[] | undefined + ): Promise { + const toolIndex = this.toolRegistry?.getToolIndex(); + const useRAG = + toolIndex?.isIndexed && + this.config.tool_rag?.enabled !== false && + !isTrivialMessage(effectiveMessage) && + !(toolLimit === null && this.config.tool_rag?.skip_unlimited_providers !== false); + + if (this.config.tool_search?.enabled && this.toolRegistry) { + // ToolSearch mode: always start with core tools only. + // The LLM discovers additional tools on demand via the tool_search meta-tool. + const tools = this.toolRegistry.getCoreTools(effectiveIsGroup, chatId, isAdmin, senderId); + log.info( + `ToolSearch: ${tools.length} core tools (${this.toolRegistry.count} total available)` + ); + return tools; + } else if (useRAG && this.toolRegistry && queryEmbedding) { + const tools = await this.toolRegistry.getForContextWithRAG( + effectiveMessage, + queryEmbedding, + effectiveIsGroup, + toolLimit, + chatId, + isAdmin, + senderId + ); + // Hybrid: always offer the tool_search escape hatch so the agent can discover + // tools the RAG pre-selection missed (the mid-loop injection handles results). + const searchTool = this.toolRegistry.getAll().find((t) => t.name === "tool_search"); + if (searchTool && !tools.some((t) => t.name === "tool_search")) { + tools.push(searchTool); + } + log.info(`Tool RAG: ${tools.length}/${this.toolRegistry.count} tools selected`); + log.debug(`Tool RAG selected: ${tools.map((t) => t.name).join(", ")}`); + return tools; + } else { + return this.toolRegistry?.getForContext( + effectiveIsGroup, + toolLimit, + chatId, + isAdmin, + senderId + ); + } + } + private async buildTurnContext( opts: ProcessMessageOptions, processStartTime: number @@ -376,7 +430,7 @@ export class AgentRuntime { } } - session = resetSessionWithPolicy(chatId, resetPolicy); + session = resetSessionWithPolicy(chatId); clearMemorySnapshot(); // New session will capture a fresh snapshot } @@ -437,30 +491,7 @@ export class AgentRuntime { const isNonTrivial = !isTrivialMessage(effectiveMessage); // Start embedding computation concurrently with session:start hook - const embeddingPromise = - this.embedder && isNonTrivial - ? (async () => { - let searchQuery = effectiveMessage; - const recentUserMsgs = context.messages - .filter((m) => m.role === "user" && typeof m.content === "string") - .slice(-3) - .map((m) => { - const text = m.content as string; - const bodyMatch = text.match(/\] (.+)/s); - return (bodyMatch ? bodyMatch[1] : text).trim(); - }) - .filter((t) => t.length > 0); - if (recentUserMsgs.length > 0) { - searchQuery = recentUserMsgs.join(" ") + " " + effectiveMessage; - } - const enrichedQuery = enrichRAGQuery(searchQuery); - if (enrichedQuery !== searchQuery) { - log.debug({ original: searchQuery, enriched: enrichedQuery }, "RAG query enriched"); - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by ternary - return this.embedder!.embedQuery(enrichedQuery.slice(0, EMBEDDING_QUERY_MAX_CHARS)); - })() - : undefined; + const embeddingPromise = this.computeRagEmbedding(effectiveMessage, context); // Await both session:start and embedding in parallel const [, embeddingResult] = await Promise.all([ @@ -616,50 +647,18 @@ export class AgentRuntime { const providerMeta = getProviderMetadata(provider); const isAdmin = toolContext?.config?.telegram.admin_ids.includes(toolContext.senderId) ?? false; - let tools: PiAiTool[] | undefined; - { - const toolIndex = this.toolRegistry?.getToolIndex(); - const useRAG = - toolIndex?.isIndexed && - this.config.tool_rag?.enabled !== false && - !isTrivialMessage(effectiveMessage) && - !( - providerMeta.toolLimit === null && - this.config.tool_rag?.skip_unlimited_providers !== false - ); + let tools = await this.selectTools( + effectiveMessage, + effectiveIsGroup, + chatId, + isAdmin, + toolContext?.senderId, + providerMeta.toolLimit, + queryEmbedding + ); - if (this.config.tool_search?.enabled && this.toolRegistry) { - // ToolSearch mode: always start with core tools only. - // The LLM discovers additional tools on demand via the tool_search meta-tool. - tools = this.toolRegistry.getCoreTools( - effectiveIsGroup, - chatId, - isAdmin, - toolContext?.senderId - ); - log.info( - `ToolSearch: ${tools.length} core tools (${this.toolRegistry.count} total available)` - ); - } else if (useRAG && this.toolRegistry && queryEmbedding) { - tools = await this.toolRegistry.getForContextWithRAG( - effectiveMessage, - queryEmbedding, - effectiveIsGroup, - providerMeta.toolLimit, - chatId, - isAdmin, - toolContext?.senderId - ); - log.info(`Tool RAG: ${tools.length}/${this.toolRegistry.count} tools selected`); - } else { - tools = this.toolRegistry?.getForContext( - effectiveIsGroup, - providerMeta.toolLimit, - chatId, - isAdmin, - toolContext?.senderId - ); - } + if (opts.isGuest && tools) { + tools = tools.filter((t) => !TELEGRAM_SEND_TOOLS.has(t.name)); } return { @@ -678,6 +677,391 @@ export class AgentRuntime { }; } + /** + * Run a single LLM iteration, streaming the draft to a bot bridge when enabled. + * Encapsulates the reset/stream/clear-draft + "all"-mode prefix bookkeeping. + * `streamAccumulatedText` is threaded in and out so the loop keeps cross-iteration + * text for "all" mode. Returns whether the response was produced via the stream path. + */ + private async streamIteration( + opts: ProcessMessageOptions, + maskedContext: Context, + systemPrompt: string, + sessionId: string, + tools: PiAiTool[] | undefined, + streamAccumulatedText: string + ): Promise<{ response: ChatResponse; streamed: boolean; streamAccumulatedText: string }> { + const streamMode = opts.streamToChat?.mode; + const shouldStream = + opts.streamToChat?.bridge.streamResponse && streamMode !== undefined && streamMode !== "off"; + + if (!shouldStream) { + const response = await chatWithContext(this.config.agent, { + systemPrompt, + context: maskedContext, + sessionId, + persistTranscript: true, + tools, + }); + return { response, streamed: false, streamAccumulatedText }; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream check + const bridge = opts.streamToChat!.bridge; + if (!isBotBridge(bridge)) { + const response = await chatWithContext(this.config.agent, { + systemPrompt, + context: maskedContext, + sessionId, + persistTranscript: true, + tools, + }); + return { response, streamed: true, streamAccumulatedText }; + } + + if (streamMode === "replace") { + // Reset draft for each iteration (new draft bubble) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream + bridge.resetDraft(opts.streamToChat!.chatId); + streamAccumulatedText = ""; + } + + const { textStream, result } = streamWithContext(this.config.agent, { + systemPrompt, + context: maskedContext, + sessionId, + persistTranscript: true, + tools, + }); + + // "all" mode: prepend accumulated text from previous iterations + const prefix = streamMode === "all" ? streamAccumulatedText : ""; + async function* prefixedStream(): AsyncIterable { + let first = true; + for await (const chunk of textStream) { + if (first && prefix) { + yield prefix + chunk; + first = false; + } else { + yield chunk; + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream check + const draftText = await bridge.streamDraft(opts.streamToChat!.chatId, prefixedStream()); + if (streamMode === "all") { + if (draftText.length === 0 && streamAccumulatedText.length > 0) { + // LLM produced only tool calls β€” clear the stale draft bubble + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream + await bridge.clearDraft(opts.streamToChat!.chatId); + } + streamAccumulatedText = draftText + "\n\n"; + } + + const response = await result; + return { response, streamed: true, streamAccumulatedText }; + } + + /** + * Handle an LLM error response: fire the response:error hook, then classify and + * recover. Context-overflow resets the session (returning the fresh session/context); + * rate-limit and server errors back off. Mutates the `retry` counters. Throws on the + * terminal cases (persistent overflow, retries exhausted, unknown error). When it + * returns, the caller must `iteration--; continue` β€” every non-throw path is a retry. + */ + private async handleLlmError( + assistantMsg: AssistantMessage, + retry: { overflowResets: number; rateLimitRetries: number; serverErrorRetries: number }, + ctx: { + session: ReturnType; + context: Context; + chatId: string; + effectiveIsGroup: boolean; + provider: SupportedProvider; + processStartTime: number; + userMsg: UserMessage; + } + ): Promise<{ session: ReturnType; context: Context }> { + let session = ctx.session; + let context = ctx.context; + const errorMsg = assistantMsg.errorMessage || ""; + const errorClass = classifyLlmError(errorMsg); + + // Hook: response:error β€” fire on all LLM errors + if (this.hookRunner) { + const errorCode = errorClass.code; + const responseErrorEvent: ResponseErrorEvent = { + chatId: ctx.chatId, + sessionId: session.sessionId, + isGroup: ctx.effectiveIsGroup, + error: errorMsg, + errorCode, + provider: ctx.provider, + model: this.config.agent.model, + retryCount: retry.rateLimitRetries + retry.serverErrorRetries, + durationMs: Date.now() - ctx.processStartTime, + }; + await this.hookRunner.runObservingHook("response:error", responseErrorEvent); + } + + if (errorClass.kind === "context_overflow") { + retry.overflowResets++; + if (retry.overflowResets > 1) { + throw new Error( + "Context overflow persists after session reset. Message may be too large for the model's context window." + ); + } + log.error(`Context overflow detected: ${errorMsg}`); + + log.info(`Saving session memory before reset...`); + const summary = extractContextSummary(context, CONTEXT_OVERFLOW_SUMMARY_MESSAGES); + appendToDailyLog(summary); + log.info(`Memory saved to daily log`); + + const archived = archiveTranscript(session.sessionId); + if (!archived) { + log.error( + `Failed to archive transcript ${session.sessionId}, proceeding with reset anyway` + ); + } + + log.info(`Resetting session due to context overflow...`); + session = resetSession(ctx.chatId); + + context = { messages: [ctx.userMsg] }; + + appendToTranscript(session.sessionId, ctx.userMsg); + + log.info(`Retrying with fresh context...`); + return { session, context }; + } else if (errorClass.kind === "rate_limit") { + retry.rateLimitRetries++; + if (retry.rateLimitRetries <= RATE_LIMIT_MAX_RETRIES) { + // Respect server Retry-After as a floor; else exponential backoff. Positive jitter de-syncs retries. + const base = parseRetryAfterMs(errorMsg) ?? 1000 * Math.pow(2, retry.rateLimitRetries - 1); + const delay = base + Math.floor(Math.random() * 500); + log.warn( + `Rate limited, retrying in ${delay}ms (attempt ${retry.rateLimitRetries}/${RATE_LIMIT_MAX_RETRIES})...` + ); + await new Promise((r) => setTimeout(r, delay)); + return { session, context }; + } + log.error(`Rate limited after ${RATE_LIMIT_MAX_RETRIES} retries: ${errorMsg}`); + throw new Error( + `API rate limited after ${RATE_LIMIT_MAX_RETRIES} retries. Please try again later.` + ); + } else if (errorClass.kind === "server_error") { + retry.serverErrorRetries++; + if (retry.serverErrorRetries <= SERVER_ERROR_MAX_RETRIES) { + const delay = + 2000 * Math.pow(2, retry.serverErrorRetries - 1) + Math.floor(Math.random() * 500); + log.warn( + `Server error, retrying in ${delay}ms (attempt ${retry.serverErrorRetries}/${SERVER_ERROR_MAX_RETRIES})...` + ); + await new Promise((r) => setTimeout(r, delay)); + return { session, context }; + } + log.error(`Server error after ${SERVER_ERROR_MAX_RETRIES} retries: ${errorMsg}`); + throw new Error( + `API server error after ${SERVER_ERROR_MAX_RETRIES} retries. The provider may be experiencing issues.` + ); + } else { + log.error(`API error: ${errorMsg}`); + throw new Error(`API error: ${errorMsg || "Unknown error"}`); + } + } + + /** + * Phases 1-2 of a tool batch: run tool:before hooks sequentially to build the plans, + * then execute the (non-blocked) tools with a bounded concurrency pool. Returns the + * plans and their results in original order. + */ + private async executeToolBatch( + toolCalls: ToolCall[], + fullContext: ToolContext, + chatId: string, + effectiveIsGroup: boolean + ): Promise<{ toolPlans: ToolPlan[]; execResults: ToolExecResult[] }> { + // Phase 1: Run tool:before hooks sequentially (hooks may cross-reference) + const toolPlans: ToolPlan[] = []; + + for (const block of toolCalls) { + if (block.type !== "toolCall") continue; + + let toolParams = (block.arguments ?? {}) as Record; + let blocked = false; + let blockReason = ""; + + if (this.hookRunner) { + const beforeEvent: BeforeToolCallEvent = { + toolName: block.name, + params: structuredClone(toolParams), + chatId, + isGroup: effectiveIsGroup, + block: false, + blockReason: "", + }; + await this.hookRunner.runModifyingHook("tool:before", beforeEvent); + if (beforeEvent.block) { + blocked = true; + blockReason = beforeEvent.blockReason || "Blocked by plugin hook"; + } else { + toolParams = structuredClone(beforeEvent.params) as Record; + } + } + + toolPlans.push({ block, blocked, blockReason, params: toolParams }); + } + + // Phase 2: Execute tools with concurrency limit (blocked tools resolve instantly) + const execResults: ToolExecResult[] = new Array(toolPlans.length); + { + let cursor = 0; + const runWorker = async (): Promise => { + while (cursor < toolPlans.length) { + const idx = cursor++; + const plan = toolPlans[idx]; + + if (plan.blocked) { + execResults[idx] = { + result: { success: false, error: plan.blockReason }, + durationMs: 0, + }; + continue; + } + + const startTime = Date.now(); + try { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- registry checked by caller + const result = await this.toolRegistry!.execute( + { ...plan.block, arguments: plan.params }, + fullContext + ); + execResults[idx] = { result, durationMs: Date.now() - startTime }; + } catch (execErr) { + const errMsg = getErrorMessage(execErr); + const errStack = execErr instanceof Error ? execErr.stack : undefined; + execResults[idx] = { + result: { success: false, error: errMsg }, + durationMs: Date.now() - startTime, + execError: { message: errMsg, stack: errStack }, + }; + } + } + }; + const workers = Math.min(TOOL_CONCURRENCY_LIMIT, toolPlans.length); + await Promise.all(Array.from({ length: workers }, () => runWorker())); + } + + return { toolPlans, execResults }; + } + + /** + * Phase 3 of a tool batch: fire tool:error/tool:after observing hooks, log + record + * each call, and build the tool-result messages (appending them to the transcript). + * Mutates the passed `totalToolCalls`/`iterationToolNames` sinks and returns the + * ordered result messages for the caller to push onto the live context. + */ + private async recordToolResults( + toolPlans: ToolPlan[], + execResults: ToolExecResult[], + sink: { + totalToolCalls: Array<{ name: string; input: Record }>; + iterationToolNames: string[]; + sessionId: string; + chatId: string; + effectiveIsGroup: boolean; + provider: SupportedProvider; + } + ): Promise { + const resultMessages: Message[] = []; + const observingHookPromises: Promise[] = []; + + for (let i = 0; i < toolPlans.length; i++) { + const plan = toolPlans[i]; + const { block } = plan; + const exec = execResults[i]; + + // Hook: tool:error (if execution threw) β€” fire-and-forget (observing) + if (exec.execError && this.hookRunner) { + const errorEvent: ToolErrorEvent = { + toolName: block.name, + params: structuredClone(plan.params), + error: exec.execError.message, + stack: exec.execError.stack, + chatId: sink.chatId, + isGroup: sink.effectiveIsGroup, + durationMs: exec.durationMs, + }; + observingHookPromises.push(this.hookRunner.runObservingHook("tool:error", errorEvent)); + } + + // Hook: tool:after (fires for all cases including blocks) β€” fire-and-forget (observing) + if (this.hookRunner) { + const afterEvent: AfterToolCallEvent = { + toolName: block.name, + params: structuredClone(plan.params), + result: { + success: exec.result.success, + data: exec.result.data, + error: exec.result.error, + }, + durationMs: exec.durationMs, + chatId: sink.chatId, + isGroup: sink.effectiveIsGroup, + ...(plan.blocked ? { blocked: true, blockReason: plan.blockReason } : {}), + }; + observingHookPromises.push(this.hookRunner.runObservingHook("tool:after", afterEvent)); + } + + const toolHint = summarizeToolParams(block.name, plan.params); + log.debug(`${block.name}: ${exec.result.success ? "βœ“" : "βœ—"} ${exec.result.error || ""}`); + sink.iterationToolNames.push(`${block.name}${toolHint} ${exec.result.success ? "βœ“" : "βœ—"}`); + + sink.totalToolCalls.push({ + name: block.name, + input: plan.params, + }); + + const resultText = truncateToolResult(exec.result, MAX_TOOL_RESULT_SIZE); + if (resultText.includes('"_truncated":true')) { + log.warn(`Tool result too large, truncated to ${resultText.length} chars`); + } + + const { buildToolResultMessage } = await import("../cocoon/tool-adapter.js"); + const resultMsg = buildToolResultMessage( + sink.provider, + block, + resultText, + !exec.result.success + ); + resultMessages.push(resultMsg); + appendToTranscript(sink.sessionId, resultMsg); + } + + // Await all observing hooks from Phase 3 (non-blocking during result processing) + if (observingHookPromises.length > 0) { + await Promise.allSettled(observingHookPromises); + } + + return resultMessages; + } + + /** + * Whether this iteration's tool batch was fully seen before (every name+sorted-args + * signature already in `seen`). Records the new signatures into `seen`. The caller + * tracks how many consecutive stalls have occurred. + */ + private detectStall(toolPlans: ToolPlan[], seen: Set): boolean { + const iterSignatures = toolPlans.map( + (p) => `${p.block.name}:${JSON.stringify(p.params, Object.keys(p.params).sort())}` + ); + const allDuplicates = iterSignatures.length > 0 && iterSignatures.every((sig) => seen.has(sig)); + for (const sig of iterSignatures) seen.add(sig); + return allDuplicates; + } + private async runAgenticLoop( turn: TurnContext, opts: ProcessMessageOptions @@ -688,16 +1072,15 @@ export class AgentRuntime { let session = turn.session; let context = turn.context; - const maxIterations = this.config.agent.max_agentic_iterations || 5; + const maxIterations = Math.max(1, this.config.agent.max_agentic_iterations || 5); let iteration = 0; - let overflowResets = 0; - let rateLimitRetries = 0; - let serverErrorRetries = 0; + const retry = { overflowResets: 0, rateLimitRetries: 0, serverErrorRetries: 0 }; let finalResponse: ChatResponse | null = null; const totalToolCalls: Array<{ name: string; input: Record }> = []; const accumulatedTexts: string[] = []; const accumulatedUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0 }; const seenToolSignatures = new Set(); + let consecutiveStalls = 0; let wasStreamed = false; let streamAccumulatedText = ""; // For "all" mode: concatenate text across iterations @@ -714,190 +1097,43 @@ export class AgentRuntime { }); const maskedContext: Context = { ...context, messages: maskedMessages }; - let response: ChatResponse; - let streamed = false; - - const streamMode = opts.streamToChat?.mode; - const shouldStream = - opts.streamToChat?.bridge.streamResponse && - streamMode !== undefined && - streamMode !== "off"; - - if (shouldStream) { - const { isBotBridge } = await import("../telegram/bridge-guards.js"); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream check - const bridge = opts.streamToChat!.bridge; - if (isBotBridge(bridge)) { - if (streamMode === "replace") { - // Reset draft for each iteration (new draft bubble) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream - bridge.resetDraft(opts.streamToChat!.chatId); - streamAccumulatedText = ""; - } - - const { textStream, result } = streamWithContext(this.config.agent, { - systemPrompt, - context: maskedContext, - sessionId: session.sessionId, - persistTranscript: true, - tools, - }); - - // "all" mode: prepend accumulated text from previous iterations - const prefix = streamMode === "all" ? streamAccumulatedText : ""; - async function* prefixedStream(): AsyncIterable { - let first = true; - for await (const chunk of textStream) { - if (first && prefix) { - yield prefix + chunk; - first = false; - } else { - yield chunk; - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream check - const draftText = await bridge.streamDraft(opts.streamToChat!.chatId, prefixedStream()); - if (streamMode === "all") { - if (draftText.length === 0 && streamAccumulatedText.length > 0) { - // LLM produced only tool calls β€” clear the stale draft bubble - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by shouldStream - await bridge.clearDraft(opts.streamToChat!.chatId); - } - streamAccumulatedText = draftText + "\n\n"; - } - - response = await result; - } else { - response = await chatWithContext(this.config.agent, { - systemPrompt, - context: maskedContext, - sessionId: session.sessionId, - persistTranscript: true, - tools, - }); - } - streamed = true; - } else { - response = await chatWithContext(this.config.agent, { - systemPrompt, - context: maskedContext, - sessionId: session.sessionId, - persistTranscript: true, - tools, - }); - } + const iterationResult = await this.streamIteration( + opts, + maskedContext, + systemPrompt, + session.sessionId, + tools, + streamAccumulatedText + ); + const response = iterationResult.response; + const streamed = iterationResult.streamed; + streamAccumulatedText = iterationResult.streamAccumulatedText; const assistantMsg = response.message; - if (assistantMsg.stopReason === "error") { - const errorMsg = assistantMsg.errorMessage || ""; - - // Hook: response:error β€” fire on all LLM errors - if (this.hookRunner) { - const errorCode = - errorMsg.includes("429") || errorMsg.toLowerCase().includes("rate") - ? "RATE_LIMIT" - : isContextOverflowError(errorMsg) - ? "CONTEXT_OVERFLOW" - : errorMsg.includes("500") || errorMsg.includes("502") || errorMsg.includes("503") - ? "PROVIDER_ERROR" - : "UNKNOWN"; - const responseErrorEvent: ResponseErrorEvent = { - chatId, - sessionId: session.sessionId, - isGroup: effectiveIsGroup, - error: errorMsg, - errorCode, - provider: provider, - model: this.config.agent.model, - retryCount: rateLimitRetries + serverErrorRetries, - durationMs: Date.now() - processStartTime, - }; - await this.hookRunner.runObservingHook("response:error", responseErrorEvent); - } - - if (isContextOverflowError(errorMsg)) { - overflowResets++; - if (overflowResets > 1) { - throw new Error( - "Context overflow persists after session reset. Message may be too large for the model's context window." - ); - } - log.error(`Context overflow detected: ${errorMsg}`); - - log.info(`Saving session memory before reset...`); - const summary = extractContextSummary(context, CONTEXT_OVERFLOW_SUMMARY_MESSAGES); - appendToDailyLog(summary); - log.info(`Memory saved to daily log`); - - const archived = archiveTranscript(session.sessionId); - if (!archived) { - log.error( - `Failed to archive transcript ${session.sessionId}, proceeding with reset anyway` - ); - } - - log.info(`Resetting session due to context overflow...`); - session = resetSession(chatId); - - context = { messages: [userMsg] }; - - appendToTranscript(session.sessionId, userMsg); - - log.info(`Retrying with fresh context...`); - continue; - } else if (errorMsg.toLowerCase().includes("rate") || errorMsg.includes("429")) { - rateLimitRetries++; - if (rateLimitRetries <= RATE_LIMIT_MAX_RETRIES) { - const delay = 1000 * Math.pow(2, rateLimitRetries - 1); - log.warn( - `Rate limited, retrying in ${delay}ms (attempt ${rateLimitRetries}/${RATE_LIMIT_MAX_RETRIES})...` - ); - await new Promise((r) => setTimeout(r, delay)); - continue; - } - log.error(`Rate limited after ${RATE_LIMIT_MAX_RETRIES} retries: ${errorMsg}`); - throw new Error( - `API rate limited after ${RATE_LIMIT_MAX_RETRIES} retries. Please try again later.` - ); - } else if ( - errorMsg.includes("500") || - errorMsg.includes("502") || - errorMsg.includes("503") || - errorMsg.includes("529") || - errorMsg.includes("overloaded") || - errorMsg.includes("Internal server error") || - errorMsg.includes("api_error") - ) { - serverErrorRetries++; - if (serverErrorRetries <= SERVER_ERROR_MAX_RETRIES) { - const delay = 2000 * Math.pow(2, serverErrorRetries - 1); - log.warn( - `Server error, retrying in ${delay}ms (attempt ${serverErrorRetries}/${SERVER_ERROR_MAX_RETRIES})...` - ); - await new Promise((r) => setTimeout(r, delay)); - continue; - } - log.error(`Server error after ${SERVER_ERROR_MAX_RETRIES} retries: ${errorMsg}`); - throw new Error( - `API server error after ${SERVER_ERROR_MAX_RETRIES} retries. The provider may be experiencing issues.` - ); - } else { - log.error(`API error: ${errorMsg}`); - throw new Error(`API error: ${errorMsg || "Unknown error"}`); - } - } - // Accumulate usage across all iterations + // Accumulate usage across all iterations β€” including errored responses that + // get retried, so cost metrics capture tokens spent on failed attempts too. const iterUsage = response.message.usage; if (iterUsage) { - accumulatedUsage.input += iterUsage.input; - accumulatedUsage.output += iterUsage.output; - accumulatedUsage.cacheRead += iterUsage.cacheRead ?? 0; - accumulatedUsage.cacheWrite += iterUsage.cacheWrite ?? 0; - accumulatedUsage.totalCost += iterUsage.cost?.total ?? 0; + addUsage(accumulatedUsage, iterUsage); + } + + if (assistantMsg.stopReason === "error") { + // Recover from LLM errors (overflow reset / rate-limit / server backoff) or throw + // on terminal cases. When it returns, this is a retry that must not consume budget. + const recovered = await this.handleLlmError(assistantMsg, retry, { + session, + context, + chatId, + effectiveIsGroup, + provider, + processStartTime, + userMsg, + }); + session = recovered.session; + context = recovered.context; + iteration--; // recovery retry, not a productive iteration β€” don't consume the budget + continue; } if (response.text) { @@ -930,171 +1166,32 @@ export class AgentRuntime { isGroup: effectiveIsGroup, }; - // Phase 1: Run tool:before hooks sequentially (hooks may cross-reference) - const toolPlans: ToolPlan[] = []; - - for (const block of toolCalls) { - if (block.type !== "toolCall") continue; - - let toolParams = (block.arguments ?? {}) as Record; - let blocked = false; - let blockReason = ""; - - if (this.hookRunner) { - const beforeEvent: BeforeToolCallEvent = { - toolName: block.name, - params: structuredClone(toolParams), - chatId, - isGroup: effectiveIsGroup, - block: false, - blockReason: "", - }; - await this.hookRunner.runModifyingHook("tool:before", beforeEvent); - if (beforeEvent.block) { - blocked = true; - blockReason = beforeEvent.blockReason || "Blocked by plugin hook"; - } else { - toolParams = structuredClone(beforeEvent.params) as Record; - } - } - - toolPlans.push({ block, blocked, blockReason, params: toolParams }); - } - - // Phase 2: Execute tools with concurrency limit (blocked tools resolve instantly) - const execResults: ToolExecResult[] = new Array(toolPlans.length); - { - let cursor = 0; - const runWorker = async (): Promise => { - while (cursor < toolPlans.length) { - const idx = cursor++; - const plan = toolPlans[idx]; - - if (plan.blocked) { - execResults[idx] = { - result: { success: false, error: plan.blockReason }, - durationMs: 0, - }; - continue; - } - - const startTime = Date.now(); - try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- registry checked at line 687 - const result = await this.toolRegistry!.execute( - { ...plan.block, arguments: plan.params }, - fullContext - ); - execResults[idx] = { result, durationMs: Date.now() - startTime }; - } catch (execErr) { - const errMsg = getErrorMessage(execErr); - const errStack = execErr instanceof Error ? execErr.stack : undefined; - execResults[idx] = { - result: { success: false, error: errMsg }, - durationMs: Date.now() - startTime, - execError: { message: errMsg, stack: errStack }, - }; - } - } - }; - const workers = Math.min(TOOL_CONCURRENCY_LIMIT, toolPlans.length); - await Promise.all(Array.from({ length: workers }, () => runWorker())); - } - - // Phase 3: Process results in original order (hooks, context, transcript) - const observingHookPromises: Promise[] = []; - for (let i = 0; i < toolPlans.length; i++) { - const plan = toolPlans[i]; - const { block } = plan; - const exec = execResults[i]; - - // Hook: tool:error (if execution threw) β€” fire-and-forget (observing) - if (exec.execError && this.hookRunner) { - const errorEvent: ToolErrorEvent = { - toolName: block.name, - params: structuredClone(plan.params), - error: exec.execError.message, - stack: exec.execError.stack, - chatId, - isGroup: effectiveIsGroup, - durationMs: exec.durationMs, - }; - observingHookPromises.push(this.hookRunner.runObservingHook("tool:error", errorEvent)); - } - - // Hook: tool:after (fires for all cases including blocks) β€” fire-and-forget (observing) - if (this.hookRunner) { - const afterEvent: AfterToolCallEvent = { - toolName: block.name, - params: structuredClone(plan.params), - result: { - success: exec.result.success, - data: exec.result.data, - error: exec.result.error, - }, - durationMs: exec.durationMs, - chatId, - isGroup: effectiveIsGroup, - ...(plan.blocked ? { blocked: true, blockReason: plan.blockReason } : {}), - }; - observingHookPromises.push(this.hookRunner.runObservingHook("tool:after", afterEvent)); - } - - const toolHint = summarizeToolParams(block.name, plan.params); - log.debug(`${block.name}: ${exec.result.success ? "βœ“" : "βœ—"} ${exec.result.error || ""}`); - iterationToolNames.push(`${block.name}${toolHint} ${exec.result.success ? "βœ“" : "βœ—"}`); - - totalToolCalls.push({ - name: block.name, - input: plan.params, - }); - - const resultText = truncateToolResult(exec.result, MAX_TOOL_RESULT_SIZE); - if (resultText.includes('"_truncated":true')) { - log.warn(`Tool result too large, truncated to ${resultText.length} chars`); - } - - if (provider === "cocoon") { - const { wrapToolResult } = await import("../cocoon/tool-adapter.js"); - const cocoonResultMsg: UserMessage = { - role: "user", - content: [ - { - type: "text", - text: wrapToolResult(resultText), - }, - ], - timestamp: Date.now(), - }; - context.messages.push(cocoonResultMsg); - appendToTranscript(session.sessionId, cocoonResultMsg); - } else { - const toolResultMsg: ToolResultMessage = { - role: "toolResult", - toolCallId: block.id, - toolName: block.name, - content: [ - { - type: "text", - text: resultText, - }, - ], - isError: !exec.result.success, - timestamp: Date.now(), - }; - context.messages.push(toolResultMsg); - appendToTranscript(session.sessionId, toolResultMsg); - } - } + // Phases 1-2: build the tool plans (tool:before hooks) and execute them. + const { toolPlans, execResults } = await this.executeToolBatch( + toolCalls, + fullContext, + chatId, + effectiveIsGroup + ); - // Await all observing hooks from Phase 3 (non-blocking during result processing) - if (observingHookPromises.length > 0) { - await Promise.allSettled(observingHookPromises); + // Phase 3: record results + observing hooks; push the returned messages in order. + const resultMessages = await this.recordToolResults(toolPlans, execResults, { + totalToolCalls, + iterationToolNames, + sessionId: session.sessionId, + chatId, + effectiveIsGroup, + provider, + }); + for (const resultMsg of resultMessages) { + context.messages.push(resultMsg); } // Mid-loop tool injection: when tool_search returns discoveries, inject schemas // into the live tools[] so the LLM can call them in the next iteration (D4). - if (this.config.tool_search?.enabled && tools) { + // Runs whenever tools exist (ToolSearch mode AND the RAG hybrid escape hatch); + // it's a no-op unless a tool_search call actually returned results. + if (tools) { let injected = 0; for (let i = 0; i < toolPlans.length; i++) { const plan = toolPlans[i]; @@ -1124,17 +1221,15 @@ export class AgentRuntime { log.info(`${iteration}/${maxIterations} β†’ ${iterationToolNames.join(", ")}`); - // Stall detection: break early if all tool calls are duplicates from prior iterations - const iterSignatures = toolPlans.map( - (p) => `${p.block.name}:${JSON.stringify(p.params, Object.keys(p.params).sort())}` - ); - const allDuplicates = - iterSignatures.length > 0 && iterSignatures.every((sig) => seenToolSignatures.has(sig)); - for (const sig of iterSignatures) seenToolSignatures.add(sig); + // Stall detection: break only after 2 *consecutive* iterations where every tool + // call (name + sorted args) was already seen β€” a single fully-repeated batch can + // be a legitimate step (e.g. re-checking), so give the model a chance to recover. + const allDuplicates = this.detectStall(toolPlans, seenToolSignatures); - if (allDuplicates) { + consecutiveStalls = allDuplicates ? consecutiveStalls + 1 : 0; + if (consecutiveStalls >= 2) { log.warn( - `Loop stall detected: all ${iterSignatures.length} tool call(s) are repeats β€” breaking early` + `Loop stall detected: ${consecutiveStalls} consecutive fully-repeated iterations β€” breaking early` ); finalResponse = response; break; @@ -1262,7 +1357,6 @@ export class AgentRuntime { // Finalize streaming draft β€” clear bubble, send final message only if no send tool was used if (wasStreamed && opts.streamToChat) { - const { isBotBridge } = await import("../telegram/bridge-guards.js"); const bridge = opts.streamToChat.bridge; if (isBotBridge(bridge)) { if (usedTelegramSendTool) { diff --git a/src/agent/tools/__tests__/registry.test.ts b/src/agent/tools/__tests__/registry.test.ts index 38c09468..c042a03a 100644 --- a/src/agent/tools/__tests__/registry.test.ts +++ b/src/agent/tools/__tests__/registry.test.ts @@ -41,13 +41,14 @@ describe("ToolRegistry", () => { registry = new ToolRegistry(); db = new Database(":memory:"); - // Create tool_config table for database tests + // Create tool_config table for database tests (post-1.19 shape). db.exec(` CREATE TABLE IF NOT EXISTS tool_config ( tool_name TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1, scope TEXT, - updated_at INTEGER NOT NULL, + scope_level TEXT NOT NULL DEFAULT 'all', + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_by INTEGER ) `); @@ -262,94 +263,60 @@ describe("ToolRegistry", () => { expect(registry.getModuleTools("non_existent")).toEqual([]); }); - it("should return tools with correct scope", () => { - registry.register(createMockTool("telegram_send"), createMockExecutor(), "always"); - registry.register(createMockTool("telegram_wallet"), createMockExecutor(), "dm-only"); - registry.register(createMockTool("telegram_kick"), createMockExecutor(), "group-only"); + it("should return tools with derived scope", () => { + registry.register(createMockTool("telegram_send"), createMockExecutor(), "open"); + registry.register(createMockTool("telegram_wallet"), createMockExecutor(), "admin-only"); + registry.register(createMockTool("telegram_kick"), createMockExecutor(), "allowlist"); const tools = registry.getModuleTools("telegram"); expect(tools).toHaveLength(3); expect(tools).toEqual([ - { name: "telegram_kick", scope: "group-only" }, + { name: "telegram_kick", scope: "allowlist" }, { name: "telegram_send", scope: "open" }, - { name: "telegram_wallet", scope: "dm-only" }, + { name: "telegram_wallet", scope: "admin-only" }, ]); }); }); - // ---------- Scope filtering ---------- + // ---------- Access-level filtering ---------- describe("getForContext()", () => { beforeEach(() => { - registry.register(createMockTool("always_tool"), createMockExecutor(), "always"); - registry.register(createMockTool("dm_only_tool"), createMockExecutor(), "dm-only"); - registry.register(createMockTool("group_only_tool"), createMockExecutor(), "group-only"); + registry.register(createMockTool("open_tool"), createMockExecutor(), "open"); registry.register(createMockTool("admin_only_tool"), createMockExecutor(), "admin-only"); + registry.register(createMockTool("disabled_tool"), createMockExecutor(), "disabled"); }); - it("should filter dm-only tools in group context", () => { - const tools = registry.getForContext(true, null); - const names = tools.map((t) => t.name); - - expect(names).toContain("always_tool"); - expect(names).toContain("group_only_tool"); - expect(names).not.toContain("dm_only_tool"); - }); - - it("should filter group-only tools in DM context", () => { - const tools = registry.getForContext(false, null); - const names = tools.map((t) => t.name); - - expect(names).toContain("always_tool"); - expect(names).toContain("dm_only_tool"); - expect(names).not.toContain("group_only_tool"); - }); - - it("should filter admin-only tools for non-admin users", () => { - const tools = registry.getForContext(false, null, undefined, false); - const names = tools.map((t) => t.name); + it("should exclude admin-only tools for non-admin users", () => { + const names = registry.getForContext(false, null, undefined, false).map((t) => t.name); + expect(names).toContain("open_tool"); expect(names).not.toContain("admin_only_tool"); + expect(names).not.toContain("disabled_tool"); }); it("should include admin-only tools for admin users", () => { - const tools = registry.getForContext(false, null, undefined, true); - const names = tools.map((t) => t.name); + const names = registry.getForContext(false, null, undefined, true).map((t) => t.name); expect(names).toContain("admin_only_tool"); + expect(names).not.toContain("disabled_tool"); }); - it("should truncate to tool limit when exceeded", () => { - const tools = registry.getForContext(false, 2); - expect(tools.length).toBe(2); - }); - - it("should not truncate when under tool limit", () => { - const tools = registry.getForContext(false, 100); - expect(tools.length).toBe(2); // always, dm-only (admin-only filtered out because isAdmin=undefined) + it("should apply the same access regardless of DM vs group context", () => { + const dm = registry + .getForContext(false, null, undefined, true) + .map((t) => t.name) + .sort(); + const group = registry + .getForContext(true, null, undefined, true) + .map((t) => t.name) + .sort(); + expect(dm).toEqual(group); }); - }); - describe("getForProvider()", () => { - beforeEach(() => { - registry.register(createMockTool("tool1"), createMockExecutor()); - registry.register(createMockTool("tool2"), createMockExecutor()); - registry.register(createMockTool("tool3"), createMockExecutor()); - }); - - it("should return all tools when limit is null", () => { - const tools = registry.getForProvider(null); - expect(tools.length).toBe(3); - }); - - it("should return all tools when under limit", () => { - const tools = registry.getForProvider(10); - expect(tools.length).toBe(3); - }); - - it("should truncate when exceeding limit", () => { - const tools = registry.getForProvider(2); - expect(tools.length).toBe(2); + it("should truncate to tool limit when exceeded", () => { + const tools = registry.getForContext(false, 1, undefined, true); + expect(tools.length).toBe(1); }); }); @@ -390,39 +357,38 @@ describe("ToolRegistry", () => { expect(result.error).toBe("Unknown tool: non_existent"); }); - it("should enforce dm-only scope in group context", async () => { - const tool = createMockTool("dm_tool"); - registry.register(tool, createMockExecutor(), "dm-only"); + it("should deny a disabled tool", async () => { + const tool = createMockTool("off_tool"); + registry.register(tool, createMockExecutor(), "disabled"); const toolCall: ToolCall = { type: "toolCall", id: "call-1", - name: "dm_tool", + name: "off_tool", arguments: { message: "test" }, }; - const groupContext = { ...mockContext, isGroup: true }; - const result = await registry.execute(toolCall, groupContext); + const result = await registry.execute(toolCall, mockContext); expect(result.success).toBe(false); - expect(result.error).toContain("not available in group chats"); + expect(result.error).toContain("currently disabled"); }); - it("should enforce group-only scope in DM context", async () => { - const tool = createMockTool("group_tool"); - registry.register(tool, createMockExecutor(), "group-only"); + it("should allow an open tool regardless of context", async () => { + const tool = createMockTool("open_tool"); + registry.register(tool, createMockExecutor({ success: true }), "open"); const toolCall: ToolCall = { type: "toolCall", id: "call-1", - name: "group_tool", + name: "open_tool", arguments: { message: "test" }, }; - const result = await registry.execute(toolCall, mockContext); + const groupContext = { ...mockContext, isGroup: true }; + const result = await registry.execute(toolCall, groupContext); - expect(result.success).toBe(false); - expect(result.error).toContain("only available in group chats"); + expect(result.success).toBe(true); }); it("should enforce admin-only scope for non-admin", async () => { @@ -523,23 +489,22 @@ describe("ToolRegistry", () => { const tool = createMockTool("test_tool"); registry.register(tool, createMockExecutor()); - // Insert directly into database + // Insert directly into database (scope_level is the source of truth). db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run("test_tool", 0, "admin-only"); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run("test_tool", 1, "admin-only", "admin"); // Load from DB (this uses the actual loadAllToolConfigs from tool-config.ts) registry.loadConfigFromDB(db); const config = registry.getToolConfig("test_tool"); - expect(config?.enabled).toBe(false); - expect(config?.scope).toBe("admin-only"); + expect(config).toEqual({ level: "admin" }); }); it("should seed missing tools with defaults", () => { const tool = createMockTool("new_tool"); - registry.register(tool, createMockExecutor(), "dm-only"); + registry.register(tool, createMockExecutor(), "admin-only"); // Load from DB - should seed the missing tool registry.loadConfigFromDB(db); @@ -549,8 +514,7 @@ describe("ToolRegistry", () => { .get("new_tool") as any; expect(row).toBeDefined(); - expect(row.enabled).toBe(1); - expect(row.scope).toBe("dm-only"); + expect(row.scope_level).toBe("admin"); }); }); @@ -562,14 +526,14 @@ describe("ToolRegistry", () => { expect(registry.isToolEnabled("test_tool")).toBe(true); }); - it("should return false when tool is disabled in config", () => { + it("should return false when tool level is off", () => { const tool = createMockTool("test_tool"); registry.register(tool, createMockExecutor()); db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run("test_tool", 0, "always"); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run("test_tool", 0, "disabled", "off"); registry.loadConfigFromDB(db); @@ -578,7 +542,7 @@ describe("ToolRegistry", () => { }); describe("setToolEnabled()", () => { - it("should enable/disable tool and persist to DB", () => { + it("should disable tool (level off) and persist to DB", () => { const tool = createMockTool("test_tool"); registry.register(tool, createMockExecutor()); registry.loadConfigFromDB(db); @@ -587,10 +551,10 @@ describe("ToolRegistry", () => { expect(result).toBe(true); const row = db - .prepare("SELECT enabled FROM tool_config WHERE tool_name = ?") + .prepare("SELECT scope_level FROM tool_config WHERE tool_name = ?") .get("test_tool") as any; - expect(row.enabled).toBe(0); + expect(row.scope_level).toBe("off"); expect(registry.isToolEnabled("test_tool")).toBe(false); }); @@ -609,40 +573,49 @@ describe("ToolRegistry", () => { }); }); - describe("updateToolScope()", () => { - it("should update tool scope and persist to DB", () => { + describe("updateToolLevel()", () => { + it("should update the access level and persist to DB", () => { const tool = createMockTool("test_tool"); - registry.register(tool, createMockExecutor(), "always"); + registry.register(tool, createMockExecutor()); registry.loadConfigFromDB(db); - const result = registry.updateToolScope("test_tool", "admin-only", 12345); + const result = registry.updateToolLevel("test_tool", "admin", 12345); expect(result).toBe(true); - const config = registry.getToolConfig("test_tool"); - expect(config?.scope).toBe("admin-only"); + expect(registry.getToolConfig("test_tool")).toEqual({ level: "admin" }); }); it("should return false for non-existent tool", () => { registry.loadConfigFromDB(db); - const result = registry.updateToolScope("non_existent", "admin-only"); + const result = registry.updateToolLevel("non_existent", "admin"); expect(result).toBe(false); }); }); + describe("updateToolScope() (legacy adapter)", () => { + it("should map a legacy scope onto a level", () => { + const tool = createMockTool("test_tool"); + registry.register(tool, createMockExecutor(), "always"); + registry.loadConfigFromDB(db); + + const result = registry.updateToolScope("test_tool", "admin-only", 12345); + expect(result).toBe(true); + + expect(registry.getToolConfig("test_tool")).toEqual({ level: "admin" }); + }); + }); + describe("getToolConfig()", () => { it("should return null for non-existent tool", () => { expect(registry.getToolConfig("non_existent")).toBeNull(); }); - it("should return default config when DB not loaded", () => { + it("should return default level when DB not loaded", () => { const tool = createMockTool("test_tool"); - registry.register(tool, createMockExecutor(), "dm-only"); + registry.register(tool, createMockExecutor(), "admin-only"); const config = registry.getToolConfig("test_tool"); - expect(config).toEqual({ - enabled: true, - scope: "dm-only", - }); + expect(config).toEqual({ level: "admin" }); }); it("should return DB config when available", () => { @@ -650,15 +623,14 @@ describe("ToolRegistry", () => { registry.register(tool, createMockExecutor(), "always"); db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run("test_tool", 0, "admin-only"); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run("test_tool", 1, "admin-only", "admin"); registry.loadConfigFromDB(db); const config = registry.getToolConfig("test_tool"); - expect(config?.enabled).toBe(false); - expect(config?.scope).toBe("admin-only"); + expect(config).toEqual({ level: "admin" }); }); }); @@ -805,7 +777,7 @@ describe("ToolRegistry", () => { { tool: createMockTool("plugin_tool"), executor: createMockExecutor(), - scope: "dm-only" as ToolScope, + scope: "admin-only" as ToolScope, }, ]; @@ -813,7 +785,7 @@ describe("ToolRegistry", () => { expect(registry.has("plugin_tool")).toBe(true); const moduleTools = registry.getModuleTools("test-plugin"); - expect(moduleTools[0].scope).toBe("dm-only"); + expect(moduleTools[0].scope).toBe("admin-only"); }); }); @@ -868,14 +840,14 @@ describe("ToolRegistry", () => { expect(modules).toContain("complex"); }); - it("should handle execution when disabled tools are filtered", async () => { + it("should handle execution when off tools are filtered", async () => { const tool = createMockTool("test_tool"); registry.register(tool, createMockExecutor()); db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run("test_tool", 0, "always"); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run("test_tool", 0, "disabled", "off"); registry.loadConfigFromDB(db); @@ -892,14 +864,14 @@ describe("ToolRegistry", () => { expect(result.error).toContain("currently disabled"); }); - it("should exclude disabled tools from getForContext", () => { + it("should exclude off tools from getForContext", () => { const tool = createMockTool("test_tool"); registry.register(tool, createMockExecutor()); db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run("test_tool", 0, "always"); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run("test_tool", 0, "disabled", "off"); registry.loadConfigFromDB(db); diff --git a/src/agent/tools/bot/index.ts b/src/agent/tools/bot/index.ts deleted file mode 100644 index 33fd56d0..00000000 --- a/src/agent/tools/bot/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Bot tools β€” inline mode integration for plugins. - */ - -import type { ToolEntry } from "../types.js"; -import { botInlineSendTool, botInlineSendExecutor } from "./inline-send.js"; - -export const tools: ToolEntry[] = [ - { - tool: botInlineSendTool, - executor: botInlineSendExecutor, - scope: "always", - requiredMode: "user", - tags: ["bot"], - }, -]; diff --git a/src/agent/tools/deals/cancel.ts b/src/agent/tools/deals/cancel.ts index a64b9818..06e47624 100644 --- a/src/agent/tools/deals/cancel.ts +++ b/src/agent/tools/deals/cancel.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import type { Deal } from "../../../deals/types.js"; +import { loadDealForActor } from "./load-deal.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -27,26 +27,13 @@ export const dealCancelExecutor: ToolExecutor = async ( try { const { dealId, reason } = params; - // Load deal from database - const deal = context.db.prepare(`SELECT * FROM deals WHERE id = ?`).get(dealId) as - | Deal - | undefined; - - if (!deal) { - return { - success: false, - error: `Deal #${dealId} not found`, - }; - } - - // User-scoping: only deal owner or admins can cancel + // Load deal + enforce owner/admin access const adminIds = context.config?.telegram.admin_ids ?? []; - if (context.senderId !== deal.user_telegram_id && !adminIds.includes(context.senderId)) { - return { - success: false, - error: `β›” You can only cancel your own deals.`, - }; + const loaded = loadDealForActor(context.db, dealId, context.senderId, adminIds, "cancel"); + if (!loaded.ok) { + return { success: false, error: loaded.error }; } + const deal = loaded.deal; // Check if deal can be cancelled const cancellableStatuses = ["proposed", "accepted"]; diff --git a/src/agent/tools/deals/load-deal.ts b/src/agent/tools/deals/load-deal.ts new file mode 100644 index 00000000..7add0302 --- /dev/null +++ b/src/agent/tools/deals/load-deal.ts @@ -0,0 +1,25 @@ +import type Database from "better-sqlite3"; +import type { Deal } from "../../../deals/types.js"; + +export type LoadDealResult = { ok: true; deal: Deal } | { ok: false; error: string }; + +/** + * Load a deal by id and enforce owner/admin access in one place. `action` fills + * the scoping error ("β›” You can only your own deals."), e.g. "cancel". + */ +export function loadDealForActor( + db: Database.Database, + dealId: string, + senderId: number, + adminIds: number[], + action: string +): LoadDealResult { + const deal = db.prepare(`SELECT * FROM deals WHERE id = ?`).get(dealId) as Deal | undefined; + if (!deal) { + return { ok: false, error: `Deal #${dealId} not found` }; + } + if (senderId !== deal.user_telegram_id && !adminIds.includes(senderId)) { + return { ok: false, error: `β›” You can only ${action} your own deals.` }; + } + return { ok: true, deal }; +} diff --git a/src/agent/tools/deals/status.ts b/src/agent/tools/deals/status.ts index ff4bed59..f4c75013 100644 --- a/src/agent/tools/deals/status.ts +++ b/src/agent/tools/deals/status.ts @@ -1,6 +1,6 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import type { Deal } from "../../../deals/types.js"; +import { loadDealForActor } from "./load-deal.js"; import { formatAsset } from "../../../deals/utils.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -26,26 +26,13 @@ export const dealStatusExecutor: ToolExecutor = async ( context ): Promise => { try { - // Load deal from database - const deal = context.db.prepare(`SELECT * FROM deals WHERE id = ?`).get(params.dealId) as - | Deal - | undefined; - - if (!deal) { - return { - success: false, - error: `Deal #${params.dealId} not found`, - }; - } - - // User-scoping: only deal owner or admins can view deal details + // Load deal + enforce owner/admin access const adminIds = context.config?.telegram.admin_ids ?? []; - if (context.senderId !== deal.user_telegram_id && !adminIds.includes(context.senderId)) { - return { - success: false, - error: `β›” You can only view your own deals.`, - }; + const loaded = loadDealForActor(context.db, params.dealId, context.senderId, adminIds, "view"); + if (!loaded.ok) { + return { success: false, error: loaded.error }; } + const deal = loaded.deal; // Format timestamps const createdAt = new Date(deal.created_at * 1000).toISOString(); diff --git a/src/agent/tools/deals/verify-payment.ts b/src/agent/tools/deals/verify-payment.ts index 84d62c0b..75ab14e2 100644 --- a/src/agent/tools/deals/verify-payment.ts +++ b/src/agent/tools/deals/verify-payment.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import type { Deal } from "../../../deals/types.js"; +import { loadDealForActor } from "./load-deal.js"; import { verifyPayment } from "../../../ton/payment-verifier.js"; import { GiftDetector } from "../../../deals/gift-detector.js"; import { getWalletAddress } from "../../../ton/wallet-service.js"; @@ -29,26 +28,19 @@ export const dealVerifyPaymentExecutor: ToolExecutor = context ): Promise => { try { - // Load deal from database - const deal = context.db.prepare(`SELECT * FROM deals WHERE id = ?`).get(params.dealId) as - | Deal - | undefined; - - if (!deal) { - return { - success: false, - error: `Deal #${params.dealId} not found`, - }; - } - - // User-scoping: only deal owner or admins can verify payment + // Load deal + enforce owner/admin access const adminIds = context.config?.telegram.admin_ids ?? []; - if (context.senderId !== deal.user_telegram_id && !adminIds.includes(context.senderId)) { - return { - success: false, - error: `β›” You can only verify payment for your own deals.`, - }; + const loaded = loadDealForActor( + context.db, + params.dealId, + context.senderId, + adminIds, + "verify payment for" + ); + if (!loaded.ok) { + return { success: false, error: loaded.error }; } + const deal = loaded.deal; // Check deal status if (deal.status !== "accepted") { @@ -165,18 +157,17 @@ export const dealVerifyPaymentExecutor: ToolExecutor = log.info(`[Deal] Checking for gift receipt for deal #${params.dealId}...`); - // Use GiftDetector to poll for new gifts - // Note: We need to pass the agent's own user ID (bot's Telegram ID) - const me = (context.bridge.getRawClient() as any).getMe(); + // Use GiftDetector to poll for new gifts β€” needs the agent's own Telegram ID + const ownUserId = context.bridge.getOwnUserId(); - if (!me) { + if (!ownUserId) { return { success: false, error: "Failed to get bot user info. Bot may not be authenticated.", }; } - const botUserId = Number(me.id); + const botUserId = Number(ownUserId); const giftDetector = new GiftDetector(); const newGifts = await giftDetector.detectNewGifts(botUserId, context); diff --git a/src/agent/tools/dedust/asset-cache.ts b/src/agent/tools/dedust/asset-cache.ts index ac3e3441..75851c6c 100644 --- a/src/agent/tools/dedust/asset-cache.ts +++ b/src/agent/tools/dedust/asset-cache.ts @@ -1,85 +1,3 @@ -import { fetchWithTimeout } from "../../../utils/fetch.js"; -import { createLogger } from "../../../utils/logger.js"; - -const log = createLogger("Tools"); - -const ASSET_LIST_URL = "https://assets.dedust.io/list.json"; -const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - -export interface DedustAsset { - type: "native" | "jetton"; - address?: string; - name: string; - symbol: string; - image?: string; - decimals: number; - sell_tax?: number; - buy_tax?: number; -} - -let cachedAssets: DedustAsset[] = []; -let cacheTimestamp = 0; - -/** - * Fetch and cache the asset list. Uses stale-while-revalidate on fetch failure. - */ -export async function getAssetList(): Promise { - if (cachedAssets.length > 0 && Date.now() - cacheTimestamp < CACHE_TTL_MS) { - return cachedAssets; - } - - try { - const response = await fetchWithTimeout(ASSET_LIST_URL); - if (!response.ok) { - throw new Error(`Failed to fetch asset list: ${response.status}`); - } - - cachedAssets = await response.json(); - cacheTimestamp = Date.now(); - return cachedAssets; - } catch (error) { - // Stale-while-revalidate: return old cache if available - if (cachedAssets.length > 0) { - log.warn({ err: error }, "Asset list fetch failed, using stale cache"); - return cachedAssets; - } - throw error; - } -} - -export async function findAsset(addressOrTon: string): Promise { - const assets = await getAssetList(); - - if (addressOrTon.toLowerCase() === "ton") { - return assets.find((a) => a.type === "native"); - } - - const normalized = addressOrTon.toLowerCase(); - return assets.find((a) => a.type === "jetton" && a.address?.toLowerCase() === normalized); -} - -export async function findAssetBySymbol(symbol: string): Promise { - const assets = await getAssetList(); - const upper = symbol.toUpperCase(); - return assets.find((a) => a.symbol.toUpperCase() === upper); -} - -export async function getDecimals(addressOrTon: string): Promise { - const asset = await findAsset(addressOrTon); - return asset?.decimals ?? 9; -} - -/** - * Convert amount to on-chain units. Uses string manipulation to avoid floating-point precision loss. - */ -export function toUnits(amount: number, decimals: number): bigint { - const str = amount.toFixed(decimals); - const [whole, frac = ""] = str.split("."); - const padded = frac.padEnd(decimals, "0").slice(0, decimals); - return BigInt(whole + padded); -} - -export function fromUnits(units: bigint, decimals: number): number { - const factor = 10 ** decimals; - return Number(units) / factor; -} +// Moved to the neutral src/ton/dedust-assets.ts to remove the sdk -> agent/tools +// layer inversion; re-exported here so existing dedust tool imports keep working. +export * from "../../../ton/dedust-assets.js"; diff --git a/src/agent/tools/dedust/constants.ts b/src/agent/tools/dedust/constants.ts index 18a0a056..2aa3ae39 100644 --- a/src/agent/tools/dedust/constants.ts +++ b/src/agent/tools/dedust/constants.ts @@ -2,23 +2,13 @@ * DeDust DEX constants */ -// Factory contract address on mainnet -export const DEDUST_FACTORY_MAINNET = "EQBfBWT7X2BHg9tXAxzhz2aKiNTU1tpt5NsiK0uSDW_YAJ67"; +// Factory address + gas live in the neutral src/ton/dex-constants.ts (shared with +// the SDK without a sdk -> agent inversion); re-exported here for the dedust tools. +export { + STONFI_PTON_ADDRESS as NATIVE_TON_ADDRESS, + DEDUST_FACTORY_MAINNET, + DEDUST_GAS, +} from "../../../ton/dex-constants.js"; // DeDust API URL export const DEDUST_API_URL = "https://api.dedust.io/v2"; - -// Gas amounts for different operations (in TON) -export const DEDUST_GAS = { - // TON to Jetton swap - SWAP_TON_TO_JETTON: "0.25", - // Jetton to any asset swap - SWAP_JETTON_TO_ANY: "0.3", - // Extra gas for multi-hop swaps - MULTIHOP_EXTRA: "0.1", - // Forward gas for jetton transfers - FORWARD_GAS: "0.2", -}; - -// Native TON address (zero address used in DeDust) -export const NATIVE_TON_ADDRESS = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; diff --git a/src/agent/tools/dedust/index.ts b/src/agent/tools/dedust/index.ts index 37cfeb0e..a2dd88a4 100644 --- a/src/agent/tools/dedust/index.ts +++ b/src/agent/tools/dedust/index.ts @@ -12,9 +12,15 @@ export { dedustPricesTool, dedustPricesExecutor }; export { dedustTokenInfoTool, dedustTokenInfoExecutor }; export const tools: ToolEntry[] = [ - { tool: dedustSwapTool, executor: dedustSwapExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: dedustQuoteTool, executor: dedustQuoteExecutor, tags: ["finance"] }, - { tool: dedustPoolsTool, executor: dedustPoolsExecutor, tags: ["finance"] }, - { tool: dedustPricesTool, executor: dedustPricesExecutor, tags: ["finance"] }, - { tool: dedustTokenInfoTool, executor: dedustTokenInfoExecutor, tags: ["finance"] }, + { + tool: dedustSwapTool, + executor: dedustSwapExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { tool: dedustQuoteTool, executor: dedustQuoteExecutor, mode: "both", tags: ["finance"] }, + { tool: dedustPoolsTool, executor: dedustPoolsExecutor, mode: "both", tags: ["finance"] }, + { tool: dedustPricesTool, executor: dedustPricesExecutor, mode: "both", tags: ["finance"] }, + { tool: dedustTokenInfoTool, executor: dedustTokenInfoExecutor, mode: "both", tags: ["finance"] }, ]; diff --git a/src/agent/tools/dedust/pool.ts b/src/agent/tools/dedust/pool.ts new file mode 100644 index 00000000..55a83122 --- /dev/null +++ b/src/agent/tools/dedust/pool.ts @@ -0,0 +1,37 @@ +import { PoolType, ReadinessStatus } from "@dedust/sdk"; +import type { Factory, Asset, Pool } from "@dedust/sdk"; +import type { OpenedContract, TonClient } from "@ton/ton"; + +export interface DedustPoolMatch { + pool: OpenedContract; + poolType: "volatile" | "stable"; +} + +/** + * Find the best READY DeDust pool for an asset pair. Tries `preferred` first + * (default "volatile"), then falls back to the other type, else null. Single + * definition so quote and swap agree on selection (swap previously tried only the + * requested type with no fallback, so quote could recommend a pool swap rejected). + */ +export async function findDedustPool( + tonClient: TonClient, + factory: OpenedContract, + fromAsset: Asset, + toAsset: Asset, + preferred: "volatile" | "stable" = "volatile" +): Promise { + const order: Array<"volatile" | "stable"> = + preferred === "stable" ? ["stable", "volatile"] : ["volatile", "stable"]; + try { + for (const type of order) { + const poolTypeEnum = type === "stable" ? PoolType.STABLE : PoolType.VOLATILE; + const pool = tonClient.open(await factory.getPool(poolTypeEnum, [fromAsset, toAsset])); + if ((await pool.getReadinessStatus()) === ReadinessStatus.READY) { + return { pool, poolType: type }; + } + } + return null; + } catch { + return null; + } +} diff --git a/src/agent/tools/dedust/swap.ts b/src/agent/tools/dedust/swap.ts index 9b1d1a4c..7e4ed96e 100644 --- a/src/agent/tools/dedust/swap.ts +++ b/src/agent/tools/dedust/swap.ts @@ -2,16 +2,18 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import { loadWallet, - getKeyPair, getCachedTonClient, invalidateTonClientCache, } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, fromNano } from "@ton/ton"; +import { toNano, fromNano } from "@ton/ton"; import { Address } from "@ton/core"; -import { Factory, Asset, PoolType, ReadinessStatus, JettonRoot, VaultJetton } from "@dedust/sdk"; +import { Factory, Asset, ReadinessStatus, JettonRoot, VaultJetton } from "@dedust/sdk"; import { DEDUST_FACTORY_MAINNET, DEDUST_GAS, NATIVE_TON_ADDRESS } from "./constants.js"; +import { findDedustPool } from "./pool.js"; import { getDecimals, toUnits, fromUnits } from "./asset-cache.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { walletTxLt, confirmWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; import { getErrorMessage, isHttpError } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -109,17 +111,20 @@ export const dedustSwapExecutor: ToolExecutor = async ( const fromAssetObj = isTonInput ? Asset.native() : Asset.jetton(Address.parse(fromAssetAddr)); const toAssetObj = isTonOutput ? Asset.native() : Asset.jetton(Address.parse(toAssetAddr)); - const poolTypeEnum = pool_type === "stable" ? PoolType.STABLE : PoolType.VOLATILE; - - const pool = tonClient.open(await factory.getPool(poolTypeEnum, [fromAssetObj, toAssetObj])); - - const readinessStatus = await pool.getReadinessStatus(); - if (readinessStatus !== ReadinessStatus.READY) { + const poolMatch = await findDedustPool( + tonClient, + factory, + fromAssetObj, + toAssetObj, + pool_type === "stable" ? "stable" : "volatile" + ); + if (!poolMatch) { return { success: false, - error: `Pool not ready. Status: ${readinessStatus}. Try the other pool type (${pool_type === "volatile" ? "stable" : "volatile"}) or check if the pool exists.`, + error: "No DeDust pool ready for this pair (tried volatile and stable).", }; } + const pool = poolMatch.pool; // Resolve correct decimals using normalized addresses (friendly format) const fromDecimals = await getDecimals(isTonInput ? "ton" : fromAssetAddr); @@ -139,16 +144,14 @@ export const dedustSwapExecutor: ToolExecutor = async ( // Prepare wallet and sender β€” wrapped in tx lock to prevent seqno races // with concurrent StonFi or other DeDust swaps return withTxLock(async () => { - const keyPair = await getKeyPair(); - if (!keyPair) { + const opened = await openWallet(tonClient); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const walletContract = tonClient.open(wallet); + const { keyPair, contract: walletContract } = opened; const sender = walletContract.sender(keyPair.secretKey); + const sinceLt = await walletTxLt(tonClient, walletContract.address); + let broadcastError: unknown; if (isTonInput) { // Check balance for TON swaps @@ -174,12 +177,16 @@ export const dedustSwapExecutor: ToolExecutor = async ( } // Use SDK's sendSwap method - await tonVault.sendSwap(sender, { - poolAddress: pool.address, - amount: amountIn, - limit: minAmountOut, - gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON), - }); + try { + await tonVault.sendSwap(sender, { + poolAddress: pool.address, + amount: amountIn, + limit: minAmountOut, + gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON), + }); + } catch (error) { + broadcastError = error; + } } else { // Jetton -> TON/Jetton swap (use normalized address) const jettonAddress = Address.parse(fromAssetAddr); @@ -206,13 +213,26 @@ export const dedustSwapExecutor: ToolExecutor = async ( }); // Send jetton transfer with swap payload - await jettonWallet.sendTransfer(sender, toNano(DEDUST_GAS.SWAP_JETTON_TO_ANY), { - destination: jettonVault.address, - amount: amountIn, - responseAddress: Address.parse(walletData.address), - forwardAmount: toNano(DEDUST_GAS.FORWARD_GAS), - forwardPayload: swapPayload, - }); + try { + await jettonWallet.sendTransfer(sender, toNano(DEDUST_GAS.SWAP_JETTON_TO_ANY), { + destination: jettonVault.address, + amount: amountIn, + responseAddress: Address.parse(walletData.address), + forwardAmount: toNano(DEDUST_GAS.FORWARD_GAS), + forwardPayload: swapPayload, + }); + } catch (error) { + broadcastError = error; + } + } + + const confirmed = await confirmWalletTx(tonClient, walletContract.address, sinceLt); + if (!confirmed) { + if (broadcastError) throw broadcastError; + return { + success: false, + error: "Swap transaction failed or could not be confirmed on-chain.", + }; } // Calculate expected output for display using correct decimals @@ -236,7 +256,8 @@ export const dedustSwapExecutor: ToolExecutor = async ( tradeFee: feeAmount.toFixed(6), poolType: pool_type, poolAddress: pool.address.toString(), - message: `Swapped ${amount} ${fromSymbol} for ~${expectedOutput.toFixed(4)} ${toSymbol} on DeDust\n Minimum output: ${minOutput.toFixed(4)}\n Slippage: ${(slippage * 100).toFixed(2)}%\n Transaction sent (check balance in ~30 seconds)`, + txHash: confirmed.hash, + message: `Swapped ${amount} ${fromSymbol} for ~${expectedOutput.toFixed(4)} ${toSymbol} on DeDust β€” confirmed on-chain\n Minimum output: ${minOutput.toFixed(4)}\n Slippage: ${(slippage * 100).toFixed(2)}%\n tx ${confirmed.hash}\n ${tonExplorerTxUrl(confirmed.hash)}`, }, }; }); // withTxLock diff --git a/src/agent/tools/dns/bid.ts b/src/agent/tools/dns/bid.ts index c587294c..42bcfde5 100644 --- a/src/agent/tools/dns/bid.ts +++ b/src/agent/tools/dns/bid.ts @@ -1,12 +1,14 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, fromNano, internal } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, fromNano, internal } from "@ton/ton"; +import { Address } from "@ton/core"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; const log = createLogger("Tools"); interface DnsBidParams { @@ -104,27 +106,17 @@ export const dnsBidExecutor: ToolExecutor = async ( }; } - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } + const { keyPair, contract } = opened; - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const contract = client.open(wallet); - - await withTxLock(async () => { - const seqno = await contract.getSeqno(); - + const sent = await withTxLock(async () => { // Send bid (just TON, no body needed for bids - op=0 is implicit) - await contract.sendTransfer({ - seqno, + return sendWalletTx(client, contract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(nftAddress), @@ -136,6 +128,10 @@ export const dnsBidExecutor: ToolExecutor = async ( }); }); + if (!sent) { + return { success: false, error: "Bid failed or could not be confirmed on-chain." }; + } + return { success: true, data: { @@ -143,7 +139,8 @@ export const dnsBidExecutor: ToolExecutor = async ( amount: `${amount} TON`, nftAddress, from: walletData.address, - message: `Bid placed on ${fullDomain}: ${amount} TON\n From: ${walletData.address}\n NFT: ${nftAddress}\n Transaction sent (check status in a few seconds)`, + txHash: sent.hash, + message: `Bid placed on ${fullDomain}: ${amount} TON β€” confirmed on-chain\n From: ${walletData.address}\n NFT: ${nftAddress}\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; } catch (error) { diff --git a/src/agent/tools/dns/index.ts b/src/agent/tools/dns/index.ts index 5bd92c1d..f2a8b176 100644 --- a/src/agent/tools/dns/index.ts +++ b/src/agent/tools/dns/index.ts @@ -22,13 +22,38 @@ export const tools: ToolEntry[] = [ tool: dnsStartAuctionTool, executor: dnsStartAuctionExecutor, scope: "dm-only", + mode: "both", tags: ["automation"], }, - { tool: dnsBidTool, executor: dnsBidExecutor, scope: "dm-only", tags: ["automation"] }, - { tool: dnsLinkTool, executor: dnsLinkExecutor, scope: "dm-only", tags: ["automation"] }, - { tool: dnsUnlinkTool, executor: dnsUnlinkExecutor, scope: "dm-only", tags: ["automation"] }, - { tool: dnsSetSiteTool, executor: dnsSetSiteExecutor, scope: "dm-only", tags: ["automation"] }, - { tool: dnsCheckTool, executor: dnsCheckExecutor, tags: ["automation"] }, - { tool: dnsAuctionsTool, executor: dnsAuctionsExecutor, tags: ["automation"] }, - { tool: dnsResolveTool, executor: dnsResolveExecutor, tags: ["automation"] }, + { + tool: dnsBidTool, + executor: dnsBidExecutor, + scope: "dm-only", + mode: "both", + tags: ["automation"], + }, + { + tool: dnsLinkTool, + executor: dnsLinkExecutor, + scope: "dm-only", + mode: "both", + tags: ["automation"], + }, + { + tool: dnsUnlinkTool, + executor: dnsUnlinkExecutor, + scope: "dm-only", + mode: "both", + tags: ["automation"], + }, + { + tool: dnsSetSiteTool, + executor: dnsSetSiteExecutor, + scope: "dm-only", + mode: "both", + tags: ["automation"], + }, + { tool: dnsCheckTool, executor: dnsCheckExecutor, mode: "both", tags: ["automation"] }, + { tool: dnsAuctionsTool, executor: dnsAuctionsExecutor, mode: "both", tags: ["automation"] }, + { tool: dnsResolveTool, executor: dnsResolveExecutor, mode: "both", tags: ["automation"] }, ]; diff --git a/src/agent/tools/dns/link.ts b/src/agent/tools/dns/link.ts index f63ec28c..202db64f 100644 --- a/src/agent/tools/dns/link.ts +++ b/src/agent/tools/dns/link.ts @@ -1,12 +1,14 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, internal, beginCell } from "@ton/ton"; +import { Address } from "@ton/core"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; const log = createLogger("Tools"); @@ -119,22 +121,14 @@ export const dnsLinkExecutor: ToolExecutor = async ( }; } - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } + const { keyPair, contract } = opened; - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const contract = client.open(wallet); - - await withTxLock(async () => { - const seqno = await contract.getSeqno(); - + const sent = await withTxLock(async () => { // Build wallet record value cell: dns_smc_address#9fd3 + address + flags const valueCell = beginCell() .storeUint(DNS_SMC_ADDRESS_PREFIX, 16) // #9fd3 @@ -150,11 +144,8 @@ export const dnsLinkExecutor: ToolExecutor = async ( .storeRef(valueCell) // value cell reference .endCell(); - // Send transaction to NFT address - await contract.sendTransfer({ - seqno, + return sendWalletTx(client, contract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(nftAddress), @@ -166,6 +157,10 @@ export const dnsLinkExecutor: ToolExecutor = async ( }); }); + if (!sent) { + return { success: false, error: "DNS update failed or could not be confirmed on-chain." }; + } + return { success: true, data: { @@ -173,7 +168,8 @@ export const dnsLinkExecutor: ToolExecutor = async ( linkedWallet: targetAddress, nftAddress, from: walletData.address, - message: `Linked ${fullDomain} β†’ ${targetAddress}\n NFT: ${nftAddress}\n Transaction sent (changes apply in a few seconds)`, + txHash: sent.hash, + message: `Linked ${fullDomain} β†’ ${targetAddress} β€” confirmed on-chain\n NFT: ${nftAddress}\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; } catch (error) { diff --git a/src/agent/tools/dns/set-site.ts b/src/agent/tools/dns/set-site.ts index 850fad76..dd393d7b 100644 --- a/src/agent/tools/dns/set-site.ts +++ b/src/agent/tools/dns/set-site.ts @@ -1,12 +1,14 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, internal, beginCell } from "@ton/ton"; +import { Address } from "@ton/core"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; const log = createLogger("Tools"); @@ -115,24 +117,16 @@ export const dnsSetSiteExecutor: ToolExecutor = async ( }; } - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } - - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const contract = client.open(wallet); + const { keyPair, contract } = opened; const adnlBuffer = Buffer.from(adnl_address, "hex"); - await withTxLock(async () => { - const seqno = await contract.getSeqno(); - + const sent = await withTxLock(async () => { // Build ADNL record value cell: dns_adnl_address#ad01 + adnl_addr:bits256 + flags:uint8 const valueCell = beginCell() .storeUint(DNS_ADNL_ADDRESS_PREFIX, 16) // #ad01 @@ -148,10 +142,8 @@ export const dnsSetSiteExecutor: ToolExecutor = async ( .storeRef(valueCell) // value cell reference .endCell(); - await contract.sendTransfer({ - seqno, + return sendWalletTx(client, contract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(nftAddress), @@ -163,6 +155,10 @@ export const dnsSetSiteExecutor: ToolExecutor = async ( }); }); + if (!sent) { + return { success: false, error: "DNS update failed or could not be confirmed on-chain." }; + } + return { success: true, data: { @@ -170,7 +166,8 @@ export const dnsSetSiteExecutor: ToolExecutor = async ( adnlAddress: adnl_address, nftAddress, from: walletData.address, - message: `Set TON Site record for ${fullDomain} β†’ ADNL ${adnl_address}\n NFT: ${nftAddress}\n Transaction sent (changes apply in a few seconds)`, + txHash: sent.hash, + message: `Set TON Site record for ${fullDomain} β†’ ADNL ${adnl_address} β€” confirmed on-chain\n NFT: ${nftAddress}\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; } catch (error) { diff --git a/src/agent/tools/dns/start-auction.ts b/src/agent/tools/dns/start-auction.ts index 070b07df..af379a2b 100644 --- a/src/agent/tools/dns/start-auction.ts +++ b/src/agent/tools/dns/start-auction.ts @@ -1,11 +1,13 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, internal, beginCell } from "@ton/ton"; +import { Address } from "@ton/core"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; const log = createLogger("Tools"); @@ -62,33 +64,22 @@ export const dnsStartAuctionExecutor: ToolExecutor = asyn }; } - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } + const { keyPair, contract } = opened; - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const contract = client.open(wallet); - - await withTxLock(async () => { - const seqno = await contract.getSeqno(); - + const sent = await withTxLock(async () => { // Build message body: op=0, domain as UTF-8 string const body = beginCell() .storeUint(0, 32) // op = 0 .storeStringTail(domain) // domain without .ton .endCell(); - // Send transaction to DNS collection - await contract.sendTransfer({ - seqno, + return sendWalletTx(client, contract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(DNS_COLLECTION), @@ -100,6 +91,10 @@ export const dnsStartAuctionExecutor: ToolExecutor = asyn }); }); + if (!sent) { + return { success: false, error: "Auction start failed or could not be confirmed on-chain." }; + } + return { success: true, data: { @@ -107,7 +102,8 @@ export const dnsStartAuctionExecutor: ToolExecutor = asyn amount: `${amount} TON`, collection: DNS_COLLECTION, from: walletData.address, - message: `Auction started for ${domain}.ton with ${amount} TON\n From: ${walletData.address}\n Collection: ${DNS_COLLECTION}\n Transaction sent (check status in a few seconds)`, + txHash: sent.hash, + message: `Auction started for ${domain}.ton with ${amount} TON β€” confirmed on-chain\n From: ${walletData.address}\n Collection: ${DNS_COLLECTION}\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; } catch (error) { diff --git a/src/agent/tools/dns/unlink.ts b/src/agent/tools/dns/unlink.ts index 15e140fe..c1bf1685 100644 --- a/src/agent/tools/dns/unlink.ts +++ b/src/agent/tools/dns/unlink.ts @@ -1,12 +1,14 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, internal, beginCell } from "@ton/ton"; +import { Address } from "@ton/core"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; const log = createLogger("Tools"); @@ -96,22 +98,14 @@ export const dnsUnlinkExecutor: ToolExecutor = async ( }; } - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } + const { keyPair, contract } = opened; - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const contract = client.open(wallet); - - await withTxLock(async () => { - const seqno = await contract.getSeqno(); - + const sent = await withTxLock(async () => { // Build change_dns_record message body WITHOUT value cell (triggers deletion) // Contract checks: if (slice_refs() > 0) set record, else delete record const body = beginCell() @@ -121,11 +115,8 @@ export const dnsUnlinkExecutor: ToolExecutor = async ( // NO storeRef() - absence of value cell triggers deletion .endCell(); - // Send transaction to NFT address - await contract.sendTransfer({ - seqno, + return sendWalletTx(client, contract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(nftAddress), @@ -137,13 +128,18 @@ export const dnsUnlinkExecutor: ToolExecutor = async ( }); }); + if (!sent) { + return { success: false, error: "DNS update failed or could not be confirmed on-chain." }; + } + return { success: true, data: { domain: fullDomain, nftAddress, from: walletData.address, - message: `Unlinked wallet from ${fullDomain}\n NFT: ${nftAddress}\n Transaction sent (changes apply in a few seconds)`, + txHash: sent.hash, + message: `Unlinked wallet from ${fullDomain} β€” confirmed on-chain\n NFT: ${nftAddress}\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; } catch (error) { diff --git a/src/agent/tools/exec/audited-run.ts b/src/agent/tools/exec/audited-run.ts new file mode 100644 index 00000000..c7a58c96 --- /dev/null +++ b/src/agent/tools/exec/audited-run.ts @@ -0,0 +1,64 @@ +import type Database from "better-sqlite3"; +import type { ExecConfig } from "../../../config/schema.js"; +import type { ExecAuditEntry, ExecResult } from "./types.js"; +import { runCommand } from "./runner.js"; +import { insertAuditEntry, updateAuditEntry } from "./audit.js"; + +export type ExecAuditStatus = Exclude; + +/** + * Map a finished command to an audit status. Timeout wins over a signal kill (a + * timeout kills via SIGTERM/SIGKILL); a bare signal means the process was killed + * externally β€” previously mislabelled 'failed' although types.ts declared 'killed'. + */ +export function mapExecStatus(result: ExecResult): ExecAuditStatus { + if (result.timedOut) return "timeout"; + if (result.signal) return "killed"; + return result.exitCode === 0 ? "success" : "failed"; +} + +/** + * Run a command with the shared audit lifecycle: insert a 'running' audit row + * (when enabled), run the command, map the status, then update the row. Returns + * the raw result and the mapped status so each tool builds its own data payload. + */ +export async function runAudited( + db: Database.Database, + execConfig: ExecConfig, + args: { tool: ExecAuditEntry["tool"]; command: string; senderId: number } +): Promise<{ result: ExecResult; status: ExecAuditStatus }> { + const { timeout, max_output } = execConfig.limits; + + let auditId: number | undefined; + if (execConfig.audit.log_commands) { + auditId = insertAuditEntry(db, { + userId: args.senderId, + username: undefined, + tool: args.tool, + command: args.command, + status: "running", + truncated: false, + }); + } + + const result = await runCommand(args.command, { + timeout: timeout * 1000, + maxOutput: max_output, + }); + + const status = mapExecStatus(result); + + if (auditId !== undefined) { + updateAuditEntry(db, auditId, { + status, + exitCode: result.exitCode ?? undefined, + signal: result.signal ?? undefined, + duration: result.duration, + stdout: result.stdout, + stderr: result.stderr, + truncated: result.truncated, + }); + } + + return { result, status }; +} diff --git a/src/agent/tools/exec/install.ts b/src/agent/tools/exec/install.ts index 1d644ec8..c5f9b548 100644 --- a/src/agent/tools/exec/install.ts +++ b/src/agent/tools/exec/install.ts @@ -1,8 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import type { ExecConfig } from "../../../config/schema.js"; -import { runCommand } from "./runner.js"; -import { insertAuditEntry, updateAuditEntry } from "./audit.js"; +import { runAudited } from "./audited-run.js"; import type Database from "better-sqlite3"; interface ExecInstallParams { @@ -38,7 +37,7 @@ export function createExecInstallExecutor( ): ToolExecutor { return async (params, context): Promise => { const { manager, packages } = params; - const { timeout, max_output } = execConfig.limits; + const { timeout } = execConfig.limits; const buildCommand = INSTALL_COMMANDS[manager]; if (!buildCommand) { @@ -49,38 +48,12 @@ export function createExecInstallExecutor( } const command = buildCommand(packages); - - let auditId: number | undefined; - if (execConfig.audit.log_commands) { - auditId = insertAuditEntry(db, { - userId: context.senderId, - username: undefined, - tool: "exec_install", - command, - status: "running", - truncated: false, - }); - } - - const result = await runCommand(command, { - timeout: timeout * 1000, - maxOutput: max_output, + const { result } = await runAudited(db, execConfig, { + tool: "exec_install", + command, + senderId: context.senderId, }); - const status = result.timedOut ? "timeout" : result.exitCode === 0 ? "success" : "failed"; - - if (auditId !== undefined) { - updateAuditEntry(db, auditId, { - status, - exitCode: result.exitCode ?? undefined, - signal: result.signal ?? undefined, - duration: result.duration, - stdout: result.stdout, - stderr: result.stderr, - truncated: result.truncated, - }); - } - return { success: result.exitCode === 0 && !result.timedOut, data: { diff --git a/src/agent/tools/exec/run.ts b/src/agent/tools/exec/run.ts index 387d42b3..312e4416 100644 --- a/src/agent/tools/exec/run.ts +++ b/src/agent/tools/exec/run.ts @@ -1,8 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import type { ExecConfig } from "../../../config/schema.js"; -import { runCommand } from "./runner.js"; -import { insertAuditEntry, updateAuditEntry } from "./audit.js"; +import { runAudited } from "./audited-run.js"; import type Database from "better-sqlite3"; interface ExecRunParams { @@ -26,38 +25,12 @@ export function createExecRunExecutor( ): ToolExecutor { return async (params, context): Promise => { const { command } = params; - const { timeout, max_output } = execConfig.limits; - - let auditId: number | undefined; - if (execConfig.audit.log_commands) { - auditId = insertAuditEntry(db, { - userId: context.senderId, - username: undefined, - tool: "exec_run", - command, - status: "running", - truncated: false, - }); - } - - const result = await runCommand(command, { - timeout: timeout * 1000, - maxOutput: max_output, + const { result } = await runAudited(db, execConfig, { + tool: "exec_run", + command, + senderId: context.senderId, }); - - const status = result.timedOut ? "timeout" : result.exitCode === 0 ? "success" : "failed"; - - if (auditId !== undefined) { - updateAuditEntry(db, auditId, { - status, - exitCode: result.exitCode ?? undefined, - signal: result.signal ?? undefined, - duration: result.duration, - stdout: result.stdout, - stderr: result.stderr, - truncated: result.truncated, - }); - } + const { timeout } = execConfig.limits; return { success: result.exitCode === 0 && !result.timedOut, diff --git a/src/agent/tools/exec/service.ts b/src/agent/tools/exec/service.ts index d257dea0..c861e509 100644 --- a/src/agent/tools/exec/service.ts +++ b/src/agent/tools/exec/service.ts @@ -1,8 +1,7 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import type { ExecConfig } from "../../../config/schema.js"; -import { runCommand } from "./runner.js"; -import { insertAuditEntry, updateAuditEntry } from "./audit.js"; +import { runAudited } from "./audited-run.js"; import type Database from "better-sqlite3"; interface ExecServiceParams { @@ -38,39 +37,13 @@ export function createExecServiceExecutor( ): ToolExecutor { return async (params, context): Promise => { const { action, name } = params; - const { timeout, max_output } = execConfig.limits; const command = `systemctl ${action} ${name}`; - - let auditId: number | undefined; - if (execConfig.audit.log_commands) { - auditId = insertAuditEntry(db, { - userId: context.senderId, - username: undefined, - tool: "exec_service", - command, - status: "running", - truncated: false, - }); - } - - const result = await runCommand(command, { - timeout: timeout * 1000, - maxOutput: max_output, + const { result } = await runAudited(db, execConfig, { + tool: "exec_service", + command, + senderId: context.senderId, }); - - const status = result.timedOut ? "timeout" : result.exitCode === 0 ? "success" : "failed"; - - if (auditId !== undefined) { - updateAuditEntry(db, auditId, { - status, - exitCode: result.exitCode ?? undefined, - signal: result.signal ?? undefined, - duration: result.duration, - stdout: result.stdout, - stderr: result.stderr, - truncated: result.truncated, - }); - } + const { timeout } = execConfig.limits; return { success: result.exitCode === 0 && !result.timedOut, diff --git a/src/agent/tools/journal/format.ts b/src/agent/tools/journal/format.ts new file mode 100644 index 00000000..6bcc352a --- /dev/null +++ b/src/agent/tools/journal/format.ts @@ -0,0 +1,31 @@ +import type { JournalEntry, JournalOutcome } from "../../../memory/journal-store.js"; + +/** Emoji for a journal outcome (profit/loss/pending/cancelled/other). */ +export function outcomeEmoji(outcome?: JournalOutcome): string { + return outcome === "profit" + ? "βœ…" + : outcome === "loss" + ? "❌" + : outcome === "pending" + ? "⏳" + : outcome === "cancelled" + ? "🚫" + : "βž–"; +} + +/** " β†’ " for an entry, or null when no assets are set. */ +export function formatAssetFlow( + entry: Pick +): string | null { + if (!entry.asset_from && !entry.asset_to) return null; + const fromStr = entry.asset_from + ? `${entry.amount_from?.toFixed(4) ?? "?"} ${entry.asset_from}` + : "β€”"; + const toStr = entry.asset_to ? `${entry.amount_to?.toFixed(4) ?? "?"} ${entry.asset_to}` : "β€”"; + return `${fromStr} β†’ ${toStr}`; +} + +/** Short display form of a tx hash: first 16 chars + ellipsis, in backticks. */ +export function formatTxHash(hash: string): string { + return `\`${hash.slice(0, 16)}...\``; +} diff --git a/src/agent/tools/journal/index.ts b/src/agent/tools/journal/index.ts index b02d6f65..e7b92cc0 100644 --- a/src/agent/tools/journal/index.ts +++ b/src/agent/tools/journal/index.ts @@ -18,9 +18,21 @@ export { journalQueryTool, journalQueryExecutor }; export { journalUpdateTool, journalUpdateExecutor }; export const tools: ToolEntry[] = [ - { tool: journalLogTool, executor: journalLogExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: journalUpdateTool, executor: journalUpdateExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: journalQueryTool, executor: journalQueryExecutor, tags: ["finance"] }, + { + tool: journalLogTool, + executor: journalLogExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { + tool: journalUpdateTool, + executor: journalUpdateExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { tool: journalQueryTool, executor: journalQueryExecutor, mode: "both", tags: ["finance"] }, ]; // Re-export types from journal-store diff --git a/src/agent/tools/journal/log.ts b/src/agent/tools/journal/log.ts index 8f23713f..fdcf0ee0 100644 --- a/src/agent/tools/journal/log.ts +++ b/src/agent/tools/journal/log.ts @@ -6,10 +6,13 @@ import { Type } from "@sinclair/typebox"; import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; +import type { JournalType, JournalOutcome } from "../../../memory/journal-store.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { withToolErrors } from "../wrap.js"; +import { formatAssetFlow, formatTxHash } from "./format.js"; interface JournalLogParams { - type: "trade" | "gift" | "middleman" | "kol"; + type: JournalType; action: string; asset_from?: string; asset_to?: string; @@ -19,7 +22,7 @@ interface JournalLogParams { counterparty?: string; platform?: string; reasoning: string; - outcome?: "pending" | "profit" | "loss" | "neutral" | "cancelled"; + outcome?: JournalOutcome; tx_hash?: string; } @@ -62,7 +65,10 @@ export const journalLogTool: Tool = { Type.Literal("neutral"), Type.Literal("cancelled"), ], - { description: "Outcome status (default: 'pending')" } + { + description: + "P&L result (default: 'pending'). Must be EXACTLY one of: 'pending' (still open), 'profit', 'loss', 'neutral' (break-even), 'cancelled'. This is the profit/loss outcome, NOT a completion status β€” do not use 'closed'/'completed'/'success'/'done'. Leave as 'pending' for in-progress operations and close them later with journal_update.", + } ) ), tx_hash: Type.Optional( @@ -71,73 +77,69 @@ export const journalLogTool: Tool = { }), }; -export const journalLogExecutor: ToolExecutor = async ( - params, - context -): Promise => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); +export const journalLogExecutor: ToolExecutor = withToolErrors( + async (params, context) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); - const entry = store.addEntry({ - type: params.type, - action: params.action, - asset_from: params.asset_from, - asset_to: params.asset_to, - amount_from: params.amount_from, - amount_to: params.amount_to, - price_ton: params.price_ton, - counterparty: params.counterparty, - platform: params.platform, - reasoning: params.reasoning, - outcome: params.outcome ?? "pending", - tx_hash: params.tx_hash, - tool_used: "journal_log", - chat_id: context.chatId?.toString(), - user_id: context.senderId, - }); + const entry = store.addEntry({ + type: params.type, + action: params.action, + asset_from: params.asset_from, + asset_to: params.asset_to, + amount_from: params.amount_from, + amount_to: params.amount_to, + price_ton: params.price_ton, + counterparty: params.counterparty, + platform: params.platform, + reasoning: params.reasoning, + outcome: params.outcome ?? "pending", + tx_hash: params.tx_hash, + tool_used: "journal_log", + chat_id: context.chatId?.toString(), + user_id: context.senderId, + }); - // Format output - const lines: string[] = [ - `πŸ“ Journal Entry #${entry.id} logged`, - ``, - `**Type**: ${entry.type}`, - `**Action**: ${entry.action}`, - ]; + // Format output + const lines: string[] = [ + `πŸ“ Journal Entry #${entry.id} logged`, + ``, + `**Type**: ${entry.type}`, + `**Action**: ${entry.action}`, + ]; - if (entry.asset_from || entry.asset_to) { - const fromStr = entry.asset_from - ? `${entry.amount_from?.toFixed(4) ?? "?"} ${entry.asset_from}` - : "β€”"; - const toStr = entry.asset_to ? `${entry.amount_to?.toFixed(4) ?? "?"} ${entry.asset_to}` : "β€”"; - lines.push(`**Assets**: ${fromStr} β†’ ${toStr}`); - } + const assetFlow = formatAssetFlow(entry); + if (assetFlow) { + lines.push(`**Assets**: ${assetFlow}`); + } - if (entry.price_ton) { - lines.push(`**Price**: ${entry.price_ton} TON`); - } + if (entry.price_ton) { + lines.push(`**Price**: ${entry.price_ton} TON`); + } - if (entry.counterparty) { - lines.push(`**Counterparty**: ${entry.counterparty}`); - } + if (entry.counterparty) { + lines.push(`**Counterparty**: ${entry.counterparty}`); + } - if (entry.platform) { - lines.push(`**Platform**: ${entry.platform}`); - } + if (entry.platform) { + lines.push(`**Platform**: ${entry.platform}`); + } - lines.push(`**Outcome**: ${entry.outcome}`); - lines.push(`**Reasoning**: ${entry.reasoning}`); + lines.push(`**Outcome**: ${entry.outcome}`); + lines.push(`**Reasoning**: ${entry.reasoning}`); - if (entry.tx_hash) { - lines.push(`**TX**: \`${entry.tx_hash.slice(0, 16)}...\``); - } + if (entry.tx_hash) { + lines.push(`**TX**: ${formatTxHash(entry.tx_hash)}`); + } - lines.push(``, `_Logged at ${new Date(entry.created_at * 1000).toISOString()}_`); + lines.push(``, `_Logged at ${new Date(entry.created_at * 1000).toISOString()}_`); - return { - success: true, - data: { - entry, - message: lines.join("\n"), - }, - }; -}; + return { + success: true, + data: { + entry, + message: lines.join("\n"), + }, + }; + } +); diff --git a/src/agent/tools/journal/query.ts b/src/agent/tools/journal/query.ts index a4f697dc..3eb119d9 100644 --- a/src/agent/tools/journal/query.ts +++ b/src/agent/tools/journal/query.ts @@ -6,12 +6,15 @@ import { Type } from "@sinclair/typebox"; import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; +import type { JournalType, JournalOutcome } from "../../../memory/journal-store.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { withToolErrors } from "../wrap.js"; +import { outcomeEmoji, formatAssetFlow, formatTxHash } from "./format.js"; interface JournalQueryParams { - type?: "trade" | "gift" | "middleman" | "kol"; + type?: JournalType; asset?: string; - outcome?: "pending" | "profit" | "loss" | "neutral" | "cancelled"; + outcome?: JournalOutcome; days?: number; limit?: number; } @@ -57,119 +60,104 @@ export const journalQueryTool: Tool = { }), }; -export const journalQueryExecutor: ToolExecutor = async ( - params -): Promise => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); - - const entries = store.queryEntries({ - type: params.type, - asset: params.asset, - outcome: params.outcome, - days: params.days, - limit: params.limit ?? 20, - }); - - if (entries.length === 0) { - return { - success: true, - data: { - entries: [], - message: "No entries found matching your filters.", - }, - }; - } +export const journalQueryExecutor: ToolExecutor = + withToolErrors(async (params) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); - // Calculate P&L summary if filtering by outcome or type - let summary = ""; - if (params.type || params.days) { - const pnl = store.calculatePnL({ + const entries = store.queryEntries({ type: params.type, + asset: params.asset, + outcome: params.outcome, days: params.days, + limit: params.limit ?? 20, }); - if (pnl.trades_count > 0) { - summary = [ - `**πŸ“Š Performance Summary**`, - ``, - `Trades: ${pnl.trades_count} (${pnl.profit_count} wins, ${pnl.loss_count} losses)`, - `Win Rate: ${pnl.win_rate.toFixed(1)}%`, - `Total P&L: ${pnl.total_pnl >= 0 ? "+" : ""}${pnl.total_pnl.toFixed(2)} TON`, - ``, - `---`, - ``, - ].join("\n"); - } - } - - // Format entries - const lines: string[] = [`πŸ“– Journal Entries (${entries.length} results)`, ``]; - - if (summary) { - lines.push(summary); - } - - for (const entry of entries) { - const date = new Date(entry.timestamp * 1000).toISOString().split("T")[0]; - const outcomeEmoji = - entry.outcome === "profit" - ? "βœ…" - : entry.outcome === "loss" - ? "❌" - : entry.outcome === "pending" - ? "⏳" - : entry.outcome === "cancelled" - ? "🚫" - : "βž–"; - - lines.push(`**#${entry.id}** ${outcomeEmoji} ${entry.type} - ${entry.action} _[${date}]_`); - - if (entry.asset_from || entry.asset_to) { - const fromStr = entry.asset_from - ? `${entry.amount_from?.toFixed(4) ?? "?"} ${entry.asset_from}` - : "β€”"; - const toStr = entry.asset_to - ? `${entry.amount_to?.toFixed(4) ?? "?"} ${entry.asset_to}` - : "β€”"; - lines.push(` ${fromStr} β†’ ${toStr}`); + if (entries.length === 0) { + return { + success: true, + data: { + entries: [], + message: "No entries found matching your filters.", + }, + }; } - if (entry.price_ton) { - lines.push(` Price: ${entry.price_ton} TON`); + // Calculate P&L summary if filtering by outcome or type + let summary = ""; + if (params.type || params.days) { + const pnl = store.calculatePnL({ + type: params.type, + days: params.days, + }); + + if (pnl.trades_count > 0) { + summary = [ + `**πŸ“Š Performance Summary**`, + ``, + `Trades: ${pnl.trades_count} (${pnl.profit_count} wins, ${pnl.loss_count} losses)`, + `Win Rate: ${pnl.win_rate.toFixed(1)}%`, + `Total P&L: ${pnl.total_pnl >= 0 ? "+" : ""}${pnl.total_pnl.toFixed(2)} TON`, + ``, + `---`, + ``, + ].join("\n"); + } } - if (entry.counterparty) { - lines.push(` Party: ${entry.counterparty}`); - } + // Format entries + const lines: string[] = [`πŸ“– Journal Entries (${entries.length} results)`, ``]; - if (entry.platform) { - lines.push(` Platform: ${entry.platform}`); + if (summary) { + lines.push(summary); } - if (entry.pnl_ton !== null && entry.pnl_ton !== undefined) { - const sign = entry.pnl_ton >= 0 ? "+" : ""; + for (const entry of entries) { + const date = new Date(entry.timestamp * 1000).toISOString().split("T")[0]; lines.push( - ` P&L: ${sign}${entry.pnl_ton.toFixed(2)} TON (${sign}${entry.pnl_pct?.toFixed(1) ?? "?"}%)` + `**#${entry.id}** ${outcomeEmoji(entry.outcome)} ${entry.type} - ${entry.action} _[${date}]_` ); - } - if (entry.reasoning) { - lines.push(` _"${entry.reasoning}"_`); - } + const assetFlow = formatAssetFlow(entry); + if (assetFlow) { + lines.push(` ${assetFlow}`); + } - if (entry.tx_hash) { - lines.push(` TX: \`${entry.tx_hash.slice(0, 16)}...\``); - } + if (entry.price_ton) { + lines.push(` Price: ${entry.price_ton} TON`); + } - lines.push(``); - } + if (entry.counterparty) { + lines.push(` Party: ${entry.counterparty}`); + } - return { - success: true, - data: { - entries, - message: lines.join("\n"), - }, - }; -}; + if (entry.platform) { + lines.push(` Platform: ${entry.platform}`); + } + + if (entry.pnl_ton !== null && entry.pnl_ton !== undefined) { + const sign = entry.pnl_ton >= 0 ? "+" : ""; + lines.push( + ` P&L: ${sign}${entry.pnl_ton.toFixed(2)} TON (${sign}${entry.pnl_pct?.toFixed(1) ?? "?"}%)` + ); + } + + if (entry.reasoning) { + lines.push(` _"${entry.reasoning}"_`); + } + + if (entry.tx_hash) { + lines.push(` TX: ${formatTxHash(entry.tx_hash)}`); + } + + lines.push(``); + } + + return { + success: true, + data: { + entries, + message: lines.join("\n"), + }, + }; + }); diff --git a/src/agent/tools/journal/update.ts b/src/agent/tools/journal/update.ts index e869f21f..2d350164 100644 --- a/src/agent/tools/journal/update.ts +++ b/src/agent/tools/journal/update.ts @@ -6,11 +6,14 @@ import { Type } from "@sinclair/typebox"; import { getDatabase } from "../../../memory/database.js"; import { JournalStore } from "../../../memory/journal-store.js"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; +import type { JournalOutcome } from "../../../memory/journal-store.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { withToolErrors } from "../wrap.js"; +import { outcomeEmoji, formatAssetFlow, formatTxHash } from "./format.js"; interface JournalUpdateParams { id: number; - outcome?: "pending" | "profit" | "loss" | "neutral" | "cancelled"; + outcome?: JournalOutcome; pnl_ton?: number; pnl_pct?: number; tx_hash?: string; @@ -32,7 +35,10 @@ export const journalUpdateTool: Tool = { Type.Literal("neutral"), Type.Literal("cancelled"), ], - { description: "Update outcome status" } + { + description: + "P&L result β€” must be EXACTLY one of: 'pending', 'profit' (use when pnl_ton > 0), 'loss' (pnl_ton < 0), 'neutral' (break-even), 'cancelled'. This is the profit/loss outcome, NOT a completion status β€” do not use 'closed'/'completed'/'success'/'done'. Setting it to a non-pending value auto-closes the entry.", + } ) ), pnl_ton: Type.Optional( @@ -43,93 +49,76 @@ export const journalUpdateTool: Tool = { }), }; -export const journalUpdateExecutor: ToolExecutor = async ( - params -): Promise => { - const db = getDatabase().getDb(); - const store = new JournalStore(db); +export const journalUpdateExecutor: ToolExecutor = + withToolErrors(async (params) => { + const db = getDatabase().getDb(); + const store = new JournalStore(db); + + // Check if entry exists + const existing = store.getEntryById(params.id); + if (!existing) { + return { + success: false, + error: `Journal entry #${params.id} not found`, + }; + } + + // Auto-set closed_at if outcome is being changed to non-pending + const closed_at = + params.outcome && params.outcome !== "pending" && !existing.closed_at + ? Math.floor(Date.now() / 1000) + : undefined; + + const updated = store.updateEntry({ + id: params.id, + outcome: params.outcome, + pnl_ton: params.pnl_ton, + pnl_pct: params.pnl_pct, + tx_hash: params.tx_hash, + closed_at, + }); + + if (!updated) { + return { + success: false, + error: "Failed to update entry", + }; + } + + // Format output + const lines: string[] = [ + `✏️ Journal Entry #${updated.id} updated`, + ``, + `**Type**: ${updated.type} - ${updated.action}`, + ]; + + const assetFlow = formatAssetFlow(updated); + if (assetFlow) { + lines.push(`**Assets**: ${assetFlow}`); + } + + lines.push(`**Outcome**: ${outcomeEmoji(updated.outcome)} ${updated.outcome}`); + + if (updated.pnl_ton !== null && updated.pnl_ton !== undefined) { + const sign = updated.pnl_ton >= 0 ? "+" : ""; + lines.push( + `**P&L**: ${sign}${updated.pnl_ton.toFixed(2)} TON (${sign}${updated.pnl_pct?.toFixed(1) ?? "?"}%)` + ); + } + + if (updated.tx_hash) { + lines.push(`**TX**: ${formatTxHash(updated.tx_hash)}`); + } + + if (updated.closed_at) { + lines.push(``, `_Closed at ${new Date(updated.closed_at * 1000).toISOString()}_`); + } - // Check if entry exists - const existing = store.getEntryById(params.id); - if (!existing) { return { - success: false, - error: `Journal entry #${params.id} not found`, + success: true, + data: { + entry: updated, + message: lines.join("\n"), + }, }; - } - - // Auto-set closed_at if outcome is being changed to non-pending - const closed_at = - params.outcome && params.outcome !== "pending" && !existing.closed_at - ? Math.floor(Date.now() / 1000) - : undefined; - - const updated = store.updateEntry({ - id: params.id, - outcome: params.outcome, - pnl_ton: params.pnl_ton, - pnl_pct: params.pnl_pct, - tx_hash: params.tx_hash, - closed_at, }); - - if (!updated) { - return { - success: false, - error: "Failed to update entry", - }; - } - - // Format output - const lines: string[] = [ - `✏️ Journal Entry #${updated.id} updated`, - ``, - `**Type**: ${updated.type} - ${updated.action}`, - ]; - - if (updated.asset_from || updated.asset_to) { - const fromStr = updated.asset_from - ? `${updated.amount_from?.toFixed(4) ?? "?"} ${updated.asset_from}` - : "β€”"; - const toStr = updated.asset_to - ? `${updated.amount_to?.toFixed(4) ?? "?"} ${updated.asset_to}` - : "β€”"; - lines.push(`**Assets**: ${fromStr} β†’ ${toStr}`); - } - - const outcomeEmoji = - updated.outcome === "profit" - ? "βœ…" - : updated.outcome === "loss" - ? "❌" - : updated.outcome === "pending" - ? "⏳" - : updated.outcome === "cancelled" - ? "🚫" - : "βž–"; - - lines.push(`**Outcome**: ${outcomeEmoji} ${updated.outcome}`); - - if (updated.pnl_ton !== null && updated.pnl_ton !== undefined) { - const sign = updated.pnl_ton >= 0 ? "+" : ""; - lines.push( - `**P&L**: ${sign}${updated.pnl_ton.toFixed(2)} TON (${sign}${updated.pnl_pct?.toFixed(1) ?? "?"}%)` - ); - } - - if (updated.tx_hash) { - lines.push(`**TX**: \`${updated.tx_hash.slice(0, 16)}...\``); - } - - if (updated.closed_at) { - lines.push(``, `_Closed at ${new Date(updated.closed_at * 1000).toISOString()}_`); - } - - return { - success: true, - data: { - entry: updated, - message: lines.join("\n"), - }, - }; -}; diff --git a/src/agent/tools/module-loader.ts b/src/agent/tools/module-loader.ts index ba91e7ef..68e5c20d 100644 --- a/src/agent/tools/module-loader.ts +++ b/src/agent/tools/module-loader.ts @@ -31,8 +31,8 @@ export function loadModules( mod.migrate?.(db); const tools = mod.tools(config); - for (const { tool, executor, scope } of tools) { - registry.register(tool, executor, scope); + for (const { tool, executor, scope, mode } of tools) { + registry.register(tool, executor, scope, mode); } loaded.push(mod); diff --git a/src/agent/tools/register-all.ts b/src/agent/tools/register-all.ts index ca625d03..5a2e6c72 100644 --- a/src/agent/tools/register-all.ts +++ b/src/agent/tools/register-all.ts @@ -16,7 +16,6 @@ import { tools as dedustTools } from "./dedust/index.js"; import { tools as journalTools } from "./journal/index.js"; import { tools as workspaceTools } from "./workspace/index.js"; import { tools as webTools } from "./web/index.js"; -import { tools as botTools } from "./bot/index.js"; import { toolSearchTool, createToolSearchExecutor } from "./search/index.js"; const ALL_CATEGORIES: ToolEntry[][] = [ @@ -28,13 +27,12 @@ const ALL_CATEGORIES: ToolEntry[][] = [ journalTools, workspaceTools, webTools, - botTools, ]; export function registerAllTools(registry: ToolRegistry): void { for (const category of ALL_CATEGORIES) { - for (const { tool, executor, scope, requiredMode, tags } of category) { - registry.register(tool, executor, scope, requiredMode, tags); + for (const { tool, executor, scope, mode, tags } of category) { + registry.register(tool, executor, scope, mode, tags); } } @@ -43,5 +41,5 @@ export function registerAllTools(registry: ToolRegistry): void { // The executor lazily reads registry.getToolIndex() + registry.getEmbedder() at call time, // both of which are set during startAgent() β€” after this registration. const toolSearchExecutor = createToolSearchExecutor(registry); - registry.register(toolSearchTool, toolSearchExecutor, "open", undefined, ["core"]); + registry.register(toolSearchTool, toolSearchExecutor, "open", "both", ["core"]); } diff --git a/src/agent/tools/registry.ts b/src/agent/tools/registry.ts index dae48b52..a1aaa29d 100644 --- a/src/agent/tools/registry.ts +++ b/src/agent/tools/registry.ts @@ -3,10 +3,12 @@ import type { Tool as PiAiTool, ToolCall } from "@mariozechner/pi-ai"; import type { TSchema } from "@sinclair/typebox"; import type { RegisteredTool, + RuntimeMode, Tool, ToolContext, ToolEntry, ToolExecutor, + ToolMode, ToolResult, ToolScope, } from "./types.js"; @@ -20,62 +22,85 @@ import { saveToolConfig, type ToolConfig, } from "../../memory/tool-config.js"; +import { scopeToLevel, levelToScope, type ToolAccessLevel } from "./scope.js"; import type { ToolIndex } from "./tool-index.js"; import { getErrorMessage } from "../../utils/errors.js"; import { createLogger } from "../../utils/logger.js"; const log = createLogger("Registry"); +/** Reason a tool is denied for a context β€” mapped to a user message by execute(). */ +type AccessDenial = + | { kind: "mode"; mode: ToolMode } + | { kind: "disabled" } + | { kind: "admin-only" } + | { kind: "allowlist" } + | { kind: "module-disabled"; module: string } + | { kind: "module-admin"; module: string }; + export class ToolRegistry { + // Single source of tool state β€” tool/executor + declared scope/mode/module/tags. private tools: Map = new Map(); - private scopes: Map = new Map(); - private toolModules: Map = new Map(); private permissions: ModulePermissions | null = null; private toolArrayCache: PiAiTool[] | null = null; - private toolConfigs: Map = new Map(); // Runtime tool configurations + private toolConfigs: Map = new Map(); // Runtime tool configurations (DB-backed) private db: Database.Database | null = null; private pluginToolNames: Map = new Map(); private toolIndex: ToolIndex | null = null; private embedderRef: EmbeddingProvider | null = null; private onToolsChangedCallbacks: Array<(removed: string[], added: PiAiTool[]) => void> = []; - private mode: "user" | "bot"; - private requiredModes: Map = new Map(); - private toolTags: Map = new Map(); - private activeToolset: string | null = null; // null = "full" (no filtering) + private mode: RuntimeMode; private allowFrom: Set = new Set(); - private static readonly TOOLSET_PROFILES: Record = { - minimal: ["core"], - standard: ["core", "workspace", "web", "social"], - trading: ["core", "workspace", "web", "finance"], - full: [], // empty = no filtering - }; - - constructor(mode: "user" | "bot" = "user") { + constructor(mode: RuntimeMode = "user") { this.mode = mode; } + /** + * Centralised insertion into the parallel registry Maps β€” single source for the + * tool/scope/mode/tags/module bookkeeping shared by register() and the plugin + * (re)registration paths. Callers own collision policy and cache invalidation. + */ + private insertTool( + name: string, + entry: { + tool: Tool; + executor: ToolExecutor; + scope?: ToolScope; + mode: ToolMode; + module: string; + tags?: string[]; + } + ): void { + this.tools.set(name, { + tool: entry.tool, + executor: entry.executor, + scope: + entry.scope && entry.scope !== "always" && entry.scope !== "open" ? entry.scope : undefined, + mode: entry.mode, + module: entry.module, + tags: entry.tags && entry.tags.length > 0 ? entry.tags : undefined, + }); + } + register( tool: Tool, executor: ToolExecutor, scope?: ToolScope, - requiredMode?: "user" | "bot", + mode: ToolMode = "both", tags?: string[] ): void { if (this.tools.has(tool.name)) { throw new Error(`Tool "${tool.name}" is already registered`); } - this.tools.set(tool.name, { tool, executor: executor as ToolExecutor }); - if (scope && scope !== "always" && scope !== "open") { - this.scopes.set(tool.name, scope); - } - if (requiredMode) { - this.requiredModes.set(tool.name, requiredMode); - } - if (tags && tags.length > 0) { - this.toolTags.set(tool.name, tags); - } - this.toolModules.set(tool.name, tool.name.split("_")[0]); + this.insertTool(tool.name, { + tool, + executor: executor as ToolExecutor, + scope, + mode, + module: tool.name.split("_")[0], + tags, + }); this.toolArrayCache = null; } @@ -83,56 +108,37 @@ export class ToolRegistry { this.permissions = mp; } - setMode(mode: "user" | "bot"): void { + setMode(mode: RuntimeMode): void { this.mode = mode; this.toolArrayCache = null; const count = Array.from(this.tools.values()).filter((rt) => { - const reqMode = this.requiredModes.get(rt.tool.name); - return !reqMode || reqMode === mode; + const toolMode = this.tools.get(rt.tool.name)?.mode; + return !toolMode || toolMode === "both" || toolMode === mode; }).length; log.info(`Mode switched to ${mode}, ${count} tools available`); } - setActiveToolset(name: string | null): void { - if (name && name !== "full" && !ToolRegistry.TOOLSET_PROFILES[name]) { - log.warn(`Unknown toolset "${name}", falling back to full`); - this.activeToolset = null; - return; - } - this.activeToolset = name === "full" ? null : name; - this.toolArrayCache = null; - log.info(`Active toolset: ${this.activeToolset ?? "full"}`); - } - setAllowFrom(ids: number[]): void { this.allowFrom = new Set(ids); } - getActiveToolset(): string | null { - return this.activeToolset; - } - - getAvailableToolsets(): string[] { - return Object.keys(ToolRegistry.TOOLSET_PROFILES); - } - getAvailableModules(): string[] { - const modules = new Set(this.toolModules.values()); + const modules = new Set(Array.from(this.tools.values()).map((rt) => rt.module)); return Array.from(modules).sort(); } getModuleToolCount(module: string): number { let count = 0; - for (const mod of this.toolModules.values()) { - if (mod === module) count++; + for (const rt of this.tools.values()) { + if (rt.module === module) count++; } return count; } getModuleTools(module: string): Array<{ name: string; scope: ToolScope }> { const result: Array<{ name: string; scope: ToolScope }> = []; - for (const [name, mod] of this.toolModules) { - if (mod === module) { + for (const [name, rt] of this.tools) { + if (rt.module === module) { result.push({ name, scope: this.getEffectiveScope(name) }); } } @@ -146,93 +152,84 @@ export class ToolRegistry { return this.toolArrayCache; } - async execute(toolCall: ToolCall, context: ToolContext): Promise { - const registered = this.tools.get(toolCall.name); - - if (!registered) { - return { - success: false, - error: `Unknown tool: ${toolCall.name}`, - }; + /** + * Single authorization grid (mode β†’ per-tool access level β†’ group module + * perms). The tool's level (all/allowlist/admin/off) is context-independent; + * the DM-vs-group "who does the agent respond to" gating is a separate global + * concern handled upstream. Shared by execute() (maps the denial to a message) + * and passesFilters() (uses only .ok), so the two can no longer drift. + * `isAdmin` is passed in by the caller. + */ + private checkAccess( + name: string, + ctx: { isGroup: boolean; isAdmin: boolean; senderId?: number; chatId?: string } + ): { ok: true } | { ok: false; reason: AccessDenial } { + const toolMode = this.tools.get(name)?.mode; + if (toolMode && toolMode !== "both" && toolMode !== this.mode) { + return { ok: false, reason: { kind: "mode", mode: toolMode } }; + } + + const level = this.getEffectiveLevel(name); + if (level === "off") return { ok: false, reason: { kind: "disabled" } }; + if (level === "admin" && !ctx.isAdmin) return { ok: false, reason: { kind: "admin-only" } }; + if (level === "allowlist" && !ctx.isAdmin) { + if (!ctx.senderId || !this.allowFrom.has(ctx.senderId)) { + return { ok: false, reason: { kind: "allowlist" } }; + } } - // Check mode restriction (defense-in-depth: tools are also filtered from LLM tool list) - const reqMode = this.requiredModes.get(toolCall.name); - if (reqMode && reqMode !== this.mode) { - return { - success: false, - error: `Tool "${toolCall.name}" requires ${reqMode} mode (current: ${this.mode})`, - }; + if (ctx.isGroup && ctx.chatId && this.permissions) { + const module = this.tools.get(name)?.module; + if (module) { + const mLevel = this.permissions.getLevel(ctx.chatId, module); + if (mLevel === "disabled") + return { ok: false, reason: { kind: "module-disabled", module } }; + if (mLevel === "admin" && !ctx.isAdmin) { + return { ok: false, reason: { kind: "module-admin", module } }; + } + } } - // Check if tool is enabled - if (!this.isToolEnabled(toolCall.name)) { - return { - success: false, - error: `Tool "${toolCall.name}" is currently disabled`, - }; - } + return { ok: true }; + } - const scope = this.getEffectiveScope(toolCall.name); - if (scope === "disabled") { - return { - success: false, - error: `Tool "${toolCall.name}" is currently disabled`, - }; - } - if (scope === "dm-only" && context.isGroup) { - return { - success: false, - error: `Tool "${toolCall.name}" is not available in group chats`, - }; + private denialMessage(name: string, reason: AccessDenial): string { + switch (reason.kind) { + case "mode": + return `Tool "${name}" requires ${reason.mode} mode (current: ${this.mode})`; + case "disabled": + return `Tool "${name}" is currently disabled`; + case "admin-only": + return `Tool "${name}" is restricted to admin users`; + case "allowlist": + return `Tool "${name}" is restricted to allowed users`; + case "module-disabled": + return `Module "${reason.module}" is disabled in this group`; + case "module-admin": + return `Module "${reason.module}" is restricted to admins in this group`; } - if (scope === "group-only" && !context.isGroup) { + } + + async execute(toolCall: ToolCall, context: ToolContext): Promise { + const registered = this.tools.get(toolCall.name); + + if (!registered) { return { success: false, - error: `Tool "${toolCall.name}" is only available in group chats`, + error: `Unknown tool: ${toolCall.name}`, }; } - if (scope === "admin-only") { - const isAdmin = context.config?.telegram.admin_ids.includes(context.senderId) ?? false; - if (!isAdmin) { - return { - success: false, - error: `Tool "${toolCall.name}" is restricted to admin users`, - }; - } - } - if (scope === "allowlist") { - const isAdmin = context.config?.telegram.admin_ids.includes(context.senderId) ?? false; - if (!isAdmin) { - if (!this.allowFrom.has(context.senderId)) { - return { - success: false, - error: `Tool "${toolCall.name}" is restricted to allowed users`, - }; - } - } - } - if (context.isGroup && this.permissions) { - const module = this.toolModules.get(toolCall.name); - if (module) { - const level = this.permissions.getLevel(context.chatId, module); - if (level === "disabled") { - return { - success: false, - error: `Module "${module}" is disabled in this group`, - }; - } - if (level === "admin") { - const isAdmin = context.config?.telegram.admin_ids.includes(context.senderId) ?? false; - if (!isAdmin) { - return { - success: false, - error: `Module "${module}" is restricted to admins in this group`, - }; - } - } - } + // Defense-in-depth authorization (tools are also filtered from the LLM tool list) + const isAdmin = context.config?.telegram.admin_ids.includes(context.senderId) ?? false; + const access = this.checkAccess(toolCall.name, { + isGroup: context.isGroup, + isAdmin, + senderId: context.senderId, + chatId: context.chatId, + }); + if (!access.ok) { + return { success: false, error: this.denialMessage(toolCall.name, access.reason) }; } try { @@ -264,17 +261,6 @@ export class ToolRegistry { } } - getForProvider(toolLimit: number | null): PiAiTool[] { - const all = this.getAll(); - if (toolLimit === null || all.length <= toolLimit) { - return all; - } - log.warn( - `Provider tool limit: ${toolLimit}, registered: ${all.length}. Truncating to ${toolLimit} tools.` - ); - return all.slice(0, toolLimit); - } - getForContext( isGroup: boolean, toolLimit: number | null, @@ -315,58 +301,65 @@ export class ToolRegistry { /** * Load tool configurations from database and seed missing ones */ - loadConfigFromDB(db: Database.Database): void { - this.db = db; - this.toolConfigs = loadAllToolConfigs(db); - - // Seed DB with defaults for tools that don't have config yet + /** + * Seed DB config defaults for any of `names` lacking one, then reload the + * in-memory config cache once if anything was seeded. No-op without a DB. + */ + private seedConfigs(names: Iterable): void { + if (!this.db) return; let seeded = false; - for (const [toolName] of this.tools) { - if (!this.toolConfigs.has(toolName)) { - const defaultScope = this.scopes.get(toolName) ?? "always"; - initializeToolConfig(db, toolName, true, defaultScope); + for (const name of names) { + if (!this.toolConfigs.has(name)) { + initializeToolConfig(this.db, name, scopeToLevel(this.tools.get(name)?.scope)); seeded = true; } } - // Reload once after all seeds if (seeded) { - this.toolConfigs = loadAllToolConfigs(db); + this.toolConfigs = loadAllToolConfigs(this.db); } + } + loadConfigFromDB(db: Database.Database): void { + this.db = db; + this.toolConfigs = loadAllToolConfigs(db); + // Seed DB with defaults for tools that don't have config yet + this.seedConfigs(this.tools.keys()); // Clear cache to force regeneration with new configs this.toolArrayCache = null; } /** - * Get effective scope for a tool (config override or default) + * Effective access level for a tool: the DB override if present, else the + * code-declared default scope mapped to a level. */ - private getEffectiveScope(toolName: string): ToolScope { + private getEffectiveLevel(toolName: string): ToolAccessLevel { const config = this.toolConfigs.get(toolName); - if (config?.scope !== null && config?.scope !== undefined) { - return config.scope === "always" ? "open" : config.scope; - } - const codeScope = this.scopes.get(toolName) ?? "open"; - return codeScope === "always" ? "open" : codeScope; + if (config) return config.level; + return scopeToLevel(this.tools.get(toolName)?.scope); } /** - * Check if a tool is enabled + * Legacy single-value scope view (derived from the level). Kept for backward- + * compatible callers (getEntry, getModuleTools). + */ + private getEffectiveScope(toolName: string): ToolScope { + return levelToScope(this.getEffectiveLevel(toolName)); + } + + /** + * Check if a tool is enabled (i.e. not off). */ isToolEnabled(toolName: string): boolean { - const config = this.toolConfigs.get(toolName); - return config?.enabled ?? true; + return this.getEffectiveLevel(toolName) !== "off"; } /** - * Update tool enabled status + * Update a tool's access level (primary writer). */ - setToolEnabled(toolName: string, enabled: boolean, updatedBy?: number): boolean { + updateToolLevel(toolName: string, level: ToolAccessLevel, updatedBy?: number): boolean { if (!this.tools.has(toolName) || !this.db) return false; - const currentConfig = this.toolConfigs.get(toolName); - const scope = currentConfig?.scope ?? this.scopes.get(toolName) ?? "always"; - - saveToolConfig(this.db, toolName, enabled, scope, updatedBy); + saveToolConfig(this.db, toolName, level, updatedBy); // Update in-memory cache this.toolConfigs = loadAllToolConfigs(this.db); @@ -376,34 +369,36 @@ export class ToolRegistry { } /** - * Update tool scope + * Toggle a tool on/off (backward-compatible adapter). Disabling sets the level + * off; re-enabling an off tool restores its code-declared default. */ - updateToolScope(toolName: string, scope: ToolScope, updatedBy?: number): boolean { + setToolEnabled(toolName: string, enabled: boolean, updatedBy?: number): boolean { if (!this.tools.has(toolName) || !this.db) return false; - const currentConfig = this.toolConfigs.get(toolName); - const enabled = currentConfig?.enabled ?? true; - - saveToolConfig(this.db, toolName, enabled, scope, updatedBy); - - // Update in-memory cache - this.toolConfigs = loadAllToolConfigs(this.db); - this.toolArrayCache = null; + let next: ToolAccessLevel; + if (!enabled) { + next = "off"; + } else if (this.getEffectiveLevel(toolName) === "off") { + next = scopeToLevel(this.tools.get(toolName)?.scope); + } else { + next = this.getEffectiveLevel(toolName); + } + return this.updateToolLevel(toolName, next, updatedBy); + } - return true; + /** + * Apply a legacy single-value scope (backward-compatible adapter). + */ + updateToolScope(toolName: string, scope: ToolScope, updatedBy?: number): boolean { + return this.updateToolLevel(toolName, scopeToLevel(scope), updatedBy); } /** - * Get tool configuration + * Get a tool's effective access level (DB override or code default). */ - getToolConfig(toolName: string): { enabled: boolean; scope: ToolScope } | null { + getToolConfig(toolName: string): { level: ToolAccessLevel } | null { if (!this.tools.has(toolName)) return null; - - const config = this.toolConfigs.get(toolName); - const enabled = config?.enabled ?? true; - const scope = config?.scope ?? this.scopes.get(toolName) ?? "always"; - - return { enabled, scope }; + return { level: this.getEffectiveLevel(toolName) }; } /** @@ -411,34 +406,24 @@ export class ToolRegistry { */ registerPluginTools( pluginName: string, - tools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope }> + tools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope; mode?: ToolMode }> ): number { const names: string[] = []; - for (const { tool, executor, scope } of tools) { + for (const { tool, executor, scope, mode } of tools) { if (this.tools.has(tool.name)) continue; - this.tools.set(tool.name, { tool, executor }); - if (scope && scope !== "always" && scope !== "open") { - this.scopes.set(tool.name, scope); - } - this.toolModules.set(tool.name, pluginName); + this.insertTool(tool.name, { + tool, + executor, + scope, + mode: mode ?? "both", + module: pluginName, + }); names.push(tool.name); } this.pluginToolNames.set(pluginName, names); // Seed new tools into DB config (if DB is initialized) - if (this.db) { - let seeded = false; - for (const name of names) { - if (!this.toolConfigs.has(name)) { - const defaultScope = this.scopes.get(name) ?? "always"; - initializeToolConfig(this.db, name, true, defaultScope); - seeded = true; - } - } - if (seeded) { - this.toolConfigs = loadAllToolConfigs(this.db); - } - } + this.seedConfigs(names); this.toolArrayCache = null; @@ -457,13 +442,13 @@ export class ToolRegistry { */ replacePluginTools( pluginName: string, - newTools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope }> + newTools: Array<{ tool: Tool; executor: ToolExecutor; scope?: ToolScope; mode?: ToolMode }> ): void { // Collect old tool names before removal (allowed to re-register these) const previousNames = new Set(this.pluginToolNames.get(pluginName) ?? []); this.removePluginTools(pluginName); const names: string[] = []; - for (const { tool, executor, scope } of newTools) { + for (const { tool, executor, scope, mode } of newTools) { // Prevent overwriting core/other-plugin tools if (this.tools.has(tool.name) && !previousNames.has(tool.name)) { log.warn( @@ -471,29 +456,19 @@ export class ToolRegistry { ); continue; } - this.tools.set(tool.name, { tool, executor }); - if (scope && scope !== "always" && scope !== "open") { - this.scopes.set(tool.name, scope); - } - this.toolModules.set(tool.name, pluginName); + this.insertTool(tool.name, { + tool, + executor, + scope, + mode: mode ?? "both", + module: pluginName, + }); names.push(tool.name); } this.pluginToolNames.set(pluginName, names); // Seed new tools into DB config (if DB is initialized) - if (this.db) { - let seeded = false; - for (const name of names) { - if (!this.toolConfigs.has(name)) { - const defaultScope = this.scopes.get(name) ?? "always"; - initializeToolConfig(this.db, name, true, defaultScope); - seeded = true; - } - } - if (seeded) { - this.toolConfigs = loadAllToolConfigs(this.db); - } - } + this.seedConfigs(names); this.toolArrayCache = null; @@ -513,8 +488,8 @@ export class ToolRegistry { if (tracked) { for (const name of tracked) { this.tools.delete(name); - this.scopes.delete(name); - this.toolModules.delete(name); + // Also drop the runtime config so removed plugin tools don't leak (prev. omitted) + this.toolConfigs.delete(name); } this.pluginToolNames.delete(pluginName); } @@ -561,8 +536,8 @@ export class ToolRegistry { tool: registered.tool, executor: registered.executor, scope: this.getEffectiveScope(name), - requiredMode: this.requiredModes.get(name), - tags: this.toolTags.get(name), + mode: this.tools.get(name)?.mode ?? "both", + tags: this.tools.get(name)?.tags, }; } @@ -578,47 +553,7 @@ export class ToolRegistry { senderId?: number ): boolean { if (!this.tools.has(name)) return false; - - // Mode restriction (user vs bot) - const reqMode = this.requiredModes.get(name); - if (reqMode && reqMode !== this.mode) return false; - - // Active toolset profile filter - if (this.activeToolset) { - const allowedTags = ToolRegistry.TOOLSET_PROFILES[this.activeToolset]; - if (allowedTags && allowedTags.length > 0) { - const toolTagList = this.toolTags.get(name); - if (toolTagList && toolTagList.length > 0) { - if (!toolTagList.some((t) => allowedTags.includes(t))) return false; - } - } - } - - // Enabled check - if (!this.isToolEnabled(name)) return false; - - const effectiveScope = this.getEffectiveScope(name); - if (effectiveScope === "disabled") return false; - - const excluded = isGroup ? "dm-only" : "group-only"; - if (effectiveScope === excluded) return false; - - if (effectiveScope === "admin-only" && !isAdmin) return false; - - if (effectiveScope === "allowlist" && !isAdmin) { - if (!senderId || !this.allowFrom.has(senderId)) return false; - } - - if (isGroup && chatId && this.permissions) { - const module = this.toolModules.get(name); - if (module) { - const level = this.permissions.getLevel(chatId, module); - if (level === "disabled") return false; - if (level === "admin" && !isAdmin) return false; - } - } - - return true; + return this.checkAccess(name, { isGroup, isAdmin: isAdmin ?? false, senderId, chatId }).ok; } /** @@ -633,7 +568,7 @@ export class ToolRegistry { ): PiAiTool[] { return Array.from(this.tools.entries()) .filter(([name]) => { - const tags = this.toolTags.get(name); + const tags = this.tools.get(name)?.tags; if (!tags?.includes("core")) return false; return this.passesFilters(name, isGroup, chatId, isAdmin, senderId); }) diff --git a/src/agent/tools/scope.ts b/src/agent/tools/scope.ts new file mode 100644 index 00000000..6d80ca65 --- /dev/null +++ b/src/agent/tools/scope.ts @@ -0,0 +1,60 @@ +import type { ToolScope } from "./types.js"; + +/** + * Per-tool access level β€” the authority ladder from most open to most + * restrictive. Context-independent: a tool's level applies identically in DMs + * and in groups. (Who the agent responds to per channel is a separate, global + * concern β€” telegram.dm_policy / telegram.group_policy.) + * - "all": anyone the agent talks to + * - "allowlist": only `telegram.allow_from` user IDs (admins always pass) + * - "admin": only `telegram.admin_ids` + * - "off": nobody β€” the tool is disabled + */ +export type ToolAccessLevel = "all" | "allowlist" | "admin" | "off"; + +export const TOOL_ACCESS_LEVELS = ["all", "allowlist", "admin", "off"] as const; + +export function isToolAccessLevel(v: unknown): v is ToolAccessLevel { + return v === "all" || v === "allowlist" || v === "admin" || v === "off"; +} + +/** + * Map a legacy single-value {@link ToolScope} (still the code-declared default + * for built-in / MCP / plugin tools) to an access level. Single source of truth + * for the oldβ†’new translation β€” reused by the DB migration, the runtime default + * seeding, and the backward-compatible API. The old context dimension + * (dm-only / group-only) collapses to "all"; the channel gating now lives in the + * global DM/Group policies. + */ +export function scopeToLevel(scope: ToolScope | null | undefined): ToolAccessLevel { + switch (scope) { + case "admin-only": + return "admin"; + case "allowlist": + return "allowlist"; + case "disabled": + return "off"; + // "open" | "always" | "dm-only" | "group-only" | null | undefined + default: + return "all"; + } +} + +/** + * Collapse an access level back to the closest legacy {@link ToolScope}. Used + * only for backward compatibility (legacy API field, ToolEntry.scope, the + * retained `scope` DB column for downgrade safety). + */ +export function levelToScope(level: ToolAccessLevel): ToolScope { + switch (level) { + case "admin": + return "admin-only"; + case "allowlist": + return "allowlist"; + case "off": + return "disabled"; + case "all": + default: + return "open"; + } +} diff --git a/src/agent/tools/stonfi/index.ts b/src/agent/tools/stonfi/index.ts index 6985e127..4e069775 100644 --- a/src/agent/tools/stonfi/index.ts +++ b/src/agent/tools/stonfi/index.ts @@ -12,9 +12,15 @@ export { stonfiTrendingTool, stonfiTrendingExecutor }; export { stonfiPoolsTool, stonfiPoolsExecutor }; export const tools: ToolEntry[] = [ - { tool: stonfiSwapTool, executor: stonfiSwapExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: stonfiQuoteTool, executor: stonfiQuoteExecutor, tags: ["finance"] }, - { tool: stonfiSearchTool, executor: stonfiSearchExecutor, tags: ["finance"] }, - { tool: stonfiTrendingTool, executor: stonfiTrendingExecutor, tags: ["finance"] }, - { tool: stonfiPoolsTool, executor: stonfiPoolsExecutor, tags: ["finance"] }, + { + tool: stonfiSwapTool, + executor: stonfiSwapExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { tool: stonfiQuoteTool, executor: stonfiQuoteExecutor, mode: "both", tags: ["finance"] }, + { tool: stonfiSearchTool, executor: stonfiSearchExecutor, mode: "both", tags: ["finance"] }, + { tool: stonfiTrendingTool, executor: stonfiTrendingExecutor, mode: "both", tags: ["finance"] }, + { tool: stonfiPoolsTool, executor: stonfiPoolsExecutor, mode: "both", tags: ["finance"] }, ]; diff --git a/src/agent/tools/stonfi/quote.ts b/src/agent/tools/stonfi/quote.ts index a18953a9..65a6deec 100644 --- a/src/agent/tools/stonfi/quote.ts +++ b/src/agent/tools/stonfi/quote.ts @@ -3,11 +3,10 @@ import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import { StonApiClient } from "@ston-fi/api"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; +import { toUnits } from "../../../ton/units.js"; +import { STONFI_PTON_ADDRESS as NATIVE_TON_ADDRESS } from "../../../ton/dex-constants.js"; const log = createLogger("Tools"); - -// Native TON address used by STON.fi API -const NATIVE_TON_ADDRESS = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; interface JettonQuoteParams { from_asset: string; to_asset: string; @@ -59,11 +58,7 @@ export const stonfiQuoteExecutor: ToolExecutor = async ( // Fetch decimals for accurate conversion (TON=9, USDT=6, WBTC=8, etc.) const fromAssetInfo = await stonApiClient.getAsset(fromAddress); const fromDecimals = fromAssetInfo?.decimals ?? 9; - const amountStr = amount.toFixed(fromDecimals); - const [whole, frac = ""] = amountStr.split("."); - const offerUnits = BigInt( - whole + (frac + "0".repeat(fromDecimals)).slice(0, fromDecimals) - ).toString(); + const offerUnits = toUnits(amount, fromDecimals).toString(); const simulationResult = await stonApiClient.simulateSwap({ offerAddress: fromAddress, diff --git a/src/agent/tools/stonfi/swap.ts b/src/agent/tools/stonfi/swap.ts index 3794b234..5b505149 100644 --- a/src/agent/tools/stonfi/swap.ts +++ b/src/agent/tools/stonfi/swap.ts @@ -2,22 +2,21 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; import { loadWallet, - getKeyPair, getCachedTonClient, invalidateTonClientCache, } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, fromNano, internal } from "@ton/ton"; -import { SendMode } from "@ton/core"; +import { fromNano, internal } from "@ton/ton"; import { dexFactory } from "@ston-fi/sdk"; import { StonApiClient } from "@ston-fi/api"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { openWallet } from "../../../ton/wallet-open.js"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; import { getErrorMessage, isHttpError } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; +import { toUnits } from "../../../ton/units.js"; +import { STONFI_PTON_ADDRESS as NATIVE_TON_ADDRESS } from "../../../ton/dex-constants.js"; const log = createLogger("Tools"); - -// Native TON address used by STON.fi API -const NATIVE_TON_ADDRESS = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; interface JettonSwapParams { from_asset: string; to_asset: string; @@ -90,12 +89,7 @@ export const stonfiSwapExecutor: ToolExecutor = async ( // Fetch decimals for accurate conversion (TON=9, USDT=6, WBTC=8, etc.) const fromAssetInfo = await stonApiClient.getAsset(fromAddress); const fromDecimals = fromAssetInfo?.decimals ?? 9; - // String-based conversion to avoid float precision loss with high-decimal tokens - const amountStr = amount.toFixed(fromDecimals); - const [whole, frac = ""] = amountStr.split("."); - const offerUnits = BigInt( - whole + (frac + "0".repeat(fromDecimals)).slice(0, fromDecimals) - ).toString(); + const offerUnits = toUnits(amount, fromDecimals).toString(); log.info(`Simulating swap: ${amount} ${fromAddress} β†’ ${toAddress}`); const simulationResult = await stonApiClient.simulateSwap({ @@ -117,16 +111,11 @@ export const stonfiSwapExecutor: ToolExecutor = async ( const router = tonClient.open(contracts.Router.create(routerInfo.address)); return withTxLock(async () => { - const keyPair = await getKeyPair(); - if (!keyPair) { + const opened = await openWallet(tonClient); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const walletContract = tonClient.open(wallet); - const seqno = await walletContract.getSeqno(); + const { keyPair, wallet, contract: walletContract } = opened; let txParams; const proxyTon = contracts.pTON.create(routerInfo.ptonMasterAddress); @@ -173,10 +162,8 @@ export const stonfiSwapExecutor: ToolExecutor = async ( }); } - await walletContract.sendTransfer({ - seqno, + const sent = await sendWalletTx(tonClient, walletContract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: txParams.to, @@ -187,6 +174,13 @@ export const stonfiSwapExecutor: ToolExecutor = async ( ], }); + if (!sent) { + return { + success: false, + error: "Swap transaction failed or could not be confirmed on-chain.", + }; + } + // Fetch ask asset decimals for accurate output conversion const toAssetInfo = await stonApiClient.getAsset(toAddress); const askDecimals = toAssetInfo?.decimals ?? 9; @@ -204,7 +198,8 @@ export const stonfiSwapExecutor: ToolExecutor = async ( slippage: `${(slippage * 100).toFixed(2)}%`, priceImpact: simulationResult.priceImpact || "N/A", router: routerInfo.address, - message: `Swapped ${amount} ${isTonInput ? "TON" : "tokens"} for ~${expectedOutput.toFixed(4)} ${isTonOutput ? "TON" : "tokens"}\n Minimum output: ${minOutput.toFixed(4)}\n Slippage: ${(slippage * 100).toFixed(2)}%\n Transaction sent (check balance in ~30 seconds)`, + txHash: sent.hash, + message: `Swapped ${amount} ${isTonInput ? "TON" : "tokens"} for ~${expectedOutput.toFixed(4)} ${isTonOutput ? "TON" : "tokens"} β€” confirmed on-chain\n Minimum output: ${minOutput.toFixed(4)}\n Slippage: ${(slippage * 100).toFixed(2)}%\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; }); // withTxLock diff --git a/src/agent/tools/telegram/chats/__tests__/check-channel-username.test.ts b/src/agent/tools/telegram/chats/__tests__/check-channel-username.test.ts index 36521f99..8486758a 100644 --- a/src/agent/tools/telegram/chats/__tests__/check-channel-username.test.ts +++ b/src/agent/tools/telegram/chats/__tests__/check-channel-username.test.ts @@ -8,7 +8,7 @@ const mockGetEntity = vi.fn(); const mockContext = { bridge: { getMode: () => "user", - getRawClient: () => ({ + getClient: () => ({ getClient: () => ({ invoke: mockInvoke, getEntity: mockGetEntity, diff --git a/src/agent/tools/telegram/chats/__tests__/create-channel-username.test.ts b/src/agent/tools/telegram/chats/__tests__/create-channel-username.test.ts index 482707f7..ef72d6dc 100644 --- a/src/agent/tools/telegram/chats/__tests__/create-channel-username.test.ts +++ b/src/agent/tools/telegram/chats/__tests__/create-channel-username.test.ts @@ -7,7 +7,7 @@ const mockInvoke = vi.fn(); const mockContext = { bridge: { getMode: () => "user", - getRawClient: () => ({ + getClient: () => ({ getClient: () => ({ invoke: mockInvoke, }), diff --git a/src/agent/tools/telegram/chats/__tests__/set-channel-username.test.ts b/src/agent/tools/telegram/chats/__tests__/set-channel-username.test.ts index cb4d32ed..2d9251e8 100644 --- a/src/agent/tools/telegram/chats/__tests__/set-channel-username.test.ts +++ b/src/agent/tools/telegram/chats/__tests__/set-channel-username.test.ts @@ -8,7 +8,7 @@ const mockGetEntity = vi.fn(); const mockContext = { bridge: { getMode: () => "user", - getRawClient: () => ({ + getClient: () => ({ getClient: () => ({ invoke: mockInvoke, getEntity: mockGetEntity, diff --git a/src/agent/tools/telegram/chats/check-channel-username.ts b/src/agent/tools/telegram/chats/check-channel-username.ts index 60493b08..5f5ccd51 100644 --- a/src/agent/tools/telegram/chats/check-channel-username.ts +++ b/src/agent/tools/telegram/chats/check-channel-username.ts @@ -3,12 +3,16 @@ import { Api } from "telegram"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; +import { + getClient, + validateChannelUsername, + resolveChannel, + cleanUsername, + mapTelegramError, +} from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); -const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_]{3,30}[a-zA-Z0-9]$/; - interface CheckChannelUsernameParams { channelId: string; username: string; @@ -34,27 +38,14 @@ export const telegramCheckChannelUsernameExecutor: ToolExecutor< > = async (params, context): Promise => { try { const { channelId, username } = params; - const clean = username.replace(/^@/, ""); - - if (!USERNAME_REGEX.test(clean)) { - return { - success: false, - error: - "Invalid username format. Must be 5-32 characters, alphanumeric and underscores only, cannot start/end with underscore.", - }; + const validation = validateChannelUsername(username); + if (!validation.ok) { + return { success: false, error: validation.error }; } + const clean = validation.clean; const gramJsClient = getClient(context.bridge); - const entity = await gramJsClient.getEntity(channelId); - - if (entity.className !== "Channel") { - return { - success: false, - error: `Entity is not a channel/group (got ${entity.className})`, - }; - } - - const channel = entity as Api.Channel; + const channel = await resolveChannel(context.bridge, channelId); const available = await gramJsClient.invoke( new Api.channels.CheckUsername({ channel, @@ -73,29 +64,13 @@ export const telegramCheckChannelUsernameExecutor: ToolExecutor< } catch (error: unknown) { log.error({ err: error }, "Error checking channel username"); - const msg = getErrorMessage(error); - - if (msg.includes("USERNAME_INVALID")) { - return { - success: false, - error: `Invalid username format: "${params.username}"`, - }; - } - - if (msg.includes("CHANNELS_ADMIN_PUBLIC_TOO_MUCH")) { - return { - success: false, - error: - "You admin too many public channels. Make some channels private before assigning a new public username.", - }; - } - - if (msg.includes("USERNAME_PURCHASE_AVAILABLE")) { + // USERNAME_PURCHASE_AVAILABLE is reported as an available-for-purchase result, not an error + if (getErrorMessage(error).includes("USERNAME_PURCHASE_AVAILABLE")) { return { success: true, data: { channelId: params.channelId, - username: params.username.replace(/^@/, ""), + username: cleanUsername(params.username), available: false, purchaseAvailable: true, message: "This username is available for purchase on fragment.com", @@ -103,9 +78,8 @@ export const telegramCheckChannelUsernameExecutor: ToolExecutor< }; } - return { - success: false, - error: msg, - }; + return mapTelegramError(error, { + USERNAME_INVALID: `Invalid username format: "${params.username}"`, + }); } }; diff --git a/src/agent/tools/telegram/chats/create-channel.ts b/src/agent/tools/telegram/chats/create-channel.ts index f77529ea..e7d0a2ee 100644 --- a/src/agent/tools/telegram/chats/create-channel.ts +++ b/src/agent/tools/telegram/chats/create-channel.ts @@ -3,15 +3,10 @@ import { Api } from "telegram"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; +import { getClient, validateChannelUsername } from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); -/** - * Parameters for telegram_create_channel tool - */ -const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_]{3,30}[a-zA-Z0-9]$/; - interface CreateChannelParams { title: string; about?: string; @@ -90,12 +85,12 @@ export const telegramCreateChannelExecutor: ToolExecutor = // Set username if provided (best-effort β€” creation still succeeds on failure) if (username && channel) { - const clean = username.replace(/^@/, ""); + const validation = validateChannelUsername(username); - if (!USERNAME_REGEX.test(clean)) { - data.usernameError = - "Invalid username format. Must be 5-32 characters, alphanumeric and underscores only, cannot start/end with underscore."; + if (!validation.ok) { + data.usernameError = validation.error; } else { + const clean = validation.clean; try { await gramJsClient.invoke( new Api.channels.UpdateUsername({ diff --git a/src/agent/tools/telegram/chats/edit-channel-info.ts b/src/agent/tools/telegram/chats/edit-channel-info.ts index 3fac8318..4e680cc2 100644 --- a/src/agent/tools/telegram/chats/edit-channel-info.ts +++ b/src/agent/tools/telegram/chats/edit-channel-info.ts @@ -3,7 +3,7 @@ import { Api } from "telegram"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; +import { getClient, resolveChannel } from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); @@ -59,18 +59,7 @@ export const telegramEditChannelInfoExecutor: ToolExecutor = async const gramJsClient = getClient(context.bridge); // Use cached peer if available, fall back to raw chatId string - const entity = context.bridge.getPeer(chatId) || chatId; + const entity = isUserBridge(context.bridge) ? context.bridge.getPeer(chatId) || chatId : chatId; // Fetch messages using GramJS getMessages const messages = await gramJsClient.getMessages(entity, { diff --git a/src/agent/tools/telegram/chats/index.ts b/src/agent/tools/telegram/chats/index.ts index a3a400d4..0703806a 100644 --- a/src/agent/tools/telegram/chats/index.ts +++ b/src/agent/tools/telegram/chats/index.ts @@ -44,80 +44,81 @@ export const tools: ToolEntry[] = [ { tool: telegramGetDialogsTool, executor: telegramGetDialogsExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramGetHistoryTool, executor: telegramGetHistoryExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramGetChatInfoTool, executor: telegramGetChatInfoExecutor, + mode: "both", tags: ["social"], }, { tool: telegramMarkAsReadTool, executor: telegramMarkAsReadExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramJoinChannelTool, executor: telegramJoinChannelExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramLeaveChannelTool, executor: telegramLeaveChannelExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramCreateChannelTool, executor: telegramCreateChannelExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramEditChannelInfoTool, executor: telegramEditChannelInfoExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramInviteToChannelTool, executor: telegramInviteToChannelExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramGetAdminedChannelsTool, executor: telegramGetAdminedChannelsExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramCheckChannelUsernameTool, executor: telegramCheckChannelUsernameExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramSetChannelUsernameTool, executor: telegramSetChannelUsernameExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, ]; diff --git a/src/agent/tools/telegram/chats/invite-to-channel.ts b/src/agent/tools/telegram/chats/invite-to-channel.ts index 03fcb9ba..8df1fff4 100644 --- a/src/agent/tools/telegram/chats/invite-to-channel.ts +++ b/src/agent/tools/telegram/chats/invite-to-channel.ts @@ -4,7 +4,7 @@ import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { toLong } from "../../../../utils/gramjs-bigint.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; +import { getClient, resolveChannel } from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); @@ -59,18 +59,7 @@ export const telegramInviteToChannelExecutor: ToolExecutor => { try { const { channelId, username } = params; - const clean = username.replace(/^@/, ""); - - if (clean.length > 0 && !USERNAME_REGEX.test(clean)) { - return { - success: false, - error: - "Invalid username format. Must be 5-32 characters, alphanumeric and underscores only, cannot start/end with underscore.", - }; + const validation = validateChannelUsername(username, { allowEmpty: true }); + if (!validation.ok) { + return { success: false, error: validation.error }; } + const clean = validation.clean; const gramJsClient = getClient(context.bridge); - const entity = await gramJsClient.getEntity(channelId); - - if (entity.className !== "Channel") { - return { - success: false, - error: `Entity is not a channel/group (got ${entity.className})`, - }; - } - - const channel = entity as Api.Channel; + const channel = await resolveChannel(context.bridge, channelId); await gramJsClient.invoke( new Api.channels.UpdateUsername({ channel, @@ -76,16 +67,8 @@ export const telegramSetChannelUsernameExecutor: ToolExecutor { const gifts = data.result?.gifts ?? []; return { success: true, - total: gifts.length, - gifts: gifts.map((g) => ({ - id: g.id, - star_count: g.star_count, - upgrade_star_count: g.upgrade_star_count ?? null, - total_count: g.total_count ?? null, - remaining_count: g.remaining_count ?? null, - premium_only: g.is_premium || false, - emoji: g.sticker?.emoji ?? null, - })), + data: { + total: gifts.length, + gifts: gifts.map((g) => ({ + id: g.id, + star_count: g.star_count, + upgrade_star_count: g.upgrade_star_count ?? null, + total_count: g.total_count ?? null, + remaining_count: g.remaining_count ?? null, + premium_only: g.is_premium || false, + emoji: g.sticker?.emoji ?? null, + })), + }, }; }; export const getAvailableGiftsBotEntry: ToolEntry = { tool, executor, - requiredMode: "bot", + mode: "bot", tags: ["finance"], }; diff --git a/src/agent/tools/telegram/gifts/get-user-gifts.ts b/src/agent/tools/telegram/gifts/get-user-gifts.ts index 8958da8e..d42c2857 100644 --- a/src/agent/tools/telegram/gifts/get-user-gifts.ts +++ b/src/agent/tools/telegram/gifts/get-user-gifts.ts @@ -1,10 +1,11 @@ import { Type } from "@sinclair/typebox"; -import type { ToolEntry } from "../../types.js"; +import type { Tool, ToolEntry } from "../../types.js"; -const tool = { +const tool: Tool = { name: "telegram_get_user_gifts", description: "Get gifts publicly displayed on a user's Telegram profile via Bot API (requires bot_token). Only returns gifts the user has chosen to show. For the full received-gifts inventory including private ones, use telegram_get_my_gifts.", + category: "data-bearing", parameters: Type.Object({ user_id: Type.Number({ description: "Telegram user ID to look up" }), }), @@ -47,25 +48,31 @@ const executor = async (params: any, context: any) => { const result = data.result ?? { total_count: 0, gifts: [] }; return { success: true, - total: result.total_count, - gifts: result.gifts.map((g) => ({ - type: g.type, - gift_id: g.gift?.id, - star_count: g.gift?.star_count, - sender: g.sender_user - ? { id: g.sender_user.id, name: g.sender_user.first_name, username: g.sender_user.username } - : null, - date: g.send_date, - text: g.text || null, - private: g.is_private || false, - convert_stars: g.convert_star_count, - })), + data: { + total: result.total_count, + gifts: result.gifts.map((g) => ({ + type: g.type, + gift_id: g.gift?.id, + star_count: g.gift?.star_count, + sender: g.sender_user + ? { + id: g.sender_user.id, + name: g.sender_user.first_name, + username: g.sender_user.username, + } + : null, + date: g.send_date, + text: g.text || null, + private: g.is_private || false, + convert_stars: g.convert_star_count, + })), + }, }; }; export const getUserGiftsEntry: ToolEntry = { tool, executor, - requiredMode: "bot", + mode: "bot", tags: ["finance"], }; diff --git a/src/agent/tools/telegram/gifts/index.ts b/src/agent/tools/telegram/gifts/index.ts index b981479b..ec95017c 100644 --- a/src/agent/tools/telegram/gifts/index.ts +++ b/src/agent/tools/telegram/gifts/index.ts @@ -51,86 +51,86 @@ export const tools: ToolEntry[] = [ { tool: telegramGetAvailableGiftsTool, executor: telegramGetAvailableGiftsExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramSendGiftTool, executor: telegramSendGiftExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetMyGiftsTool, executor: telegramGetMyGiftsExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramTransferCollectibleTool, executor: telegramTransferCollectibleExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramSetCollectiblePriceTool, executor: telegramSetCollectiblePriceExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetResaleGiftsTool, executor: telegramGetResaleGiftsExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramBuyResaleGiftTool, executor: telegramBuyResaleGiftExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramSetGiftStatusTool, executor: telegramSetGiftStatusExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetCollectibleInfoTool, executor: telegramGetCollectibleInfoExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetUniqueGiftTool, executor: telegramGetUniqueGiftExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetUniqueGiftValueTool, executor: telegramGetUniqueGiftValueExecutor, - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramSendGiftOfferTool, executor: telegramSendGiftOfferExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramResolveGiftOfferTool, executor: telegramResolveGiftOfferExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, getUserGiftsEntry, diff --git a/src/agent/tools/telegram/groups/index.ts b/src/agent/tools/telegram/groups/index.ts index b971fc2f..42b28902 100644 --- a/src/agent/tools/telegram/groups/index.ts +++ b/src/agent/tools/telegram/groups/index.ts @@ -32,47 +32,48 @@ export const tools: ToolEntry[] = [ { tool: telegramGetMeTool, executor: telegramGetMeExecutor, + mode: "both", tags: ["social"], }, { tool: telegramGetParticipantsTool, executor: telegramGetParticipantsExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramKickUserTool, executor: telegramKickUserExecutor, scope: "group-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramBanUserTool, executor: telegramBanUserExecutor, scope: "group-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramUnbanUserTool, executor: telegramUnbanUserExecutor, scope: "group-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramCreateGroupTool, executor: telegramCreateGroupExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramSetChatPhotoTool, executor: telegramSetChatPhotoExecutor, scope: "group-only", - requiredMode: "user", + mode: "user", tags: ["admin"], }, ]; diff --git a/src/agent/tools/telegram/interactive/index.ts b/src/agent/tools/telegram/interactive/index.ts index ff957fbf..947b9e43 100644 --- a/src/agent/tools/telegram/interactive/index.ts +++ b/src/agent/tools/telegram/interactive/index.ts @@ -15,29 +15,31 @@ export const tools: ToolEntry[] = [ { tool: telegramCreatePollTool, executor: telegramCreatePollExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramCreateQuizTool, executor: telegramCreateQuizExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramReplyKeyboardTool, executor: telegramReplyKeyboardExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramReactTool, executor: telegramReactExecutor, + mode: "both", tags: ["core"], }, { tool: telegramSendDiceTool, executor: telegramSendDiceExecutor, + mode: "both", tags: ["media"], }, ]; diff --git a/src/agent/tools/telegram/interactive/send-dice.ts b/src/agent/tools/telegram/interactive/send-dice.ts index 1cbe1eb3..9493ba26 100644 --- a/src/agent/tools/telegram/interactive/send-dice.ts +++ b/src/agent/tools/telegram/interactive/send-dice.ts @@ -12,7 +12,6 @@ const log = createLogger("Tools"); interface SendDiceParams { chat_id: string; emoticon?: "🎲" | "🎯" | "πŸ€" | "⚽" | "🎰" | "🎳"; - reply_to?: number; } export const telegramSendDiceTool: Tool = { @@ -29,11 +28,6 @@ export const telegramSendDiceTool: Tool = { enum: ["🎲", "🎯", "πŸ€", "⚽", "🎰", "🎳"], }) ), - reply_to: Type.Optional( - Type.Number({ - description: "Message ID to reply to", - }) - ), }), }; diff --git a/src/agent/tools/telegram/media/index.ts b/src/agent/tools/telegram/media/index.ts index 03238310..16c85280 100644 --- a/src/agent/tools/telegram/media/index.ts +++ b/src/agent/tools/telegram/media/index.ts @@ -22,42 +22,43 @@ export const tools: ToolEntry[] = [ { tool: telegramSendPhotoTool, executor: telegramSendPhotoExecutor, + mode: "both", tags: ["media"], }, { tool: telegramSendVoiceTool, executor: telegramSendVoiceExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramSendStickerTool, executor: telegramSendStickerExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramSendGifTool, executor: telegramSendGifExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramDownloadMediaTool, executor: telegramDownloadMediaExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: visionAnalyzeTool, executor: visionAnalyzeExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramTranscribeAudioTool, executor: telegramTranscribeAudioExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, ]; diff --git a/src/agent/tools/telegram/media/transcribe-audio.ts b/src/agent/tools/telegram/media/transcribe-audio.ts index 6741568c..56f3cf02 100644 --- a/src/agent/tools/telegram/media/transcribe-audio.ts +++ b/src/agent/tools/telegram/media/transcribe-audio.ts @@ -1,9 +1,8 @@ import { Type } from "@sinclair/typebox"; -import { Api } from "telegram"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; -import { getClient } from "../../../../sdk/telegram-utils.js"; +import { transcribeAudio } from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); @@ -27,78 +26,29 @@ export const telegramTranscribeAudioTool: Tool = { }), }; -const POLL_INTERVAL_MS = 1500; -const MAX_POLL_RETRIES = 15; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - export const telegramTranscribeAudioExecutor: ToolExecutor = async ( params, context ): Promise => { try { - const { chatId, messageId } = params; - - const gramJsClient = getClient(context.bridge); - const entity = await gramJsClient.getEntity(chatId); - - let result = await gramJsClient.invoke( - new Api.messages.TranscribeAudio({ - peer: entity, - msgId: messageId, - }) - ); - - // Poll if transcription is still pending - let retries = 0; - while (result.pending && retries < MAX_POLL_RETRIES) { - retries++; - log.debug(`⏳ Transcription pending, polling (${retries}/${MAX_POLL_RETRIES})...`); - await sleep(POLL_INTERVAL_MS); - - try { - result = await gramJsClient.invoke( - new Api.messages.TranscribeAudio({ - peer: entity, - msgId: messageId, - }) - ); - } catch (pollError: unknown) { - // On transient errors (FLOOD_WAIT, network), keep polling - log.warn(`Transcription poll ${retries} failed: ${getErrorMessage(pollError)}`); - continue; - } - } + const result = await transcribeAudio(context.bridge, params.chatId, params.messageId); if (result.pending) { - log.warn(`Transcription still pending after ${MAX_POLL_RETRIES} retries`); + log.warn(`Transcription still pending after polling`); return { success: true, data: { - transcriptionId: result.transcriptionId?.toString(), - text: result.text || null, + transcriptionId: result.transcriptionId, + text: result.text, pending: true, message: "Transcription is still processing. Try again later.", }, }; } - log.info(`transcribe_audio: msg ${messageId} β†’ "${result.text?.substring(0, 50)}..."`); + log.info(`transcribe_audio: msg ${params.messageId} β†’ "${result.text?.substring(0, 50)}..."`); - return { - success: true, - data: { - transcriptionId: result.transcriptionId?.toString(), - text: result.text, - pending: false, - ...(result.trialRemainsNum !== undefined && { - trialRemainsNum: result.trialRemainsNum, - trialRemainsUntilDate: result.trialRemainsUntilDate, - }), - }, - }; + return { success: true, data: result }; } catch (error: unknown) { // Handle specific Telegram errors const errMsg = getErrorMessage(error); diff --git a/src/agent/tools/telegram/memory/index.ts b/src/agent/tools/telegram/memory/index.ts index 23947964..28cb71c4 100644 --- a/src/agent/tools/telegram/memory/index.ts +++ b/src/agent/tools/telegram/memory/index.ts @@ -10,8 +10,26 @@ export { memorySearchTool, memorySearchExecutor }; export { sessionSearchTool, sessionSearchExecutor }; export const tools: ToolEntry[] = [ - { tool: memoryWriteTool, executor: memoryWriteExecutor, scope: "dm-only", tags: ["core"] }, - { tool: memoryReadTool, executor: memoryReadExecutor, tags: ["core"] }, - { tool: memorySearchTool, executor: memorySearchExecutor, scope: "dm-only", tags: ["core"] }, - { tool: sessionSearchTool, executor: sessionSearchExecutor, scope: "dm-only", tags: ["core"] }, + { + tool: memoryWriteTool, + executor: memoryWriteExecutor, + scope: "dm-only", + mode: "both", + tags: ["core"], + }, + { tool: memoryReadTool, executor: memoryReadExecutor, mode: "both", tags: ["core"] }, + { + tool: memorySearchTool, + executor: memorySearchExecutor, + scope: "dm-only", + mode: "both", + tags: ["core"], + }, + { + tool: sessionSearchTool, + executor: sessionSearchExecutor, + scope: "dm-only", + mode: "both", + tags: ["core"], + }, ]; diff --git a/src/agent/tools/telegram/memory/session-search.ts b/src/agent/tools/telegram/memory/session-search.ts index e0946890..5350b879 100644 --- a/src/agent/tools/telegram/memory/session-search.ts +++ b/src/agent/tools/telegram/memory/session-search.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getEffectiveApiKey } from "../../../client.js"; import { summarizeViaClaude } from "../../../../memory/ai-summarization.js"; +import { escapeFts5Query } from "../../../../memory/search/fts-utils.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; import type { SupportedProvider } from "../../../../config/providers.js"; @@ -29,16 +30,6 @@ interface Cluster { totalRank: number; } -/** - * Escape FTS5 special characters (mirrors hybrid.ts). - */ -function escapeFts5Query(query: string): string { - return query - .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - /** * Extract the raw numeric chat ID from the context chatId. * "telegram:direct:123456" β†’ "123456" diff --git a/src/agent/tools/bot/__tests__/inline-send.test.ts b/src/agent/tools/telegram/messaging/__tests__/inline-send.test.ts similarity index 97% rename from src/agent/tools/bot/__tests__/inline-send.test.ts rename to src/agent/tools/telegram/messaging/__tests__/inline-send.test.ts index b1a812cf..2f9f2ebc 100644 --- a/src/agent/tools/bot/__tests__/inline-send.test.ts +++ b/src/agent/tools/telegram/messaging/__tests__/inline-send.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { botInlineSendExecutor } from "../inline-send.js"; // Mock the gramjs-bigint module -vi.mock("../../../../utils/gramjs-bigint.js", () => ({ +vi.mock("../../../../../utils/gramjs-bigint.js", () => ({ randomLong: () => BigInt(12345), })); @@ -17,7 +17,7 @@ describe("bot_inline_send", () => { const mockBridge = { isAvailable: () => true, getMode: () => "user", - getRawClient: () => ({ + getClient: () => ({ getClient: () => mockGramJsClient, }), }; diff --git a/src/agent/tools/telegram/messaging/index.ts b/src/agent/tools/telegram/messaging/index.ts index 2fff4661..bc523298 100644 --- a/src/agent/tools/telegram/messaging/index.ts +++ b/src/agent/tools/telegram/messaging/index.ts @@ -27,6 +27,7 @@ import { telegramSendScheduledNowTool, telegramSendScheduledNowExecutor, } from "./send-scheduled-now.js"; +import { botInlineSendTool, botInlineSendExecutor } from "./inline-send.js"; import type { ToolEntry } from "../../types.js"; export { telegramSendMessageTool, telegramSendMessageExecutor }; @@ -46,79 +47,92 @@ export { telegramGetRepliesTool, telegramGetRepliesExecutor }; export { telegramGetScheduledMessagesTool, telegramGetScheduledMessagesExecutor }; export { telegramDeleteScheduledMessageTool, telegramDeleteScheduledMessageExecutor }; export { telegramSendScheduledNowTool, telegramSendScheduledNowExecutor }; +export { botInlineSendTool, botInlineSendExecutor }; export const tools: ToolEntry[] = [ { tool: telegramSendMessageTool, executor: telegramSendMessageExecutor, + mode: "both", tags: ["core"], }, { tool: telegramQuoteReplyTool, executor: telegramQuoteReplyExecutor, - requiredMode: "user", + mode: "user", tags: ["core"], }, { tool: telegramGetRepliesTool, executor: telegramGetRepliesExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramEditMessageTool, executor: telegramEditMessageExecutor, + mode: "both", tags: ["core"], }, { tool: telegramScheduleMessageTool, executor: telegramScheduleMessageExecutor, - requiredMode: "user", + mode: "user", tags: ["automation"], }, { tool: telegramGetScheduledMessagesTool, executor: telegramGetScheduledMessagesExecutor, - requiredMode: "user", + mode: "user", tags: ["automation"], }, { tool: telegramDeleteScheduledMessageTool, executor: telegramDeleteScheduledMessageExecutor, - requiredMode: "user", + mode: "user", tags: ["automation"], }, { tool: telegramSendScheduledNowTool, executor: telegramSendScheduledNowExecutor, - requiredMode: "user", + mode: "user", tags: ["automation"], }, { tool: telegramSearchMessagesTool, executor: telegramSearchMessagesExecutor, - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramPinMessageTool, executor: telegramPinMessageExecutor, + mode: "both", tags: ["admin"], }, { tool: telegramUnpinMessageTool, executor: telegramUnpinMessageExecutor, - requiredMode: "user", + mode: "user", tags: ["admin"], }, { tool: telegramForwardMessageTool, executor: telegramForwardMessageExecutor, + mode: "both", tags: ["social"], }, { tool: telegramDeleteMessageTool, executor: telegramDeleteMessageExecutor, + mode: "both", tags: ["core"], }, + { + tool: botInlineSendTool, + executor: botInlineSendExecutor, + scope: "always", + mode: "user", + tags: ["bot"], + }, ]; diff --git a/src/agent/tools/bot/inline-send.ts b/src/agent/tools/telegram/messaging/inline-send.ts similarity index 91% rename from src/agent/tools/bot/inline-send.ts rename to src/agent/tools/telegram/messaging/inline-send.ts index 4063141c..ae792e83 100644 --- a/src/agent/tools/bot/inline-send.ts +++ b/src/agent/tools/telegram/messaging/inline-send.ts @@ -5,11 +5,11 @@ import { Api } from "telegram"; import { Type } from "@sinclair/typebox"; -import { randomLong } from "../../../utils/gramjs-bigint.js"; -import type { Tool, ToolExecutor } from "../types.js"; -import { createLogger } from "../../../utils/logger.js"; -import { getErrorMessage } from "../../../utils/errors.js"; -import { getClient } from "../../../sdk/telegram-utils.js"; +import { randomLong } from "../../../../utils/gramjs-bigint.js"; +import type { Tool, ToolExecutor } from "../../types.js"; +import { createLogger } from "../../../../utils/logger.js"; +import { getErrorMessage } from "../../../../utils/errors.js"; +import { getClient } from "../../../../sdk/telegram-utils.js"; const log = createLogger("BotInlineSend"); diff --git a/src/agent/tools/telegram/messaging/pin.ts b/src/agent/tools/telegram/messaging/pin.ts index 04e98124..8ee0748b 100644 --- a/src/agent/tools/telegram/messaging/pin.ts +++ b/src/agent/tools/telegram/messaging/pin.ts @@ -5,18 +5,16 @@ import { Type } from "@sinclair/typebox"; import { Api } from "telegram"; -import type { TelegramClient } from "telegram"; import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; +import { getClient } from "../../../../sdk/telegram-utils.js"; const log = createLogger("Tools"); interface PinMessageParams { chat_id: string; message_id: number; - silent?: boolean; - both_sides?: boolean; } export const telegramPinMessageTool: Tool = { @@ -29,16 +27,6 @@ export const telegramPinMessageTool: Tool = { message_id: Type.Number({ description: "ID of the message to pin", }), - silent: Type.Optional( - Type.Boolean({ - description: "Pin silently without notification (default: false)", - }) - ), - both_sides: Type.Optional( - Type.Boolean({ - description: "Pin for both sides in private chats (default: true)", - }) - ), }), }; @@ -103,7 +91,7 @@ export const telegramUnpinMessageExecutor: ToolExecutor = as try { const { chat_id, message_id, unpin_all = false } = params; - const client = context.bridge.getRawClient() as TelegramClient; + const client = getClient(context.bridge); if (unpin_all) { await client.invoke( diff --git a/src/agent/tools/telegram/profile/index.ts b/src/agent/tools/telegram/profile/index.ts index 8c705a13..15430ba3 100644 --- a/src/agent/tools/telegram/profile/index.ts +++ b/src/agent/tools/telegram/profile/index.ts @@ -17,28 +17,28 @@ export const tools: ToolEntry[] = [ tool: telegramUpdateProfileTool, executor: telegramUpdateProfileExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramSetBioTool, executor: telegramSetBioExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramSetUsernameTool, executor: telegramSetUsernameExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["social"], }, { tool: telegramSetPersonalChannelTool, executor: telegramSetPersonalChannelExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["social"], }, ]; diff --git a/src/agent/tools/telegram/send-buttons.ts b/src/agent/tools/telegram/send-buttons.ts index fc527576..ce070596 100644 --- a/src/agent/tools/telegram/send-buttons.ts +++ b/src/agent/tools/telegram/send-buttons.ts @@ -58,6 +58,6 @@ export const sendButtonsEntry: ToolEntry = { tool, executor, scope: "always", - requiredMode: "bot", + mode: "bot", tags: ["core", "bot"], }; diff --git a/src/agent/tools/telegram/stars/index.ts b/src/agent/tools/telegram/stars/index.ts index 9cf03009..ef2ec331 100644 --- a/src/agent/tools/telegram/stars/index.ts +++ b/src/agent/tools/telegram/stars/index.ts @@ -17,14 +17,14 @@ export const tools: ToolEntry[] = [ tool: telegramGetStarsBalanceTool, executor: telegramGetStarsBalanceExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, { tool: telegramGetStarsTransactionsTool, executor: telegramGetStarsTransactionsExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["finance"], }, ]; diff --git a/src/agent/tools/telegram/stickers/index.ts b/src/agent/tools/telegram/stickers/index.ts index fe503fc0..4bde04d4 100644 --- a/src/agent/tools/telegram/stickers/index.ts +++ b/src/agent/tools/telegram/stickers/index.ts @@ -13,25 +13,25 @@ export const tools: ToolEntry[] = [ { tool: telegramSearchStickersTool, executor: telegramSearchStickersExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramSearchGifsTool, executor: telegramSearchGifsExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramGetMyStickersTool, executor: telegramGetMyStickersExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, { tool: telegramAddStickerSetTool, executor: telegramAddStickerSetExecutor, - requiredMode: "user", + mode: "user", tags: ["media"], }, ]; diff --git a/src/agent/tools/telegram/stories/index.ts b/src/agent/tools/telegram/stories/index.ts index 2155f8b6..0a4f78ea 100644 --- a/src/agent/tools/telegram/stories/index.ts +++ b/src/agent/tools/telegram/stories/index.ts @@ -8,7 +8,7 @@ export const tools: ToolEntry[] = [ tool: telegramSendStoryTool, executor: telegramSendStoryExecutor, scope: "dm-only", - requiredMode: "user", + mode: "user", tags: ["social"], }, ]; diff --git a/src/agent/tools/telegram/tasks/create-scheduled-task.ts b/src/agent/tools/telegram/tasks/create-scheduled-task.ts index 7ba54443..331a15c3 100644 --- a/src/agent/tools/telegram/tasks/create-scheduled-task.ts +++ b/src/agent/tools/telegram/tasks/create-scheduled-task.ts @@ -15,6 +15,7 @@ const log = createLogger("Tools"); interface CreateScheduledTaskParams { description: string; scheduleDate?: string; + scheduleInSeconds?: number; payload?: string; reason?: string; priority?: number; @@ -57,10 +58,17 @@ export const telegramCreateScheduledTaskTool: Tool = { description: Type.String({ description: "What the task is about (e.g., 'Check TON price and alert if > $5')", }), + scheduleInSeconds: Type.Optional( + Type.Number({ + description: + "PREFERRED for relative timing ('in 60 seconds', 'every minute', 'in 2 hours'): number of seconds from now until execution. Timezone-proof β€” the server computes the exact moment. Always use this for delays instead of scheduleDate.", + minimum: 1, + }) + ), scheduleDate: Type.Optional( Type.String({ description: - "When to execute the task (ISO 8601 format, e.g., '2024-12-25T10:00:00Z' or Unix timestamp). Optional if dependsOn is provided - task will execute when dependencies complete.", + "Absolute execution time, ISO 8601. IMPORTANT: must be UTC ('Z' suffix) or carry an explicit offset (e.g. '+02:00'). Message timestamps are shown in LOCAL time, so do NOT copy the local wall-clock and append 'Z' β€” that shifts the task by your UTC offset. For relative delays use scheduleInSeconds. Optional if dependsOn is provided.", }) ), payload: Type.Optional( @@ -108,19 +116,30 @@ export const telegramCreateScheduledTaskExecutor: ToolExecutor => { try { - const { description, scheduleDate, payload, reason, priority, dependsOn } = params; + const { description, scheduleDate, scheduleInSeconds, payload, reason, priority, dependsOn } = + params; - // Validate: either scheduleDate OR dependsOn must be provided - if (!scheduleDate && (!dependsOn || dependsOn.length === 0)) { + // Validate: scheduleInSeconds, scheduleDate, OR dependsOn must be provided + if ( + scheduleInSeconds === undefined && + !scheduleDate && + (!dependsOn || dependsOn.length === 0) + ) { return { success: false, - error: "Either scheduleDate or dependsOn must be provided", + error: "One of scheduleInSeconds, scheduleDate, or dependsOn must be provided", }; } - // Parse schedule date if provided + // Resolve the schedule timestamp. Relative (scheduleInSeconds) is timezone-proof + // and takes precedence over the absolute scheduleDate. let scheduleTimestamp: number | undefined; - if (scheduleDate) { + if (scheduleInSeconds !== undefined) { + if (!Number.isFinite(scheduleInSeconds) || scheduleInSeconds < 1) { + return { success: false, error: "scheduleInSeconds must be a positive number of seconds" }; + } + scheduleTimestamp = Math.floor(Date.now() / 1000) + Math.floor(scheduleInSeconds); + } else if (scheduleDate) { const parsedDate = new Date(scheduleDate); if (!isNaN(parsedDate.getTime())) { scheduleTimestamp = Math.floor(parsedDate.getTime() / 1000); diff --git a/src/agent/tools/telegram/tasks/index.ts b/src/agent/tools/telegram/tasks/index.ts index 1467bec9..9d84a1f3 100644 --- a/src/agent/tools/telegram/tasks/index.ts +++ b/src/agent/tools/telegram/tasks/index.ts @@ -10,7 +10,7 @@ export const tools: ToolEntry[] = [ { tool: telegramCreateScheduledTaskTool, executor: telegramCreateScheduledTaskExecutor, - requiredMode: "user", + mode: "user", tags: ["automation"], }, ]; diff --git a/src/agent/tools/ton/dex-quote.ts b/src/agent/tools/ton/dex-quote.ts index ce176703..4987c86c 100644 --- a/src/agent/tools/ton/dex-quote.ts +++ b/src/agent/tools/ton/dex-quote.ts @@ -4,8 +4,9 @@ import type { TonClient } from "@ton/ton"; import { Address } from "@ton/core"; import { getCachedTonClient } from "../../../ton/wallet-service.js"; import { StonApiClient } from "@ston-fi/api"; -import { Factory, Asset, PoolType, ReadinessStatus } from "@dedust/sdk"; +import { Factory, Asset } from "@dedust/sdk"; import { DEDUST_FACTORY_MAINNET, NATIVE_TON_ADDRESS } from "../dedust/constants.js"; +import { findDedustPool } from "../dedust/pool.js"; import { getDecimals, toUnits, fromUnits } from "../dedust/asset-cache.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; @@ -148,39 +149,19 @@ async function getDedustQuote( const fromAssetObj = isTonInput ? Asset.native() : Asset.jetton(Address.parse(fromAsset)); const toAssetObj = isTonOutput ? Asset.native() : Asset.jetton(Address.parse(toAsset)); - // Try volatile pool first, then stable - let pool; - let poolType = "volatile"; - - try { - pool = tonClient.open(await factory.getPool(PoolType.VOLATILE, [fromAssetObj, toAssetObj])); - const status = await pool.getReadinessStatus(); - if (status !== ReadinessStatus.READY) { - // Try stable pool - pool = tonClient.open(await factory.getPool(PoolType.STABLE, [fromAssetObj, toAssetObj])); - const stableStatus = await pool.getReadinessStatus(); - if (stableStatus !== ReadinessStatus.READY) { - return { - dex: "DeDust", - expectedOutput: 0, - minOutput: 0, - rate: 0, - available: false, - error: "No pool available", - }; - } - poolType = "stable"; - } - } catch { + // Find the best pool (volatile first, then stable fallback) + const poolMatch = await findDedustPool(tonClient, factory, fromAssetObj, toAssetObj); + if (!poolMatch) { return { dex: "DeDust", expectedOutput: 0, minOutput: 0, rate: 0, available: false, - error: "Pool lookup failed", + error: "No pool available", }; } + const { pool, poolType } = poolMatch; // Resolve correct decimals const fromDecimals = await getDecimals(isTonInput ? "ton" : fromAsset); diff --git a/src/agent/tools/ton/index.ts b/src/agent/tools/ton/index.ts index 71d5a6bf..439e9afd 100644 --- a/src/agent/tools/ton/index.ts +++ b/src/agent/tools/ton/index.ts @@ -32,19 +32,41 @@ export { jettonHistoryTool, jettonHistoryExecutor }; export { dexQuoteTool, dexQuoteExecutor }; export const tools: ToolEntry[] = [ - { tool: tonSendTool, executor: tonSendExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: tonGetAddressTool, executor: tonGetAddressExecutor, tags: ["finance"] }, - { tool: tonGetBalanceTool, executor: tonGetBalanceExecutor, tags: ["finance"] }, - { tool: tonPriceTool, executor: tonPriceExecutor, tags: ["finance"] }, - { tool: tonGetTransactionsTool, executor: tonGetTransactionsExecutor, tags: ["finance"] }, - { tool: tonMyTransactionsTool, executor: tonMyTransactionsExecutor, tags: ["finance"] }, - { tool: tonChartTool, executor: tonChartExecutor, tags: ["finance"] }, - { tool: nftListTool, executor: nftListExecutor, tags: ["finance"] }, - { tool: jettonSendTool, executor: jettonSendExecutor, scope: "dm-only", tags: ["finance"] }, - { tool: jettonBalancesTool, executor: jettonBalancesExecutor, tags: ["finance"] }, - { tool: jettonInfoTool, executor: jettonInfoExecutor, tags: ["finance"] }, - { tool: jettonPriceTool, executor: jettonPriceExecutor, tags: ["finance"] }, - { tool: jettonHoldersTool, executor: jettonHoldersExecutor, tags: ["finance"] }, - { tool: jettonHistoryTool, executor: jettonHistoryExecutor, tags: ["finance"] }, - { tool: dexQuoteTool, executor: dexQuoteExecutor, tags: ["finance"] }, + { + tool: tonSendTool, + executor: tonSendExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { tool: tonGetAddressTool, executor: tonGetAddressExecutor, mode: "both", tags: ["finance"] }, + { tool: tonGetBalanceTool, executor: tonGetBalanceExecutor, mode: "both", tags: ["finance"] }, + { tool: tonPriceTool, executor: tonPriceExecutor, mode: "both", tags: ["finance"] }, + { + tool: tonGetTransactionsTool, + executor: tonGetTransactionsExecutor, + mode: "both", + tags: ["finance"], + }, + { + tool: tonMyTransactionsTool, + executor: tonMyTransactionsExecutor, + mode: "both", + tags: ["finance"], + }, + { tool: tonChartTool, executor: tonChartExecutor, mode: "both", tags: ["finance"] }, + { tool: nftListTool, executor: nftListExecutor, mode: "both", tags: ["finance"] }, + { + tool: jettonSendTool, + executor: jettonSendExecutor, + scope: "dm-only", + mode: "both", + tags: ["finance"], + }, + { tool: jettonBalancesTool, executor: jettonBalancesExecutor, mode: "both", tags: ["finance"] }, + { tool: jettonInfoTool, executor: jettonInfoExecutor, mode: "both", tags: ["finance"] }, + { tool: jettonPriceTool, executor: jettonPriceExecutor, mode: "both", tags: ["finance"] }, + { tool: jettonHoldersTool, executor: jettonHoldersExecutor, mode: "both", tags: ["finance"] }, + { tool: jettonHistoryTool, executor: jettonHistoryExecutor, mode: "both", tags: ["finance"] }, + { tool: dexQuoteTool, executor: dexQuoteExecutor, mode: "both", tags: ["finance"] }, ]; diff --git a/src/agent/tools/ton/jetton-send.ts b/src/agent/tools/ton/jetton-send.ts index aa3de48a..51d21d76 100644 --- a/src/agent/tools/ton/jetton-send.ts +++ b/src/agent/tools/ton/jetton-send.ts @@ -1,12 +1,15 @@ import { Type } from "@sinclair/typebox"; import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js"; -import { WalletContractV5R1, toNano, internal } from "@ton/ton"; -import { Address, SendMode, beginCell } from "@ton/core"; +import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js"; +import { toNano, internal } from "@ton/ton"; +import { Address, beginCell } from "@ton/core"; +import { sendWalletTx, tonExplorerTxUrl } from "../../../ton/confirm.js"; import { tonapiFetch } from "../../../constants/api-endpoints.js"; import { getErrorMessage } from "../../../utils/errors.js"; import { createLogger } from "../../../utils/logger.js"; import { withTxLock } from "../../../ton/tx-lock.js"; +import { toUnits } from "../../../ton/units.js"; +import { openWallet } from "../../../ton/wallet-open.js"; const log = createLogger("Tools"); @@ -103,9 +106,7 @@ export const jettonSendExecutor: ToolExecutor = async ( const currentBalance = BigInt(jettonBalance.balance); // Convert amount to blockchain units (string-based to avoid float precision loss) - const amountStr = amount.toFixed(decimals); - const [whole, frac = ""] = amountStr.split("."); - const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals)); + const amountInUnits = toUnits(amount, decimals); // Check sufficient balance if (amountInUnits > currentBalance) { @@ -138,26 +139,17 @@ export const jettonSendExecutor: ToolExecutor = async ( .storeRef(comment ? forwardPayload : beginCell().endCell()) // forward_payload .endCell(); - const keyPair = await getKeyPair(); - if (!keyPair) { + const client = await getCachedTonClient(); + const opened = await openWallet(client); + if (!opened) { return { success: false, error: "Wallet key derivation failed." }; } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const walletContract = client.open(wallet); + const { keyPair, contract: walletContract } = opened; return withTxLock(async () => { - const seqno = await walletContract.getSeqno(); - // Send transfer to our jetton wallet (NOT to recipient!) - await walletContract.sendTransfer({ - seqno, + const sent = await sendWalletTx(client, walletContract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: Address.parse(senderJettonWallet), @@ -168,6 +160,13 @@ export const jettonSendExecutor: ToolExecutor = async ( ], }); + if (!sent) { + return { + success: false, + error: "Jetton transfer failed or could not be confirmed on-chain.", + }; + } + return { success: true, data: { @@ -177,7 +176,8 @@ export const jettonSendExecutor: ToolExecutor = async ( to, from: walletData.address, comment: comment || null, - message: `Sent ${amount} ${symbol} to ${to}${comment ? ` (${comment})` : ""}\n Transaction sent (check balance in ~30 seconds)`, + txHash: sent.hash, + message: `Sent ${amount} ${symbol} to ${to}${comment ? ` (${comment})` : ""} β€” confirmed on-chain\n tx ${sent.hash}\n ${tonExplorerTxUrl(sent.hash)}`, }, }; }); diff --git a/src/agent/tools/ton/send.ts b/src/agent/tools/ton/send.ts index d8ecf4eb..0ac854f8 100644 --- a/src/agent/tools/ton/send.ts +++ b/src/agent/tools/ton/send.ts @@ -57,12 +57,13 @@ export const tonSendExecutor: ToolExecutor = async ( }; } - const txRef = await sendTon({ toAddress: to, amount, comment }); + const result = await sendTon({ toAddress: to, amount, comment }); - if (!txRef) { + if (!result) { return { success: false, - error: "TON transfer failed β€” check blockchain node connectivity.", + error: + "TON transfer failed or could not be confirmed on-chain β€” check wallet balance and node connectivity.", }; } @@ -73,7 +74,8 @@ export const tonSendExecutor: ToolExecutor = async ( amount, comment: comment || null, from: walletData.address, - message: `Sent ${amount} TON to ${to}${comment ? ` (${comment})` : ""}`, + txHash: result.hash, + message: `Sent ${amount} TON to ${to}${comment ? ` (${comment})` : ""} β€” tx ${result.hash}`, }, }; } catch (error) { diff --git a/src/agent/tools/tool-index.ts b/src/agent/tools/tool-index.ts index 7200c724..cedd3458 100644 --- a/src/agent/tools/tool-index.ts +++ b/src/agent/tools/tool-index.ts @@ -8,6 +8,7 @@ import { TOOL_RAG_KEYWORD_WEIGHT, } from "../../constants/limits.js"; import { createLogger } from "../../utils/logger.js"; +import { escapeFts5Query, bm25ToScore } from "../../memory/search/fts-utils.js"; const log = createLogger("ToolRAG"); @@ -25,24 +26,6 @@ export interface ToolSearchResult { keywordScore?: number; } -/** - * Escape FTS5 special characters to prevent syntax errors. - */ -function escapeFts5Query(query: string): string { - return query - .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -/** - * Convert BM25 rank to normalized score. - * FTS5 rank is negative; more negative = better match. - */ -function bm25ToScore(rank: number): number { - return 1 / (1 + Math.exp(rank)); -} - /** * Semantic index for tool definitions. * Uses the same hybrid search pattern (vector + FTS5) as the knowledge RAG. diff --git a/src/agent/tools/types.ts b/src/agent/tools/types.ts index 188ac9c5..37aae7db 100644 --- a/src/agent/tools/types.ts +++ b/src/agent/tools/types.ts @@ -57,6 +57,22 @@ export type ToolScope = | "allowlist" | "disabled"; +/** + * Telegram execution mode a tool supports. + * - "user": userbot only β€” relies on an MTProto capability the Bot API lacks + * - "bot": Bot API only β€” relies on a capability exclusive to bots + * - "both": works identically in either mode + * + * Mandatory on every built-in tool: the compiler refuses an undeclared tool. + */ +export type ToolMode = "user" | "bot" | "both"; + +/** + * The two runtime bridge modes a registry can operate in. A tool's "both" is a + * declaration, not a runtime mode β€” the registry itself is always user or bot. + */ +export type RuntimeMode = Exclude; + /** * Tool definition compatible with pi-ai */ @@ -85,6 +101,14 @@ export type ToolExecutor = ( export interface RegisteredTool { tool: Tool; executor: ToolExecutor; + /** Declared non-trivial scope (undefined for the default always/open). */ + scope?: ToolScope; + /** Telegram mode this tool runs in. */ + mode: ToolMode; + /** Module this tool belongs to (name prefix for built-ins, plugin name otherwise). */ + module: string; + /** Toolset tags (e.g. "core", "finance"). */ + tags?: string[]; } /** @@ -96,8 +120,8 @@ export interface ToolEntry { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool executors accept varied param shapes executor: ToolExecutor; scope?: ToolScope; - /** When set to "user", excluded in bot mode. When set to "bot", excluded in user mode. */ - requiredMode?: "user" | "bot"; + /** Telegram mode(s) this tool runs in. Mandatory β€” every tool must declare it. */ + mode: ToolMode; /** Toolset tags for profile-based filtering (e.g. "core", "finance", "social") */ tags?: string[]; } @@ -120,6 +144,8 @@ export interface PluginModule { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- tool executors accept varied param shapes executor: ToolExecutor; scope?: ToolScope; + /** Telegram mode(s) this module tool runs in. Defaults to "both" when omitted. */ + mode?: ToolMode; }>; /** Start background jobs (polling, timers, etc.) */ start?(context: PluginContext): Promise; diff --git a/src/agent/tools/web/fetch.ts b/src/agent/tools/web/fetch.ts index 554c991c..ff697b59 100644 --- a/src/agent/tools/web/fetch.ts +++ b/src/agent/tools/web/fetch.ts @@ -1,11 +1,11 @@ // src/agent/tools/web/fetch.ts import { Type } from "@sinclair/typebox"; -import { tavily } from "@tavily/core"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; +import type { Tool, ToolExecutor } from "../types.js"; import { WEB_FETCH_MAX_TEXT_LENGTH } from "../../../constants/limits.js"; import { sanitizeForContext } from "../../../utils/sanitize.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import { withToolErrors } from "../wrap.js"; +import { resolveTavily } from "./tavily.js"; interface WebFetchParams { url: string; @@ -28,19 +28,10 @@ export const webFetchTool: Tool = { }), }; -export const webFetchExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const apiKey = context.config?.tavily_api_key; - if (!apiKey) { - return { - success: false, - error: - "Tavily API key not configured. Set tavily_api_key in config.yaml (free at https://tavily.com)", - }; - } +export const webFetchExecutor: ToolExecutor = withToolErrors( + async (params, context) => { + const tav = resolveTavily(context); + if (!tav.ok) return tav.error; const { url, max_length = WEB_FETCH_MAX_TEXT_LENGTH } = params; @@ -59,7 +50,7 @@ export const webFetchExecutor: ToolExecutor = async ( }; } - const client = tavily({ apiKey }); + const client = tav.client; const response = await client.extract([url], { extractDepth: "basic", }); @@ -92,10 +83,5 @@ export const webFetchExecutor: ToolExecutor = async ( truncated, }, }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error), - }; } -}; +); diff --git a/src/agent/tools/web/index.ts b/src/agent/tools/web/index.ts index 9aa0a7fd..bbf3e6c0 100644 --- a/src/agent/tools/web/index.ts +++ b/src/agent/tools/web/index.ts @@ -8,6 +8,6 @@ export { webSearchTool, webSearchExecutor }; export { webFetchTool, webFetchExecutor }; export const tools: ToolEntry[] = [ - { tool: webSearchTool, executor: webSearchExecutor, tags: ["web"] }, - { tool: webFetchTool, executor: webFetchExecutor, tags: ["web"] }, + { tool: webSearchTool, executor: webSearchExecutor, mode: "both", tags: ["web"] }, + { tool: webFetchTool, executor: webFetchExecutor, mode: "both", tags: ["web"] }, ]; diff --git a/src/agent/tools/web/search.ts b/src/agent/tools/web/search.ts index d667b9db..24a9600e 100644 --- a/src/agent/tools/web/search.ts +++ b/src/agent/tools/web/search.ts @@ -1,11 +1,11 @@ // src/agent/tools/web/search.ts import { Type } from "@sinclair/typebox"; -import { tavily } from "@tavily/core"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; +import type { Tool, ToolExecutor } from "../types.js"; import { WEB_SEARCH_MAX_RESULTS } from "../../../constants/limits.js"; import { sanitizeForContext } from "../../../utils/sanitize.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import { withToolErrors } from "../wrap.js"; +import { resolveTavily } from "./tavily.js"; interface WebSearchParams { query: string; @@ -33,24 +33,15 @@ export const webSearchTool: Tool = { }), }; -export const webSearchExecutor: ToolExecutor = async ( - params, - context -): Promise => { - try { - const apiKey = context.config?.tavily_api_key; - if (!apiKey) { - return { - success: false, - error: - "Tavily API key not configured. Set tavily_api_key in config.yaml (free at https://tavily.com)", - }; - } +export const webSearchExecutor: ToolExecutor = withToolErrors( + async (params, context) => { + const tav = resolveTavily(context); + if (!tav.ok) return tav.error; const { query, count = 5, topic = "general" } = params; const maxResults = Math.min(Math.max(1, count), WEB_SEARCH_MAX_RESULTS); - const client = tavily({ apiKey }); + const client = tav.client; const response = await client.search(query, { maxResults, topic, @@ -73,10 +64,5 @@ export const webSearchExecutor: ToolExecutor = async ( results, }, }; - } catch (error) { - return { - success: false, - error: getErrorMessage(error), - }; } -}; +); diff --git a/src/agent/tools/web/tavily.ts b/src/agent/tools/web/tavily.ts new file mode 100644 index 00000000..586492cb --- /dev/null +++ b/src/agent/tools/web/tavily.ts @@ -0,0 +1,26 @@ +import { tavily } from "@tavily/core"; +import type { ToolContext, ToolResult } from "../types.js"; + +type TavilyClient = ReturnType; + +/** + * Resolve a Tavily client from the configured API key, or a failed ToolResult + * carrying the standard "key not configured" message. Shared by web_fetch and + * web_search so the check + client init lives in one place. + */ +export function resolveTavily( + context: ToolContext +): { ok: true; client: TavilyClient } | { ok: false; error: ToolResult } { + const apiKey = context.config?.tavily_api_key; + if (!apiKey) { + return { + ok: false, + error: { + success: false, + error: + "Tavily API key not configured. Set tavily_api_key in config.yaml (free at https://tavily.com)", + }, + }; + } + return { ok: true, client: tavily({ apiKey }) }; +} diff --git a/src/agent/tools/workspace/delete.ts b/src/agent/tools/workspace/delete.ts index 4f5c99d4..1b27b11c 100644 --- a/src/agent/tools/workspace/delete.ts +++ b/src/agent/tools/workspace/delete.ts @@ -2,25 +2,15 @@ import { Type } from "@sinclair/typebox"; import { unlinkSync, rmdirSync, readdirSync, rmSync } from "fs"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { validatePath, WorkspaceSecurityError } from "../../../workspace/index.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { validatePath, PROTECTED_WORKSPACE_FILES } from "../../../workspace/index.js"; +import { withToolErrors } from "../wrap.js"; interface WorkspaceDeleteParams { path: string; recursive?: boolean; } -// Files that cannot be deleted (core workspace files) -const PROTECTED_WORKSPACE_FILES = [ - "SOUL.md", - "STRATEGY.md", - "SECURITY.md", - "MEMORY.md", - "IDENTITY.md", - "USER.md", -]; - export const workspaceDeleteTool: Tool = { name: "workspace_delete", description: @@ -38,11 +28,8 @@ export const workspaceDeleteTool: Tool = { }), }; -export const workspaceDeleteExecutor: ToolExecutor = async ( - params, - _context -): Promise => { - try { +export const workspaceDeleteExecutor: ToolExecutor = + withToolErrors(async (params) => { const { path, recursive = false } = params; // Validate the path @@ -86,16 +73,4 @@ export const workspaceDeleteExecutor: ToolExecutor = asyn message: `Successfully deleted ${validated.isDirectory ? "directory" : "file"}`, }, }; - } catch (error) { - if (error instanceof WorkspaceSecurityError) { - return { - success: false, - error: error.message, - }; - } - return { - success: false, - error: getErrorMessage(error), - }; - } -}; + }); diff --git a/src/agent/tools/workspace/index.ts b/src/agent/tools/workspace/index.ts index 80f7c46b..a1767e90 100644 --- a/src/agent/tools/workspace/index.ts +++ b/src/agent/tools/workspace/index.ts @@ -20,21 +20,24 @@ export const tools: ToolEntry[] = [ tool: workspaceWriteTool, executor: workspaceWriteExecutor, scope: "dm-only", + mode: "both", tags: ["workspace"], }, { tool: workspaceDeleteTool, executor: workspaceDeleteExecutor, scope: "dm-only", + mode: "both", tags: ["workspace"], }, { tool: workspaceRenameTool, executor: workspaceRenameExecutor, scope: "dm-only", + mode: "both", tags: ["workspace"], }, - { tool: workspaceListTool, executor: workspaceListExecutor, tags: ["workspace"] }, - { tool: workspaceReadTool, executor: workspaceReadExecutor, tags: ["workspace"] }, - { tool: workspaceInfoTool, executor: workspaceInfoExecutor, tags: ["workspace"] }, + { tool: workspaceListTool, executor: workspaceListExecutor, mode: "both", tags: ["workspace"] }, + { tool: workspaceReadTool, executor: workspaceReadExecutor, mode: "both", tags: ["workspace"] }, + { tool: workspaceInfoTool, executor: workspaceInfoExecutor, mode: "both", tags: ["workspace"] }, ]; diff --git a/src/agent/tools/workspace/list.ts b/src/agent/tools/workspace/list.ts index 1c8cc181..3ed31984 100644 --- a/src/agent/tools/workspace/list.ts +++ b/src/agent/tools/workspace/list.ts @@ -3,13 +3,9 @@ import { Type } from "@sinclair/typebox"; import { readdirSync, lstatSync } from "fs"; import { join } from "path"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { - validateDirectory, - WORKSPACE_ROOT, - WorkspaceSecurityError, -} from "../../../workspace/index.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { validateDirectory, WORKSPACE_ROOT } from "../../../workspace/index.js"; +import { withToolErrors } from "../wrap.js"; interface WorkspaceListParams { path?: string; @@ -87,11 +83,8 @@ function listDir(dirPath: string, recursive: boolean, filter: string): FileInfo[ return results; } -export const workspaceListExecutor: ToolExecutor = async ( - params, - _context -): Promise => { - try { +export const workspaceListExecutor: ToolExecutor = + withToolErrors(async (params) => { const { path = "", recursive = false, filter = "all" } = params; // Validate the path @@ -119,16 +112,4 @@ export const workspaceListExecutor: ToolExecutor = async ( workspaceRoot: WORKSPACE_ROOT, }, }; - } catch (error) { - if (error instanceof WorkspaceSecurityError) { - return { - success: false, - error: error.message, - }; - } - return { - success: false, - error: getErrorMessage(error), - }; - } -}; + }); diff --git a/src/agent/tools/workspace/read.ts b/src/agent/tools/workspace/read.ts index 3425a96c..9a7534b8 100644 --- a/src/agent/tools/workspace/read.ts +++ b/src/agent/tools/workspace/read.ts @@ -2,9 +2,9 @@ import { Type } from "@sinclair/typebox"; import { readFileSync, lstatSync } from "fs"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { validateReadPath, WorkspaceSecurityError } from "../../../workspace/index.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { validateReadPath, TEXT_FILE_EXTENSIONS } from "../../../workspace/index.js"; +import { withToolErrors } from "../wrap.js"; interface WorkspaceReadParams { path: string; @@ -35,11 +35,8 @@ export const workspaceReadTool: Tool = { }), }; -export const workspaceReadExecutor: ToolExecutor = async ( - params, - _context -): Promise => { - try { +export const workspaceReadExecutor: ToolExecutor = + withToolErrors(async (params) => { const { path, encoding = "utf-8", maxSize = 1024 * 1024 } = params; // Validate the path @@ -56,22 +53,7 @@ export const workspaceReadExecutor: ToolExecutor = async ( } // Check if it's a text file or binary - const textExtensions = [ - ".md", - ".txt", - ".json", - ".csv", - ".yaml", - ".yml", - ".xml", - ".html", - ".css", - ".js", - ".ts", - ".py", - ".sh", - ]; - const isTextFile = textExtensions.includes(validated.extension); + const isTextFile = TEXT_FILE_EXTENSIONS.includes(validated.extension); if (!isTextFile && encoding === "utf-8") { // Return metadata only for binary files @@ -105,16 +87,4 @@ export const workspaceReadExecutor: ToolExecutor = async ( modified: stats.mtime.toISOString(), }, }; - } catch (error) { - if (error instanceof WorkspaceSecurityError) { - return { - success: false, - error: error.message, - }; - } - return { - success: false, - error: getErrorMessage(error), - }; - } -}; + }); diff --git a/src/agent/tools/workspace/rename.ts b/src/agent/tools/workspace/rename.ts index 23156480..3c5b8a17 100644 --- a/src/agent/tools/workspace/rename.ts +++ b/src/agent/tools/workspace/rename.ts @@ -4,9 +4,9 @@ import { Type } from "@sinclair/typebox"; import { renameSync, existsSync } from "fs"; import { dirname } from "path"; import { mkdirSync } from "fs"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { validatePath, WorkspaceSecurityError } from "../../../workspace/index.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { validatePath } from "../../../workspace/index.js"; +import { withToolErrors } from "../wrap.js"; interface WorkspaceRenameParams { from: string; @@ -34,11 +34,8 @@ export const workspaceRenameTool: Tool = { }), }; -export const workspaceRenameExecutor: ToolExecutor = async ( - params, - _context -): Promise => { - try { +export const workspaceRenameExecutor: ToolExecutor = + withToolErrors(async (params) => { const { from, to, overwrite = false } = params; // Validate source path (must exist) @@ -79,16 +76,4 @@ export const workspaceRenameExecutor: ToolExecutor = asyn message: `File renamed successfully`, }, }; - } catch (error) { - if (error instanceof WorkspaceSecurityError) { - return { - success: false, - error: error.message, - }; - } - return { - success: false, - error: getErrorMessage(error), - }; - } -}; + }); diff --git a/src/agent/tools/workspace/write.ts b/src/agent/tools/workspace/write.ts index d9612a24..65abc43f 100644 --- a/src/agent/tools/workspace/write.ts +++ b/src/agent/tools/workspace/write.ts @@ -4,9 +4,9 @@ import { Type } from "@sinclair/typebox"; import { writeFileSync, appendFileSync, mkdirSync, existsSync } from "fs"; import { dirname } from "path"; import { MAX_WRITE_SIZE } from "../../../constants/limits.js"; -import type { Tool, ToolExecutor, ToolResult } from "../types.js"; -import { validateWritePath, WorkspaceSecurityError } from "../../../workspace/index.js"; -import { getErrorMessage } from "../../../utils/errors.js"; +import type { Tool, ToolExecutor } from "../types.js"; +import { validateWritePath, MEMORY_SCAN_FILES } from "../../../workspace/index.js"; +import { withToolErrors } from "../wrap.js"; import { scanMemoryContent } from "../../../utils/memory-guard.js"; interface WorkspaceWriteParams { @@ -48,11 +48,8 @@ export const workspaceWriteTool: Tool = { }), }; -export const workspaceWriteExecutor: ToolExecutor = async ( - params, - _context -): Promise => { - try { +export const workspaceWriteExecutor: ToolExecutor = + withToolErrors(async (params) => { const { path, content, encoding = "utf-8", append = false, createDirs = true } = params; // Validate the path (no extension enforcement - fix from audit) @@ -64,12 +61,9 @@ export const workspaceWriteExecutor: ToolExecutor = async mkdirSync(parentDir, { recursive: true }); } - // SECURITY: Scan memory-sensitive files for injection attempts + // SECURITY: Scan memory-sensitive files (and anything under memory/) for injection const isMemoryFile = - validated.relativePath === "MEMORY.md" || - validated.relativePath === "HEARTBEAT.md" || - validated.relativePath === "USER.md" || - validated.relativePath === "IDENTITY.md" || + MEMORY_SCAN_FILES.includes(validated.relativePath) || validated.relativePath.startsWith("memory/"); if (isMemoryFile && encoding !== "base64") { const scan = scanMemoryContent(content); @@ -115,16 +109,4 @@ export const workspaceWriteExecutor: ToolExecutor = async message: `File ${append ? "appended" : "written"} successfully`, }, }; - } catch (error) { - if (error instanceof WorkspaceSecurityError) { - return { - success: false, - error: error.message, - }; - } - return { - success: false, - error: getErrorMessage(error), - }; - } -}; + }); diff --git a/src/agent/tools/wrap.ts b/src/agent/tools/wrap.ts new file mode 100644 index 00000000..78b3bc69 --- /dev/null +++ b/src/agent/tools/wrap.ts @@ -0,0 +1,17 @@ +import type { ToolExecutor } from "./types.js"; +import { getErrorMessage } from "../../utils/errors.js"; + +/** + * Wrap a tool executor so any thrown error becomes the standard failed ToolResult + * rather than escaping the ToolResult contract. Single source for the generic + * try/catch β†’ { success: false, error } mapping repeated across executors. + */ +export function withToolErrors

(fn: ToolExecutor

): ToolExecutor

{ + return async (params, context) => { + try { + return await fn(params, context); + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }; +} diff --git a/src/api/middleware/auth.ts b/src/api/middleware/auth.ts index 77fc2406..225e21f3 100644 --- a/src/api/middleware/auth.ts +++ b/src/api/middleware/auth.ts @@ -1,7 +1,8 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { timingSafeEqual } from "node:crypto"; import type { MiddlewareHandler } from "hono"; import { HTTPException } from "hono/http-exception"; import { createProblemResponse } from "../schemas/common.js"; +import { hashApiKey } from "../../utils/crypto-tokens.js"; interface FailedAttempt { count: number; @@ -17,10 +18,6 @@ function normalizeIp(ip: string): string { return ip; } -function hashApiKey(key: string): string { - return createHash("sha256").update(key).digest("hex"); -} - export function createAuthMiddleware(config: { keyHash: string; allowedIps: string[]; diff --git a/src/api/routes/agent.ts b/src/api/routes/agent.ts index 4074ceb4..383da2c2 100644 --- a/src/api/routes/agent.ts +++ b/src/api/routes/agent.ts @@ -1,21 +1,97 @@ import { Hono } from "hono"; +import type { Context } from "hono"; import type { AgentLifecycle } from "../../agent/lifecycle.js"; import { createProblemResponse } from "../schemas/common.js"; import { createLogger } from "../../utils/logger.js"; -const log = createLogger("ManagementAPI"); +const log = createLogger("AgentRoutes"); -export function createAgentRoutes(lifecycle: AgentLifecycle | null | undefined) { +/** + * Per-server error mapper. The API emits RFC 9457 problem+json while the WebUI + * emits `{ error }`, so each server injects its own envelope formatter; the + * shared lifecycle logic (guard rules, fire-and-forget transitions) lives here. + */ +export type AgentRouteErrorMapper = ( + c: Context, + status: number, + title: string, + detail: string +) => Response; + +/** Default mapper used by the Management API (RFC 9457 problem+json). */ +export const problemErrorMapper: AgentRouteErrorMapper = (c, status, title, detail) => + createProblemResponse(c, status, title, detail); + +/** + * Agent lifecycle routes: start, stop, status and restart. + * + * Shared by the WebUI (`/api/agent/*`) and Management API (`/v1/agent/*`) + * servers. The unavailable (503) and transient-conflict (409) responses use the + * injected `errorResponse` mapper so each server keeps its own error envelope; + * the `{ state }` conflicts (running/stopped) and success payloads are identical + * across servers and emitted directly. + */ +export function createAgentRoutes( + lifecycle: AgentLifecycle | null | undefined, + options: { errorResponse?: AgentRouteErrorMapper } = {} +) { + const errorResponse = options.errorResponse ?? problemErrorMapper; const app = new Hono(); - app.post("/restart", async (c) => { - if (!lifecycle) { - return createProblemResponse(c, 503, "Service Unavailable", "Agent lifecycle not available"); + const unavailable = (c: Context) => + errorResponse(c, 503, "Service Unavailable", "Agent lifecycle not available"); + + app.post("/start", async (c) => { + if (!lifecycle) return unavailable(c); + + const state = lifecycle.getState(); + if (state === "running") { + return c.json({ state: "running" }, 409); + } + if (state === "stopping") { + return errorResponse(c, 409, "Conflict", "Agent is currently stopping, please wait"); + } + + // Fire-and-forget: start is async, we return immediately + lifecycle.start().catch((err: Error) => { + log.error({ err }, "Agent start failed"); + }); + return c.json({ state: "starting" }); + }); + + app.post("/stop", async (c) => { + if (!lifecycle) return unavailable(c); + + const state = lifecycle.getState(); + if (state === "stopped") { + return c.json({ state: "stopped" }, 409); } + if (state === "starting") { + return errorResponse(c, 409, "Conflict", "Agent is currently starting, please wait"); + } + + // Fire-and-forget: stop is async, we return immediately + lifecycle.stop().catch((err: Error) => { + log.error({ err }, "Agent stop failed"); + }); + return c.json({ state: "stopping" }); + }); + + app.get("/status", (c) => { + if (!lifecycle) return unavailable(c); + return c.json({ + state: lifecycle.getState(), + uptime: lifecycle.getUptime(), + error: lifecycle.getError() ?? null, + }); + }); + + app.post("/restart", async (c) => { + if (!lifecycle) return unavailable(c); const state = lifecycle.getState(); if (state === "starting" || state === "stopping") { - return createProblemResponse(c, 409, "Conflict", `Agent is currently ${state}, please wait`); + return errorResponse(c, 409, "Conflict", `Agent is currently ${state}, please wait`); } // Fire-and-forget restart: stop then start @@ -25,7 +101,7 @@ export function createAgentRoutes(lifecycle: AgentLifecycle | null | undefined) await lifecycle.stop(); } await lifecycle.start(); - log.info("Agent restarted via Management API"); + log.info("Agent restarted"); } catch (error) { log.error({ err: error }, "Agent restart failed"); } diff --git a/src/api/server.ts b/src/api/server.ts index f7b22265..332048e1 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,23 +1,23 @@ import { Hono } from "hono"; -import { bodyLimit } from "hono/body-limit"; import { timeout } from "hono/timeout"; -import { streamSSE } from "hono/streaming"; -import { serve, type ServerType } from "@hono/node-server"; +import type { serve, ServerType } from "@hono/node-server"; import type { HttpBindings } from "@hono/node-server"; import { createServer as createHttpsServer } from "node:https"; -import { randomBytes, createHash } from "node:crypto"; -import type { Server as HttpServer } from "node:http"; +import { randomBytes } from "node:crypto"; import { HTTPException } from "hono/http-exception"; import { existsSync, statSync } from "node:fs"; import { join } from "node:path"; import { createLogger } from "../utils/logger.js"; +import { hashApiKey } from "../utils/crypto-tokens.js"; import { TELETON_ROOT } from "../workspace/paths.js"; import { ensureTlsCert, type TlsCert } from "./tls.js"; import type { ApiServerDeps } from "./deps.js"; import { createDepsAdapter } from "./deps.js"; import type { ApiConfig } from "../config/schema.js"; -import type { StateChangeEvent } from "../agent/lifecycle.js"; +import { createLifecycleSSE } from "../webui/lifecycle-sse.js"; +import { applySecurityMiddleware, sharedBodyLimit } from "../webui/http-common.js"; +import { startHonoServer, stopHonoServer } from "../utils/http-server.js"; import { createProblem } from "./schemas/common.js"; // Middleware @@ -27,19 +27,7 @@ import { globalRateLimit, mutatingRateLimit, readRateLimit } from "./middleware/ import { auditMiddleware } from "./middleware/audit.js"; // Existing WebUI route factories -import { createStatusRoutes } from "../webui/routes/status.js"; -import { createToolsRoutes } from "../webui/routes/tools.js"; -import { createLogsRoutes } from "../webui/routes/logs.js"; -import { createMemoryRoutes } from "../webui/routes/memory.js"; -import { createSoulRoutes } from "../webui/routes/soul.js"; -import { createPluginsRoutes } from "../webui/routes/plugins.js"; -import { createMcpRoutes } from "../webui/routes/mcp.js"; -import { createWorkspaceRoutes } from "../webui/routes/workspace.js"; -import { createTasksRoutes } from "../webui/routes/tasks.js"; -import { createConfigRoutes } from "../webui/routes/config.js"; -import { createMarketplaceRoutes } from "../webui/routes/marketplace.js"; -import { createHooksRoutes } from "../webui/routes/hooks.js"; -import { createTonProxyRoutes } from "../webui/routes/ton-proxy.js"; +import { SHARED_ROUTE_FACTORIES } from "../webui/routes/shared.js"; import { createSetupRoutes } from "../webui/routes/setup.js"; // New API routes @@ -59,11 +47,6 @@ function generateApiKey(): string { return KEY_PREFIX + randomBytes(32).toString("base64url"); } -/** Hash an API key with SHA-256 */ -function hashApiKey(key: string): string { - return createHash("sha256").update(key).digest("hex"); -} - /** Check setup completeness by probing key files */ function getSetupStatus(): Record { return { @@ -143,18 +126,11 @@ export class ApiServer { // 2. Body limit (2MB) this.app.use( "*", - bodyLimit({ - maxSize: 2 * 1024 * 1024, - onError: (c) => { - return c.json( - createProblem(413, "Payload Too Large", "Request body exceeds 2MB limit"), - 413, - { - "Content-Type": "application/problem+json", - } - ); - }, - }) + sharedBodyLimit((c) => + c.json(createProblem(413, "Payload Too Large", "Request body exceeds 2MB limit"), 413, { + "Content-Type": "application/problem+json", + }) + ) ); // 3. Timeout (30s) β€” exclude SSE endpoints @@ -165,13 +141,8 @@ export class ApiServer { return timeout(30_000)(c, next); }); - // 4. Security headers - this.app.use("*", async (c, next) => { - await next(); - c.res.headers.set("X-Content-Type-Options", "nosniff"); - c.res.headers.set("X-Frame-Options", "DENY"); - c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); - }); + // 4. Security headers (HSTS β€” HTTPS-only API) + applySecurityMiddleware(this.app, { hsts: true }); } private setupRoutes(): void { @@ -225,102 +196,14 @@ export class ApiServer { const adaptedDeps = createDepsAdapter(this.deps); // Mount existing WebUI route factories under /v1/ - this.app.route("/v1/status", createStatusRoutes(adaptedDeps)); - this.app.route("/v1/tools", createToolsRoutes(adaptedDeps)); - this.app.route("/v1/logs", createLogsRoutes(adaptedDeps)); - this.app.route("/v1/memory", createMemoryRoutes(adaptedDeps)); - this.app.route("/v1/soul", createSoulRoutes(adaptedDeps)); - this.app.route("/v1/plugins", createPluginsRoutes(adaptedDeps)); - this.app.route("/v1/mcp", createMcpRoutes(adaptedDeps)); - this.app.route("/v1/workspace", createWorkspaceRoutes(adaptedDeps)); - this.app.route("/v1/tasks", createTasksRoutes(adaptedDeps)); - this.app.route("/v1/config", createConfigRoutes(adaptedDeps)); - this.app.route("/v1/marketplace", createMarketplaceRoutes(adaptedDeps)); - this.app.route("/v1/hooks", createHooksRoutes(adaptedDeps)); - this.app.route("/v1/ton-proxy", createTonProxyRoutes(adaptedDeps)); + for (const [seg, make] of SHARED_ROUTE_FACTORIES) { + this.app.route(`/v1/${seg}`, make(adaptedDeps)); + } // Setup routes (no agent deps needed, keyHash for config persistence) this.app.route("/v1/setup", createSetupRoutes({ keyHash: this.keyHash })); - // Agent lifecycle routes (inline, same pattern as WebUI) - this.app.post("/v1/agent/start", async (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json( - createProblem(503, "Service Unavailable", "Agent lifecycle not available"), - 503, - { - "Content-Type": "application/problem+json", - } - ); - } - const state = lifecycle.getState(); - if (state === "running") { - return c.json({ state: "running" }, 409); - } - if (state === "stopping") { - return c.json( - createProblem(409, "Conflict", "Agent is currently stopping, please wait"), - 409, - { - "Content-Type": "application/problem+json", - } - ); - } - lifecycle.start().catch((err: Error) => { - log.error({ err }, "Agent start failed"); - }); - return c.json({ state: "starting" }); - }); - - this.app.post("/v1/agent/stop", async (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json( - createProblem(503, "Service Unavailable", "Agent lifecycle not available"), - 503, - { - "Content-Type": "application/problem+json", - } - ); - } - const state = lifecycle.getState(); - if (state === "stopped") { - return c.json({ state: "stopped" }, 409); - } - if (state === "starting") { - return c.json( - createProblem(409, "Conflict", "Agent is currently starting, please wait"), - 409, - { - "Content-Type": "application/problem+json", - } - ); - } - lifecycle.stop().catch((err: Error) => { - log.error({ err }, "Agent stop failed"); - }); - return c.json({ state: "stopping" }); - }); - - this.app.get("/v1/agent/status", (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json( - createProblem(503, "Service Unavailable", "Agent lifecycle not available"), - 503, - { - "Content-Type": "application/problem+json", - } - ); - } - return c.json({ - state: lifecycle.getState(), - uptime: lifecycle.getUptime(), - error: lifecycle.getError() ?? null, - }); - }); - + // Agent lifecycle SSE stream (start/stop/status/restart live in createAgentRoutes) this.app.get("/v1/agent/events", (c) => { const lifecycle = this.deps.lifecycle; if (!lifecycle) { @@ -333,51 +216,7 @@ export class ApiServer { ); } - return streamSSE(c, async (stream) => { - let aborted = false; - - stream.onAbort(() => { - aborted = true; - }); - - const now = Date.now(); - await stream.writeSSE({ - event: "status", - id: String(now), - data: JSON.stringify({ - state: lifecycle.getState(), - error: lifecycle.getError() ?? null, - timestamp: now, - }), - retry: 3000, - }); - - const onStateChange = (event: StateChangeEvent) => { - if (aborted) return; - void stream.writeSSE({ - event: "status", - id: String(event.timestamp), - data: JSON.stringify({ - state: event.state, - error: event.error ?? null, - timestamp: event.timestamp, - }), - }); - }; - - lifecycle.on("stateChange", onStateChange); - - while (!aborted) { - await stream.sleep(30_000); - if (aborted) break; - await stream.writeSSE({ - event: "ping", - data: "", - }); - } - - lifecycle.off("stateChange", onStateChange); - }); + return createLifecycleSSE(c, lifecycle); }); // New API-only routes under /v1/ @@ -419,50 +258,33 @@ export class ApiServer { this.setupMiddleware(); this.setupRoutes(); - return new Promise((resolve, reject) => { - try { - this.server = serve( - { - fetch: this.app.fetch as Parameters[0]["fetch"], - port: this.config.port, - createServer: createHttpsServer, - serverOptions: { - cert: tls.cert, - key: tls.key, - }, - }, - (info) => { - (this.server as HttpServer).maxConnections = 20; - log.info(`Management API server running on https://localhost:${info.port}`); - if (this.apiKey) { - log.info( - `API key: ${KEY_PREFIX}${this.apiKey.slice(KEY_PREFIX.length, KEY_PREFIX.length + 4)}...` - ); - } - log.info(`TLS fingerprint: ${tls.fingerprint.slice(0, 16)}...`); - resolve(); + try { + this.server = await startHonoServer({ + fetch: this.app.fetch as Parameters[0]["fetch"], + port: this.config.port, + createServer: createHttpsServer, + serverOptions: { cert: tls.cert, key: tls.key }, + maxConnections: 20, + onListen: (info) => { + log.info(`Management API server running on https://localhost:${info.port}`); + if (this.apiKey) { + log.info( + `API key: ${KEY_PREFIX}${this.apiKey.slice(KEY_PREFIX.length, KEY_PREFIX.length + 4)}...` + ); } - ); - - (this.server as HttpServer).on("error", (err: Error) => { - log.error({ err }, "Management API server error"); - reject(err); - }); - } catch (error) { - reject(error); - } - }); + log.info(`TLS fingerprint: ${tls.fingerprint.slice(0, 16)}...`); + }, + }); + } catch (err) { + log.error({ err }, "Management API server error"); + throw err; + } } async stop(): Promise { if (this.server) { - return new Promise((resolve) => { - (this.server as HttpServer).closeAllConnections(); - (this.server as HttpServer).close(() => { - log.info("Management API server stopped"); - resolve(); - }); - }); + await stopHonoServer(this.server); + log.info("Management API server stopped"); } } diff --git a/src/bot/index.ts b/src/bot/index.ts index 98111f3b..e23bcbe6 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -4,8 +4,8 @@ */ import { Bot, type MiddlewareFn, type Context } from "grammy"; -import { Api } from "telegram"; import type Database from "better-sqlite3"; +import { getGramJSErrorMessage, getGrammyErrorDescription } from "../utils/errors.js"; import type { BotConfig, DealContext } from "./types.js"; import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../constants/limits.js"; import { decodeCallback } from "./types.js"; @@ -28,11 +28,15 @@ import { } from "./services/message-builder.js"; import { toGrammyKeyboard, - toTLMarkup, hasStyledButtons, + stripCustomEmoji, type StyledButtonDef, -} from "./services/styled-keyboard.js"; -import { parseHtml, stripCustomEmoji } from "./services/html-parser.js"; +} from "../sdk/formatting.js"; +import { + answerInlineQueryStyled, + editInlineViaGramJS, + editInlineViaGramJSStrict, +} from "./services/inline-transport.js"; import { GramJSBotClient } from "./gramjs-bot.js"; import { getWalletAddress } from "../ton/wallet-service.js"; import { createLogger } from "../utils/logger.js"; @@ -122,7 +126,15 @@ export class DealBot { if (this.gramjsBot?.isConnected() && hasStyledButtons(buttons)) { try { - await this.answerInlineQueryStyled(queryId, dealId, deal, text, buttons); + await answerInlineQueryStyled({ + gramjsBot: this.gramjsBot, + queryId, + resultId: dealId, + title: `πŸ“‹ Deal #${dealId}`, + description: this.formatShortDescription(deal), + html: text, + buttons, + }); return; } catch (error) { log.warn({ err: error }, "[Bot] GramJS styled answer failed, falling back to Grammy"); @@ -169,10 +181,15 @@ export class DealBot { let edited = false; if (this.gramjsBot?.isConnected()) { try { - await this.editViaGramJS(inlineMessageId, text, buttons); + await editInlineViaGramJSStrict({ + gramjsBot: this.gramjsBot, + inlineMessageId, + html: text, + buttons, + }); edited = true; } catch (error: unknown) { - const errMsg = (error as Record)?.errorMessage; + const errMsg = getGramJSErrorMessage(error); log.warn( { err: error }, `[Bot] chosen_inline_result GramJS edit failed: ${errMsg || error}` @@ -189,7 +206,7 @@ export class DealBot { reply_markup: keyboard, }); } catch (error: unknown) { - const errDesc = (error as Record)?.description; + const errDesc = getGrammyErrorDescription(error); log.error( { err: error }, `[Bot] chosen_inline_result Grammy fallback failed: ${errDesc || error}` @@ -267,43 +284,6 @@ export class DealBot { }); } - /** - * Answer inline query via GramJS with styled buttons. - * Custom emojis stripped (SetInlineBotResults doesn't support them). - */ - private async answerInlineQueryStyled( - queryId: string, - dealId: string, - deal: DealContext, - htmlText: string, - buttons: StyledButtonDef[][] - ): Promise { - if (!this.gramjsBot) throw new Error("GramJS bot not available"); - - const strippedHtml = stripCustomEmoji(htmlText); - const { text: plainText, entities } = parseHtml(strippedHtml); - const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined; - - await this.gramjsBot.answerInlineQuery({ - queryId, - results: [ - new Api.InputBotInlineResult({ - id: dealId, - type: "article", - title: `πŸ“‹ Deal #${dealId}`, - description: this.formatShortDescription(deal), - sendMessage: new Api.InputBotInlineMessageText({ - message: plainText, - entities: entities.length > 0 ? entities : undefined, - noWebpage: true, - replyMarkup: markup, - }), - }), - ], - cacheTime: 0, - }); - } - private async handleAccept(ctx: Context, deal: DealContext): Promise { if (deal.status !== "proposed") { await ctx.answerCallbackQuery({ text: "Already processed" }); @@ -398,14 +378,17 @@ export class DealBot { if (this.gramjsBot?.isConnected()) { try { - await this.editViaGramJS(inlineMsgId, text, buttons); + await editInlineViaGramJS({ + gramjsBot: this.gramjsBot, + inlineMessageId: inlineMsgId, + html: text, + buttons, + }); return; } catch (error: unknown) { - const errMsg = (error as Record)?.errorMessage; - if (errMsg === "MESSAGE_NOT_MODIFIED") return; log.warn( { err: error }, - `[Bot] GramJS edit failed, falling back to Grammy: ${errMsg || error}` + `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` ); } } @@ -418,7 +401,7 @@ export class DealBot { reply_markup: keyboard, }); } catch (error: unknown) { - const desc = (error as Record)?.description; + const desc = getGrammyErrorDescription(error); if (desc?.includes("message is not modified")) return; log.error({ err: error }, "[Bot] Failed to edit inline message"); } @@ -431,14 +414,17 @@ export class DealBot { ): Promise { if (this.gramjsBot?.isConnected() && buttons) { try { - await this.editViaGramJS(inlineMessageId, text, buttons); + await editInlineViaGramJS({ + gramjsBot: this.gramjsBot, + inlineMessageId, + html: text, + buttons, + }); return; } catch (error: unknown) { - const errMsg = (error as Record)?.errorMessage; - if (errMsg === "MESSAGE_NOT_MODIFIED") return; log.warn( { err: error }, - `[Bot] GramJS edit failed, falling back to Grammy: ${errMsg || error}` + `[Bot] GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` ); } } @@ -455,24 +441,6 @@ export class DealBot { } } - private async editViaGramJS( - inlineMessageId: string, - htmlText: string, - buttons: StyledButtonDef[][] - ): Promise { - if (!this.gramjsBot) throw new Error("GramJS bot not available"); - - const { text: plainText, entities } = parseHtml(htmlText); - const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined; - - await this.gramjsBot.editInlineMessageByStringId({ - inlineMessageId, - text: plainText, - entities: entities.length > 0 ? entities : undefined, - replyMarkup: markup, - }); - } - private formatShortDescription(deal: DealContext): string { const userGives = deal.userGivesType === "ton" diff --git a/src/bot/inline-router.ts b/src/bot/inline-router.ts index 689a5525..665a8244 100644 --- a/src/bot/inline-router.ts +++ b/src/bot/inline-router.ts @@ -8,6 +8,7 @@ import type { Context, MiddlewareFn } from "grammy"; import type { InlineQueryResult } from "@grammyjs/types"; +import { getGramJSErrorMessage } from "../utils/errors.js"; import type { InlineQueryContext, InlineResult, @@ -16,9 +17,13 @@ import type { ButtonDef, } from "@teleton-agent/sdk"; import type { GramJSBotClient } from "./gramjs-bot.js"; +import { splitPrefix } from "./types.js"; import { createLogger } from "../utils/logger.js"; -import { toGrammyKeyboard, toTLMarkup, prefixButtons } from "./services/styled-keyboard.js"; -import { stripCustomEmoji, parseHtml } from "./services/html-parser.js"; +import { toGrammyKeyboard, prefixButtons, stripCustomEmoji } from "../sdk/formatting.js"; +import { editInlineViaGramJS } from "./services/inline-transport.js"; + +// Re-exported for callers that import the router's glob compiler (now shared). +export { compileGlob } from "../sdk/formatting.js"; const log = createLogger("InlineRouter"); @@ -37,15 +42,6 @@ export interface PluginBotHandlers { onChosenResult?: (ctx: ChosenResultContext) => Promise; } -/** - * Compile a glob-like pattern to a RegExp. - * Supports `*` as wildcard matching any sequence of characters. - */ -export function compileGlob(pattern: string): RegExp { - const regexStr = "^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "(.*)") + "$"; - return new RegExp(regexStr); -} - /** * Match a pre-compiled glob regex against a string. * Returns match groups (the parts matched by `*`) or null. @@ -56,8 +52,6 @@ function globMatch(regex: RegExp, input: string): string[] | null { return match.slice(1); } -// prefixButtons imported from shared styled-keyboard.ts - export class InlineRouter { private plugins = new Map(); private gramjsBot: GramJSBotClient | null = null; @@ -81,17 +75,24 @@ export class InlineRouter { return this.plugins.has(name); } + // ── Prefix routing contract ────────────────────────────────────────────── + // This middleware is installed BEFORE DealBot in the Grammy chain. It peels a + // `prefix:rest` segment off inline queries / callback data / chosen-result ids + // (via splitPrefix) and dispatches to the matching registered plugin. + // + // A prefix is RESERVED for DealBot β€” accept Β· decline Β· sent Β· copy_addr Β· + // copy_memo Β· refresh (see decodeCallback in types.ts) β€” and plugins must not + // register those names. Any prefix without a registered plugin handler (or no + // colon at all) falls through via next() to DealBot's own handlers. middleware(): MiddlewareFn { return async (ctx, next) => { // ── Inline Query ───────────────────────────────── if (ctx.inlineQuery) { - const rawQuery = ctx.inlineQuery.query.trim(); - const colonIdx = rawQuery.indexOf(":"); - if (colonIdx > 0) { - const prefix = rawQuery.slice(0, colonIdx); - const plugin = this.plugins.get(prefix); + const split = splitPrefix(ctx.inlineQuery.query.trim()); + if (split) { + const plugin = this.plugins.get(split.prefix); if (plugin?.onInlineQuery) { - await this.handleInlineQuery(ctx, prefix, rawQuery.slice(colonIdx + 1), plugin); + await this.handleInlineQuery(ctx, split.prefix, split.rest, plugin); return; // handled, don't fall through } } @@ -101,14 +102,11 @@ export class InlineRouter { // ── Callback Query ─────────────────────────────── if (ctx.callbackQuery?.data) { - const data = ctx.callbackQuery.data; - const colonIdx = data.indexOf(":"); - if (colonIdx > 0) { - const prefix = data.slice(0, colonIdx); - const plugin = this.plugins.get(prefix); + const split = splitPrefix(ctx.callbackQuery.data); + if (split) { + const plugin = this.plugins.get(split.prefix); if (plugin?.onCallback) { - const strippedData = data.slice(colonIdx + 1); - await this.handleCallback(ctx, prefix, strippedData, plugin); + await this.handleCallback(ctx, split.prefix, split.rest, plugin); return; } } @@ -117,13 +115,11 @@ export class InlineRouter { // ── Chosen Inline Result ───────────────────────── if (ctx.chosenInlineResult) { - const resultId = ctx.chosenInlineResult.result_id; - const colonIdx = resultId.indexOf(":"); - if (colonIdx > 0) { - const prefix = resultId.slice(0, colonIdx); - const plugin = this.plugins.get(prefix); + const split = splitPrefix(ctx.chosenInlineResult.result_id); + if (split) { + const plugin = this.plugins.get(split.prefix); if (plugin?.onChosenResult) { - await this.handleChosenResult(ctx, prefix, plugin); + await this.handleChosenResult(ctx, split.prefix, plugin); return; } } @@ -233,21 +229,17 @@ export class InlineRouter { const inlineMsgId = ctx.callbackQuery?.inline_message_id; if (inlineMsgId && gramjsBotRef?.isConnected() && styledButtons) { try { - const strippedHtml = stripCustomEmoji(text); - const { text: plainText, entities } = parseHtml(strippedHtml); - const markup = toTLMarkup(styledButtons); - - await gramjsBotRef.editInlineMessageByStringId({ + await editInlineViaGramJS({ + gramjsBot: gramjsBotRef, inlineMessageId: inlineMsgId, - text: plainText, - entities: entities.length > 0 ? entities : undefined, - replyMarkup: markup, + html: stripCustomEmoji(text), + buttons: styledButtons, }); return; } catch (error: unknown) { - const errMsg = (error as Record)?.errorMessage; - if (errMsg === "MESSAGE_NOT_MODIFIED") return; - log.debug(`GramJS edit failed, falling back to Grammy: ${errMsg || error}`); + log.debug( + `GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` + ); } } @@ -293,8 +285,7 @@ export class InlineRouter { if (!chosenResult || !plugin.onChosenResult) return; const resultId = chosenResult.result_id; - const colonIdx = resultId.indexOf(":"); - const strippedResultId = colonIdx > 0 ? resultId.slice(colonIdx + 1) : resultId; + const strippedResultId = splitPrefix(resultId)?.rest ?? resultId; const crCtx: ChosenResultContext = { resultId: strippedResultId, diff --git a/src/bot/services/deal-service.ts b/src/bot/services/deal-service.ts index 246cb445..b77d0c28 100644 --- a/src/bot/services/deal-service.ts +++ b/src/bot/services/deal-service.ts @@ -6,6 +6,13 @@ import type Database from "better-sqlite3"; import type { DealContext, DealStatus } from "../types.js"; import { DEAL_VERIFICATION_WINDOW_SECONDS } from "../../constants/limits.js"; +/** Column list selected by every deal query, mapped by rowToDeal. */ +const DEAL_COLUMNS = `id, user_telegram_id, user_username, chat_id, + user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton, + agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton, + profit_ton, status, created_at, expires_at, + inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at`; + interface DealRow { id: string; user_telegram_id: number; @@ -61,11 +68,7 @@ export function getDeal(db: Database.Database, dealId: string): DealContext | nu const row = db .prepare( `SELECT - id, user_telegram_id, user_username, chat_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton, - profit_ton, status, created_at, expires_at, - inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at + ${DEAL_COLUMNS} FROM deals WHERE id = ?` ) .get(dealId) as DealRow | undefined; @@ -155,11 +158,7 @@ export function getDealsAwaitingVerification(db: Database.Database): DealContext const rows = db .prepare( `SELECT - id, user_telegram_id, user_username, chat_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton, - profit_ton, status, created_at, expires_at, - inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at + ${DEAL_COLUMNS} FROM deals WHERE status = 'payment_claimed' ORDER BY payment_claimed_at ASC @@ -177,11 +176,7 @@ export function getDealsAwaitingExecution(db: Database.Database): DealContext[] const rows = db .prepare( `SELECT - id, user_telegram_id, user_username, chat_id, - user_gives_type, user_gives_ton_amount, user_gives_gift_slug, user_gives_value_ton, - agent_gives_type, agent_gives_ton_amount, agent_gives_gift_slug, agent_gives_value_ton, - profit_ton, status, created_at, expires_at, - inline_message_id, payment_claimed_at, user_payment_verified_at, completed_at + ${DEAL_COLUMNS} FROM deals WHERE status = 'verified' AND agent_sent_at IS NULL ORDER BY user_payment_verified_at ASC diff --git a/src/bot/services/html-parser.ts b/src/bot/services/html-parser.ts deleted file mode 100644 index 98205600..00000000 --- a/src/bot/services/html-parser.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Simple HTML β†’ MessageEntity parser for MTProto - * Converts our limited HTML subset (, , , ) to - * plain text + Telegram MessageEntity array. - * - * Entity offsets use UTF-16 code units (matching Telegram's spec and JS string.length). - */ - -import { Api } from "telegram"; -import { toLong } from "../../utils/gramjs-bigint.js"; - -export interface ParsedMessage { - text: string; - entities: Api.TypeMessageEntity[]; -} - -/** - * Parse HTML string to plain text + MessageEntity array - */ -export function parseHtml(html: string): ParsedMessage { - const entities: Api.TypeMessageEntity[] = []; - let text = ""; - let pos = 0; - - // Stack for tracking open tags - const stack: { tag: string; offset: number; url?: string; emojiId?: string }[] = []; - - while (pos < html.length) { - if (html[pos] === "<") { - const endBracket = html.indexOf(">", pos); - if (endBracket === -1) { - // Malformed HTML - treat '<' as literal - text += "<"; - pos++; - continue; - } - - const tagStr = html.substring(pos + 1, endBracket); - - if (tagStr.startsWith("/")) { - // Closing tag - const tagName = tagStr.substring(1).toLowerCase().trim(); - for (let i = stack.length - 1; i >= 0; i--) { - if (stack[i].tag === tagName) { - const open = stack[i]; - const length = text.length - open.offset; - - if (length > 0) { - switch (tagName) { - case "b": - case "strong": - entities.push(new Api.MessageEntityBold({ offset: open.offset, length })); - break; - case "i": - case "em": - entities.push(new Api.MessageEntityItalic({ offset: open.offset, length })); - break; - case "code": - entities.push(new Api.MessageEntityCode({ offset: open.offset, length })); - break; - case "a": - if (open.url) { - entities.push( - new Api.MessageEntityTextUrl({ - offset: open.offset, - length, - url: open.url, - }) - ); - } - break; - case "tg-emoji": - if (open.emojiId) { - entities.push( - new Api.MessageEntityCustomEmoji({ - offset: open.offset, - length, - documentId: toLong(open.emojiId), - }) - ); - } - break; - } - } - - stack.splice(i, 1); - break; - } - } - } else { - // Opening tag - const spaceIdx = tagStr.indexOf(" "); - const tagName = (spaceIdx >= 0 ? tagStr.substring(0, spaceIdx) : tagStr).toLowerCase(); - const attrs = spaceIdx >= 0 ? tagStr.substring(spaceIdx) : ""; - - let url: string | undefined; - let emojiId: string | undefined; - if (tagName === "a") { - const hrefMatch = attrs.match(/href="([^"]+)"/); - if (hrefMatch) { - const rawUrl = unescapeHtml(hrefMatch[1]); - if (/^(javascript|data|vbscript|file):/i.test(rawUrl.trim())) { - url = "#"; - } else { - url = rawUrl; - } - } - } else if (tagName === "tg-emoji") { - const eidMatch = attrs.match(/emoji-id="([^"]+)"/); - if (eidMatch) emojiId = eidMatch[1]; - } - - stack.push({ tag: tagName, offset: text.length, url, emojiId }); - } - - pos = endBracket + 1; - } else if (html.substring(pos, pos + 5) === "&") { - text += "&"; - pos += 5; - } else if (html.substring(pos, pos + 4) === "<") { - text += "<"; - pos += 4; - } else if (html.substring(pos, pos + 4) === ">") { - text += ">"; - pos += 4; - } else if (html.substring(pos, pos + 6) === """) { - text += '"'; - pos += 6; - } else { - text += html[pos]; - pos++; - } - } - - return { text, entities }; -} - -/** - * Strip tags for Grammy/Bot API fallback (keeps unicode emoji inside) - */ -export function stripCustomEmoji(html: string): string { - return html.replace(/]*>([^<]*)<\/tg-emoji>/g, "$1"); -} - -function unescapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"'); -} diff --git a/src/bot/services/inline-transport.ts b/src/bot/services/inline-transport.ts new file mode 100644 index 00000000..84c86413 --- /dev/null +++ b/src/bot/services/inline-transport.ts @@ -0,0 +1,126 @@ +/** + * Inline-message MTProto transport. + * + * Encapsulates every GramJS (MTProto) operation the bot performs on inline + * messages β€” answering inline queries with styled buttons and editing inline + * messages β€” so DealBot and the plugin SDK stay free of transport plumbing. + * + * Depends only on a GramJSBotClient and the pure formatting helpers; it has no + * knowledge of deals or business logic. Each higher-level caller keeps its own + * Grammy (Bot API) fallback. + */ + +import { Api } from "telegram"; +import type { GramJSBotClient } from "../gramjs-bot.js"; +import { + toTLMarkup, + hasStyledButtons, + parseHtml, + stripCustomEmoji, + type StyledButtonDef, +} from "../../sdk/formatting.js"; +import { getGramJSErrorMessage } from "../../utils/errors.js"; + +/** + * Edit an inline message via GramJS MTProto (raw β€” does NOT swallow errors). + * + * The GramJS trunk shared by every inline-edit site: parseHtml β†’ toTLMarkup β†’ + * editInlineMessageByStringId. Throws on any GramJS error, including + * MESSAGE_NOT_MODIFIED, so callers can decide how to handle each case. + */ +async function editInlineRaw(params: { + gramjsBot: GramJSBotClient; + inlineMessageId: string; + html: string; + buttons?: StyledButtonDef[][]; +}): Promise { + const { gramjsBot, inlineMessageId, html, buttons } = params; + + const { text: plainText, entities } = parseHtml(html); + const markup = buttons && hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined; + + await gramjsBot.editInlineMessageByStringId({ + inlineMessageId, + text: plainText, + entities: entities.length > 0 ? entities : undefined, + replyMarkup: markup, + }); +} + +/** + * Edit an inline message via GramJS MTProto (styled buttons, custom emoji). + * + * @returns `true` if the edit succeeded (or was a no-op because the content was + * unchanged β€” MESSAGE_NOT_MODIFIED is swallowed). Throws on any other GramJS + * error so callers can run their own Grammy fallback. + */ +export async function editInlineViaGramJS(params: { + gramjsBot: GramJSBotClient; + inlineMessageId: string; + html: string; + buttons?: StyledButtonDef[][]; +}): Promise { + try { + await editInlineRaw(params); + return true; + } catch (error: unknown) { + if (getGramJSErrorMessage(error) === "MESSAGE_NOT_MODIFIED") return true; + throw error; + } +} + +/** + * Edit an inline message via GramJS without swallowing MESSAGE_NOT_MODIFIED. + * + * Used by chosen_inline_result, where any failure (including a no-op edit on a + * freshly created message) must surface to the caller's Grammy fallback path. + */ +export async function editInlineViaGramJSStrict(params: { + gramjsBot: GramJSBotClient; + inlineMessageId: string; + html: string; + buttons?: StyledButtonDef[][]; +}): Promise { + await editInlineRaw(params); +} + +/** + * Answer an inline query with a single styled article result via GramJS MTProto. + * + * Custom emojis are stripped (SetInlineBotResults does not support them). The + * caller supplies the already-built title/description and the styled message body. + */ +export async function answerInlineQueryStyled(params: { + gramjsBot: GramJSBotClient; + queryId: string; + resultId: string; + title: string; + description: string; + html: string; + buttons: StyledButtonDef[][]; +}): Promise { + const { gramjsBot, queryId, resultId, title, description, html, buttons } = params; + + const strippedHtml = stripCustomEmoji(html); + const { text: plainText, entities } = parseHtml(strippedHtml); + const markup = hasStyledButtons(buttons) ? toTLMarkup(buttons) : undefined; + + await gramjsBot.answerInlineQuery({ + queryId, + results: [ + new Api.InputBotInlineResult({ + id: resultId, + type: "article", + title, + description, + sendMessage: new Api.InputBotInlineMessageText({ + message: plainText, + entities: entities.length > 0 ? entities : undefined, + noWebpage: true, + replyMarkup: markup, + }), + }), + ], + cacheTime: 0, + }); +} diff --git a/src/bot/services/message-builder.ts b/src/bot/services/message-builder.ts index 3535e57c..6f38f30f 100644 --- a/src/bot/services/message-builder.ts +++ b/src/bot/services/message-builder.ts @@ -8,7 +8,7 @@ */ import type { DealContext } from "../types.js"; -import type { StyledButtonDef, DealMessage } from "./styled-keyboard.js"; +import type { StyledButtonDef, DealMessage } from "../../sdk/formatting.js"; /** * Escape HTML special characters @@ -34,6 +34,16 @@ function formatAsset(type: "ton" | "gift", tonAmount?: number, giftSlug?: string return "???"; } +/** What the user gives in a deal, formatted. */ +function formatUserGives(deal: DealContext): string { + return formatAsset(deal.userGivesType, deal.userGivesTonAmount, deal.userGivesGiftSlug); +} + +/** What the agent gives in a deal, formatted. */ +function formatAgentGives(deal: DealContext): string { + return formatAsset(deal.agentGivesType, deal.agentGivesTonAmount, deal.agentGivesGiftSlug); +} + /** * Format remaining time */ @@ -50,16 +60,8 @@ function getRemainingTime(expiresAt: number): string { * Proposal state - Accept/Decline */ export function buildProposalMessage(deal: DealContext): DealMessage { - const userGives = formatAsset( - deal.userGivesType, - deal.userGivesTonAmount, - deal.userGivesGiftSlug - ); - const agentGives = formatAsset( - deal.agentGivesType, - deal.agentGivesTonAmount, - deal.agentGivesGiftSlug - ); + const userGives = formatUserGives(deal); + const agentGives = formatAgentGives(deal); const remaining = getRemainingTime(deal.expiresAt); const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`; @@ -84,16 +86,8 @@ ${TIMER_EMOJI} Expires in ${remaining}`; * Accepted state - Payment/gift instructions */ export function buildAcceptedMessage(deal: DealContext, agentWallet: string): DealMessage { - const userGives = formatAsset( - deal.userGivesType, - deal.userGivesTonAmount, - deal.userGivesGiftSlug - ); - const agentGives = formatAsset( - deal.agentGivesType, - deal.agentGivesTonAmount, - deal.agentGivesGiftSlug - ); + const userGives = formatUserGives(deal); + const agentGives = formatAgentGives(deal); let instructions: string; let buttons: StyledButtonDef[][]; @@ -157,11 +151,7 @@ This usually takes 10-30 seconds.`; * Verified - Sending agent's part */ export function buildSendingMessage(deal: DealContext): DealMessage { - const agentGives = formatAsset( - deal.agentGivesType, - deal.agentGivesTonAmount, - deal.agentGivesGiftSlug - ); + const agentGives = formatAgentGives(deal); const text = `βœ… Payment Verified @@ -175,16 +165,8 @@ Sending ${agentGives}...`; * Completed - Final recap */ export function buildCompletedMessage(deal: DealContext): DealMessage { - const userGives = formatAsset( - deal.userGivesType, - deal.userGivesTonAmount, - deal.userGivesGiftSlug - ); - const agentGives = formatAsset( - deal.agentGivesType, - deal.agentGivesTonAmount, - deal.agentGivesGiftSlug - ); + const userGives = formatUserGives(deal); + const agentGives = formatAgentGives(deal); const user = deal.username ? `@${esc(deal.username)}` : `${deal.userId}`; const duration = deal.completedAt diff --git a/src/bot/services/styled-keyboard.ts b/src/bot/services/styled-keyboard.ts deleted file mode 100644 index 3bfce4d6..00000000 --- a/src/bot/services/styled-keyboard.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Styled keyboard helpers for deal buttons - * Converts button definitions to GramJS TL objects (with color styles + copy buttons) - * or Grammy InlineKeyboard (fallback, no colors, popup copy) - */ - -import { Api } from "telegram"; -import { InlineKeyboard } from "grammy"; - -export type ButtonStyle = "success" | "danger" | "primary"; - -export interface StyledButtonDef { - text: string; - callbackData: string; - style?: ButtonStyle; - /** If set, renders as KeyboardButtonCopy (click-to-clipboard) via MTProto */ - copyText?: string; -} - -/** - * Result type for all message builders - */ -export interface DealMessage { - text: string; - buttons: StyledButtonDef[][]; -} - -/** - * Convert styled button definitions to GramJS TL markup (with colors + copy buttons) - * Uses native Layer 223 constructors (KeyboardButtonStyle, KeyboardButtonCopy) - */ -export function toTLMarkup(buttons: StyledButtonDef[][]): Api.ReplyInlineMarkup { - return new Api.ReplyInlineMarkup({ - rows: buttons - .filter((row) => row.length > 0) - .map( - (row) => - new Api.KeyboardButtonRow({ - buttons: row.map((btn) => { - // Copy button: native click-to-clipboard (no callback needed) - if (btn.copyText) { - return new Api.KeyboardButtonCopy({ - text: btn.text, - copyText: btn.copyText, - }); - } - - // Callback button: with optional color style - const style = btn.style - ? new Api.KeyboardButtonStyle({ - bgSuccess: btn.style === "success", - bgDanger: btn.style === "danger", - bgPrimary: btn.style === "primary", - }) - : undefined; - return new Api.KeyboardButtonCallback({ - text: btn.text, - data: Buffer.from(btn.callbackData), - style, - }); - }), - }) - ), - }); -} - -/** - * Convert styled button definitions to Grammy InlineKeyboard (fallback, no colors) - * Copy buttons use Bot API's native copy_text field (click-to-clipboard) - */ -export function toGrammyKeyboard(buttons: StyledButtonDef[][]): InlineKeyboard { - const kb = new InlineKeyboard(); - for (let i = 0; i < buttons.length; i++) { - if (i > 0) kb.row(); - for (const btn of buttons[i]) { - if (btn.copyText) { - kb.copyText(btn.text, btn.copyText); - } else { - kb.text(btn.text, btn.callbackData); - } - } - } - return kb; -} - -/** - * Check if button array has any buttons - */ -export function hasStyledButtons(buttons: StyledButtonDef[][]): boolean { - return buttons.some((row) => row.length > 0); -} - -/** - * Convert plugin ButtonDef[][] to StyledButtonDef[][] with prefixed callbacks. - * Shared by both sdk/bot.ts and bot/inline-router.ts. - */ -export function prefixButtons( - rows: { text: string; callback?: string; url?: string; copy?: string; style?: ButtonStyle }[][], - pluginName: string -): StyledButtonDef[][] { - return rows.map((row) => - row.map((btn) => { - if (btn.copy) { - return { text: btn.text, callbackData: "", copyText: btn.copy, style: btn.style }; - } - return { - text: btn.text, - callbackData: btn.callback ? `${pluginName}:${btn.callback}` : "", - style: btn.style, - }; - }) - ); -} diff --git a/src/bot/services/verification-poller.ts b/src/bot/services/verification-poller.ts index 9426ab09..fd97b153 100644 --- a/src/bot/services/verification-poller.ts +++ b/src/bot/services/verification-poller.ts @@ -187,11 +187,10 @@ export class VerificationPoller { ): Promise<{ verified: boolean; giftMsgId?: string }> { try { // Get agent's own user ID - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto path - const me = (this.bridge.getRawClient() as any)?.getMe?.(); - if (!me) return { verified: false }; + const ownUserId = this.bridge.getOwnUserId(); + if (!ownUserId) return { verified: false }; - const botUserId = Number(me.id); + const botUserId = Number(ownUserId); // Import gift executor const { telegramGetMyGiftsExecutor } = diff --git a/src/bot/types.ts b/src/bot/types.ts index e40aac89..26347b96 100644 --- a/src/bot/types.ts +++ b/src/bot/types.ts @@ -60,10 +60,40 @@ export interface CallbackData { dealId: string; } +/** + * Split a `prefix:rest` string on its first colon. + * Returns null if there is no colon, or the colon is the first character + * (i.e. there is no non-empty prefix). The `rest` may itself contain colons. + * + * Shared routing primitive: the inline-router uses this to peel a plugin prefix + * off inline queries / callback data / chosen-result ids before dispatch. + */ +export function splitPrefix(raw: string): { prefix: string; rest: string } | null { + const colonIdx = raw.indexOf(":"); + if (colonIdx <= 0) return null; + return { prefix: raw.slice(0, colonIdx), rest: raw.slice(colonIdx + 1) }; +} + export function encodeCallback(data: CallbackData): string { return `${data.action}:${data.dealId}`; } +/** + * Callback-data routing contract (Grammy `callback_query:data` middleware chain). + * + * Two prefix conventions co-route in the same Grammy dispatch, distinguished by + * the first `:`-delimited segment: + * + * - DealBot RESERVES these six action prefixes, decoded here as `action:dealId` + * (exactly one colon): accept Β· decline Β· sent Β· copy_addr Β· copy_memo Β· refresh. + * decodeCallback returns null for anything else, so DealBot ignores it. + * - Every OTHER prefix belongs to a registered plugin and is claimed by the + * InlineRouter middleware (installed BEFORE DealBot) via its `prefix:rest` + * split; unmatched prefixes fall through to DealBot. + * + * New DealBot actions must be added to BOTH the union below and the whitelist in + * decodeCallback; plugins must avoid these reserved prefixes to prevent collisions. + */ export function decodeCallback(raw: string): CallbackData | null { const parts = raw.split(":"); if (parts.length !== 2) return null; diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index b4a998d5..76bf39ad 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -9,6 +9,7 @@ import { type SupportedProvider, } from "../../config/providers.js"; import { getErrorMessage } from "../../utils/errors.js"; +import { GREEN, YELLOW, RED } from "../prompts.js"; interface CheckResult { name: string; @@ -16,22 +17,21 @@ interface CheckResult { message: string; } -const green = "\x1b[32m"; -const yellow = "\x1b[33m"; -const red = "\x1b[31m"; -const reset = "\x1b[0m"; +// ASCII banner colors (raw ANSI β€” chalk has no equivalent blue export) const blue = "\x1b[34m"; +const reset = "\x1b[0m"; function formatResult(result: CheckResult): string { const icon = - result.status === "ok" - ? `${green}βœ“${reset}` - : result.status === "warn" - ? `${yellow}⚠${reset}` - : `${red}βœ—${reset}`; + result.status === "ok" ? GREEN("βœ“") : result.status === "warn" ? YELLOW("⚠") : RED("βœ—"); return `${icon} ${result.name}: ${result.message}`; } +/** Read and YAML-parse the config file (shared by the config-dependent checks). */ +function readAndParseConfig(configPath: string) { + return parse(readFileSync(configPath, "utf-8")); +} + async function checkConfig(workspaceDir: string): Promise { const configPath = join(workspaceDir, "config.yaml"); @@ -44,8 +44,7 @@ async function checkConfig(workspaceDir: string): Promise { } try { - const content = readFileSync(configPath, "utf-8"); - const raw = parse(content); + const raw = readAndParseConfig(configPath); const result = ConfigSchema.safeParse(raw); if (!result.success) { @@ -82,8 +81,7 @@ async function checkTelegramCredentials(workspaceDir: string): Promise { } try { - const content = readFileSync(configPath, "utf-8"); - const config = parse(content); + const config = readAndParseConfig(configPath); const provider = (config.agent?.provider || "anthropic") as SupportedProvider; const apiKey = config.agent?.api_key; @@ -334,8 +331,7 @@ async function checkModel(workspaceDir: string): Promise { } try { - const content = readFileSync(configPath, "utf-8"); - const config = parse(content); + const config = readAndParseConfig(configPath); const provider = (config.agent?.provider || "anthropic") as SupportedProvider; let model = config.agent?.model; @@ -373,8 +369,7 @@ async function checkAdmins(workspaceDir: string): Promise { } try { - const content = readFileSync(configPath, "utf-8"); - const config = parse(content); + const config = readAndParseConfig(configPath); const admins = config.telegram?.admin_ids || []; @@ -464,14 +459,16 @@ ${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ if (errors > 0) { console.log( - `${red} βœ— ${errors} error${errors > 1 ? "s" : ""} found - run 'teleton setup' to fix${reset}` + RED(` βœ— ${errors} error${errors > 1 ? "s" : ""} found - run 'teleton setup' to fix`) ); } else if (warnings > 0) { console.log( - `${yellow} ⚠ ${warnings} warning${warnings > 1 ? "s" : ""} - agent may work with limited features${reset}` + YELLOW( + ` ⚠ ${warnings} warning${warnings > 1 ? "s" : ""} - agent may work with limited features` + ) ); } else { - console.log(`${green} βœ“ All ${ok} checks passed - system ready${reset}`); + console.log(GREEN(` βœ“ All ${ok} checks passed - system ready`)); } console.log(""); diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index aeef5e7a..a3f8cf74 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -29,11 +29,12 @@ import { type StepDef, } from "../prompts.js"; -import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js"; +import { ensureWorkspace, isNewWorkspace, type Workspace } from "../../workspace/manager.js"; import { writeFileSync, readFileSync, existsSync } from "fs"; import { join } from "path"; import { TELETON_ROOT } from "../../workspace/paths.js"; import { TelegramUserClient } from "../../telegram/client.js"; +import { maskSecret } from "../../utils/mask.js"; import YAML from "yaml"; import { type Config, DealsConfigSchema } from "../../config/schema.js"; import { getModelsForProvider } from "../../config/model-catalog.js"; @@ -43,6 +44,7 @@ import { saveWallet, walletExists, loadWallet, + type WalletData, } from "../../ton/wallet-service.js"; import { getSupportedProviders, @@ -54,10 +56,6 @@ import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; import { fetchWithTimeout } from "../../utils/fetch.js"; import { getErrorMessage } from "../../utils/errors.js"; import ora from "ora"; -import { - getClaudeCodeApiKey, - isClaudeCodeTokenValid, -} from "../../providers/claude-code-credentials.js"; import { getCodexApiKey, isCodexTokenValid } from "../../providers/codex-credentials.js"; export interface OnboardOptions { @@ -102,57 +100,291 @@ function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } -// Model catalog imported from shared source (see src/config/model-catalog.ts) +/** + * Auto-detect provider credentials (Codex), with a manual + * API-key fallback. Returns the resolved api key (empty when auto-detected, + * since it is read at runtime) and the STEPS[1].value status string. + */ +async function handleAutoDetectedProvider(opts: { + getKey: () => string; + isValid: () => boolean; + displayName: string; + noteTitle: string; + detectedFromMsg: string; + statusExpiredMsg: string; + noteFooterMsg: string; + notFoundHint: string; + fallbackKeyLabel: string; + prompter: ReturnType; +}): Promise<{ apiKey: string; stepValue: string }> { + let apiKey = ""; + let detected = false; + try { + const key = opts.getKey(); + const valid = opts.isValid(); + apiKey = ""; // Don't store in config β€” auto-detected at runtime + detected = true; + const masked = maskSecret(key, 12, 4); + noteBox( + `${opts.detectedFromMsg}\n` + + `Key: ${masked}\n` + + `Status: ${valid ? GREEN("valid βœ“") : opts.statusExpiredMsg}\n` + + opts.noteFooterMsg, + opts.noteTitle, + TON + ); + await confirm({ + message: "Continue with auto-detected credentials?", + default: true, + theme, + }); + } catch (error) { + if (error instanceof CancelledError) throw error; + opts.prompter.warn(opts.notFoundHint); + const useFallback = await confirm({ + message: "Enter an API key manually instead?", + default: true, + theme, + }); + if (useFallback) { + apiKey = await password({ + message: opts.fallbackKeyLabel, + theme, + validate: (value = "") => { + if (!value || value.trim().length === 0) return "API key is required"; + return true; + }, + }); + } else { + throw new CancelledError(); + } + } + + const stepValue = detected + ? `${opts.displayName} ${DIM("auto-detected βœ“")}` + : `${opts.displayName} ${DIM(maskSecret(apiKey))}`; + return { apiKey, stepValue }; +} /** - * Main onboard command + * Prompt for an optional integration key: ask to enable, show a note, then + * collect+validate the key. Returns the entered value, or undefined if skipped. + * Callers handle assignment and `extras.push` based on the return value. */ -export async function onboardCommand(options: OnboardOptions = {}): Promise { - // Web UI mode - if (options.ui) { - const { SetupServer } = await import("../../webui/setup-server.js"); - const port = parseInt(options.uiPort || "7777") || 7777; - const url = `http://localhost:${port}/setup`; - - const blue = "\x1b[34m"; - const reset = "\x1b[0m"; - const dim = "\x1b[2m"; - console.log(` -${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ β”‚ - β”‚ ______________ ________________ _ __ ___ _____________ ________ β”‚ - β”‚ /_ __/ ____/ / / ____/_ __/ __ \\/ | / / / | / ____/ ____/ | / /_ __/ β”‚ - β”‚ / / / __/ / / / __/ / / / / / / |/ / / /| |/ / __/ __/ / |/ / / / β”‚ - β”‚ / / / /___/ /___/ /___ / / / /_/ / /| / / ___ / /_/ / /___/ /| / / / β”‚ - β”‚ /_/ /_____/_____/_____/ /_/ \\____/_/ |_/ /_/ |_\\____/_____/_/ |_/ /_/ β”‚ - β”‚ β”‚ - └────────────────────────────────────────────────────────────────── DEV: ZKPROOF.T.ME β”€β”€β”˜${reset} +async function promptOptionalKey(opts: { + confirmMsg: string; + note: string; + noteTitle: string; + inputMsg: string; + validate: (value: string) => true | string; +}): Promise { + const enable = await confirm({ + message: opts.confirmMsg, + default: false, + theme, + }); - ${dim}Setup wizard running at${reset} ${url} - ${dim}Opening in your default browser...${reset} - ${dim}Press Ctrl+C to cancel.${reset} -`); + if (!enable) return undefined; + + noteBox(opts.note, opts.noteTitle, TON); + return input({ + message: opts.inputMsg, + theme, + validate: (v = "") => opts.validate(v), + }); +} - const server = new SetupServer(port); - await server.start(); +/** Shared bot-token format (id:hash). Used by all interactive + non-interactive sites. */ +const BOT_TOKEN_REGEX = /^[0-9]+:[A-Za-z0-9_-]+$/; - process.on("SIGINT", () => { - void server.stop().then(() => process.exit(0)); - }); +/** Validate bot-token format for an inquirer `validate` callback. */ +function validateBotTokenFormat(value: string): true | string { + if (!value) return "Bot token is required"; + if (!BOT_TOKEN_REGEX.test(value)) return "Invalid format (expected 123456:ABC...)"; + return true; +} + +/** + * Call Telegram getMe to verify a bot token and fetch its username. + * Returns `ok:true` with the username when verified, `ok:false` when the + * token is rejected, or `networkError:true` when the API is unreachable. + */ +async function validateAndFetchBot( + token: string +): Promise<{ ok: boolean; username?: string; networkError?: boolean }> { + try { + const res = await fetchWithTimeout(`https://api.telegram.org/bot${token}/getMe`); + const data = await res.json(); + if (!data.ok) return { ok: false }; + return { ok: true, username: data.result.username }; + } catch { + return { ok: false, networkError: true }; + } +} + +/** Prompt for a 24-word mnemonic and import+save the wallet, with spinner feedback. */ +async function importWalletFlow(spinner: ReturnType): Promise { + const mnemonicInput = await input({ + message: "Enter your 24-word mnemonic (space-separated)", + theme, + validate: (value = "") => { + const words = value.trim().split(/\s+/); + return words.length === 24 ? true : `Expected 24 words, got ${words.length}`; + }, + }); + spinner.start(DIM("Importing wallet...")); + const wallet = await importWallet(mnemonicInput.trim().split(/\s+/)); + saveWallet(wallet); + spinner.succeed(DIM(`Wallet imported: ${wallet.address}`)); + return wallet; +} + +type Policy = "open" | "allowlist" | "admin-only" | "disabled"; + +/** Variable inputs for {@link buildConfig}; everything else is a centralized default. */ +interface BuildConfigInput { + provider: SupportedProvider; + apiKey: string; + baseUrl?: string; + model: string; + maxAgenticIterations: number; + telegramMode: "user" | "bot"; + apiId: number; + apiHash: string; + phone: string; + userId: number; + dmPolicy: Policy; + groupPolicy: Policy; + requireMention: boolean; + execMode: "off" | "yolo"; + botToken?: string; + botUsername?: string; + tonapiKey?: string; + toncenterApiKey?: string; + tavilyApiKey?: string; + cocoonPort?: number; + sessionPath: string; + workspaceRoot: string; +} - // Wait for user to click "Start Agent" in the browser - await server.waitForLaunch(); - console.log("\n Launch signal received β€” stopping setup server"); - await server.stop(); +/** + * Build the full Config object from the variable wizard inputs, centralizing + * every schema default in one place. Called by both the interactive and the + * non-interactive flows so they can never silently diverge. + */ +function buildConfig(input: BuildConfigInput): Config { + return { + meta: { + version: "1.0.0", + created_at: new Date().toISOString(), + onboard_command: "teleton setup", + }, + agent: { + provider: input.provider, + api_key: input.apiKey, + ...(input.baseUrl ? { base_url: input.baseUrl } : {}), + model: input.model, + max_tokens: 4096, + temperature: 0.7, + system_prompt: null, + max_agentic_iterations: input.maxAgenticIterations, + session_reset_policy: { + daily_reset_enabled: true, + daily_reset_hour: 4, + idle_expiry_enabled: true, + idle_expiry_minutes: 1440, + }, + }, + telegram: { + mode: input.telegramMode, + api_id: input.telegramMode === "user" ? input.apiId : 0, + api_hash: input.telegramMode === "user" ? input.apiHash : "", + phone: input.telegramMode === "user" ? input.phone : "", + session_name: "teleton_session", + session_path: input.sessionPath, + dm_policy: input.dmPolicy, + allow_from: [], + group_policy: input.groupPolicy, + group_allow_from: [], + require_mention: input.requireMention, + max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH, + typing_simulation: true, + rate_limit_messages_per_second: 1.0, + rate_limit_groups_per_minute: 20, + admin_ids: [input.userId], + owner_id: input.userId, + agent_channel: null, + debounce_ms: 1500, + bot_token: input.botToken, + bot_username: input.botUsername, + stream_mode: "all", + guest_mode: false, + }, + storage: { + sessions_file: `${input.workspaceRoot}/sessions.json`, + memory_file: `${input.workspaceRoot}/memory.json`, + history_limit: 100, + }, + embedding: { provider: "local" }, + deals: DealsConfigSchema.parse({ enabled: !!input.botToken }), + webui: { + enabled: false, + port: 7777, + host: "127.0.0.1", + cors_origins: ["http://localhost:5173", "http://localhost:7777"], + log_requests: false, + }, + dev: { hot_reload: false }, + tool_rag: { + enabled: true, + top_k: 25, + always_include: [ + "telegram_send_message", + "telegram_quote_reply", + "telegram_send_photo", + "journal_*", + "workspace_*", + "web_*", + ], + skip_unlimited_providers: false, + }, + logging: { level: "info", pretty: true }, + mcp: { servers: {} }, + capabilities: { + exec: { + mode: input.execMode, + scope: "admin-only", + allowlist: [], + limits: { timeout: 120, max_output: 50000 }, + audit: { log_commands: true }, + }, + }, + ton_proxy: { enabled: false, port: 8080 }, + heartbeat: { + enabled: true, + interval_ms: 3_600_000, + prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.", + self_configurable: false, + }, + plugins: {}, + ...(input.provider === "cocoon" && input.cocoonPort + ? { cocoon: { port: input.cocoonPort } } + : {}), + tonapi_key: input.tonapiKey, + toncenter_api_key: input.toncenterApiKey, + tavily_api_key: input.tavilyApiKey, + }; +} - // Boot TonnetApp on the same port - console.log(" Starting TonnetApp...\n"); - const { TeletonApp } = await import("../../index.js"); - const configPath = join(TELETON_ROOT, "config.yaml"); - const app = new TeletonApp(configPath); - await app.start(); +// Model catalog imported from shared source (see src/config/model-catalog.ts) - // Keep process alive (TonnetApp manages its own lifecycle) +/** + * Main onboard command + */ +export async function onboardCommand(options: OnboardOptions = {}): Promise { + // Web UI mode + if (options.ui) { + await runUiSetup(options); return; } @@ -173,44 +405,99 @@ ${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ } } +/** + * Web UI setup mode: serve the browser setup wizard, then boot TonnetApp once + * the user clicks "Start Agent". Keeps the CLI->App lifecycle in one place. + */ +async function runUiSetup(options: OnboardOptions): Promise { + const { SetupServer } = await import("../../webui/setup-server.js"); + const port = parseInt(options.uiPort || "7777") || 7777; + const url = `http://localhost:${port}/setup`; + + // ASCII banner colors (raw ANSI β€” chalk has no equivalent blue export) + const blue = "\x1b[34m"; + const reset = "\x1b[0m"; + console.log(` +${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β”‚ ______________ ________________ _ __ ___ _____________ ________ β”‚ + β”‚ /_ __/ ____/ / / ____/_ __/ __ \\/ | / / / | / ____/ ____/ | / /_ __/ β”‚ + β”‚ / / / __/ / / / __/ / / / / / / |/ / / /| |/ / __/ __/ / |/ / / / β”‚ + β”‚ / / / /___/ /___/ /___ / / / /_/ / /| / / ___ / /_/ / /___/ /| / / / β”‚ + β”‚ /_/ /_____/_____/_____/ /_/ \\____/_/ |_/ /_/ |_\\____/_____/_/ |_/ /_/ β”‚ + β”‚ β”‚ + └────────────────────────────────────────────────────────────────── DEV: ZKPROOF.T.ME β”€β”€β”˜${reset} + + ${DIM("Setup wizard running at")} ${url} + ${DIM("Opening in your default browser...")} + ${DIM("Press Ctrl+C to cancel.")} +`); + + const server = new SetupServer(port); + await server.start(); + + process.on("SIGINT", () => { + void server.stop().then(() => process.exit(0)); + }); + + // Wait for user to click "Start Agent" in the browser + await server.waitForLaunch(); + console.log("\n Launch signal received β€” stopping setup server"); + await server.stop(); + + // Boot TonnetApp on the same port + console.log(" Starting TonnetApp...\n"); + const { TeletonApp } = await import("../../index.js"); + const configPath = join(TELETON_ROOT, "config.yaml"); + const app = new TeletonApp(configPath); + await app.start(); + + // Keep process alive (TonnetApp manages its own lifecycle) +} + /** * Interactive onboarding wizard */ -async function runInteractiveOnboarding( +// ── Interactive wizard state ────────────────────────────────────────── + +/** Mutable state shared across the interactive wizard steps. */ +interface OnboardState { + selectedProvider: SupportedProvider; + selectedModel: string; + apiKey: string; + localBaseUrl: string; + cocoonInstance: number; + apiId: number; + apiHash: string; + phone: string; + userId: number; + telegramMode: "user" | "bot"; + botToken: string | undefined; + botUsername: string | undefined; + dmPolicy: Policy; + groupPolicy: Policy; + requireMention: boolean; + maxAgenticIterations: string; + execMode: "off" | "yolo"; + tonapiKey: string | undefined; + toncenterApiKey: string | undefined; + tavilyApiKey: string | undefined; +} + +/** + * Step 0: Agent β€” security warning, workspace, name, Telegram mode. + * Returns the workspace/spinner shared by later steps, plus the agent name and + * mode. Returns null when the user declines to overwrite an existing config. + */ +async function stepAgent( options: OnboardOptions, prompter: ReturnType -): Promise { - // ── Shared state ── - let selectedProvider: SupportedProvider = "anthropic"; - let selectedModel = ""; - let apiKey = ""; - let apiId = 0; - let apiHash = ""; - let phone = ""; - let userId = 0; - let tonapiKey: string | undefined; - let toncenterApiKey: string | undefined; - let tavilyApiKey: string | undefined; - let telegramMode: "user" | "bot" = "user"; - let botToken: string | undefined; - let botUsername: string | undefined; - let dmPolicy: "open" | "allowlist" | "admin-only" | "disabled" = "admin-only"; - let groupPolicy: "open" | "allowlist" | "admin-only" | "disabled" = "admin-only"; - let requireMention = true; - let maxAgenticIterations = "5"; - let execMode: "off" | "yolo" = "off"; - let cocoonInstance = 10000; - - // Intro - console.clear(); - console.log(); - console.log(wizardFrame(0, STEPS)); - console.log(); - await sleep(800); - - // ════════════════════════════════════════════════════════════════════ - // Step 0: Agent β€” security warning, workspace, name, mode, modules - // ════════════════════════════════════════════════════════════════════ +): Promise<{ + workspace: Workspace; + spinner: ReturnType; + agentName: string; + telegramMode: "user" | "bot"; +} | null> { redraw(0); noteBox( @@ -258,7 +545,7 @@ async function runInteractiveOnboarding( }); if (!shouldOverwrite) { console.log(`\n ${DIM("Setup cancelled β€” existing configuration preserved.")}\n`); - return; + return null; } } @@ -275,7 +562,7 @@ async function runInteractiveOnboarding( writeFileSync(workspace.identityPath, updated, "utf-8"); } - telegramMode = await select({ + const telegramMode = await select({ message: "Telegram mode", default: "user", theme, @@ -294,14 +581,24 @@ async function runInteractiveOnboarding( }); STEPS[0].value = agentName; + return { workspace, spinner, agentName, telegramMode }; +} - // ════════════════════════════════════════════════════════════════════ - // Step 1: Provider β€” select + tool limit warning + API key - // ════════════════════════════════════════════════════════════════════ +/** Step 1: Provider β€” select provider, resolve API key/base URL, pick model. */ +async function stepProvider( + options: OnboardOptions, + prompter: ReturnType +): Promise<{ + selectedProvider: SupportedProvider; + apiKey: string; + localBaseUrl: string; + cocoonInstance: number; + selectedModel: string; +}> { redraw(1); const providers = getSupportedProviders(); - selectedProvider = await select({ + const selectedProvider = await select({ message: "AI Provider", default: "anthropic", theme, @@ -326,7 +623,9 @@ async function runInteractiveOnboarding( } // API key (or Cocoon / Local setup) + let apiKey = ""; let localBaseUrl = ""; + let cocoonInstance = 10000; if (selectedProvider === "cocoon") { // Cocoon Network β€” no API key, managed externally via cocoon-cli apiKey = ""; @@ -379,110 +678,23 @@ async function runInteractiveOnboarding( ); STEPS[1].value = `${providerMeta.displayName} ${DIM(localBaseUrl)}`; - } else if (selectedProvider === "claude-code") { - // Claude Code β€” auto-detect credentials, fallback to manual key - let detected = false; - try { - const key = getClaudeCodeApiKey(); - const valid = isClaudeCodeTokenValid(); - apiKey = ""; // Don't store in config β€” auto-detected at runtime - detected = true; - const masked = key.length > 16 ? key.slice(0, 12) + "..." + key.slice(-4) : "***"; - noteBox( - `Credentials auto-detected from Claude Code\n` + - `Key: ${masked}\n` + - `Status: ${valid ? GREEN("valid βœ“") : "expired (will refresh on use)"}\n` + - `Token will auto-refresh when it expires.`, - "Claude Code", - TON - ); - await confirm({ - message: "Continue with auto-detected credentials?", - default: true, - theme, - }); - } catch (error) { - if (error instanceof CancelledError) throw error; - prompter.warn( - "Claude Code credentials not found. Make sure Claude Code is installed and authenticated (claude login)." - ); - const useFallback = await confirm({ - message: "Enter an API key manually instead?", - default: true, - theme, - }); - if (useFallback) { - apiKey = await password({ - message: `Anthropic API Key (fallback)`, - theme, - validate: (value = "") => { - if (!value || value.trim().length === 0) return "API key is required"; - return true; - }, - }); - } else { - throw new CancelledError(); - } - } - - if (detected) { - STEPS[1].value = `${providerMeta.displayName} ${DIM("auto-detected βœ“")}`; - } else { - const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***"; - STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`; - } } else if (selectedProvider === "codex") { // Codex β€” auto-detect credentials from ~/.codex/auth.json - let detected = false; - try { - const key = getCodexApiKey(); - const valid = isCodexTokenValid(); - apiKey = ""; // Don't store in config β€” auto-detected at runtime - detected = true; - const masked = key.length > 16 ? key.slice(0, 12) + "..." + key.slice(-4) : "***"; - noteBox( - `Credentials auto-detected from Codex CLI\n` + - `Key: ${masked}\n` + - `Status: ${valid ? GREEN("valid βœ“") : "expired (run codex to re-authenticate)"}\n` + - `Token read from ~/.codex/auth.json`, - "Codex", - TON - ); - await confirm({ - message: "Continue with auto-detected credentials?", - default: true, - theme, - }); - } catch (error) { - if (error instanceof CancelledError) throw error; - prompter.warn( - "Codex credentials not found. Make sure Codex CLI is installed and authenticated." - ); - const useFallback = await confirm({ - message: "Enter an OpenAI API key manually instead?", - default: true, - theme, - }); - if (useFallback) { - apiKey = await password({ - message: `OpenAI API Key (fallback)`, - theme, - validate: (value = "") => { - if (!value || value.trim().length === 0) return "API key is required"; - return true; - }, - }); - } else { - throw new CancelledError(); - } - } - - if (detected) { - STEPS[1].value = `${providerMeta.displayName} ${DIM("auto-detected βœ“")}`; - } else { - const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***"; - STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`; - } + const result = await handleAutoDetectedProvider({ + getKey: getCodexApiKey, + isValid: isCodexTokenValid, + displayName: providerMeta.displayName, + noteTitle: "Codex", + detectedFromMsg: "Credentials auto-detected from Codex CLI", + statusExpiredMsg: "expired (run codex to re-authenticate)", + noteFooterMsg: "Token read from ~/.codex/auth.json", + notFoundHint: + "Codex credentials not found. Make sure Codex CLI is installed and authenticated.", + fallbackKeyLabel: "OpenAI API Key (fallback)", + prompter, + }); + apiKey = result.apiKey; + STEPS[1].value = result.stepValue; } else { // Standard providers β€” API key required const envApiKey = process.env.TELETON_API_KEY; @@ -514,12 +726,12 @@ async function runInteractiveOnboarding( }); } - const maskedKey = apiKey.length > 10 ? apiKey.slice(0, 6) + "..." + apiKey.slice(-4) : "***"; + const maskedKey = maskSecret(apiKey); STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`; } // Model selection (advanced mode only, after provider + API key) - selectedModel = providerMeta.defaultModel; + let selectedModel = providerMeta.defaultModel; if (selectedProvider !== "cocoon" && selectedProvider !== "local") { const providerModels = getModelsForProvider(selectedProvider); @@ -550,9 +762,21 @@ async function runInteractiveOnboarding( STEPS[1].value = `${STEPS[1].value ?? providerMeta.displayName}, ${modelLabel}`; } - // ════════════════════════════════════════════════════════════════════ - // Step 2: Config β€” admin + policies - // ════════════════════════════════════════════════════════════════════ + return { selectedProvider, apiKey, localBaseUrl, cocoonInstance, selectedModel }; +} + +/** Step 2: Config β€” admin user ID, DM/group policies, iterations, exec mode. */ +async function stepConfig( + options: OnboardOptions, + telegramMode: "user" | "bot" +): Promise<{ + userId: number; + dmPolicy: Policy; + groupPolicy: Policy; + requireMention: boolean; + maxAgenticIterations: string; + execMode: "off" | "yolo"; +}> { redraw(2); // Admin User ID @@ -575,8 +799,11 @@ async function runInteractiveOnboarding( return true; }, }); - userId = parseInt(userIdStr); + const userId = parseInt(userIdStr); + let dmPolicy: Policy = "admin-only"; + let groupPolicy: Policy = "admin-only"; + let requireMention = true; if (telegramMode === "bot") { dmPolicy = "admin-only"; groupPolicy = "admin-only"; @@ -621,7 +848,7 @@ async function runInteractiveOnboarding( }); } - maxAgenticIterations = await input({ + const maxAgenticIterations = await input({ message: "Max agentic iterations (tool call loops per message)", default: "5", theme, @@ -631,7 +858,7 @@ async function runInteractiveOnboarding( }, }); - execMode = await select({ + const execMode = await select({ message: "Coding Agent (system execution)", choices: [ { value: "off" as const, name: "Disabled", description: "No system execution capability" }, @@ -646,13 +873,25 @@ async function runInteractiveOnboarding( }); STEPS[2].value = `${dmPolicy}/${groupPolicy}`; + return { userId, dmPolicy, groupPolicy, requireMention, maxAgenticIterations, execMode }; +} - // ════════════════════════════════════════════════════════════════════ - // Step 3: Modules β€” optional API keys - // ════════════════════════════════════════════════════════════════════ +/** Step 3: Modules β€” optional bot token (user mode) and TonAPI/TonCenter/Tavily keys. */ +async function stepModules( + telegramMode: "user" | "bot", + spinner: ReturnType +): Promise<{ + botToken: string | undefined; + botUsername: string | undefined; + tonapiKey: string | undefined; + toncenterApiKey: string | undefined; + tavilyApiKey: string | undefined; +}> { redraw(3); const extras: string[] = []; + let botToken: string | undefined; + let botUsername: string | undefined; // Bot token (recommended β€” required for deals module; skipped in bot mode, handled at step 5) const setupBot = @@ -677,26 +916,18 @@ async function runInteractiveOnboarding( const tokenInput = await password({ message: "Bot token (from @BotFather)", theme, - validate: (value) => { - if (!value || !value.includes(":")) return "Invalid format (expected id:hash)"; - return true; - }, + validate: (value = "") => validateBotTokenFormat(value), }); // Validate bot token spinner.start(DIM("Validating bot token...")); - try { - const res = await fetchWithTimeout(`https://api.telegram.org/bot${tokenInput}/getMe`); - const data = await res.json(); - if (!data.ok) { - spinner.warn(DIM("Bot token is invalid β€” skipping bot setup")); - } else { - botToken = tokenInput; - botUsername = data.result.username; - spinner.succeed(DIM(`Bot verified: @${botUsername}`)); - extras.push("Bot"); - } - } catch { + const result = await validateAndFetchBot(tokenInput); + if (result.ok) { + botToken = tokenInput; + botUsername = result.username; + spinner.succeed(DIM(`Bot verified: @${botUsername}`)); + extras.push("Bot"); + } else if (result.networkError) { spinner.warn(DIM("Could not validate bot token (network error) β€” saving anyway")); botToken = tokenInput; const usernameInput = await input({ @@ -709,109 +940,70 @@ async function runInteractiveOnboarding( }); botUsername = usernameInput; extras.push("Bot"); + } else { + spinner.warn(DIM("Bot token is invalid β€” skipping bot setup")); } } // TonAPI key - const setupTonapi = await confirm({ - message: `Add a TonAPI key? ${DIM("(strongly recommended for TON features)")}`, - default: false, - theme, + const tonapiKey = await promptOptionalKey({ + confirmMsg: `Add a TonAPI key? ${DIM("(strongly recommended for TON features)")}`, + note: + "Blockchain data β€” jettons, NFTs, prices, transaction history.\n" + + "Without key: 1 req/s (you WILL hit rate limits)\n" + + "With free key: 5 req/s\n" + + "\n" + + "Open @tonapibot on Telegram β†’ mini app β†’ generate a server key", + noteTitle: "TonAPI", + inputMsg: "TonAPI key", + validate: (v) => (!v || v.length < 10 ? "Key too short" : true), }); - - if (setupTonapi) { - noteBox( - "Blockchain data β€” jettons, NFTs, prices, transaction history.\n" + - "Without key: 1 req/s (you WILL hit rate limits)\n" + - "With free key: 5 req/s\n" + - "\n" + - "Open @tonapibot on Telegram β†’ mini app β†’ generate a server key", - "TonAPI", - TON - ); - const keyInput = await input({ - message: "TonAPI key", - theme, - validate: (v) => { - if (!v || v.length < 10) return "Key too short"; - return true; - }, - }); - tonapiKey = keyInput; - extras.push("TonAPI"); - } + if (tonapiKey) extras.push("TonAPI"); // TonCenter key - const setupToncenter = await confirm({ - message: `Add a TonCenter API key? ${DIM("(optional, dedicated RPC endpoint)")}`, - default: false, - theme, - }); - - if (setupToncenter) { - noteBox( + const toncenterApiKey = await promptOptionalKey({ + confirmMsg: `Add a TonCenter API key? ${DIM("(optional, dedicated RPC endpoint)")}`, + note: "Blockchain RPC β€” send transactions, check balances.\n" + - "Without key: falls back to ORBS network (decentralized, slower)\n" + - "With free key: dedicated RPC endpoint\n" + - "\n" + - "Go to https://toncenter.com β†’ get a free API key (instant, no signup)", - "TonCenter", - TON - ); - const keyInput = await input({ - message: "TonCenter API key", - theme, - validate: (v) => { - if (!v || v.length < 10) return "Key too short"; - return true; - }, - }); - toncenterApiKey = keyInput; - extras.push("TonCenter"); - } - - // Tavily key - const setupTavily = await confirm({ - message: `Enable web search? ${DIM("(free Tavily key β€” 1,000 req/month)")}`, - default: false, - theme, + "Without key: falls back to ORBS network (decentralized, slower)\n" + + "With free key: dedicated RPC endpoint\n" + + "\n" + + "Go to https://toncenter.com β†’ get a free API key (instant, no signup)", + noteTitle: "TonCenter", + inputMsg: "TonCenter API key", + validate: (v) => (!v || v.length < 10 ? "Key too short" : true), }); + if (toncenterApiKey) extras.push("TonCenter"); - if (setupTavily) { - noteBox( + // Tavily key + const tavilyApiKey = await promptOptionalKey({ + confirmMsg: `Enable web search? ${DIM("(free Tavily key β€” 1,000 req/month)")}`, + note: "Web search lets your agent search the internet and read web pages.\n" + - "\n" + - "To get your free API key (takes 30 seconds):\n" + - "\n" + - " 1. Go to https://app.tavily.com/sign-in\n" + - " 2. Create an account (email or Google/GitHub)\n" + - " 3. Your API key is displayed on the dashboard\n" + - " (starts with tvly-)\n" + - "\n" + - "Free plan: 1,000 requests/month β€” no credit card required.", - "Tavily β€” Web Search API", - TON - ); - const keyInput = await input({ - message: "Tavily API key (starts with tvly-)", - theme, - validate: (v) => { - if (!v || !v.startsWith("tvly-")) return "Should start with tvly-"; - return true; - }, - }); - tavilyApiKey = keyInput; - extras.push("Tavily"); - } + "\n" + + "To get your free API key (takes 30 seconds):\n" + + "\n" + + " 1. Go to https://app.tavily.com/sign-in\n" + + " 2. Create an account (email or Google/GitHub)\n" + + " 3. Your API key is displayed on the dashboard\n" + + " (starts with tvly-)\n" + + "\n" + + "Free plan: 1,000 requests/month β€” no credit card required.", + noteTitle: "Tavily β€” Web Search API", + inputMsg: "Tavily API key (starts with tvly-)", + validate: (v) => (!v || !v.startsWith("tvly-") ? "Should start with tvly-" : true), + }); + if (tavilyApiKey) extras.push("Tavily"); STEPS[3].value = extras.length ? extras.join(", ") : "defaults"; + return { botToken, botUsername, tonapiKey, toncenterApiKey, tavilyApiKey }; +} - // ════════════════════════════════════════════════════════════════════ - // Step 4: Wallet β€” generate / import / keep - // ════════════════════════════════════════════════════════════════════ +/** Step 4: Wallet β€” generate / import / keep, then show the mnemonic backup. */ +async function stepWallet(spinner: ReturnType): Promise { redraw(4); - let wallet; + let wallet: WalletData; const existingWallet = walletExists() ? loadWallet() : null; if (existingWallet) { @@ -835,18 +1027,7 @@ async function runInteractiveOnboarding( if (walletAction === "keep") { wallet = existingWallet; } else if (walletAction === "import") { - const mnemonicInput = await input({ - message: "Enter your 24-word mnemonic (space-separated)", - theme, - validate: (value = "") => { - const words = value.trim().split(/\s+/); - return words.length === 24 ? true : `Expected 24 words, got ${words.length}`; - }, - }); - spinner.start(DIM("Importing wallet...")); - wallet = await importWallet(mnemonicInput.trim().split(/\s+/)); - saveWallet(wallet); - spinner.succeed(DIM(`Wallet imported: ${wallet.address}`)); + wallet = await importWalletFlow(spinner); } else { spinner.start(DIM("Generating new TON wallet...")); wallet = await generateWallet(); @@ -869,18 +1050,7 @@ async function runInteractiveOnboarding( }); if (walletAction === "import") { - const mnemonicInput = await input({ - message: "Enter your 24-word mnemonic (space-separated)", - theme, - validate: (value = "") => { - const words = value.trim().split(/\s+/); - return words.length === 24 ? true : `Expected 24 words, got ${words.length}`; - }, - }); - spinner.start(DIM("Importing wallet...")); - wallet = await importWallet(mnemonicInput.trim().split(/\s+/)); - saveWallet(wallet); - spinner.succeed(DIM(`Wallet imported: ${wallet.address}`)); + wallet = await importWalletFlow(spinner); } else { spinner.start(DIM("Generating TON wallet...")); wallet = await generateWallet(); @@ -941,10 +1111,25 @@ async function runInteractiveOnboarding( } STEPS[4].value = `${wallet.address.slice(0, 8)}...${wallet.address.slice(-4)}`; + return wallet; +} - // ════════════════════════════════════════════════════════════════════ - // Step 5: Telegram β€” credentials - // ════════════════════════════════════════════════════════════════════ +/** + * Step 5: Telegram β€” bot token (bot mode) or API id/hash/phone (user mode). + * In bot mode it returns the resolved bot token/username; in user mode it + * returns the captured credentials. Unset fields keep their incoming values. + */ +async function stepTelegram( + options: OnboardOptions, + telegramMode: "user" | "bot", + spinner: ReturnType +): Promise<{ + botToken?: string; + botUsername?: string; + apiId?: number; + apiHash?: string; + phone?: string; +}> { redraw(5); if (telegramMode === "bot") { @@ -960,195 +1145,120 @@ async function runInteractiveOnboarding( const tokenInput = await password({ message: "Bot token (from @BotFather)", theme, - validate: (value = "") => { - if (!value) return "Bot token is required"; - if (!/^[0-9]+:[A-Za-z0-9_-]+$/.test(value)) - return "Invalid format (expected 123456:ABC...)"; - return true; - }, + validate: (value = "") => validateBotTokenFormat(value), }); - botToken = tokenInput; + const botToken = tokenInput; + let botUsername: string | undefined; // Validate and fetch bot username spinner.start(DIM("Validating bot token...")); - try { - const res = await fetchWithTimeout(`https://api.telegram.org/bot${botToken}/getMe`); - const data = await res.json(); - if (!data.ok) { - spinner.warn(DIM("Bot token validation failed β€” saving anyway")); - } else { - botUsername = data.result.username; - spinner.succeed(DIM(`Bot verified: @${botUsername}`)); - } - } catch { + const result = await validateAndFetchBot(botToken); + if (result.ok) { + botUsername = result.username; + spinner.succeed(DIM(`Bot verified: @${botUsername}`)); + } else if (result.networkError) { spinner.warn(DIM("Could not validate bot token (network error) β€” saving anyway")); + } else { + spinner.warn(DIM("Bot token validation failed β€” saving anyway")); } STEPS[5].value = botUsername ? `@${botUsername}` : "bot token set"; - } else { - noteBox( - "To get your API credentials:\n" + - "\n" + - " 1. Go to https://my.telegram.org/apps\n" + - " 2. Log in with your phone number\n" + - ' 3. Click "API development tools"\n' + - " 4. Create an application (any name/short name works)\n" + - " 5. Copy the API ID (number) and API Hash (hex string)\n" + - "\n" + - "⚠ Do NOT use a VPN β€” Telegram will block the login page.", - "Telegram", - TON - ); + return { botToken, botUsername }; + } - const envApiId = process.env.TELETON_TG_API_ID; - const envApiHash = process.env.TELETON_TG_API_HASH; - const envPhone = process.env.TELETON_TG_PHONE; + noteBox( + "To get your API credentials:\n" + + "\n" + + " 1. Go to https://my.telegram.org/apps\n" + + " 2. Log in with your phone number\n" + + ' 3. Click "API development tools"\n' + + " 4. Create an application (any name/short name works)\n" + + " 5. Copy the API ID (number) and API Hash (hex string)\n" + + "\n" + + "⚠ Do NOT use a VPN β€” Telegram will block the login page.", + "Telegram", + TON + ); - const apiIdStr = options.apiId - ? options.apiId.toString() - : await input({ - message: envApiId ? "API ID (from env)" : "API ID (from my.telegram.org)", - default: envApiId, - theme, - validate: (value) => { - if (!value || isNaN(parseInt(value))) return "Invalid API ID (must be a number)"; - return true; - }, - }); - apiId = parseInt(apiIdStr); + const envApiId = process.env.TELETON_TG_API_ID; + const envApiHash = process.env.TELETON_TG_API_HASH; + const envPhone = process.env.TELETON_TG_PHONE; - apiHash = options.apiHash - ? options.apiHash - : await input({ - message: envApiHash ? "API Hash (from env)" : "API Hash (from my.telegram.org)", - default: envApiHash, - theme, - validate: (value) => { - if (!value || value.length < 10) return "Invalid API Hash"; - return true; - }, - }); + const apiIdStr = options.apiId + ? options.apiId.toString() + : await input({ + message: envApiId ? "API ID (from env)" : "API ID (from my.telegram.org)", + default: envApiId, + theme, + validate: (value) => { + if (!value || isNaN(parseInt(value))) return "Invalid API ID (must be a number)"; + return true; + }, + }); + const apiId = parseInt(apiIdStr); - phone = options.phone - ? options.phone - : await input({ - message: envPhone ? "Phone number (from env)" : "Phone number (international format)", - default: envPhone, - theme, - validate: (value) => { - if (!value || !value.startsWith("+")) return "Must start with +"; - return true; - }, - }); + const apiHash = options.apiHash + ? options.apiHash + : await input({ + message: envApiHash ? "API Hash (from env)" : "API Hash (from my.telegram.org)", + default: envApiHash, + theme, + validate: (value) => { + if (!value || value.length < 10) return "Invalid API Hash"; + return true; + }, + }); - STEPS[5].value = phone; - } + const phone = options.phone + ? options.phone + : await input({ + message: envPhone ? "Phone number (from env)" : "Phone number (international format)", + default: envPhone, + theme, + validate: (value) => { + if (!value || !value.startsWith("+")) return "Must start with +"; + return true; + }, + }); - // ════════════════════════════════════════════════════════════════════ - // Step 6: Connect β€” save config + Telegram auth - // ════════════════════════════════════════════════════════════════════ + STEPS[5].value = phone; + return { apiId, apiHash, phone }; +} + +/** Step 6: Connect β€” build+save config, optionally authenticate with Telegram. */ +async function stepConnect( + state: OnboardState, + workspace: Workspace, + spinner: ReturnType, + prompter: ReturnType +): Promise { redraw(6); - // Build config - const config: Config = { - meta: { - version: "1.0.0", - created_at: new Date().toISOString(), - onboard_command: "teleton setup", - }, - agent: { - provider: selectedProvider, - api_key: apiKey, - ...(selectedProvider === "local" && localBaseUrl ? { base_url: localBaseUrl } : {}), - model: selectedModel, - max_tokens: 4096, - temperature: 0.7, - system_prompt: null, - max_agentic_iterations: parseInt(maxAgenticIterations, 10), - toolset: "full", - session_reset_policy: { - daily_reset_enabled: true, - daily_reset_hour: 4, - idle_expiry_enabled: true, - idle_expiry_minutes: 1440, - }, - }, - telegram: { - mode: telegramMode, - api_id: telegramMode === "user" ? apiId : 0, - api_hash: telegramMode === "user" ? apiHash : "", - phone: telegramMode === "user" ? phone : "", - session_name: "teleton_session", - session_path: workspace.sessionPath, - dm_policy: dmPolicy, - allow_from: [], - group_policy: groupPolicy, - group_allow_from: [], - require_mention: requireMention, - max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH, - typing_simulation: true, - rate_limit_messages_per_second: 1.0, - rate_limit_groups_per_minute: 20, - admin_ids: [userId], - owner_id: userId, - agent_channel: null, - debounce_ms: 1500, - bot_token: botToken, - bot_username: botUsername, - stream_mode: "all", - }, - storage: { - sessions_file: `${workspace.root}/sessions.json`, - memory_file: `${workspace.root}/memory.json`, - history_limit: 100, - }, - embedding: { provider: "local" }, - deals: DealsConfigSchema.parse({ enabled: !!botToken }), - webui: { - enabled: false, - port: 7777, - host: "127.0.0.1", - cors_origins: ["http://localhost:5173", "http://localhost:7777"], - log_requests: false, - }, - dev: { hot_reload: false }, - tool_rag: { - enabled: true, - top_k: 25, - always_include: [ - "telegram_send_message", - "telegram_quote_reply", - "telegram_send_photo", - "journal_*", - "workspace_*", - "web_*", - ], - skip_unlimited_providers: false, - }, - logging: { level: "info", pretty: true }, - mcp: { servers: {} }, - capabilities: { - exec: { - mode: execMode, - scope: "admin-only", - allowlist: [], - limits: { timeout: 120, max_output: 50000 }, - audit: { log_commands: true }, - }, - }, - ton_proxy: { enabled: false, port: 8080 }, - heartbeat: { - enabled: true, - interval_ms: 3_600_000, - prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.", - self_configurable: false, - }, - plugins: {}, - ...(selectedProvider === "cocoon" ? { cocoon: { port: cocoonInstance } } : {}), - tonapi_key: tonapiKey, - toncenter_api_key: toncenterApiKey, - tavily_api_key: tavilyApiKey, - }; + // Build config (shared with the non-interactive flow via buildConfig) + const config = buildConfig({ + provider: state.selectedProvider, + apiKey: state.apiKey, + baseUrl: state.selectedProvider === "local" ? state.localBaseUrl : undefined, + model: state.selectedModel, + maxAgenticIterations: parseInt(state.maxAgenticIterations, 10), + telegramMode: state.telegramMode, + apiId: state.apiId, + apiHash: state.apiHash, + phone: state.phone, + userId: state.userId, + dmPolicy: state.dmPolicy, + groupPolicy: state.groupPolicy, + requireMention: state.requireMention, + execMode: state.execMode, + botToken: state.botToken, + botUsername: state.botUsername, + tonapiKey: state.tonapiKey, + toncenterApiKey: state.toncenterApiKey, + tavilyApiKey: state.tavilyApiKey, + cocoonPort: state.selectedProvider === "cocoon" ? state.cocoonInstance : undefined, + sessionPath: workspace.sessionPath, + workspaceRoot: workspace.root, + }); // Save config spinner.start(DIM("Saving configuration...")); @@ -1158,7 +1268,7 @@ async function runInteractiveOnboarding( // Telegram authentication let telegramConnected = false; - if (telegramMode === "bot") { + if (state.telegramMode === "bot") { console.log(`\n ${DIM("Bot mode β€” no Telegram auth required. Ready to start.")}\n`); STEPS[6].value = "Bot mode βœ“"; telegramConnected = true; @@ -1176,9 +1286,9 @@ async function runInteractiveOnboarding( try { const sessionPath = join(TELETON_ROOT, "telegram_session.txt"); const client = new TelegramUserClient({ - apiId, - apiHash, - phone, + apiId: state.apiId, + apiHash: state.apiHash, + phone: state.phone, sessionPath, }); await client.connect(); @@ -1201,6 +1311,89 @@ async function runInteractiveOnboarding( } } + return telegramConnected; +} + +async function runInteractiveOnboarding( + options: OnboardOptions, + prompter: ReturnType +): Promise { + // ── Mutable shared state ── + const state: OnboardState = { + selectedProvider: "anthropic", + selectedModel: "", + apiKey: "", + localBaseUrl: "", + cocoonInstance: 10000, + apiId: 0, + apiHash: "", + phone: "", + userId: 0, + telegramMode: "user", + botToken: undefined, + botUsername: undefined, + dmPolicy: "admin-only", + groupPolicy: "admin-only", + requireMention: true, + maxAgenticIterations: "5", + execMode: "off", + tonapiKey: undefined, + toncenterApiKey: undefined, + tavilyApiKey: undefined, + }; + + // Intro + console.clear(); + console.log(); + console.log(wizardFrame(0, STEPS)); + console.log(); + await sleep(800); + + // Step 0: Agent + const agentResult = await stepAgent(options, prompter); + if (!agentResult) return; // user declined to overwrite existing config + const { workspace, spinner } = agentResult; + state.telegramMode = agentResult.telegramMode; + + // Step 1: Provider + const provider = await stepProvider(options, prompter); + state.selectedProvider = provider.selectedProvider; + state.apiKey = provider.apiKey; + state.localBaseUrl = provider.localBaseUrl; + state.cocoonInstance = provider.cocoonInstance; + state.selectedModel = provider.selectedModel; + + // Step 2: Config + const cfg = await stepConfig(options, state.telegramMode); + state.userId = cfg.userId; + state.dmPolicy = cfg.dmPolicy; + state.groupPolicy = cfg.groupPolicy; + state.requireMention = cfg.requireMention; + state.maxAgenticIterations = cfg.maxAgenticIterations; + state.execMode = cfg.execMode; + + // Step 3: Modules + const modules = await stepModules(state.telegramMode, spinner); + state.botToken = modules.botToken; + state.botUsername = modules.botUsername; + state.tonapiKey = modules.tonapiKey; + state.toncenterApiKey = modules.toncenterApiKey; + state.tavilyApiKey = modules.tavilyApiKey; + + // Step 4: Wallet (produced + persisted inside the step; not needed downstream) + await stepWallet(spinner); + + // Step 5: Telegram + const telegram = await stepTelegram(options, state.telegramMode, spinner); + if (telegram.botToken !== undefined) state.botToken = telegram.botToken; + if (telegram.botUsername !== undefined) state.botUsername = telegram.botUsername; + if (telegram.apiId !== undefined) state.apiId = telegram.apiId; + if (telegram.apiHash !== undefined) state.apiHash = telegram.apiHash; + if (telegram.phone !== undefined) state.phone = telegram.phone; + + // Step 6: Connect + const telegramConnected = await stepConnect(state, workspace, spinner, prompter); + // ════════════════════════════════════════════════════════════════════ // Final summary // ════════════════════════════════════════════════════════════════════ @@ -1231,7 +1424,7 @@ async function runNonInteractiveOnboarding( prompter.error("Non-interactive bot mode requires: --bot-token"); process.exit(1); } - if (!/^[0-9]+:[A-Za-z0-9_-]+$/.test(options.botToken)) { + if (!BOT_TOKEN_REGEX.test(options.botToken)) { prompter.error("--bot-token format invalid (expected 123456:ABC...)"); process.exit(1); } @@ -1261,102 +1454,27 @@ async function runNonInteractiveOnboarding( const providerMeta = getProviderMetadata(selectedProvider); - const config: Config = { - meta: { - version: "1.0.0", - created_at: new Date().toISOString(), - onboard_command: "teleton setup", - }, - agent: { - provider: selectedProvider, - api_key: options.apiKey || "", - ...(options.baseUrl ? { base_url: options.baseUrl } : {}), - model: providerMeta.defaultModel, - max_tokens: 4096, - temperature: 0.7, - system_prompt: null, - max_agentic_iterations: 5, - toolset: "full", - session_reset_policy: { - daily_reset_enabled: true, - daily_reset_hour: 4, - idle_expiry_enabled: true, - idle_expiry_minutes: 1440, - }, - }, - telegram: { - mode: nonInteractiveMode, - api_id: nonInteractiveMode === "user" ? (options.apiId ?? 0) : 0, - api_hash: nonInteractiveMode === "user" ? (options.apiHash ?? "") : "", - phone: nonInteractiveMode === "user" ? (options.phone ?? "") : "", - session_name: "teleton_session", - session_path: workspace.sessionPath, - dm_policy: "admin-only", - allow_from: [], - group_policy: "admin-only", - group_allow_from: [], - require_mention: true, - max_message_length: TELEGRAM_MAX_MESSAGE_LENGTH, - typing_simulation: true, - rate_limit_messages_per_second: 1.0, - rate_limit_groups_per_minute: 20, - admin_ids: [options.userId ?? 0], - owner_id: options.userId ?? 0, - agent_channel: null, - debounce_ms: 1500, - bot_token: nonInteractiveMode === "bot" ? options.botToken : undefined, - bot_username: undefined, - stream_mode: "all", - }, - storage: { - sessions_file: `${workspace.root}/sessions.json`, - memory_file: `${workspace.root}/memory.json`, - history_limit: 100, - }, - embedding: { provider: "local" }, - deals: DealsConfigSchema.parse({}), - webui: { - enabled: false, - port: 7777, - host: "127.0.0.1", - cors_origins: ["http://localhost:5173", "http://localhost:7777"], - log_requests: false, - }, - dev: { hot_reload: false }, - tool_rag: { - enabled: true, - top_k: 25, - always_include: [ - "telegram_send_message", - "telegram_quote_reply", - "telegram_send_photo", - "journal_*", - "workspace_*", - "web_*", - ], - skip_unlimited_providers: false, - }, - logging: { level: "info", pretty: true }, - capabilities: { - exec: { - mode: "off", - scope: "admin-only", - allowlist: [], - limits: { timeout: 120, max_output: 50000 }, - audit: { log_commands: true }, - }, - }, - ton_proxy: { enabled: false, port: 8080 }, - heartbeat: { - enabled: true, - interval_ms: 3_600_000, - prompt: "Execute your HEARTBEAT.md checklist now. Work through each item using tool calls.", - self_configurable: false, - }, - mcp: { servers: {} }, - plugins: {}, - tavily_api_key: options.tavilyApiKey, - }; + const config = buildConfig({ + provider: selectedProvider, + apiKey: options.apiKey || "", + baseUrl: options.baseUrl, + model: providerMeta.defaultModel, + maxAgenticIterations: 5, + telegramMode: nonInteractiveMode, + apiId: options.apiId ?? 0, + apiHash: options.apiHash ?? "", + phone: options.phone ?? "", + userId: options.userId ?? 0, + dmPolicy: "admin-only", + groupPolicy: "admin-only", + requireMention: true, + execMode: "off", + botToken: nonInteractiveMode === "bot" ? options.botToken : undefined, + botUsername: undefined, + tavilyApiKey: options.tavilyApiKey, + sessionPath: workspace.sessionPath, + workspaceRoot: workspace.root, + }); const configYaml = YAML.stringify(config); writeFileSync(workspace.configPath, configYaml, { encoding: "utf-8", mode: 0o600 }); diff --git a/src/cocoon/tool-adapter.ts b/src/cocoon/tool-adapter.ts index b63d7fce..781479ed 100644 --- a/src/cocoon/tool-adapter.ts +++ b/src/cocoon/tool-adapter.ts @@ -10,7 +10,7 @@ import { randomUUID } from "crypto"; import { createLogger } from "../utils/logger.js"; -import type { Tool } from "@mariozechner/pi-ai"; +import type { Tool, UserMessage, ToolResultMessage } from "@mariozechner/pi-ai"; const log = createLogger("Cocoon"); @@ -215,3 +215,32 @@ export function wrapToolResult(resultText: string): string { const safe = resultText.replace(/]]>/g, "]]]]>"); return `\n\n`; } + +/** + * Build the message that carries a tool result back to the model. Cocoon has no + * native tool-result role, so its results are wrapped as a plain user message; + * every other provider uses the standard toolResult message. Centralising the + * branch here keeps the cocoon-specific shape out of the agentic loop. + */ +export function buildToolResultMessage( + provider: string, + block: { id: string; name: string }, + resultText: string, + isError: boolean +): UserMessage | ToolResultMessage { + if (provider === "cocoon") { + return { + role: "user", + content: [{ type: "text", text: wrapToolResult(resultText) }], + timestamp: Date.now(), + }; + } + return { + role: "toolResult", + toolCallId: block.id, + toolName: block.name, + content: [{ type: "text", text: resultText }], + isError, + timestamp: Date.now(), + }; +} diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts index de0404c8..1d5081e8 100644 --- a/src/config/__tests__/configurable-keys.test.ts +++ b/src/config/__tests__/configurable-keys.test.ts @@ -254,8 +254,8 @@ describe("existing keys unchanged", () => { expect(meta.validate("long-enough-key-here")).toBeUndefined(); }); - it("agent.provider still has all 16 options", () => { + it("agent.provider still has all 15 options", () => { const meta = CONFIGURABLE_KEYS["agent.provider"]; - expect(meta.options).toHaveLength(16); + expect(meta.options).toHaveLength(15); }); }); diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts index a34e8593..4c4612c9 100644 --- a/src/config/configurable-keys.ts +++ b/src/config/configurable-keys.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, existsSync } from "fs"; import { parse, stringify } from "yaml"; import { expandPath } from "./loader.js"; -import { ConfigSchema } from "./schema.js"; +import { ConfigSchema, DMPolicy, GroupPolicy, ExecMode, ExecScope } from "./schema.js"; import { getSupportedProviders } from "./providers.js"; // ── Types ────────────────────────────────────────────────────────────── @@ -282,6 +282,7 @@ export const CONFIGURABLE_KEYS: Record = { description: "Who can message the bot in private", sensitive: false, hotReload: "instant", + // UI order intentionally differs from the schema enum order options: ["admin-only", "allowlist", "open", "disabled"], optionLabels: { "admin-only": "Admin Only", @@ -289,7 +290,7 @@ export const CONFIGURABLE_KEYS: Record = { open: "Open", disabled: "Disabled", }, - validate: enumValidator(["open", "allowlist", "admin-only", "disabled"]), + validate: enumValidator([...DMPolicy.options]), mask: identity, parse: identity, }, @@ -300,14 +301,14 @@ export const CONFIGURABLE_KEYS: Record = { description: "Which groups the bot can respond in", sensitive: false, hotReload: "instant", - options: ["open", "allowlist", "admin-only", "disabled"], + options: [...GroupPolicy.options], optionLabels: { open: "Open", allowlist: "Allow Groups", "admin-only": "Admin Only", disabled: "Disabled", }, - validate: enumValidator(["open", "allowlist", "admin-only", "disabled"]), + validate: enumValidator([...GroupPolicy.options]), mask: identity, parse: identity, }, @@ -457,6 +458,17 @@ export const CONFIGURABLE_KEYS: Record = { mask: identity, parse: (v) => Number(v), }, + "telegram.guest_mode": { + type: "boolean", + category: "Telegram", + label: "Guest Mode", + description: "Answer guest queries in chats the bot is not a member of", + sensitive: false, + hotReload: "instant", + validate: enumValidator(["true", "false"]), + mask: identity, + parse: (v) => v === "true", + }, // ─── Embedding ───────────────────────────────────────────────────── "embedding.provider": { @@ -596,9 +608,9 @@ export const CONFIGURABLE_KEYS: Record = { description: "System execution: off (disabled) or yolo (full system access)", sensitive: false, hotReload: "restart", - options: ["off", "yolo"], + options: [...ExecMode.options], optionLabels: { off: "Disabled", yolo: "YOLO" }, - validate: enumValidator(["off", "yolo"]), + validate: enumValidator([...ExecMode.options]), mask: identity, parse: identity, }, @@ -609,9 +621,9 @@ export const CONFIGURABLE_KEYS: Record = { description: "Who can trigger exec tools", sensitive: false, hotReload: "restart", - options: ["admin-only", "allowlist", "all"], + options: [...ExecScope.options], optionLabels: { "admin-only": "Admin Only", allowlist: "Allowlist", all: "Everyone" }, - validate: enumValidator(["admin-only", "allowlist", "all"]), + validate: enumValidator([...ExecScope.options]), mask: identity, parse: identity, }, diff --git a/src/config/loader.ts b/src/config/loader.ts index d2524def..f80800a6 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -47,6 +47,18 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config { delete (raw as Record).market; } + // Backward compatibility: the 'claude-code' provider was removed. Migrate existing + // configs to 'anthropic' so they keep loading instead of failing enum validation. + // The old credential auto-detection is gone β€” users must supply an Anthropic key. + const rawAgent = (raw as { agent?: { provider?: unknown } } | null)?.agent; + if (rawAgent && rawAgent.provider === "claude-code") { + log.warn( + "Provider 'claude-code' was removed; migrating to 'anthropic'. Set agent.api_key " + + "(or the TELETON_API_KEY env var) to your Anthropic key (sk-ant-...)." + ); + rawAgent.provider = "anthropic"; + } + const result = ConfigSchema.safeParse(raw); if (!result.success) { throw new Error(`Invalid config: ${result.error.message}`); @@ -54,11 +66,7 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config { const config = result.data; const provider = config.agent.provider as SupportedProvider; - if ( - provider !== "anthropic" && - provider !== "claude-code" && - !(raw as Record>).agent?.model - ) { + if (provider !== "anthropic" && !(raw as Record>).agent?.model) { const meta = getProviderMetadata(provider); config.agent.model = meta.defaultModel; } diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts index 8c53cfd8..09514238 100644 --- a/src/config/model-catalog.ts +++ b/src/config/model-catalog.ts @@ -15,54 +15,54 @@ export const MODEL_OPTIONS: Record = { { value: "claude-opus-4-7", name: "Claude Opus 4.7", - description: "Most capable, 1M ctx, reasoning, $5/M", + description: "Most capable available, 1M ctx, reasoning, $5/$25", }, { value: "claude-opus-4-6", name: "Claude Opus 4.6", - description: "Previous gen, 1M ctx, reasoning, $5/M", + description: "Previous gen, 1M ctx, reasoning, $5/$25", }, { value: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", - description: "Older gen, 200K ctx, $5/M", + description: "Older gen, 200K ctx, $5/$25", }, { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", - description: "Balanced, 1M ctx, reasoning, $3/M", + description: "Balanced, 1M ctx, reasoning, $3/$15", }, { value: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", - description: "Fast & cheap, 200K ctx, $1/M (default)", + description: "Fast & cheap, 200K ctx, $1/$5 (default)", }, ], openai: [ { value: "gpt-5.5", name: "GPT-5.5", - description: "Latest frontier, reasoning, 272K ctx, $5/M", + description: "Latest frontier, reasoning, 272K ctx, $5/$30", }, { value: "gpt-5.5-pro", name: "GPT-5.5 Pro", - description: "Max capability, reasoning, 1M ctx, $30/M", + description: "Max capability, reasoning, 1M ctx, $30/$180", }, - { value: "gpt-5.4", name: "GPT-5.4", description: "Reasoning, 272K ctx, $2.50/M" }, + { value: "gpt-5.4", name: "GPT-5.4", description: "Reasoning, 272K ctx, $2.50/$15" }, { value: "gpt-5.4-pro", name: "GPT-5.4 Pro", - description: "Extended thinking, 1M ctx, $30/M", + description: "Extended thinking, 1M ctx, $30/$180", }, { value: "gpt-5.4-mini", name: "GPT-5.4 Mini", - description: "Fast & cheap, reasoning, 400K ctx, $0.75/M", + description: "Fast & cheap, reasoning, 400K ctx, $0.75/$4.50", }, - { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/M" }, - { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/M" }, - { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/M" }, + { value: "gpt-4o", name: "GPT-4o", description: "Balanced, 128K ctx, $2.50/$10" }, + { value: "gpt-4.1", name: "GPT-4.1", description: "1M ctx, $2/$8" }, + { value: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "1M ctx, cheap, $0.40/$1.60" }, ], "openai-codex": [ { value: "gpt-5.5", name: "GPT-5.5", description: "Latest frontier, reasoning, 272K ctx" }, @@ -79,31 +79,45 @@ export const MODEL_OPTIONS: Record = { { value: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", - description: "Preview, latest gen, reasoning, 1M ctx", + description: "Preview, latest gen, reasoning, 1M ctx, $2/$12", + }, + { + value: "gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash Lite", + description: "Preview, fast & cheap, reasoning, 1M ctx, $0.25/$1.50", + }, + { value: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Stable, 1M ctx, $1.25/$10" }, + { + value: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + description: "Fast, 1M ctx, $0.30/$2.50", }, - { value: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Stable, 1M ctx, $1.25/M" }, - { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" }, { value: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite", - description: "Ultra cheap, 1M ctx, $0.10/M", + description: "Ultra cheap, 1M ctx, $0.10/$0.40", }, ], xai: [ { value: "grok-4.3", name: "Grok 4.3", - description: "Latest, reasoning, vision, 1M ctx, $1.25/M", + description: "Latest, reasoning, vision, 1M ctx, $1.25/$2.50", }, { value: "grok-4.20-0309-reasoning", name: "Grok 4.20 Reasoning", - description: "Reasoning, vision, 2M ctx, $2/M", + description: "Reasoning, vision, 2M ctx, $2/$6", }, { value: "grok-4.20-0309-non-reasoning", name: "Grok 4.20 Non-Reasoning", - description: "Fast, vision, 2M ctx, $2/M", + description: "Fast, vision, 2M ctx, $2/$6", + }, + { + value: "grok-4-1-fast-non-reasoning", + name: "Grok 4.1 Fast", + description: "Fast, vision, 2M ctx, $0.20/$0.50", }, ], groq: [ @@ -250,9 +264,8 @@ export const MODEL_OPTIONS: Record = { ], }; -/** Get models for a provider (claude-code β†’ anthropic, codex β†’ openai-codex) */ +/** Get models for a provider (codex β†’ openai-codex) */ export function getModelsForProvider(provider: string): ModelOption[] { - const key = - provider === "claude-code" ? "anthropic" : provider === "codex" ? "openai-codex" : provider; + const key = provider === "codex" ? "openai-codex" : provider; return MODEL_OPTIONS[key] || []; } diff --git a/src/config/providers.ts b/src/config/providers.ts index 235517dd..d3a1d8fa 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -1,6 +1,5 @@ export type SupportedProvider = | "anthropic" - | "claude-code" | "codex" | "openai" | "google" @@ -30,18 +29,6 @@ export interface ProviderMetadata { } const PROVIDER_REGISTRY: Record = { - "claude-code": { - id: "claude-code", - displayName: "Claude Code (Auto)", - envVar: "ANTHROPIC_API_KEY", - keyPrefix: "sk-ant-", - keyHint: "Auto-detected from Claude Code", - consoleUrl: "https://console.anthropic.com/", - defaultModel: "claude-haiku-4-5-20251001", - utilityModel: "claude-haiku-4-5-20251001", - toolLimit: null, - piAiProvider: "anthropic", - }, codex: { id: "codex", displayName: "Codex (Auto)", @@ -236,16 +223,19 @@ export function getSupportedProviders(): ProviderMetadata[] { return Object.values(PROVIDER_REGISTRY); } +/** + * Provider ids as a non-empty tuple, derived from the single registry so the + * Zod `agent.provider` enum stays in sync with PROVIDER_REGISTRY (no 3rd copy). + */ +export const SUPPORTED_PROVIDER_IDS = Object.keys(PROVIDER_REGISTRY) as [ + SupportedProvider, + ...SupportedProvider[], +]; + export function validateApiKeyFormat(provider: SupportedProvider, key: string): string | undefined { const meta = PROVIDER_REGISTRY[provider]; if (!meta) return `Unknown provider: ${provider}`; - if ( - provider === "cocoon" || - provider === "local" || - provider === "claude-code" || - provider === "codex" - ) - return undefined; + if (provider === "cocoon" || provider === "local" || provider === "codex") return undefined; if (!key || key.trim().length === 0) return "API key is required"; if (meta.keyPrefix && !key.startsWith(meta.keyPrefix)) { return `Invalid format (should start with ${meta.keyPrefix})`; diff --git a/src/config/schema.ts b/src/config/schema.ts index 09827664..689bda22 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,9 +1,15 @@ import { z } from "zod"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js"; +import { SUPPORTED_PROVIDER_IDS } from "./providers.js"; export const DMPolicy = z.enum(["allowlist", "open", "admin-only", "disabled"]); export const GroupPolicy = z.enum(["open", "allowlist", "admin-only", "disabled"]); +// Exec enums exported so the UI whitelist (configurable-keys.ts) reuses them +// instead of re-listing the literals. +export const ExecMode = z.enum(["off", "yolo"]); +export const ExecScope = z.enum(["admin-only", "allowlist", "all"]); + export const SessionResetPolicySchema = z.object({ daily_reset_enabled: z.boolean().default(true).describe("Enable daily session reset"), daily_reset_hour: z @@ -20,26 +26,7 @@ export const SessionResetPolicySchema = z.object({ }); export const AgentConfigSchema = z.object({ - provider: z - .enum([ - "anthropic", - "claude-code", - "codex", - "openai", - "google", - "xai", - "groq", - "openrouter", - "moonshot", - "mistral", - "cerebras", - "zai", - "minimax", - "huggingface", - "cocoon", - "local", - ]) - .default("anthropic"), + provider: z.enum(SUPPORTED_PROVIDER_IDS).default("anthropic"), api_key: z.string().default(""), base_url: z .string() @@ -58,10 +45,6 @@ export const AgentConfigSchema = z.object({ .number() .default(5) .describe("Maximum number of agentic loop iterations (tool call β†’ result β†’ tool call cycles)"), - toolset: z - .string() - .default("full") - .describe("Active toolset profile: minimal, standard, trading, full"), session_reset_policy: SessionResetPolicySchema.default(SessionResetPolicySchema.parse({})), }); @@ -105,6 +88,10 @@ export const TelegramConfigSchema = z .describe( "Bot streaming mode: replace=each iteration replaces draft (default), all=concatenate all iterations, off=no streaming" ), + guest_mode: z + .boolean() + .default(false) + .describe("Allow the bot to answer guest queries in chats it is not a member of"), }) .superRefine((data, ctx) => { if (data.mode === "user") { @@ -315,14 +302,8 @@ const _ExecAuditObject = z.object({ }); const _ExecObject = z.object({ - mode: z - .enum(["off", "yolo"]) - .default("off") - .describe("Exec mode: off (disabled) or yolo (full system access)"), - scope: z - .enum(["admin-only", "allowlist", "all"]) - .default("admin-only") - .describe("Who can trigger exec tools"), + mode: ExecMode.default("off").describe("Exec mode: off (disabled) or yolo (full system access)"), + scope: ExecScope.default("admin-only").describe("Who can trigger exec tools"), allowlist: z .array(z.number()) .default([]) diff --git a/src/constants/timeouts.ts b/src/constants/timeouts.ts index 16059be2..7d718e63 100644 --- a/src/constants/timeouts.ts +++ b/src/constants/timeouts.ts @@ -10,6 +10,9 @@ export const RETRY_DEFAULT_TIMEOUT_MS = 15_000; export const RETRY_BLOCKCHAIN_BASE_DELAY_MS = 2_000; export const RETRY_BLOCKCHAIN_MAX_DELAY_MS = 15_000; export const RETRY_BLOCKCHAIN_TIMEOUT_MS = 30_000; +/** Max wait for an outgoing transfer to commit on-chain (TON finality is sub-second). */ +export const TON_CONFIRM_TIMEOUT_MS = 20_000; +export const TON_CONFIRM_POLL_INTERVAL_MS = 1_000; export const GRAMJS_RETRY_DELAY_MS = 1_000; export const GRAMJS_CONNECT_RETRY_DELAY_MS = 3_000; export const TOOL_EXECUTION_TIMEOUT_MS = 90_000; diff --git a/src/deals/executor.ts b/src/deals/executor.ts index 7af7c90a..9f39c0b5 100644 --- a/src/deals/executor.ts +++ b/src/deals/executor.ts @@ -7,9 +7,11 @@ import type Database from "better-sqlite3"; import type { ITelegramBridge } from "../telegram/bridge-interface.js"; import type { Deal } from "./types.js"; import { sendTon } from "../ton/transfer.js"; +import { tonExplorerTxUrl } from "../ton/confirm.js"; import { formatAsset } from "./utils.js"; import { JournalStore } from "../memory/journal-store.js"; import { getErrorMessage } from "../utils/errors.js"; +import { getClient } from "../sdk/telegram-utils.js"; import { createLogger } from "../utils/logger.js"; const log = createLogger("Deal"); @@ -88,15 +90,16 @@ export async function executeDeal( ); // Send TON to user's wallet - const txHash = await sendTon({ + const sendResult = await sendTon({ toAddress: deal.user_payment_wallet, amount: deal.agent_gives_ton_amount, comment: `Deal #${dealId} - ${formatAsset(deal.agent_gives_type, deal.agent_gives_ton_amount, deal.agent_gives_gift_slug)}`, }); - if (!txHash) { - throw new Error("TON transfer failed (wallet not initialized or invalid parameters)"); + if (!sendResult) { + throw new Error("TON transfer failed or could not be confirmed on-chain"); } + const txHash = sendResult.hash; // Update deal: mark as completed (agent_sent_at already set by lock) db.prepare( @@ -120,6 +123,7 @@ export async function executeDeal( I've sent **${deal.agent_gives_ton_amount} TON** to your wallet. TX Hash: \`${txHash}\` +${tonExplorerTxUrl(txHash)} Thank you for trading! πŸŽ‰`, }); @@ -144,8 +148,7 @@ Thank you for trading! πŸŽ‰`, ); // Transfer collectible gift using Telegram API - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto path - const gramJsClient = bridge.getRawClient() as any; + const gramJsClient = getClient(bridge); const Api = (await import("telegram")).Api; try { diff --git a/src/deals/module.ts b/src/deals/module.ts index dde71482..8b2c19f5 100644 --- a/src/deals/module.ts +++ b/src/deals/module.ts @@ -50,23 +50,35 @@ const dealsModule: PluginModule = { tools(config) { if (!config.deals?.enabled) return []; + // Deals run on the GramJS DealBot β€” userbot only (skipped in bot mode). return [ { tool: dealProposeTool, executor: withDealsDb(dealProposeExecutor), scope: "dm-only" as const, + mode: "user" as const, }, { tool: dealVerifyPaymentTool, executor: withDealsDb(dealVerifyPaymentExecutor), scope: "dm-only" as const, + mode: "user" as const, + }, + { + tool: dealStatusTool, + executor: withDealsDb(dealStatusExecutor), + mode: "user" as const, + }, + { + tool: dealListTool, + executor: withDealsDb(dealListExecutor), + mode: "user" as const, }, - { tool: dealStatusTool, executor: withDealsDb(dealStatusExecutor) }, - { tool: dealListTool, executor: withDealsDb(dealListExecutor) }, { tool: dealCancelTool, executor: withDealsDb(dealCancelExecutor), scope: "dm-only" as const, + mode: "user" as const, }, ]; }, diff --git a/src/index.ts b/src/index.ts index af696894..80e26294 100644 --- a/src/index.ts +++ b/src/index.ts @@ -207,6 +207,7 @@ export class TeletonApp { this.bridge, this.config.telegram, this.agent, + this.configPath, modulePermissions, this.toolRegistry ); @@ -366,6 +367,7 @@ ${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ this.bridge, this.config.telegram, this.agent, + this.configPath, modulePermissions, this.toolRegistry ); @@ -609,6 +611,27 @@ ${blue} β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ void this.debouncer!.enqueue(msg); }); if (firstStart) { + this.bridge.onGuestMessage(async (msg) => { + if (!this.config.telegram.guest_mode) return ""; + if (this.adminHandler.isPaused()) return ""; + const response = await this.agent.processMessage({ + chatId: `telegram:guest:${msg.chatId}`, + userMessage: msg.text, + userName: msg.senderFirstName, + senderUsername: msg.senderUsername, + isGroup: true, + isGuest: true, + timestamp: msg.timestamp.getTime(), + messageId: msg.id, + toolContext: { + bridge: this.bridge, + db: getDatabase().getDb(), + senderId: msg.senderId, + config: this.config, + }, + }); + return response.content; + }); this.bridge.startPolling(); } void this.bridge.syncCommands(); diff --git a/src/memory/__tests__/schema.test.ts b/src/memory/__tests__/schema.test.ts index 67554f37..8faa0ba5 100644 --- a/src/memory/__tests__/schema.test.ts +++ b/src/memory/__tests__/schema.test.ts @@ -1081,7 +1081,7 @@ describe("Memory Schema", () => { }); it("CURRENT_SCHEMA_VERSION is set to expected value", () => { - expect(CURRENT_SCHEMA_VERSION).toBe("1.18.0"); + expect(CURRENT_SCHEMA_VERSION).toBe("1.19.0"); }); }); @@ -1360,5 +1360,94 @@ describe("Memory Schema", () => { }; expect(row.enabled).toBe(1); }); + + it("migration 1.19.0 adds scope_level column and keeps legacy ones", () => { + ensureSchema(db); + runMigrations(db); + + const cols = ( + db.prepare(`PRAGMA table_info(tool_config)`).all() as Array<{ name: string }> + ).map((c) => c.name); + + expect(cols).toContain("enabled"); + expect(cols).toContain("scope"); + expect(cols).toContain("scope_level"); + }); + + it("migration 1.19.0 backfills scope_level from legacy (enabled, scope)", () => { + ensureSchema(db); + // Simulate a pre-1.19 tool_config table populated by an existing user. + db.exec(` + CREATE TABLE tool_config ( + tool_name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), + scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_by INTEGER + ); + `); + const seed: Array<[string, number, string | null]> = [ + ["t_open", 1, "open"], + ["t_always", 1, "always"], + ["t_dm", 1, "dm-only"], // context scope collapses to 'all' + ["t_group", 1, "group-only"], // collapses to 'all' + ["t_admin", 1, "admin-only"], + ["t_allow", 1, "allowlist"], + ["t_disabled", 1, "disabled"], + ["t_enabled0", 0, "admin-only"], // enabled=0 overrides scope β†’ off + ["t_null", 1, null], + ]; + const ins = db.prepare( + `INSERT INTO tool_config (tool_name, enabled, scope, updated_at) VALUES (?, ?, ?, unixepoch())` + ); + for (const [n, e, s] of seed) ins.run(n, e, s); + + setSchemaVersion(db, "1.18.0"); + runMigrations(db); + + const level = (name: string) => + ( + db.prepare(`SELECT scope_level FROM tool_config WHERE tool_name = ?`).get(name) as { + scope_level: string; + } + ).scope_level; + + expect(level("t_open")).toBe("all"); + expect(level("t_always")).toBe("all"); + expect(level("t_dm")).toBe("all"); + expect(level("t_group")).toBe("all"); + expect(level("t_admin")).toBe("admin"); + expect(level("t_allow")).toBe("allowlist"); + expect(level("t_disabled")).toBe("off"); + expect(level("t_enabled0")).toBe("off"); + expect(level("t_null")).toBe("all"); + }); + + it("migration 1.19.0 is idempotent on re-run", () => { + ensureSchema(db); + db.exec(` + CREATE TABLE tool_config ( + tool_name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), + scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_by INTEGER + ); + `); + db.prepare( + `INSERT INTO tool_config (tool_name, enabled, scope, updated_at) VALUES ('t_admin', 1, 'admin-only', unixepoch())` + ).run(); + + setSchemaVersion(db, "1.18.0"); + runMigrations(db); + // Force the 1.19.0 block to run a second time. + setSchemaVersion(db, "1.18.0"); + expect(() => runMigrations(db)).not.toThrow(); + + const row = db + .prepare(`SELECT scope_level FROM tool_config WHERE tool_name = 't_admin'`) + .get() as { scope_level: string }; + expect(row.scope_level).toBe("admin"); + }); }); }); diff --git a/src/memory/agent/tasks.ts b/src/memory/agent/tasks.ts index a531778e..698ab507 100644 --- a/src/memory/agent/tasks.ts +++ b/src/memory/agent/tasks.ts @@ -147,11 +147,8 @@ export class TaskStore { return this.getTask(taskId); } - getTask(id: string): Task | undefined { - const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow | undefined; - - if (!row) return undefined; - + /** Map a tasks row to a Task (single source for the 13-field projection). */ + private rowToTask(row: TaskRow): Task { return { id: row.id, description: row.description, @@ -170,6 +167,11 @@ export class TaskStore { }; } + getTask(id: string): Task | undefined { + const row = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(id) as TaskRow | undefined; + return row ? this.rowToTask(row) : undefined; + } + listTasks(filter?: { status?: TaskStatus; createdBy?: string }): Task[] { let sql = `SELECT * FROM tasks WHERE 1=1`; const params: (string | number)[] = []; @@ -188,22 +190,7 @@ export class TaskStore { const rows = this.db.prepare(sql).all(...params) as TaskRow[]; - return rows.map((row) => ({ - id: row.id, - description: row.description, - status: row.status as TaskStatus, - priority: row.priority, - createdBy: row.created_by ?? undefined, - createdAt: new Date(row.created_at * 1000), - startedAt: row.started_at ? new Date(row.started_at * 1000) : undefined, - completedAt: row.completed_at ? new Date(row.completed_at * 1000) : undefined, - result: row.result ?? undefined, - error: row.error ?? undefined, - scheduledFor: row.scheduled_for ? new Date(row.scheduled_for * 1000) : undefined, - payload: row.payload ?? undefined, - reason: row.reason ?? undefined, - scheduledMessageId: row.scheduled_message_id ?? undefined, - })); + return rows.map((row) => this.rowToTask(row)); } getActiveTasks(): Task[] { @@ -217,22 +204,7 @@ export class TaskStore { ) .all() as TaskRow[]; - return rows.map((row) => ({ - id: row.id, - description: row.description, - status: row.status as TaskStatus, - priority: row.priority, - createdBy: row.created_by ?? undefined, - createdAt: new Date(row.created_at * 1000), - startedAt: row.started_at ? new Date(row.started_at * 1000) : undefined, - completedAt: row.completed_at ? new Date(row.completed_at * 1000) : undefined, - result: row.result ?? undefined, - error: row.error ?? undefined, - scheduledFor: row.scheduled_for ? new Date(row.scheduled_for * 1000) : undefined, - payload: row.payload ?? undefined, - reason: row.reason ?? undefined, - scheduledMessageId: row.scheduled_message_id ?? undefined, - })); + return rows.map((row) => this.rowToTask(row)); } deleteTask(taskId: string): boolean { diff --git a/src/memory/ai-summarization.ts b/src/memory/ai-summarization.ts index a286e333..e6a9354c 100644 --- a/src/memory/ai-summarization.ts +++ b/src/memory/ai-summarization.ts @@ -1,11 +1,6 @@ -import { - complete, - type Context, - type Message, - type TextContent, - type ToolCall, -} from "@mariozechner/pi-ai"; -import { getUtilityModel } from "../agent/client.js"; +import { complete, type Context, type Message, type TextContent } from "@mariozechner/pi-ai"; +import { extractText, extractToolNames, stripEnvelopePrefix } from "../utils/pi-message.js"; +import { getUtilityModel } from "../providers/model-resolver.js"; import type { SupportedProvider } from "../config/providers.js"; import { CHARS_PER_TOKEN_ESTIMATE, @@ -84,10 +79,7 @@ function extractMessageContent(message: Message): string { if (message.role === "user") { return typeof message.content === "string" ? message.content : "[complex content]"; } else if (message.role === "assistant") { - return message.content - .filter((block): block is TextContent => block.type === "text") - .map((block) => block.text) - .join("\n"); + return extractText(message); } return ""; } @@ -98,19 +90,16 @@ export function formatMessagesForSummary(messages: Message[]): string { for (const msg of messages) { if (msg.role === "user") { const content = typeof msg.content === "string" ? msg.content : "[complex]"; - const bodyMatch = content.match(/\] (.+)/s); - const body = bodyMatch ? bodyMatch[1] : content; + const body = stripEnvelopePrefix(content); formatted.push(`User: ${body}`); } else if (msg.role === "assistant") { const textBlocks = msg.content.filter((b): b is TextContent => b.type === "text"); if (textBlocks.length > 0) { - const text = textBlocks.map((b) => b.text).join("\n"); - formatted.push(`Assistant: ${text}`); + formatted.push(`Assistant: ${extractText(msg)}`); } - const toolCalls = msg.content.filter((b): b is ToolCall => b.type === "toolCall"); - if (toolCalls.length > 0) { - const toolNames = toolCalls.map((b) => b.name).join(", "); - formatted.push(`[Used tools: ${toolNames}]`); + const toolNames = extractToolNames(msg); + if (toolNames.length > 0) { + formatted.push(`[Used tools: ${toolNames.join(", ")}]`); } } else if (msg.role === "toolResult") { formatted.push(`[Tool result: ${msg.toolName}]`); diff --git a/src/memory/compaction.ts b/src/memory/compaction.ts index 413c05e8..002702cb 100644 --- a/src/memory/compaction.ts +++ b/src/memory/compaction.ts @@ -1,4 +1,5 @@ import type { Context, Message, TextContent } from "@mariozechner/pi-ai"; +import { truncate } from "../utils/pi-message.js"; import { appendToTranscript, readTranscript } from "../session/transcript.js"; import { randomUUID } from "crypto"; import { writeSummaryToDailyLog } from "./daily-logs.js"; @@ -16,6 +17,7 @@ import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_SUMMARY_TOKENS, MEMORY_FLUSH_RECENT_MESSAGES, + CHARS_PER_TOKEN_ESTIMATE, } from "../constants/limits.js"; const COMPACTION_PREFIX = "[Auto-compacted"; @@ -65,7 +67,7 @@ function estimateContextTokens(context: Context): number { } } - return Math.ceil(charCount / 4); + return Math.ceil(charCount / CHARS_PER_TOKEN_ESTIMATE); } export function shouldFlushMemory( @@ -97,12 +99,12 @@ function flushMemoryToDailyLog(context: Context): void { for (const msg of recentMessages) { if (msg.role === "user") { const content = typeof msg.content === "string" ? msg.content : "[complex content]"; - summary.push(`- User: ${content.substring(0, 100)}${content.length > 100 ? "..." : ""}`); + summary.push(`- User: ${truncate(content, 100)}`); } else if (msg.role === "assistant") { const textBlocks = msg.content.filter((b): b is TextContent => b.type === "text"); if (textBlocks.length > 0) { const text = textBlocks[0].text || ""; - summary.push(`- Assistant: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`); + summary.push(`- Assistant: ${truncate(text, 100)}`); } } } diff --git a/src/memory/schema.ts b/src/memory/schema.ts index ecdee1fd..5e2a4577 100644 --- a/src/memory/schema.ts +++ b/src/memory/schema.ts @@ -4,6 +4,78 @@ import { createLogger } from "../utils/logger.js"; const log = createLogger("Memory"); +// ── Shared DDL ───────────────────────────────────────────────────────── +// Single source for table DDL that is otherwise duplicated verbatim between +// ensureSchema (fresh DB) and a historical migration (existing DB). Only tables +// whose inline and migration DDL were already byte-identical are factored here. +// Tables whose two definitions diverge (exec_audit) or whose migration replaces +// rather than creates (tg_messages FTS triggers: DROP+CREATE vs CREATE IF NOT +// EXISTS) are deliberately left inline to avoid changing applied-migration +// semantics on existing databases. + +const DDL_TASK_DEPENDENCIES = ` + CREATE TABLE IF NOT EXISTS task_dependencies ( + task_id TEXT NOT NULL, + depends_on_task_id TEXT NOT NULL, + PRIMARY KEY (task_id, depends_on_task_id), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id); + CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id); +`; + +const DDL_EMBEDDING_CACHE = ` + CREATE TABLE IF NOT EXISTS embedding_cache ( + hash TEXT NOT NULL, + model TEXT NOT NULL, + provider TEXT NOT NULL, + embedding BLOB NOT NULL, + dims INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + accessed_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (hash, model, provider) + ); + + CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at); +`; + +const DDL_PLUGIN_CONFIG = ` + CREATE TABLE IF NOT EXISTS plugin_config ( + plugin_name TEXT PRIMARY KEY, + priority INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`; + +const DDL_USER_HOOK_CONFIG = ` + CREATE TABLE IF NOT EXISTS user_hook_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`; + +/** + * Idempotent ALTER TABLE ... ADD COLUMN: swallows the "duplicate column name" + * error so migrations can re-run. Single definition shared by all migrations. + */ +function addColumnIfNotExists( + db: Database.Database, + table: string, + column: string, + type: string +): void { + try { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); + } catch (error: unknown) { + if (!(error instanceof Error) || !error.message.includes("duplicate column name")) { + throw error; + } + } +} + function compareSemver(a: string, b: string): number { const parseVersion = (v: string) => { const parts = v.split("-")[0].split(".").map(Number); @@ -135,16 +207,7 @@ export function ensureSchema(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by) WHERE created_by IS NOT NULL; -- Task Dependencies (for chained tasks) - CREATE TABLE IF NOT EXISTS task_dependencies ( - task_id TEXT NOT NULL, - depends_on_task_id TEXT NOT NULL, - PRIMARY KEY (task_id, depends_on_task_id), - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id); - CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id); + ${DDL_TASK_DEPENDENCIES} -- ============================================ -- TELEGRAM FEED @@ -243,18 +306,7 @@ export function ensureSchema(db: Database.Database): void { -- EMBEDDING CACHE -- ============================================ - CREATE TABLE IF NOT EXISTS embedding_cache ( - hash TEXT NOT NULL, - model TEXT NOT NULL, - provider TEXT NOT NULL, - embedding BLOB NOT NULL, - dims INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - accessed_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (hash, model, provider) - ); - - CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at); + ${DDL_EMBEDDING_CACHE} -- ===================================================== -- EXEC AUDIT (Command Execution History) @@ -284,21 +336,13 @@ export function ensureSchema(db: Database.Database): void { -- PLUGIN CONFIG (Plugin Priority Order) -- ===================================================== - CREATE TABLE IF NOT EXISTS plugin_config ( - plugin_name TEXT PRIMARY KEY, - priority INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); + ${DDL_PLUGIN_CONFIG} -- ===================================================== -- USER HOOK CONFIG (Keyword Blocklist + Context Triggers) -- ===================================================== - CREATE TABLE IF NOT EXISTS user_hook_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); + ${DDL_USER_HOOK_CONFIG} -- ===================================================== -- JOURNAL (Trading & Business Operations) @@ -356,7 +400,7 @@ export function setSchemaVersion(db: Database.Database, version: string): void { ).run(version); } -export const CURRENT_SCHEMA_VERSION = "1.18.0"; +export const CURRENT_SCHEMA_VERSION = "1.19.0"; export function runMigrations(db: Database.Database): void { const currentVersion = getSchemaVersion(db); @@ -393,18 +437,7 @@ export function runMigrations(db: Database.Database): void { `CREATE INDEX IF NOT EXISTS idx_tasks_scheduled ON tasks(scheduled_for) WHERE scheduled_for IS NOT NULL` ); - db.exec(` - CREATE TABLE IF NOT EXISTS task_dependencies ( - task_id TEXT NOT NULL, - depends_on_task_id TEXT NOT NULL, - PRIMARY KEY (task_id, depends_on_task_id), - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (depends_on_task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_task_deps_task ON task_dependencies(task_id); - CREATE INDEX IF NOT EXISTS idx_task_deps_parent ON task_dependencies(depends_on_task_id); - `); + db.exec(DDL_TASK_DEPENDENCIES); log.info("Migration 1.1.0 complete: Scheduled tasks support added"); } catch (error) { @@ -417,28 +450,19 @@ export function runMigrations(db: Database.Database): void { log.info("Running migration 1.2.0: Extend sessions table for SQLite backend"); // Add missing columns to sessions table - const addColumnIfNotExists = (table: string, column: string, type: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); - } catch (error: unknown) { - if (!(error instanceof Error) || !error.message.includes("duplicate column name")) { - throw error; - } - } - }; - addColumnIfNotExists( + db, "sessions", "updated_at", "INTEGER NOT NULL DEFAULT (unixepoch() * 1000)" ); - addColumnIfNotExists("sessions", "last_message_id", "INTEGER"); - addColumnIfNotExists("sessions", "last_channel", "TEXT"); - addColumnIfNotExists("sessions", "last_to", "TEXT"); - addColumnIfNotExists("sessions", "context_tokens", "INTEGER"); - addColumnIfNotExists("sessions", "model", "TEXT"); - addColumnIfNotExists("sessions", "provider", "TEXT"); - addColumnIfNotExists("sessions", "last_reset_date", "TEXT"); + addColumnIfNotExists(db, "sessions", "last_message_id", "INTEGER"); + addColumnIfNotExists(db, "sessions", "last_channel", "TEXT"); + addColumnIfNotExists(db, "sessions", "last_to", "TEXT"); + addColumnIfNotExists(db, "sessions", "context_tokens", "INTEGER"); + addColumnIfNotExists(db, "sessions", "model", "TEXT"); + addColumnIfNotExists(db, "sessions", "provider", "TEXT"); + addColumnIfNotExists(db, "sessions", "last_reset_date", "TEXT"); const sessions = db.prepare("SELECT started_at FROM sessions LIMIT 1").all() as Array<{ started_at: number; @@ -461,19 +485,7 @@ export function runMigrations(db: Database.Database): void { log.info("Running migration 1.9.0: Upgrade embedding_cache to BLOB storage"); try { db.exec(`DROP TABLE IF EXISTS embedding_cache`); - db.exec(` - CREATE TABLE IF NOT EXISTS embedding_cache ( - hash TEXT NOT NULL, - model TEXT NOT NULL, - provider TEXT NOT NULL, - embedding BLOB NOT NULL, - dims INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - accessed_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (hash, model, provider) - ); - CREATE INDEX IF NOT EXISTS idx_embedding_cache_accessed ON embedding_cache(accessed_at); - `); + db.exec(DDL_EMBEDDING_CACHE); log.info("Migration 1.9.0 complete: embedding_cache upgraded to BLOB storage"); } catch (error) { log.error({ err: error }, "Migration 1.9.0 failed"); @@ -597,18 +609,8 @@ export function runMigrations(db: Database.Database): void { if (!currentVersion || versionLessThan(currentVersion, "1.13.0")) { log.info("Running migration 1.13.0: Add token usage columns to sessions"); try { - const addColumnIfNotExists = (table: string, column: string, type: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); - } catch (error: unknown) { - if (!(error instanceof Error) || !error.message.includes("duplicate column name")) { - throw error; - } - } - }; - - addColumnIfNotExists("sessions", "input_tokens", "INTEGER DEFAULT 0"); - addColumnIfNotExists("sessions", "output_tokens", "INTEGER DEFAULT 0"); + addColumnIfNotExists(db, "sessions", "input_tokens", "INTEGER DEFAULT 0"); + addColumnIfNotExists(db, "sessions", "output_tokens", "INTEGER DEFAULT 0"); log.info("Migration 1.13.0 complete: Token usage columns added to sessions"); } catch (error) { @@ -620,13 +622,7 @@ export function runMigrations(db: Database.Database): void { if (!currentVersion || versionLessThan(currentVersion, "1.14.0")) { log.info("Running migration 1.14.0: Add plugin_config table for plugin priority"); try { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_config ( - plugin_name TEXT PRIMARY KEY, - priority INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - `); + db.exec(DDL_PLUGIN_CONFIG); log.info("Migration 1.14.0 complete: plugin_config table created"); } catch (error) { log.error({ err: error }, "Migration 1.14.0 failed"); @@ -637,13 +633,7 @@ export function runMigrations(db: Database.Database): void { if (!currentVersion || versionLessThan(currentVersion, "1.15.0")) { log.info("Running migration 1.15.0: Add user_hook_config table"); try { - db.exec(` - CREATE TABLE IF NOT EXISTS user_hook_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - `); + db.exec(DDL_USER_HOOK_CONFIG); log.info("Migration 1.15.0 complete: user_hook_config table created"); } catch (error) { log.error({ err: error }, "Migration 1.15.0 failed"); @@ -685,21 +675,11 @@ export function runMigrations(db: Database.Database): void { "Running migration 1.17.0: Add importance, access tracking, and lifecycle columns to knowledge" ); try { - const addColumnIfNotExists = (table: string, column: string, type: string) => { - try { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); - } catch (error: unknown) { - if (!(error instanceof Error) || !error.message.includes("duplicate column name")) { - throw error; - } - } - }; - - addColumnIfNotExists("knowledge", "importance", "REAL DEFAULT 0.5"); - addColumnIfNotExists("knowledge", "access_count", "INTEGER DEFAULT 0"); - addColumnIfNotExists("knowledge", "last_accessed_at", "INTEGER"); - addColumnIfNotExists("knowledge", "status", "TEXT DEFAULT 'active'"); - addColumnIfNotExists("knowledge", "memory_type", "TEXT DEFAULT 'semantic'"); + addColumnIfNotExists(db, "knowledge", "importance", "REAL DEFAULT 0.5"); + addColumnIfNotExists(db, "knowledge", "access_count", "INTEGER DEFAULT 0"); + addColumnIfNotExists(db, "knowledge", "last_accessed_at", "INTEGER"); + addColumnIfNotExists(db, "knowledge", "status", "TEXT DEFAULT 'active'"); + addColumnIfNotExists(db, "knowledge", "memory_type", "TEXT DEFAULT 'semantic'"); db.exec(`CREATE INDEX IF NOT EXISTS idx_knowledge_status ON knowledge(status)`); @@ -752,5 +732,58 @@ export function runMigrations(db: Database.Database): void { } } + if (!currentVersion || versionLessThan(currentVersion, "1.19.0")) { + log.info( + "Running migration 1.19.0: Add tool_config.scope_level (per-tool access: all/allowlist/admin/off)" + ); + try { + const tableExists = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tool_config'") + .get(); + + if (tableExists) { + db.transaction(() => { + // Additive: keep the legacy enabled/scope columns intact (rollback to + // pre-1.19 code still reads a coherent table) and add scope_level. + addColumnIfNotExists( + db, + "tool_config", + "scope_level", + "TEXT NOT NULL DEFAULT 'all' CHECK(scope_level IN ('all', 'allowlist', 'admin', 'off'))" + ); + // Backfill from the legacy (enabled, scope) pair. enabled=0 wins β†’ off. + // The old context dimension (dm-only/group-only) collapses to 'all' β€” + // channel gating now lives in the global DM/Group policies. Full + // recompute (no WHERE) so a re-run is idempotent. + db.exec(` + UPDATE tool_config SET + scope_level = CASE + WHEN enabled = 0 THEN 'off' + WHEN scope = 'admin-only' THEN 'admin' + WHEN scope = 'allowlist' THEN 'allowlist' + WHEN scope = 'disabled' THEN 'off' + ELSE 'all' + END; + `); + })(); + } else { + db.exec(` + CREATE TABLE IF NOT EXISTS tool_config ( + tool_name TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)), + scope TEXT CHECK(scope IN ('always', 'open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled')), + scope_level TEXT NOT NULL DEFAULT 'all' CHECK(scope_level IN ('all', 'allowlist', 'admin', 'off')), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_by INTEGER + ); + `); + } + log.info("Migration 1.19.0 complete: tool_config.scope_level added"); + } catch (error) { + log.error({ err: error }, "Migration 1.19.0 failed"); + throw error; + } + } + setSchemaVersion(db, CURRENT_SCHEMA_VERSION); } diff --git a/src/memory/search/fts-utils.ts b/src/memory/search/fts-utils.ts new file mode 100644 index 00000000..6fcb7062 --- /dev/null +++ b/src/memory/search/fts-utils.ts @@ -0,0 +1,25 @@ +/** + * Shared SQLite FTS5 query helpers. + * + * The escape function is a security-relevant surface (prevents FTS5 syntax + * injection); keeping a single definition avoids the escaped-character list + * drifting between the knowledge RAG, the tool index and session search. + */ + +/** + * Escape FTS5 special characters to prevent syntax errors. + */ +export function escapeFts5Query(query: string): string { + return query + .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Convert BM25 rank to normalized score. + * FTS5 rank is negative; more negative = better match. + */ +export function bm25ToScore(rank: number): number { + return 1 / (1 + Math.exp(rank)); +} diff --git a/src/memory/search/hybrid.ts b/src/memory/search/hybrid.ts index 540affeb..7182985f 100644 --- a/src/memory/search/hybrid.ts +++ b/src/memory/search/hybrid.ts @@ -8,6 +8,7 @@ import { SECONDS_PER_HOUR, } from "../../constants/limits.js"; import { createLogger } from "../../utils/logger.js"; +import { escapeFts5Query, bm25ToScore } from "./fts-utils.js"; const log = createLogger("Memory"); @@ -61,16 +62,6 @@ export function parseTemporalIntent(query: string): { afterTimestamp?: number } return {}; } -/** - * Escape FTS5 special characters to prevent syntax errors. - */ -function escapeFts5Query(query: string): string { - return query - .replace(/["\*\-\+\(\)\:\^\~\?\.\@\#\$\%\&\!\[\]\{\}\|\\\/<>=,;'`]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - /** * Hybrid search combining vector similarity and BM25 keyword search. */ @@ -235,7 +226,7 @@ export class HybridSearch { return rows.map((row) => ({ ...row, - keywordScore: this.bm25ToScore(row.score), + keywordScore: bm25ToScore(row.score), createdAt: row.created_at ?? undefined, importance: row.importance ?? 0.5, lastAccessedAt: row.last_accessed_at ?? undefined, @@ -346,7 +337,7 @@ export class HybridSearch { return rows.map((row) => ({ ...row, text: row.text ?? "", - keywordScore: this.bm25ToScore(row.score), + keywordScore: bm25ToScore(row.score), createdAt: row.timestamp ?? undefined, })); } catch (error) { @@ -401,12 +392,4 @@ export class HybridSearch { .sort((a, b) => b.score - a.score) .slice(0, limit); } - - /** - * Convert BM25 rank to normalized score. - * FTS5 rank is negative; more negative = better match. - */ - private bm25ToScore(rank: number): number { - return 1 / (1 + Math.exp(rank)); - } } diff --git a/src/memory/tool-config.ts b/src/memory/tool-config.ts index e4b075e2..84ed10a9 100644 --- a/src/memory/tool-config.ts +++ b/src/memory/tool-config.ts @@ -1,43 +1,42 @@ import type Database from "better-sqlite3"; -import type { ToolScope } from "../agent/tools/types.js"; +import { levelToScope, type ToolAccessLevel } from "../agent/tools/scope.js"; export interface ToolConfig { toolName: string; - enabled: boolean; - scope: ToolScope | null; // null = use default from tool definition + level: ToolAccessLevel; updatedAt: number; updatedBy: number | null; } +interface ToolConfigRow { + tool_name: string; + scope_level: ToolAccessLevel; + updated_at: number; + updated_by: number | null; +} + +function rowToConfig(row: ToolConfigRow): ToolConfig { + return { + toolName: row.tool_name, + level: row.scope_level, + updatedAt: row.updated_at, + updatedBy: row.updated_by, + }; +} + /** * Load tool configuration from database */ export function loadToolConfig(db: Database.Database, toolName: string): ToolConfig | null { const row = db .prepare( - `SELECT tool_name, enabled, scope, updated_at, updated_by + `SELECT tool_name, scope_level, updated_at, updated_by FROM tool_config WHERE tool_name = ?` ) - .get(toolName) as - | { - tool_name: string; - enabled: number; - scope: ToolScope | null; - updated_at: number; - updated_by: number | null; - } - | undefined; - - if (!row) return null; + .get(toolName) as ToolConfigRow | undefined; - return { - toolName: row.tool_name, - enabled: row.enabled === 1, - scope: row.scope, - updatedAt: row.updated_at, - updatedBy: row.updated_by, - }; + return row ? rowToConfig(row) : null; } /** @@ -46,49 +45,41 @@ export function loadToolConfig(db: Database.Database, toolName: string): ToolCon export function loadAllToolConfigs(db: Database.Database): Map { const rows = db .prepare( - `SELECT tool_name, enabled, scope, updated_at, updated_by + `SELECT tool_name, scope_level, updated_at, updated_by FROM tool_config` ) - .all() as Array<{ - tool_name: string; - enabled: number; - scope: ToolScope | null; - updated_at: number; - updated_by: number | null; - }>; + .all() as ToolConfigRow[]; const configs = new Map(); for (const row of rows) { - configs.set(row.tool_name, { - toolName: row.tool_name, - enabled: row.enabled === 1, - scope: row.scope, - updatedAt: row.updated_at, - updatedBy: row.updated_by, - }); + configs.set(row.tool_name, rowToConfig(row)); } return configs; } /** - * Save or update tool configuration + * Save or update a tool's access level. The legacy enabled/scope columns are + * kept in sync (derived) so a downgrade to pre-1.19 code still sees coherent + * values. */ export function saveToolConfig( db: Database.Database, toolName: string, - enabled: boolean, - scope: ToolScope | null, + level: ToolAccessLevel, updatedBy?: number ): void { + const legacyScope = levelToScope(level); + const legacyEnabled = level === "off" ? 0 : 1; db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), ?) + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), ?) ON CONFLICT(tool_name) DO UPDATE SET enabled = excluded.enabled, scope = excluded.scope, + scope_level = excluded.scope_level, updated_at = excluded.updated_at, updated_by = excluded.updated_by` - ).run(toolName, enabled ? 1 : 0, scope, updatedBy ?? null); + ).run(toolName, legacyEnabled, legacyScope, level, updatedBy ?? null); } /** @@ -97,15 +88,16 @@ export function saveToolConfig( export function initializeToolConfig( db: Database.Database, toolName: string, - defaultEnabled: boolean, - defaultScope: ToolScope + level: ToolAccessLevel ): void { const existing = loadToolConfig(db, toolName); if (!existing) { + const legacyScope = levelToScope(level); + const legacyEnabled = level === "off" ? 0 : 1; db.prepare( - `INSERT INTO tool_config (tool_name, enabled, scope, updated_at, updated_by) - VALUES (?, ?, ?, unixepoch(), NULL)` - ).run(toolName, defaultEnabled ? 1 : 0, defaultScope); + `INSERT INTO tool_config (tool_name, enabled, scope, scope_level, updated_at, updated_by) + VALUES (?, ?, ?, ?, unixepoch(), NULL)` + ).run(toolName, legacyEnabled, legacyScope, level); } } diff --git a/src/providers/__tests__/claude-code-credentials.test.ts b/src/providers/__tests__/claude-code-credentials.test.ts deleted file mode 100644 index 2aba36d6..00000000 --- a/src/providers/__tests__/claude-code-credentials.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { join } from "path"; -import { mkdirSync, writeFileSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { randomBytes } from "crypto"; - -// ── Test fixtures ─────────────────────────────────────────────────────── - -const TEST_DIR = join(tmpdir(), `claude-creds-test-${randomBytes(8).toString("hex")}`); -const CREDS_FILE = join(TEST_DIR, ".credentials.json"); - -function validCredentials(overrides: Record = {}) { - return { - claudeAiOauth: { - accessToken: "sk-ant-oat01-test-token-abc123", - refreshToken: "sk-ant-ort01-refresh-xyz", - expiresAt: Date.now() + 3_600_000, // 1h from now - scopes: ["user:inference", "user:profile"], - ...overrides, - }, - }; -} - -function expiredCredentials() { - return validCredentials({ expiresAt: Date.now() - 60_000 }); // 1min ago -} - -function writeCredsFile(data: unknown) { - writeFileSync(CREDS_FILE, JSON.stringify(data), "utf-8"); -} - -// ── Env management ────────────────────────────────────────────────────── - -const envKeysToClean: string[] = []; - -function setEnv(key: string, value: string) { - process.env[key] = value; - envKeysToClean.push(key); -} - -// ── Setup / Teardown ──────────────────────────────────────────────────── - -beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }); - setEnv("CLAUDE_CONFIG_DIR", TEST_DIR); - // Default: OAuth refresh endpoint returns an error (tests rely on disk fallback) - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "Internal Server Error" }) - ); -}); - -afterEach(async () => { - for (const key of envKeysToClean) delete process.env[key]; - envKeysToClean.length = 0; - try { - rmSync(TEST_DIR, { recursive: true, force: true }); - } catch {} - vi.unstubAllGlobals(); - // Reset module to clear cached state - vi.resetModules(); -}); - -// ── Helper: fresh import ──────────────────────────────────────────────── - -async function importModule() { - return import("../claude-code-credentials.js"); -} - -// ── Tests ─────────────────────────────────────────────────────────────── - -describe("claude-code-credentials", () => { - // T1 - it("reads valid credentials from .credentials.json", async () => { - writeCredsFile(validCredentials()); - const mod = await importModule(); - const key = mod.getClaudeCodeApiKey(); - expect(key).toBe("sk-ant-oat01-test-token-abc123"); - }); - - // T2 - it("throws when no credentials file and no fallback", async () => { - const mod = await importModule(); - expect(() => mod.getClaudeCodeApiKey()).toThrow(/No Claude Code credentials found/); - }); - - // T3 - it("falls back to manual key on malformed JSON", async () => { - writeFileSync(CREDS_FILE, "NOT VALID JSON{{{", "utf-8"); - const mod = await importModule(); - const key = mod.getClaudeCodeApiKey("sk-ant-api03-fallback"); - expect(key).toBe("sk-ant-api03-fallback"); - }); - - // T4 - it("falls back when claudeAiOauth field is missing", async () => { - writeCredsFile({ someOtherKey: "value" }); - const mod = await importModule(); - const key = mod.getClaudeCodeApiKey("sk-ant-api03-fallback"); - expect(key).toBe("sk-ant-api03-fallback"); - }); - - // T5 - it("caches token and does not re-read on second call", async () => { - writeCredsFile(validCredentials()); - const mod = await importModule(); - - const key1 = mod.getClaudeCodeApiKey(); - // Overwrite file with different token - writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-different" })); - const key2 = mod.getClaudeCodeApiKey(); - - // Should still return cached token - expect(key1).toBe(key2); - expect(key2).toBe("sk-ant-oat01-test-token-abc123"); - }); - - // T6 - it("re-reads file when cached token is expired", async () => { - writeCredsFile(expiredCredentials()); - const mod = await importModule(); - - // First call reads expired token β€” it still returns it (just read) - const key1 = mod.getClaudeCodeApiKey(); - expect(key1).toBe("sk-ant-oat01-test-token-abc123"); - - // Now token is cached but expired, write new token - writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-refreshed" })); - const key2 = mod.getClaudeCodeApiKey(); - expect(key2).toBe("sk-ant-oat01-refreshed"); - }); - - // T7 - it("refreshClaudeCodeApiKey clears cache and re-reads", async () => { - writeCredsFile(validCredentials()); - const mod = await importModule(); - - const key1 = mod.getClaudeCodeApiKey(); - expect(key1).toBe("sk-ant-oat01-test-token-abc123"); - - // Write new token and force refresh (OAuth fails β†’ disk fallback) - writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-new-token" })); - const key2 = await mod.refreshClaudeCodeApiKey(); - expect(key2).toBe("sk-ant-oat01-new-token"); - }); - - // T8 - it("respects CLAUDE_CONFIG_DIR override", async () => { - const customDir = join(tmpdir(), `claude-custom-${randomBytes(4).toString("hex")}`); - mkdirSync(customDir, { recursive: true }); - writeFileSync( - join(customDir, ".credentials.json"), - JSON.stringify(validCredentials({ accessToken: "sk-ant-oat01-custom-dir" })), - "utf-8" - ); - - // Override the env - setEnv("CLAUDE_CONFIG_DIR", customDir); - const mod = await importModule(); - const key = mod.getClaudeCodeApiKey(); - expect(key).toBe("sk-ant-oat01-custom-dir"); - - rmSync(customDir, { recursive: true, force: true }); - }); - - // T9 - it("falls back to manual api_key when no credentials", async () => { - const mod = await importModule(); - const key = mod.getClaudeCodeApiKey("sk-ant-api03-manual-key"); - expect(key).toBe("sk-ant-api03-manual-key"); - }); - - // T10 - it("isClaudeCodeTokenValid returns true for valid cached token", async () => { - writeCredsFile(validCredentials()); - const mod = await importModule(); - mod.getClaudeCodeApiKey(); // populate cache - expect(mod.isClaudeCodeTokenValid()).toBe(true); - }); - - // T11 - it("isClaudeCodeTokenValid returns false when no cached token", async () => { - const mod = await importModule(); - expect(mod.isClaudeCodeTokenValid()).toBe(false); - }); - - // T12 - it("refreshClaudeCodeApiKey returns null when no credentials available", async () => { - const mod = await importModule(); - const result = await mod.refreshClaudeCodeApiKey(); - expect(result).toBeNull(); - }); - - // T13 - it("_resetCache clears the cache", async () => { - writeCredsFile(validCredentials()); - const mod = await importModule(); - mod.getClaudeCodeApiKey(); - expect(mod.isClaudeCodeTokenValid()).toBe(true); - mod._resetCache(); - expect(mod.isClaudeCodeTokenValid()).toBe(false); - }); -}); diff --git a/src/providers/__tests__/claude-code-provider.test.ts b/src/providers/__tests__/claude-code-provider.test.ts deleted file mode 100644 index f7621e39..00000000 --- a/src/providers/__tests__/claude-code-provider.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - getProviderMetadata, - validateApiKeyFormat, - getSupportedProviders, -} from "../../config/providers.js"; -import { AgentConfigSchema } from "../../config/schema.js"; - -describe("claude-code provider registration", () => { - // T14 - it("is registered with correct metadata", () => { - const meta = getProviderMetadata("claude-code"); - expect(meta.id).toBe("claude-code"); - expect(meta.displayName).toBe("Claude Code (Auto)"); - expect(meta.piAiProvider).toBe("anthropic"); - expect(meta.toolLimit).toBeNull(); - expect(meta.defaultModel).toBe("claude-haiku-4-5-20251001"); - expect(meta.utilityModel).toBe("claude-haiku-4-5-20251001"); - expect(meta.keyPrefix).toBe("sk-ant-"); - }); - - it("appears in getSupportedProviders()", () => { - const providers = getSupportedProviders(); - const ids = providers.map((p) => p.id); - expect(ids).toContain("claude-code"); - }); - - it("has identical API config to anthropic except display", () => { - const anthropic = getProviderMetadata("anthropic"); - const claudeCode = getProviderMetadata("claude-code"); - - expect(claudeCode.piAiProvider).toBe(anthropic.piAiProvider); - expect(claudeCode.toolLimit).toBe(anthropic.toolLimit); - expect(claudeCode.defaultModel).toBe(anthropic.defaultModel); - expect(claudeCode.utilityModel).toBe(anthropic.utilityModel); - expect(claudeCode.envVar).toBe(anthropic.envVar); - expect(claudeCode.keyPrefix).toBe(anthropic.keyPrefix); - }); - - // T15 - it("is accepted by AgentConfigSchema", () => { - const result = AgentConfigSchema.safeParse({ provider: "claude-code" }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.provider).toBe("claude-code"); - } - }); - - it("skips api key validation for claude-code (auto-detects)", () => { - // claude-code is exempt from key validation β€” credentials are auto-detected - expect(validateApiKeyFormat("claude-code", "sk-ant-api03-valid")).toBeUndefined(); - expect(validateApiKeyFormat("claude-code", "sk-ant-oat01-oauth")).toBeUndefined(); - expect(validateApiKeyFormat("claude-code", "invalid-key")).toBeUndefined(); - expect(validateApiKeyFormat("claude-code", "")).toBeUndefined(); - }); -}); diff --git a/src/providers/__tests__/claude-code-retry.test.ts b/src/providers/__tests__/claude-code-retry.test.ts deleted file mode 100644 index 20f0c0d4..00000000 --- a/src/providers/__tests__/claude-code-retry.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { join } from "path"; -import { mkdirSync, writeFileSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { randomBytes } from "crypto"; - -// ── Test fixtures ─────────────────────────────────────────────────────── - -const TEST_DIR = join(tmpdir(), `claude-retry-test-${randomBytes(8).toString("hex")}`); -const CREDS_FILE = join(TEST_DIR, ".credentials.json"); - -function validCredentials(token = "sk-ant-oat01-test-token") { - return { - claudeAiOauth: { - accessToken: token, - refreshToken: "sk-ant-ort01-refresh", - expiresAt: Date.now() + 3_600_000, - scopes: ["user:inference"], - }, - }; -} - -function writeCredsFile(data: unknown) { - writeFileSync(CREDS_FILE, JSON.stringify(data), "utf-8"); -} - -// ── Mock pi-ai complete ───────────────────────────────────────────────── - -const mockComplete = vi.fn(); -vi.mock("@mariozechner/pi-ai", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - complete: (...args: unknown[]) => mockComplete(...args), - }; -}); - -// ── Env management ────────────────────────────────────────────────────── - -const envKeysToClean: string[] = []; - -function setEnv(key: string, value: string) { - process.env[key] = value; - envKeysToClean.push(key); -} - -beforeEach(() => { - mkdirSync(TEST_DIR, { recursive: true }); - setEnv("CLAUDE_CONFIG_DIR", TEST_DIR); - vi.clearAllMocks(); -}); - -afterEach(() => { - for (const key of envKeysToClean) delete process.env[key]; - envKeysToClean.length = 0; - try { - rmSync(TEST_DIR, { recursive: true, force: true }); - } catch {} -}); - -// ── Helpers ───────────────────────────────────────────────────────────── - -function makeAssistantMessage(text: string, stopReason = "endTurn", errorMessage?: string) { - return { - role: "assistant", - content: [{ type: "text", text }], - stopReason, - errorMessage, - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }, - }; -} - -// ── Tests ─────────────────────────────────────────────────────────────── - -describe("claude-code 401 retry", () => { - // T12 - it("retries once on 401 and succeeds", async () => { - writeCredsFile(validCredentials("sk-ant-oat01-first-token")); - - // First call: 401 error - mockComplete.mockResolvedValueOnce(makeAssistantMessage("", "error", "401 Unauthorized")); - // After refresh: write new credentials and return success - writeCredsFile(validCredentials("sk-ant-oat01-refreshed-token")); - mockComplete.mockResolvedValueOnce(makeAssistantMessage("Hello!", "endTurn")); - - const { chatWithContext } = await import("../../agent/client.js"); - const { _resetCache } = await import("../claude-code-credentials.js"); - _resetCache(); - - const response = await chatWithContext( - { - provider: "claude-code", - api_key: "", - model: "claude-opus-4-6", - max_tokens: 1024, - temperature: 0.7, - system_prompt: null, - max_agentic_iterations: 5, - session_reset_policy: { - daily_reset_enabled: false, - daily_reset_hour: 4, - idle_expiry_enabled: false, - idle_expiry_minutes: 1440, - }, - }, - { - context: { messages: [], systemPrompt: "test" }, - } - ); - - expect(mockComplete).toHaveBeenCalledTimes(2); - expect(response.text).toBe("Hello!"); - }); - - // T13 - it("does not retry more than once on persistent 401", async () => { - writeCredsFile(validCredentials()); - - // Both calls return 401 - mockComplete.mockResolvedValue(makeAssistantMessage("", "error", "401 Unauthorized")); - - const { chatWithContext } = await import("../../agent/client.js"); - const { _resetCache } = await import("../claude-code-credentials.js"); - _resetCache(); - - const response = await chatWithContext( - { - provider: "claude-code", - api_key: "", - model: "claude-opus-4-6", - max_tokens: 1024, - temperature: 0.7, - system_prompt: null, - max_agentic_iterations: 5, - session_reset_policy: { - daily_reset_enabled: false, - daily_reset_hour: 4, - idle_expiry_enabled: false, - idle_expiry_minutes: 1440, - }, - }, - { - context: { messages: [], systemPrompt: "test" }, - } - ); - - // Should have retried exactly once (2 calls total) - expect(mockComplete).toHaveBeenCalledTimes(2); - // Response should still be the error (not infinite loop) - expect(response.message.stopReason).toBe("error"); - }); -}); diff --git a/src/providers/claude-code-credentials.ts b/src/providers/claude-code-credentials.ts deleted file mode 100644 index 9c67301e..00000000 --- a/src/providers/claude-code-credentials.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Claude Code credential reader. - * - * Reads OAuth tokens from the local Claude Code installation: - * - Linux/Windows: ~/.claude/.credentials.json - * - macOS: Keychain (service "Claude Code-credentials") β†’ file fallback - * - * Tokens are cached in memory and re-read only on expiration or forced refresh. - */ - -import { readFileSync, writeFileSync, existsSync } from "fs"; -import { execSync } from "child_process"; -import { homedir } from "os"; -import { join } from "path"; -import { createLogger } from "../utils/logger.js"; - -const log = createLogger("ClaudeCodeCreds"); - -// ── OAuth constants (extracted from Claude Code binary) ──────────────── - -const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; -const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const OAUTH_SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers"; - -// ── Types ────────────────────────────────────────────────────────────── - -interface ClaudeOAuthCredentials { - claudeAiOauth?: { - accessToken?: string; - refreshToken?: string; - expiresAt?: number; - scopes?: string[]; - }; -} - -// ── Module-level cache ───────────────────────────────────────────────── - -let cachedToken: string | null = null; -let cachedExpiresAt = 0; -let cachedRefreshToken: string | null = null; - -// ── Internal helpers ─────────────────────────────────────────────────── - -function getClaudeConfigDir(): string { - return process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); -} - -function getCredentialsFilePath(): string { - return join(getClaudeConfigDir(), ".credentials.json"); -} - -/** Read credentials from ~/.claude/.credentials.json */ -function readCredentialsFile(): ClaudeOAuthCredentials | null { - const filePath = getCredentialsFilePath(); - if (!existsSync(filePath)) return null; - - try { - const raw = readFileSync(filePath, "utf-8"); - return JSON.parse(raw) as ClaudeOAuthCredentials; - } catch (error) { - log.warn({ err: error, path: filePath }, "Failed to parse Claude Code credentials file"); - return null; - } -} - -/** Read credentials from macOS Keychain via security CLI */ -function readKeychainCredentials(): ClaudeOAuthCredentials | null { - // Try the standard service name, then the legacy one (bug #1311) - const serviceNames = ["Claude Code-credentials", "Claude Code"]; - - for (const service of serviceNames) { - try { - const raw = execSync(`security find-generic-password -s "${service}" -w`, { - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return JSON.parse(raw) as ClaudeOAuthCredentials; - } catch { - // Not found under this service name, try next - } - } - return null; -} - -/** Read credentials using the appropriate platform method */ -function readCredentials(): ClaudeOAuthCredentials | null { - if (process.platform === "darwin") { - // macOS: Keychain first, file fallback - const keychainCreds = readKeychainCredentials(); - if (keychainCreds) return keychainCreds; - log.debug("Keychain read failed, falling back to credentials file"); - } - - return readCredentialsFile(); -} - -/** Extract and validate token + expiresAt from raw credentials */ -function extractToken(creds: ClaudeOAuthCredentials): { - token: string; - expiresAt: number; - refreshToken?: string; -} | null { - const oauth = creds.claudeAiOauth; - if (!oauth?.accessToken) { - log.warn("Claude Code credentials found but missing accessToken"); - return null; - } - return { - token: oauth.accessToken, - expiresAt: oauth.expiresAt ?? 0, - refreshToken: oauth.refreshToken, - }; -} - -// ── Public API ───────────────────────────────────────────────────────── - -/** - * Get the Claude Code API key with intelligent caching. - * - * Resolution order: - * 1. Return cached token if still valid (Date.now() < expiresAt) - * 2. Read from disk/Keychain and cache - * 3. Fall back to `fallbackKey` if provided - * 4. Throw if nothing works - */ -export function getClaudeCodeApiKey(fallbackKey?: string): string { - // Fast path: cached and valid - if (cachedToken && Date.now() < cachedExpiresAt) { - return cachedToken; - } - - // Read from disk - const creds = readCredentials(); - if (creds) { - const extracted = extractToken(creds); - if (extracted) { - cachedToken = extracted.token; - cachedExpiresAt = extracted.expiresAt; - cachedRefreshToken = extracted.refreshToken ?? null; - log.debug("Claude Code credentials loaded successfully"); - return cachedToken; - } - } - - // Fallback to manual key - if (fallbackKey && fallbackKey.length > 0) { - log.warn("Claude Code credentials not found, using fallback api_key from config"); - return fallbackKey; - } - - throw new Error("No Claude Code credentials found. Run 'claude login' or set api_key in config."); -} - -/** - * Call the Claude Code OAuth token endpoint to exchange a refresh token for a new access token. - * On success, persists the new credentials to disk and returns the new access token. - */ -async function performOAuthRefresh(refreshToken: string): Promise { - try { - const res = await fetch(OAUTH_TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: OAUTH_CLIENT_ID, - scope: OAUTH_SCOPES, - }), - }); - - if (!res.ok) { - log.warn(`OAuth token refresh failed: ${res.status} ${res.statusText}`); - return null; - } - - const data = (await res.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!data.access_token || !data.expires_in) { - log.warn("OAuth token refresh: unexpected response shape"); - return null; - } - - const newExpiresAt = Date.now() + data.expires_in * 1000; - const newRefreshToken = data.refresh_token ?? refreshToken; - - // Persist updated credentials to disk - const filePath = getCredentialsFilePath(); - try { - const existing = existsSync(filePath) - ? (JSON.parse(readFileSync(filePath, "utf-8")) as ClaudeOAuthCredentials) - : {}; - const updated: ClaudeOAuthCredentials = { - ...existing, - claudeAiOauth: { - ...existing.claudeAiOauth, - accessToken: data.access_token, - refreshToken: newRefreshToken, - expiresAt: newExpiresAt, - }, - }; - writeFileSync(filePath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - } catch (innerError) { - log.warn({ err: innerError }, "Failed to persist refreshed OAuth credentials to disk"); - } - - // Update in-memory cache - cachedToken = data.access_token; - cachedExpiresAt = newExpiresAt; - cachedRefreshToken = newRefreshToken; - - log.info("Claude Code OAuth token refreshed successfully"); - return cachedToken; - } catch (error) { - log.warn({ err: error }, "OAuth token refresh request failed"); - return null; - } -} - -/** - * Force credential refresh (called on 401). - * First attempts OAuth refresh via the refresh token, then falls back to re-reading disk. - * Returns the new token or null if unavailable. - */ -export async function refreshClaudeCodeApiKey(): Promise { - // Clear access token cache (keep refresh token for OAuth attempt) - cachedToken = null; - cachedExpiresAt = 0; - - // Populate refresh token from disk if not already cached - if (!cachedRefreshToken) { - const creds = readCredentials(); - if (creds) { - const extracted = extractToken(creds); - cachedRefreshToken = extracted?.refreshToken ?? null; - } - } - - // Try OAuth refresh first - if (cachedRefreshToken) { - const refreshed = await performOAuthRefresh(cachedRefreshToken); - if (refreshed) return refreshed; - log.warn("OAuth refresh failed, falling back to disk read"); - } - - // Fallback: re-read from disk (in case another process already refreshed it) - const creds = readCredentials(); - if (creds) { - const extracted = extractToken(creds); - if (extracted) { - cachedToken = extracted.token; - cachedExpiresAt = extracted.expiresAt; - cachedRefreshToken = extracted.refreshToken ?? null; - log.info("Claude Code credentials refreshed from disk"); - return cachedToken; - } - } - - log.warn("Failed to refresh Claude Code credentials"); - return null; -} - -/** Check if the currently cached token is still valid */ -export function isClaudeCodeTokenValid(): boolean { - return cachedToken !== null && Date.now() < cachedExpiresAt; -} - -/** Reset internal cache β€” exposed for testing only */ -export function _resetCache(): void { - cachedToken = null; - cachedExpiresAt = 0; - cachedRefreshToken = null; -} diff --git a/src/providers/codex-credentials.ts b/src/providers/codex-credentials.ts index 5f812f84..e341085f 100644 --- a/src/providers/codex-credentials.ts +++ b/src/providers/codex-credentials.ts @@ -14,6 +14,8 @@ import { readFileSync, existsSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { createLogger } from "../utils/logger.js"; +import { extractJwtExpiry } from "../utils/jwt.js"; +import { createTokenCache } from "./token-cache.js"; const log = createLogger("CodexCreds"); @@ -33,8 +35,7 @@ interface CodexAuthFile { // ── Module-level cache ───────────────────────────────────────────────── -let cachedToken: string | null = null; -let cachedExpiresAt = 0; +const tokenCache = createTokenCache(); // ── Internal helpers ─────────────────────────────────────────────────── @@ -60,18 +61,6 @@ function readAuthFile(): CodexAuthFile | null { } } -/** Extract expiry from a JWT token (exp claim in seconds) */ -function extractJwtExpiry(token: string): number { - try { - const parts = token.split("."); - if (parts.length !== 3) return 0; - const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); - return (payload.exp ?? 0) * 1000; // Convert seconds β†’ ms - } catch { - return 0; - } -} - /** Extract the access token and its expiry from the auth file */ function extractToken(auth: CodexAuthFile): { token: string; expiresAt: number } | null { // Prefer OAuth token from tokens object @@ -103,8 +92,8 @@ function extractToken(auth: CodexAuthFile): { token: string; expiresAt: number } */ export function getCodexApiKey(fallbackKey?: string): string { // Fast path: cached and valid - if (cachedToken && Date.now() < cachedExpiresAt) { - return cachedToken; + if (tokenCache.isValid()) { + return tokenCache.get() as string; } // Read from disk @@ -112,10 +101,9 @@ export function getCodexApiKey(fallbackKey?: string): string { if (auth) { const extracted = extractToken(auth); if (extracted) { - cachedToken = extracted.token; - cachedExpiresAt = extracted.expiresAt; + tokenCache.set(extracted.token, extracted.expiresAt); log.debug("Codex credentials loaded successfully"); - return cachedToken; + return extracted.token; } } @@ -136,17 +124,15 @@ export function getCodexApiKey(fallbackKey?: string): string { * in case the CLI has already refreshed the token. */ export async function refreshCodexApiKey(): Promise { - cachedToken = null; - cachedExpiresAt = 0; + tokenCache.reset(); const auth = readAuthFile(); if (auth) { const extracted = extractToken(auth); if (extracted) { - cachedToken = extracted.token; - cachedExpiresAt = extracted.expiresAt; + tokenCache.set(extracted.token, extracted.expiresAt); log.info("Codex credentials refreshed from disk"); - return cachedToken; + return extracted.token; } } @@ -156,11 +142,10 @@ export async function refreshCodexApiKey(): Promise { /** Check if the currently cached token is still valid */ export function isCodexTokenValid(): boolean { - return cachedToken !== null && Date.now() < cachedExpiresAt; + return tokenCache.isValid(); } /** Reset internal cache β€” exposed for testing only */ export function _resetCache(): void { - cachedToken = null; - cachedExpiresAt = 0; + tokenCache.reset(); } diff --git a/src/providers/model-resolver.ts b/src/providers/model-resolver.ts new file mode 100644 index 00000000..f42994db --- /dev/null +++ b/src/providers/model-resolver.ts @@ -0,0 +1,186 @@ +import { getModel, type Model, type Api } from "@mariozechner/pi-ai"; +import { getProviderMetadata, type SupportedProvider } from "../config/providers.js"; +import { createLogger } from "../utils/logger.js"; +import { fetchWithTimeout } from "../utils/fetch.js"; + +const log = createLogger("LLM"); + +const modelCache = new Map>(); + +const COCOON_MODELS: Record> = {}; + +/** Register models discovered from a running Cocoon client */ +export async function registerCocoonModels(httpPort: number): Promise { + try { + const res = await fetchWithTimeout(`http://localhost:${httpPort}/v1/models`, { + timeoutMs: 3000, + }); + if (!res.ok) return []; + const body = (await res.json()) as { + data?: { id?: string; name?: string }[]; + models?: { id?: string; name?: string }[]; + }; + const models = body.data || body.models || []; + if (!Array.isArray(models)) return []; + const ids: string[] = []; + for (const m of models) { + const id = m.id || m.name || String(m); + COCOON_MODELS[id] = { + id, + name: id, + api: "openai-completions", + provider: "cocoon", + baseUrl: `http://localhost:${httpPort}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }; + ids.push(id); + } + return ids; + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + log.warn({ port: httpPort }, "Cocoon /v1/models timed out after 3s, returning empty list"); + } + return []; + } +} + +const LOCAL_MODELS: Record> = {}; + +/** Register models discovered from a local OpenAI-compatible server */ +export async function registerLocalModels(baseUrl: string): Promise { + try { + const parsed = new URL(baseUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + log.warn(`Local LLM base_url must use http or https (got ${parsed.protocol})`); + return []; + } + const url = baseUrl.replace(/\/+$/, ""); + const res = await fetchWithTimeout(`${url}/models`, { timeoutMs: 10_000 }); + if (!res.ok) return []; + const body = (await res.json()) as { + data?: { id?: string; name?: string }[]; + models?: { id?: string; name?: string }[]; + }; + const rawModels = body.data || body.models || []; + if (!Array.isArray(rawModels)) return []; + const models = rawModels.slice(0, 500); + const ids: string[] = []; + for (const m of models) { + const id = m.id || m.name || String(m); + LOCAL_MODELS[id] = { + id, + name: id, + api: "openai-completions", + provider: "local", + baseUrl: url, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + }, + }; + ids.push(id); + } + return ids; + } catch { + return []; + } +} + +/** Moonshot backward-compat: old model IDs β†’ kimi-coding IDs */ +const MOONSHOT_MODEL_ALIASES: Record = { + "kimi-k2.5": "kimi-for-coding", + k2p6: "kimi-for-coding", +}; + +export function getProviderModel(provider: SupportedProvider, modelId: string): Model { + const cacheKey = `${provider}:${modelId}`; + const cached = modelCache.get(cacheKey); + if (cached) return cached; + + const meta = getProviderMetadata(provider); + + if (meta.piAiProvider === "cocoon") { + let model = COCOON_MODELS[modelId]; + if (!model) { + model = Object.values(COCOON_MODELS)[0]; + if (model) log.warn(`Cocoon model "${modelId}" not found, using "${model.id}"`); + } + if (model) { + modelCache.set(cacheKey, model); + return model; + } + throw new Error("No Cocoon models available. Is the cocoon client running?"); + } + + if (meta.piAiProvider === "local") { + let model = LOCAL_MODELS[modelId]; + if (!model) { + model = Object.values(LOCAL_MODELS)[0]; + if (model) log.warn(`Local model "${modelId}" not found, using "${model.id}"`); + } + if (model) { + modelCache.set(cacheKey, model); + return model; + } + throw new Error("No local models available. Is the LLM server running?"); + } + + // Moonshot backward-compat: remap old model IDs to kimi-coding IDs + if (provider === "moonshot" && MOONSHOT_MODEL_ALIASES[modelId]) { + modelId = MOONSHOT_MODEL_ALIASES[modelId]; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- getModel requires literal provider+model types; dynamic strings need casts + const model = getModel(meta.piAiProvider as any, modelId as any); + if (!model) { + throw new Error(`getModel returned undefined for ${provider}/${modelId}`); + } + modelCache.set(cacheKey, model); + return model; + } catch { + log.warn(`Model ${modelId} not found for ${provider}, falling back to ${meta.defaultModel}`); + const fallbackKey = `${provider}:${meta.defaultModel}`; + const fallbackCached = modelCache.get(fallbackKey); + if (fallbackCached) return fallbackCached; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- same as above: dynamic strings + const model = getModel(meta.piAiProvider as any, meta.defaultModel as any); + if (!model) { + throw new Error( + `Fallback model ${meta.defaultModel} also returned undefined for ${provider}` + ); + } + modelCache.set(fallbackKey, model); + return model; + } catch { + throw new Error( + `Could not find model ${modelId} or fallback ${meta.defaultModel} for ${provider}` + ); + } + } +} + +export function getUtilityModel(provider: SupportedProvider, overrideModel?: string): Model { + const meta = getProviderMetadata(provider); + const modelId = overrideModel || meta.utilityModel; + return getProviderModel(provider, modelId); +} diff --git a/src/providers/token-cache.ts b/src/providers/token-cache.ts new file mode 100644 index 00000000..2f5fd76f --- /dev/null +++ b/src/providers/token-cache.ts @@ -0,0 +1,40 @@ +/** + * In-memory access-token cache shared by the credential readers. + * + * Encapsulates the `cachedToken` / `cachedExpiresAt` pair and the + * "valid while now < expiresAt" rule that every provider reimplemented + * identically. Refresh-token handling and disk/Keychain/OAuth readers stay + * provider-specific. + */ +export interface TokenCache { + /** The cached token, or null when unset/reset. */ + get(): string | null; + /** Store a token and its expiry (Unix ms). */ + set(token: string, expiresAt: number): void; + /** True when a token is cached and not yet expired. */ + isValid(): boolean; + /** Clear the cache. */ + reset(): void; +} + +export function createTokenCache(): TokenCache { + let cachedToken: string | null = null; + let cachedExpiresAt = 0; + + return { + get() { + return cachedToken; + }, + set(token, expiresAt) { + cachedToken = token; + cachedExpiresAt = expiresAt; + }, + isValid() { + return cachedToken !== null && Date.now() < cachedExpiresAt; + }, + reset() { + cachedToken = null; + cachedExpiresAt = 0; + }, + }; +} diff --git a/src/sdk/__tests__/bot.test.ts b/src/sdk/__tests__/bot.test.ts index b6b31c82..38b5ea3c 100644 --- a/src/sdk/__tests__/bot.test.ts +++ b/src/sdk/__tests__/bot.test.ts @@ -4,28 +4,6 @@ import type { InlineRouter, PluginBotHandlers } from "../../bot/inline-router.js import type { PluginRateLimiter } from "../../bot/rate-limiter.js"; import type { PluginLogger, BotManifest } from "@teleton-agent/sdk"; -// Mock html-parser module -vi.mock("../../bot/services/html-parser.js", () => ({ - stripCustomEmoji: (text: string) => text, - parseHtml: (text: string) => ({ text, entities: [] }), -})); - -// Mock styled-keyboard module -vi.mock("../../bot/services/styled-keyboard.js", () => ({ - toTLMarkup: (buttons: any) => ({ _: "ReplyInlineMarkup", buttons }), - toGrammyKeyboard: (buttons: any) => ({ _: "InlineKeyboard", buttons }), - hasStyledButtons: () => true, - prefixButtons: (rows: any[][], pluginName: string) => - rows.map((row: any[]) => - row.map((btn: any) => ({ - text: btn.text, - callbackData: btn.callback ? `${pluginName}:${btn.callback}` : "", - copyText: btn.copy, - style: btn.style, - })) - ), -})); - function createMockRouter(): InlineRouter & { _plugins: Map } { const plugins = new Map(); return { diff --git a/src/sdk/__tests__/telegram-social.test.ts b/src/sdk/__tests__/telegram-social.test.ts index 3432f96e..61af8ef0 100644 --- a/src/sdk/__tests__/telegram-social.test.ts +++ b/src/sdk/__tests__/telegram-social.test.ts @@ -290,9 +290,9 @@ describe("createTelegramSocialSDK", () => { }); it("returns null on unexpected top-level error, logs it", async () => { - // Make getRawClient throw to hit the outer catch - const originalGetRawClient = mockBridge.getRawClient; - mockBridge.getRawClient = () => { + // Make getClient throw to hit the outer catch + const originalGetClient = mockBridge.getClient; + mockBridge.getClient = () => { throw new Error("unexpected"); }; @@ -302,7 +302,7 @@ describe("createTelegramSocialSDK", () => { expect(result).toBeNull(); expect(mockLog.error).toHaveBeenCalled(); - mockBridge.getRawClient = originalGetRawClient; + mockBridge.getClient = originalGetClient; }); }); diff --git a/src/sdk/__tests__/ton.test.ts b/src/sdk/__tests__/ton.test.ts index e5f98c11..3984a6f2 100644 --- a/src/sdk/__tests__/ton.test.ts +++ b/src/sdk/__tests__/ton.test.ts @@ -99,6 +99,24 @@ const mockLog = { const VALID_ADDRESS = "EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2"; +/** A confirmed external-in wallet tx, for mocking on-chain send confirmation (hash = "cd"*32). */ +const CONFIRMED_TX = { + lt: 100n, + now: 2000, + inMessage: { info: { type: "external-in" } }, + description: { + type: "generic", + computePhase: { type: "vm", success: true, exitCode: 0 }, + actionPhase: { success: true, resultCode: 0, noFunds: false }, + }, + hash: () => Buffer.from("cd".repeat(32), "hex"), +} as any; + +/** getTransactions mock: empty pre-send snapshot, then the confirmed tx on every poll. */ +function confirmingGetTransactions() { + return vi.fn().mockResolvedValueOnce([]).mockResolvedValue([CONFIRMED_TX]); +} + function mockResponse(data: any, status = 200): Response { return { ok: status >= 200 && status < 300, @@ -263,11 +281,15 @@ describe("createTonSDK", () => { }); }); - it("returns txRef on success", async () => { - (sendTon as Mock).mockResolvedValue("42_1700000000_1.5"); + it("returns the on-chain tx hash as txRef on success", async () => { + (sendTon as Mock).mockResolvedValue({ + hash: "cd".repeat(32), + seqno: 42, + at: 1700000000000, + }); const result = await sdk.sendTON(VALID_ADDRESS, 1.5, "hello"); - expect(result).toEqual({ txRef: "42_1700000000_1.5", amount: 1.5 }); + expect(result).toEqual({ txRef: "cd".repeat(32), amount: 1.5 }); expect(sendTon).toHaveBeenCalledWith({ toAddress: VALID_ADDRESS, amount: 1.5, @@ -276,12 +298,12 @@ describe("createTonSDK", () => { }); }); - it("throws OPERATION_FAILED when sendTon returns null", async () => { + it("throws OPERATION_FAILED when the transfer is not confirmed", async () => { (sendTon as Mock).mockResolvedValue(null); await expect(sdk.sendTON(VALID_ADDRESS, 1)).rejects.toMatchObject({ code: "OPERATION_FAILED", - message: expect.stringContaining("no reference returned"), + message: expect.stringContaining("not be confirmed"), }); }); @@ -618,11 +640,13 @@ describe("createTonSDK", () => { // Mock getCachedTonClient β€” returns a client with an open() method const mockWalletContract = { + address: { toString: () => "EQwallet" }, getSeqno: vi.fn().mockResolvedValue(42), sendTransfer: vi.fn().mockResolvedValue(undefined), }; (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockWalletContract), + getTransactions: confirmingGetTransactions(), }); }); @@ -716,7 +740,7 @@ describe("createTonSDK", () => { ); const result = await sdk.sendJetton(jettonAddr, recipientAddr, 10); - expect(result).toEqual({ success: true, seqno: 42 }); + expect(result).toEqual({ success: true, seqno: 42, txRef: "cd".repeat(32) }); }); it("throws OPERATION_FAILED when getKeyPair returns null", async () => { @@ -1347,6 +1371,7 @@ describe("createTonSDK", () => { }; (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); mocks.toNano.mockReturnValue(BigInt(50000000)); mocks.internal.mockReturnValue({}); @@ -1487,6 +1512,7 @@ describe("createTonSDK", () => { }; (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); // Mock TonAPI jetton balances @@ -1607,6 +1633,7 @@ describe("createTonSDK", () => { }; (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); }); @@ -1761,6 +1788,7 @@ describe("createTonSDK", () => { describe("send()", () => { const mockContract = { + address: { toString: () => VALID_ADDRESS }, getSeqno: vi.fn().mockResolvedValue(7), sendTransfer: vi.fn().mockResolvedValue(undefined), }; @@ -1777,6 +1805,7 @@ describe("createTonSDK", () => { })); (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); (withTxLock as Mock).mockImplementation((fn: () => any) => fn()); mockContract.getSeqno.mockResolvedValue(7); @@ -1785,9 +1814,7 @@ describe("createTonSDK", () => { it("sends with default options", async () => { const result = await sdk.send(VALID_ADDRESS, 1); - expect(result).toMatchObject({ seqno: 7 }); - expect(result.hash).toContain("7_"); - expect(result.hash).toContain("_send"); + expect(result).toEqual({ hash: "cd".repeat(32), seqno: 7 }); expect(withTxLock).toHaveBeenCalled(); }); @@ -1829,6 +1856,7 @@ describe("createTonSDK", () => { mockContract.sendTransfer.mockResolvedValue(undefined); (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); (withTxLock as Mock).mockImplementation((fn: () => any) => fn()); @@ -1901,6 +1929,7 @@ describe("createTonSDK", () => { describe("sendMessages()", () => { const mockContract = { + address: { toString: () => VALID_ADDRESS }, getSeqno: vi.fn().mockResolvedValue(15), sendTransfer: vi.fn().mockResolvedValue(undefined), }; @@ -1917,6 +1946,7 @@ describe("createTonSDK", () => { })); (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); (withTxLock as Mock).mockImplementation((fn: () => any) => fn()); mockContract.getSeqno.mockResolvedValue(15); @@ -1929,8 +1959,7 @@ describe("createTonSDK", () => { { to: VALID_ADDRESS, value: 2 }, ]; const result = await sdk.sendMessages(msgs); - expect(result).toMatchObject({ seqno: 15 }); - expect(result.hash).toContain("_sendMessages"); + expect(result).toEqual({ hash: "cd".repeat(32), seqno: 15 }); expect(mocks.internal).toHaveBeenCalledTimes(2); expect(withTxLock).toHaveBeenCalled(); }); @@ -1999,6 +2028,7 @@ describe("createTonSDK", () => { describe("createSender()", () => { const mockContract = { + address: { toString: () => VALID_ADDRESS }, getSeqno: vi.fn().mockResolvedValue(20), sendTransfer: vi.fn().mockResolvedValue(undefined), }; @@ -2013,6 +2043,7 @@ describe("createTonSDK", () => { mocks.internal.mockReturnValue({}); (getCachedTonClient as Mock).mockResolvedValue({ open: vi.fn().mockReturnValue(mockContract), + getTransactions: confirmingGetTransactions(), }); (withTxLock as Mock).mockImplementation((fn: () => any) => fn()); mockContract.getSeqno.mockResolvedValue(20); diff --git a/src/sdk/bot.ts b/src/sdk/bot.ts index f8d7ad4a..e8b9e0e3 100644 --- a/src/sdk/bot.ts +++ b/src/sdk/bot.ts @@ -8,9 +8,15 @@ import type { InlineRouter, PluginBotHandlers } from "../bot/inline-router.js"; import type { GramJSBotClient } from "../bot/gramjs-bot.js"; import type { Bot } from "grammy"; import type { PluginRateLimiter } from "../bot/rate-limiter.js"; -import { toTLMarkup, toGrammyKeyboard, prefixButtons } from "../bot/services/styled-keyboard.js"; -import { stripCustomEmoji, parseHtml } from "../bot/services/html-parser.js"; -import { compileGlob } from "../bot/inline-router.js"; +import { + toTLMarkup, + toGrammyKeyboard, + prefixButtons, + stripCustomEmoji, + compileGlob, +} from "./formatting.js"; +import { editInlineViaGramJS } from "../bot/services/inline-transport.js"; +import { getGramJSErrorMessage } from "../utils/errors.js"; export function createBotSDK( router: InlineRouter | null, @@ -90,22 +96,16 @@ export function createBotSDK( // Try GramJS first (styled buttons) if (gramjsBot?.isConnected() && keyboard) { try { - const strippedHtml = stripCustomEmoji(text); - const { text: plainText, entities } = parseHtml(strippedHtml); - const markup = toTLMarkup(keyboard); - - await gramjsBot.editInlineMessageByStringId({ + await editInlineViaGramJS({ + gramjsBot, inlineMessageId, - text: plainText, - entities: entities.length > 0 ? entities : undefined, - replyMarkup: markup, + html: stripCustomEmoji(text), + buttons: keyboard, }); return; } catch (error: unknown) { - const grammJsErr = error as { errorMessage?: string }; - if (grammJsErr.errorMessage === "MESSAGE_NOT_MODIFIED") return; log.warn( - `GramJS edit failed, falling back to Grammy: ${grammJsErr.errorMessage || error}` + `GramJS edit failed, falling back to Grammy: ${getGramJSErrorMessage(error) || error}` ); } } diff --git a/src/sdk/formatting.ts b/src/sdk/formatting.ts new file mode 100644 index 00000000..1b519305 --- /dev/null +++ b/src/sdk/formatting.ts @@ -0,0 +1,280 @@ +/** + * Pure formatting helpers shared across the bot transport layer and the plugin + * bot SDK. These functions have no module-level state and depend only on the + * Telegram/Grammy primitives β€” they live in sdk/ (the reusable layer) so that + * sdk/bot.ts no longer has to reach into the concrete bot/ layer for them. + * + * Contents: + * - Styled keyboard conversion (TL markup, Grammy keyboard, plugin prefixing) + * - HTML β†’ MessageEntity parsing for MTProto + * - Glob β†’ RegExp compilation for callback routing + */ + +import { Api } from "telegram"; +import { InlineKeyboard } from "grammy"; +import { toLong } from "../utils/gramjs-bigint.js"; + +// ── Styled keyboard ────────────────────────────────────────────────────────── + +export type ButtonStyle = "success" | "danger" | "primary"; + +export interface StyledButtonDef { + text: string; + callbackData: string; + style?: ButtonStyle; + /** If set, renders as KeyboardButtonCopy (click-to-clipboard) via MTProto */ + copyText?: string; +} + +/** + * Result type for all message builders + */ +export interface DealMessage { + text: string; + buttons: StyledButtonDef[][]; +} + +/** + * Convert styled button definitions to GramJS TL markup (with colors + copy buttons) + * Uses native Layer 223 constructors (KeyboardButtonStyle, KeyboardButtonCopy) + */ +export function toTLMarkup(buttons: StyledButtonDef[][]): Api.ReplyInlineMarkup { + return new Api.ReplyInlineMarkup({ + rows: buttons + .filter((row) => row.length > 0) + .map( + (row) => + new Api.KeyboardButtonRow({ + buttons: row.map((btn) => { + // Copy button: native click-to-clipboard (no callback needed) + if (btn.copyText) { + return new Api.KeyboardButtonCopy({ + text: btn.text, + copyText: btn.copyText, + }); + } + + // Callback button: with optional color style + const style = btn.style + ? new Api.KeyboardButtonStyle({ + bgSuccess: btn.style === "success", + bgDanger: btn.style === "danger", + bgPrimary: btn.style === "primary", + }) + : undefined; + return new Api.KeyboardButtonCallback({ + text: btn.text, + data: Buffer.from(btn.callbackData), + style, + }); + }), + }) + ), + }); +} + +/** + * Convert styled button definitions to Grammy InlineKeyboard (fallback, no colors) + * Copy buttons use Bot API's native copy_text field (click-to-clipboard) + */ +export function toGrammyKeyboard(buttons: StyledButtonDef[][]): InlineKeyboard { + const kb = new InlineKeyboard(); + for (let i = 0; i < buttons.length; i++) { + if (i > 0) kb.row(); + for (const btn of buttons[i]) { + if (btn.copyText) { + kb.copyText(btn.text, btn.copyText); + } else { + kb.text(btn.text, btn.callbackData); + } + } + } + return kb; +} + +/** + * Check if button array has any buttons + */ +export function hasStyledButtons(buttons: StyledButtonDef[][]): boolean { + return buttons.some((row) => row.length > 0); +} + +/** + * Convert plugin ButtonDef[][] to StyledButtonDef[][] with prefixed callbacks. + * Shared by both sdk/bot.ts and bot/inline-router.ts. + */ +export function prefixButtons( + rows: { text: string; callback?: string; url?: string; copy?: string; style?: ButtonStyle }[][], + pluginName: string +): StyledButtonDef[][] { + return rows.map((row) => + row.map((btn) => { + if (btn.copy) { + return { text: btn.text, callbackData: "", copyText: btn.copy, style: btn.style }; + } + return { + text: btn.text, + callbackData: btn.callback ? `${pluginName}:${btn.callback}` : "", + style: btn.style, + }; + }) + ); +} + +// ── HTML parsing ───────────────────────────────────────────────────────────── + +export interface ParsedMessage { + text: string; + entities: Api.TypeMessageEntity[]; +} + +/** + * Parse HTML string to plain text + MessageEntity array. + * + * Converts our limited HTML subset (, , , , ) + * to plain text + Telegram MessageEntity array. Entity offsets use UTF-16 code + * units (matching Telegram's spec and JS string.length). + */ +export function parseHtml(html: string): ParsedMessage { + const entities: Api.TypeMessageEntity[] = []; + let text = ""; + let pos = 0; + + // Stack for tracking open tags + const stack: { tag: string; offset: number; url?: string; emojiId?: string }[] = []; + + while (pos < html.length) { + if (html[pos] === "<") { + const endBracket = html.indexOf(">", pos); + if (endBracket === -1) { + // Malformed HTML - treat '<' as literal + text += "<"; + pos++; + continue; + } + + const tagStr = html.substring(pos + 1, endBracket); + + if (tagStr.startsWith("/")) { + // Closing tag + const tagName = tagStr.substring(1).toLowerCase().trim(); + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].tag === tagName) { + const open = stack[i]; + const length = text.length - open.offset; + + if (length > 0) { + switch (tagName) { + case "b": + case "strong": + entities.push(new Api.MessageEntityBold({ offset: open.offset, length })); + break; + case "i": + case "em": + entities.push(new Api.MessageEntityItalic({ offset: open.offset, length })); + break; + case "code": + entities.push(new Api.MessageEntityCode({ offset: open.offset, length })); + break; + case "a": + if (open.url) { + entities.push( + new Api.MessageEntityTextUrl({ + offset: open.offset, + length, + url: open.url, + }) + ); + } + break; + case "tg-emoji": + if (open.emojiId) { + entities.push( + new Api.MessageEntityCustomEmoji({ + offset: open.offset, + length, + documentId: toLong(open.emojiId), + }) + ); + } + break; + } + } + + stack.splice(i, 1); + break; + } + } + } else { + // Opening tag + const spaceIdx = tagStr.indexOf(" "); + const tagName = (spaceIdx >= 0 ? tagStr.substring(0, spaceIdx) : tagStr).toLowerCase(); + const attrs = spaceIdx >= 0 ? tagStr.substring(spaceIdx) : ""; + + let url: string | undefined; + let emojiId: string | undefined; + if (tagName === "a") { + const hrefMatch = attrs.match(/href="([^"]+)"/); + if (hrefMatch) { + const rawUrl = unescapeHtml(hrefMatch[1]); + if (/^(javascript|data|vbscript|file):/i.test(rawUrl.trim())) { + url = "#"; + } else { + url = rawUrl; + } + } + } else if (tagName === "tg-emoji") { + const eidMatch = attrs.match(/emoji-id="([^"]+)"/); + if (eidMatch) emojiId = eidMatch[1]; + } + + stack.push({ tag: tagName, offset: text.length, url, emojiId }); + } + + pos = endBracket + 1; + } else if (html.substring(pos, pos + 5) === "&") { + text += "&"; + pos += 5; + } else if (html.substring(pos, pos + 4) === "<") { + text += "<"; + pos += 4; + } else if (html.substring(pos, pos + 4) === ">") { + text += ">"; + pos += 4; + } else if (html.substring(pos, pos + 6) === """) { + text += '"'; + pos += 6; + } else { + text += html[pos]; + pos++; + } + } + + return { text, entities }; +} + +/** + * Strip tags for Grammy/Bot API fallback (keeps unicode emoji inside) + */ +export function stripCustomEmoji(html: string): string { + return html.replace(/]*>([^<]*)<\/tg-emoji>/g, "$1"); +} + +function unescapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"'); +} + +// ── Glob compilation ───────────────────────────────────────────────────────── + +/** + * Compile a glob-like pattern to a RegExp. + * Supports `*` as wildcard matching any sequence of characters. + */ +export function compileGlob(pattern: string): RegExp { + const regexStr = "^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "(.*)") + "$"; + return new RegExp(regexStr); +} diff --git a/src/sdk/telegram-social.ts b/src/sdk/telegram-social.ts index 5126ccc3..5db2d280 100644 --- a/src/sdk/telegram-social.ts +++ b/src/sdk/telegram-social.ts @@ -52,6 +52,49 @@ export function createTelegramSocialSDK( return getClientUtil(bridge); } + type UserOpContext = { client: ReturnType; Api: typeof Api }; + + /** + * Shared preamble + catch for user-mode operations whose failure is a thrown + * PluginSDKError (the ~17 throw-on-error methods). Applies requireUserMode + + * requireBridge, resolves {client, Api}, runs `fn`, and normalizes the catch: + * PluginSDKError passes through, anything else is wrapped as + * `Failed to ${label}: …` / OPERATION_FAILED. Methods with graceful + * return-default catches (null/[]) keep their own try/catch and do NOT use this. + */ + async function userOp( + name: string, + label: string, + fn: (ctx: UserOpContext) => Promise + ): Promise { + requireUserMode(name); + requireBridge(); + try { + const client = getClient(); + const Api = await getApi(); + return await fn({ client, Api }); + } catch (error) { + if (error instanceof PluginSDKError) throw error; + throw new PluginSDKError(`Failed to ${label}: ${getErrorMessage(error)}`, "OPERATION_FAILED"); + } + } + + /** Issue an EditBanned with the given rights β€” shared by ban/unban/mute. */ + async function editBanned( + ctx: UserOpContext, + chatId: string, + userId: number | string, + bannedRights: Api.ChatBannedRights + ): Promise { + await ctx.client.invoke( + new ctx.Api.channels.EditBanned({ + channel: chatId, + participant: userId.toString(), + bannedRights, + }) + ); + } + return { // ─── Chat & Users ───────────────────────────────────────── @@ -140,11 +183,7 @@ export function createTelegramSocialSDK( }, async getUserInfo(userId: number | string): Promise { - requireUserMode("getUserInfo"); - requireBridge(); - try { - const client = getClient(); - + return userOp("getUserInfo", "get user info", async ({ client }) => { let entity; try { const id = typeof userId === "string" ? userId.replace("@", "") : userId.toString(); @@ -163,22 +202,11 @@ export function createTelegramSocialSDK( username: user.username || undefined, isBot: user.bot || false, }; - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to get user info: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async resolveUsername(username: string): Promise { - requireUserMode("resolveUsername"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("resolveUsername", "resolve username", async ({ client, Api }) => { const cleanUsername = username.replace("@", "").toLowerCase(); if (!cleanUsername) return null; @@ -225,13 +253,7 @@ export function createTelegramSocialSDK( } return null; - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to resolve username: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async getParticipants(chatId: string, limit?: number): Promise { @@ -285,18 +307,13 @@ export function createTelegramSocialSDK( answers: string[], opts?: PollOptions ): Promise { - requireUserMode("createPoll"); - requireBridge(); - if (!answers || answers.length < 2) { - throw new PluginSDKError("Poll must have at least 2 answers", "OPERATION_FAILED"); - } - if (answers.length > 10) { - throw new PluginSDKError("Poll cannot have more than 10 answers", "OPERATION_FAILED"); - } - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("createPoll", "create poll", async ({ client, Api }) => { + if (!answers || answers.length < 2) { + throw new PluginSDKError("Poll must have at least 2 answers", "OPERATION_FAILED"); + } + if (answers.length > 10) { + throw new PluginSDKError("Poll cannot have more than 10 answers", "OPERATION_FAILED"); + } const anonymous = opts?.isAnonymous ?? true; const multipleChoice = opts?.multipleChoice ?? false; @@ -337,13 +354,7 @@ export function createTelegramSocialSDK( } return null; - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to create poll: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async createQuiz( @@ -353,24 +364,19 @@ export function createTelegramSocialSDK( correctIndex: number, explanation?: string ): Promise { - requireUserMode("createQuiz"); - requireBridge(); - if (!answers || answers.length < 2) { - throw new PluginSDKError("Quiz must have at least 2 answers", "OPERATION_FAILED"); - } - if (answers.length > 10) { - throw new PluginSDKError("Quiz cannot have more than 10 answers", "OPERATION_FAILED"); - } - if (correctIndex < 0 || correctIndex >= answers.length) { - throw new PluginSDKError( - `correctIndex ${correctIndex} is out of bounds (0-${answers.length - 1})`, - "OPERATION_FAILED" - ); - } - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("createQuiz", "create quiz", async ({ client, Api }) => { + if (!answers || answers.length < 2) { + throw new PluginSDKError("Quiz must have at least 2 answers", "OPERATION_FAILED"); + } + if (answers.length > 10) { + throw new PluginSDKError("Quiz cannot have more than 10 answers", "OPERATION_FAILED"); + } + if (correctIndex < 0 || correctIndex >= answers.length) { + throw new PluginSDKError( + `correctIndex ${correctIndex} is out of bounds (0-${answers.length - 1})`, + "OPERATION_FAILED" + ); + } const poll = new Api.Poll({ id: randomLong(), question: new Api.TextWithEntities({ text: question, entities: [] }), @@ -413,110 +419,53 @@ export function createTelegramSocialSDK( } return null; - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to create quiz: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, // ─── Moderation ─────────────────────────────────────────── async banUser(chatId: string, userId: number | string): Promise { - requireUserMode("banUser"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - - await client.invoke( - new Api.channels.EditBanned({ - channel: chatId, - participant: userId.toString(), - bannedRights: new Api.ChatBannedRights({ - untilDate: 0, - viewMessages: true, - sendMessages: true, - sendMedia: true, - sendStickers: true, - sendGifs: true, - sendGames: true, - sendInline: true, - embedLinks: true, - }), + return userOp("banUser", "ban user", async (ctx) => { + await editBanned( + ctx, + chatId, + userId, + new ctx.Api.ChatBannedRights({ + untilDate: 0, + viewMessages: true, + sendMessages: true, + sendMedia: true, + sendStickers: true, + sendGifs: true, + sendGames: true, + sendInline: true, + embedLinks: true, }) ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to ban user: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async unbanUser(chatId: string, userId: number | string): Promise { - requireUserMode("unbanUser"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - - await client.invoke( - new Api.channels.EditBanned({ - channel: chatId, - participant: userId.toString(), - bannedRights: new Api.ChatBannedRights({ - untilDate: 0, - }), - }) - ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to unban user: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + return userOp("unbanUser", "unban user", async (ctx) => { + await editBanned(ctx, chatId, userId, new ctx.Api.ChatBannedRights({ untilDate: 0 })); + }); }, async muteUser(chatId: string, userId: number | string, untilDate: number): Promise { - requireUserMode("muteUser"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - - await client.invoke( - new Api.channels.EditBanned({ - channel: chatId, - participant: userId.toString(), - bannedRights: new Api.ChatBannedRights({ - untilDate, - sendMessages: true, - }), - }) - ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to mute user: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + return userOp("muteUser", "mute user", async (ctx) => { + await editBanned( + ctx, + chatId, + userId, + new ctx.Api.ChatBannedRights({ untilDate, sendMessages: true }) + ); + }); }, // ─── Stars & Gifts ──────────────────────────────────────── async getStarsBalance(): Promise { - requireUserMode("getStarsBalance"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("getStarsBalance", "get stars balance", async ({ client, Api }) => { const result = await client.invoke( new Api.payments.GetStarsStatus({ peer: new Api.InputPeerSelf(), @@ -524,13 +473,7 @@ export function createTelegramSocialSDK( ); return Number(result.balance?.amount?.toString() || "0"); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to get stars balance: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async sendGift( @@ -538,12 +481,7 @@ export function createTelegramSocialSDK( giftId: string, opts?: { message?: string; anonymous?: boolean } ): Promise { - requireUserMode("sendGift"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("sendGift", "send gift", async ({ client, Api }) => { const user = await client.getInputEntity(userId.toString()); const invoiceData = { @@ -567,22 +505,11 @@ export function createTelegramSocialSDK( invoice: new Api.InputInvoiceStarGift(invoiceData), }) ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to send gift: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async getAvailableGifts(): Promise { - requireUserMode("getAvailableGifts"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("getAvailableGifts", "get available gifts", async ({ client, Api }) => { const result = await client.invoke(new Api.payments.GetStarGifts({ hash: 0 })); if (result.className === "payments.StarGiftsNotModified") { @@ -601,22 +528,11 @@ export function createTelegramSocialSDK( ? Number(gift.availabilityTotal?.toString() || "0") : undefined, })); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to get available gifts: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async getMyGifts(limit?: number): Promise { - requireUserMode("getMyGifts"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("getMyGifts", "get my gifts", async ({ client, Api }) => { const result = await client.invoke( new Api.payments.GetSavedStarGifts({ peer: new Api.InputPeerSelf(), @@ -636,22 +552,11 @@ export function createTelegramSocialSDK( messageId: savedGift.msgId || undefined, }; }); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to get my gifts: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async getResaleGifts(giftId: string, limit?: number): Promise { - requireUserMode("getResaleGifts"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("getResaleGifts", "get resale gifts", async ({ client, Api }) => { const result = await client.invoke( new Api.payments.GetResaleStarGifts({ giftId: toLong(giftId), @@ -667,22 +572,11 @@ export function createTelegramSocialSDK( starsAmount: Number(listing.resellAmount?.[0]?.amount?.toString() || "0"), }; }); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to get resale gifts: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async buyResaleGift(giftId: string): Promise { - requireUserMode("buyResaleGift"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("buyResaleGift", "buy resale gift", async ({ client, Api }) => { const toId = new Api.InputPeerSelf(); const invoice = new Api.InputInvoiceStarGiftResale({ slug: giftId, @@ -697,13 +591,7 @@ export function createTelegramSocialSDK( invoice, }) ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to buy resale gift: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, // ─── Chat ─────────────────────────────────────────────────── @@ -791,12 +679,7 @@ export function createTelegramSocialSDK( }, async transferCollectible(msgId: number, toUserId: number | string): Promise { - requireUserMode("transferCollectible"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("transferCollectible", "transfer collectible", async ({ client, Api }) => { const toUser = await client.getInputEntity(toUserId.toString()); const stargiftInput = new Api.InputSavedStarGiftUser({ msgId }); @@ -829,22 +712,11 @@ export function createTelegramSocialSDK( starsSpent: transferCost, }; } - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to transfer collectible: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async setCollectiblePrice(msgId: number, price: number): Promise { - requireUserMode("setCollectiblePrice"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("setCollectiblePrice", "set collectible price", async ({ client, Api }) => { await client.invoke( new Api.payments.UpdateStarGiftPrice({ stargift: new Api.InputSavedStarGiftUser({ msgId }), @@ -854,13 +726,7 @@ export function createTelegramSocialSDK( }), }) ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to set collectible price: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, async getCollectibleInfo(slug: string): Promise { @@ -1022,12 +888,7 @@ export function createTelegramSocialSDK( price: number, opts?: GiftOfferOptions ): Promise { - requireUserMode("sendGiftOffer"); - requireBridge(); - try { - const client = getClient(); - const Api = await getApi(); - + return userOp("sendGiftOffer", "send gift offer", async ({ client, Api }) => { const peer = await client.getInputEntity(userId.toString()); const duration = opts?.duration ?? 86400; @@ -1040,23 +901,14 @@ export function createTelegramSocialSDK( randomId: randomLong(), }) ); - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to send gift offer: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, // ─── Stories ─────────────────────────────────────────────── async sendStory(mediaPath: string, opts?: { caption?: string }): Promise { - requireUserMode("sendStory"); - requireBridge(); - try { - const client = getClient(); - const { Api, helpers } = await import("telegram"); + return userOp("sendStory", "send story", async ({ client, Api }) => { + const { helpers } = await import("telegram"); const { CustomFile } = await import("telegram/client/uploads.js"); const { readFileSync, statSync } = await import("fs"); const { basename } = await import("path"); @@ -1134,13 +986,7 @@ export function createTelegramSocialSDK( ? result.updates.find((u) => u.className === "UpdateStory") : undefined; return storyUpdate?.story?.id ?? null; - } catch (error) { - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to send story: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + }); }, }; } diff --git a/src/sdk/telegram-utils.ts b/src/sdk/telegram-utils.ts index dee917be..20cc4f5f 100644 --- a/src/sdk/telegram-utils.ts +++ b/src/sdk/telegram-utils.ts @@ -1,7 +1,45 @@ import type { ITelegramBridge } from "../telegram/bridge-interface.js"; +import { isUserBridge } from "../telegram/bridge-guards.js"; import type { Api } from "telegram"; import type { SimpleMessage } from "@teleton-agent/sdk"; import { PluginSDKError } from "@teleton-agent/sdk"; +import { getErrorMessage } from "../utils/errors.js"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("Telegram"); + +/** + * Canonical friendly messages for known Telegram/GramJS error codes. Shared so a + * single code does not drift into several different wordings across executors. + */ +const TELEGRAM_ERROR_MESSAGES: Record = { + USERNAME_OCCUPIED: "Username is already taken. Please choose another.", + CHAT_ADMIN_REQUIRED: "You need admin rights to change this channel's username.", + CHANNELS_ADMIN_PUBLIC_TOO_MUCH: + "You admin too many public channels. Make some channels private first.", +}; + +/** + * Map a Telegram/GramJS error to a failed ToolResult using the shared friendly- + * message table. Pass `overrides` for codes that need tool-specific (often dynamic) + * text; unknown codes fall back to the raw error message. + * + * Only handles errorβ†’failure mapping. Executors that turn a code into a *success* + * (e.g. USERNAME_NOT_MODIFIED) must keep that branch before calling this. + */ +export function mapTelegramError( + error: unknown, + overrides?: Record +): { success: false; error: string } { + const msg = getErrorMessage(error); + const table = overrides ? { ...TELEGRAM_ERROR_MESSAGES, ...overrides } : TELEGRAM_ERROR_MESSAGES; + for (const code of Object.keys(table)) { + if (msg.includes(code)) { + return { success: false, error: table[code] }; + } + } + return { success: false, error: msg }; +} export function requireBridge(bridge: ITelegramBridge): void { if (!bridge.isAvailable()) { @@ -13,8 +51,56 @@ export function requireBridge(bridge: ITelegramBridge): void { } export function getClient(bridge: ITelegramBridge) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only escape hatch, cast to GramJS client - return (bridge.getRawClient() as any).getClient(); + if (!isUserBridge(bridge)) { + throw new Error( + "This tool requires user mode β€” it relies on an MTProto capability the Bot API does not provide." + ); + } + return bridge.getClient().getClient(); +} + +/** Canonical public-username rule for channels/groups (5-32 chars, no leading/trailing underscore). */ +const CHANNEL_USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_]{3,30}[a-zA-Z0-9]$/; + +/** Strip a leading "@" from a username. */ +export function cleanUsername(input: string): string { + return input.replace(/^@/, ""); +} + +/** + * Validate a channel/group public username against the canonical Telegram rule. + * Pass `allowEmpty` when an empty value is meaningful (e.g. removing a username). + */ +export function validateChannelUsername( + input: string, + options: { allowEmpty?: boolean } = {} +): { ok: true; clean: string } | { ok: false; error: string } { + const clean = cleanUsername(input); + const invalid = clean.length === 0 ? !options.allowEmpty : !CHANNEL_USERNAME_REGEX.test(clean); + if (invalid) { + return { + ok: false, + error: + "Invalid username format. Must be 5-32 characters, alphanumeric and underscores only, cannot start/end with underscore.", + }; + } + return { ok: true, clean }; +} + +/** + * Resolve a channel/group entity and narrow it to Api.Channel. Throws a readable + * Error (caught by the executor's existing catch) when the entity isn't a channel. + */ +export async function resolveChannel( + bridge: ITelegramBridge, + channelId: string +): Promise { + const client = getClient(bridge); + const entity = await client.getEntity(channelId); + if (entity.className !== "Channel") { + throw new Error(`Entity is not a channel/group (got ${entity.className})`); + } + return entity as Api.Channel; } /** Convert a GramJS message to a SimpleMessage */ @@ -42,3 +128,60 @@ export async function getApi(): Promise { } return _Api; } + +export interface TranscribeResult { + transcriptionId?: string; + text: string | null; + pending: boolean; + trialRemainsNum?: number; + trialRemainsUntilDate?: number; +} + +const TRANSCRIBE_POLL_INTERVAL_MS = 1500; +const TRANSCRIBE_MAX_POLL_RETRIES = 15; + +/** + * Server-side transcription of a voice/audio message, polling until it completes. + * Core shared by the telegram_transcribe_audio tool and the auto-transcribe path + * in the message handler (so the bridge layer no longer imports a tool executor). + * Throws on Telegram errors (PREMIUM_ACCOUNT_REQUIRED, MSG_ID_INVALID, …). + */ +export async function transcribeAudio( + bridge: ITelegramBridge, + chatId: string, + messageId: number +): Promise { + const ApiNs = await getApi(); + const client = getClient(bridge); + const entity = await client.getEntity(chatId); + + let result = await client.invoke( + new ApiNs.messages.TranscribeAudio({ peer: entity, msgId: messageId }) + ); + + let retries = 0; + while (result.pending && retries < TRANSCRIBE_MAX_POLL_RETRIES) { + retries++; + log.debug(`⏳ Transcription pending, polling (${retries}/${TRANSCRIBE_MAX_POLL_RETRIES})...`); + await new Promise((resolve) => setTimeout(resolve, TRANSCRIBE_POLL_INTERVAL_MS)); + try { + result = await client.invoke( + new ApiNs.messages.TranscribeAudio({ peer: entity, msgId: messageId }) + ); + } catch (pollError: unknown) { + // On transient errors (FLOOD_WAIT, network), keep polling + log.warn(`Transcription poll ${retries} failed: ${getErrorMessage(pollError)}`); + continue; + } + } + + return { + transcriptionId: result.transcriptionId?.toString(), + text: result.text ?? null, + pending: result.pending ?? false, + ...(result.trialRemainsNum !== undefined && { + trialRemainsNum: result.trialRemainsNum, + trialRemainsUntilDate: result.trialRemainsUntilDate, + }), + }; +} diff --git a/src/sdk/telegram.ts b/src/sdk/telegram.ts index fc49cd39..2ca7cfff 100644 --- a/src/sdk/telegram.ts +++ b/src/sdk/telegram.ts @@ -1,5 +1,5 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { ITelegramBridge } from "../telegram/bridge-interface.js"; +import { isUserBridge } from "../telegram/bridge-guards.js"; import type { TelegramSDK, TelegramUser, SimpleMessage, PluginLogger } from "@teleton-agent/sdk"; import { PluginSDKError } from "@teleton-agent/sdk"; import { getErrorMessage } from "../utils/errors.js"; @@ -97,6 +97,7 @@ export function createTelegramSDK( }, async getMessages(chatId, limit): Promise { + requireUserMode("getMessages"); requireBridge(); try { const messages = await bridge.getMessages(chatId, limit ?? 50); @@ -116,7 +117,8 @@ export function createTelegramSDK( getMe(): TelegramUser | null { requireUserMode("getMe"); try { - const me = (bridge.getRawClient() as any)?.getMe?.(); + if (!isUserBridge(bridge)) return null; + const me = bridge.getClient().getMe(); if (!me) return null; return { id: Number(me.id), @@ -138,7 +140,7 @@ export function createTelegramSDK( log.warn("getRawClient() called β€” this bypasses SDK sandbox guarantees"); if (!bridge.isAvailable()) return null; try { - return bridge.getRawClient(); + return isUserBridge(bridge) ? bridge.getClient() : null; } catch { return null; } diff --git a/src/sdk/ton-dex.ts b/src/sdk/ton-dex.ts index 03a2e5fe..b1685431 100644 --- a/src/sdk/ton-dex.ts +++ b/src/sdk/ton-dex.ts @@ -9,15 +9,18 @@ import type { } from "@teleton-agent/sdk"; import { PluginSDKError } from "@teleton-agent/sdk"; import { WalletContractV5R1, toNano, internal } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; +import { Address } from "@ton/core"; import { getCachedTonClient, loadWallet, getKeyPair } from "../ton/wallet-service.js"; +import { sendWalletTx, walletTxLt, confirmWalletTx } from "../ton/confirm.js"; import { StonApiClient } from "@ston-fi/api"; import { dexFactory } from "@ston-fi/sdk"; import { Factory, Asset, PoolType, ReadinessStatus, JettonRoot, VaultJetton } from "@dedust/sdk"; import type { Pool } from "@dedust/sdk"; -import { DEDUST_FACTORY_MAINNET, DEDUST_GAS } from "../agent/tools/dedust/constants.js"; -import { getDecimals, toUnits, fromUnits } from "../agent/tools/dedust/asset-cache.js"; +import { DEDUST_FACTORY_MAINNET, DEDUST_GAS } from "../ton/dex-constants.js"; +import { getDecimals } from "../ton/dedust-assets.js"; +import { toUnits, fromUnits } from "../ton/units.js"; import { withTxLock } from "../ton/tx-lock.js"; +import { STONFI_PTON_ADDRESS } from "../ton/dex-constants.js"; import type { OpenedContract } from "@ton/ton"; @@ -43,7 +46,7 @@ async function findDedustPool( } } -const STONFI_NATIVE_TON = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; +const STONFI_NATIVE_TON = STONFI_PTON_ADDRESS; const stonApiClient = new StonApiClient(); @@ -152,6 +155,34 @@ async function getDedustQuote( } } +type SwapWallet = ReturnType; + +/** + * Thin wallet-provisioning wrapper shared by the STON.fi and DeDust swap paths: + * runs `fn` under the tx lock with a freshly derived key and opened V5R1 contract. + * The divergent txParams cores stay in each caller's `fn`. + */ +async function withSwapWallet( + tonClient: Awaited>, + fn: (ctx: { + keyPair: NonNullable>>; + wallet: SwapWallet; + walletContract: OpenedContract; + }) => Promise +): Promise { + return withTxLock(async () => { + const keyPair = await getKeyPair(); + if (!keyPair) { + throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); + } + + const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }); + const walletContract = tonClient.open(wallet); + + return fn({ keyPair, wallet, walletContract }); + }); +} + async function executeSTONfiSwap( params: DexSwapParams, _log: PluginLogger @@ -188,15 +219,7 @@ async function executeSTONfiSwap( const contracts = dexFactory(routerInfo); const router = tonClient.open(contracts.Router.create(routerInfo.address)); - return withTxLock(async () => { - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } - - const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }); - const walletContract = tonClient.open(wallet); - const seqno = await walletContract.getSeqno(); + return withSwapWallet(tonClient, async ({ keyPair, walletContract }) => { const proxyTon = contracts.pTON.create(routerInfo.ptonMasterAddress); let txParams; @@ -227,10 +250,8 @@ async function executeSTONfiSwap( }); } - await walletContract.sendTransfer({ - seqno, + const sent = await sendWalletTx(tonClient, walletContract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [ internal({ to: txParams.to, @@ -241,6 +262,13 @@ async function executeSTONfiSwap( ], }); + if (!sent) { + throw new PluginSDKError( + "Swap transaction failed or could not be confirmed on-chain", + "OPERATION_FAILED" + ); + } + const toDecimals = await getDecimals(isTonOutput ? "ton" : toAsset); const expectedOutput = fromUnits(BigInt(simulationResult.askUnits), toDecimals); const minOutput = fromUnits(BigInt(simulationResult.minAskUnits), toDecimals); @@ -253,6 +281,7 @@ async function executeSTONfiSwap( expectedOutput: expectedOutput.toFixed(6), minOutput: minOutput.toFixed(6), slippage: `${(slippage * 100).toFixed(2)}%`, + txRef: sent.hash, }; }); } @@ -293,42 +322,50 @@ async function executeDedustSwap( }); const minAmountOut = amountOut - (amountOut * BigInt(Math.floor(slippage * 10000))) / 10000n; - return withTxLock(async () => { - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } - - const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }); - const walletContract = tonClient.open(wallet); + return withSwapWallet(tonClient, async ({ keyPair, walletContract }) => { const sender = walletContract.sender(keyPair.secretKey); + const sinceLt = await walletTxLt(tonClient, walletContract.address); + let broadcastError: unknown; + + try { + if (isTonInput) { + const tonVault = tonClient.open(await factory.getNativeVault()); + await tonVault.sendSwap(sender, { + poolAddress: pool.address, + amount: amountIn, + limit: minAmountOut, + gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON), + }); + } else { + const jettonAddress = Address.parse(fromAsset); + const jettonVault = tonClient.open(await factory.getJettonVault(jettonAddress)); + const jettonRoot = tonClient.open(JettonRoot.createFromAddress(jettonAddress)); + const jettonWallet = tonClient.open( + await jettonRoot.getWallet(Address.parse(walletData.address)) + ); + const swapPayload = VaultJetton.createSwapPayload({ + poolAddress: pool.address, + limit: minAmountOut, + }); + await jettonWallet.sendTransfer(sender, toNano(DEDUST_GAS.SWAP_JETTON_TO_ANY), { + destination: jettonVault.address, + amount: amountIn, + responseAddress: Address.parse(walletData.address), + forwardAmount: toNano(DEDUST_GAS.FORWARD_GAS), + forwardPayload: swapPayload, + }); + } + } catch (error) { + broadcastError = error; + } - if (isTonInput) { - const tonVault = tonClient.open(await factory.getNativeVault()); - await tonVault.sendSwap(sender, { - poolAddress: pool.address, - amount: amountIn, - limit: minAmountOut, - gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON), - }); - } else { - const jettonAddress = Address.parse(fromAsset); - const jettonVault = tonClient.open(await factory.getJettonVault(jettonAddress)); - const jettonRoot = tonClient.open(JettonRoot.createFromAddress(jettonAddress)); - const jettonWallet = tonClient.open( - await jettonRoot.getWallet(Address.parse(walletData.address)) + const confirmed = await confirmWalletTx(tonClient, walletContract.address, sinceLt); + if (!confirmed) { + if (broadcastError) throw broadcastError; + throw new PluginSDKError( + "Swap transaction failed or could not be confirmed on-chain", + "OPERATION_FAILED" ); - const swapPayload = VaultJetton.createSwapPayload({ - poolAddress: pool.address, - limit: minAmountOut, - }); - await jettonWallet.sendTransfer(sender, toNano(DEDUST_GAS.SWAP_JETTON_TO_ANY), { - destination: jettonVault.address, - amount: amountIn, - responseAddress: Address.parse(walletData.address), - forwardAmount: toNano(DEDUST_GAS.FORWARD_GAS), - forwardPayload: swapPayload, - }); } const expectedOutput = fromUnits(amountOut, toDecimals); @@ -342,6 +379,7 @@ async function executeDedustSwap( expectedOutput: expectedOutput.toFixed(6), minOutput: minOutput.toFixed(6), slippage: `${(slippage * 100).toFixed(2)}%`, + txRef: confirmed.hash, }; }); } diff --git a/src/sdk/ton-dns.ts b/src/sdk/ton-dns.ts index 73868e03..6f116756 100644 --- a/src/sdk/ton-dns.ts +++ b/src/sdk/ton-dns.ts @@ -11,8 +11,9 @@ import { PluginSDKError } from "@teleton-agent/sdk"; import { tonapiFetch } from "../constants/api-endpoints.js"; import { loadWallet, getKeyPair, getCachedTonClient } from "../ton/wallet-service.js"; import { WalletContractV5R1, toNano, internal } from "@ton/ton"; -import { Address, beginCell, SendMode } from "@ton/core"; +import { Address, beginCell } from "@ton/core"; import { withTxLock } from "../ton/tx-lock.js"; +import { sendWalletTx, type SentTx } from "../ton/confirm.js"; import { createHash } from "crypto"; import { getErrorMessage } from "../utils/errors.js"; @@ -46,25 +47,29 @@ async function sendWalletMessage( value: bigint, body?: ReturnType, bounce = true -): Promise { - await withTxLock(async () => { - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } - - const tonClient = await getCachedTonClient(); +): Promise { + const keyPair = await getKeyPair(); + if (!keyPair) { + throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); + } + + const tonClient = await getCachedTonClient(); + const sent = await withTxLock(async () => { const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }); const walletContract = tonClient.open(wallet); - const seqno = await walletContract.getSeqno(); - - await walletContract.sendTransfer({ - seqno, + return sendWalletTx(tonClient, walletContract, { secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, messages: [internal({ to, value, body, bounce })], }); }); + + if (!sent) { + throw new PluginSDKError( + "Transaction failed or could not be confirmed on-chain", + "OPERATION_FAILED" + ); + } + return sent; } function normalizeDomain(domain: string): string { diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts index df8f94f3..afa9f34f 100644 --- a/src/sdk/ton.ts +++ b/src/sdk/ton.ts @@ -34,6 +34,7 @@ import { invalidateTonClientCache, } from "../ton/wallet-service.js"; import { sendTon } from "../ton/transfer.js"; +import { sendWalletTx } from "../ton/confirm.js"; import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js"; import { withBlockchainRetry } from "../utils/retry.js"; import { tonapiFetch, GECKOTERMINAL_API_URL } from "../constants/api-endpoints.js"; @@ -46,6 +47,7 @@ import { WalletContractV5R1, internal, } from "@ton/ton"; +import type { OpenedContract } from "@ton/ton"; import { Address as TonAddress, beginCell, SendMode, storeMessage } from "@ton/core"; import type { TupleItem, Cell as TonCell } from "@ton/core"; import { withTxLock } from "../ton/tx-lock.js"; @@ -92,6 +94,142 @@ function findJettonBalance( }); } +/** Normalized jetton metadata from the TonAPI `/jettons/{addr}` endpoint. */ +interface JettonMeta { + address: string; + decimals: number; + symbol: string; + name: string; + totalSupply: string; + holdersCount: number; + verified: boolean; + description?: string; + image?: string; +} + +/** + * Fetch jetton metadata from TonAPI `/jettons/{addr}` with a SINGLE decimals parse. + * Returns the HTTP `ok`/`status` so each caller keeps its own graceful error policy; + * `meta` is only populated when the response was ok. + */ +async function fetchJettonMeta( + jettonAddress: string +): Promise<{ ok: boolean; status: number; meta: JettonMeta | null }> { + const response = await tonapiFetch(`/jettons/${encodeURIComponent(jettonAddress)}`); + if (!response.ok) { + return { ok: false, status: response.status, meta: null }; + } + + const data = await response.json(); + const metadata = data.metadata || {}; + return { + ok: true, + status: response.status, + meta: { + address: metadata.address || jettonAddress, + decimals: parseInt(metadata.decimals || "9"), + symbol: metadata.symbol || "UNKNOWN", + name: metadata.name || "Unknown", + totalSupply: data.total_supply || "0", + holdersCount: data.holders_count || 0, + verified: data.verification === "whitelist", + description: metadata.description || undefined, + image: data.preview || metadata.image || undefined, + }, + }; +} + +/** Seconds a signed transfer remains valid (`validUntil` window). */ +const TX_VALID_UNTIL_SECONDS = 120; + +type WalletKeyPair = NonNullable>>; +type WalletV5R1 = ReturnType; + +/** + * Build the V5R1 wallet, opened contract and current seqno for `keyPair`. + * Single source for the create β†’ getCachedTonClient β†’ open β†’ getSeqno sequence. + */ +async function buildWalletContext(keyPair: WalletKeyPair): Promise<{ + wallet: WalletV5R1; + contract: OpenedContract; + seqno: number; +}> { + const wallet = WalletContractV5R1.create({ + workchain: 0, + publicKey: keyPair.publicKey, + }); + const client = await getCachedTonClient(); + const contract = client.open(wallet); + const seqno = await contract.getSeqno(); + return { wallet, contract, seqno }; +} + +/** Wrap a signed transfer cell into a broadcastable external-in BOC (base64). */ +function wrapExternalMessage(wallet: WalletV5R1, transferCell: TonCell, seqno: number): string { + const extMsg = beginCell() + .store( + storeMessage({ + info: { + type: "external-in" as const, + dest: wallet.address, + importFee: 0n, + }, + init: seqno === 0 ? wallet.init : undefined, + body: transferCell, + }) + ) + .endCell(); + + return extMsg.toBoc().toString("base64"); +} + +/** + * Internal core shared by `send` (1 message) and `sendMessages` (N messages): + * derive key, build wallet context under the tx lock, broadcast and map errors. + */ +async function signAndSend( + messages: TonMessage[], + opts: { sendMode?: number; errorPrefix: string } +): Promise { + const keyPair = await getKeyPair(); + if (!keyPair) { + throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); + } + + try { + const client = await getCachedTonClient(); + const sent = await withTxLock(async () => { + const { contract } = await buildWalletContext(keyPair); + return sendWalletTx(client, contract, { + secretKey: keyPair.secretKey, + sendMode: opts.sendMode ?? SendMode.PAY_GAS_SEPARATELY, + messages: messages.map((m) => + internal({ + to: TonAddress.parse(m.to), + value: tonToNano(m.value.toString()), + body: m.body, + bounce: m.bounce ?? true, + init: m.stateInit, + }) + ), + }); + }); + + if (!sent) { + throw new PluginSDKError(`${opts.errorPrefix}: not confirmed on-chain`, "OPERATION_FAILED"); + } + return { hash: sent.hash, seqno: sent.seqno }; + } catch (error) { + const httpErr = isHttpError(error) ? error : undefined; + const status = httpErr?.status || httpErr?.response?.status; + if (status === 429 || (status !== undefined && status >= 500)) { + invalidateTonClientCache(); + } + if (error instanceof PluginSDKError) throw error; + throw new PluginSDKError(`${opts.errorPrefix}: ${getErrorMessage(error)}`, "OPERATION_FAILED"); + } +} + function cleanupOldTransactions( db: Database.Database, retentionDays: number, @@ -160,21 +298,21 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } try { - const txRef = await sendTon({ + const result = await sendTon({ toAddress: to, amount, comment, bounce: false, }); - if (!txRef) { + if (!result) { throw new PluginSDKError( - "Transaction failed β€” no reference returned", + "Transaction failed or could not be confirmed on-chain", "OPERATION_FAILED" ); } - return { txRef, amount }; + return { txRef: result.hash, amount }; } catch (error) { if (error instanceof PluginSDKError) throw error; throw new PluginSDKError( @@ -335,27 +473,23 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T async getJettonInfo(jettonAddress: string): Promise { try { - const response = await tonapiFetch(`/jettons/${encodeURIComponent(jettonAddress)}`); - if (response.status === 404) return null; - if (!response.ok) { - log.error(`ton.getJettonInfo() TonAPI error: ${response.status}`); + const { ok, status, meta } = await fetchJettonMeta(jettonAddress); + if (status === 404) return null; + if (!ok || !meta) { + log.error(`ton.getJettonInfo() TonAPI error: ${status}`); return null; } - const data = await response.json(); - const metadata = data.metadata || {}; - const decimals = parseInt(metadata.decimals || "9"); - return { - address: metadata.address || jettonAddress, - name: metadata.name || "Unknown", - symbol: metadata.symbol || "UNKNOWN", - decimals, - totalSupply: data.total_supply || "0", - holdersCount: data.holders_count || 0, - verified: data.verification === "whitelist", - description: metadata.description || undefined, - image: data.preview || metadata.image || undefined, + address: meta.address, + name: meta.name, + symbol: meta.symbol, + decimals: meta.decimals, + totalSupply: meta.totalSupply, + holdersCount: meta.holdersCount, + verified: meta.verified, + description: meta.description, + image: meta.image, }; } catch (error) { log.error("ton.getJettonInfo() failed:", error); @@ -448,58 +582,30 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); } - const seqno = await withTxLock(async () => { - const MAX_SEND_ATTEMPTS = 3; - let lastErr: unknown; - - for (let attempt = 1; attempt <= MAX_SEND_ATTEMPTS; attempt++) { - try { - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - - const client = await getCachedTonClient(); - const walletContract = client.open(wallet); - const seq = await walletContract.getSeqno(); - - await walletContract.sendTransfer({ - seqno: seq, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: TonAddress.parse(senderJettonWallet), - value: tonToNano("0.05"), - body: messageBody, - bounce: true, - }), - ], - }); - - return seq; - } catch (innerError) { - lastErr = innerError; - const httpErr = isHttpError(innerError) ? innerError : undefined; - const status = httpErr?.status || httpErr?.response?.status; - const respData = httpErr?.response?.data; - if (status === 429 || (status && status >= 500)) { - invalidateTonClientCache(); - if (attempt < MAX_SEND_ATTEMPTS) { - log.warn( - `sendJetton attempt ${attempt} failed (${status}): ${JSON.stringify(respData ?? (innerError as Error).message)}, retrying...` - ); - await new Promise((r) => setTimeout(r, 1000 * attempt)); - continue; - } - } - throw innerError; - } - } - throw lastErr; + const client = await getCachedTonClient(); + const sent = await withTxLock(async () => { + const { contract: walletContract } = await buildWalletContext(keyPair); + return sendWalletTx(client, walletContract, { + secretKey: keyPair.secretKey, + messages: [ + internal({ + to: TonAddress.parse(senderJettonWallet), + value: tonToNano("0.05"), + body: messageBody, + bounce: true, + }), + ], + }); }); - return { success: true, seqno }; + if (!sent) { + throw new PluginSDKError( + "Jetton transfer failed or could not be confirmed on-chain", + "OPERATION_FAILED" + ); + } + + return { success: true, seqno: sent.seqno, txRef: sent.hash }; } catch (error) { // Invalidate node cache on 429/5xx so next attempt picks a fresh node const outerHttpErr = isHttpError(error) ? error : undefined; @@ -561,15 +667,9 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } const txResult = await withTxLock(async () => { - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const client = await getCachedTonClient(); - const contract = client.open(wallet); - const seqno = await contract.getSeqno(); + const { wallet, seqno } = await buildWalletContext(keyPair); - const validUntil = Math.floor(Date.now() / 1000) + 120; + const validUntil = Math.floor(Date.now() / 1000) + TX_VALID_UNTIL_SECONDS; const transferCell = wallet.createTransfer({ seqno, @@ -586,22 +686,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T ], }); - // Wrap in external message (broadcastable BOC) - const extMsg = beginCell() - .store( - storeMessage({ - info: { - type: "external-in" as const, - dest: wallet.address, - importFee: 0n, - }, - init: seqno === 0 ? wallet.init : undefined, - body: transferCell, - }) - ) - .endCell(); - - const boc = extMsg.toBoc().toString("base64"); + const boc = wrapExternalMessage(wallet, transferCell, seqno); return { boc, seqno, validUntil, walletAddress: wallet.address.toRawString() }; }); @@ -712,15 +797,9 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } const txResult = await withTxLock(async () => { - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const client = await getCachedTonClient(); - const walletContract = client.open(wallet); - const seqno = await walletContract.getSeqno(); + const { wallet, seqno } = await buildWalletContext(keyPair); - const validUntil = Math.floor(Date.now() / 1000) + 120; + const validUntil = Math.floor(Date.now() / 1000) + TX_VALID_UNTIL_SECONDS; const transferCell = wallet.createTransfer({ seqno, @@ -737,22 +816,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T ], }); - // Wrap in external message (broadcastable BOC) - const extMsg = beginCell() - .store( - storeMessage({ - info: { - type: "external-in" as const, - dest: wallet.address, - importFee: 0n, - }, - init: seqno === 0 ? wallet.init : undefined, - body: transferCell, - }) - ) - .endCell(); - - const boc = extMsg.toBoc().toString("base64"); + const boc = wrapExternalMessage(wallet, transferCell, seqno); return { boc, seqno, validUntil, walletAddress: wallet.address.toRawString() }; }); @@ -895,11 +959,11 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T const effectiveLimit = Math.min(limit ?? 10, 100); // Parallel fetch: holders + decimals info - const [holdersResponse, infoResponse] = await Promise.all([ + const [holdersResponse, info] = await Promise.all([ tonapiFetch( `/jettons/${encodeURIComponent(jettonAddress)}/holders?limit=${effectiveLimit}` ), - tonapiFetch(`/jettons/${encodeURIComponent(jettonAddress)}`), + fetchJettonMeta(jettonAddress), ]); if (!holdersResponse.ok) { @@ -910,11 +974,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T const data = await holdersResponse.json(); const addresses = data.addresses || []; - let decimals = 9; - if (infoResponse.ok) { - const infoData = await infoResponse.json(); - decimals = parseInt(infoData.metadata?.decimals || "9"); - } + const decimals = info.meta?.decimals ?? 9; return addresses.map((h: TonApiJettonHolder, index: number) => { return { @@ -1025,13 +1085,8 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); } - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const client = await getCachedTonClient(); - const contract = client.open(wallet); - return await contract.getSeqno(); + const { seqno } = await buildWalletContext(keyPair); + return seqno; } catch (error) { const httpErr = isHttpError(error) ? error : undefined; const status = httpErr?.status || httpErr?.response?.status; @@ -1115,49 +1170,10 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T throw new PluginSDKError("Unsafe sendMode", "OPERATION_FAILED"); } - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } - - try { - const seqno = await withTxLock(async () => { - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const client = await getCachedTonClient(); - const contract = client.open(wallet); - const seq = await contract.getSeqno(); - - await contract.sendTransfer({ - seqno: seq, - secretKey: keyPair.secretKey, - sendMode: opts?.sendMode ?? SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: TonAddress.parse(to), - value: tonToNano(value.toString()), - body: opts?.body, - bounce: opts?.bounce ?? true, - init: opts?.stateInit, - }), - ], - }); - - return seq; - }); - - return { hash: `${seqno}_${Date.now()}_send`, seqno }; - } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError(`Failed to send: ${getErrorMessage(error)}`, "OPERATION_FAILED"); - } + return signAndSend( + [{ to, value, body: opts?.body, bounce: opts?.bounce, stateInit: opts?.stateInit }], + { sendMode: opts?.sendMode, errorPrefix: "Failed to send" } + ); }, async sendMessages(messages: TonMessage[], opts?: TonSendOptions): Promise { @@ -1190,52 +1206,10 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T } } - const keyPair = await getKeyPair(); - if (!keyPair) { - throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED"); - } - - try { - const seqno = await withTxLock(async () => { - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); - const client = await getCachedTonClient(); - const contract = client.open(wallet); - const seq = await contract.getSeqno(); - - await contract.sendTransfer({ - seqno: seq, - secretKey: keyPair.secretKey, - sendMode: opts?.sendMode ?? SendMode.PAY_GAS_SEPARATELY, - messages: messages.map((m) => - internal({ - to: TonAddress.parse(m.to), - value: tonToNano(m.value.toString()), - body: m.body, - bounce: m.bounce ?? true, - init: m.stateInit, - }) - ), - }); - - return seq; - }); - - return { hash: `${seqno}_${Date.now()}_sendMessages`, seqno }; - } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } - if (error instanceof PluginSDKError) throw error; - throw new PluginSDKError( - `Failed to send messages: ${getErrorMessage(error)}`, - "OPERATION_FAILED" - ); - } + return signAndSend(messages, { + sendMode: opts?.sendMode, + errorPrefix: "Failed to send messages", + }); }, async createSender(): Promise { @@ -1260,34 +1234,28 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T if (args.sendMode !== undefined && (args.sendMode < 0 || args.sendMode > 3)) { throw new PluginSDKError("Unsafe sendMode", "OPERATION_FAILED"); } - try { - await withTxLock(async () => { - const client = await getCachedTonClient(); - const contract = client.open(wallet); - const seqno = await contract.getSeqno(); - - await contract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: args.sendMode ?? SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: args.to, - value: args.value, - body: args.body, - bounce: args.bounce ?? true, - init: args.init ?? undefined, - }), - ], - }); + const client = await getCachedTonClient(); + const sent = await withTxLock(async () => { + const contract = client.open(wallet); + return sendWalletTx(client, contract, { + secretKey: keyPair.secretKey, + sendMode: args.sendMode, + messages: [ + internal({ + to: args.to, + value: args.value, + body: args.body, + bounce: args.bounce ?? true, + init: args.init ?? undefined, + }), + ], }); - } catch (error) { - const httpErr = isHttpError(error) ? error : undefined; - const status = httpErr?.status || httpErr?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } - throw error; + }); + if (!sent) { + throw new PluginSDKError( + "Transaction failed or could not be confirmed on-chain", + "OPERATION_FAILED" + ); } }, }; diff --git a/src/services/tts.ts b/src/services/tts.ts index 6dafb171..8ea81e75 100644 --- a/src/services/tts.ts +++ b/src/services/tts.ts @@ -8,7 +8,7 @@ * - elevenlabs: ElevenLabs API */ -import { spawn } from "child_process"; +import { spawn, type ChildProcess, type SpawnOptions } from "child_process"; import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -128,16 +128,67 @@ export async function generateSpeech(options: TTSOptions): Promise { } } +/** Ensure the shared TTS temp directory exists and return its path. */ +function ensureTtsTempDir(): string { + const tempDir = join(tmpdir(), "teleton-tts"); + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); + } + return tempDir; +} + +/** + * Spawn a process and resolve when it exits successfully, else reject with a + * labelled error carrying stderr. Shared by the void-resolving TTS subprocesses. + */ +function spawnToPromise( + command: string, + args: string[], + options: SpawnOptions, + opts: { + label: string; + onSpawn?: (proc: ChildProcess) => void; + successCheck?: (code: number | null) => boolean; + spawnErrorMessage?: (err: Error) => string; + } +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args, options); + opts.onSpawn?.(proc); + + let stderr = ""; + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + const ok = opts.successCheck ? opts.successCheck(code) : code === 0; + if (ok) { + resolve(); + } else { + reject(new Error(`${opts.label} failed (code ${code}): ${stderr}`)); + } + }); + + proc.on("error", (err) => { + reject( + new Error( + opts.spawnErrorMessage + ? opts.spawnErrorMessage(err) + : `${opts.label} spawn error: ${err.message}` + ) + ); + }); + }); +} + /** * Generate TTS using Piper (offline neural TTS) * Uses custom voices from ~/.teleton/piper-voices/ * Converts WAV to OGG/Opus for Telegram voice messages */ async function generatePiperTTS(text: string, voice: string): Promise { - const tempDir = join(tmpdir(), "teleton-tts"); - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }); - } + const tempDir = ensureTtsTempDir(); const id = randomUUID(); const wavPath = join(tempDir, `${id}.wav`); @@ -156,77 +207,38 @@ async function generatePiperTTS(text: string, voice: string): Promise // Run piper from the Python venv const piperBin = join(PIPER_VENV, "bin", "piper"); - await new Promise((resolve, reject) => { - const proc = spawn( - piperBin, - [ - "--model", - modelPath, - "--output_file", - wavPath, - "--sentence_silence", - "0.5", // 500ms pause between sentences - ], - { - stdio: ["pipe", "pipe", "pipe"], - } - ); - - // Send text via stdin - proc.stdin?.write(text); - proc.stdin?.end(); - - let stderr = ""; - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - if (code === 0 && existsSync(wavPath)) { - resolve(); - } else { - reject(new Error(`Piper TTS failed (code ${code}): ${stderr}`)); - } - }); - - proc.on("error", (err) => { - reject(new Error(`Piper spawn error: ${err.message}. Is Piper installed in ${PIPER_VENV}?`)); - }); - }); + await spawnToPromise( + piperBin, + [ + "--model", + modelPath, + "--output_file", + wavPath, + "--sentence_silence", + "0.5", // 500ms pause between sentences + ], + { stdio: ["pipe", "pipe", "pipe"] }, + { + label: "Piper TTS", + onSpawn: (proc) => { + // Send text via stdin + proc.stdin?.write(text); + proc.stdin?.end(); + }, + successCheck: (code) => code === 0 && existsSync(wavPath), + spawnErrorMessage: (err) => + `Piper spawn error: ${err.message}. Is Piper installed in ${PIPER_VENV}?`, + } + ); // Convert WAV to OGG/Opus for Telegram voice messages try { - await new Promise((resolve, reject) => { - const proc = spawn("ffmpeg", [ - "-y", - "-i", - wavPath, - "-c:a", - "libopus", - "-b:a", - "48k", - "-application", - "voip", - oggPath, - ]); - - let stderr = ""; - proc.stderr?.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`ffmpeg failed (code ${code}): ${stderr}`)); - } - }); - - proc.on("error", (err) => { - reject(new Error(`ffmpeg spawn error: ${err.message}`)); - }); - }); + await spawnToPromise( + "ffmpeg", + ["-y", "-i", wavPath, "-c:a", "libopus", "-b:a", "48k", "-application", "voip", oggPath], + {}, + { label: "ffmpeg" } + ); // Cleanup WAV unlinkSync(wavPath); @@ -255,10 +267,7 @@ async function generateEdgeTTS( rate?: string, pitch?: string ): Promise { - const tempDir = join(tmpdir(), "teleton-tts"); - if (!existsSync(tempDir)) { - mkdirSync(tempDir, { recursive: true }); - } + const tempDir = ensureTtsTempDir(); const outputPath = join(tempDir, `${randomUUID()}.mp3`); @@ -309,10 +318,7 @@ async function generateOpenAITTS(text: string, voice: string): Promise; +/** + * camelCase field β†’ snake_case column for the fields updateSession can write. + * `updated_at` is always set separately; `chatId`/`createdAt` are immutable. + * Single source for the dynamic UPDATE so the SET clause can't drift from the + * field list. rowToSession keeps its own (non-mechanical) coercions. + */ +const SESSION_UPDATE_COLUMNS: Record< + keyof Omit, + string +> = { + sessionId: "id", + messageCount: "message_count", + lastMessageId: "last_message_id", + lastChannel: "last_channel", + lastTo: "last_to", + contextTokens: "context_tokens", + model: "model", + provider: "provider", + lastResetDate: "last_reset_date", + inputTokens: "input_tokens", + outputTokens: "output_tokens", +}; + interface SessionRow { id: string; chat_id: string; @@ -123,7 +151,7 @@ export function saveSessionStore(store: SessionStore): void { } export function getOrCreateSession(chatId: string): SessionEntry { const db = getDb(); - const sessionKey = `telegram:${chatId}`; + const sessionKey = sessionKeyFor(chatId); const row = db.prepare("SELECT * FROM sessions WHERE chat_id = ?").get(sessionKey) as | SessionRow @@ -169,7 +197,7 @@ export function updateSession( update: Partial> ): SessionEntry { const db = getDb(); - const sessionKey = `telegram:${chatId}`; + const sessionKey = sessionKeyFor(chatId); const existing = db.prepare("SELECT * FROM sessions WHERE chat_id = ?").get(sessionKey) as | SessionRow @@ -182,49 +210,15 @@ export function updateSession( const updates: string[] = []; const values: unknown[] = []; - if (update.sessionId !== undefined) { - updates.push("id = ?"); - values.push(update.sessionId); - } - if (update.messageCount !== undefined) { - updates.push("message_count = ?"); - values.push(update.messageCount); - } - if (update.lastMessageId !== undefined) { - updates.push("last_message_id = ?"); - values.push(update.lastMessageId); - } - if (update.lastChannel !== undefined) { - updates.push("last_channel = ?"); - values.push(update.lastChannel); - } - if (update.lastTo !== undefined) { - updates.push("last_to = ?"); - values.push(update.lastTo); - } - if (update.contextTokens !== undefined) { - updates.push("context_tokens = ?"); - values.push(update.contextTokens); - } - if (update.model !== undefined) { - updates.push("model = ?"); - values.push(update.model); - } - if (update.provider !== undefined) { - updates.push("provider = ?"); - values.push(update.provider); - } - if (update.lastResetDate !== undefined) { - updates.push("last_reset_date = ?"); - values.push(update.lastResetDate); - } - if (update.inputTokens !== undefined) { - updates.push("input_tokens = ?"); - values.push(update.inputTokens); - } - if (update.outputTokens !== undefined) { - updates.push("output_tokens = ?"); - values.push(update.outputTokens); + for (const [field, column] of Object.entries(SESSION_UPDATE_COLUMNS) as [ + keyof typeof SESSION_UPDATE_COLUMNS, + string, + ][]) { + const value = update[field]; + if (value !== undefined) { + updates.push(`${column} = ?`); + values.push(value); + } } updates.push("updated_at = ?"); @@ -247,7 +241,7 @@ export function updateSession( } export function incrementMessageCount(chatId: string): void { const db = getDb(); - const sessionKey = `telegram:${chatId}`; + const sessionKey = sessionKeyFor(chatId); const result = db .prepare( @@ -265,7 +259,7 @@ export function incrementMessageCount(chatId: string): void { } export function getSession(chatId: string): SessionEntry | null { const db = getDb(); - const sessionKey = `telegram:${chatId}`; + const sessionKey = sessionKeyFor(chatId); const row = db.prepare("SELECT * FROM sessions WHERE chat_id = ?").get(sessionKey) as | SessionRow | undefined; @@ -290,7 +284,7 @@ export function resetSession(chatId: string): SessionEntry { }; const db = getDb(); - const sessionKey = `telegram:${chatId}`; + const sessionKey = sessionKeyFor(chatId); db.prepare( ` @@ -340,7 +334,7 @@ export function shouldResetSession(session: SessionEntry, policy: SessionResetPo return false; } -export function resetSessionWithPolicy(chatId: string, _policy: SessionResetPolicy): SessionEntry { +export function resetSessionWithPolicy(chatId: string): SessionEntry { resetSession(chatId); const today = new Date().toISOString().split("T")[0]; diff --git a/src/telegram/__tests__/handlers.test.ts b/src/telegram/__tests__/handlers.test.ts index 2cc60330..af125ccb 100644 --- a/src/telegram/__tests__/handlers.test.ts +++ b/src/telegram/__tests__/handlers.test.ts @@ -95,6 +95,7 @@ function makeBridge() { setTyping: vi.fn().mockResolvedValue(undefined), fetchReplyContext: vi.fn().mockResolvedValue(null), getMode: vi.fn().mockReturnValue("user"), + requiresOffsetDedup: vi.fn().mockReturnValue(true), } as any; } diff --git a/src/telegram/admin.ts b/src/telegram/admin.ts index 7d1fe7ca..3e788c9b 100644 --- a/src/telegram/admin.ts +++ b/src/telegram/admin.ts @@ -11,6 +11,8 @@ const log = createLogger("Telegram"); import type { ModulePermissions, ModuleLevel } from "../agent/tools/module-permissions.js"; import type { ToolRegistry } from "../agent/tools/registry.js"; import { writePluginSecret, deletePluginSecret, listPluginSecretKeys } from "../sdk/secrets.js"; +import { readRawConfig, writeRawConfig, setNestedValue } from "../config/configurable-keys.js"; +import { getErrorMessage } from "../utils/errors.js"; export interface AdminCommand { command: string; @@ -30,17 +32,20 @@ export class AdminHandler { private paused = false; private permissions: ModulePermissions | null; private registry: ToolRegistry | null; + private configPath: string; constructor( bridge: ITelegramBridge, config: TelegramConfig, agent: AgentRuntime, + configPath: string, permissions?: ModulePermissions, registry?: ToolRegistry ) { this.bridge = bridge; this.config = config; this.agent = agent; + this.configPath = configPath; this.permissions = permissions ?? null; this.registry = registry ?? null; } @@ -109,6 +114,8 @@ export class AdminHandler { return this.handleVerboseCommand(); case "rag": return this.handleRagCommand(command); + case "guest": + return this.handleGuestCommand(command); case "modules": return this.handleModulesCommand(command, isGroup ?? false); case "plugin": @@ -322,6 +329,25 @@ export class AdminHandler { return next ? "πŸ” Tool RAG **ON**" : "πŸ”‡ Tool RAG **OFF**"; } + private handleGuestCommand(command: AdminCommand): string { + const cfg = this.agent.getConfig(); + const sub = command.args[0]?.toLowerCase(); + if (sub !== "on" && sub !== "off") { + const state = cfg.telegram.guest_mode ? "ON" : "OFF"; + return `πŸ‘€ Guest mode: **${state}**\n\nUsage: /guest on|off`; + } + const enabled = sub === "on"; + try { + const raw = readRawConfig(this.configPath); + setNestedValue(raw, "telegram.guest_mode", enabled); + writeRawConfig(raw, this.configPath); + } catch (error) { + return `❌ Error saving config: ${getErrorMessage(error)}`; + } + cfg.telegram.guest_mode = enabled; + return enabled ? "πŸ‘€ Guest mode **ON**" : "πŸ‘€ Guest mode **OFF**"; + } + private handleModulesCommand(command: AdminCommand, isGroup: boolean): string { if (!this.permissions || !this.registry) { return "❌ Module permissions not available"; @@ -558,6 +584,9 @@ Toggle verbose debug logging **/rag** [status|topk ] Toggle Tool RAG or view status +**/guest** on|off +Toggle guest mode (answer queries in non-member chats) + **/pause** / **/resume** Pause or resume the agent diff --git a/src/telegram/bridge-interface.ts b/src/telegram/bridge-interface.ts index 96fb2d73..b640a749 100644 --- a/src/telegram/bridge-interface.ts +++ b/src/telegram/bridge-interface.ts @@ -103,8 +103,20 @@ export interface ITelegramBridge { // Chat info getChatInfo(chatId: string): Promise; - /** Stream a response token by token via message drafts (bot mode). Returns final sent message. */ + // Capabilities + /** True when the handler must dedup messages via the offset store (user mode redelivers; bot mode dedupes via update_id). */ + requiresOffsetDedup(): boolean; + + /** Stream a response token by token via message drafts. Returns the final sent message. */ streamResponse?(chatId: string, textStream: AsyncIterable): Promise; + /** Push a chunk to a streaming draft. Returns the un-sent remainder. */ + streamDraft?(chatId: string, textStream: AsyncIterable): Promise; + /** Clear an active streaming draft. */ + clearDraft?(chatId: string): Promise; + /** Send the final draft as a real message. */ + finalizeDraft?(chatId: string, text: string): Promise; + /** Reset draft state for the next iteration. */ + resetDraft?(chatId: string): void; // Events onNewMessage( @@ -112,8 +124,4 @@ export interface ITelegramBridge { filters?: { incoming?: boolean; outgoing?: boolean; chats?: string[] } ): void; fetchReplyContext(rawMsg: unknown): Promise; - - // Escape hatches (user-only tools) - getPeer(chatId: string): unknown | undefined; - getRawClient(): unknown; } diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts index 813a2f6f..c389b719 100644 --- a/src/telegram/bridge.ts +++ b/src/telegram/bridge.ts @@ -1,5 +1,6 @@ -// Re-export shim for backward compatibility -export { GramJSUserBridge as TelegramBridge } from "./bridges/user.js"; +// Type re-export shim for backward compatibility. (The former +// `GramJSUserBridge as TelegramBridge` value alias was unused and ambiguous with +// ITelegramBridge β€” removed; import the class from ./bridges/user.js directly.) export type { TelegramMessage, InlineButton, diff --git a/src/telegram/bridges/bot.ts b/src/telegram/bridges/bot.ts index 0fec64ad..74c2d605 100644 --- a/src/telegram/bridges/bot.ts +++ b/src/telegram/bridges/bot.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Bot, InlineKeyboard, InputFile, type Context } from "grammy"; import { markdownToTelegramHtml } from "../formatting.js"; import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js"; @@ -76,6 +75,10 @@ export class GrammyBotBridge implements ITelegramBridge { return "bot"; } + requiresOffsetDedup(): boolean { + return false; + } + isAvailable(): boolean { return this.connected; } @@ -88,6 +91,15 @@ export class GrammyBotBridge implements ITelegramBridge { return this.botInfo?.username; } + /** + * Convert a decimal-string chatId to the JS number the grammy/Bot API expects. + * The Bot API types chat ids as JS numbers; bridge-interface keeps them as + * strings, so this is the single conversion point for the whole bridge. + */ + private toChatId(chatId: string): number { + return Number(chatId); + } + async sendMessage(options: SendMessageOptions): Promise { if (!options.text || options.text.trim().length === 0) { log.debug("sendMessage skipped: empty text"); @@ -105,7 +117,7 @@ export class GrammyBotBridge implements ITelegramBridge { return this.sendLongMessage(options.chatId, html, options.replyToId, replyMarkup); } - const result = await this.bot.api.sendMessage(Number(options.chatId), html, { + const result = await this.bot.api.sendMessage(this.toChatId(options.chatId), html, { parse_mode: "HTML", reply_to_message_id: options.replyToId, reply_markup: replyMarkup, @@ -150,7 +162,7 @@ export class GrammyBotBridge implements ITelegramBridge { for (let i = 0; i < chunks.length; i++) { const isFirst = i === 0; const isLast = i === chunks.length - 1; - const result = await this.bot.api.sendMessage(Number(chatId), chunks[i], { + const result = await this.bot.api.sendMessage(this.toChatId(chatId), chunks[i], { parse_mode: "HTML", reply_to_message_id: isFirst ? replyToId : undefined, reply_markup: isLast ? replyMarkup : undefined, @@ -167,7 +179,7 @@ export class GrammyBotBridge implements ITelegramBridge { : undefined; const result = await this.bot.api.editMessageText( - Number(options.chatId), + this.toChatId(options.chatId), options.messageId, markdownToTelegramHtml(options.text), { parse_mode: "HTML", reply_markup: replyMarkup } @@ -185,7 +197,7 @@ export class GrammyBotBridge implements ITelegramBridge { } async deleteMessage(chatId: string, messageId: number): Promise { - await this.bot.api.deleteMessage(Number(chatId), messageId); + await this.bot.api.deleteMessage(this.toChatId(chatId), messageId); return true; } @@ -195,8 +207,8 @@ export class GrammyBotBridge implements ITelegramBridge { messageId: number ): Promise { const result = await this.bot.api.forwardMessage( - Number(toChatId), - Number(fromChatId), + this.toChatId(toChatId), + this.toChatId(fromChatId), messageId ); @@ -214,7 +226,7 @@ export class GrammyBotBridge implements ITelegramBridge { replyToId?: number ): Promise { const input = Buffer.isBuffer(photo) ? new InputFile(photo) : photo; - const result = await this.bot.api.sendPhoto(Number(chatId), input, { + const result = await this.bot.api.sendPhoto(this.toChatId(chatId), input, { caption, reply_to_message_id: replyToId, }); @@ -227,13 +239,13 @@ export class GrammyBotBridge implements ITelegramBridge { } async pinMessage(chatId: string, messageId: number): Promise { - await this.bot.api.pinChatMessage(Number(chatId), messageId); + await this.bot.api.pinChatMessage(this.toChatId(chatId), messageId); return true; } async sendDice(chatId: string, emoji?: string): Promise { const result = await this.bot.api.sendDice( - Number(chatId), + this.toChatId(chatId), emoji as Parameters[1] ); @@ -245,7 +257,7 @@ export class GrammyBotBridge implements ITelegramBridge { } async getChatInfo(chatId: string): Promise { - const chat = await this.bot.api.getChat(Number(chatId)); + const chat = await this.bot.api.getChat(this.toChatId(chatId)); return { id: String(chat.id), @@ -270,14 +282,14 @@ export class GrammyBotBridge implements ITelegramBridge { async setTyping(chatId: string): Promise { try { - await this.bot.api.sendChatAction(Number(chatId), "typing"); + await this.bot.api.sendChatAction(this.toChatId(chatId), "typing"); } catch { // 429 rate-limits on typing are harmless β€” swallow silently } } async sendReaction(chatId: string, messageId: number, emoji: string): Promise { - await this.bot.api.setMessageReaction(Number(chatId), messageId, [ + await this.bot.api.setMessageReaction(this.toChatId(chatId), messageId, [ { type: "emoji", emoji } as Parameters< typeof this.bot.api.setMessageReaction >[2] extends (infer U)[] @@ -299,7 +311,7 @@ export class GrammyBotBridge implements ITelegramBridge { let fullText = ""; let lastDraftTime = 0; const THROTTLE_MS = 300; - const numericChatId = Number(chatId); + const numericChatId = this.toChatId(chatId); // Leave headroom for HTML expansion from markdownToTelegramHtml const SPLIT_THRESHOLD = TELEGRAM_MAX_MESSAGE_LENGTH - 300; @@ -359,7 +371,7 @@ export class GrammyBotBridge implements ITelegramBridge { const draftId = this.activeDraftIds.get(chatId); if (draftId) { try { - await this.bot.api.sendMessageDraft(Number(chatId), draftId, " "); + await this.bot.api.sendMessageDraft(this.toChatId(chatId), draftId, " "); } catch { /* best effort */ } @@ -387,7 +399,9 @@ export class GrammyBotBridge implements ITelegramBridge { } async getMessages(_chatId: string, _limit: number): Promise { - return []; + throw new Error( + "getMessages is unavailable in bot mode β€” bots cannot read arbitrary chat history." + ); } parseMessage(msg: GrammyMessage): TelegramMessage { @@ -452,6 +466,7 @@ export class GrammyBotBridge implements ITelegramBridge { mediaType, timestamp: new Date(msg.date * 1000), replyToId: msg.reply_to_message?.message_id, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bot mode stores a Grammy message where the interface types a GramJS Api.Message _rawMessage: msg.reply_to_message ? (msg as any) : undefined, }; } @@ -512,6 +527,28 @@ export class GrammyBotBridge implements ITelegramBridge { }); } + /** Register a handler for Bot API 10.0 guest queries. Reply text is sent via answerGuestQuery. */ + onGuestMessage(handler: (msg: TelegramMessage) => Promise): void { + this.bot.on("guest_message", async (ctx) => { + const gm = ctx.guestMessage; + if (!gm) return; + try { + const content = await handler(this.parseMessage(gm)); + const text = content?.trim(); + if (!text || text === "__SILENT__") return; + const html = markdownToTelegramHtml(text).slice(0, TELEGRAM_MAX_MESSAGE_LENGTH); + await ctx.answerGuestQuery({ + type: "article", + id: String(gm.message_id), + title: this.botInfo?.firstName ?? "Reply", + input_message_content: { message_text: html, parse_mode: "HTML" }, + }); + } catch (err) { + log.error({ err }, "Error in guest message handler"); + } + }); + } + async fetchReplyContext(rawMsg: unknown): Promise { const msg = rawMsg as GrammyMessage | undefined; if (!msg?.reply_to_message) return null; @@ -529,14 +566,6 @@ export class GrammyBotBridge implements ITelegramBridge { }; } - getPeer(_chatId: string): undefined { - return undefined; - } - - getRawClient(): Bot { - return this.bot; - } - /** Set callback handler for synthetic message injection (from CallbackRouter) */ setCallbackHandler(handler: (msg: TelegramMessage) => void): void { this.callbackHandler = handler; @@ -555,6 +584,7 @@ export class GrammyBotBridge implements ITelegramBridge { { command: "wallet", description: "Check TON wallet balance" }, { command: "verbose", description: "Toggle verbose logging" }, { command: "rag", description: "Toggle Tool RAG or view status" }, + { command: "guest", description: "Toggle guest mode" }, { command: "pause", description: "Pause the agent" }, { command: "resume", description: "Resume the agent" }, { command: "stop", description: "Emergency shutdown" }, diff --git a/src/telegram/bridges/user.ts b/src/telegram/bridges/user.ts index 43492a3d..f1f82fa5 100644 --- a/src/telegram/bridges/user.ts +++ b/src/telegram/bridges/user.ts @@ -20,6 +20,9 @@ export type { TelegramMessage, InlineButton, SendMessageOptions } from "../bridg const log = createLogger("Telegram"); +/** Max time to wait for getSender() before giving up (deleted accounts, timeouts). */ +const SENDER_RESOLVE_TIMEOUT_MS = 5000; + export class GramJSUserBridge implements ITelegramBridge { private client: TelegramUserClient; private ownUserId?: bigint; @@ -34,6 +37,10 @@ export class GramJSUserBridge implements ITelegramBridge { return "user"; } + requiresOffsetDedup(): boolean { + return true; + } + async connect(): Promise { await this.client.connect(); const me = this.client.getMe(); @@ -100,20 +107,7 @@ export class GramJSUserBridge implements ITelegramBridge { let msg: Api.Message; if (options.inlineKeyboard && options.inlineKeyboard.length > 0) { - const buttons = new Api.ReplyInlineMarkup({ - rows: options.inlineKeyboard.map( - (row) => - new Api.KeyboardButtonRow({ - buttons: row.map( - (btn) => - new Api.KeyboardButtonCallback({ - text: btn.text, - data: Buffer.from(btn.callback_data), - }) - ), - }) - ), - }); + const buttons = this.buildInlineMarkup(options.inlineKeyboard); const gramJsClient = this.client.getClient(); msg = await withFloodRetry( @@ -147,26 +141,33 @@ export class GramJSUserBridge implements ITelegramBridge { } } + /** Build a GramJS inline-keyboard markup from the bridge's button rows. */ + private buildInlineMarkup( + inlineKeyboard: Array> + ): Api.ReplyInlineMarkup { + return new Api.ReplyInlineMarkup({ + rows: inlineKeyboard.map( + (row) => + new Api.KeyboardButtonRow({ + buttons: row.map( + (btn) => + new Api.KeyboardButtonCallback({ + text: btn.text, + data: Buffer.from(btn.callback_data), + }) + ), + }) + ), + }); + } + async editMessage(options: EditMessageOptions): Promise { try { const peer = this.peerCache.get(options.chatId) || options.chatId; let buttons: Api.ReplyInlineMarkup | undefined; if (options.inlineKeyboard && options.inlineKeyboard.length > 0) { - buttons = new Api.ReplyInlineMarkup({ - rows: options.inlineKeyboard.map( - (row) => - new Api.KeyboardButtonRow({ - buttons: row.map( - (btn) => - new Api.KeyboardButtonCallback({ - text: btn.text, - data: Buffer.from(btn.callback_data), - }) - ), - }) - ), - }); + buttons = this.buildInlineMarkup(options.inlineKeyboard); } const gramJsClient = this.client.getClient(); @@ -477,12 +478,9 @@ export class GramJSUserBridge implements ITelegramBridge { return this.peerCache.get(chatId); } - getRawClient(): unknown { - return this.client; - } - // --- Non-interface methods (user-bridge specific) --- + /** The GramJS client wrapper. Reach it through the isUserBridge type guard. */ getClient(): TelegramUserClient { return this.client; } @@ -518,6 +516,35 @@ export class GramJSUserBridge implements ITelegramBridge { }); } + /** Resolve sender username/firstName/bot flag with a timeout; non-fatal on failure. */ + private async resolveSender( + msg: Api.Message | Api.MessageService + ): Promise<{ senderUsername?: string; senderFirstName?: string; isBot: boolean }> { + let senderUsername: string | undefined; + let senderFirstName: string | undefined; + let isBot = false; + try { + const sender = await Promise.race([ + msg.getSender(), + new Promise((resolve) => + setTimeout(() => resolve(undefined), SENDER_RESOLVE_TIMEOUT_MS) + ), + ]); + if (sender && "username" in sender) { + senderUsername = sender.username ?? undefined; + } + if (sender && "firstName" in sender) { + senderFirstName = sender.firstName ?? undefined; + } + if (sender instanceof Api.User) { + isBot = sender.bot ?? false; + } + } catch { + // getSender() can fail on deleted accounts, timeouts, etc. β€” non-critical + } + return { senderUsername, senderFirstName, isBot }; + } + private async parseMessage(msg: Api.Message): Promise { const chatId = msg.chatId?.toString() ?? msg.peerId?.toString() ?? "unknown"; const senderIdBig = msg.senderId ? BigInt(msg.senderId.toString()) : BigInt(0); @@ -539,26 +566,7 @@ export class GramJSUserBridge implements ITelegramBridge { } } - let senderUsername: string | undefined; - let senderFirstName: string | undefined; - let isBot = false; - try { - const sender = await Promise.race([ - msg.getSender(), - new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)), - ]); - if (sender && "username" in sender) { - senderUsername = sender.username ?? undefined; - } - if (sender && "firstName" in sender) { - senderFirstName = sender.firstName ?? undefined; - } - if (sender instanceof Api.User) { - isBot = sender.bot ?? false; - } - } catch { - // getSender() can fail on deleted accounts, timeouts, etc. - } + const { senderUsername, senderFirstName, isBot } = await this.resolveSender(msg); const hasMedia = !!( msg.photo || @@ -639,26 +647,7 @@ export class GramJSUserBridge implements ITelegramBridge { const senderIdBig = msg.senderId ? BigInt(msg.senderId.toString()) : BigInt(0); const senderId = Number(senderIdBig); - let senderUsername: string | undefined; - let senderFirstName: string | undefined; - let isBot = false; - try { - const sender = await Promise.race([ - msg.getSender(), - new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)), - ]); - if (sender && "username" in sender) { - senderUsername = sender.username ?? undefined; - } - if (sender && "firstName" in sender) { - senderFirstName = sender.firstName ?? undefined; - } - if (sender instanceof Api.User) { - isBot = sender.bot ?? false; - } - } catch { - // getSender() can fail β€” non-critical - } + const { senderUsername, senderFirstName, isBot } = await this.resolveSender(msg); let text = ""; diff --git a/src/telegram/callbacks/handler.ts b/src/telegram/callbacks/handler.ts index 75c0739b..5e102885 100644 --- a/src/telegram/callbacks/handler.ts +++ b/src/telegram/callbacks/handler.ts @@ -1,4 +1,5 @@ import type { ITelegramBridge } from "../bridge-interface.js"; +import { isUserBridge } from "../bridge-guards.js"; import { createLogger } from "../../utils/logger.js"; const log = createLogger("Telegram"); @@ -68,8 +69,9 @@ export class CallbackQueryHandler { private async answerCallback(queryId: bigint, message?: string, alert = false): Promise { try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto callback answer - await (this.bridge.getRawClient() as any)?.answerCallbackQuery?.(queryId, { message, alert }); + if (isUserBridge(this.bridge)) { + await this.bridge.getClient().answerCallbackQuery(queryId, { message, alert }); + } } catch (error) { log.error({ err: error }, "Error answering callback"); } diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts index 88ee7ff5..448254bc 100644 --- a/src/telegram/handlers.ts +++ b/src/telegram/handlers.ts @@ -10,7 +10,7 @@ import { PendingHistory } from "../memory/pending-history.js"; import type { ToolContext } from "../agent/tools/types.js"; import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js"; import { isSilentReply } from "../constants/tokens.js"; -import { telegramTranscribeAudioExecutor } from "../agent/tools/telegram/media/transcribe-audio.js"; +import { transcribeAudio } from "../sdk/telegram-utils.js"; import { TYPING_REFRESH_MS } from "../constants/timeouts.js"; import { createLogger } from "../utils/logger.js"; import { getErrorMessage } from "../utils/errors.js"; @@ -201,8 +201,8 @@ export class MessageHandler { analyzeMessage(message: TelegramMessage): MessageContext { const isAdmin = this.config.admin_ids.includes(message.senderId); - // Skip offset dedup in bot mode β€” Grammy handles dedup via update_id internally - if (this.bridge.getMode() !== "bot") { + // Bridges that redeliver (user mode) need handler-side dedup; bot mode dedupes via update_id. + if (this.bridge.requiresOffsetDedup()) { const chatOffset = readOffset(message.chatId) ?? 0; if (message.id <= chatOffset) { return { @@ -391,9 +391,8 @@ export class MessageHandler { await this.chatQueue.enqueue(message.chatId, async () => { try { // Re-check offset after queue wait to prevent duplicate processing - // (GramJS may fire duplicate NewMessage events during reconnection) - // Skip in bot mode β€” Grammy handles dedup via update_id - if (this.bridge.getMode() !== "bot") { + // (GramJS may fire duplicate NewMessage events during reconnection). + if (this.bridge.requiresOffsetDedup()) { const postQueueOffset = readOffset(message.chatId) ?? 0; if (message.id <= postQueueOffset) { log.debug(`Skipping message ${message.id} (already processed after queue wait)`); @@ -430,20 +429,13 @@ export class MessageHandler { let transcriptionText: string | null = null; if (message.mediaType === "voice" || message.mediaType === "audio") { try { - const transcribeResult = await telegramTranscribeAudioExecutor( - { chatId: message.chatId, messageId: message.id }, - { - bridge: this.bridge, - db: this.db, - chatId: message.chatId, - senderId: message.senderId, - isGroup: message.isGroup, - config: this.fullConfig, - } + const transcribeResult = await transcribeAudio( + this.bridge, + message.chatId, + message.id ); - const transcribeData = transcribeResult.data as Record | undefined; - if (transcribeResult.success && transcribeData?.text) { - transcriptionText = transcribeData.text as string; + if (transcribeResult.text) { + transcriptionText = transcribeResult.text; log.info( `Auto-transcribed voice msg ${message.id}: "${transcriptionText?.substring(0, 80)}..."` ); @@ -473,7 +465,7 @@ export class MessageHandler { : message.text; const streamMode = this.fullConfig?.telegram?.stream_mode ?? "all"; const streamToChat = - this.bridge.getMode() === "bot" && this.bridge.streamResponse && streamMode !== "off" + this.bridge.streamResponse && streamMode !== "off" ? { chatId: message.chatId, bridge: this.bridge, @@ -573,9 +565,8 @@ export class MessageHandler { this.pendingHistory.clearPending(message.chatId); } - // Mark as processed AFTER successful handling (prevents message loss on crash) - // Skip in bot mode β€” Grammy handles dedup via update_id - if (this.bridge.getMode() !== "bot") { + // Mark as processed AFTER successful handling (prevents message loss on crash). + if (this.bridge.requiresOffsetDedup()) { writeOffset(message.id, message.chatId); } } finally { diff --git a/src/telegram/task-dependency-resolver.ts b/src/telegram/task-dependency-resolver.ts index 3a9a40c0..aee1259c 100644 --- a/src/telegram/task-dependency-resolver.ts +++ b/src/telegram/task-dependency-resolver.ts @@ -187,17 +187,17 @@ export class TaskDependencyResolver { log.warn(`↳ Cannot trigger [TASK:${taskId}]: no admin_id configured in bot mode`); } } else { - // User mode: send to Saved Messages via GramJS - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- user-only MTProto path - const gramJsClient = this.bridge.getRawClient() as any; - const me = await gramJsClient.getMe(); - - // Send task message immediately (no scheduling) - await gramJsClient.sendMessage(me, { - message: `[TASK:${taskId}] ${task.description}`, - }); - - log.info(`↳ Sent [TASK:${taskId}] to Saved Messages`); + // User mode: send to Saved Messages (the agent's own chat) + const ownId = this.bridge.getOwnUserId(); + if (ownId) { + await this.bridge.sendMessage({ + chatId: String(ownId), + text: `[TASK:${taskId}] ${task.description}`, + }); + log.info(`↳ Sent [TASK:${taskId}] to Saved Messages`); + } else { + log.warn(`↳ Cannot trigger [TASK:${taskId}]: own user id unavailable`); + } } } catch (error) { log.error({ err: error }, `Error triggering task ${taskId}`); diff --git a/src/ton-proxy/manager.ts b/src/ton-proxy/manager.ts index c74356a1..053beb68 100644 --- a/src/ton-proxy/manager.ts +++ b/src/ton-proxy/manager.ts @@ -19,6 +19,7 @@ import { join } from "path"; import { pipeline } from "stream/promises"; import { createLogger } from "../utils/logger.js"; import { TELETON_ROOT } from "../workspace/paths.js"; +import type { TonProxyConfig } from "../config/schema.js"; const log = createLogger("TonProxy"); @@ -29,12 +30,6 @@ const HEALTH_CHECK_INTERVAL_MS = 30_000; const HEALTH_CHECK_TIMEOUT_MS = 5_000; const KILL_GRACE_MS = 5_000; -export interface TonProxyConfig { - enabled: boolean; - port: number; - binary_path?: string; -} - export class TonProxyManager { private process: ChildProcess | null = null; private healthInterval: ReturnType | null = null; diff --git a/src/ton/__tests__/confirm.test.ts b/src/ton/__tests__/confirm.test.ts new file mode 100644 index 00000000..dde446a9 --- /dev/null +++ b/src/ton/__tests__/confirm.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SendMode } from "@ton/core"; + +// ─── Mocks ──────────────────────────────────────────────────────── + +vi.mock("../../utils/logger.js", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})); + +vi.mock("../../utils/retry.js", () => ({ + withBlockchainRetry: (fn: () => unknown) => fn(), +})); + +vi.mock("../../constants/timeouts.js", () => ({ + TON_CONFIRM_TIMEOUT_MS: 120, + TON_CONFIRM_POLL_INTERVAL_MS: 10, +})); + +vi.mock("../wallet-service.js", () => ({ invalidateTonClientCache: vi.fn() })); + +// ─── Imports (after mocks) ──────────────────────────────────────── + +import { walletTxLt, confirmWalletTx, sendWalletTx, tonExplorerTxUrl } from "../confirm.js"; + +// ─── Helpers ────────────────────────────────────────────────────── + +interface TxOpts { + lt: bigint; + external?: boolean; + computeOk?: boolean; + actionOk?: boolean; + hash?: string; + now?: number; +} + +function makeTx(o: TxOpts) { + return { + lt: o.lt, + now: o.now ?? 1000, + inMessage: { info: { type: o.external === false ? "internal" : "external-in" } }, + description: { + type: "generic", + computePhase: { type: "vm", success: o.computeOk ?? true, exitCode: 0 }, + actionPhase: { success: o.actionOk ?? true, resultCode: 0, noFunds: false }, + }, + hash: () => Buffer.from((o.hash ?? "ab".repeat(32)).padEnd(64, "0").slice(0, 64), "hex"), + } as any; +} + +const ADDR = { toString: () => "EQwallet" } as any; + +function fakeClient(getTransactions: ReturnType) { + return { getTransactions } as any; +} + +// ─── Tests ──────────────────────────────────────────────────────── + +describe("walletTxLt", () => { + it("returns the latest transaction lt", async () => { + const getTx = vi.fn().mockResolvedValue([makeTx({ lt: 77n })]); + expect(await walletTxLt(fakeClient(getTx), ADDR)).toBe(77n); + expect(getTx).toHaveBeenCalledWith(ADDR, { limit: 1 }); + }); + + it("returns 0n for a wallet with no transactions", async () => { + const getTx = vi.fn().mockResolvedValue([]); + expect(await walletTxLt(fakeClient(getTx), ADDR)).toBe(0n); + }); +}); + +describe("confirmWalletTx", () => { + it("returns the real hash for a confirmed external-in tx", async () => { + const getTx = vi + .fn() + .mockResolvedValue([makeTx({ lt: 10n, hash: "cd".repeat(32), now: 2000 })]); + expect(await confirmWalletTx(fakeClient(getTx), ADDR, 5n)).toEqual({ + hash: "cd".repeat(32), + at: 2_000_000, + }); + }); + + it("returns null when the action phase failed (funds never left)", async () => { + const getTx = vi.fn().mockResolvedValue([makeTx({ lt: 10n, actionOk: false })]); + expect(await confirmWalletTx(fakeClient(getTx), ADDR, 5n)).toBeNull(); + }); + + it("ignores incoming (internal) txs and times out", async () => { + const getTx = vi.fn().mockResolvedValue([makeTx({ lt: 10n, external: false })]); + expect(await confirmWalletTx(fakeClient(getTx), ADDR, 5n)).toBeNull(); + }); + + it("ignores txs at or before the pre-send snapshot lt", async () => { + const getTx = vi.fn().mockResolvedValue([makeTx({ lt: 5n })]); + expect(await confirmWalletTx(fakeClient(getTx), ADDR, 5n)).toBeNull(); + }); +}); + +describe("sendWalletTx", () => { + const secretKey = Buffer.alloc(64); + + function fakeContract() { + return { + address: ADDR, + getSeqno: vi.fn().mockResolvedValue(7), + sendTransfer: vi.fn().mockResolvedValue(undefined), + }; + } + + beforeEach(() => vi.clearAllMocks()); + + it("returns the confirmed tx and broadcasts once", async () => { + const contract = fakeContract(); + const getTx = vi + .fn() + .mockResolvedValueOnce([makeTx({ lt: 100n, external: false })]) // snapshot + .mockResolvedValue([makeTx({ lt: 101n, hash: "ef".repeat(32), now: 3000 })]); // confirm + + const result = await sendWalletTx(fakeClient(getTx), contract as never, { + secretKey, + messages: [], + }); + + expect(result).toEqual({ hash: "ef".repeat(32), seqno: 7, at: 3_000_000 }); + expect(contract.sendTransfer).toHaveBeenCalledTimes(1); + expect(contract.sendTransfer).toHaveBeenCalledWith( + expect.objectContaining({ seqno: 7, sendMode: SendMode.PAY_GAS_SEPARATELY }) + ); + }); + + it("confirms via the chain even when the broadcast call throws but the message lands", async () => { + const contract = fakeContract(); + contract.sendTransfer.mockRejectedValue(new Error("ETIMEDOUT")); + const getTx = vi + .fn() + .mockResolvedValueOnce([]) // snapshot + .mockResolvedValue([makeTx({ lt: 1n, hash: "11".repeat(32) })]); + + const result = await sendWalletTx(fakeClient(getTx), contract as never, { + secretKey, + messages: [], + }); + expect(result?.hash).toBe("11".repeat(32)); + }); + + it("rethrows the broadcast error when nothing lands on-chain", async () => { + const contract = fakeContract(); + contract.sendTransfer.mockRejectedValue(new Error("network down")); + const getTx = vi.fn().mockResolvedValue([]); + + await expect( + sendWalletTx(fakeClient(getTx), contract as never, { secretKey, messages: [] }) + ).rejects.toThrow("network down"); + }); +}); + +describe("tonExplorerTxUrl", () => { + it("builds a tonviewer link", () => { + expect(tonExplorerTxUrl("abc")).toBe("https://tonviewer.com/transaction/abc"); + }); +}); diff --git a/src/ton/__tests__/transfer.test.ts b/src/ton/__tests__/transfer.test.ts new file mode 100644 index 00000000..b0b5d9b7 --- /dev/null +++ b/src/ton/__tests__/transfer.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ─── Mocks ──────────────────────────────────────────────────────── + +const h = vi.hoisted(() => { + const contract = { + address: { toString: () => "EQwallet" }, + getSeqno: vi.fn(), + sendTransfer: vi.fn(), + }; + const client = { open: vi.fn(() => contract), getTransactions: vi.fn() }; + return { + contract, + client, + getKeyPair: vi.fn(), + getCachedTonClient: vi.fn(), + invalidateTonClientCache: vi.fn(), + addressParse: vi.fn(), + }; +}); + +vi.mock("../../utils/logger.js", () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})); + +vi.mock("../wallet-service.js", () => ({ + getKeyPair: h.getKeyPair, + getCachedTonClient: h.getCachedTonClient, + invalidateTonClientCache: h.invalidateTonClientCache, +})); + +vi.mock("../../utils/retry.js", () => ({ + withBlockchainRetry: (fn: () => unknown) => fn(), +})); + +vi.mock("../../constants/timeouts.js", () => ({ + TON_CONFIRM_TIMEOUT_MS: 200, + TON_CONFIRM_POLL_INTERVAL_MS: 10, +})); + +vi.mock("@ton/core", () => ({ + Address: { parse: h.addressParse }, + SendMode: { PAY_GAS_SEPARATELY: 1 }, +})); + +vi.mock("@ton/ton", () => ({ + WalletContractV5R1: { create: vi.fn(() => ({ address: { toString: () => "EQwallet" } })) }, + toNano: (n: number) => BigInt(Math.round(n * 1e9)), + internal: (x: unknown) => x, +})); + +// ─── Imports (after mocks) ──────────────────────────────────────── + +import { sendTon } from "../transfer.js"; + +// ─── Helpers ────────────────────────────────────────────────────── + +const GOOD = "EQrecipient"; + +interface TxOpts { + lt: bigint; + external?: boolean; + computeOk?: boolean; + actionOk?: boolean; + noFunds?: boolean; + hash?: string; + now?: number; +} + +function makeTx(o: TxOpts) { + return { + lt: o.lt, + now: o.now ?? 1000, + inMessage: { info: { type: o.external === false ? "internal" : "external-in" } }, + description: { + type: "generic", + computePhase: { type: "vm", success: o.computeOk ?? true, exitCode: 0 }, + actionPhase: { success: o.actionOk ?? true, resultCode: 0, noFunds: o.noFunds ?? false }, + }, + hash: () => Buffer.from((o.hash ?? "ab".repeat(32)).padEnd(64, "0").slice(0, 64), "hex"), + } as unknown as import("@ton/core").Transaction; +} + +// ─── Tests ──────────────────────────────────────────────────────── + +describe("sendTon", () => { + beforeEach(() => { + vi.clearAllMocks(); + h.getCachedTonClient.mockResolvedValue(h.client); + h.client.open.mockReturnValue(h.contract); + h.addressParse.mockImplementation((s: string) => { + if (s === "BAD") throw new Error("invalid address"); + return { toString: () => s }; + }); + h.getKeyPair.mockResolvedValue({ publicKey: Buffer.alloc(32), secretKey: Buffer.alloc(64) }); + h.contract.getSeqno.mockResolvedValue(5); + h.contract.sendTransfer.mockResolvedValue(undefined); + h.client.getTransactions.mockResolvedValue([]); + }); + + it("rejects a non-positive amount without broadcasting", async () => { + expect(await sendTon({ toAddress: GOOD, amount: 0 })).toBeNull(); + expect(h.contract.sendTransfer).not.toHaveBeenCalled(); + }); + + it("rejects an invalid address without broadcasting", async () => { + expect(await sendTon({ toAddress: "BAD", amount: 1 })).toBeNull(); + expect(h.contract.sendTransfer).not.toHaveBeenCalled(); + }); + + it("returns null when the wallet is not initialized", async () => { + h.getKeyPair.mockResolvedValue(null); + expect(await sendTon({ toAddress: GOOD, amount: 1 })).toBeNull(); + expect(h.contract.sendTransfer).not.toHaveBeenCalled(); + }); + + it("returns the real on-chain hash once the transfer commits", async () => { + h.client.getTransactions + .mockResolvedValueOnce([makeTx({ lt: 100n, external: false })]) // pre-send snapshot + .mockResolvedValue([makeTx({ lt: 101n, hash: "cd".repeat(32), now: 2000 })]); // confirm + + const result = await sendTon({ toAddress: GOOD, amount: 1.5, comment: "hi" }); + + expect(result).toEqual({ hash: "cd".repeat(32), seqno: 5, at: 2_000_000 }); + expect(h.contract.sendTransfer).toHaveBeenCalledTimes(1); + expect(h.contract.sendTransfer).toHaveBeenCalledWith( + expect.objectContaining({ seqno: 5, sendMode: 1 }) + ); + }); + + it("returns null when the action phase fails (funds never left the wallet)", async () => { + h.client.getTransactions + .mockResolvedValueOnce([makeTx({ lt: 100n, external: false })]) + .mockResolvedValue([makeTx({ lt: 101n, actionOk: false, noFunds: true })]); + + expect(await sendTon({ toAddress: GOOD, amount: 999 })).toBeNull(); + expect(h.contract.sendTransfer).toHaveBeenCalledTimes(1); + }); + + it("returns null when the transfer is not confirmed within the window", async () => { + h.client.getTransactions.mockResolvedValue([]); // never appears + expect(await sendTon({ toAddress: GOOD, amount: 1 })).toBeNull(); + }); + + it("still confirms via the chain when the broadcast call errors but the message lands", async () => { + h.contract.sendTransfer.mockRejectedValue(new Error("ETIMEDOUT")); + h.client.getTransactions + .mockResolvedValueOnce([]) // snapshot + .mockResolvedValue([makeTx({ lt: 1n, hash: "ef".repeat(32) })]); // landed despite RPC error + + const result = await sendTon({ toAddress: GOOD, amount: 1 }); + expect(result?.hash).toBe("ef".repeat(32)); + }); + + it("rethrows the broadcast error when nothing lands on-chain", async () => { + h.contract.sendTransfer.mockRejectedValue(new Error("network down")); + h.client.getTransactions.mockResolvedValue([]); + + await expect(sendTon({ toAddress: GOOD, amount: 1 })).rejects.toThrow("network down"); + }); + + it("invalidates the node cache on a 5xx broadcast error", async () => { + h.contract.sendTransfer.mockRejectedValue({ status: 503 }); + h.client.getTransactions.mockResolvedValue([]); + + await expect(sendTon({ toAddress: GOOD, amount: 1 })).rejects.toBeDefined(); + expect(h.invalidateTonClientCache).toHaveBeenCalled(); + }); +}); diff --git a/src/ton/confirm.ts b/src/ton/confirm.ts new file mode 100644 index 00000000..0ee99e1c --- /dev/null +++ b/src/ton/confirm.ts @@ -0,0 +1,128 @@ +import type { WalletContractV5R1, TonClient, OpenedContract } from "@ton/ton"; +import { SendMode, type Address, type MessageRelaxed } from "@ton/core"; +import { invalidateTonClientCache } from "./wallet-service.js"; +import { createLogger } from "../utils/logger.js"; +import { withBlockchainRetry } from "../utils/retry.js"; +import { TON_CONFIRM_TIMEOUT_MS, TON_CONFIRM_POLL_INTERVAL_MS } from "../constants/timeouts.js"; + +const log = createLogger("TON"); + +type WalletV5R1 = WalletContractV5R1; + +export interface ConfirmedTx { + /** Real on-chain account-transaction hash (hex) β€” verifiable on TON explorers. */ + hash: string; + /** Unix-ms timestamp of the confirmed transaction. */ + at: number; +} + +export interface SentTx extends ConfirmedTx { + /** Wallet seqno consumed by this transfer. */ + seqno: number; +} + +/** Explorer link for a confirmed transaction (endpoints are mainnet β€” see endpoint.ts). */ +export function tonExplorerTxUrl(hash: string): string { + return `https://tonviewer.com/transaction/${hash}`; +} + +function isServerError(error: unknown): boolean { + const err = error as { status?: number; response?: { status?: number } }; + const status = err?.status ?? err?.response?.status; + return status === 429 || (status !== undefined && status >= 500); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Latest transaction lt for the wallet β€” snapshot this before sending to identify our own tx. */ +export async function walletTxLt(client: TonClient, walletAddress: Address): Promise { + const recent = await withBlockchainRetry( + () => client.getTransactions(walletAddress, { limit: 1 }), + "getTransactions" + ); + return recent[0]?.lt ?? 0n; +} + +/** + * Locate the wallet's own outgoing transaction and confirm it committed. Our send is the + * newest `external-in` tx past the pre-send `lt` snapshot β€” incoming payments are `internal` + * and the tx-lock serialises our sends, so the match is unambiguous. A seqno bump alone is + * not enough: the action phase must also succeed, else the funds never left the wallet. + */ +export async function confirmWalletTx( + client: TonClient, + walletAddress: Address, + sinceLt: bigint +): Promise { + const deadline = Date.now() + TON_CONFIRM_TIMEOUT_MS; + + while (Date.now() < deadline) { + try { + const txs = await client.getTransactions(walletAddress, { limit: 10 }); + const ours = txs.find((tx) => tx.inMessage?.info.type === "external-in" && tx.lt > sinceLt); + + if (ours) { + const d = ours.description; + if (d.type !== "generic") { + log.error({ type: d.type }, "Unexpected transfer transaction type"); + return null; + } + const computeOk = d.computePhase.type === "vm" && d.computePhase.success; + const actionOk = d.actionPhase?.success === true; + if (!computeOk || !actionOk) { + log.error( + { + exitCode: d.computePhase.type === "vm" ? d.computePhase.exitCode : undefined, + actionResult: d.actionPhase?.resultCode, + noFunds: d.actionPhase?.noFunds, + }, + "Transfer failed on-chain β€” funds did not leave the wallet" + ); + return null; + } + return { hash: ours.hash().toString("hex"), at: ours.now * 1000 }; + } + } catch (error) { + log.debug({ err: error }, "Confirm poll failed; retrying"); + } + await sleep(TON_CONFIRM_POLL_INTERVAL_MS); + } + + return null; +} + +/** + * Broadcast a transfer once, then confirm it on-chain and return the real hash. We confirm + * regardless of the broadcast call's outcome β€” the message can land even if the RPC response + * errors, and re-broadcasting a consumed seqno is a no-op. Returns null if unconfirmed within + * the finality window (never an optimistic success). Callers MUST hold the wallet tx-lock. + */ +export async function sendWalletTx( + client: TonClient, + contract: OpenedContract, + args: { secretKey: Buffer; messages: MessageRelaxed[]; sendMode?: SendMode } +): Promise { + const seqno = await withBlockchainRetry(() => contract.getSeqno(), "getSeqno"); + const sinceLt = await walletTxLt(client, contract.address); + + let broadcastError: unknown; + try { + await contract.sendTransfer({ + seqno, + secretKey: args.secretKey, + sendMode: args.sendMode ?? SendMode.PAY_GAS_SEPARATELY, + messages: args.messages, + }); + } catch (error) { + broadcastError = error; + if (isServerError(error)) invalidateTonClientCache(); + log.warn({ err: error }, "Broadcast errored β€” verifying on-chain whether it landed"); + } + + const confirmed = await confirmWalletTx(client, contract.address, sinceLt); + if (!confirmed) { + if (broadcastError) throw broadcastError; + return null; + } + return { hash: confirmed.hash, seqno, at: confirmed.at }; +} diff --git a/src/ton/dedust-assets.ts b/src/ton/dedust-assets.ts new file mode 100644 index 00000000..d5f2ca42 --- /dev/null +++ b/src/ton/dedust-assets.ts @@ -0,0 +1,72 @@ +import { fetchWithTimeout } from "../utils/fetch.js"; +import { createLogger } from "../utils/logger.js"; + +const log = createLogger("Tools"); + +const ASSET_LIST_URL = "https://assets.dedust.io/list.json"; +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +export interface DedustAsset { + type: "native" | "jetton"; + address?: string; + name: string; + symbol: string; + image?: string; + decimals: number; + sell_tax?: number; + buy_tax?: number; +} + +let cachedAssets: DedustAsset[] = []; +let cacheTimestamp = 0; + +/** + * Fetch and cache the asset list. Uses stale-while-revalidate on fetch failure. + */ +export async function getAssetList(): Promise { + if (cachedAssets.length > 0 && Date.now() - cacheTimestamp < CACHE_TTL_MS) { + return cachedAssets; + } + + try { + const response = await fetchWithTimeout(ASSET_LIST_URL); + if (!response.ok) { + throw new Error(`Failed to fetch asset list: ${response.status}`); + } + + cachedAssets = await response.json(); + cacheTimestamp = Date.now(); + return cachedAssets; + } catch (error) { + // Stale-while-revalidate: return old cache if available + if (cachedAssets.length > 0) { + log.warn({ err: error }, "Asset list fetch failed, using stale cache"); + return cachedAssets; + } + throw error; + } +} + +export async function findAsset(addressOrTon: string): Promise { + const assets = await getAssetList(); + + if (addressOrTon.toLowerCase() === "ton") { + return assets.find((a) => a.type === "native"); + } + + const normalized = addressOrTon.toLowerCase(); + return assets.find((a) => a.type === "jetton" && a.address?.toLowerCase() === normalized); +} + +export async function findAssetBySymbol(symbol: string): Promise { + const assets = await getAssetList(); + const upper = symbol.toUpperCase(); + return assets.find((a) => a.symbol.toUpperCase() === upper); +} + +export async function getDecimals(addressOrTon: string): Promise { + const asset = await findAsset(addressOrTon); + return asset?.decimals ?? 9; +} + +export { toUnits, fromUnits } from "./units.js"; diff --git a/src/ton/dex-constants.ts b/src/ton/dex-constants.ts new file mode 100644 index 00000000..73052b9a --- /dev/null +++ b/src/ton/dex-constants.ts @@ -0,0 +1,25 @@ +/** + * Shared DEX constants (neutral layer, no tool/SDK dependency). + */ + +/** + * STON.fi pTON proxy address β€” the placeholder both STON.fi and DeDust APIs use + * to denote raw TON. Single source; previously duplicated under two names + * (NATIVE_TON_ADDRESS / STONFI_NATIVE_TON) across stonfi/, dedust/ and the SDK. + */ +export const STONFI_PTON_ADDRESS = "EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c"; + +/** DeDust factory contract address on mainnet. */ +export const DEDUST_FACTORY_MAINNET = "EQBfBWT7X2BHg9tXAxzhz2aKiNTU1tpt5NsiK0uSDW_YAJ67"; + +/** Gas amounts (in TON) for DeDust operations. */ +export const DEDUST_GAS = { + // TON to Jetton swap + SWAP_TON_TO_JETTON: "0.25", + // Jetton to any asset swap + SWAP_JETTON_TO_ANY: "0.3", + // Extra gas for multi-hop swaps + MULTIHOP_EXTRA: "0.1", + // Forward gas for jetton transfers + FORWARD_GAS: "0.2", +}; diff --git a/src/ton/transfer.ts b/src/ton/transfer.ts index 35d6b918..0e70e705 100644 --- a/src/ton/transfer.ts +++ b/src/ton/transfer.ts @@ -1,8 +1,9 @@ import { WalletContractV5R1, toNano, internal } from "@ton/ton"; -import { Address, SendMode } from "@ton/core"; -import { getKeyPair, getCachedTonClient, invalidateTonClientCache } from "./wallet-service.js"; +import { Address } from "@ton/core"; +import { getKeyPair, getCachedTonClient } from "./wallet-service.js"; import { createLogger } from "../utils/logger.js"; import { withTxLock } from "./tx-lock.js"; +import { sendWalletTx, type SentTx } from "./confirm.js"; const log = createLogger("TON"); @@ -13,68 +14,54 @@ export interface SendTonParams { bounce?: boolean; } -export async function sendTon(params: SendTonParams): Promise { - return withTxLock(async () => { - try { - const { toAddress, amount, comment = "", bounce = false } = params; - - if (!Number.isFinite(amount) || amount <= 0) { - log.error({ amount }, "Invalid transfer amount"); - return null; - } +export type SendTonResult = SentTx; - let recipientAddress: Address; - try { - recipientAddress = Address.parse(toAddress); - } catch (innerError) { - log.error({ err: innerError }, `Invalid recipient address: ${toAddress}`); - return null; - } - - const keyPair = await getKeyPair(); - if (!keyPair) { - log.error("Wallet not initialized"); - return null; - } - - const wallet = WalletContractV5R1.create({ - workchain: 0, - publicKey: keyPair.publicKey, - }); +/** + * Send TON and return the real, explorer-verifiable transaction once it has committed + * on-chain. Returns null when the transfer cannot be confirmed (invalid params, wallet not + * initialized, or not committed within the finality window) β€” never an optimistic success. + * Serialized via the wallet tx-lock so the seqno read β†’ send β†’ confirm sequence is atomic. + */ +export async function sendTon(params: SendTonParams): Promise { + return withTxLock(async () => { + const { toAddress, amount, comment = "", bounce = false } = params; - const client = await getCachedTonClient(); - const contract = client.open(wallet); + if (!Number.isFinite(amount) || amount <= 0) { + log.error({ amount }, "Invalid transfer amount"); + return null; + } - const seqno = await contract.getSeqno(); + let recipientAddress: Address; + try { + recipientAddress = Address.parse(toAddress); + } catch (error) { + log.error({ err: error }, `Invalid recipient address: ${toAddress}`); + return null; + } - await contract.sendTransfer({ - seqno, - secretKey: keyPair.secretKey, - sendMode: SendMode.PAY_GAS_SEPARATELY, - messages: [ - internal({ - to: recipientAddress, - value: toNano(amount), - body: comment, - bounce, - }), - ], - }); + const keyPair = await getKeyPair(); + if (!keyPair) { + log.error("Wallet not initialized"); + return null; + } - const pseudoHash = `${seqno}_${Date.now()}_${amount.toFixed(2)}`; + const wallet = WalletContractV5R1.create({ workchain: 0, publicKey: keyPair.publicKey }); + const client = await getCachedTonClient(); + const contract = client.open(wallet); - log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`); + const sent = await sendWalletTx(client, contract, { + secretKey: keyPair.secretKey, + messages: [internal({ to: recipientAddress, value: toNano(amount), body: comment, bounce })], + }); - return pseudoHash; - } catch (error: unknown) { - // Invalidate node cache on 429/5xx so next attempt picks a fresh node - const err = error as { status?: number; response?: { status?: number } }; - const status = err?.status || err?.response?.status; - if (status === 429 || (status !== undefined && status >= 500)) { - invalidateTonClientCache(); - } - log.error({ err: error }, "Error sending TON"); - throw error; + if (!sent) { + log.error({ toAddress, amount }, "Transfer not confirmed on-chain within timeout"); + return null; } - }); // withTxLock + + log.info( + `Sent ${amount} TON to ${toAddress.slice(0, 8)}... β€” seqno ${sent.seqno}, tx ${sent.hash.slice(0, 8)}...` + ); + return sent; + }); } diff --git a/src/ton/units.ts b/src/ton/units.ts new file mode 100644 index 00000000..0864dd02 --- /dev/null +++ b/src/ton/units.ts @@ -0,0 +1,21 @@ +/** + * On-chain unit conversion helpers β€” pure, with no asset-cache/DEX dependency. + * + * String-based to avoid floating-point precision loss: an off-by-one on the + * decimals here means lost funds, so keep a single tested definition that any + * layer (SDK, DEX tools, jetton transfers) can import without coupling to DeDust. + */ + +/** Convert a human amount to on-chain integer units (10^decimals). */ +export function toUnits(amount: number, decimals: number): bigint { + const str = amount.toFixed(decimals); + const [whole, frac = ""] = str.split("."); + const padded = frac.padEnd(decimals, "0").slice(0, decimals); + return BigInt(whole + padded); +} + +/** Convert on-chain integer units back to a human amount. */ +export function fromUnits(units: bigint, decimals: number): number { + const factor = 10 ** decimals; + return Number(units) / factor; +} diff --git a/src/ton/wallet-open.ts b/src/ton/wallet-open.ts new file mode 100644 index 00000000..08269de5 --- /dev/null +++ b/src/ton/wallet-open.ts @@ -0,0 +1,36 @@ +import { WalletContractV5R1, type TonClient, type OpenedContract } from "@ton/ton"; +import { getKeyPair, getCachedTonClient } from "./wallet-service.js"; + +type WalletV5R1 = ReturnType; + +/** Opened V5R1 wallet context shared by the on-chain write tools. */ +export interface OpenedWallet { + keyPair: NonNullable>>; + wallet: WalletV5R1; + contract: OpenedContract; +} + +/** + * Provision the agent's V5R1 wallet: derive the key pair, build the contract and + * open it on a TON client. Returns `null` when no key pair can be derived, so each + * tool emits its own standard error result. + * + * Pass an existing `tonClient` (e.g. one already used for a DEX factory/router) to + * open the wallet on the same client; otherwise the cached client is used. + * + * Wallet provisioning ONLY β€” callers keep their own `withTxLock`/seqno handling and + * transactional body unchanged. + */ +export async function openWallet(tonClient?: TonClient): Promise { + const keyPair = await getKeyPair(); + if (!keyPair) return null; + + const wallet = WalletContractV5R1.create({ + workchain: 0, + publicKey: keyPair.publicKey, + }); + const client = tonClient ?? (await getCachedTonClient()); + const contract = client.open(wallet); + + return { keyPair, wallet, contract }; +} diff --git a/src/utils/crypto-tokens.ts b/src/utils/crypto-tokens.ts new file mode 100644 index 00000000..13e9cba4 --- /dev/null +++ b/src/utils/crypto-tokens.ts @@ -0,0 +1,14 @@ +import { createHash } from "node:crypto"; + +/** + * Hash an API key with SHA-256, returning a hex digest. + * + * Shared by the Management API server (key generation) and its auth middleware + * (incoming key verification) so the hashing stays byte-identical in both + * places. The timing-safe comparisons are intentionally NOT mutualized here: + * the WebUI compares raw UTF-8 tokens while the API compares hex digests β€” + * semantically distinct operations kept separate on purpose. + */ +export function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 09773ff0..8c3feba6 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -3,6 +3,16 @@ export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** GramJS errors carry a `.errorMessage`; read it from an unknown catch value. */ +export function getGramJSErrorMessage(err: unknown): string | undefined { + return (err as { errorMessage?: string } | null | undefined)?.errorMessage; +} + +/** Grammy errors carry a `.description`; read it from an unknown catch value. */ +export function getGrammyErrorDescription(err: unknown): string | undefined { + return (err as { description?: string } | null | undefined)?.description; +} + /** Structural type guard for HTTP-style errors (axios, ton-client, etc.) */ export function isHttpError( err: unknown diff --git a/src/utils/http-server.ts b/src/utils/http-server.ts new file mode 100644 index 00000000..053cd9ba --- /dev/null +++ b/src/utils/http-server.ts @@ -0,0 +1,71 @@ +import { serve, type ServerType } from "@hono/node-server"; +import type { Server as HttpServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +type ServeOptions = Parameters[0]; + +export interface StartHonoServerOptions { + /** Hono `app.fetch` callback. */ + fetch: ServeOptions["fetch"]; + /** Port to listen on. */ + port: number; + /** Optional bind hostname (WebUI/Setup pass this). */ + hostname?: string; + /** Optional custom server factory (API passes the HTTPS `createServer`). */ + createServer?: ServeOptions["createServer"]; + /** Optional server options (API passes the TLS cert/key). */ + serverOptions?: ServeOptions["serverOptions"]; + /** Optional cap on concurrent connections (API sets 20; WebUI/Setup leave unset). */ + maxConnections?: number; + /** Invoked once the server is listening, with the resolved address info. */ + onListen?: (info: AddressInfo) => void; +} + +/** + * Start a `@hono/node-server` instance and resolve once it is listening. + * + * Encapsulates the shared lifecycle for the API, WebUI and Setup servers: + * `serve()`, the `'error'` β†’ reject handler, the optional `maxConnections` + * cap, and the `onListen` callback. Divergent options (HTTPS `createServer` + + * `serverOptions` for the API, `hostname` for WebUI/Setup, `maxConnections` + * only for the API) stay as explicit parameters so behavior is unchanged. + */ +export function startHonoServer(options: StartHonoServerOptions): Promise { + return new Promise((resolve, reject) => { + try { + const server = serve( + { + fetch: options.fetch, + port: options.port, + ...(options.hostname ? { hostname: options.hostname } : {}), + ...(options.createServer ? { createServer: options.createServer } : {}), + ...(options.serverOptions ? { serverOptions: options.serverOptions } : {}), + } as ServeOptions, + (info) => { + if (options.maxConnections !== undefined) { + (server as HttpServer).maxConnections = options.maxConnections; + } + options.onListen?.(info); + resolve(server); + } + ); + + (server as HttpServer).on("error", (err: Error) => { + reject(err); + }); + } catch (error) { + reject(error); + } + }); +} + +/** + * Gracefully stop a `@hono/node-server` instance: force-close keep-alive + * connections (so we don't wait ~30s for them to drain) then close the server. + */ +export function stopHonoServer(server: ServerType): Promise { + return new Promise((resolve) => { + (server as HttpServer).closeAllConnections(); + (server as HttpServer).close(() => resolve()); + }); +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 00000000..41250445 --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,19 @@ +/** + * Minimal JWT helpers β€” no signature verification, only claim extraction. + */ + +/** + * Extract the expiry of a JWT from its `exp` claim. + * Returns the expiry as a Unix timestamp in milliseconds, or 0 when the token + * is malformed or carries no `exp` claim. + */ +export function extractJwtExpiry(token: string): number { + try { + const parts = token.split("."); + if (parts.length !== 3) return 0; + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); + return (payload.exp ?? 0) * 1000; // Convert seconds β†’ ms + } catch { + return 0; + } +} diff --git a/src/utils/mask.ts b/src/utils/mask.ts new file mode 100644 index 00000000..519729e6 --- /dev/null +++ b/src/utils/mask.ts @@ -0,0 +1,10 @@ +/** + * Mask a secret for display: keep `prefix` leading and `suffix` trailing chars + * with "..." between, or "***" when the value is too short to mask meaningfully. + * Single source for the CLI key-masking formula. + */ +export function maskSecret(value: string, prefix = 6, suffix = 4): string { + return value.length > prefix + suffix + ? value.slice(0, prefix) + "..." + value.slice(-suffix) + : "***"; +} diff --git a/src/utils/pi-message.ts b/src/utils/pi-message.ts new file mode 100644 index 00000000..c33991c0 --- /dev/null +++ b/src/utils/pi-message.ts @@ -0,0 +1,51 @@ +import type { Message, TextContent, ToolCall } from "@mariozechner/pi-ai"; + +/** + * Neutral, pure primitives for reading pi-ai `Message` content. Shared by the + * agent (runtime-utils) and memory (ai-summarization, compaction) layers so that + * message-content access and envelope parsing live in one place. Caller-specific + * formatting (prefixes, placeholder labels, truncation lengths) stays local. + */ + +/** + * Concatenate the textual content of a message into a single string. + * - user: returns string content as-is, or joins the text blocks of array content. + * - assistant: joins its text blocks with newlines. + * - other roles: "". + */ +export function extractText(msg: Message): string { + if (msg.role === "user") { + if (typeof msg.content === "string") return msg.content; + return msg.content + .filter((b): b is TextContent => b.type === "text") + .map((b) => b.text) + .join("\n"); + } + if (msg.role === "assistant") { + return msg.content + .filter((b): b is TextContent => b.type === "text") + .map((b) => b.text) + .join("\n"); + } + return ""; +} + +/** Names of the tool calls in an assistant message, in order. Empty for other roles. */ +export function extractToolNames(msg: Message): string[] { + if (msg.role !== "assistant") return []; + return msg.content.filter((b): b is ToolCall => b.type === "toolCall").map((b) => b.name); +} + +/** + * Strip a leading envelope prefix such as "[2026-01-01 user] " by returning the + * text after the first "] ". Returns the input unchanged when no envelope is present. + */ +export function stripEnvelopePrefix(content: string): string { + const match = content.match(/\] (.+)/s); + return match ? match[1] : content; +} + +/** Truncate to `n` characters, appending "..." only when the string was longer. */ +export function truncate(s: string, n: number): string { + return s.length > n ? s.substring(0, n) + "..." : s; +} diff --git a/src/webui/__tests__/validate-step.test.ts b/src/webui/__tests__/validate-step.test.ts deleted file mode 100644 index ffe6288e..00000000 --- a/src/webui/__tests__/validate-step.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { validateStep, type WizardData } from "../validate-step.js"; - -function makeData(overrides: Partial = {}): WizardData { - return { - riskAccepted: false, - agentName: "Nova", - provider: "", - apiKey: "", - cocoonPort: 11435, - localUrl: "http://localhost:11434/v1", - apiId: 0, - apiHash: "", - phone: "", - userId: 0, - mode: "quick", - model: "", - customModel: "", - dmPolicy: "open", - groupPolicy: "allowlist", - requireMention: true, - maxIterations: 5, - botToken: "", - botUsername: "", - tonapiKey: "", - tavilyKey: "", - customizeThresholds: false, - buyMaxFloor: 95, - sellMinFloor: 105, - walletAction: "generate", - mnemonic: "", - walletAddress: "", - mnemonicSaved: false, - authSessionId: "", - telegramUser: null, - skipConnect: false, - webuiEnabled: false, - ...overrides, - }; -} - -describe("validateStep", () => { - // ── Step 0: Welcome ────────────────────────────────────────────── - describe("step 0 β€” Welcome", () => { - it("returns true when riskAccepted is true", () => { - expect(validateStep(0, makeData({ riskAccepted: true }))).toBe(true); - }); - - it("returns false when riskAccepted is false", () => { - expect(validateStep(0, makeData({ riskAccepted: false }))).toBe(false); - }); - }); - - // ── Step 1: Provider ───────────────────────────────────────────── - describe("step 1 β€” Provider", () => { - it("returns true with a provider and apiKey", () => { - expect(validateStep(1, makeData({ provider: "anthropic", apiKey: "sk-abc" }))).toBe(true); - }); - - it("returns false when provider is empty", () => { - expect(validateStep(1, makeData({ provider: "", apiKey: "sk-abc" }))).toBe(false); - }); - - it("returns false when apiKey is empty for standard provider", () => { - expect(validateStep(1, makeData({ provider: "anthropic", apiKey: "" }))).toBe(false); - }); - - it("returns true for cocoon with valid port", () => { - expect(validateStep(1, makeData({ provider: "cocoon", cocoonPort: 3000 }))).toBe(true); - }); - - it("returns true for cocoon with port 1 (lower bound)", () => { - expect(validateStep(1, makeData({ provider: "cocoon", cocoonPort: 1 }))).toBe(true); - }); - - it("returns true for cocoon with port 65535 (upper bound)", () => { - expect(validateStep(1, makeData({ provider: "cocoon", cocoonPort: 65535 }))).toBe(true); - }); - - it("returns false for cocoon with port 0", () => { - expect(validateStep(1, makeData({ provider: "cocoon", cocoonPort: 0 }))).toBe(false); - }); - - it("returns false for cocoon with port exceeding 65535", () => { - expect(validateStep(1, makeData({ provider: "cocoon", cocoonPort: 70000 }))).toBe(false); - }); - - it("returns true for local with valid URL", () => { - expect( - validateStep(1, makeData({ provider: "local", localUrl: "http://localhost:11434/v1" })) - ).toBe(true); - }); - - it("returns false for local with invalid URL", () => { - expect(validateStep(1, makeData({ provider: "local", localUrl: "not-a-url" }))).toBe(false); - }); - - it("returns true for local with empty localUrl (default is valid)", () => { - // Default localUrl is http://localhost:11434/v1 which is valid - expect(validateStep(1, makeData({ provider: "local" }))).toBe(true); - }); - }); - - // ── Step 2: Config ─────────────────────────────────────────────── - describe("step 2 β€” Config", () => { - it("returns true with a model and userId", () => { - expect( - validateStep(2, makeData({ provider: "anthropic", model: "claude-sonnet", userId: 123 })) - ).toBe(true); - }); - - it("returns false with no model for standard provider", () => { - expect(validateStep(2, makeData({ provider: "anthropic", model: "", userId: 123 }))).toBe( - false - ); - }); - - it("returns true with __custom__ and customModel set", () => { - expect( - validateStep( - 2, - makeData({ - provider: "anthropic", - model: "__custom__", - customModel: "my-model", - userId: 123, - }) - ) - ).toBe(true); - }); - - it("returns false with __custom__ but empty customModel", () => { - expect( - validateStep( - 2, - makeData({ provider: "anthropic", model: "__custom__", customModel: "", userId: 123 }) - ) - ).toBe(false); - }); - - it("returns true for cocoon without model (skips model check)", () => { - expect(validateStep(2, makeData({ provider: "cocoon", model: "", userId: 123 }))).toBe(true); - }); - - it("returns true for local without model (skips model check)", () => { - expect(validateStep(2, makeData({ provider: "local", model: "", userId: 123 }))).toBe(true); - }); - - it("returns false when userId is 0", () => { - expect(validateStep(2, makeData({ provider: "cocoon", userId: 0 }))).toBe(false); - }); - - it("returns false when maxIterations is 0", () => { - expect(validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 0 }))).toBe( - false - ); - }); - - it("returns false when maxIterations exceeds 50", () => { - expect( - validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 51 })) - ).toBe(false); - }); - - it("returns true when maxIterations is 1 (lower bound)", () => { - expect(validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 1 }))).toBe( - true - ); - }); - - it("returns true when maxIterations is 50 (upper bound)", () => { - expect( - validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 50 })) - ).toBe(true); - }); - }); - - // ── Step 3: Wallet ─────────────────────────────────────────────── - describe("step 3 β€” Wallet", () => { - it("returns true when walletAction is keep", () => { - expect(validateStep(3, makeData({ walletAction: "keep" }))).toBe(true); - }); - - it("returns false when no walletAddress after generate", () => { - expect(validateStep(3, makeData({ walletAction: "generate", walletAddress: "" }))).toBe( - false - ); - }); - - it("returns false when walletAddress set but mnemonicSaved is false", () => { - expect( - validateStep( - 3, - makeData({ walletAction: "generate", walletAddress: "EQ...", mnemonicSaved: false }) - ) - ).toBe(false); - }); - - it("returns true when walletAddress set and mnemonicSaved is true", () => { - expect( - validateStep( - 3, - makeData({ walletAction: "generate", walletAddress: "EQ...", mnemonicSaved: true }) - ) - ).toBe(true); - }); - - it("returns true for import with address and mnemonicSaved", () => { - expect( - validateStep( - 3, - makeData({ walletAction: "import", walletAddress: "EQ...", mnemonicSaved: true }) - ) - ).toBe(true); - }); - }); - - // ── Step 4: Telegram ───────────────────────────────────────────── - describe("step 4 β€” Telegram", () => { - const validTelegram = { - apiId: 123456, - apiHash: "abcdef1234", - phone: "+33612345678", - }; - - it("returns true with all valid Telegram fields", () => { - expect(validateStep(4, makeData(validTelegram))).toBe(true); - }); - - it("returns false when apiId is 0", () => { - expect(validateStep(4, makeData({ ...validTelegram, apiId: 0 }))).toBe(false); - }); - - it("returns false when apiHash is too short", () => { - expect(validateStep(4, makeData({ ...validTelegram, apiHash: "short" }))).toBe(false); - }); - - it("returns true when apiHash is exactly 10 characters", () => { - expect(validateStep(4, makeData({ ...validTelegram, apiHash: "1234567890" }))).toBe(true); - }); - - it("returns false when phone does not start with +", () => { - expect(validateStep(4, makeData({ ...validTelegram, phone: "33612345678" }))).toBe(false); - }); - - it("returns true for +888 anonymous number", () => { - expect(validateStep(4, makeData({ ...validTelegram, phone: "+88812345678" }))).toBe(true); - }); - }); - - // ── Step 5: Connect ────────────────────────────────────────────── - describe("step 5 β€” Connect", () => { - it("returns true when telegramUser is set", () => { - expect( - validateStep( - 5, - makeData({ telegramUser: { id: 1, firstName: "Alice", username: "alice" } }) - ) - ).toBe(true); - }); - - it("returns true when skipConnect is true", () => { - expect(validateStep(5, makeData({ skipConnect: true }))).toBe(true); - }); - - it("returns false when telegramUser is null and skipConnect is false", () => { - expect(validateStep(5, makeData({ telegramUser: null, skipConnect: false }))).toBe(false); - }); - }); - - // ── Default: unknown step ──────────────────────────────────────── - describe("unknown step", () => { - it("returns false for step 99", () => { - expect(validateStep(99, makeData())).toBe(false); - }); - - it("returns false for negative step", () => { - expect(validateStep(-1, makeData())).toBe(false); - }); - }); -}); diff --git a/src/webui/http-common.ts b/src/webui/http-common.ts new file mode 100644 index 00000000..7510b2bf --- /dev/null +++ b/src/webui/http-common.ts @@ -0,0 +1,39 @@ +import type { Hono } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import { bodyLimit } from "hono/body-limit"; + +/** Shared body-size limit for all HTTP servers (2 MB). */ +const BODY_LIMIT_BYTES = 2 * 1024 * 1024; + +/** + * Register security response headers on every route. The base headers + * (X-Content-Type-Options, X-Frame-Options) are always applied; HSTS and + * Referrer-Policy are opt-in so each server keeps its own divergent policy + * (HSTS for the HTTPS-only API, Referrer-Policy for the WebUI/Setup servers). + */ +export function applySecurityMiddleware( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono generic varies per server + app: Hono, + options: { hsts?: boolean; referrerPolicy?: string } = {} +): void { + app.use("*", async (c, next) => { + await next(); + c.res.headers.set("X-Content-Type-Options", "nosniff"); + c.res.headers.set("X-Frame-Options", "DENY"); + if (options.hsts) { + c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + if (options.referrerPolicy) { + c.res.headers.set("Referrer-Policy", options.referrerPolicy); + } + }); +} + +/** + * Body-limit middleware shared by all HTTP servers. The over-limit error + * envelope differs per server (problem+json for the API, `{success:false}` for + * WebUI/Setup), so the `onError` handler is passed in by the caller. + */ +export function sharedBodyLimit(onError: (c: Context) => Response): MiddlewareHandler { + return bodyLimit({ maxSize: BODY_LIMIT_BYTES, onError }); +} diff --git a/src/webui/lifecycle-sse.ts b/src/webui/lifecycle-sse.ts new file mode 100644 index 00000000..7db63439 --- /dev/null +++ b/src/webui/lifecycle-sse.ts @@ -0,0 +1,58 @@ +import { streamSSE } from "hono/streaming"; +import type { Context } from "hono"; +import type { AgentLifecycle, StateChangeEvent } from "../agent/lifecycle.js"; + +/** + * SSE stream of agent lifecycle state changes. Shared by the WebUI and API + * servers (each keeps its own 503 guard for the missing-lifecycle case, with its + * own error envelope, before calling this). + */ +export function createLifecycleSSE(c: Context, lifecycle: AgentLifecycle) { + return streamSSE(c, async (stream) => { + let aborted = false; + + stream.onAbort(() => { + aborted = true; + }); + + // Push current state immediately on connection + const now = Date.now(); + await stream.writeSSE({ + event: "status", + id: String(now), + data: JSON.stringify({ + state: lifecycle.getState(), + error: lifecycle.getError() ?? null, + timestamp: now, + }), + retry: 3000, + }); + + const onStateChange = (event: StateChangeEvent) => { + if (aborted) return; + void stream.writeSSE({ + event: "status", + id: String(event.timestamp), + data: JSON.stringify({ + state: event.state, + error: event.error ?? null, + timestamp: event.timestamp, + }), + }); + }; + + lifecycle.on("stateChange", onStateChange); + + // Heartbeat loop + keep connection alive + while (!aborted) { + await stream.sleep(30_000); + if (aborted) break; + await stream.writeSSE({ + event: "ping", + data: "", + }); + } + + lifecycle.off("stateChange", onStateChange); + }); +} diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts index 25a914b3..8d92aa7c 100644 --- a/src/webui/routes/config.ts +++ b/src/webui/routes/config.ts @@ -335,7 +335,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) { const provider = c.req.param("provider"); try { const meta = getProviderMetadata(provider as SupportedProvider); - const needsKey = provider !== "claude-code" && provider !== "cocoon" && provider !== "local"; + const needsKey = provider !== "cocoon" && provider !== "local"; return c.json({ success: true, data: { diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts index eb465eee..41932985 100644 --- a/src/webui/routes/setup.ts +++ b/src/webui/routes/setup.ts @@ -16,10 +16,6 @@ import { validateApiKeyFormat, type SupportedProvider, } from "../../config/providers.js"; -import { - getClaudeCodeApiKey, - isClaudeCodeTokenValid, -} from "../../providers/claude-code-credentials.js"; import { ConfigSchema, DealsConfigSchema } from "../../config/schema.js"; import { ensureWorkspace, isNewWorkspace } from "../../workspace/manager.js"; import { TELETON_ROOT } from "../../workspace/paths.js"; @@ -96,8 +92,7 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { toolLimit: p.toolLimit, keyPrefix: p.keyPrefix, consoleUrl: p.consoleUrl, - requiresApiKey: p.id !== "cocoon" && p.id !== "local" && p.id !== "claude-code", - autoDetectsKey: p.id === "claude-code", + requiresApiKey: p.id !== "cocoon" && p.id !== "local", requiresBaseUrl: p.id === "local", })); return c.json({ success: true, data: providers }); @@ -119,27 +114,6 @@ export function createSetupRoutes(options?: { keyHash?: string }): Hono { return c.json({ success: true, data: result }); }); - // ── GET /detect-claude-code-key ─────────────────────────────────── - app.get("/detect-claude-code-key", (c) => { - try { - const key = getClaudeCodeApiKey(); - const masked = maskKey(key); - return c.json({ - success: true, - data: { - found: true, - maskedKey: masked, - valid: isClaudeCodeTokenValid(), - }, - }); - } catch { - return c.json({ - success: true, - data: { found: false, maskedKey: null, valid: false }, - }); - } - }); - // ── POST /validate/api-key ──────────────────────────────────────── app.post("/validate/api-key", async (c) => { try { diff --git a/src/webui/routes/shared.ts b/src/webui/routes/shared.ts new file mode 100644 index 00000000..58bbde12 --- /dev/null +++ b/src/webui/routes/shared.ts @@ -0,0 +1,45 @@ +import type { Hono } from "hono"; +import type { WebUIServerDeps } from "../types.js"; + +import { createStatusRoutes } from "./status.js"; +import { createToolsRoutes } from "./tools.js"; +import { createLogsRoutes } from "./logs.js"; +import { createMemoryRoutes } from "./memory.js"; +import { createSoulRoutes } from "./soul.js"; +import { createPluginsRoutes } from "./plugins.js"; +import { createMcpRoutes } from "./mcp.js"; +import { createWorkspaceRoutes } from "./workspace.js"; +import { createTasksRoutes } from "./tasks.js"; +import { createConfigRoutes } from "./config.js"; +import { createMarketplaceRoutes } from "./marketplace.js"; +import { createHooksRoutes } from "./hooks.js"; +import { createTonProxyRoutes } from "./ton-proxy.js"; + +/** A route factory shared by the WebUI and Management API servers. */ +export type RouteFactory = (deps: WebUIServerDeps) => Hono; + +/** + * Route factories common to both the WebUI server (mounted under `/api/*`) + * and the Management API server (mounted under `/v1/*`). Listing them once + * here keeps the two mount sites in sync β€” adding or removing a shared route + * is a single edit instead of two. + * + * Server-specific routes (WebUI: conversations, wallet, agent/mode; API: + * agent, system, auth, api-logs, api-memory, setup) stay mounted explicitly + * in each server. + */ +export const SHARED_ROUTE_FACTORIES: ReadonlyArray<[string, RouteFactory]> = [ + ["status", createStatusRoutes], + ["tools", createToolsRoutes], + ["logs", createLogsRoutes], + ["memory", createMemoryRoutes], + ["soul", createSoulRoutes], + ["plugins", createPluginsRoutes], + ["mcp", createMcpRoutes], + ["workspace", createWorkspaceRoutes], + ["tasks", createTasksRoutes], + ["config", createConfigRoutes], + ["marketplace", createMarketplaceRoutes], + ["hooks", createHooksRoutes], + ["ton-proxy", createTonProxyRoutes], +]; diff --git a/src/webui/routes/tools.ts b/src/webui/routes/tools.ts index fed02ba0..d5ae9ff4 100644 --- a/src/webui/routes/tools.ts +++ b/src/webui/routes/tools.ts @@ -1,45 +1,61 @@ import { Hono } from "hono"; import type { WebUIServerDeps, ToolInfo, ModuleInfo, APIResponse } from "../types.js"; import type { ToolScope } from "../../agent/tools/types.js"; +import { + scopeToLevel, + levelToScope, + isToolAccessLevel, + type ToolAccessLevel, +} from "../../agent/tools/scope.js"; import { getErrorMessage } from "../../utils/errors.js"; import { readRawConfig, setNestedValue, writeRawConfig } from "../../config/configurable-keys.js"; +const VALID_SCOPES: readonly ToolScope[] = [ + "always", + "dm-only", + "group-only", + "admin-only", + "open", + "allowlist", + "disabled", +]; + export function createToolsRoutes(deps: WebUIServerDeps) { const app = new Hono(); + // Build the API view of a single tool from its effective access level. + const buildToolInfo = (toolName: string, moduleName: string): ToolInfo | null => { + const tool = deps.toolRegistry.getAll().find((t) => t.name === toolName); + if (!tool) return null; + const level = deps.toolRegistry.getToolConfig(toolName)?.level ?? "all"; + return { + name: tool.name, + description: tool.description || "", + module: moduleName, + level, + category: deps.toolRegistry.getToolCategory(tool.name), + scope: levelToScope(level), + enabled: level !== "off", + }; + }; + + const buildModuleTools = (moduleName: string): ToolInfo[] => + deps.toolRegistry + .getModuleTools(moduleName) + .map((entry) => buildToolInfo(entry.name, moduleName)) + .filter((t): t is ToolInfo => t !== null); + // Get all tools grouped by module app.get("/", (c) => { try { - const allTools = deps.toolRegistry.getAll(); const modules = deps.toolRegistry.getAvailableModules(); - // Create a map of tool name to tool definition for fast lookup - const toolMap = new Map(allTools.map((t) => [t.name, t])); - const moduleData: ModuleInfo[] = modules.map((moduleName) => { - const moduleToolNames = deps.toolRegistry.getModuleTools(moduleName); - - const toolsInfo: ToolInfo[] = moduleToolNames - .map((toolEntry) => { - const tool = toolMap.get(toolEntry.name); - if (!tool) return null; - - const config = deps.toolRegistry.getToolConfig(toolEntry.name); - return { - name: tool.name, - description: tool.description || "", - module: moduleName, - scope: config?.scope ?? toolEntry.scope, - category: deps.toolRegistry.getToolCategory(tool.name), - enabled: config?.enabled ?? true, - } as ToolInfo; - }) - .filter((t) => t !== null) as ToolInfo[]; - + const tools = buildModuleTools(moduleName); return { name: moduleName, - toolCount: moduleToolNames.length, - tools: toolsInfo, + toolCount: tools.length, + tools, isPlugin: deps.toolRegistry.isPluginModule(moduleName), }; }); @@ -152,75 +168,65 @@ export function createToolsRoutes(deps: WebUIServerDeps) { // ── Per-tool routes (wildcard) ───────────────────────────────────── - // Update tool configuration + // Update tool configuration. Accepts the access-level model { level } and, for + // backward compatibility, the legacy { enabled?, scope? } shape. app.put("/:name", async (c) => { try { const toolName = c.req.param("name"); - const body = await c.req.json(); + const body = (await c.req.json()) as { + enabled?: boolean; + scope?: string; + level?: string; + }; if (!deps.toolRegistry.has(toolName)) { - const response: APIResponse = { - success: false, - error: `Tool "${toolName}" not found`, - }; - return c.json(response, 404); + return c.json({ success: false, error: `Tool "${toolName}" not found` }, 404); } - const { enabled, scope } = body as { enabled?: boolean; scope?: string }; - - // Validate scope against whitelist - const VALID_SCOPES = [ - "always", - "dm-only", - "group-only", - "admin-only", - "open", - "allowlist", - "disabled", - ] as const; - if (scope !== undefined && !(VALID_SCOPES as readonly string[]).includes(scope)) { - const response: APIResponse = { - success: false, - error: `Invalid scope "${scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, - }; - return c.json(response, 400); + // Validate provided values up front. + if (body.scope !== undefined && !(VALID_SCOPES as readonly string[]).includes(body.scope)) { + return c.json( + { + success: false, + error: `Invalid scope "${body.scope}". Must be one of: ${VALID_SCOPES.join(", ")}`, + }, + 400 + ); } - - // Update enabled status if provided - if (enabled !== undefined) { - const success = deps.toolRegistry.setToolEnabled(toolName, enabled); - if (!success) { - const response: APIResponse = { + if (body.level !== undefined && !isToolAccessLevel(body.level)) { + return c.json( + { success: false, - error: "Failed to update tool enabled status", - }; - return c.json(response, 500); - } + error: `Invalid level "${body.level}". Must be one of: all, allowlist, admin, off`, + }, + 400 + ); } - // Update scope if provided - if (scope !== undefined) { - const success = deps.toolRegistry.updateToolScope(toolName, scope as ToolScope); - if (!success) { - const response: APIResponse = { - success: false, - error: "Failed to update tool scope", - }; - return c.json(response, 500); - } + // Resolve the next level, layering each provided field. + let next: ToolAccessLevel = deps.toolRegistry.getToolConfig(toolName)?.level ?? "all"; + if (body.scope !== undefined) next = scopeToLevel(body.scope as ToolScope); + if (body.enabled === false) next = "off"; + else if (body.enabled === true && next === "off") { + next = "all"; } + if (isToolAccessLevel(body.level)) next = body.level; - const config = deps.toolRegistry.getToolConfig(toolName); - const response: APIResponse = { + const ok = deps.toolRegistry.updateToolLevel(toolName, next); + if (!ok) { + return c.json({ success: false, error: "Failed to update tool access" }, 500); + } + + const level = deps.toolRegistry.getToolConfig(toolName)?.level ?? next; + return c.json({ success: true, data: { tool: toolName, - enabled: config?.enabled ?? true, - scope: config?.scope ?? "always", + level, + scope: levelToScope(level), + enabled: level !== "off", }, - }; - - return c.json(response); + }); } catch (error) { const response: APIResponse = { success: false, @@ -236,24 +242,19 @@ export function createToolsRoutes(deps: WebUIServerDeps) { const toolName = c.req.param("name"); if (!deps.toolRegistry.has(toolName)) { - const response: APIResponse = { - success: false, - error: `Tool "${toolName}" not found`, - }; - return c.json(response, 404); + return c.json({ success: false, error: `Tool "${toolName}" not found` }, 404); } - const config = deps.toolRegistry.getToolConfig(toolName); - const response: APIResponse = { + const level = deps.toolRegistry.getToolConfig(toolName)?.level ?? "all"; + return c.json({ success: true, data: { tool: toolName, - enabled: config?.enabled ?? true, - scope: config?.scope ?? "always", + level, + scope: levelToScope(level), + enabled: level !== "off", }, - }; - - return c.json(response); + }); } catch (error) { const response: APIResponse = { success: false, @@ -267,33 +268,10 @@ export function createToolsRoutes(deps: WebUIServerDeps) { app.get("/:module", (c) => { try { const moduleName = c.req.param("module"); - const allTools = deps.toolRegistry.getAll(); - const toolMap = new Map(allTools.map((t) => [t.name, t])); - - const moduleToolNames = deps.toolRegistry.getModuleTools(moduleName); - - const toolsInfo: ToolInfo[] = moduleToolNames - .map((toolEntry) => { - const tool = toolMap.get(toolEntry.name); - if (!tool) return null; - - const config = deps.toolRegistry.getToolConfig(toolEntry.name); - return { - name: tool.name, - description: tool.description || "", - module: moduleName, - scope: config?.scope ?? toolEntry.scope, - category: deps.toolRegistry.getToolCategory(tool.name), - enabled: config?.enabled ?? true, - } as ToolInfo; - }) - .filter((t) => t !== null) as ToolInfo[]; - const response: APIResponse = { success: true, - data: toolsInfo, + data: buildModuleTools(moduleName), }; - return c.json(response); } catch (error) { const response: APIResponse = { diff --git a/src/webui/server.ts b/src/webui/server.ts index e3714d1b..95893287 100644 --- a/src/webui/server.ts +++ b/src/webui/server.ts @@ -1,16 +1,12 @@ import { Hono } from "hono"; -import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; import { cors } from "hono/cors"; -import { streamSSE } from "hono/streaming"; -import { bodyLimit } from "hono/body-limit"; import { setCookie, getCookie, deleteCookie } from "hono/cookie"; -import type { Server as HttpServer } from "node:http"; -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { join, dirname, resolve, relative } from "node:path"; -import { fileURLToPath } from "node:url"; import type { WebUIServerDeps } from "./types.js"; -import type { StateChangeEvent } from "../agent/lifecycle.js"; +import { createLifecycleSSE } from "./lifecycle-sse.js"; +import { applySecurityMiddleware, sharedBodyLimit } from "./http-common.js"; +import { findWebDist, createStaticHandler } from "./static-serving.js"; +import { startHonoServer, stopHonoServer } from "../utils/http-server.js"; import { createLogger } from "../utils/logger.js"; const log = createLogger("WebUI"); @@ -22,47 +18,15 @@ import { COOKIE_MAX_AGE, } from "./middleware/auth.js"; import { logInterceptor } from "./log-interceptor.js"; -import { createStatusRoutes } from "./routes/status.js"; -import { createToolsRoutes } from "./routes/tools.js"; -import { createLogsRoutes } from "./routes/logs.js"; -import { createMemoryRoutes } from "./routes/memory.js"; -import { createSoulRoutes } from "./routes/soul.js"; -import { createPluginsRoutes } from "./routes/plugins.js"; -import { createMcpRoutes } from "./routes/mcp.js"; -import { createWorkspaceRoutes } from "./routes/workspace.js"; -import { createTasksRoutes } from "./routes/tasks.js"; -import { createConfigRoutes } from "./routes/config.js"; -import { createMarketplaceRoutes } from "./routes/marketplace.js"; -import { createHooksRoutes } from "./routes/hooks.js"; -import { createTonProxyRoutes } from "./routes/ton-proxy.js"; +import { SHARED_ROUTE_FACTORIES } from "./routes/shared.js"; +import { createAgentRoutes } from "../api/routes/agent.js"; import { createConversationRoutes } from "./routes/conversations.js"; import { createWalletRoutes } from "./routes/wallet.js"; import { readRawConfig, writeRawConfig } from "../config/configurable-keys.js"; -function findWebDist(): string | null { - // Try common locations relative to CWD (where teleton is launched from) - const candidates = [ - resolve("dist/web"), // npm start / teleton start (from project root) - resolve("web"), // fallback - ]; - // Also try relative to the compiled file - const __dirname = dirname(fileURLToPath(import.meta.url)); - candidates.push( - resolve(__dirname, "web"), // dist/web when __dirname = dist/ - resolve(__dirname, "../dist/web") // when running with tsx from src/ - ); - - for (const candidate of candidates) { - if (existsSync(join(candidate, "index.html"))) { - return candidate; - } - } - return null; -} - export class WebUIServer { private app: Hono; - private server: ReturnType | null = null; + private server: ServerType | null = null; private deps: WebUIServerDeps; private authToken: string; @@ -115,19 +79,13 @@ export class WebUIServer { // Body size limit (defense-in-depth against oversized payloads) this.app.use( "*", - bodyLimit({ - maxSize: 2 * 1024 * 1024, // 2MB - onError: (c) => c.json({ success: false, error: "Request body too large (max 2MB)" }, 413), - }) + sharedBodyLimit((c) => + c.json({ success: false, error: "Request body too large (max 2MB)" }, 413) + ) ); // Security headers for all responses - this.app.use("*", async (c, next) => { - await next(); - c.res.headers.set("X-Content-Type-Options", "nosniff"); - c.res.headers.set("X-Frame-Options", "DENY"); - c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - }); + applySecurityMiddleware(this.app, { referrerPolicy: "strict-origin-when-cross-origin" }); // Auth for all /api/* routes // Accepts: HttpOnly cookie > Bearer header > ?token= query param (fallback) @@ -203,72 +161,19 @@ export class WebUIServer { }); // API routes (all require auth via middleware above) - this.app.route("/api/status", createStatusRoutes(this.deps)); - this.app.route("/api/tools", createToolsRoutes(this.deps)); - this.app.route("/api/logs", createLogsRoutes(this.deps)); - this.app.route("/api/memory", createMemoryRoutes(this.deps)); - this.app.route("/api/soul", createSoulRoutes(this.deps)); - this.app.route("/api/plugins", createPluginsRoutes(this.deps)); - this.app.route("/api/mcp", createMcpRoutes(this.deps)); - this.app.route("/api/workspace", createWorkspaceRoutes(this.deps)); - this.app.route("/api/tasks", createTasksRoutes(this.deps)); - this.app.route("/api/config", createConfigRoutes(this.deps)); - this.app.route("/api/marketplace", createMarketplaceRoutes(this.deps)); - this.app.route("/api/hooks", createHooksRoutes(this.deps)); - this.app.route("/api/ton-proxy", createTonProxyRoutes(this.deps)); + for (const [seg, make] of SHARED_ROUTE_FACTORIES) { + this.app.route(`/api/${seg}`, make(this.deps)); + } this.app.route("/api/conversations", createConversationRoutes(this.deps)); this.app.route("/api/wallet", createWalletRoutes(this.deps)); - // Agent lifecycle routes - this.app.post("/api/agent/start", async (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json({ error: "Agent lifecycle not available" }, 503); - } - const state = lifecycle.getState(); - if (state === "running") { - return c.json({ state: "running" }, 409); - } - if (state === "stopping") { - return c.json({ error: "Agent is currently stopping, please wait" }, 409); - } - // Fire-and-forget: start is async, we return immediately - lifecycle.start().catch((err: Error) => { - log.error({ err }, "Agent start failed"); - }); - return c.json({ state: "starting" }); - }); - - this.app.post("/api/agent/stop", async (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json({ error: "Agent lifecycle not available" }, 503); - } - const state = lifecycle.getState(); - if (state === "stopped") { - return c.json({ state: "stopped" }, 409); - } - if (state === "starting") { - return c.json({ error: "Agent is currently starting, please wait" }, 409); - } - // Fire-and-forget: stop is async, we return immediately - lifecycle.stop().catch((err: Error) => { - log.error({ err }, "Agent stop failed"); - }); - return c.json({ state: "stopping" }); - }); - - this.app.get("/api/agent/status", (c) => { - const lifecycle = this.deps.lifecycle; - if (!lifecycle) { - return c.json({ error: "Agent lifecycle not available" }, 503); - } - return c.json({ - state: lifecycle.getState(), - uptime: lifecycle.getUptime(), - error: lifecycle.getError() ?? null, - }); - }); + // Agent lifecycle routes (start/stop/status/restart) with WebUI error envelope + this.app.route( + "/api/agent", + createAgentRoutes(this.deps.lifecycle, { + errorResponse: (c, status, _title, detail) => c.json({ error: detail }, status as 503), + }) + ); this.app.get("/api/agent/mode", (c) => { const raw = readRawConfig(this.deps.configPath); @@ -360,103 +265,13 @@ export class WebUIServer { return c.json({ error: "Agent lifecycle not available" }, 503); } - return streamSSE(c, async (stream) => { - let aborted = false; - - stream.onAbort(() => { - aborted = true; - }); - - // Push current state immediately on connection - const now = Date.now(); - await stream.writeSSE({ - event: "status", - id: String(now), - data: JSON.stringify({ - state: lifecycle.getState(), - error: lifecycle.getError() ?? null, - timestamp: now, - }), - retry: 3000, - }); - - // Listen for state changes - const onStateChange = (event: StateChangeEvent) => { - if (aborted) return; - void stream.writeSSE({ - event: "status", - id: String(event.timestamp), - data: JSON.stringify({ - state: event.state, - error: event.error ?? null, - timestamp: event.timestamp, - }), - }); - }; - - lifecycle.on("stateChange", onStateChange); - - // Heartbeat loop + keep connection alive - while (!aborted) { - await stream.sleep(30_000); - if (aborted) break; - await stream.writeSSE({ - event: "ping", - data: "", - }); - } - - lifecycle.off("stateChange", onStateChange); - }); + return createLifecycleSSE(c, lifecycle); }); - // Serve static files in production (if built) + // Serve static files in production (if built) with SPA fallback const webDist = findWebDist(); if (webDist) { - const indexHtml = readFile(join(webDist, "index.html"), "utf-8"); - - const mimeTypes: Record = { - js: "application/javascript", - css: "text/css", - svg: "image/svg+xml", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - ico: "image/x-icon", - json: "application/json", - woff2: "font/woff2", - woff: "font/woff", - }; - - // Serve static files (assets, images, etc.) with SPA fallback - this.app.get("*", async (c) => { - const filePath = resolve(join(webDist, c.req.path)); - // Prevent path traversal β€” resolved path must stay inside webDist - const rel = relative(webDist, filePath); - if (rel.startsWith("..") || resolve(filePath) !== filePath) { - return c.html(await indexHtml); - } - - // Try serving the actual file - try { - const content = await readFile(filePath); - const ext = filePath.split(".").pop() || ""; - if (mimeTypes[ext]) { - const immutable = c.req.path.startsWith("/assets/"); - return c.body(content, 200, { - "Content-Type": mimeTypes[ext], - "Cache-Control": immutable - ? "public, max-age=31536000, immutable" - : "public, max-age=3600", - }); - } - } catch { - // File not found β€” fall through to SPA - } - - // SPA fallback: serve index.html for all non-file routes - return c.html(await indexHtml); - }); + this.app.get("*", createStaticHandler(webDist, { async: true })); } // Error handler @@ -473,44 +288,33 @@ export class WebUIServer { } async start(): Promise { - return new Promise((resolve, reject) => { - try { - // Install log interceptor - logInterceptor.install(); - - // Start HTTP server - this.server = serve( - { - fetch: this.app.fetch, - hostname: this.deps.config.host, - port: this.deps.config.port, - }, - (info) => { - const url = `http://${info.address}:${info.port}`; - - log.info(`WebUI server running`); - log.info(`URL: ${url}/auth/exchange?token=${this.authToken}`); - log.info(`Token: ${maskToken(this.authToken)} (use Bearer header for API access)`); - resolve(); - } - ); - } catch (error) { - logInterceptor.uninstall(); - reject(error); - } - }); + // Install log interceptor + logInterceptor.install(); + + try { + this.server = await startHonoServer({ + fetch: this.app.fetch, + hostname: this.deps.config.host, + port: this.deps.config.port, + onListen: (info) => { + const url = `http://${info.address}:${info.port}`; + + log.info(`WebUI server running`); + log.info(`URL: ${url}/auth/exchange?token=${this.authToken}`); + log.info(`Token: ${maskToken(this.authToken)} (use Bearer header for API access)`); + }, + }); + } catch (error) { + logInterceptor.uninstall(); + throw error; + } } async stop(): Promise { if (this.server) { - return new Promise((resolve) => { - (this.server as HttpServer).closeAllConnections(); - this.server?.close(() => { - logInterceptor.uninstall(); - log.info("WebUI server stopped"); - resolve(); - }); - }); + await stopHonoServer(this.server); + logInterceptor.uninstall(); + log.info("WebUI server stopped"); } } diff --git a/src/webui/setup-server.ts b/src/webui/setup-server.ts index 2206a233..96db3565 100644 --- a/src/webui/setup-server.ts +++ b/src/webui/setup-server.ts @@ -7,37 +7,24 @@ */ import { Hono } from "hono"; -import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; import { cors } from "hono/cors"; -import { bodyLimit } from "hono/body-limit"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join, dirname, resolve, relative } from "node:path"; -import { fileURLToPath } from "node:url"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { spawn } from "node:child_process"; import { platform } from "node:os"; import { createSetupRoutes } from "./routes/setup.js"; +import { applySecurityMiddleware, sharedBodyLimit } from "./http-common.js"; +import { findWebDist, createStaticHandler } from "./static-serving.js"; +import { startHonoServer, stopHonoServer } from "../utils/http-server.js"; import { randomBytes } from "node:crypto"; import YAML from "yaml"; import { TELETON_ROOT } from "../workspace/paths.js"; -import type { Server as HttpServer } from "node:http"; import { createLogger } from "../utils/logger.js"; import { getErrorMessage } from "../utils/errors.js"; const log = createLogger("Setup"); -function findWebDist(): string | null { - const candidates = [resolve("dist/web"), resolve("web")]; - const __dirname = dirname(fileURLToPath(import.meta.url)); - candidates.push(resolve(__dirname, "web"), resolve(__dirname, "../dist/web")); - - for (const candidate of candidates) { - if (existsSync(join(candidate, "index.html"))) { - return candidate; - } - } - return null; -} - function autoOpenBrowser(url: string): void { const os = platform(); let prog: string; @@ -59,7 +46,7 @@ function autoOpenBrowser(url: string): void { export class SetupServer { private app: Hono; - private server: ReturnType | null = null; + private server: ServerType | null = null; private launchResolve: ((token: string) => void) | null = null; private launchPromise: Promise; @@ -99,19 +86,13 @@ export class SetupServer { // Body size limit this.app.use( "*", - bodyLimit({ - maxSize: 2 * 1024 * 1024, - onError: (c) => c.json({ success: false, error: "Request body too large (max 2MB)" }, 413), - }) + sharedBodyLimit((c) => + c.json({ success: false, error: "Request body too large (max 2MB)" }, 413) + ) ); // Security headers - this.app.use("*", async (c, next) => { - await next(); - c.res.headers.set("X-Content-Type-Options", "nosniff"); - c.res.headers.set("X-Frame-Options", "DENY"); - c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - }); + applySecurityMiddleware(this.app, { referrerPolicy: "strict-origin-when-cross-origin" }); // No auth middleware β€” localhost-only setup server } @@ -168,83 +149,26 @@ export class SetupServer { private setupStaticServing(): void { const webDist = findWebDist(); if (!webDist) return; - - const indexHtml = readFileSync(join(webDist, "index.html"), "utf-8"); - - const mimeTypes: Record = { - js: "application/javascript", - css: "text/css", - svg: "image/svg+xml", - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - ico: "image/x-icon", - json: "application/json", - woff2: "font/woff2", - woff: "font/woff", - }; - - this.app.get("*", (c) => { - const filePath = resolve(join(webDist, c.req.path)); - // Prevent path traversal - const rel = relative(webDist, filePath); - if (rel.startsWith("..") || resolve(filePath) !== filePath) { - return c.html(indexHtml); - } - - try { - const content = readFileSync(filePath); - const ext = filePath.split(".").pop() || ""; - if (mimeTypes[ext]) { - const immutable = c.req.path.startsWith("/assets/"); - return c.body(content, 200, { - "Content-Type": mimeTypes[ext], - "Cache-Control": immutable - ? "public, max-age=31536000, immutable" - : "public, max-age=3600", - }); - } - } catch { - // File not found β€” fall through to SPA - } - - // SPA fallback - return c.html(indexHtml); - }); + this.app.get("*", createStaticHandler(webDist, { async: false })); } async start(): Promise { - return new Promise((resolve, reject) => { - try { - this.server = serve( - { - fetch: this.app.fetch, - hostname: "127.0.0.1", - port: this.port, - }, - () => { - const url = `http://localhost:${this.port}/setup`; - log.info(`Setup wizard: ${url}`); - autoOpenBrowser(url); - resolve(); - } - ); - } catch (error: unknown) { - reject(error); - } + this.server = await startHonoServer({ + fetch: this.app.fetch, + hostname: "127.0.0.1", + port: this.port, + onListen: () => { + const url = `http://localhost:${this.port}/setup`; + log.info(`Setup wizard: ${url}`); + autoOpenBrowser(url); + }, }); } async stop(): Promise { if (this.server) { - return new Promise((resolve) => { - // Force-close keep-alive connections so we don't wait ~30s for them to expire - (this.server as unknown as HttpServer).closeAllConnections(); - this.server?.close(() => { - log.info("Setup server stopped"); - resolve(); - }); - }); + await stopHonoServer(this.server); + log.info("Setup server stopped"); } } } diff --git a/src/webui/static-serving.ts b/src/webui/static-serving.ts new file mode 100644 index 00000000..3e217350 --- /dev/null +++ b/src/webui/static-serving.ts @@ -0,0 +1,102 @@ +import type { Context } from "hono"; +import { existsSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join, dirname, resolve, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Locate the built web SPA directory (`dist/web`). Tries common locations + * relative to the launch CWD first, then relative to the compiled file. + * Returns the directory containing `index.html`, or null if none is found. + * + * Shared by the WebUI and Setup servers so both probe the same candidates. + */ +export function findWebDist(): string | null { + // Try common locations relative to CWD (where teleton is launched from) + const candidates = [ + resolve("dist/web"), // npm start / teleton start (from project root) + resolve("web"), // fallback + ]; + // Also try relative to the compiled file + const __dirname = dirname(fileURLToPath(import.meta.url)); + candidates.push( + resolve(__dirname, "web"), // dist/web when __dirname = dist/ + resolve(__dirname, "../dist/web") // when running with tsx from src/ + ); + + for (const candidate of candidates) { + if (existsSync(join(candidate, "index.html"))) { + return candidate; + } + } + return null; +} + +/** Static asset MIME types served with explicit Content-Type. */ +export const MIME_TYPES: Record = { + js: "application/javascript", + css: "text/css", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + ico: "image/x-icon", + json: "application/json", + woff2: "font/woff2", + woff: "font/woff", +}; + +/** + * Build a catch-all static-file handler with SPA fallback for the given build + * directory. Includes a path-traversal guard, immutable caching for `/assets/`, + * and serves `index.html` for any non-file route. + * + * The `async` flag selects the file-read strategy: the WebUI server reads files + * asynchronously (`readFile`), while the Setup server reads synchronously + * (`readFileSync`) β€” kept behind the flag to preserve each server's behavior. + */ +export function createStaticHandler( + webDist: string, + options: { async: boolean } +): (c: Context) => Response | Promise { + const indexPath = join(webDist, "index.html"); + + // Read index.html fresh per request (cheap) so a web rebuild is picked up + // without restarting the server, and serve it with no-cache so browsers + // always revalidate and fetch the current hashed chunk references. + const renderIndex = async (c: Context): Promise => { + const html = options.async + ? await readFile(indexPath, "utf-8") + : readFileSync(indexPath, "utf-8"); + return c.html(html, 200, { "Cache-Control": "no-cache" }); + }; + + return async (c: Context): Promise => { + const filePath = resolve(join(webDist, c.req.path)); + // Prevent path traversal β€” resolved path must stay inside webDist + const rel = relative(webDist, filePath); + if (rel.startsWith("..") || resolve(filePath) !== filePath) { + return renderIndex(c); + } + + // Try serving the actual file + try { + const content = options.async ? await readFile(filePath) : readFileSync(filePath); + const ext = filePath.split(".").pop() || ""; + if (MIME_TYPES[ext]) { + const immutable = c.req.path.startsWith("/assets/"); + return c.body(content, 200, { + "Content-Type": MIME_TYPES[ext], + "Cache-Control": immutable + ? "public, max-age=31536000, immutable" + : "public, max-age=3600", + }); + } + } catch { + // File not found β€” fall through to SPA + } + + // SPA fallback: serve index.html for all non-file routes + return renderIndex(c); + }; +} diff --git a/src/webui/types.ts b/src/webui/types.ts index a5a95db1..5963dbe9 100644 --- a/src/webui/types.ts +++ b/src/webui/types.ts @@ -5,6 +5,7 @@ import type { ToolRegistry } from "../agent/tools/registry.js"; import type { WebUIConfig, Config } from "../config/schema.js"; import type { Database } from "better-sqlite3"; import type { PluginModule, PluginContext, ToolScope } from "../agent/tools/types.js"; +import type { ToolAccessLevel } from "../agent/tools/scope.js"; import type { SDKDependencies } from "../sdk/index.js"; import type { AgentLifecycle } from "../agent/lifecycle.js"; import type { UserHookEvaluator } from "../agent/hooks/user-hook-evaluator.js"; @@ -104,9 +105,13 @@ export interface ToolInfo { name: string; description: string; module: string; - scope: ToolScope; + /** Per-tool access: who may use this tool (context-independent). */ + level: ToolAccessLevel; category?: string; - enabled: boolean; + /** Legacy single-value scope (derived) β€” kept for backward compatibility. */ + scope?: ToolScope; + /** Derived: false only when the tool is off. */ + enabled?: boolean; } export interface ModuleInfo { diff --git a/src/webui/validate-step.ts b/src/webui/validate-step.ts deleted file mode 100644 index c35db569..00000000 --- a/src/webui/validate-step.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Mirrored from web/src/components/setup/SetupContext.tsx β€” keep in sync - -export interface WizardData { - riskAccepted: boolean; - agentName: string; - provider: string; - apiKey: string; - cocoonPort: number; - localUrl: string; - apiId: number; - apiHash: string; - phone: string; - userId: number; - mode: "quick" | "advanced"; - model: string; - customModel: string; - dmPolicy: string; - groupPolicy: string; - requireMention: boolean; - maxIterations: number; - botToken: string; - botUsername: string; - tonapiKey: string; - tavilyKey: string; - customizeThresholds: boolean; - buyMaxFloor: number; - sellMinFloor: number; - walletAction: "keep" | "generate" | "import"; - mnemonic: string; - walletAddress: string; - mnemonicSaved: boolean; - authSessionId: string; - telegramUser: { id: number; firstName: string; username: string } | null; - skipConnect: boolean; - webuiEnabled: boolean; -} - -export function validateStep(step: number, data: WizardData): boolean { - switch (step) { - case 0: - return data.riskAccepted; - case 1: - if (!data.provider) return false; - if (data.provider === "cocoon") { - return data.cocoonPort >= 1 && data.cocoonPort <= 65535; - } - if (data.provider === "local") { - try { - new URL(data.localUrl); - return true; - } catch { - return false; - } - } - if (data.provider === "claude-code") { - return true; // credentials auto-detected at runtime - } - return data.apiKey.length > 0; - case 2: { - // Config - if (data.provider !== "cocoon" && data.provider !== "local") { - const modelValue = data.model === "__custom__" ? data.customModel : data.model; - if (!modelValue) return false; - } - return data.userId > 0 && data.maxIterations >= 1 && data.maxIterations <= 50; - } - case 3: - // Wallet: if generated/imported, must confirm mnemonic saved - if (data.walletAction === "keep") return true; - if (!data.walletAddress) return false; - return data.mnemonicSaved; - case 4: - // Telegram - return data.apiId > 0 && data.apiHash.length >= 10 && data.phone.startsWith("+"); - case 5: - return data.telegramUser !== null || data.skipConnect; - default: - return false; - } -} diff --git a/src/workspace/index.ts b/src/workspace/index.ts index 98409027..7951176f 100644 --- a/src/workspace/index.ts +++ b/src/workspace/index.ts @@ -4,6 +4,9 @@ export { WORKSPACE_PATHS, ALLOWED_EXTENSIONS, MAX_FILE_SIZES, + TEXT_FILE_EXTENSIONS, + PROTECTED_WORKSPACE_FILES, + MEMORY_SCAN_FILES, } from "./paths.js"; export { diff --git a/src/workspace/paths.ts b/src/workspace/paths.ts index bb4ff406..6a9c4926 100644 --- a/src/workspace/paths.ts +++ b/src/workspace/paths.ts @@ -83,3 +83,49 @@ export const MAX_FILE_SIZES = { document: 50 * 1024 * 1024, // 50 MB total_workspace: 500 * 1024 * 1024, // 500 MB total } as const; + +/** + * Extensions treated as text (vs binary) when reading a workspace file. + * Distinct from ALLOWED_EXTENSIONS (upload/media policy): this drives text/binary + * detection and includes markup (.xml/.html/.css) but not .pdf/.sql. + */ +export const TEXT_FILE_EXTENSIONS: readonly string[] = [ + ".md", + ".txt", + ".json", + ".csv", + ".yaml", + ".yml", + ".xml", + ".html", + ".css", + ".js", + ".ts", + ".py", + ".sh", +]; + +/** + * Core files that must never be deleted (overwrite-protection is separate β€” see + * MEMORY_SCAN_FILES, a distinct security concern). + */ +export const PROTECTED_WORKSPACE_FILES: readonly string[] = [ + "SOUL.md", + "STRATEGY.md", + "SECURITY.md", + "MEMORY.md", + "IDENTITY.md", + "USER.md", +]; + +/** + * Memory-sensitive files whose content is scanned for injection on write (plus + * anything under the memory/ directory). NOT the same set as + * PROTECTED_WORKSPACE_FILES: a scanned file may still be deletable, and vice versa. + */ +export const MEMORY_SCAN_FILES: readonly string[] = [ + "MEMORY.md", + "HEARTBEAT.md", + "USER.md", + "IDENTITY.md", +]; diff --git a/tsconfig.json b/tsconfig.json index de4affce..c093043d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,11 +11,7 @@ "declaration": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/**/__tests__"] diff --git a/web/package-lock.json b/web/package-lock.json index b8704dae..c2b84878 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,10 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-markdown": "^10.1.0", + "react-router-dom": "^6.28.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1207,25 +1210,64 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1242,6 +1284,18 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1263,6 +1317,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.24", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz", @@ -1331,6 +1395,66 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1342,14 +1466,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1363,6 +1485,41 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -1422,6 +1579,34 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1461,88 +1646,1066 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lottie-react": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", + "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "license": "MIT", + "dependencies": { + "lottie-web": "^5.10.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lottie-react": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/lottie-react/-/lottie-react-2.4.1.tgz", - "integrity": "sha512-LQrH7jlkigIIv++wIyrOYFLHSKQpEY4zehPicL9bQsrt1rnoKRYCYgpCUe5maqylNtacy58/sQDZTkwMcTRxZw==", + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "lottie-web": "^5.10.2" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lottie-web": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", - "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1571,6 +2734,31 @@ "dev": true, "license": "MIT" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1620,6 +2808,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -1654,6 +2852,33 @@ "react": "^18.3.1" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1696,6 +2921,87 @@ "react-dom": ">=16.8" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -1770,6 +3076,48 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1787,6 +3135,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1801,6 +3169,93 @@ "node": ">=14.17" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1832,6 +3287,34 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1913,6 +3396,16 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web/package.json b/web/package.json index 18fdfcce..1a0732b5 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,10 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-markdown": "^10.1.0", + "react-router-dom": "^6.28.0", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/web/src/App.tsx b/web/src/App.tsx index acf3f1c9..5756204f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; import React, { useEffect, useState, Suspense } from 'react'; import { Layout } from './components/Layout'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -17,6 +17,7 @@ const Tasks = React.lazy(() => import('./pages/Tasks').then(m => ({ default: m.T const Mcp = React.lazy(() => import('./pages/Mcp').then(m => ({ default: m.Mcp }))); const Config = React.lazy(() => import('./pages/Config').then(m => ({ default: m.Config }))); const Hooks = React.lazy(() => import('./pages/Hooks').then(m => ({ default: m.Hooks }))); +const Logs = React.lazy(() => import('./pages/Logs').then(m => ({ default: m.Logs }))); const Conversations = React.lazy(() => import('./pages/Conversations').then(m => ({ default: m.Conversations }))); const Wallet = React.lazy(() => import('./pages/Wallet').then(m => ({ default: m.Wallet }))); @@ -129,30 +130,39 @@ function AuthenticatedApp() { return ( - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + ); +} + +// Keyed by pathname so a crashed page's error boundary clears on navigation +// (otherwise a broken route traps the user until manual "Try Again"). +function RoutedContent() { + const location = useLocation(); + return ( + + Loading...}> + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - ); } export default App; -export { AuthenticatedApp }; diff --git a/web/src/components/AgentControl.tsx b/web/src/components/AgentControl.tsx index 79f1d575..5877a46c 100644 --- a/web/src/components/AgentControl.tsx +++ b/web/src/components/AgentControl.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useAgentStatus, AgentState } from '../hooks/useAgentStatus'; +import { errMsg } from '../lib/utils'; const API_BASE = '/api'; const MAX_START_RETRIES = 3; @@ -52,8 +53,6 @@ export function AgentControl() { const abortRef = useRef(null); const displayState = error && state === 'stopped' ? 'error' : state; - const config = STATE_CONFIG[displayState]; - const clearRetry = useCallback(() => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); @@ -86,11 +85,11 @@ export function AgentControl() { retryTimerRef.current = null; doStart(attempt + 1); }, delay); - setActionError(err instanceof Error ? err.message : String(err)); + setActionError(errMsg(err)); setInflight(false); return; } - setActionError(err instanceof Error ? err.message : String(err)); + setActionError(errMsg(err)); setRetrying(false); } finally { setInflight(false); @@ -116,7 +115,7 @@ export function AgentControl() { throw new Error(json.error); } } catch (err) { - setActionError(err instanceof Error ? err.message : String(err)); + setActionError(errMsg(err)); } finally { setInflight(false); } diff --git a/web/src/components/AgentSettingsPanel.tsx b/web/src/components/AgentSettingsPanel.tsx index ed4053fb..72dda6ea 100644 --- a/web/src/components/AgentSettingsPanel.tsx +++ b/web/src/components/AgentSettingsPanel.tsx @@ -2,6 +2,7 @@ import { ProviderMeta } from '../hooks/useConfigState'; import { Select } from './Select'; import { EditableField } from './EditableField'; import { InfoTip } from './InfoTip'; +import { ProviderSwitchZone, PROVIDER_OPTIONS, PROVIDER_LABELS } from './ProviderControl'; interface AgentSettingsPanelProps { getLocal: (key: string) => string; @@ -22,6 +23,8 @@ interface AgentSettingsPanelProps { handleProviderCancel: () => void; /** Hide temperature/tokens/iterations (Dashboard mode) */ compact?: boolean; + /** Hide the Provider select + switch zone (rendered elsewhere, e.g. Dashboard hero) */ + hideProvider?: boolean; } export function AgentSettingsPanel({ @@ -31,66 +34,36 @@ export function AgentSettingsPanel({ pendingValidating, pendingError, setPendingError, handleProviderChange, handleProviderConfirm, handleProviderCancel, compact = false, + hideProvider = false, }: AgentSettingsPanelProps) { return ( <>

- - )} - {pendingError && ( -
- {pendingError} -
- )} -
- - -
+ {!hideProvider && ( +
+ + void; + /** Optional extra action buttons (e.g. a Retry button). */ + children?: ReactNode; + style?: CSSProperties; +} + +/** + * Shared dismissible alert reusing existing .alert .error / .success CSS classes. + * If onDismiss is not provided, no Dismiss button is rendered. + */ +export function Alert({ type, message, onDismiss, children, style }: AlertProps) { + return ( +
+ {message} + {(onDismiss || children) && ( +
+ {children} + {onDismiss && ( + + )} +
+ )} +
+ ); +} diff --git a/web/src/components/AllowLists.tsx b/web/src/components/AllowLists.tsx new file mode 100644 index 00000000..944b30e4 --- /dev/null +++ b/web/src/components/AllowLists.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Segmented } from './Segmented'; + +interface AllowListsProps { + getLocal: (key: string) => string; + onSave: (key: string, values: string[]) => void; +} + +type ListKey = 'telegram.admin_ids' | 'telegram.allow_from' | 'telegram.group_allow_from'; + +const LISTS: { key: ListKey; label: string; empty: string }[] = [ + { key: 'telegram.admin_ids', label: 'Admins', empty: 'No admins yet' }, + { key: 'telegram.allow_from', label: 'Users', empty: 'No allowed users yet' }, + { key: 'telegram.group_allow_from', label: 'Groups', empty: 'No allowed groups yet' }, +]; + +function parseIds(raw: string): string[] { + if (!raw) return []; + try { + const v = JSON.parse(raw); + return Array.isArray(v) ? v.map(String) : []; + } catch { + return []; + } +} + +export function AllowLists({ getLocal, onSave }: AllowListsProps) { + const [tab, setTab] = useState(LISTS[0].key); + const [draft, setDraft] = useState(''); + + const active = LISTS.find((l) => l.key === tab)!; + const ids = parseIds(getLocal(tab)); + + const add = () => { + const v = draft.trim(); + setDraft(''); + if (!/^\d+$/.test(v) || ids.includes(v)) return; + onSave(tab, [...ids, v]); + }; + const remove = (id: string) => onSave(tab, ids.filter((x) => x !== id)); + + return ( +
+
Allow Lists
+ + + value={tab} + onChange={setTab} + ariaLabel="Allow list" + options={LISTS.map((l) => ({ value: l.key, label: `${l.label} ${parseIds(getLocal(l.key)).length}` }))} + /> + +
+ {ids.length === 0 ? ( +
+ + + + + + {active.empty} +
+ ) : ( + ids.map((id) => ( +
+ {id} + +
+ )) + )} +
+ +
+ setDraft(e.target.value.replace(/[^\d]/g, ''))} + onKeyDown={(e) => { if (e.key === 'Enter') add(); }} + placeholder="Add ID…" + aria-label="Add ID" + /> + +
+
+ ); +} diff --git a/web/src/components/ArrayInput.tsx b/web/src/components/ArrayInput.tsx index 4eb93b05..fc1cb0fa 100644 --- a/web/src/components/ArrayInput.tsx +++ b/web/src/components/ArrayInput.tsx @@ -13,12 +13,10 @@ export function ArrayInput({ value, onChange, validate, placeholder, disabled }: const [inputValue, setInputValue] = useState(''); const [error, setError] = useState(null); const [focusedChip, setFocusedChip] = useState(-1); - const [hoverRemove, setHoverRemove] = useState(-1); const inputRef = useRef(null); const chipRefs = useRef<(HTMLDivElement | null)[]>([]); - // Reset draft when props.value changes useEffect(() => { setDraft(value); }, [value]); @@ -43,17 +41,14 @@ export function ArrayInput({ value, onChange, validate, placeholder, disabled }: const removeItem = useCallback((index: number) => { setDraft(prev => prev.filter((_, i) => i !== index)); - // Focus adjacent chip or input if (draft.length <= 1) { inputRef.current?.focus(); setFocusedChip(-1); } else if (index >= draft.length - 1) { - // Removed last chip, focus the new last const newIdx = index - 1; setFocusedChip(newIdx); setTimeout(() => chipRefs.current[newIdx]?.focus(), 0); } else { - // Focus the chip that takes this position setFocusedChip(index); setTimeout(() => chipRefs.current[index]?.focus(), 0); } @@ -120,16 +115,13 @@ export function ArrayInput({ value, onChange, validate, placeholder, disabled }: const handlePaste = useCallback((e: React.ClipboardEvent) => { const text = e.clipboardData.getData('text'); const items = text.split(/[,;\n\t]+/).map(s => s.trim()).filter(Boolean); - if (items.length <= 1) return; // Let normal paste handle single values + if (items.length <= 1) return; e.preventDefault(); const toAdd: string[] = []; for (const item of items) { if (draft.includes(item) || toAdd.includes(item)) continue; - if (validate) { - const err = validate(item); - if (err) continue; // Skip invalid items silently on paste - } + if (validate && validate(item)) continue; toAdd.push(item); } if (toAdd.length > 0) { @@ -142,19 +134,9 @@ export function ArrayInput({ value, onChange, validate, placeholder, disabled }: const handleCancel = () => { setDraft(value); setError(null); }; return ( -
- {/* Chips */} +
{draft.length > 0 && ( -
+
{draft.map((item, idx) => (
handleChipKeyDown(e, idx)} onFocus={() => setFocusedChip(idx)} onBlur={() => { if (focusedChip === idx) setFocusedChip(-1); }} - style={{ - display: 'inline-flex', - alignItems: 'center', - gap: '4px', - padding: '2px 8px', - background: 'color-mix(in srgb, var(--accent) 15%, transparent)', - border: 'none', - color: 'var(--text-primary)', - borderRadius: '6px', - fontSize: 'var(--font-sm)', - lineHeight: '1.4', - outline: focusedChip === idx ? '2px solid var(--border-strong)' : 'none', - outlineOffset: focusedChip === idx ? '1px' : undefined, - cursor: 'default', - }} + className={`tag${focusedChip === idx ? ' focused' : ''}`} > - {item} + {item}
))}
)} - {/* Input row */} -
+
{ setInputValue(e.target.value); setError(null); }} onKeyDown={handleInputKeyDown} @@ -217,51 +178,17 @@ export function ArrayInput({ value, onChange, validate, placeholder, disabled }: placeholder={placeholder ?? 'Add pattern…'} disabled={disabled} aria-invalid={error ? true : undefined} - style={{ - flex: 1, - background: 'var(--bg-glass)', - border: '1px solid var(--border-glass)', - color: 'var(--text-primary)', - fontSize: 'var(--font-sm)', - padding: '5px 10px', - outline: 'none', - fontFamily: 'var(--font-mono)', - }} - onFocus={e => { e.target.style.borderColor = 'var(--border-strong)'; }} - onBlur={e => { e.target.style.borderColor = 'var(--border-glass)'; }} /> -
- {error && ( -
- {error} -
- )} + {error &&
{error}
} {isDirty && ( -
- - +
+ +
)}
diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx new file mode 100644 index 00000000..9765f3ed --- /dev/null +++ b/web/src/components/CodeBlock.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +interface CodeBlockProps { + children: string; + /** Optional header row (e.g. source + score, or line range + date). */ + header?: ReactNode; + maxHeight?: number; + resizable?: boolean; +} + +/** Themed monospace block for memory chunks, search hits, task payloads, etc. */ +export function CodeBlock({ children, header, maxHeight = 300, resizable = false }: CodeBlockProps) { + return ( +
+ {header &&
{header}
} +
+        {children}
+      
+
+ ); +} diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..92b0ac55 --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,70 @@ +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'; + +export interface ConfirmOptions { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise; + +const ConfirmContext = createContext(() => Promise.resolve(false)); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [opts, setOpts] = useState(null); + const resolver = useRef<((v: boolean) => void) | null>(null); + + const confirm = useCallback( + (o) => + new Promise((resolve) => { + resolver.current = resolve; + setOpts(o); + }), + [], + ); + + const close = (value: boolean) => { + resolver.current?.(value); + resolver.current = null; + setOpts(null); + }; + + return ( + + {children} + {opts && ( +
close(false)}> +
e.stopPropagation()} + role="alertdialog" + aria-modal="true" + > + {opts.title &&

{opts.title}

} +

{opts.message}

+
+ + +
+
+
+ )} +
+ ); +} + +/** Returns an async confirm(opts) β†’ Promise. Replaces window.confirm(). */ +export function useConfirm() { + return useContext(ConfirmContext); +} diff --git a/web/src/components/EmptyState.tsx b/web/src/components/EmptyState.tsx new file mode 100644 index 00000000..2787ff40 --- /dev/null +++ b/web/src/components/EmptyState.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + description?: string; + action?: ReactNode; +} + +export function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( +
+ {icon && } +
{title}
+ {description &&
{description}
} + {action &&
{action}
} +
+ ); +} diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 19679d52..a2ae873b 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -2,6 +2,8 @@ import { Component, type ReactNode } from 'react'; interface Props { children: ReactNode; + /** When this value changes, a captured error is cleared (e.g. on route change). */ + resetKey?: string; } interface State { @@ -16,6 +18,12 @@ export class ErrorBoundary extends Component { return { hasError: true, error: error.message }; } + componentDidUpdate(prevProps: Props) { + if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { + this.setState({ hasError: false, error: null }); + } + } + render() { if (this.state.hasError) { return ( diff --git a/web/src/components/InfoBanner.tsx b/web/src/components/InfoBanner.tsx deleted file mode 100644 index 7c96d6ed..00000000 --- a/web/src/components/InfoBanner.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useState, type ReactNode } from 'react'; - -interface InfoBannerProps { - children: ReactNode; -} - -export function InfoBanner({ children }: InfoBannerProps) { - const [open, setOpen] = useState(false); - - return ( -
- - {open && ( -
- {children} -
- )} -
- ); -} diff --git a/web/src/components/InfoTip.tsx b/web/src/components/InfoTip.tsx index 78c1dbca..33ab4c0f 100644 --- a/web/src/components/InfoTip.tsx +++ b/web/src/components/InfoTip.tsx @@ -45,24 +45,29 @@ export function InfoTip({ text }: InfoTipProps) { onBlur={hide} > {/* Trigger icon */} - i - + {/* Tooltip */} + + + + + ); +} + function IconConfig() { return ( @@ -118,8 +129,8 @@ const navLinkBase: CSSProperties = { display: 'flex', alignItems: 'center', gap: '10px', - padding: '10px 16px', - borderRadius: 'var(--radius-md)', + padding: '6px 12px', + borderRadius: 'var(--radius-sm)', color: 'var(--text-secondary)', textDecoration: 'none', fontSize: '13px', @@ -171,7 +182,7 @@ function DashboardNav() { { to: '/', icon: , label: 'Dashboard' }, { to: '/tools', icon: , label: 'Tools' }, { to: '/plugins', icon: , label: 'Plugins' }, - { to: '/soul', icon: , label: 'Soul' }, + { to: '/soul', icon: , label: 'System Prompt' }, { to: '/memory', icon: , label: 'Memory' }, { to: '/conversations', icon: , label: 'Chats' }, { to: '/wallet', icon: , label: 'Wallet' }, @@ -179,6 +190,7 @@ function DashboardNav() { { to: '/tasks', icon: , label: 'Tasks' }, { to: '/mcp', icon: , label: 'MCP' }, { to: '/hooks', icon: , label: 'Hooks' }, + { to: '/logs', icon: , label: 'Logs' }, { to: '/config', icon: , label: 'Config' }, ]; @@ -188,7 +200,7 @@ function DashboardNav() {
-
+
+
-
+
-
+
+ {open && ( +
+ {items.map((item, i) => + item === 'separator' ? ( +
+ ) : ( + + ), + )} +
+ )} +
+ ); +} diff --git a/web/src/components/PillTabs.tsx b/web/src/components/PillTabs.tsx new file mode 100644 index 00000000..f408cfa5 --- /dev/null +++ b/web/src/components/PillTabs.tsx @@ -0,0 +1,33 @@ +export interface PillTabOption { + value: T; + label: string; +} + +interface PillTabsProps { + value: T; + options: PillTabOption[]; + onChange: (value: T) => void; + disabled?: boolean; + ariaLabel?: string; +} + +/** Telegram-folders-style horizontal pill tabs: selected = filled pill, rest = plain text. */ +export function PillTabs({ value, options, onChange, disabled, ariaLabel }: PillTabsProps) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ProviderControl.tsx b/web/src/components/ProviderControl.tsx new file mode 100644 index 00000000..6c0c8d38 --- /dev/null +++ b/web/src/components/ProviderControl.tsx @@ -0,0 +1,61 @@ +import type { ProviderMeta } from '../hooks/useConfigState'; + +export const PROVIDER_OPTIONS = ['anthropic', 'openai', 'google', 'xai', 'groq', 'openrouter', 'moonshot', 'mistral', 'cerebras', 'zai', 'minimax', 'huggingface', 'cocoon', 'local']; +export const PROVIDER_LABELS = ['Anthropic', 'OpenAI', 'Google', 'xAI', 'Groq', 'OpenRouter', 'Moonshot', 'Mistral', 'Cerebras', 'ZAI (Zhipu)', 'MiniMax', 'HuggingFace', 'Cocoon', 'Local']; + +interface ProviderSwitchZoneProps { + pendingMeta: ProviderMeta; + pendingApiKey: string; + setPendingApiKey: (v: string) => void; + pendingValidating: boolean; + pendingError: string | null; + setPendingError: (v: string | null) => void; + onConfirm: () => void; + onCancel: () => void; +} + +/** Gated API-key entry shown while switching to a provider that needs a key. */ +export function ProviderSwitchZone({ + pendingMeta, pendingApiKey, setPendingApiKey, pendingValidating, pendingError, setPendingError, onConfirm, onCancel, +}: ProviderSwitchZoneProps) { + return ( +
+ ); +} diff --git a/web/src/components/RefreshButton.tsx b/web/src/components/RefreshButton.tsx new file mode 100644 index 00000000..e1ed0a2d --- /dev/null +++ b/web/src/components/RefreshButton.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; + +interface RefreshButtonProps { + /** May return a promise β€” the icon spins until it resolves (min 500ms). */ + onRefresh: () => void | Promise; + disabled?: boolean; +} + +/** Unified refresh control: a circular icon button that spins while refreshing. */ +export function RefreshButton({ onRefresh, disabled }: RefreshButtonProps) { + const [spinning, setSpinning] = useState(false); + + const handle = async () => { + if (spinning) return; + setSpinning(true); + try { + await onRefresh(); + } catch { + /* errors are surfaced by the caller's own handling */ + } finally { + window.setTimeout(() => setSpinning(false), 500); + } + }; + + return ( + + ); +} diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx new file mode 100644 index 00000000..b1d3a6a3 --- /dev/null +++ b/web/src/components/SearchBar.tsx @@ -0,0 +1,32 @@ +interface SearchBarProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +/** Full-width iOS-style search field with leading magnifier and clear button. */ +export function SearchBar({ value, onChange, placeholder = 'Search' }: SearchBarProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + aria-label={placeholder} + /> + {value && ( + + )} +
+ ); +} diff --git a/web/src/components/SearchInput.tsx b/web/src/components/SearchInput.tsx index fb3562a0..907e454a 100644 --- a/web/src/components/SearchInput.tsx +++ b/web/src/components/SearchInput.tsx @@ -3,13 +3,15 @@ interface SearchInputProps { onChange: (value: string) => void; placeholder?: string; style?: React.CSSProperties; + wrapperStyle?: React.CSSProperties; } -export function SearchInput({ value, onChange, placeholder = 'Search...', style }: SearchInputProps) { +export function SearchInput({ value, onChange, placeholder = 'Search...', style, wrapperStyle }: SearchInputProps) { return ( -
+
onChange(e.target.value)} @@ -27,6 +29,8 @@ export function SearchInput({ value, onChange, placeholder = 'Search...', style /> {value && ( + ))} +
+ ); +} diff --git a/web/src/components/Select.tsx b/web/src/components/Select.tsx index 889178bc..50002e0d 100644 --- a/web/src/components/Select.tsx +++ b/web/src/components/Select.tsx @@ -7,9 +7,10 @@ interface SelectProps { labels?: string[]; onChange: (value: string) => void; style?: React.CSSProperties; + disabled?: boolean; } -export function Select({ value, options, labels, onChange, style }: SelectProps) { +export function Select({ value, options, labels, onChange, style, disabled }: SelectProps) { const [open, setOpen] = useState(false); const [focusIdx, setFocusIdx] = useState(-1); const [menuPos, setMenuPos] = useState<{ top: number; left: number; width: number } | null>(null); @@ -104,8 +105,9 @@ export function Select({ value, options, labels, onChange, style }: SelectProps) + ))} +
+ ); +} diff --git a/web/src/components/ToolRow.tsx b/web/src/components/ToolRow.tsx index 76796462..05ed6637 100644 --- a/web/src/components/ToolRow.tsx +++ b/web/src/components/ToolRow.tsx @@ -1,52 +1,32 @@ -import { ToolInfo } from '../lib/api'; +import { ToolInfo, ToolAccessLevel } from '../lib/api'; +import { PillTabs } from './PillTabs'; interface ToolRowProps { tool: ToolInfo; updating: string | null; - onToggle: (name: string, enabled: boolean) => void; - onScope: (name: string, scope: ToolInfo['scope']) => void; + onLevel: (tool: ToolInfo, level: ToolAccessLevel) => void; } -export function ToolRow({ tool, updating, onToggle, onScope }: ToolRowProps) { - return ( -
-
-
{tool.name}
-
{tool.description}
-
+export const LEVEL_OPTIONS: { value: string; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'allowlist', label: 'List' }, + { value: 'admin', label: 'Admin' }, + { value: 'off', label: 'Off' }, +]; -
- {(['open', 'dm-only', 'group-only', 'admin-only', 'allowlist', 'disabled'] as const).map((s) => ( - - ))} -
+export function ToolRow({ tool, updating, onLevel }: ToolRowProps) { + const busy = updating === tool.name; - + return ( +
+
{tool.name}
+ onLevel(tool, v as ToolAccessLevel)} + disabled={busy} + ariaLabel={`Access level for ${tool.name}`} + />
); } diff --git a/web/src/components/setup/ConfigStep.tsx b/web/src/components/setup/ConfigStep.tsx index ebfa831f..e9a63850 100644 --- a/web/src/components/setup/ConfigStep.tsx +++ b/web/src/components/setup/ConfigStep.tsx @@ -1,9 +1,9 @@ import { useState } from 'react'; import { setup, BotValidation } from '../../lib/api'; -import { Select } from '../Select'; import { Stepper } from '../Stepper'; import { PasswordInput } from './PasswordInput'; import type { StepProps } from '../../pages/Setup'; +import { errMsg } from '../../lib/utils'; export function ConfigStep({ data, onChange }: StepProps) { const [botLoading, setBotLoading] = useState(false); @@ -29,7 +29,7 @@ export function ConfigStep({ data, onChange }: StepProps) { setBotError(result.error || 'Invalid bot token'); } } catch (err) { - setBotError(err instanceof Error ? err.message : String(err)); + setBotError(errMsg(err)); } finally { setBotLoading(false); } diff --git a/web/src/components/setup/ConnectStep.tsx b/web/src/components/setup/ConnectStep.tsx index 24671888..899a9bce 100644 --- a/web/src/components/setup/ConnectStep.tsx +++ b/web/src/components/setup/ConnectStep.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react' import { QRCodeSVG } from 'qrcode.react'; import { setup } from '../../lib/api'; import type { StepProps } from '../../pages/Setup'; +import { errMsg } from '../../lib/utils'; const Lottie = lazy(() => import('lottie-react')); @@ -83,7 +84,7 @@ export function ConnectStep({ data, onChange }: StepProps) { setPhase('qr_waiting'); startQrPolling(result.authSessionId); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errMsg(err); if (msg.includes('FLOOD') || msg.includes('Rate limited')) { const seconds = parseInt(msg.match(/(\d+)/)?.[1] || '60'); setFloodWait(seconds); @@ -136,7 +137,7 @@ export function ConnectStep({ data, onChange }: StepProps) { setPhase('code_sent'); setCanResend(false); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errMsg(err); if (msg.includes('FLOOD')) { const seconds = parseInt(msg.match(/(\d+)/)?.[1] || '60'); setFloodWait(seconds); @@ -162,7 +163,7 @@ export function ConnectStep({ data, onChange }: StepProps) { setPhase('2fa'); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setLoading(false); } @@ -180,7 +181,7 @@ export function ConnectStep({ data, onChange }: StepProps) { setPhase('done'); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setLoading(false); } @@ -196,7 +197,7 @@ export function ConnectStep({ data, onChange }: StepProps) { setCode(''); setCanResend(false); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setLoading(false); } diff --git a/web/src/components/setup/ProviderStep.tsx b/web/src/components/setup/ProviderStep.tsx index afe106b9..3b507f33 100644 --- a/web/src/components/setup/ProviderStep.tsx +++ b/web/src/components/setup/ProviderStep.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef } from 'react'; -import { setup, SetupProvider, SetupModelOption, ClaudeCodeKeyDetection } from '../../lib/api'; +import { setup, SetupProvider, SetupModelOption } from '../../lib/api'; import { Select } from '../Select'; import type { StepProps } from '../../pages/Setup'; +import { errMsg } from '../../lib/utils'; +import { Loading } from '../Loading'; export function ProviderStep({ data, onChange }: StepProps) { const [providers, setProviders] = useState([]); @@ -11,40 +13,18 @@ export function ProviderStep({ data, onChange }: StepProps) { const [keyError, setKeyError] = useState(''); const [validating, setValidating] = useState(false); const debounceRef = useRef | null>(null); - const [ccDetection, setCcDetection] = useState(null); - const [ccDetecting, setCcDetecting] = useState(false); - const [ccShowFallback, setCcShowFallback] = useState(false); const [models, setModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); useEffect(() => { setup.getProviders() .then((p) => setProviders(p)) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .catch((err) => setError(errMsg(err))) .finally(() => setLoading(false)); }, []); const selected = providers.find((p) => p.id === data.provider); - // Auto-detect Claude Code credentials when provider is selected - useEffect(() => { - if (selected?.autoDetectsKey) { - setCcDetecting(true); - setCcDetection(null); - setCcShowFallback(false); - setup.detectClaudeCodeKey() - .then((result) => { - setCcDetection(result); - if (result.found) { - // Clear manual key β€” auto-detected key will be used at runtime - onChange({ ...data, apiKey: '' }); - } - }) - .catch(() => setCcDetection({ found: false, maskedKey: null, valid: false })) - .finally(() => setCcDetecting(false)); - } - }, [selected?.id]); - // Load models when provider changes useEffect(() => { if (!data.provider || data.provider === 'cocoon' || data.provider === 'local') { @@ -67,8 +47,6 @@ export function ProviderStep({ data, onChange }: StepProps) { onChange({ ...data, provider: id, apiKey: '', model: '', customModel: '' }); setKeyValid(null); setKeyError(''); - setCcDetection(null); - setCcShowFallback(false); if (debounceRef.current) clearTimeout(debounceRef.current); }; @@ -97,7 +75,7 @@ export function ProviderStep({ data, onChange }: StepProps) { } }; - if (loading) return
Loading providers...
; + if (loading) return ; if (error) return
{error}
; return ( @@ -131,68 +109,6 @@ export function ProviderStep({ data, onChange }: StepProps) {
)} - {selected && selected.autoDetectsKey && ( -
- {ccDetecting && ( -
- Detecting Claude Code credentials... -
- )} - {!ccDetecting && ccDetection?.found && ( -
-
- Credentials auto-detected from Claude Code -
- {ccDetection.maskedKey} -
- Token will auto-refresh when it expires. No configuration needed. -
-
- )} - {!ccDetecting && ccDetection && !ccDetection.found && !ccShowFallback && ( -
-
- Claude Code credentials not found. Make sure Claude Code is installed and authenticated - (claude login). -
- -
- )} - {!ccDetecting && ccShowFallback && ( -
- - handleKeyChange(e.target.value)} - placeholder={selected.keyPrefix ? `${selected.keyPrefix}...` : 'Enter API key'} - className="w-full" - /> - {validating && ( -
Validating...
- )} - {!validating && keyValid === true && ( -
Key format looks valid.
- )} - {!validating && keyValid === false && keyError && ( -
{keyError}
- )} -
- Get your key at:{' '} - - https://console.anthropic.com/ - -
-
- )} -
- )} - {selected && selected.requiresApiKey && (
diff --git a/web/src/components/setup/ReviewStep.tsx b/web/src/components/setup/ReviewStep.tsx deleted file mode 100644 index 18e646f0..00000000 --- a/web/src/components/setup/ReviewStep.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import type { StepProps } from '../../pages/Setup'; - -function maskKey(key: string): string { - if (key.length <= 10) return '***'; - return key.slice(0, 6) + '...' + key.slice(-4); -} - -export function ReviewStep({ data, onChange }: StepProps) { - const resolvedModel = - data.mode === 'quick' - ? '(default)' - : data.model === '__custom__' - ? data.customModel || '(custom)' - : data.model || '(default)'; - - return ( -
-

Review Your Setup

-

- Double-check everything before saving. You can go back to change any step. -

- - {/* Provider */} -
-
Provider
-
-
Provider: {data.provider}
-
Model: {resolvedModel}
- {data.provider !== 'cocoon' && data.apiKey && ( -
API Key: {maskKey(data.apiKey)}
- )} - {data.provider === 'cocoon' && ( -
Port: {data.cocoonPort}
- )} -
-
- - {/* Telegram */} -
-
Telegram
-
-
Phone: {data.phone}
-
User ID: {data.userId}
- {data.apiHash && ( -
API Hash: {maskKey(data.apiHash)}
- )} - {data.botToken && ( -
Bot Token: {maskKey(data.botToken)}
- )} - {data.botUsername && ( -
Bot: @{data.botUsername}
- )} -
-
- - {/* Policies */} -
-
Policies
-
-
DM Policy: {data.dmPolicy}
-
Group Policy: {data.groupPolicy}
-
Require @mention: {data.requireMention ? 'Yes' : 'No'}
-
-
- - {/* Modules */} -
-
Modules
-
-
TonAPI: {data.tonapiKey ? {maskKey(data.tonapiKey)} : 'No'}
-
TonCenter: {data.toncenterKey ? {maskKey(data.toncenterKey)} : 'No'}
-
Web Search: {data.tavilyKey ? {maskKey(data.tavilyKey)} : 'No'}
- {data.customizeThresholds && ( -
Deals: Buy max {data.buyMaxFloor}% / Sell min {data.sellMinFloor}%
- )} -
-
- - {/* Wallet */} -
-
Wallet
-
- {data.walletAddress ? ( - {data.walletAddress} - ) : ( - Not configured - )} -
-
- - {/* Connection */} -
-
Connection
-
- {data.telegramUser ? ( - - Connected as {data.telegramUser.firstName} - {data.telegramUser.username && <> (@{data.telegramUser.username})} - - ) : ( - Not connected (deferred) - )} -
-
- - {/* WebUI Toggle */} -
-
-
- Enable WebUI Dashboard -
- Start the dashboard on next launch -
-
-
- ); -} diff --git a/web/src/components/setup/SetupContext.tsx b/web/src/components/setup/SetupContext.tsx index b5d1a4c4..af65838a 100644 --- a/web/src/components/setup/SetupContext.tsx +++ b/web/src/components/setup/SetupContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'; import { setup, SetupConfig } from '../../lib/api'; +import { errMsg } from '../../lib/utils'; // ── Step metadata ─────────────────────────────────────────────────── @@ -19,9 +20,6 @@ export function getSteps(telegramMode: 'user' | 'bot') { return ALL_STEPS; } -// Default export for backward compat β€” components can use getSteps(data.telegramMode) for dynamic -export const STEPS = ALL_STEPS; - // ── Shared types ──────────────────────────────────────────────────── export interface WizardData { @@ -109,7 +107,7 @@ const DEFAULTS: WizardData = { // ── Validation ────────────────────────────────────────────────────── -export function validateStep(step: number, data: WizardData): boolean { +function validateStep(step: number, data: WizardData): boolean { switch (step) { case 0: return data.riskAccepted; @@ -122,9 +120,6 @@ export function validateStep(step: number, data: WizardData): boolean { try { new URL(data.localUrl); return true; } catch { return false; } } - if (data.provider === 'claude-code') { - return true; // credentials auto-detected or fallback handled by ProviderStep - } return data.apiKey.length > 0; case 2: { // Config @@ -200,57 +195,72 @@ export function SetupProvider({ children }: { children: ReactNode }) { setStep((s) => Math.max(s - 1, 0)); }, []); + const buildConfig = useCallback((): SetupConfig => { + const resolvedModel = + data.model === '__custom__' + ? data.customModel + : data.model || undefined; + + return { + agent: { + provider: data.provider, + ...(data.provider !== 'cocoon' && data.provider !== 'local' && data.apiKey ? { api_key: data.apiKey } : {}), + ...(data.provider === 'local' ? { base_url: data.localUrl } : {}), + ...(resolvedModel ? { model: resolvedModel } : {}), + max_agentic_iterations: data.maxIterations, + }, + telegram: { + ...(data.telegramMode === 'bot' ? { mode: 'bot' as const } : {}), + api_id: data.apiId, + api_hash: data.apiHash, + phone: data.phone, + admin_ids: [data.userId], + owner_id: data.userId, + dm_policy: data.telegramMode === 'bot' ? 'admin-only' : data.dmPolicy, + group_policy: data.groupPolicy, + require_mention: data.telegramMode === 'bot' ? true : data.requireMention, + ...(data.botToken ? { bot_token: data.botToken } : {}), + ...(data.botUsername ? { bot_username: data.botUsername } : {}), + }, + ...(data.provider === 'cocoon' ? { cocoon: { port: data.cocoonPort } } : {}), + deals: { + enabled: !!data.botToken, + ...(data.customizeThresholds + ? { buy_max_floor_percent: data.buyMaxFloor, sell_min_floor_percent: data.sellMinFloor } + : {}), + }, + ...(data.tonapiKey ? { tonapi_key: data.tonapiKey } : {}), + ...(data.toncenterKey ? { toncenter_api_key: data.toncenterKey } : {}), + ...(data.tavilyKey ? { tavily_api_key: data.tavilyKey } : {}), + webui: { enabled: true }, + }; + }, [data]); + + // Final save: writes config.yaml and marks the wizard complete (β†’ SetupComplete). const handleSave = useCallback(async () => { setLoading(true); setError(''); try { - const resolvedModel = - data.model === '__custom__' - ? data.customModel - : data.model || undefined; - - const config: SetupConfig = { - agent: { - provider: data.provider, - ...(data.provider !== 'cocoon' && data.provider !== 'local' && data.apiKey ? { api_key: data.apiKey } : {}), - ...(data.provider === 'local' ? { base_url: data.localUrl } : {}), - ...(resolvedModel ? { model: resolvedModel } : {}), - max_agentic_iterations: data.maxIterations, - }, - telegram: { - ...(data.telegramMode === 'bot' ? { mode: 'bot' as const } : {}), - api_id: data.apiId, - api_hash: data.apiHash, - phone: data.phone, - admin_ids: [data.userId], - owner_id: data.userId, - dm_policy: data.telegramMode === 'bot' ? 'admin-only' : data.dmPolicy, - group_policy: data.groupPolicy, - require_mention: data.telegramMode === 'bot' ? true : data.requireMention, - ...(data.botToken ? { bot_token: data.botToken } : {}), - ...(data.botUsername ? { bot_username: data.botUsername } : {}), - }, - ...(data.provider === 'cocoon' ? { cocoon: { port: data.cocoonPort } } : {}), - deals: { - enabled: !!data.botToken, - ...(data.customizeThresholds - ? { buy_max_floor_percent: data.buyMaxFloor, sell_min_floor_percent: data.sellMinFloor } - : {}), - }, - ...(data.tonapiKey ? { tonapi_key: data.tonapiKey } : {}), - ...(data.toncenterKey ? { toncenter_api_key: data.toncenterKey } : {}), - ...(data.tavilyKey ? { tavily_api_key: data.tavilyKey } : {}), - webui: { enabled: true }, - }; - - await setup.saveConfig(config); + await setup.saveConfig(buildConfig()); setSaved(true); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setLoading(false); } - }, [data]); + }, [buildConfig]); + + // Silent persist: writes config.yaml WITHOUT marking the wizard complete. + // Used before the Telegram auth flow so the backend has a config to merge into. + const persistConfig = useCallback(async () => { + try { + await setup.saveConfig(buildConfig()); + return true; + } catch (err) { + setError(errMsg(err)); + return false; + } + }, [buildConfig]); const handleLaunch = useCallback(async () => { setLaunching(true); @@ -262,14 +272,40 @@ export function SetupProvider({ children }: { children: ReactNode }) { // Redirect to the dashboard with token-based auth window.location.href = `/auth/exchange?token=${encodeURIComponent(token)}`; } catch (err) { - setLaunchError(err instanceof Error ? err.message : String(err)); + setLaunchError(errMsg(err)); } finally { setLaunching(false); } }, []); - // Auto-save when Telegram connects on the last step (user mode only) - // In bot mode, the last step is Wallet β€” save is triggered by the Finish button + // Persist config.yaml as soon as the user reaches the Connect step (user mode), + // BEFORE the Telegram auth flow runs. The auth flow (saveSession on the backend) + // does a read-modify-write of config.yaml to merge the Telegram credentials, so + // the file must already exist by the time the user submits their login code / 2FA + // password β€” otherwise readRawConfig throws "Config file not found". All required + // data (api_id/api_hash/phone) is already collected in the preceding Telegram step. + // This is silent: it does NOT mark the wizard complete (the Connect UI must stay). + const persistRef = useRef(persistConfig); + persistRef.current = persistConfig; + const preSavedRef = useRef(false); + useEffect(() => { + const onConnectStep = step === steps.length - 1 && data.telegramMode === 'user'; + if (!onConnectStep) { + // Reset so a return visit (after editing earlier steps) re-persists. + preSavedRef.current = false; + return; + } + // Fire once per visit; the guard prevents an error from spinning a retry loop. + if (!preSavedRef.current && !saved) { + preSavedRef.current = true; + void persistRef.current(); + } + }, [step, steps.length, data.telegramMode, saved]); + + // Auto-save when Telegram connects on the last step (user mode only). + // Marks the wizard complete (β†’ SetupComplete). config.yaml already exists at this + // point (persisted on entering Connect + merged with credentials by the backend). + // In bot mode, the last step is Wallet β€” save is triggered by the Finish button. const saveRef = useRef(handleSave); saveRef.current = handleSave; useEffect(() => { diff --git a/web/src/components/setup/SetupLayout.tsx b/web/src/components/setup/SetupLayout.tsx index cd3aa6f8..965a23e6 100644 --- a/web/src/components/setup/SetupLayout.tsx +++ b/web/src/components/setup/SetupLayout.tsx @@ -2,12 +2,13 @@ import { Outlet } from 'react-router-dom'; import { Shell } from '../Shell'; import { SetupProvider } from './SetupContext'; import { SetupNav } from './SetupNav'; +import { ThemeToggle } from '../ThemeToggle'; const DASHBOARD_LINKS = [ { label: 'Dashboard', path: '/' }, { label: 'Tools', path: '/tools' }, { label: 'Plugins', path: '/plugins' }, - { label: 'Soul', path: '/soul' }, + { label: 'System Prompt', path: '/soul' }, { label: 'Memory', path: '/memory' }, { label: 'Logs', path: '/logs' }, { label: 'Workspace', path: '/workspace' }, @@ -40,7 +41,7 @@ function SetupMain() { export function SetupLayout() { return ( - }> + } topRight={}> diff --git a/web/src/components/setup/WalletStep.tsx b/web/src/components/setup/WalletStep.tsx index 5321d9cc..a0b27a64 100644 --- a/web/src/components/setup/WalletStep.tsx +++ b/web/src/components/setup/WalletStep.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; import { setup, WalletStatus } from '../../lib/api'; import type { StepProps } from '../../pages/Setup'; +import { errMsg } from '../../lib/utils'; +import { Loading } from '../Loading'; function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); @@ -32,7 +34,7 @@ export function WalletStep({ data, onChange }: StepProps) { onChange({ ...data, walletAddress: s.address, walletAction: 'keep' }); } }) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .catch((err) => setError(errMsg(err))) .finally(() => setLoading(false)); }, []); @@ -56,13 +58,13 @@ export function WalletStep({ data, onChange }: StepProps) { setMnemonicWords(result.mnemonic); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setActionLoading(false); } }; - if (loading) return
Checking wallet...
; + if (loading) return ; const showMnemonic = mnemonicWords.length > 0; const actionNeeded = data.walletAction !== 'keep' && !showMnemonic; diff --git a/web/src/components/setup/WelcomeStep.tsx b/web/src/components/setup/WelcomeStep.tsx index f390b7d4..ee3c7f5e 100644 --- a/web/src/components/setup/WelcomeStep.tsx +++ b/web/src/components/setup/WelcomeStep.tsx @@ -1,32 +1,20 @@ import { useState, useEffect } from 'react'; import { setup, SetupStatusResponse } from '../../lib/api'; import type { StepProps } from '../../pages/Setup'; +import { errMsg } from '../../lib/utils'; export function WelcomeStep({ data, onChange }: StepProps) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [initDone, setInitDone] = useState(false); useEffect(() => { setup.getStatus() .then((s) => setStatus(s)) - .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .catch((err) => setError(errMsg(err))) .finally(() => setLoading(false)); }, []); - const handleAccept = async (accepted: boolean) => { - onChange({ ...data, riskAccepted: accepted }); - if (accepted && !initDone) { - try { - await setup.initWorkspace(data.agentName || undefined); - setInitDone(true); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - }; - return (

Welcome to Teleton Setup

diff --git a/web/src/hooks/useAgentStatus.ts b/web/src/hooks/useAgentStatus.ts index faf8670e..dfb0dc31 100644 --- a/web/src/hooks/useAgentStatus.ts +++ b/web/src/hooks/useAgentStatus.ts @@ -1,147 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { useSyncExternalStore } from 'react'; +import { agentStatusStore, type AgentState } from '../lib/agent-status-store'; -export type AgentState = 'stopped' | 'starting' | 'running' | 'stopping'; - -interface AgentStatusEvent { - state: AgentState; - error: string | null; - timestamp: number; -} - -const SSE_URL = '/api/agent/events'; -const POLL_URL = '/api/agent/status'; -const MAX_RETRIES = 5; -const MAX_BACKOFF_MS = 30_000; -const POLL_INTERVAL_MS = 3_000; - -function backoffMs(attempt: number): number { - const base = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); - const jitter = base * 0.3 * Math.random(); - return base + jitter; -} +export type { AgentState }; +/** + * Subscribe to the shared agent run-state. All consumers share a single + * EventSource (with polling fallback) via agentStatusStore β€” see that module. + */ export function useAgentStatus(): { state: AgentState; error: string | null } { - const [state, setState] = useState('stopped'); - const [error, setError] = useState(null); - - const mountedRef = useRef(true); - const esRef = useRef(null); - const retryCountRef = useRef(0); - const retryTimerRef = useRef | null>(null); - const pollTimerRef = useRef | null>(null); - const sseFailedRef = useRef(false); - - useEffect(() => { - mountedRef.current = true; - - function handleStatusEvent(ev: MessageEvent) { - if (!mountedRef.current) return; - try { - const data: AgentStatusEvent = JSON.parse(ev.data); - setState(data.state); - setError(data.error ?? null); - retryCountRef.current = 0; // reset on successful message - } catch { - // ignore parse errors - } - } - - function closeSSE() { - if (esRef.current) { - esRef.current.removeEventListener('status', handleStatusEvent as EventListener); - esRef.current.close(); - esRef.current = null; - } - } - - function stopPolling() { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - } - - function startPolling() { - if (pollTimerRef.current) return; - const poll = async () => { - if (!mountedRef.current) return; - try { - const res = await fetch(POLL_URL, { credentials: 'include' }); - if (!res.ok) return; - const json = await res.json(); - const data = json.data ?? json; - if (mountedRef.current) { - setState(data.state); - setError(data.error ?? null); - } - } catch { - // ignore fetch errors during polling - } - }; - poll(); // immediate first poll - pollTimerRef.current = setInterval(poll, POLL_INTERVAL_MS); - } - - function connect() { - if (!mountedRef.current) return; - closeSSE(); - - const es = new EventSource(SSE_URL, { withCredentials: true }); - esRef.current = es; - - es.addEventListener('status', handleStatusEvent as EventListener); - - es.addEventListener('open', () => { - retryCountRef.current = 0; - sseFailedRef.current = false; - stopPolling(); - }); - - es.onerror = () => { - closeSSE(); - if (!mountedRef.current) return; - - retryCountRef.current += 1; - if (retryCountRef.current <= MAX_RETRIES) { - const delay = backoffMs(retryCountRef.current - 1); - retryTimerRef.current = setTimeout(connect, delay); - } else { - // SSE exhausted β€” fall back to polling - sseFailedRef.current = true; - startPolling(); - } - }; - } - - function handleVisibility() { - if (document.hidden) { - closeSSE(); - stopPolling(); - if (retryTimerRef.current) { - clearTimeout(retryTimerRef.current); - retryTimerRef.current = null; - } - } else { - retryCountRef.current = 0; - sseFailedRef.current = false; - connect(); - } - } - - connect(); - document.addEventListener('visibilitychange', handleVisibility); - - return () => { - mountedRef.current = false; - closeSSE(); - stopPolling(); - if (retryTimerRef.current) { - clearTimeout(retryTimerRef.current); - retryTimerRef.current = null; - } - document.removeEventListener('visibilitychange', handleVisibility); - }; - }, []); - - return { state, error }; + return useSyncExternalStore(agentStatusStore.subscribe, agentStatusStore.getSnapshot); } diff --git a/web/src/hooks/useConfigState.ts b/web/src/hooks/useConfigState.ts index e5047d9c..4b5d9ea5 100644 --- a/web/src/hooks/useConfigState.ts +++ b/web/src/hooks/useConfigState.ts @@ -1,5 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import { api, StatusData, MemoryStats, ToolRagStatus, ConfigKeyData } from '../lib/api'; +import { errMsg } from '../lib/utils'; export interface ProviderMeta { needsKey: boolean; @@ -70,7 +71,7 @@ export function useConfigState() { await api.setConfigKey(key, value.trim()); await loadData(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } }; @@ -79,7 +80,7 @@ export function useConfigState() { const res = await api.updateToolRag(update); setToolRag(res.data); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } }; @@ -121,7 +122,7 @@ export function useConfigState() { setPendingError(null); } } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } }; @@ -148,7 +149,7 @@ export function useConfigState() { setPendingMeta(null); setPendingApiKey(''); } catch (err) { - setPendingError(err instanceof Error ? err.message : String(err)); + setPendingError(errMsg(err)); } finally { setPendingValidating(false); } diff --git a/web/src/hooks/useResource.ts b/web/src/hooks/useResource.ts new file mode 100644 index 00000000..f3d26942 --- /dev/null +++ b/web/src/hooks/useResource.ts @@ -0,0 +1,68 @@ +import { useState, useEffect, useCallback, useRef, type DependencyList } from 'react'; +import { errMsg } from '../lib/utils'; + +export interface ResourceState { + data: T | null; + loading: boolean; + error: string | null; + /** Triggers a re-fetch; returns a Promise that resolves when the fetch settles. */ + reload: () => Promise; + setError: (msg: string | null) => void; +} + +/** + * Encapsulates the repeated load/loading/error state machine: + * useState(true) + useState(null) + try/catch/finally + useEffect. + * + * @param fetcher Async function that returns the data. + * @param deps Dependency list β€” when they change the data is re-fetched automatically. + */ +export function useResource( + fetcher: () => Promise, + deps: DependencyList, +): ResourceState { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Increment to trigger a manual reload without changing external deps. + const [tick, setTick] = useState(0); + + // Keep fetcher stable across renders via ref so the effect dep array stays clean. + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + + // Cancellation token: each fetch cycle gets a unique object; stale fetches are ignored. + const cancelRef = useRef({}); + + useEffect(() => { + const token = {}; + cancelRef.current = token; + setLoading(true); + setError(null); + fetcherRef.current() + .then((result) => { + if (cancelRef.current === token) { setData(result); setLoading(false); } + }) + .catch((err: unknown) => { + if (cancelRef.current === token) { setError(errMsg(err)); setLoading(false); } + }); + return () => { cancelRef.current = {}; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tick, ...deps]); + + /** + * Imperatively re-fetch and return a Promise that settles when done. + * Safe to use as a `useToolManager` reload callback. + */ + const reload = useCallback((): Promise => { + setTick((n) => n + 1); + // Run the fetcher directly so callers that await reload() get fresh data. + setLoading(true); + setError(null); + return fetcherRef.current() + .then((result) => { setData(result); setLoading(false); }) + .catch((err: unknown) => { setError(errMsg(err)); setLoading(false); }); + }, []); + + return { data, loading, error, reload, setError }; +} diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts new file mode 100644 index 00000000..cd7b3028 --- /dev/null +++ b/web/src/hooks/useTheme.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; +import { + getStoredMode, + resolveMode, + setMode as applyMode, + subscribe, + type ResolvedTheme, + type ThemeMode, +} from '../lib/theme'; + +export function useTheme() { + const [mode, setLocalMode] = useState(getStoredMode); + const [resolved, setResolved] = useState(() => resolveMode(getStoredMode())); + + useEffect( + () => + subscribe(() => { + const next = getStoredMode(); + setLocalMode(next); + setResolved(resolveMode(next)); + }), + [], + ); + + const setMode = (m: ThemeMode) => applyMode(m); + const toggle = () => applyMode(resolved === 'dark' ? 'light' : 'dark'); + + return { mode, resolved, setMode, toggle }; +} diff --git a/web/src/hooks/useToolManager.ts b/web/src/hooks/useToolManager.ts index 4418e3ce..0625c5e9 100644 --- a/web/src/hooks/useToolManager.ts +++ b/web/src/hooks/useToolManager.ts @@ -1,65 +1,37 @@ import { useState } from 'react'; -import { api, ToolInfo, ModuleInfo } from '../lib/api'; +import { api, ToolInfo, ModuleInfo, ToolAccessLevel } from '../lib/api'; +import { errMsg } from '../lib/utils'; export function useToolManager(reloadFn: () => Promise) { const [updating, setUpdating] = useState(null); const [error, setError] = useState(null); - const toggleEnabled = async (toolName: string, currentEnabled: boolean) => { - setUpdating(toolName); + // Shared envelope: mark `key` updating, run the mutation, reload, surface errors. + const runUpdate = async (key: string, body: () => Promise) => { + setUpdating(key); try { - await api.updateToolConfig(toolName, { enabled: !currentEnabled }); + await body(); await reloadFn(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errMsg(err)); } finally { setUpdating(null); } }; - const updateScope = async (toolName: string, newScope: ToolInfo['scope']) => { - setUpdating(toolName); - try { - await api.updateToolConfig(toolName, { scope: newScope }); - await reloadFn(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setUpdating(null); - } - }; + // Set the access level for a single tool. + const updateLevel = (tool: ToolInfo, level: ToolAccessLevel) => + runUpdate(tool.name, () => api.updateToolConfig(tool.name, { level })); - const bulkToggle = async (module: ModuleInfo, enabled: boolean) => { - setUpdating(module.name); - try { + // Set the access level for every tool in a module. + const bulkLevel = (module: ModuleInfo, level: ToolAccessLevel) => + runUpdate(module.name, async () => { for (const tool of module.tools) { - if (tool.enabled !== enabled) { - await api.updateToolConfig(tool.name, { enabled }); + if (tool.level !== level) { + await api.updateToolConfig(tool.name, { level }); } } - await reloadFn(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setUpdating(null); - } - }; - - const bulkScope = async (module: ModuleInfo, scope: ToolInfo['scope']) => { - setUpdating(module.name); - try { - for (const tool of module.tools) { - if (tool.scope !== scope) { - await api.updateToolConfig(tool.name, { scope }); - } - } - await reloadFn(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setUpdating(null); - } - }; + }); - return { updating, error, setError, toggleEnabled, updateScope, bulkToggle, bulkScope }; + return { updating, error, setError, updateLevel, bulkLevel }; } diff --git a/web/src/index.css b/web/src/index.css index 2b6680ba..d1d9c897 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -7,70 +7,22 @@ padding: 0; } +/* ───────────────────────────────────────────────────────────── + Design tokens β€” grounded in the Telegram iOS UI Kit. + Theme-independent tokens live in :root; color tokens are themed. + The useTheme hook sets [data-theme="light|dark"] on . + Variable NAMES are preserved so existing rules keep working. + ───────────────────────────────────────────────────────────── */ :root { - color-scheme: dark; - - /* ── Surfaces (midnight blue) ── */ - --bg-primary: #0b1120; - --bg-secondary: rgba(255, 255, 255, 0.06); - --bg-tertiary: rgba(255, 255, 255, 0.08); - --bg-muted: rgba(255, 255, 255, 0.06); - --bg-glass: rgba(255, 255, 255, 0.05); - --bg-glass-hover: rgba(255, 255, 255, 0.10); - - /* ── Glass system (5 levels) ── */ - --glass-ultrathin: rgba(255, 255, 255, 0.02); - --glass-micro: rgba(255,255,255,0.03); - --glass-thin: rgba(255, 255, 255, 0.04); - --glass-regular: rgba(255, 255, 255, 0.06); - --glass-thick: rgba(255, 255, 255, 0.10); - --glass-ultrathick: rgba(255, 255, 255, 0.14); - - /* ── Borders ── */ - --border: rgba(255, 255, 255, 0.10); - --border-glass: rgba(255, 255, 255, 0.08); - --border-strong: rgba(255, 255, 255, 0.20); - - /* ── Text ── */ - --text-primary: #e8e8ed; - --text-secondary: #8e8e93; - --text-tertiary: rgba(255, 255, 255, 0.38); - --text-on-accent: #ffffff; - - /* ── Accent (electric blue) ── */ - --accent: #2d8cf0; - --accent-hover: #1a6dd6; - --accent-subtle: rgba(45, 140, 240, 0.10); - --accent-soft: #5aa8f5; - --accent-dim: rgba(45, 140, 240, 0.12); - --accent-shadow: none; + color-scheme: light dark; - /* ── Semantic (pastel / desaturated) ── */ - --success: #7dd8a0; - --warning: #f0c878; - --error: #f09090; - --info: #a0a4f0; - --green: #7dd8a0; - --green-dim: rgba(125, 216, 160, 0.15); - --red: #f09090; - --red-dim: rgba(240, 144, 144, 0.12); - --purple: #c8a0f0; - --purple-dim: rgba(200, 160, 240, 0.12); - --cyan: #90d8f0; - --cyan-dim: rgba(144, 216, 240, 0.12); - - /* ── Radii ── */ + /* ── Radii (iOS) ── */ --radius-pill: 999px; - --radius-card: 24px; - --radius-sm: 12px; - --radius-md: 14px; - --radius-lg: 20px; - --radius-xl: 26px; - - /* ── Shadows ── */ - --panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - --glass-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --radius-card: 22px; + --radius-sm: 10px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; /* ── Animation ── */ --easing-spring: cubic-bezier(.34, 1.56, .64, 1); @@ -89,9 +41,9 @@ --space-xl: 24px; --space-2xl: 32px; - /* ── Typography (Major Third scale 1.25, base 15px) ── */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + /* ── Typography (SF Pro / system) ── */ + --font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', 'Inter', system-ui, sans-serif; + --font-mono: 'SF Mono', 'JetBrains Mono', ui-monospace, 'Fira Code', monospace; --font-xs: 12px; --font-sm: 13px; --font-md: 14px; @@ -106,6 +58,125 @@ --leading-relaxed: 1.6; } +/* ── Light theme (default) ───────────────────────────────────── */ +:root, +:root[data-theme="light"] { + color-scheme: light; + + /* Surfaces β€” iOS grouped backgrounds */ + --bg-primary: #f2f2f7; + --bg-secondary: #ffffff; + --bg-tertiary: rgba(118, 118, 128, 0.12); + --bg-muted: rgba(118, 118, 128, 0.08); + --bg-glass: rgba(255, 255, 255, 0.72); + --bg-glass-hover: rgba(255, 255, 255, 0.88); + + /* Glass / system fills (gray translucencies, read on white or gray) */ + --glass-ultrathin: rgba(118, 118, 128, 0.04); + --glass-micro: rgba(118, 118, 128, 0.06); + --glass-thin: rgba(118, 118, 128, 0.08); + --glass-regular: rgba(118, 118, 128, 0.12); + --glass-thick: rgba(118, 118, 128, 0.16); + --glass-ultrathick: rgba(118, 118, 128, 0.20); + + /* Separators */ + --border: rgba(60, 60, 67, 0.18); + --border-glass: rgba(60, 60, 67, 0.12); + --border-strong: rgba(60, 60, 67, 0.28); + + /* Labels */ + --text-primary: #000000; + --text-secondary: rgba(60, 60, 67, 0.60); + --text-tertiary: rgba(60, 60, 67, 0.30); + --text-on-accent: #ffffff; + + /* Accent β€” Telegram blue (kit) */ + --accent: #008bff; + --accent-hover: #0072d6; + --accent-subtle: rgba(0, 139, 255, 0.10); + --accent-soft: #4daaff; + --accent-dim: rgba(0, 139, 255, 0.12); + --accent-shadow: none; + + /* Semantic β€” iOS system colors */ + --success: #34c759; + --warning: #ff9f0a; + --error: #ff3b30; + --info: #5856d6; + --green: #34c759; + --green-dim: rgba(52, 199, 89, 0.14); + --red: #ff3b30; + --red-dim: rgba(255, 59, 48, 0.12); + --purple: #af52de; + --purple-dim: rgba(175, 82, 222, 0.12); + --cyan: #32ade6; + --cyan-dim: rgba(50, 173, 230, 0.12); + + /* Shadows */ + --panel-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --glass-shadow: 0 4px 16px rgba(0, 0, 0, 0.10); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +/* ── Dark theme ──────────────────────────────────────────────── */ +:root[data-theme="dark"] { + color-scheme: dark; + + /* Surfaces β€” iOS grouped backgrounds (dark) */ + --bg-primary: #000000; + --bg-secondary: #1c1c1e; + --bg-tertiary: #2c2c2e; + --bg-muted: rgba(118, 118, 128, 0.18); + --bg-glass: rgba(28, 28, 30, 0.72); + --bg-glass-hover: rgba(44, 44, 46, 0.82); + + /* Glass / system fills (white translucencies) */ + --glass-ultrathin: rgba(255, 255, 255, 0.04); + --glass-micro: rgba(255, 255, 255, 0.06); + --glass-thin: rgba(255, 255, 255, 0.08); + --glass-regular: rgba(255, 255, 255, 0.10); + --glass-thick: rgba(255, 255, 255, 0.14); + --glass-ultrathick: rgba(255, 255, 255, 0.18); + + /* Separators */ + --border: rgba(255, 255, 255, 0.12); + --border-glass: rgba(255, 255, 255, 0.10); + --border-strong: rgba(255, 255, 255, 0.22); + + /* Labels */ + --text-primary: #ffffff; + --text-secondary: rgba(235, 235, 245, 0.60); + --text-tertiary: rgba(235, 235, 245, 0.30); + --text-on-accent: #ffffff; + + /* Accent β€” iOS systemBlue (dark) */ + --accent: #0a84ff; + --accent-hover: #3a9bff; + --accent-subtle: rgba(10, 132, 255, 0.16); + --accent-soft: #5aa8f5; + --accent-dim: rgba(10, 132, 255, 0.18); + --accent-shadow: none; + + /* Semantic β€” iOS system colors (dark) */ + --success: #30d158; + --warning: #ff9f0a; + --error: #ff453a; + --info: #5e5ce6; + --green: #30d158; + --green-dim: rgba(48, 209, 88, 0.16); + --red: #ff453a; + --red-dim: rgba(255, 69, 58, 0.16); + --purple: #bf5af2; + --purple-dim: rgba(191, 90, 242, 0.16); + --cyan: #64d2ff; + --cyan-dim: rgba(100, 210, 255, 0.16); + + /* Shadows */ + --panel-shadow: 0 2px 8px rgba(0, 0, 0, 0.40); + --glass-shadow: 0 4px 16px rgba(0, 0, 0, 0.50); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.50); +} + html, body { height: 100%; } @@ -150,7 +221,10 @@ body { } .sidebar-brand { - padding: 4px 12px 20px; + display: flex; + justify-content: center; + align-items: center; + padding: 6px 8px 14px; font-size: var(--font-lg); font-weight: 600; letter-spacing: -0.2px; @@ -291,51 +365,6 @@ body { letter-spacing: -0.5px; } -/* ---- Dashboard status bar ---- */ - -.status-bar { - padding: 14px 18px !important; - border-radius: var(--radius-pill) !important; -} - -.status-row { - display: flex; - align-items: center; - justify-content: center; - gap: 24px; - flex-wrap: wrap; -} - -.status-divider { - height: 1px; - background: var(--border); - margin: 10px 0; -} - -.metric { - display: flex; - align-items: baseline; - gap: 6px; -} - -.metric-label { - font-size: var(--font-xs); - font-weight: 500; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.4px; -} - -.metric-value { - font-size: var(--font-base); - font-weight: 600; - color: var(--text-primary); -} - -.metric-value.mono { - font-size: var(--font-md); - font-family: var(--font-mono); -} /* ---- Dashboard layout ---- */ @@ -378,13 +407,6 @@ body { margin-bottom: 10px; } -.dashboard-logs-scroll { - flex: 1; - min-height: 0; - max-height: 360px; - overflow-y: auto; -} - /* ---- Buttons ---- */ button { @@ -392,11 +414,11 @@ button { background: var(--accent); color: var(--text-on-accent); border: none; - height: 48px; - padding: 8px 16px; + height: 36px; + padding: 0 16px; border-radius: var(--radius-pill); cursor: pointer; - font-size: 17px; + font-size: 14px; font-weight: 500; transition: all var(--duration-color) var(--easing-standard); } @@ -419,21 +441,31 @@ button:disabled { input, textarea, select { font-family: var(--font-sans); - background: rgba(0, 0, 0, 0.25); + background: var(--glass-thin); border: 1px solid var(--border-glass); color: var(--text-primary); - height: 44px; - padding: 10px 14px; + height: 38px; + padding: 0 16px; border-radius: var(--radius-pill); font-size: var(--font-md); outline: none; transition: border-color var(--duration-color) var(--easing-standard), box-shadow var(--duration-color) var(--easing-standard); } +/* Multi-line fields stay a soft rounded rectangle β€” a pill on a tall textarea + reads wrong (iOS: search field = pill, compose area = rounded rect). */ +textarea { + height: auto; + min-height: 76px; + padding: 10px 16px; + border-radius: var(--radius-lg); + line-height: var(--leading-normal); +} + select { appearance: none; -webkit-appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.55)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238e8e93' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 36px; @@ -566,7 +598,7 @@ a:hover { } .badge.count { color: var(--text-primary); } -.badge.warn { color: var(--text-secondary); border-color: rgba(255, 255, 255, 0.12); } +.badge.warn { color: var(--text-secondary); border-color: var(--border); } .badge.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 30%, transparent); } .badge.always { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); } @@ -717,15 +749,15 @@ button.btn-sm { } .alert.success { - background: rgba(255, 255, 255, 0.04); + background: var(--glass-thin); color: var(--text-secondary); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-glass); } .alert.error { - background: rgba(255, 255, 255, 0.04); + background: var(--glass-thin); color: var(--text-secondary); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-glass); } /* ---- File rows (table rows in Workspace, Tasks) ---- */ @@ -778,6 +810,52 @@ button.btn-sm { border-bottom: none; } +/* ── Logs page ── */ +.logs-controls { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; } +.logs-autoscroll { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--font-sm); + color: var(--text-secondary); + white-space: nowrap; +} +.logs-card { padding: 0; overflow: hidden; } +.logs-statusbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid var(--border); + font-size: var(--font-sm); + color: var(--text-secondary); +} +.logs-shown { margin-left: auto; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } +.logs-jump { + height: 24px; + padding: 0 10px; + font-size: var(--font-xs); + font-weight: 600; + border-radius: var(--radius-pill); +} +.logs-scroll { + height: calc(100vh - 268px); + min-height: 320px; + overflow-y: auto; + padding: 8px 16px; +} +.logs-scroll .log-time { color: var(--text-tertiary); } +.logs-scroll .log-msg { color: var(--text-primary); white-space: pre-wrap; word-break: break-word; } +/* Level tint β€” bleeds into the scroll padding for a full-width row highlight. */ +.logs-scroll .log-entry.error, +.logs-scroll .log-entry.warn { + margin: 0 -16px; + padding-left: 16px; + padding-right: 16px; +} +.logs-scroll .log-entry.error { background: var(--red-dim); } +.logs-scroll .log-entry.warn { background: color-mix(in srgb, var(--warning) 10%, transparent); } + /* ---- Accordion header ---- */ .accordion-header { @@ -951,12 +1029,12 @@ button.btn-sm { } ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); + background: var(--glass-thick); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.2); + background: var(--glass-ultrathick); } /* ---- Segmented Control ---- */ @@ -1033,7 +1111,7 @@ button.btn-sm { .toggle-track { position: absolute; inset: 0; - background: rgba(255, 255, 255, 0.1); + background: var(--glass-thick); border-radius: 14px; transition: background 0.25s var(--easing-spring); } @@ -1111,50 +1189,92 @@ button.btn-sm { flex-shrink: 0; } -/* ---- Info Banner (collapsible) ---- */ - -.info-banner { - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border-glass); - overflow: hidden; - margin-bottom: var(--space-md); -} - -.info-banner-trigger { - all: unset; +/* ── Config card (grouped section with integrated header) ── */ +.config-card { padding: 0; overflow: hidden; } +.config-card-head { display: flex; align-items: center; - gap: 6px; - width: 100%; - padding: 8px 12px; - font-size: 12px; - color: var(--text-tertiary); - cursor: pointer; - transition: color 0.15s ease; - box-sizing: border-box; + justify-content: space-between; + gap: 12px; + padding: 13px 16px; + border-bottom: 1px solid var(--border); } - -.info-banner-trigger:hover { - color: var(--text-secondary); +.config-card-title { font-size: var(--font-base); font-weight: 600; color: var(--text-primary); } +.config-card-action { display: inline-flex; align-items: center; gap: 12px; flex-shrink: 0; } +.config-card-body { padding: 16px; transition: opacity var(--duration-color) var(--easing-standard); } +.config-card-body.dimmed { opacity: 0.4; pointer-events: none; user-select: none; } +.config-status { display: inline-flex; align-items: center; gap: 6px; font-size: var(--font-xs); color: var(--text-secondary); } +.config-status.running { color: var(--green); } +.config-spinner { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: config-spin 0.8s linear infinite; } - -.info-banner-chevron { - margin-left: auto; - transition: transform 0.2s ease; - opacity: 0.5; +@keyframes config-spin { to { transform: rotate(360deg); } } +.config-subhead { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + margin: 16px 4px 8px; } -.info-banner[data-open] .info-banner-chevron { - transform: rotate(180deg); +/* ── Tags (ArrayInput) ── */ +.array-input.disabled { opacity: 0.5; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: var(--space-sm); } +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 5px 3px 10px; + background: var(--glass-thin); + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); + font-size: var(--font-sm); + color: var(--text-primary); } - -.info-banner-content { - padding: 0 12px 10px; - font-size: 12px; - line-height: 1.5; +.tag.focused { outline: 2px solid var(--accent); outline-offset: 1px; } +.tag-label { font-family: var(--font-mono); letter-spacing: -0.2px; } +.tag-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 17px; + height: 17px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--glass-regular); color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-color), color var(--duration-color); +} +.tag-remove:hover { background: var(--red); color: #fff; } +.tag-remove:disabled { cursor: not-allowed; opacity: 0.5; } +.tag-input-row { display: flex; gap: 8px; align-items: center; } +.tag-input-row button { flex-shrink: 0; height: 38px; padding: 0 18px; } +.tag-input { + flex: 1; + min-width: 0; + height: 38px; + padding: 0 16px; + font-family: var(--font-mono); + font-size: var(--font-sm); + color: var(--text-primary); + background: var(--glass-thin); + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); + outline: none; } +.tag-input:focus { border-color: var(--accent); } +.tag-input[aria-invalid="true"] { border-color: var(--red); } +.tag-error { font-size: var(--font-sm); color: var(--red); margin-top: 4px; } +.tag-actions { display: flex; gap: 8px; margin-top: var(--space-sm); } /* ---- Modal ---- */ @@ -1172,10 +1292,10 @@ button.btn-sm { } .modal { - background: rgba(30, 30, 35, 0.65); + background: var(--bg-glass); backdrop-filter: blur(50px) saturate(1.8); -webkit-backdrop-filter: blur(50px) saturate(1.8); - border: 1px solid rgba(255, 255, 255, 0.12); + border: 1px solid var(--border); border-radius: var(--radius-xl); padding: 28px; max-width: 480px; @@ -1213,19 +1333,6 @@ button.btn-sm { text-align: center; } -/* ---- Shimmer skeleton ---- */ - -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -} - -.skeleton { - background: linear-gradient(90deg, var(--glass-thin) 25%, var(--glass-regular) 50%, var(--glass-thin) 75%); - background-size: 200% 100%; - animation: shimmer 1.2s infinite; - border-radius: var(--radius-sm); -} /* ---- Setup Wizard ---- */ @@ -1382,8 +1489,8 @@ button.btn-sm { } .provider-card.selected { - border-color: rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.08); + border-color: var(--border-strong); + background: var(--glass-thick); } .provider-card h3 { @@ -1430,8 +1537,8 @@ button.btn-sm { .guide-dropdown { margin-bottom: 20px; border-radius: var(--radius-sm); - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-glass); + background: var(--glass-micro); } .guide-dropdown summary { @@ -1468,7 +1575,7 @@ button.btn-sm { font-size: 13px; line-height: 1.6; color: var(--text-secondary); - border-top: 1px solid rgba(255, 255, 255, 0.06); + border-top: 1px solid var(--border-glass); } .guide-section { @@ -1496,7 +1603,7 @@ button.btn-sm { } .guide-section code { - background: rgba(255, 255, 255, 0.08); + background: var(--glass-thick); padding: 1px 5px; border-radius: 3px; font-size: 12px; @@ -1510,9 +1617,9 @@ button.btn-sm { margin-bottom: 16px; font-size: 13px; line-height: 1.6; - background: rgba(255, 255, 255, 0.04); + background: var(--glass-thin); color: var(--text-secondary); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-glass); } .warning-card strong { @@ -1527,9 +1634,9 @@ button.btn-sm { margin-bottom: 16px; font-size: 13px; line-height: 1.5; - background: rgba(255, 255, 255, 0.04); + background: var(--glass-thin); color: var(--text-secondary); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-glass); } /* ---- Code Input (Telegram verification) ---- */ @@ -1802,8 +1909,9 @@ button.btn-sm { /* ---- Button sizes ---- */ button.btn-lg { - padding: 12px 24px; - font-size: var(--font-base); + height: 40px; + padding: 0 20px; + font-size: var(--font-md); font-weight: 600; } @@ -1970,3 +2078,1120 @@ button.btn-lg { .hover-fade:hover { opacity: 0.9; } .hover-fade-half { opacity: 0.5; transition: opacity 0.15s; } .hover-fade-half:hover { opacity: 0.9; } + +/* ── Toasts ── */ +.toaster { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + max-width: min(380px, calc(100vw - 40px)); + pointer-events: none; +} +.toast { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius-md); + background: var(--bg-glass); + backdrop-filter: blur(40px) saturate(1.6); + -webkit-backdrop-filter: blur(40px) saturate(1.6); + border: 1px solid var(--border-glass); + box-shadow: var(--glass-shadow); + color: var(--text-primary); + font-size: var(--font-md); + line-height: var(--leading-normal); + cursor: pointer; + animation: toast-in 220ms var(--easing-ease-out); + border-left: 3px solid var(--accent); +} +.toast-success { border-left-color: var(--green); } +.toast-error { border-left-color: var(--red); } +.toast-info { border-left-color: var(--accent); } +@keyframes toast-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Confirm dialog ── */ +.confirm-modal { max-width: 400px; padding: 22px; } +.confirm-title { + font-size: var(--font-lg); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} +.confirm-message { + font-size: var(--font-md); + color: var(--text-secondary); + line-height: var(--leading-normal); + margin-bottom: 20px; +} +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} +button.btn-destructive { + background: var(--red); + color: #fff; +} +button.btn-destructive:hover { background: var(--red); opacity: 0.9; } + +/* ── Skeletons ── */ +.skeleton { + background: linear-gradient( + 90deg, + var(--glass-thin) 25%, + var(--glass-thick) 37%, + var(--glass-thin) 63% + ); + background-size: 400% 100%; + animation: skeleton-shimmer 1.4s ease infinite; +} +.skeleton-group { display: flex; flex-direction: column; gap: 10px; } +@keyframes skeleton-shimmer { + from { background-position: 100% 50%; } + to { background-position: 0 50%; } +} + +/* ── Empty state ── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + gap: 6px; +} +.empty-state-icon { color: var(--text-tertiary); margin-bottom: 6px; opacity: 0.8; } +.empty-state-title { font-size: var(--font-base); font-weight: 600; color: var(--text-primary); } +.empty-state-desc { font-size: var(--font-md); color: var(--text-secondary); max-width: 380px; } +.empty-state-action { margin-top: 12px; } + +/* ── Chat master-detail (single integrated card) ── */ +.chat-layout { + display: flex; + height: calc(100vh - 168px); + min-height: 420px; + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + overflow: hidden; +} +.chat-list-pane { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--border); +} +.chat-list-head { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.chat-list-scroll { flex: 1; overflow-y: auto; min-height: 0; } +.chat-detail-pane { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.chat-detail-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.chat-detail-body { flex: 1; min-height: 0; overflow-y: auto; } +@media (max-width: 768px) { + .chat-layout { flex-direction: column; height: auto; } + .chat-list-pane { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 280px; } + .chat-detail-pane { min-height: 360px; } +} + +/* ── Chat bubbles ── */ +.chat-thread { + display: flex; + flex-direction: column; + gap: 4px; + padding: 16px; +} +.chat-msg { + display: flex; + flex-direction: column; + max-width: 78%; +} +.chat-msg.out { align-self: flex-end; align-items: flex-end; } +.chat-msg.in { align-self: flex-start; align-items: flex-start; } +.chat-sender { + font-size: var(--font-xs); + font-weight: 600; + color: var(--accent); + margin: 0 0 2px 12px; +} +.chat-bubble { + padding: 7px 12px; + border-radius: 16px; + font-size: var(--font-md); + line-height: 1.4; + word-break: break-word; +} +.chat-msg.in .chat-bubble { + background: var(--glass-thick); + color: var(--text-primary); + border-bottom-left-radius: 5px; +} +.chat-msg.out .chat-bubble { + background: var(--accent); + color: #fff; + border-bottom-right-radius: 5px; +} +.chat-bubble.media { font-style: italic; opacity: 0.8; } +.chat-time { font-size: 10px; color: var(--text-tertiary); margin: 2px 8px 0; } + +/* ── Markdown ── */ +.md > :first-child { margin-top: 0; } +.md > :last-child { margin-bottom: 0; } +.md p { margin: 0 0 6px; } +.md h1, .md h2, .md h3, .md h4 { font-size: var(--font-base); font-weight: 700; margin: 8px 0 4px; } +.md ul, .md ol { margin: 4px 0; padding-left: 18px; } +.md li { margin: 1px 0; } +.md a { color: var(--accent); text-decoration: underline; } +.md strong { font-weight: 700; } +.md em { font-style: italic; } +.md code { + font-family: var(--font-mono); + font-size: 0.9em; + background: var(--glass-thick); + padding: 1px 4px; + border-radius: 4px; +} +.md pre { + background: var(--glass-thick); + padding: 8px 10px; + border-radius: 8px; + overflow: auto; + margin: 6px 0; +} +.md pre code { background: none; padding: 0; } +.md blockquote { + margin: 6px 0; + padding-left: 10px; + border-left: 2px solid var(--border-strong); + color: var(--text-secondary); +} +.md hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; } +.md table { border-collapse: collapse; margin: 6px 0; font-size: var(--font-sm); } +.md th, .md td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; } + +/* Markdown inside an outgoing (accent) bubble */ +.chat-msg.out .chat-bubble .md a { color: #fff; } +.chat-msg.out .chat-bubble .md code, +.chat-msg.out .chat-bubble .md pre { background: rgba(255, 255, 255, 0.2); } +.chat-msg.out .chat-bubble .md blockquote { border-left-color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.85); } + +/* ── Refresh button (unified) ── */ +.refresh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + flex-shrink: 0; + background: transparent; + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); + color: var(--text-secondary); + cursor: pointer; + transition: color var(--duration-color) var(--easing-standard), + background var(--duration-color) var(--easing-standard); +} +.refresh-btn:hover { background: var(--glass-thin); color: var(--text-primary); } +.refresh-btn svg { display: block; } +.refresh-btn svg.spin { animation: refresh-spin 0.7s linear infinite; transform-origin: center; } +@keyframes refresh-spin { to { transform: rotate(360deg); } } + +/* ── Code / content block ── */ +.code-block { + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + background: var(--glass-thin); + overflow: hidden; +} +.code-block-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-bottom: 1px solid var(--border-glass); + font-size: var(--font-xs); + color: var(--text-secondary); +} +.code-block-pre { + margin: 0; + padding: 10px 12px; + font-family: var(--font-mono); + font-size: var(--font-sm); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + overflow: auto; + color: var(--text-primary); +} + +/* ── iOS grouped list ── */ +.ios-list { + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + overflow: hidden; +} +.ios-row { + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + padding: 8px 16px; + position: relative; + cursor: default; + transition: background var(--duration-color) var(--easing-standard); +} +.ios-row.tappable { cursor: pointer; } +.ios-row.tappable:hover { background: var(--glass-thin); } +.ios-row.expanded { background: var(--glass-micro); } +.ios-row.selected { background: var(--glass-thick); } +.ios-row.selected:hover { background: var(--glass-thick); } +/* Inset separator: starts after the leading content, like iOS */ +.ios-row:not(:first-child)::before { + content: ''; + position: absolute; + top: 0; + left: 16px; + right: 0; + height: 1px; + background: var(--border); +} +.ios-row.inset-sep::before { left: 52px; } +.ios-row-lead { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 7px; + background: var(--accent); + color: #fff; + flex-shrink: 0; +} +.ios-row-main { flex: 1; min-width: 0; } +.ios-row-title { + font-size: var(--font-base); + font-weight: 500; + color: var(--text-primary); + letter-spacing: -0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ios-row-sub { + font-size: var(--font-sm); + color: var(--text-secondary); + margin-top: 1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ios-row-trail { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} +.ios-row-chevron { + color: var(--text-tertiary); + display: inline-flex; + transition: transform var(--duration-color) var(--easing-standard); +} +.ios-row-chevron.open { transform: rotate(90deg); } +.ios-sublist { background: var(--glass-micro); } +.ios-sublist .ios-row { padding-left: 28px; } +.ios-row.dimmed .ios-row-main { opacity: 0.5; } + +/* ── Menu (popover) ── */ +.menu-anchor { position: relative; display: inline-flex; } +.menu-trigger { border: none; background: none; padding: 0; cursor: pointer; } +.menu-pop { + position: absolute; + top: calc(100% + 6px); + z-index: 50; + min-width: 220px; + padding: 6px; + background: var(--bg-glass); + backdrop-filter: blur(40px) saturate(1.6); + -webkit-backdrop-filter: blur(40px) saturate(1.6); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + box-shadow: var(--glass-shadow); + animation: menu-in 0.16s var(--easing-standard); +} +.menu-pop.right { right: 0; } +.menu-pop.left { left: 0; } +@keyframes menu-in { + from { opacity: 0; transform: translateY(-4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.menu-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 9px 10px; + border: none; + background: none; + border-radius: var(--radius-sm); + font-size: var(--font-base); + color: var(--text-primary); + cursor: pointer; + text-align: left; + transition: background var(--duration-color) var(--easing-standard); +} +.menu-item:hover:not(:disabled) { background: var(--glass-thin); } +.menu-item:disabled { opacity: 0.4; cursor: default; } +.menu-item.destructive { color: var(--red); } +.menu-item-icon { display: inline-flex; width: 20px; justify-content: center; color: var(--text-secondary); flex-shrink: 0; } +.menu-item.destructive .menu-item-icon { color: var(--red); } +.menu-item-label { flex: 1; } +.menu-sep { height: 1px; margin: 5px 8px; background: var(--border); } + +/* ── Workspace ── */ +.ios-row-lead.muted { background: var(--glass-regular); color: var(--text-secondary); } + +.ws-path { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; } +.ws-crumbs { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); +} +.ws-crumb { + border: none; + background: none; + padding: 2px 6px; + font-size: var(--font-sm); + color: var(--accent); + cursor: pointer; + border-radius: var(--radius-sm); +} +.ws-crumb:hover { background: var(--glass-thin); } +.ws-crumb.current { color: var(--text-primary); font-weight: 600; cursor: default; } +.ws-crumb.current:hover { background: none; } +.ws-crumb-sep { color: var(--text-tertiary); font-size: var(--font-sm); } + +.ws-add { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--accent); + color: #fff; +} +.ws-add:hover { background: var(--accent-hover); } + +.ws-prompt { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + margin-bottom: 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); +} +.ws-prompt-label { font-size: var(--font-sm); color: var(--text-secondary); white-space: nowrap; } +.ws-prompt input { + flex: 1; + min-width: 0; + padding: 7px 12px; + font-size: var(--font-sm); + background: var(--glass-thin); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + color: var(--text-primary); +} +.ws-prompt input:focus { outline: none; border-color: var(--accent); } + +.ws-row-actions { display: flex; align-items: center; gap: 2px; } +.ws-act { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: none; + background: none; + border-radius: 50%; + cursor: pointer; + color: var(--text-tertiary); + transition: background var(--duration-color), color var(--duration-color); +} +.ws-act svg { flex-shrink: 0; } +.ws-act:hover { background: var(--glass-regular); color: var(--text-primary); } +.ws-act.delete:hover { background: var(--red-dim); color: var(--red); } + +.ws-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.ws-editor-name { + min-width: 0; + font-family: var(--font-mono); + font-size: var(--font-sm); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ws-editor-name .dot { color: var(--accent); margin-left: 6px; } +.ws-editor { + width: 100%; + min-height: 280px; + max-height: 60vh; + padding: 12px; + font-family: var(--font-mono); + font-size: var(--font-sm); + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-primary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + resize: vertical; + tab-size: 2; +} +.ws-editor:focus { outline: none; border-color: var(--accent); } +.ws-preview { display: flex; justify-content: center; padding: 16px 0; } +.ws-preview img { max-width: 100%; max-height: 60vh; object-fit: contain; border-radius: var(--radius-md); } +.ws-binary { padding: 24px; text-align: center; color: var(--text-secondary); font-size: var(--font-sm); } + +/* ── Wallet ── */ +.wallet-hero { + position: relative; + overflow: hidden; + background: + radial-gradient(120% 140% at 100% 0%, var(--accent-subtle), transparent 55%), + var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-card); + padding: var(--space-xl); + margin-bottom: var(--space-lg); +} +.wallet-diamond { + position: absolute; + top: -8px; + right: 4px; + width: 96px; + height: 96px; + color: var(--accent); + opacity: 0.12; + pointer-events: none; +} +.wallet-hero-empty { + text-align: center; + color: var(--text-secondary); + font-size: var(--font-sm); + padding: var(--space-md) 0; +} +.wallet-hero-label { + font-size: var(--font-xs); + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--text-tertiary); +} +.wallet-balance { + display: flex; + align-items: baseline; + gap: 8px; + margin: 6px 0 16px; +} +.wallet-balance-amount { + font-size: 40px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text-primary); + line-height: 1; +} +.wallet-balance-unit { + font-size: var(--font-lg); + font-weight: 600; + color: var(--accent); +} +.wallet-address { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 8px 6px 12px; + background: var(--glass-thin); + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); + max-width: 100%; +} +.wallet-address code { + font-family: var(--font-mono); + font-size: var(--font-sm); + color: var(--text-primary); + word-break: break-all; + line-height: 1.4; +} +.wallet-addr-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + min-width: 26px; + padding: 0; + box-sizing: border-box; + flex-shrink: 0; + border-radius: var(--radius-pill); + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-color) var(--easing-standard), color var(--duration-color) var(--easing-standard); +} +.wallet-addr-btn svg { display: block; width: 15px; height: 15px; flex-shrink: 0; } +.wallet-addr-btn:hover { + background: var(--glass-regular); + color: var(--accent); +} + +/* Transaction rows: direction-colored leading icon + amount */ +.ios-row.tx-in .ios-row-lead { background: var(--green-dim); color: var(--green); } +.ios-row.tx-out .ios-row-lead { background: var(--red-dim); color: var(--red); } +.ios-row.tx-other .ios-row-lead { background: var(--bg-muted); color: var(--text-secondary); } +.ios-row.tx-in .ios-row-sub, +.ios-row.tx-out .ios-row-sub, +.ios-row.tx-other .ios-row-sub { font-family: var(--font-mono); font-size: var(--font-xs); } +.tx-amount { + font-size: var(--font-base); + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: -0.2px; +} +.tx-amount-in { color: var(--green); } +.tx-amount-out { color: var(--text-primary); } +.tx-amount-other { color: var(--text-secondary); } + +.tx-detail { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px 14px 52px; +} +.tx-kv { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + font-size: var(--font-sm); +} +.tx-kv-label { color: var(--text-secondary); flex-shrink: 0; } +.tx-kv-value { + color: var(--text-primary); + text-align: right; + word-break: break-all; +} +.tx-kv-value.mono { font-family: var(--font-mono); font-size: var(--font-xs); } +.tx-kv-value a { color: var(--accent); } + +/* ── Hooks ── */ +.hooks-card { margin-bottom: var(--space-lg); } +.hooks-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-lg); + margin-bottom: var(--space-lg); +} +.hooks-card-head .card-description { font-size: var(--font-sm); margin-top: 2px; } +.hooks-body { + display: flex; + flex-direction: column; + gap: var(--space-lg); + transition: opacity var(--duration-color) var(--easing-standard); +} +.hooks-body.dimmed { opacity: 0.45; pointer-events: none; } +.field-label { + display: block; + font-size: var(--font-xs); + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +/* Chip input */ +.chip-field { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + padding: 8px; + min-height: 40px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.chip-field:focus-within { border-color: var(--accent); } +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 6px 3px 10px; + border-radius: var(--radius-pill); + background: var(--accent-subtle); + color: var(--text-primary); + font-size: var(--font-sm); +} +.chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-secondary); + font-size: 10px; + line-height: 1; + cursor: pointer; +} +.chip-remove:hover { background: var(--glass-regular); color: var(--text-primary); } +.chip-input { + flex: 1; + min-width: 110px; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: var(--font-sm); + padding: 2px 0; +} + +/* Trigger leading glyph + edit form */ +.ios-row .ios-row-lead { font-weight: 600; } +.ios-row.hooks-add .ios-row-lead { background: var(--glass-regular); color: var(--accent); } +.ios-row.hooks-add .ios-row-title { color: var(--accent); } +.hooks-edit { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px 14px; +} +.hooks-edit textarea { + min-height: 72px; + font-family: var(--font-sans); + font-size: var(--font-sm); +} +.hooks-edit-actions { display: flex; align-items: center; gap: 8px; } + +/* ── MCP ── */ +.mcp-add { margin-bottom: var(--space-lg); } +.mcp-add .section-title { margin-bottom: var(--space-md); } +.mcp-add .field { margin-bottom: var(--space-md); } +.mcp-add-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); } +.mcp-env-row { display: grid; grid-template-columns: 1fr 1.6fr auto; gap: 6px; margin-bottom: 6px; align-items: center; } +.mcp-add-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; } +.mcp-add-hint { flex: 1; font-size: var(--font-xs); color: var(--text-tertiary); } + +.ios-row.mcp-on .ios-row-lead { background: var(--green-dim); color: var(--green); } +.ios-row.mcp-off .ios-row-lead { background: var(--red-dim); color: var(--red); } +.mcp-title { display: flex; align-items: center; gap: 6px; } +.ios-row.mcp-on .ios-row-sub, +.ios-row.mcp-off .ios-row-sub { font-family: var(--font-mono); font-size: var(--font-xs); } + +.mcp-detail { display: flex; flex-direction: column; gap: 10px; padding: 12px 16px 14px; } +.mcp-status { display: inline-flex; align-items: center; gap: 6px; font-size: var(--font-sm); font-weight: 500; } +.mcp-status.on { color: var(--green); } +.mcp-status.off { color: var(--red); } +.mcp-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.mcp-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip.chip-mono { background: var(--glass-thin); color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--font-xs); padding: 3px 8px; } +.mcp-detail-actions { display: flex; } + +/* ── Tasks ── */ +.task-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; } +.ios-row.task-row .ios-row-lead { background: transparent; width: 12px; } +.ios-row.task-row::before { left: 16px; } +.task-dot.pulse { animation: task-pulse 1.4s ease-in-out infinite; } +@keyframes task-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 var(--accent-dim); } + 50% { opacity: 0.55; box-shadow: 0 0 0 4px transparent; } +} +.task-prio { font-size: 9px; letter-spacing: 1px; color: var(--text-tertiary); white-space: nowrap; } + +.task-detail { display: flex; flex-direction: column; gap: 10px; padding: 12px 16px 14px; } +.task-kv { + display: grid; + grid-template-columns: 110px 1fr; + gap: 5px 12px; + font-size: var(--font-sm); +} +.task-kv-label { color: var(--text-secondary); } +.task-kv-value { color: var(--text-primary); word-break: break-word; } +.task-kv-value code { font-family: var(--font-mono); font-size: var(--font-xs); word-break: break-all; } +.task-detail-actions { display: flex; gap: 8px; margin-top: 2px; } + +/* ── Dashboard ── */ +.dash-hero { display: flex; align-items: center; gap: 14px; padding: 16px 18px; } +.dash-orb { position: relative; width: 12px; height: 12px; border-radius: 50%; background: var(--green); flex-shrink: 0; } +.dash-orb::after { + content: ''; position: absolute; inset: 0; border-radius: 50%; background: var(--green); + animation: orb-ping 1.9s ease-out infinite; +} +@keyframes orb-ping { 0% { opacity: 0.55; transform: scale(1); } 100% { opacity: 0; transform: scale(3.4); } } +.dash-hero-main { flex: 1; min-width: 0; } +.dash-hero-title { display: flex; align-items: center; gap: 10px; font-size: var(--font-lg); font-weight: 600; color: var(--text-primary); } +.dash-hero-state { + font-size: var(--font-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; + color: var(--green); background: var(--green-dim); padding: 2px 8px; border-radius: var(--radius-pill); +} +.dash-hero-sub { font-size: var(--font-sm); color: var(--text-secondary); margin-top: 3px; } +.dash-hero-selects { display: flex; gap: 12px; flex-shrink: 0; } +.dash-hero-field { display: flex; flex-direction: column; gap: 5px; width: 168px; } +.dash-hero-field.model { width: 232px; } +.dash-hero-label { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--text-tertiary); + padding-left: 2px; +} + +/* Compact menu-bar-style stats strip */ +.dash-statbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 6px; + padding: 6px 10px; + margin-bottom: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-pill); +} +.stat-item { + display: inline-flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; + padding: 3px 9px; + border-radius: var(--radius-pill); +} +.stat-item .stat-v { font-size: var(--font-sm); font-weight: 600; color: var(--text-primary); } +.stat-item .stat-v.mono { font-family: var(--font-mono); } +.stat-item .stat-k { font-size: var(--font-xs); color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.3px; } +.stat-item.clickable { cursor: pointer; transition: background var(--duration-color) var(--easing-standard); } +.stat-item.clickable:hover { background: var(--glass-thin); } +.stat-item.clickable:hover .stat-k { color: var(--text-secondary); } + +.dashboard-settings { margin-bottom: 16px; } +.dash-policy { display: grid; gap: 16px; margin-top: 16px; } + +/* ── Allow Lists β€” iOS grouped list, one ID per row (Figma 6023:2101) ── */ +.dash-card-fill { display: flex; flex-direction: column; } +.allowlists { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; } +/* Tabs hug their 3 buttons instead of stretching to the card width. */ +.allowlists .segmented { align-self: flex-start; } +.allowlist-rows { display: flex; flex-direction: column; flex: 1; min-height: 120px; } +.allowlist-row { + display: flex; + align-items: center; + gap: 10px; + min-height: 46px; + border-top: 1px solid var(--border); +} +.allowlist-row:first-child { border-top: none; } +.allowlist-id { + flex: 1; + min-width: 0; + font-family: var(--font-mono); + font-size: var(--font-md); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.allowlist-clear { + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + display: grid; + place-items: center; + border-radius: var(--radius-pill); + background: var(--glass-thick); + color: var(--text-tertiary); + transition: background var(--duration-color) var(--easing-standard), color var(--duration-color) var(--easing-standard); +} +.allowlist-clear:hover { + background: var(--red-dim); + color: var(--red); +} +.allowlist-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-tertiary); + font-size: var(--font-sm); +} +.allowlist-empty svg { opacity: 0.5; } +.allowlist-add { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} +/* Pill input (inherits the global pill field style) + matching pill button. */ +.allowlist-add input { + flex: 1; + min-width: 0; + font-family: var(--font-mono); +} +.allowlist-addbtn { flex-shrink: 0; height: 38px; } + +/* ── Telegram folder-style pill tabs (scope selector) ── */ +.pill-tabs { + display: flex; + gap: 4px; + overflow-x: auto; + scrollbar-width: none; + padding: 2px 0; +} +.pill-tabs::-webkit-scrollbar { display: none; } +.pill-tab { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--text-secondary); + height: 30px; + padding: 0 12px; + border-radius: var(--radius-pill); + font-size: var(--font-sm); + font-weight: 500; + letter-spacing: -0.08px; + cursor: pointer; + white-space: nowrap; + transition: color var(--duration-color) var(--easing-standard), + background var(--duration-color) var(--easing-standard); +} +.pill-tab:hover:not(.active):not(:disabled) { color: var(--text-primary); } +.pill-tab.active { + background: var(--glass-thick); + color: var(--text-primary); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} +.pill-tab:disabled { opacity: 0.45; cursor: not-allowed; } + +/* ── Tool row (expanded): name + access-level pills on one line ── */ +.tool-row2 { + display: flex; + align-items: center; + gap: 12px; + padding: 7px 16px; +} +.tool-row2:not(:first-child) { border-top: 1px solid var(--border); } +.tool-row2-main { flex: 1; min-width: 0; } +.tool-row2 .pill-tabs { flex-shrink: 0; } +.tool-row2 .tool-desc { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* List section header (uppercase, iOS grouped) */ +.ios-section-header { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-secondary); + padding: 6px 4px; +} + +/* ── Segmented control (Apple) ── */ +.segmented { + display: inline-flex; + padding: 3px; + gap: 2px; + background: var(--glass-regular); + border-radius: var(--radius-pill); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12); +} +.segmented-item { + border: none; + background: transparent; + color: var(--text-secondary); + height: 32px; + padding: 0 16px; + border-radius: var(--radius-pill); + font-size: var(--font-md); + font-weight: 510; + letter-spacing: -0.08px; + cursor: pointer; + transition: color var(--duration-color) var(--easing-standard), + background var(--duration-color) var(--easing-standard); + white-space: nowrap; +} +.segmented-item:hover:not(.active) { color: var(--text-primary); } +.segmented-item.active { + background: var(--bg-secondary); + color: var(--text-primary); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} +/* No blue focus ring / tap flash on the pill selectors (selection is the indicator) */ +.segmented-item, +.pill-tab { + -webkit-tap-highlight-color: transparent; +} +.segmented-item:focus, +.segmented-item:focus-visible, +.pill-tab:focus, +.pill-tab:focus-visible { + outline: none; +} + +/* ── iOS search bar ── */ +.ios-search { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 36px; + padding: 0 12px; + background: var(--glass-regular); + border: none; + border-radius: var(--radius-md); + color: var(--text-secondary); +} +.ios-search svg { flex-shrink: 0; opacity: 0.7; } +.ios-search input { + flex: 1; + height: auto; + min-width: 0; + padding: 0; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--font-base); +} +.ios-search input:focus { outline: none; box-shadow: none; } +.ios-search-clear { + all: unset; + display: inline-flex; + cursor: pointer; + color: var(--text-tertiary); + border-radius: 50%; +} +.ios-search-clear:hover { color: var(--text-secondary); } + +/* Indeterminate toggle (partial state) */ +.toggle input:indeterminate + .toggle-track { + background: var(--accent-dim); +} +.toggle input:indeterminate + .toggle-track + .toggle-thumb { + transform: translateX(10px); + background: var(--accent); +} + +/* ── Global focus-visible (a11y) ── */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible, +.custom-select-trigger:focus-visible, +.stepper-btn:focus-visible, +.toggle input:focus-visible + .toggle-track, +.theme-toggle-btn:focus-visible, +.pill-bar button:focus-visible, +[role="button"]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +/* Don't show the ring on mouse interactions */ +:focus:not(:focus-visible) { outline: none; } + +/* ── Floating top-right slot (dashboard theme toggle) ── */ +.shell-topright { + position: fixed; + top: 16px; + right: 20px; + z-index: 50; +} + +/* ── Theme toggle (iOS segmented) ── */ +.theme-toggle { + display: inline-flex; + gap: 2px; + padding: 2px; + border-radius: var(--radius-pill); + background: var(--glass-regular); + border: 1px solid var(--border-glass); +} +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 24px; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-secondary); + font-size: 13px; + line-height: 1; + cursor: pointer; + transition: color var(--duration-color) var(--easing-standard), + background var(--duration-color) var(--easing-standard); +} +.theme-toggle-btn:hover { color: var(--text-primary); } +.theme-toggle-btn.active { + background: var(--bg-secondary); + color: var(--accent); + box-shadow: var(--shadow-sm); +} diff --git a/web/src/lib/a11y.ts b/web/src/lib/a11y.ts new file mode 100644 index 00000000..43d20e13 --- /dev/null +++ b/web/src/lib/a11y.ts @@ -0,0 +1,25 @@ +import type { KeyboardEvent } from 'react'; + +/** + * Returns the a11y props needed to make a non-button element keyboard-activatable. + * Handles Enter and Space exactly like a native button would. + * + * Usage: + *
+ */ +export function expandableRowProps(onActivate: () => void): { + onKeyDown: (e: KeyboardEvent) => void; + tabIndex: number; + role: string; +} { + return { + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onActivate(); + } + }, + tabIndex: 0, + role: 'button', + }; +} diff --git a/web/src/lib/agent-status-store.ts b/web/src/lib/agent-status-store.ts new file mode 100644 index 00000000..b485506c --- /dev/null +++ b/web/src/lib/agent-status-store.ts @@ -0,0 +1,168 @@ +export type AgentState = 'stopped' | 'starting' | 'running' | 'stopping'; + +export interface AgentStatusSnapshot { + state: AgentState; + error: string | null; +} + +interface AgentStatusEvent { + state: AgentState; + error: string | null; + timestamp: number; +} + +type Listener = () => void; + +const SSE_URL = '/api/agent/events'; +const POLL_URL = '/api/agent/status'; +const MAX_RETRIES = 5; +const MAX_BACKOFF_MS = 30_000; +const POLL_INTERVAL_MS = 3_000; + +function backoffMs(attempt: number): number { + const base = Math.min(1000 * 2 ** attempt, MAX_BACKOFF_MS); + return base + base * 0.3 * Math.random(); +} + +/** + * Singleton store for agent run-state. A single EventSource (with polling + * fallback) is shared by every useAgentStatus() consumer β€” ref-counted so it + * connects on the first subscriber and tears down on the last. Previously each + * hook instance opened its own SSE connection (badge + control = 2 streams). + */ +class AgentStatusStore { + private snapshot: AgentStatusSnapshot = { state: 'stopped', error: null }; + private listeners = new Set(); + + private es: EventSource | null = null; + private retryCount = 0; + private retryTimer: ReturnType | null = null; + private pollTimer: ReturnType | null = null; + private visibilityBound = false; + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + if (this.listeners.size === 1) this.start(); + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0) this.stop(); + }; + }; + + getSnapshot = (): AgentStatusSnapshot => this.snapshot; + + private setStatus(state: AgentState, error: string | null) { + if (this.snapshot.state === state && this.snapshot.error === error) return; + this.snapshot = { state, error }; + for (const fn of this.listeners) fn(); + } + + private start() { + if (!this.visibilityBound) { + document.addEventListener('visibilitychange', this.handleVisibility); + this.visibilityBound = true; + } + this.connect(); + } + + private stop() { + this.closeSSE(); + this.stopPolling(); + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + if (this.visibilityBound) { + document.removeEventListener('visibilitychange', this.handleVisibility); + this.visibilityBound = false; + } + } + + private handleStatusEvent = (ev: MessageEvent) => { + try { + const data: AgentStatusEvent = JSON.parse(ev.data); + this.setStatus(data.state, data.error ?? null); + this.retryCount = 0; + } catch { + // ignore parse errors + } + }; + + private closeSSE() { + if (this.es) { + this.es.removeEventListener('status', this.handleStatusEvent as EventListener); + this.es.close(); + this.es = null; + } + } + + private stopPolling() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + private startPolling() { + if (this.pollTimer) return; + const poll = async () => { + try { + const res = await fetch(POLL_URL, { credentials: 'include' }); + if (!res.ok) return; + const json = await res.json(); + const data = json.data ?? json; + this.setStatus(data.state, data.error ?? null); + } catch { + // ignore fetch errors during polling + } + }; + poll(); + this.pollTimer = setInterval(poll, POLL_INTERVAL_MS); + } + + private connect = () => { + if (this.listeners.size === 0) return; + this.closeSSE(); + + const es = new EventSource(SSE_URL, { withCredentials: true }); + this.es = es; + + es.addEventListener('status', this.handleStatusEvent as EventListener); + + es.addEventListener('open', () => { + this.retryCount = 0; + this.stopPolling(); + }); + + es.onerror = () => { + this.closeSSE(); + if (this.listeners.size === 0) return; + + this.retryCount += 1; + if (this.retryCount <= MAX_RETRIES) { + const delay = backoffMs(this.retryCount - 1); + this.retryTimer = setTimeout(this.connect, delay); + } else { + // SSE exhausted β€” fall back to polling + this.startPolling(); + } + }; + }; + + private handleVisibility = () => { + if (document.hidden) { + this.closeSSE(); + this.stopPolling(); + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } else if (this.listeners.size > 0) { + this.retryCount = 0; + this.connect(); + } + }; +} + +// Singleton β€” one connection shared across all consumers, survives route changes. +export const agentStatusStore = new AgentStatusStore(); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8dd6d3db..07441445 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -26,13 +26,6 @@ export interface SetupProvider { keyPrefix: string | null; consoleUrl: string | null; requiresApiKey: boolean; - autoDetectsKey?: boolean; -} - -export interface ClaudeCodeKeyDetection { - found: boolean; - maskedKey: string | null; - valid: boolean; } export interface SetupModelOption { @@ -180,13 +173,19 @@ export interface WalletTransaction { nftAddress?: string; } +export type ToolAccessLevel = 'all' | 'allowlist' | 'admin' | 'off'; + export interface ToolInfo { name: string; description: string; module: string; - scope: 'open' | 'always' | 'dm-only' | 'group-only' | 'admin-only' | 'allowlist' | 'disabled'; + /** Per-tool access: who may use this tool (context-independent). */ + level: ToolAccessLevel; category?: string; - enabled: boolean; + /** Legacy single-value scope (derived) β€” present for backward compatibility. */ + scope?: string; + /** Derived: false only when the tool is off. */ + enabled?: boolean; } export interface ModuleInfo { @@ -239,8 +238,9 @@ export interface WorkspaceInfo { export interface ToolConfigData { tool: string; - enabled: boolean; - scope: string; + level: ToolAccessLevel; + scope?: string; + enabled?: boolean; } export interface ToolRagStatus { @@ -319,7 +319,11 @@ interface APIResponse { // ── Fetch helpers ─────────────────────────────────────────────────── -async function fetchSetupAPI(endpoint: string, options?: RequestInit): Promise { +async function fetchJson( + endpoint: string, + options: RequestInit | undefined, + opts: { credentials?: boolean; unwrapData?: boolean } +): Promise { const headers: HeadersInit = { 'Content-Type': 'application/json', ...options?.headers, @@ -328,6 +332,7 @@ async function fetchSetupAPI(endpoint: string, options?: RequestInit): Promis const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, + ...(opts.credentials ? { credentials: 'include' } : {}), // send HttpOnly cookie automatically }); if (!response.ok) { @@ -336,27 +341,15 @@ async function fetchSetupAPI(endpoint: string, options?: RequestInit): Promis } const json = await response.json(); - return json.data !== undefined ? json.data : json; + return opts.unwrapData ? (json.data !== undefined ? json.data : json) : json; } -async function fetchAPI(endpoint: string, options?: RequestInit): Promise { - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...options?.headers, - }; - - const response = await fetch(`${API_BASE}${endpoint}`, { - ...options, - headers, - credentials: 'include', // send HttpOnly cookie automatically - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || `HTTP ${response.status}`); - } +async function fetchSetupAPI(endpoint: string, options?: RequestInit): Promise { + return fetchJson(endpoint, options, { unwrapData: true }); +} - return response.json(); +async function fetchAPI(endpoint: string, options?: RequestInit): Promise { + return fetchJson(endpoint, options, { credentials: true }); } // ── Auth ──────────────────────────────────────────────────────────── @@ -497,7 +490,7 @@ export const api = { async updateToolConfig( toolName: string, - config: { enabled?: boolean; scope?: ToolInfo['scope'] } + config: { level?: ToolAccessLevel } ) { return fetchAPI>(`/tools/${toolName}`, { method: 'PUT', @@ -755,9 +748,6 @@ export const setup = { body: JSON.stringify({ provider, apiKey }), }), - detectClaudeCodeKey: () => - fetchSetupAPI('/setup/detect-claude-code-key'), - validateBotToken: (token: string) => fetchSetupAPI('/setup/validate/bot-token', { method: 'POST', diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts new file mode 100644 index 00000000..d2969376 --- /dev/null +++ b/web/src/lib/theme.ts @@ -0,0 +1,57 @@ +// Theme management β€” light / dark / system, grounded in the iOS token layer. +// Applies [data-theme="light|dark"] on ; the CSS in index.css does the rest. + +export type ThemeMode = 'light' | 'dark' | 'system'; +export type ResolvedTheme = 'light' | 'dark'; + +const STORAGE_KEY = 'teleton-theme'; +const mql = () => window.matchMedia('(prefers-color-scheme: dark)'); + +export function getStoredMode(): ThemeMode { + const v = localStorage.getItem(STORAGE_KEY); + return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'; +} + +export function resolveMode(mode: ThemeMode): ResolvedTheme { + return mode === 'system' ? (mql().matches ? 'dark' : 'light') : mode; +} + +function apply(mode: ThemeMode): void { + document.documentElement.dataset.theme = resolveMode(mode); +} + +// ── Subscriptions so every useTheme consumer re-renders on change ── +const subscribers = new Set<() => void>(); +export function subscribe(cb: () => void): () => void { + subscribers.add(cb); + return () => subscribers.delete(cb); +} +function notify(): void { + subscribers.forEach((cb) => cb()); +} + +// Keep in sync with the OS only while in "system" mode. +let mediaHandler: (() => void) | null = null; + +export function setMode(mode: ThemeMode): void { + localStorage.setItem(STORAGE_KEY, mode); + apply(mode); + + if (mediaHandler) { + mql().removeEventListener('change', mediaHandler); + mediaHandler = null; + } + if (mode === 'system') { + mediaHandler = () => { + apply('system'); + notify(); + }; + mql().addEventListener('change', mediaHandler); + } + notify(); +} + +// Call once, before first render, to avoid a flash of the wrong theme. +export function initTheme(): void { + setMode(getStoredMode()); +} diff --git a/web/src/lib/toast.tsx b/web/src/lib/toast.tsx new file mode 100644 index 00000000..cbcc5e63 --- /dev/null +++ b/web/src/lib/toast.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; + +// Lightweight toast store (react-hot-toast style) β€” importable anywhere, no provider. +// Mount once near the app root. + +export type ToastKind = 'success' | 'error' | 'info'; +export interface ToastItem { + id: number; + kind: ToastKind; + message: string; +} + +let items: ToastItem[] = []; +const subscribers = new Set<() => void>(); +let counter = 0; + +function emit() { + subscribers.forEach((cb) => cb()); +} + +function dismiss(id: number) { + items = items.filter((t) => t.id !== id); + emit(); +} + +function push(kind: ToastKind, message: string, ttl: number) { + const id = ++counter; + items = [...items, { id, kind, message }]; + emit(); + window.setTimeout(() => dismiss(id), ttl); +} + +export const toast = { + success: (message: string) => push('success', message, 3500), + error: (message: string) => push('error', message, 5000), + info: (message: string) => push('info', message, 3500), +}; + +export function Toaster() { + const [, force] = useState(0); + useEffect(() => { + const cb = () => force((n) => n + 1); + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, []); + + if (items.length === 0) return null; + + return ( +
+ {items.map((t) => ( +
dismiss(t.id)} + > + {t.message} +
+ ))} +
+ ); +} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index b87895a7..fc1485d1 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,5 +1,22 @@ +/** Extract a human message from an unknown thrown value. */ +export function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + export function formatDate(input: string | number | null | undefined, epochScale = 1): string { if (input == null) return '\u2014'; const date = typeof input === 'number' ? new Date(input * epochScale) : new Date(input); return date.toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' }); } + +export function formatDateTime(input: string | number | null | undefined, epochScale = 1): string { + if (input == null) return '\u2014'; + const date = typeof input === 'number' ? new Date(input * epochScale) : new Date(input); + return date.toLocaleString('fr-FR', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202a..42145090 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,9 +2,29 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { initTheme } from './lib/theme' +import { ConfirmProvider } from './components/ConfirmDialog' +import { Toaster } from './lib/toast' + +initTheme() + +// Self-heal stale code-split chunks: after a rebuild, an open tab may hold an +// index.html that references chunk hashes that no longer exist, so the dynamic +// import gets index.html (text/html) instead of JS. Reload once to fetch the +// fresh manifest. Guard with sessionStorage to avoid a reload loop. +window.addEventListener('vite:preloadError', () => { + if (!sessionStorage.getItem('chunk-reloaded')) { + sessionStorage.setItem('chunk-reloaded', '1'); + window.location.reload(); + } +}); +window.addEventListener('load', () => sessionStorage.removeItem('chunk-reloaded')); createRoot(document.getElementById('root')!).render( - + + + + , ) diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 93d26085..3e486484 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; import { api } from '../lib/api'; import { useConfigState } from '../hooks/useConfigState'; @@ -10,7 +10,10 @@ import { ArrayInput } from '../components/ArrayInput'; import { EditableField } from '../components/EditableField'; import { ConfigSection } from '../components/ConfigSection'; import { InfoTip } from '../components/InfoTip'; -import { InfoBanner } from '../components/InfoBanner'; +import { Alert } from '../components/Alert'; +import { errMsg } from '../lib/utils'; +import { toast } from '../lib/toast'; +import { useConfirm } from '../components/ConfirmDialog'; const TABS = [ { id: 'llm', label: 'LLM' }, @@ -36,14 +39,47 @@ const SESSION_KEYS = [ 'agent.session_reset_policy.idle_expiry_minutes', ]; +function Switch({ checked, onChange, disabled }: { + checked: boolean; + onChange: (e: React.ChangeEvent) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +/** Grouped card with an integrated header (title + control). Body dims when `dimmed`. */ +function ConfigCard({ title, action, dimmed, children }: { + title: string; + action?: ReactNode; + dimmed?: boolean; + children: ReactNode; +}) { + return ( +
+
+ {title} + {action &&
{action}
} +
+
{children}
+
+ ); +} + export function Config() { const [searchParams, setSearchParams] = useSearchParams(); const activeTab = searchParams.get('tab') || 'llm'; + const confirm = useConfirm(); + const config = useConfigState(); const configKeys = config.configKeys; - // TON Proxy state const [proxyLoading, setProxyLoading] = useState(false); const [proxyStatus, setProxyStatus] = useState<{ running: boolean; installed: boolean; port: number; enabled: boolean; pid?: number } | null>(null); const [proxyError, setProxyError] = useState(null); @@ -52,12 +88,11 @@ export function Config() { setSearchParams({ tab: id }, { replace: true }); }; - // Load proxy status when TON Proxy tab is active useEffect(() => { if (activeTab !== 'ton-proxy') return; api.getTonProxyStatus() .then((res) => setProxyStatus(res.data)) - .catch(() => {}); + .catch((err) => toast.error(errMsg(err))); }, [activeTab]); const handleArraySave = async (key: string, values: string[]) => { @@ -66,7 +101,7 @@ export function Config() { await api.setConfigKey(key, values); config.loadData(); } catch (err) { - config.setError(err instanceof Error ? err.message : String(err)); + config.setError(errMsg(err)); } }; @@ -80,26 +115,14 @@ export function Config() { {config.error && ( -
- {config.error} - -
+ config.setError(null)} style={{ marginBottom: '14px' }} /> )} - {/* LLM Tab */} {activeTab === 'llm' && ( <> -
-
Agent
-
- - Choose the AI provider and model that powers the agent. Stronger models reason better but cost more per message. The API key is sent only to the selected provider. -
-
-
Cocoon
-
+
Cocoon
)} - )} @@ -165,17 +185,59 @@ export function Config() { {/* Heartbeat Tab */} {activeTab === 'heartbeat' && ( - <> -
-
-
Heartbeat
+ { + const val = e.target.checked; + try { + await config.saveConfig('heartbeat.enabled', String(val)); + toast.success(val ? 'Heartbeat enabled' : 'Heartbeat disabled'); + } catch (err) { + toast.error(errMsg(err)); + } + }} + /> + } + > +
+ config.setLocal('heartbeat.interval_ms', String(Number(v) * 60000))} + onSave={(v) => config.saveConfig('heartbeat.interval_ms', String(Number(v) * 60000))} + onCancel={() => config.cancelLocal('heartbeat.interval_ms')} + min={1} + max={1440} + placeholder="30" + hotReload="restart" + inline + /> + +
+
+ Self-configurable + +
- - The agent wakes up on a timer, reads its HEARTBEAT.md checklist, and executes each task autonomously (check feeds, send reports, monitor wallets...). If nothing needs doing, it stays silent. Edit HEARTBEAT.md in the Soul tab to define what the agent should do on each tick. - -
-
- - config.setLocal('heartbeat.interval_ms', String(Number(v) * 60000))} - onSave={(v) => config.saveConfig('heartbeat.interval_ms', String(Number(v) * 60000))} - onCancel={() => config.cancelLocal('heartbeat.interval_ms')} - min={1} - max={1440} - placeholder="30" - hotReload="restart" - inline - /> - -
-
- Self-configurable - -
- -
-
-
- +
)} {/* API Keys Tab */} {activeTab === 'api-keys' && ( <> -
-
API Keys
-
- - Secrets used to connect to external services. Keys are stored locally in your config file and never shared. Leave a field empty to disable that integration. -
-
-
-
TON Proxy
- + {proxyLoading && ( - - - {proxyStatus?.installed === false ? 'Downloading...' : 'Starting...'} + + + {proxyStatus?.installed === false ? 'Downloading…' : 'Starting…'} )} {!proxyLoading && proxyStatus?.running && ( - - Running (PID {proxyStatus.pid}) - + Running (PID {proxyStatus.pid}) )} -
-
- - Local HTTP proxy that lets the agent browse .ton websites and TON Sites. The binary is auto-downloaded on first enable, no manual install needed. - -
- {proxyError && ( -
- {proxyError} - -
- )} + { + const enable = e.target.checked; + setProxyLoading(true); + setProxyError(null); + try { + const res = enable ? await api.startTonProxy() : await api.stopTonProxy(); + setProxyStatus(res.data); + config.loadData(); + toast.success(enable ? 'TON Proxy started' : 'TON Proxy stopped'); + } catch (err) { + setProxyError(errMsg(err)); + toast.error(errMsg(err)); + } finally { + setProxyLoading(false); + } + }} + /> + + } + > + {proxyError && ( + setProxyError(null)} style={{ marginBottom: '14px' }} /> + )} -
- {/* Uninstall button */} - {!(proxyStatus?.enabled) && ( +
+ {!(proxyStatus?.enabled) && (
)} - {/* Port */} - {/* Binary Path */} -
-
- + )} {/* Advanced Tab */} {activeTab === 'advanced' && ( <> -
-
Advanced
-
- - Low-level settings for embeddings, deals, WebUI, and dev mode. Only change these if you know what you're doing, wrong values can break the agent. -
-
-
Sessions
-
- - Controls how the agent manages conversation memory. Daily reset clears context at a set hour to keep responses fresh. Idle expiry forgets inactive chats after a timeout, saving tokens. -
-
-
-
Tool RAG
-

Anthropic
Claude Opus 4.6


Claude Code
Auto-detected


Codex
Auto-detected


OpenAI
GPT-5


Google
Gemini 3 Pro


xAI
Grok 4.1

- - - - - - - - - - {filtered.map((chat) => { - const isExpanded = expandedChat === chat.id; +
+ {loading ? ( +
+ ) : filtered.length === 0 ? ( + setFilter('')}>Clear filter : undefined} + /> + ) : ( + filtered.map((chat) => { + const name = chat.title || chat.username || chat.id; return ( - -
toggleChat(chat.id)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleChat(chat.id); } }} - tabIndex={0} - role="button" - style={{ - cursor: 'pointer', - borderBottom: isExpanded ? 'none' : '1px solid var(--border)', - backgroundColor: isExpanded ? 'var(--glass-micro)' : undefined, - }} - className="file-row" - > - - - - - - {isExpanded && ( - - - - )} - - ); - })} - -
ChatTypeMessagesLast Active
- - {isExpanded ? '\u25BC' : '\u25B6'} - - {chat.title || chat.username || chat.id} - - - {chat.type} - - - {chat.message_count} - - {chat.last_message_at ? formatDate(chat.last_message_at, 1000) : '\u2014'} -
- {messagesLoading ? ( -
Loading messages...
- ) : messages.length === 0 ? ( -
No messages
+ selectChat(chat.id)} + /> + ); + }) + )} + + + +
+ {!selected ? ( + + ) : ( + <> +
+
{selectedName.charAt(0).toUpperCase()}
+
+
{selectedName}
+
{selected.type} Β· {selected.message_count} {selected.message_count === 1 ? 'message' : 'messages'}
+
+
+
+ {messagesLoading ? ( +
+ ) : messages.length === 0 ? ( +
+ No messages +
+ ) : ( +
+ {messages.map((msg) => { + const out = msg.is_from_agent === 1; + return ( +
+ {!out && selectedIsGroup && {msg.sender_id || 'Unknown'}} + {msg.text ? ( +
{msg.text}
) : ( -
- {messages.map((msg) => ( -
-
- {msg.sender_id || 'Unknown'} - {msg.is_from_agent === 1 && ( - - Agent - - )} - {formatDate(msg.timestamp, 1000)} -
-
-                                    {msg.text || (msg.has_media ? `[${msg.media_type || 'media'}]` : '[empty]')}
-                                  
-
- ))} +
+ {msg.has_media ? `[${msg.media_type || 'media'}]` : '[empty]'}
)} -
- )} + {formatDate(msg.timestamp, 1000)} + + ); + })} + + )} + + + )} + ); diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index db0bae1a..0dba2c0a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -1,116 +1,169 @@ -import { useEffect, useRef, useSyncExternalStore, useState, useCallback } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useConfigState } from '../hooks/useConfigState'; -import { AgentSettingsPanel } from '../components/AgentSettingsPanel'; -import { TelegramSettingsPanel } from '../components/TelegramSettingsPanel'; +import { POLICY_OPTIONS } from '../components/TelegramSettingsPanel'; +import { AllowLists } from '../components/AllowLists'; import { ExecSettingsPanel } from '../components/ExecSettingsPanel'; -import { logStore } from '../lib/log-store'; +import { PillTabs } from '../components/PillTabs'; +import { InfoTip } from '../components/InfoTip'; +import { Select } from '../components/Select'; +import { ProviderSwitchZone, PROVIDER_OPTIONS, PROVIDER_LABELS } from '../components/ProviderControl'; import { api, StatusData } from '../lib/api'; +import { errMsg } from '../lib/utils'; +import { Skeleton } from '../components/Skeleton'; +import { Alert } from '../components/Alert'; -function Metric({ label, value, mono }: { label: string; value: string | number; mono?: boolean }) { +const PLATFORM_LABEL: Record = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' }; + +function fmtUptime(sec: number): string { + if (sec < 3600) return `${Math.floor(sec / 60)}m`; + return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; +} + +function StatItem({ label, value, mono, to }: { label: string; value: string | number; mono?: boolean; to?: string }) { + const navigate = useNavigate(); + const clickable = !!to; return ( -
- {label} - {value} -
+ navigate(to) : undefined} + {...(clickable ? { role: 'button', tabIndex: 0, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Enter') navigate(to); } } : {})} + > + {value} + {label} + ); } export function Dashboard() { const { loading, error, setError, status, stats, - getLocal, getServer, setLocal, cancelLocal, saveConfig, + getLocal, saveConfig, modelOptions, pendingProvider, pendingMeta, pendingApiKey, setPendingApiKey, pendingValidating, pendingError, setPendingError, handleProviderChange, handleProviderConfirm, handleProviderCancel, + loadData, } = useConfigState(); - // Poll /api/status every 10s for live metrics (tokens, uptime) + const handleArraySave = async (key: string, values: string[]) => { + try { + await api.setConfigKey(key, values); + await loadData(); + } catch (err) { + setError(errMsg(err)); + } + }; + + // Poll /api/status every 10s for live metrics (tokens, uptime). const [liveStatus, setLiveStatus] = useState(null); + const [balance, setBalance] = useState(null); useEffect(() => { let active = true; - const poll = () => { - api.getStatus().then((res) => { if (active) setLiveStatus(res.data); }).catch(() => {}); - }; + const poll = () => api.getStatus().then((r) => { if (active) setLiveStatus(r.data); }).catch(() => {}); const id = setInterval(poll, 10_000); + api.getWallet().then((r) => { if (active) setBalance(r.data?.balance ?? null); }).catch(() => {}); return () => { active = false; clearInterval(id); }; }, []); - const currentStatus = liveStatus ?? status; - - const logs = useSyncExternalStore( - (cb) => logStore.subscribe(cb), - () => logStore.getLogs() - ); - const connected = useSyncExternalStore( - (cb) => logStore.subscribe(cb), - () => logStore.isConnected() - ); - const bottomRef = useRef(null); - - useEffect(() => { - logStore.connect(); - }, []); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [logs]); - - if (loading) return
Loading...
; + if (loading) { + return ( +
+

Dashboard

System overview

+
+
+
+ ); + } if (!status || !stats) return
Failed to load dashboard data
; - const s = currentStatus ?? status; - const uptime = s.uptime < 3600 - ? `${Math.floor(s.uptime / 60)}m` - : `${Math.floor(s.uptime / 3600)}h ${Math.floor((s.uptime % 3600) / 60)}m`; + const s = liveStatus ?? status; + const platform = s.platform ? (PLATFORM_LABEL[s.platform] ?? s.platform) : null; return (
-
-

Dashboard

-

System overview

+

Dashboard

System overview

+ + {error && setError(null)} style={{ marginBottom: '14px' }} />} + + {/* ── Status hero ── */} +
+
- {error && ( -
- {error} - + {pendingProvider && pendingMeta && ( +
+
)} - - {/* ── Status bar ─────────────────────────────────────── */} -
-
- - - - - - - - -
+ {/* ── Metrics ── */} +
+ + + + + + + +
- {/* ── Settings (side by side) ────────────────────────── */} + {/* ── Settings (side by side) ── */}
- +
Access Policy
+
+
+ + saveConfig('telegram.dm_policy', v)} ariaLabel="DM policy" /> +
+
+ + saveConfig('telegram.group_policy', v)} ariaLabel="Group policy" /> +
+
-
- +
+
{s.platform === 'linux' && (
@@ -118,77 +171,6 @@ export function Dashboard() {
)}
- - {/* ── Live Logs (collapsible) ── */} - -
- ); -} - -function LogsPanel({ logs, connected, bottomRef }: { - logs: Array<{ level: string; timestamp: number; message: string }>; - connected: boolean; - bottomRef: React.RefObject; -}) { - const [open, setOpen] = useState(true); - const toggle = useCallback(() => setOpen((v) => !v), []); - - return ( -
- - {open && ( - <> -
- -
-
- {logs.length === 0 ? ( -
Waiting for logs...
- ) : ( - logs.map((log, i) => ( -
- - {log.level.toUpperCase()} - {' '} - - {new Date(log.timestamp).toLocaleTimeString()} - {' '} - {log.message} -
- )) - )} -
-
- - )}
); } diff --git a/web/src/pages/Hooks.tsx b/web/src/pages/Hooks.tsx index 43eae8d2..d08f72b7 100644 --- a/web/src/pages/Hooks.tsx +++ b/web/src/pages/Hooks.tsx @@ -1,5 +1,12 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect, Fragment } from 'react'; import { api } from '../lib/api'; +import { errMsg } from '../lib/utils'; +import { List, ListRow } from '../components/List'; +import { useResource } from '../hooks/useResource'; +import { Alert } from '../components/Alert'; +import { SkeletonRows } from '../components/Skeleton'; +import { toast } from '../lib/toast'; +import { useConfirm } from '../components/ConfirmDialog'; interface TriggerEntry { id: string; @@ -8,155 +15,294 @@ interface TriggerEntry { enabled: boolean; } -export function Hooks() { - // Blocklist state - const [blockEnabled, setBlockEnabled] = useState(false); - const [keywords, setKeywords] = useState([]); - const [blockMessage, setBlockMessage] = useState(''); - const [keywordInput, setKeywordInput] = useState(''); - - // Triggers state - const [triggers, setTriggers] = useState([]); - const [newKeyword, setNewKeyword] = useState(''); - const [newContext, setNewContext] = useState(''); - const [editingId, setEditingId] = useState(null); - const [editKeyword, setEditKeyword] = useState(''); - const [editContext, setEditContext] = useState(''); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); +interface BlocklistData { + enabled: boolean; + keywords: string[]; + message: string; +} - // Debounced save for blocklist - const saveTimer = useRef | null>(null); - const blocklistRef = useRef({ enabled: false, keywords: [] as string[], message: '' }); +interface HooksData { + blocklist: BlocklistData; + triggers: TriggerEntry[]; +} - const loadData = async () => { - setLoading(true); - try { - const [blockRes, trigRes] = await Promise.all([api.getBlocklist(), api.getTriggers()]); - const bl = blockRes.data; - setBlockEnabled(bl.enabled); - setKeywords(bl.keywords); - setBlockMessage(bl.message); - blocklistRef.current = { enabled: bl.enabled, keywords: bl.keywords, message: bl.message }; - setTriggers(trigRes.data); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }; +function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) { + return ( + + ); +} + +// ── BlocklistCard ────────────────────────────────────────────────────────── - useEffect(() => { - loadData(); - return () => { if (saveTimer.current) clearTimeout(saveTimer.current); }; - }, []); +function BlocklistCard({ initialData, onError }: { initialData: BlocklistData; onError: (msg: string | null) => void }) { + const [enabled, setEnabled] = useState(initialData.enabled); + const [keywords, setKeywords] = useState(initialData.keywords); + const [message, setMessage] = useState(initialData.message); + const [input, setInput] = useState(''); - const saveBlocklist = useCallback((config: { enabled: boolean; keywords: string[]; message: string }) => { - blocklistRef.current = config; + const saveTimer = useRef | null>(null); + const ref = useRef(initialData); + ref.current = { enabled, keywords, message }; + + useEffect(() => () => { if (saveTimer.current) clearTimeout(saveTimer.current); }, []); + + const save = useCallback((config: BlocklistData) => { if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { try { await api.updateBlocklist(config); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + onError(errMsg(err)); + toast.error(errMsg(err)); } }, 400); - }, []); + }, [onError]); - const handleToggleBlocklist = () => { - const next = !blockEnabled; - setBlockEnabled(next); - saveBlocklist({ ...blocklistRef.current, enabled: next }); + const toggle = () => { + const next = !enabled; + setEnabled(next); + save({ ...ref.current, enabled: next }); }; - const handleAddKeyword = () => { - const kw = keywordInput.trim(); + const addKeyword = () => { + const kw = input.trim(); if (kw.length < 2) return; - if (keywords.includes(kw)) { setKeywordInput(''); return; } + if (keywords.includes(kw)) { setInput(''); return; } const next = [...keywords, kw]; setKeywords(next); - setKeywordInput(''); - saveBlocklist({ ...blocklistRef.current, keywords: next }); + setInput(''); + save({ ...ref.current, keywords: next }); }; - const handleRemoveKeyword = (kw: string) => { + const removeKeyword = (kw: string) => { const next = keywords.filter((k) => k !== kw); setKeywords(next); - saveBlocklist({ ...blocklistRef.current, keywords: next }); + save({ ...ref.current, keywords: next }); }; - const handleBlockMessageChange = (msg: string) => { - setBlockMessage(msg); - saveBlocklist({ ...blocklistRef.current, message: msg }); + const changeMessage = (msg: string) => { + setMessage(msg); + save({ ...ref.current, message: msg }); }; - // ── Trigger actions ──────────────────────────────────────────────── + return ( +
+
+
+
Keyword Blocklist
+

Messages containing these keywords are blocked. Word-boundary matching, no substrings.

+
+ +
- const handleAddTrigger = async () => { - const kw = newKeyword.trim(); - const ctx = newContext.trim(); - if (kw.length < 2 || ctx.length < 1) return; +
+
+ +
+ {keywords.map((kw) => ( + + {kw} + + + ))} + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); addKeyword(); } + if (e.key === 'Backspace' && !input && keywords.length) removeKeyword(keywords[keywords.length - 1]); + }} + /> +
+
+ +
+ + changeMessage(e.target.value)} + maxLength={500} + style={{ width: '100%' }} + /> +
+
+
+ ); +} + +// ── TriggersCard ─────────────────────────────────────────────────────────── + +const EMPTY = { keyword: '', context: '' }; + +function TriggersCard({ initialTriggers, onError }: { initialTriggers: TriggerEntry[]; onError: (msg: string | null) => void }) { + const confirm = useConfirm(); + const [triggers, setTriggers] = useState(initialTriggers); + const [openId, setOpenId] = useState(null); // trigger id, '__new__', or null + const [form, setForm] = useState(EMPTY); + const [saving, setSaving] = useState(false); + + const valid = form.keyword.trim().length >= 2 && form.context.trim().length >= 1; + + const openEdit = (t: TriggerEntry) => { + if (openId === t.id) { setOpenId(null); return; } + setOpenId(t.id); + setForm({ keyword: t.keyword, context: t.context }); + }; + + const openNew = () => { + if (openId === '__new__') { setOpenId(null); return; } + setOpenId('__new__'); + setForm(EMPTY); + }; + + const create = async () => { + if (!valid) return; setSaving(true); try { - const res = await api.createTrigger({ keyword: kw, context: ctx }); + const res = await api.createTrigger({ keyword: form.keyword.trim(), context: form.context.trim() }); setTriggers((prev) => [...prev, res.data]); - setNewKeyword(''); - setNewContext(''); + setOpenId(null); + toast.success('Trigger created'); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + onError(errMsg(err)); + toast.error(errMsg(err)); } finally { setSaving(false); } }; - const handleDeleteTrigger = async (id: string) => { + const saveEdit = async () => { + if (!valid || !openId) return; + setSaving(true); try { - await api.deleteTrigger(id); - setTriggers((prev) => prev.filter((t) => t.id !== id)); + const res = await api.updateTrigger(openId, { keyword: form.keyword.trim(), context: form.context.trim() }); + setTriggers((prev) => prev.map((t) => (t.id === openId ? res.data : t))); + setOpenId(null); + toast.success('Trigger updated'); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + onError(errMsg(err)); + toast.error(errMsg(err)); + } finally { + setSaving(false); } }; - const handleToggleTrigger = async (id: string, enabled: boolean) => { + const remove = async (id: string) => { + if (!(await confirm({ message: 'Delete this trigger?', destructive: true, confirmLabel: 'Delete' }))) return; try { - await api.toggleTrigger(id, !enabled); - setTriggers((prev) => - prev.map((t) => (t.id === id ? { ...t, enabled: !enabled } : t)) - ); + await api.deleteTrigger(id); + setTriggers((prev) => prev.filter((t) => t.id !== id)); + setOpenId(null); + toast.success('Trigger deleted'); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + onError(errMsg(err)); + toast.error(errMsg(err)); } }; - const startEdit = (t: TriggerEntry) => { - setEditingId(t.id); - setEditKeyword(t.keyword); - setEditContext(t.context); - }; - - const handleSaveEdit = async () => { - if (!editingId) return; - const kw = editKeyword.trim(); - const ctx = editContext.trim(); - if (kw.length < 2 || ctx.length < 1) return; - setSaving(true); + const toggle = async (t: TriggerEntry) => { try { - const res = await api.updateTrigger(editingId, { keyword: kw, context: ctx }); - setTriggers((prev) => - prev.map((t) => (t.id === editingId ? res.data : t)) - ); - setEditingId(null); + await api.toggleTrigger(t.id, !t.enabled); + setTriggers((prev) => prev.map((x) => (x.id === t.id ? { ...x, enabled: !t.enabled } : x))); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setSaving(false); + onError(errMsg(err)); + toast.error(errMsg(err)); } }; - if (loading) return
Loading...
; + // Rendered as a direct call (not ) so the inputs keep focus across keystrokes. + const editForm = (onSubmit: () => void, submitLabel: string, onDelete?: () => void) => ( +
+ setForm((f) => ({ ...f, keyword: e.target.value }))} + maxLength={100} + style={{ width: '100%' }} + /> +