diff --git a/.github/workflows/telegram-notify.yml b/.github/workflows/telegram-notify.yml
new file mode 100644
index 0000000..523c0ef
--- /dev/null
+++ b/.github/workflows/telegram-notify.yml
@@ -0,0 +1,81 @@
+name: Telegram Notify
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+
+jobs:
+ # ---- Commit notification ----
+ commit:
+ if: startsWith(github.ref, 'refs/heads/')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Split commit message
+ id: msg
+ run: |
+ FULL="${{ github.event.head_commit.message }}"
+ TITLE=$(echo "$FULL" | head -1)
+ BODY=$(echo "$FULL" | tail -n +3)
+ echo "title=$TITLE" >> "$GITHUB_OUTPUT"
+ {
+ echo "body<> "$GITHUB_OUTPUT"
+
+ - name: Build message
+ id: fmt
+ run: |
+ BODY="${{ steps.msg.outputs.body }}"
+ if [ -n "$BODY" ]; then
+ MSG="${{ steps.msg.outputs.title }}
+
+ $BODY
+
+ View commit"
+ else
+ MSG="${{ steps.msg.outputs.title }}
+
+ View commit"
+ fi
+ {
+ echo "text<> "$GITHUB_OUTPUT"
+
+ - 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: ${{ steps.fmt.outputs.text }}
+
+ # ---- Release notification ----
+ release:
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Extract version
+ id: ver
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
+
+ - name: Send release 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: |
+ ๐ New Release ${{ github.ref_name }}
+
+ Teleton ${{ steps.ver.outputs.version }} is now available on npm.
+ Update your agents:
+
+ npm i -g teleton@latest
+
+ View release
diff --git a/.gitignore b/.gitignore
index 1a4ef19..1bf3186 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,6 +28,11 @@ telegram_session.txt
telegram-offset.json
.reference/
+# Specs & working docs (local only)
+specs/
+docs/plans/
+TODOS/
+
# TTS voice models (large binaries)
piper-voices/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 271c696..0cdb54a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.7.4] - 2026-02-25
+
+### Added
+- **Configurable keys overhaul**: Array type support (admin_ids, allow_from, group_allow_from), labels and option labels on all keys, new keys for Telegram rate limits, Deals params, Embedding model, Cocoon port, Agent base_url
+- **ArrayInput component**: Tag-style input for managing array config values in the dashboard
+- **Memory sources browser**: List indexed knowledge sources with entry counts, expand to view individual chunks with line ranges
+- **Workspace image preview**: Serve raw images with correct MIME type, 5MB limit, SVG sandboxing
+- **Tool RAG persistence**: RAG config (enabled, topK, alwaysInclude, skipUnlimitedProviders) now persists to YAML
+- **Tasks bulk clean**: Clean tasks by terminal status (done, failed, cancelled) instead of just done
+- **GramJS bot session persistence**: Save/load MTProto session string to avoid re-auth on restart
+
+### Changed
+- **Remove "pairing" DM policy**: Simplified to open/allowlist/disabled โ pairing was unused
+- Dashboard Config page reorganized with Telegram settings section, Cocoon port panel, extended Tool RAG controls
+- Setup wizard flow reordered, wallet and modules steps cleaned up
+- Dashboard and Config pages restructured for better UX
+- Soul editor textarea fills available height
+
+### Fixed
+- Select dropdown renders via portal (z-index stacking fix)
+- Model selection moved into Provider step (no longer separate Config step)
+- Async log pollution during CLI setup suppressed
+- Telegram commit notification extra blank lines removed
+- owner_id auto-syncs to admin_ids on save
+
+## [0.7.3] - 2026-02-24
+
+### Added
+- **Claude Code provider**: Auto-detect OAuth tokens from local Claude Code installation (~/.claude/.credentials.json on Linux/Windows, macOS Keychain) with intelligent caching and 401 retry
+- **Reply-to context**: Inject quoted message context into LLM prompt when user replies to a message
+- **Fragment auth**: Support Telegram anonymous numbers (+888) via Fragment.com verification
+- **7 new Telegram tools** (66 โ 73): transcribe-audio, get/delete-scheduled-messages, send-scheduled-now, get-collectible-info, get-admined-channels, set-personal-channel
+- **Voice auto-transcription**: Automatic transcription of voice/audio messages in handler
+- **Gated provider switch**: Dashboard provider change requires API key validation before applying
+- **Shared model catalog**: 60+ models across 11 providers, extracted to `model-catalog.ts` (eliminates ~220 duplicated lines)
+
+### Fixed
+- **TEP-74 encoding**: Correct jetton transfer payload encoding and infrastructure robustness
+- Replace deprecated `claude-3-5-haiku` with `claude-haiku-4-5`
+- Seed phrase display in CLI setup
+- Bump pi-ai 0.52 โ 0.54, hono 4.11.9 โ 4.12.2, ajv 8.17.1 โ 8.18.0
+
+## [0.7.2] - 2026-02-23
+
+### Fixed
+- **Plugins route**: WebUI now reflects runtime-loaded plugins instead of static config
+
+## [0.7.1] - 2026-02-23
+
+### Added
+- **Agent Run/Stop control**: Separate agent lifecycle from WebUI โ start/stop the agent at runtime without killing the server. New `AgentLifecycle` state machine (`stopped/starting/running/stopping`), REST endpoints (`POST /api/agent/start`, `/stop`, `GET /api/agent/status`), SSE endpoint (`GET /api/agent/events`) for real-time state push, `useAgentStatus` hook (SSE + polling fallback), and `AgentControl` sidebar component with confirmation dialog
+- **MCP Streamable HTTP transport**: `StreamableHTTPClientTransport` as primary transport for URL-based MCP servers, with automatic fallback to `SSEClientTransport` on failure. `mcpServers` list is now a lazy function for live status. Resource cleanup (AbortController, sockets) on fallback. Improved error logging with stack traces
+
+### Fixed
+- **WebUI setup wizard**: Neutralize color accent overuse โ selection states, warning cards, tag pills, step dots all moved to neutral white/grey palette; security notice collapsed into ``; "Optional Integrations" renamed to "Optional API Keys"; bot token marked as "(recommended)"
+- **Jetton send**: Wrap entire `sendJetton` flow in try/catch for consistent `PluginSDKError` propagation; remove `SendMode.IGNORE_ERRORS` (errors are no longer silently swallowed); fix `||` โ `??` on jetton decimals (prevents `0` decimals being replaced by `9`)
+
## [0.7.0] - 2026-02-21
### Added
@@ -268,7 +325,11 @@ Git history rewritten to fix commit attribution (email update from `tonresistor@
- Professional distribution (npm, Docker, CI/CD)
- Pre-commit hooks and linting infrastructure
-[Unreleased]: https://github.com/TONresistor/teleton-agent/compare/v0.7.0...HEAD
+[Unreleased]: https://github.com/TONresistor/teleton-agent/compare/v0.7.4...HEAD
+[0.7.4]: https://github.com/TONresistor/teleton-agent/compare/v0.7.3...v0.7.4
+[0.7.3]: https://github.com/TONresistor/teleton-agent/compare/v0.7.2...v0.7.3
+[0.7.2]: https://github.com/TONresistor/teleton-agent/compare/v0.7.1...v0.7.2
+[0.7.1]: https://github.com/TONresistor/teleton-agent/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/TONresistor/teleton-agent/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/TONresistor/teleton-agent/compare/v0.5.2...v0.6.0
[0.5.2]: https://github.com/TONresistor/teleton-agent/compare/v0.5.1...v0.5.2
diff --git a/README.md b/README.md
index afe12b1..1a4f37a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
-
Teleton Agent
+
+
+
Autonomous AI agent platform for Telegram with native TON blockchain integration
@@ -7,21 +9,27 @@
-
+
+
---
-
Teleton is an autonomous AI agent platform that operates as a real Telegram user account (not a bot). It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 114 built-in tools, supports 6 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.
+
Teleton is an autonomous AI agent platform that operates as a real Telegram user account (not a bot). It thinks through an agentic loop with tool calling, remembers conversations across sessions with hybrid RAG, and natively integrates the TON blockchain: send crypto, swap on DEXs, bid on domains, verify payments - all from a chat message. It can schedule tasks to run autonomously at any time. It ships with 100+ built-in tools, supports 10 LLM providers, and exposes a Plugin SDK so you can build your own tools on top of the platform.
+
+### GroypFi Perps & Groypad Integration (MCP)
+
+See [docs/groypfi-mcp.md](docs/groypfi-mcp.md) for full setup instructions.
+AI agents can now trade perpetuals and launch tokens on Groypad via the official GroypFi MCP server.
### Key Highlights
- **Full Telegram access** - Operates as a real user via MTProto (GramJS), not a limited bot
- **Agentic loop** - Up to 5 iterations of tool calling per message, the agent thinks, acts, observes, and repeats
-- **Multi-Provider LLM** - Anthropic, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter
+- **Multi-Provider LLM** - Anthropic, Claude Code, OpenAI, Google Gemini, xAI Grok, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local (11 providers)
- **TON Blockchain** - Built-in W5R1 wallet, send/receive TON & jettons, swap on STON.fi and DeDust, NFTs, DNS domains
- **Persistent memory** - Hybrid RAG (sqlite-vec + FTS5), auto-compaction with AI summarization, daily logs
-- **114 built-in tools** - Messaging, media, blockchain, DEX trading, deals, DNS, journaling, and more
+- **100+ built-in tools** - Messaging, media, blockchain, DEX trading, deals, DNS, journaling, and more
- **Plugin SDK** - Extend the agent with custom tools, frozen SDK with isolated databases, secrets management, lifecycle hooks
- **MCP Client** - Connect external tool servers (stdio/SSE) with 2 lines of YAML, no code, no rebuild
- **Secure by design** - Prompt injection defense, sandboxed workspace, plugin isolation, wallet encryption
@@ -34,7 +42,7 @@
| Category | Tools | Description |
| ------------- | ----- | ------------------------------------------------------------------------------------------------------------------ |
-| Telegram | 66 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks |
+| Telegram | 73 | Messaging, media, chats, groups, polls, stickers, gifts, stars, stories, contacts, folders, profile, memory, tasks, voice transcription, scheduled messages |
| TON & Jettons | 15 | W5R1 wallet, send/receive TON & jettons, balances, prices, holders, history, charts, NFTs, smart DEX router |
| STON.fi DEX | 5 | Swap, quote, search, trending tokens, liquidity pools |
| DeDust DEX | 5 | Swap, quote, pools, prices, token analytics (holders, top traders, buy/sell tax) |
@@ -48,7 +56,7 @@
| Capability | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- |
-| **Multi-Provider LLM** | Switch between Anthropic, OpenAI, Google, xAI, Groq, OpenRouter with one config change |
+| **Multi-Provider LLM** | Switch between Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, or Local โ Dashboard validates API key before switching |
| **RAG + Hybrid Search** | Local ONNX embeddings (384d) or Voyage AI (512d/1024d) with FTS5 keyword + sqlite-vec cosine similarity, fused via RRF |
| **Auto-Compaction** | AI-summarized context management prevents overflow, preserves key information in `memory/*.md` files |
| **Observation Masking** | Compresses old tool results to one-line summaries, saving ~90% context window |
@@ -68,7 +76,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/)
+- **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/) โ or keyless: Claude Code (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)
@@ -106,7 +114,7 @@ teleton setup
```
The wizard will configure:
-- LLM provider selection (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter)
+- LLM provider selection (11 providers: Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local)
- Telegram authentication (API credentials, phone, login code)
- Access policies (DM/group response rules)
- Admin user ID
@@ -149,10 +157,10 @@ The `teleton setup` wizard generates a fully configured `~/.teleton/config.yaml`
```yaml
agent:
- provider: "anthropic" # anthropic | openai | google | xai | groq | openrouter
+ provider: "anthropic" # anthropic | claude-code | openai | google | xai | groq | openrouter | moonshot | mistral | cocoon | local
api_key: "sk-ant-api03-..."
- model: "claude-opus-4-5-20251101"
- utility_model: "claude-3-5-haiku-20241022" # for summarization, compaction, vision
+ model: "claude-opus-4-6"
+ utility_model: "claude-haiku-4-5-20251001" # for summarization, compaction, vision
max_agentic_iterations: 5
telegram:
@@ -180,6 +188,24 @@ webui: # Optional: Web dashboard
# auth_token: "..." # Auto-generated if omitted
```
+### Supported Models
+
+All models are defined in `src/config/model-catalog.ts` and shared across the CLI setup, WebUI setup wizard, and Dashboard. To add a model, add it there โ it will appear everywhere automatically.
+
+| Provider | Models |
+|----------|--------|
+| **Anthropic** | Claude Opus 4.6, Claude Opus 4.5, Claude Sonnet 4.6, Claude Haiku 4.5 |
+| **Claude Code** | Same as Anthropic (auto-detected credentials) |
+| **OpenAI** | GPT-5, GPT-5 Pro, GPT-5 Mini, GPT-5.1, GPT-4o, GPT-4.1, GPT-4.1 Mini, o4 Mini, o3, Codex Mini |
+| **Google** | Gemini 3 Pro (preview), Gemini 3 Flash (preview), Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash Lite, Gemini 2.0 Flash |
+| **xAI** | Grok 4.1 Fast, Grok 4 Fast, Grok 4, Grok Code, Grok 3 |
+| **Groq** | Llama 4 Maverick, Qwen3 32B, DeepSeek R1 70B, Llama 3.3 70B |
+| **OpenRouter** | Claude Opus/Sonnet, GPT-5, Gemini, DeepSeek R1/V3, Qwen3 Coder/Max/235B, Nemotron, Sonar Pro, MiniMax, Grok 4 |
+| **Moonshot** | Kimi K2.5, Kimi K2 Thinking |
+| **Mistral** | Devstral Small/Medium, Mistral Large, Magistral Small |
+| **Cocoon** | Qwen/Qwen3-32B (decentralized, pays in TON) |
+| **Local** | Auto-detected (Ollama, vLLM, LM Studio) |
+
### MCP Servers
Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io/). No code needed - tools are auto-discovered and registered at startup.
@@ -280,7 +306,7 @@ Teleton includes an **optional web dashboard** for monitoring and configuration.
### Features
-- **Dashboard**: System status, uptime, model info, session count, memory stats
+- **Dashboard**: System status, uptime, model info, session count, memory stats, provider switching with API key validation
- **Tools Management**: View all tools grouped by module, toggle enable/disable, change scope per tool
- **Plugin Marketplace**: Install, update, and manage plugins from registry with secrets management
- **Soul Editor**: Edit SOUL.md, SECURITY.md, STRATEGY.md, MEMORY.md with unsaved changes warning
@@ -383,7 +409,7 @@ All admin commands support `/`, `!`, or `.` prefix:
| Layer | Technology |
|-------|------------|
-| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (Anthropic, OpenAI, Google, xAI, Groq, OpenRouter) |
+| LLM | Multi-provider via [pi-ai](https://github.com/mariozechner/pi-ai) (11 providers: Anthropic, Claude Code, OpenAI, Google, xAI, Groq, OpenRouter, Moonshot, Mistral, Cocoon, Local) |
| Telegram Userbot | [GramJS](https://gram.js.org/) (MTProto) |
| Inline Bot | [Grammy](https://grammy.dev/) (Bot API, for deals) |
| Blockchain | [TON SDK](https://github.com/ton-org/ton) (W5R1 wallet) |
@@ -405,13 +431,13 @@ src/
โโโ agent/ # Core agent runtime
โ โโโ runtime.ts # Agentic loop (5 iterations, tool calling, masking, compaction)
โ โโโ client.ts # Multi-provider LLM client
-โ โโโ tools/ # 114 built-in tools
+โ โโโ tools/ # 100+ built-in tools
โ โโโ register-all.ts # Central tool registration (8 categories, 109 tools)
โ โโโ registry.ts # Tool registry, scope filtering, provider limits
โ โโโ module-loader.ts # Built-in module loading (deals โ +5 tools)
โ โโโ plugin-loader.ts # External plugin discovery, validation, hot-reload
โ โโโ mcp-loader.ts # MCP client (stdio/SSE), tool discovery, lifecycle
-โ โโโ telegram/ # Telegram operations (66 tools)
+โ โโโ telegram/ # Telegram operations (73 tools)
โ โโโ ton/ # TON blockchain + jettons + DEX router (15 tools)
โ โโโ stonfi/ # STON.fi DEX (5 tools)
โ โโโ dedust/ # DeDust DEX (5 tools)
@@ -460,7 +486,8 @@ src/
โ โโโ loader.ts # 10 sections: soul + security + strategy + memory + context + ...
โโโ config/ # Configuration
โ โโโ schema.ts # Zod schemas + validation
-โ โโโ providers.ts # Multi-provider LLM registry (6 providers)
+โ โโโ providers.ts # Multi-provider LLM registry (11 providers)
+โ โโโ model-catalog.ts # Shared model catalog (60+ models across all providers)
โโโ webui/ # Optional web dashboard
โ โโโ server.ts # Hono server, auth middleware, static serving
โ โโโ routes/ # 11 API route groups (status, tools, logs, memory, soul, plugins, mcp, tasks, workspace, config, marketplace)
diff --git a/docs/groypfi-mcp.md b/docs/groypfi-mcp.md
new file mode 100644
index 0000000..3922966
--- /dev/null
+++ b/docs/groypfi-mcp.md
@@ -0,0 +1,1071 @@
+# teleton-plugin
+
+This section documents the Teleton plugin integration for GroypFi products on TON.
+
+## Files
+
+- `plugins/groypfi-groypad.js` (Groypad launchpad integration)
+- `plugins/groypfi-perps.js` (GroypFi Perps integration)
+
+## Installation
+
+1. Copy both plugin files into your Teleton `plugins/` directory.
+2. Set `API_KEY` in each plugin.
+3. Restart Teleton.
+
+## `plugins/groypfi-groypad.js`
+
+```javascript
+// plugins/groypfi-groypad.js โ Teleton plugin for Groypad (Bonding Curve Launchpad on TON)
+// Repository: https://github.com/TONresistor/teleton-agent
+//
+// Groypad is a memecoin launchpad using linear bonding curves on TON.
+// Contracts are Blumpad-compatible. Graduation target: 1,050 TON.
+//
+// MemeFactory: EQAO4cYqithwdltzmrlal1L5JKLK5Xk76feAJq0VoBC6Fy8T
+// Deploy opcode: 0x6ff416dc โ MemeFactory
+// Buy opcode: 0x742b36d8 โ Meme (jetton master)
+// Sell opcode: 0x595f07bc โ MemeWallet (user jetton wallet)
+// Claim fee: 0xad7269a8 โ Meme (jetton master)
+//
+// Docs: https://groypfi.io/docs/groypad
+
+const MEME_FACTORY = "EQAO4cYqithwdltzmrlal1L5JKLK5Xk76feAJq0VoBC6Fy8T";
+const SUPABASE_URL = "https://rcuesqclhdghrqrmwjlk.supabase.co";
+const API_KEY = "";
+const PRECISION = BigInt(1e9);
+
+// โโ Bonding curve math โโ
+
+function integrateCurve(s1, s2, alpha, beta) {
+ const dx = s2 - s1;
+ if (dx <= 0n) return 0n;
+ const term1 = (alpha * dx) / PRECISION;
+ const term2 = (beta * dx * (s1 + s2)) / (2n * PRECISION * PRECISION);
+ return term1 + term2;
+}
+
+function buyQuote(amountTon, currentSupply, alpha, beta) {
+ const avgPrice = alpha + (beta * currentSupply) / PRECISION;
+ if (avgPrice <= 0n) return 0n;
+ let tokensOut = (amountTon * PRECISION) / avgPrice;
+
+ for (let i = 0; i < 10; i++) {
+ const cost = integrateCurve(currentSupply, currentSupply + tokensOut, alpha, beta);
+ const diff = cost - amountTon;
+ if (diff === 0n) break;
+ const priceAtEnd = alpha + (beta * (currentSupply + tokensOut)) / PRECISION;
+ if (priceAtEnd <= 0n) break;
+ const adj = (diff * PRECISION) / priceAtEnd;
+ tokensOut -= adj;
+ if (tokensOut <= 0n) return 0n;
+ if (adj > -2n && adj < 2n) break;
+ }
+ return tokensOut;
+}
+
+function sellQuote(tokenAmount, currentSupply, alpha, beta, tradeFeeBPS = 100) {
+ if (tokenAmount <= 0n || tokenAmount > currentSupply) return 0n;
+ const newSupply = currentSupply - tokenAmount;
+ const rawTon = integrateCurve(newSupply, currentSupply, alpha, beta);
+ return (rawTon * (10000n - BigInt(tradeFeeBPS))) / 10000n;
+}
+
+module.exports = {
+ name: "groypfi-groypad",
+ description:
+ "Deploy, trade, and manage tokens on Groypad โ a bonding-curve memecoin launchpad on TON.",
+ version: "1.2.0",
+
+ tools: [
+ // โโ Deploy Token โโ
+ {
+ name: "groypad_deploy",
+ description:
+ "Deploy a new memecoin on Groypad. Creates a bonding curve token on the MemeFactory contract with metadata and an initial buy.",
+ parameters: {
+ name: {
+ type: "string",
+ description: "Token name (e.g. 'Pepe the Frog')",
+ required: true,
+ },
+ ticker: {
+ type: "string",
+ description: "Token ticker/symbol, 2-10 chars (e.g. 'PEPE')",
+ required: true,
+ },
+ description: {
+ type: "string",
+ description: "Token description (min 10 chars)",
+ required: true,
+ },
+ initial_buy_ton: {
+ type: "number",
+ description: "TON for initial buy (minimum 10 TON)",
+ required: true,
+ },
+ image_url: {
+ type: "string",
+ description: "Public URL to token logo image (required)",
+ required: true,
+ },
+ website: {
+ type: "string",
+ description: "Project website URL",
+ default: "",
+ },
+ telegram: {
+ type: "string",
+ description: "Telegram group/channel URL",
+ default: "",
+ },
+ twitter: {
+ type: "string",
+ description: "X/Twitter profile URL",
+ default: "",
+ },
+ },
+ async execute(
+ {
+ name,
+ ticker,
+ description,
+ initial_buy_ton,
+ image_url,
+ website = "",
+ telegram = "",
+ twitter = "",
+ },
+ { ton, log }
+ ) {
+ if (!image_url) {
+ return {
+ success: false,
+ error: "Token logo image_url is required",
+ };
+ }
+ if (initial_buy_ton < 10) {
+ return {
+ success: false,
+ error: "Minimum initial buy is 10 TON",
+ };
+ }
+
+ // Step 1: Upload metadata JSON to Supabase storage
+ const metadata = {
+ name,
+ symbol: ticker,
+ description,
+ image: image_url,
+ website,
+ social: { telegram, twitter },
+ };
+
+ log.info(`Uploading metadata for ${ticker}...`);
+
+ const metaFileName = `${Date.now()}_${ticker.toLowerCase()}.json`;
+ const uploadRes = await fetch(
+ `${SUPABASE_URL}/functions/v1/upload-token-asset`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ bucket: "token-metadata",
+ fileName: metaFileName,
+ content: JSON.stringify(metadata),
+ }),
+ }
+ );
+
+ if (!uploadRes.ok) {
+ const err = await uploadRes.text();
+ return { success: false, error: "Metadata upload failed: " + err };
+ }
+
+ const { publicUrl: metadataUrl } = await uploadRes.json();
+ log.info(`Metadata uploaded: ${metadataUrl}`);
+
+ // Step 2: Build deploy transaction
+ // DeployMeme opcode: 0x6ff416dc
+ // Message: op(32) + queryId(64) + presetId(4) + metadata_url(ref) + initialBuy(coins)
+ // + partnerConfig(bit=0) + referrerConfig(bit=0)
+ const initialBuyNano = BigInt(Math.floor(initial_buy_ton * 1e9));
+ const gasAmount = 500000000n; // 0.5 TON gas
+
+ log.info(
+ `Deploying ${ticker} with ${initial_buy_ton} TON initial buy...`
+ );
+
+ const tx = await ton.send(MEME_FACTORY, Number(gasAmount + initialBuyNano) / 1e9, {
+ opcode: 0x6ff416dc,
+ queryId: 0,
+ payload: {
+ uint4: 0, // presetId
+ ref: { string: metadataUrl }, // metadata URL in ref cell
+ coins: initialBuyNano, // initial buy amount
+ bit: false, // no partner config
+ bit2: false, // no referrer config
+ },
+ });
+
+ log.info(`Token deployed! TX: ${tx.hash}`);
+
+ return {
+ success: true,
+ txHash: tx.hash,
+ ticker,
+ name,
+ initialBuy: initial_buy_ton + " TON",
+ metadataUrl,
+ factory: MEME_FACTORY,
+ };
+ },
+ },
+
+ // โโ List Tokens โโ
+ {
+ name: "groypad_list_tokens",
+ description:
+ "List active Groypad tokens with market cap, progress, volume, and holders",
+ parameters: {},
+ async execute(_, { log }) {
+ const res = await fetch(
+ `${SUPABASE_URL}/rest/v1/launchpad_tokens?is_graduated=eq.false&order=market_cap.desc.nullslast&limit=50`,
+ { headers: { apikey: API_KEY } }
+ );
+ const tokens = await res.json();
+ log.info(`Found ${tokens.length} active Groypad tokens`);
+ return tokens.map((t) => ({
+ ticker: t.ticker,
+ name: t.name,
+ address: t.meme_address,
+ marketCap: t.market_cap?.toFixed(2) + " TON",
+ progress: (t.progress || 0).toFixed(1) + "%",
+ volume24h: t.volume_24h?.toFixed(2) + " TON",
+ holders: t.holders,
+ }));
+ },
+ },
+
+ // โโ Token Info (on-chain) โโ
+ {
+ name: "groypad_token_info",
+ description:
+ "Get on-chain bonding curve data for a Groypad token (price, progress, supply, raised funds)",
+ parameters: {
+ address: {
+ type: "string",
+ description: "Meme contract address",
+ required: true,
+ },
+ },
+ async execute({ address }, { ton, log }) {
+ const result = await ton.runGetMethod(address, "get_meme_data", []);
+ const stack = result.stack;
+ const data = {
+ initialized: stack[0] !== "0",
+ migrated: stack[1] !== "0",
+ isGraduated: stack[6] !== "0",
+ alpha: BigInt(stack[7]),
+ beta: BigInt(stack[8]),
+ tradeFeeBPS: Number(stack[10]),
+ raisedFunds: BigInt(stack[11]),
+ currentSupply: BigInt(stack[12]),
+ };
+ const price =
+ Number(data.alpha + (data.beta * data.currentSupply) / PRECISION) /
+ 1e9;
+ const progress = Math.min(
+ 100,
+ Number((data.raisedFunds * 10000n) / (1050n * PRECISION)) / 100
+ );
+ log.info(
+ `Token price: ${price.toFixed(9)} TON, progress: ${progress.toFixed(1)}%`
+ );
+ return {
+ ...data,
+ price,
+ progress: progress.toFixed(1) + "%",
+ alpha: data.alpha.toString(),
+ beta: data.beta.toString(),
+ raisedFunds:
+ (Number(data.raisedFunds) / 1e9).toFixed(4) + " TON",
+ currentSupply: data.currentSupply.toString(),
+ };
+ },
+ },
+
+ // โโ Buy โโ
+ {
+ name: "groypad_buy",
+ description:
+ "Buy tokens on the Groypad bonding curve. Sends TON from the agent wallet.",
+ parameters: {
+ address: {
+ type: "string",
+ description: "Meme contract address",
+ required: true,
+ },
+ amount_ton: {
+ type: "number",
+ description: "TON to spend",
+ required: true,
+ },
+ slippage: {
+ type: "number",
+ description: "Slippage % (default 5)",
+ default: 5,
+ },
+ },
+ async execute({ address, amount_ton, slippage = 5 }, { ton, log }) {
+ // Read on-chain state
+ const result = await ton.runGetMethod(address, "get_meme_data", []);
+ const stack = result.stack;
+ const alpha = BigInt(stack[7]);
+ const beta = BigInt(stack[8]);
+ const supply = BigInt(stack[12]);
+
+ const amountNano = BigInt(Math.floor(amount_ton * 1e9));
+ const tokensOut = buyQuote(amountNano, supply, alpha, beta);
+ const minOut = (tokensOut * BigInt(100 - slippage)) / 100n;
+
+ log.info(
+ `Buying ~${Number(tokensOut) / 1e9} tokens for ${amount_ton} TON`
+ );
+
+ // Build & send transaction
+ // opcode 0x742b36d8 + queryId(0) + minTokensOut(coins)
+ const tx = await ton.send(address, amount_ton + 0.3, {
+ opcode: 0x742b36d8,
+ queryId: 0,
+ payload: { coins: minOut },
+ });
+
+ return {
+ success: true,
+ txHash: tx.hash,
+ estimatedTokens: (Number(tokensOut) / 1e9).toFixed(2),
+ };
+ },
+ },
+
+ // โโ Sell โโ
+ {
+ name: "groypad_sell",
+ description:
+ "Sell Groypad tokens back to the bonding curve. Burns tokens from the agent wallet.",
+ parameters: {
+ address: {
+ type: "string",
+ description: "Meme contract address",
+ required: true,
+ },
+ amount: {
+ type: "string",
+ description:
+ "Token amount to sell (nano-tokens, bigint string). Use 'all' for full balance.",
+ required: true,
+ },
+ },
+ async execute({ address, amount }, { ton, log }) {
+ const walletAddr = await ton.getMyAddress();
+
+ // Resolve user's MemeWallet via get_wallet_address
+ const walletResult = await ton.runGetMethod(
+ address,
+ "get_wallet_address",
+ [{ type: "slice", value: walletAddr }]
+ );
+ const memeWallet = walletResult.stack[0];
+
+ // Get actual on-chain balance (prevents exit_code 27 bounces)
+ const balResult = await ton.runGetMethod(
+ memeWallet,
+ "get_wallet_data",
+ []
+ );
+ const actualBalance = BigInt(balResult.stack[0]);
+
+ let sellAmount =
+ amount === "all" ? actualBalance : BigInt(amount);
+ if (sellAmount > actualBalance) sellAmount = actualBalance;
+
+ if (sellAmount <= 0n)
+ return { success: false, error: "Zero balance" };
+
+ log.info(
+ `Selling ${Number(sellAmount) / 1e9} tokens from ${memeWallet}`
+ );
+
+ // opcode 0x595f07bc + queryId(0) + amount(coins) + responseDestination(address) + customPayload(bit=0)
+ const tx = await ton.send(memeWallet, 0.3, {
+ opcode: 0x595f07bc,
+ queryId: 0,
+ payload: { coins: sellAmount, address: walletAddr, bit: false },
+ });
+
+ return {
+ success: true,
+ txHash: tx.hash,
+ sold: (Number(sellAmount) / 1e9).toFixed(2),
+ };
+ },
+ },
+
+ // โโ Get Quote (preview buy/sell) โโ
+ {
+ name: "groypad_get_quote",
+ description:
+ "Preview a buy or sell quote on the Groypad bonding curve without executing a trade. Returns estimated tokens out (buy) or TON out (sell).",
+ parameters: {
+ address: {
+ type: "string",
+ description: "Meme contract address",
+ required: true,
+ },
+ side: {
+ type: "string",
+ description: "'buy' or 'sell'",
+ required: true,
+ },
+ amount: {
+ type: "number",
+ description:
+ "For buy: TON to spend. For sell: token amount (human-readable, e.g. 1000.5)",
+ required: true,
+ },
+ },
+ async execute({ address, side, amount }, { ton, log }) {
+ const result = await ton.runGetMethod(address, "get_meme_data", []);
+ const stack = result.stack;
+ const alpha = BigInt(stack[7]);
+ const beta = BigInt(stack[8]);
+ const tradeFeeBPS = Number(stack[10]);
+ const supply = BigInt(stack[12]);
+
+ const currentPrice =
+ Number(alpha + (beta * supply) / PRECISION) / 1e9;
+
+ if (side === "buy") {
+ const amountNano = BigInt(Math.floor(amount * 1e9));
+ const tokensOut = buyQuote(amountNano, supply, alpha, beta);
+ const cost = integrateCurve(supply, supply + tokensOut, alpha, beta);
+ const avgPrice = tokensOut > 0n ? Number(cost) / Number(tokensOut) : 0;
+ const priceAfter =
+ Number(alpha + (beta * (supply + tokensOut)) / PRECISION) / 1e9;
+ const priceImpact =
+ currentPrice > 0
+ ? (((priceAfter - currentPrice) / currentPrice) * 100).toFixed(2)
+ : "0";
+
+ log.info(
+ `Buy quote: ${amount} TON โ ~${(Number(tokensOut) / 1e9).toFixed(2)} tokens (impact: ${priceImpact}%)`
+ );
+
+ return {
+ side: "buy",
+ inputTon: amount,
+ estimatedTokensOut: (Number(tokensOut) / 1e9).toFixed(4),
+ avgPricePerToken: avgPrice.toFixed(9) + " TON",
+ currentPrice: currentPrice.toFixed(9) + " TON",
+ priceAfter: priceAfter.toFixed(9) + " TON",
+ priceImpact: priceImpact + "%",
+ };
+ } else if (side === "sell") {
+ const tokenNano = BigInt(Math.floor(amount * 1e9));
+ const tonOut = sellQuote(tokenNano, supply, alpha, beta, tradeFeeBPS);
+ const newSupply = supply - tokenNano;
+ const priceAfter =
+ newSupply > 0n
+ ? Number(alpha + (beta * newSupply) / PRECISION) / 1e9
+ : 0;
+ const priceImpact =
+ currentPrice > 0
+ ? (((priceAfter - currentPrice) / currentPrice) * 100).toFixed(2)
+ : "0";
+
+ log.info(
+ `Sell quote: ${amount} tokens โ ~${(Number(tonOut) / 1e9).toFixed(4)} TON (impact: ${priceImpact}%)`
+ );
+
+ return {
+ side: "sell",
+ inputTokens: amount,
+ estimatedTonOut: (Number(tonOut) / 1e9).toFixed(4) + " TON",
+ currentPrice: currentPrice.toFixed(9) + " TON",
+ priceAfter: priceAfter.toFixed(9) + " TON",
+ priceImpact: priceImpact + "%",
+ tradeFee: tradeFeeBPS / 100 + "%",
+ };
+ } else {
+ return { success: false, error: "side must be 'buy' or 'sell'" };
+ }
+ },
+ },
+
+ // โโ Claim Creator Fee โโ
+ {
+ name: "groypad_claim_fee",
+ description:
+ "Claim accumulated creator trading fees from a token you deployed on Groypad.",
+ parameters: {
+ address: {
+ type: "string",
+ description: "Meme contract address",
+ required: true,
+ },
+ },
+ async execute({ address }, { ton, log }) {
+ const walletAddr = await ton.getMyAddress();
+
+ // Check claimable amount from get_meme_data stack[4]
+ const result = await ton.runGetMethod(address, "get_meme_data", []);
+ const creatorFee = BigInt(result.stack[4]);
+ if (creatorFee <= 0n) {
+ return {
+ success: false,
+ error: "No fees to claim",
+ claimable: "0 TON",
+ };
+ }
+
+ log.info(
+ `Claiming ${Number(creatorFee) / 1e9} TON in creator fees`
+ );
+
+ // opcode 0xad7269a8 + queryId(0) + to(address) + excessesTo(address)
+ const tx = await ton.send(address, 0.3, {
+ opcode: 0xad7269a8,
+ queryId: 0,
+ payload: { address: walletAddr, address2: walletAddr },
+ });
+
+ return {
+ success: true,
+ txHash: tx.hash,
+ claimed: (Number(creatorFee) / 1e9).toFixed(4) + " TON",
+ };
+ },
+ },
+ ],
+};
+```
+
+# perps-mcp
+
+This section documents GroypFi Perps MCP behavior and Teleton tool bindings.
+
+## `plugins/groypfi-perps.js`
+
+```javascript
+// plugins/groypfi-perps.js โ Teleton plugin for GroypFi Perpetuals
+// Repository: https://github.com/TONresistor/teleton-agent
+//
+// GroypFi Perps is powered by Storm Trade on TON.
+// Supports 50+ markets (crypto, forex, commodities) with up to 100x leverage.
+// Collaterals: TON (9 dec), USDT (6 dec), NOT (9 dec)
+// House fee: 1% of margin (TON collateral only)
+//
+// Docs: https://groypfi.io/docs/groypad#perps-mcp
+
+const EDGE_BASE = "https://rcuesqclhdghrqrmwjlk.supabase.co/functions/v1";
+const STORM_API = "https://api.taragodsnode.xyz/api";
+const API_KEY = "";
+
+const headers = (extra = {}) => ({
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${API_KEY}`,
+ ...extra,
+});
+
+// Helper: call edge function
+async function edgePost(fn, body) {
+ const res = await fetch(`${EDGE_BASE}/${fn}`, {
+ method: "POST",
+ headers: headers(),
+ body: JSON.stringify(body),
+ });
+ return res.json();
+}
+
+// Helper: wait & confirm order
+async function waitForConfirmation(
+ walletAddress,
+ uniqueId,
+ txBoc,
+ maxAttempts = 5
+) {
+ await new Promise((r) => setTimeout(r, 5000)); // initial wait
+ for (let i = 0; i < maxAttempts; i++) {
+ const result = await edgePost("confirm-perps-order", {
+ walletAddress,
+ uniqueId,
+ txBoc,
+ });
+ if (result.status === "confirmed")
+ return { confirmed: true, txHash: result.txHash };
+ if (result.status === "failed")
+ return { confirmed: false, error: "Transaction failed on-chain" };
+ await new Promise((r) => setTimeout(r, 15000));
+ }
+ return { confirmed: false, error: "Confirmation timeout" };
+}
+
+module.exports = {
+ name: "groypfi-perps",
+ description:
+ "Trade perpetual futures on GroypFi Perps (Storm Trade on TON) โ open, close, TP/SL, cancel, oracle prices",
+ version: "2.0.0",
+
+ tools: [
+ // โโ Oracle Price โโ
+ {
+ name: "perps_oracle_price",
+ description: "Get current oracle price for a perps asset",
+ parameters: {
+ asset: {
+ type: "string",
+ description: "Asset symbol e.g. BTC, ETH, TON",
+ required: true,
+ },
+ },
+ async execute({ asset }, { log }) {
+ const data = await edgePost("perps-oracle", {
+ baseAssetName: asset.toUpperCase(),
+ });
+ if (!data.success) return { error: data.error };
+ log.info(`${asset} oracle: $${data.oraclePrice}`);
+ return {
+ asset,
+ price: data.oraclePrice,
+ timestamp: data.timestamp,
+ };
+ },
+ },
+
+ // โโ List Markets โโ
+ {
+ name: "perps_list_markets",
+ description:
+ "List all available perps markets with prices and leverage limits",
+ parameters: {},
+ async execute(_, { log }) {
+ const res = await fetch(`${STORM_API}/markets`);
+ const data = await res.json();
+ const markets = (data.data || data)
+ .filter((m) => {
+ const tags = m.config?.tags || [];
+ return tags.includes("Crypto") && !m.settings?.isCloseOnly;
+ })
+ .map((m) => ({
+ asset: m.config?.baseAsset || m.baseAsset,
+ maxLeverage: Math.round(
+ (m.settings?.maxLeverage || 50e9) / 1e9
+ ),
+ indexPrice: (
+ parseFloat(m.amm?.indexPrice || "0") / 1e9
+ ).toFixed(2),
+ volume24h: (
+ parseFloat(m.change?.quoteVolume || "0") / 1e9
+ ).toFixed(0),
+ }));
+ log.info(`Found ${markets.length} active perps markets`);
+ return markets;
+ },
+ },
+
+ // โโ Get Positions โโ
+ {
+ name: "perps_get_positions",
+ description: "Get all open perpetual positions for a wallet",
+ parameters: {
+ wallet_address: { type: "string", required: true },
+ },
+ async execute({ wallet_address }, { log }) {
+ const res = await fetch(
+ `${STORM_API}/positions/${encodeURIComponent(wallet_address)}`
+ );
+ if (!res.ok) return [];
+ const positions = await res.json();
+ const arr = Array.isArray(positions)
+ ? positions
+ : positions.data || [];
+ log.info(`Found ${arr.length} open positions`);
+ return arr.map((p) => ({
+ asset: p.asset,
+ direction: p.direction,
+ size: p.sizeCrypto,
+ sizeRaw:
+ p.sizeRaw ||
+ String(Math.floor(parseFloat(p.sizeCrypto || "0") * 1e9)),
+ notional: p.size,
+ entryPrice: p.entryPrice,
+ markPrice: p.markPrice,
+ leverage: p.leverage,
+ margin: p.margin,
+ pnl: p.pnl,
+ liquidationPrice: p.liquidationPrice,
+ collateral: p.collateralAsset || "TON",
+ }));
+ },
+ },
+
+ // โโ Open Position โโ
+ {
+ name: "perps_open_position",
+ description:
+ "Open a leveraged long or short position. Returns tx to sign & sends it.",
+ parameters: {
+ pair: {
+ type: "string",
+ required: true,
+ description: "Asset e.g. BTC",
+ },
+ direction: {
+ type: "string",
+ required: true,
+ description: "long or short",
+ },
+ margin: {
+ type: "number",
+ required: true,
+ description: "Collateral amount",
+ },
+ leverage: { type: "number", default: 5 },
+ collateral: { type: "string", default: "TON" },
+ order_type: { type: "string", default: "market" },
+ limit_price: {
+ type: "number",
+ description: "For limit orders",
+ },
+ stop_loss: { type: "number", description: "SL price USD" },
+ take_profit: { type: "number", description: "TP price USD" },
+ },
+ async execute(
+ {
+ pair,
+ direction,
+ margin,
+ leverage = 5,
+ collateral = "TON",
+ order_type = "market",
+ limit_price,
+ stop_loss,
+ take_profit,
+ },
+ { ton, log }
+ ) {
+ const walletAddr = await ton.getMyAddress();
+ const size = margin * leverage;
+ log.info(
+ `Opening ${direction} ${pair} โ ${margin} ${collateral} ร ${leverage}x = $${size}`
+ );
+
+ // 1. Build transaction via edge function
+ const orderRes = await edgePost("perps-order", {
+ action: "create",
+ traderAddress: walletAddr,
+ baseAssetName: pair.toUpperCase(),
+ collateralAssetName: collateral,
+ direction,
+ amount: margin,
+ leverage,
+ orderType: order_type,
+ limitPrice: limit_price,
+ stopLossPrice: stop_loss,
+ takeProfitPrice: take_profit,
+ });
+
+ if (!orderRes.success)
+ return { success: false, error: orderRes.error };
+
+ // 2. Sign & send
+ const tx = await ton.sendRaw(orderRes.transaction);
+ log.info(`TX sent: ${tx.hash}`);
+
+ // 3. Track pending order
+ const uniqueId = `teleton_${Date.now()}`;
+ await edgePost("track-perps-order", {
+ wallet_address: walletAddr,
+ pair: `${pair}/USD`,
+ direction,
+ collateral,
+ order_type,
+ leverage,
+ margin,
+ size,
+ oracle_price_at_submit:
+ orderRes.orderDetails?.sdkOraclePrice,
+ unique_id: uniqueId,
+ submitted_at: Math.floor(Date.now() / 1000),
+ tx_boc: tx.boc,
+ limit_price: limit_price || null,
+ });
+
+ // 4. Wait for confirmation
+ const confirmation = await waitForConfirmation(
+ walletAddr,
+ uniqueId,
+ tx.boc
+ );
+ log.info(`Confirmation: ${JSON.stringify(confirmation)}`);
+
+ return {
+ success: true,
+ txHash: confirmation.txHash || tx.hash,
+ confirmed: confirmation.confirmed,
+ pair,
+ direction,
+ size,
+ leverage,
+ margin,
+ collateral,
+ };
+ },
+ },
+
+ // โโ Close Position โโ
+ {
+ name: "perps_close_position",
+ description: "Close an open perpetual position",
+ parameters: {
+ pair: { type: "string", required: true },
+ direction: { type: "string", required: true },
+ collateral: { type: "string", default: "TON" },
+ },
+ async execute(
+ { pair, direction, collateral = "TON" },
+ { ton, log }
+ ) {
+ const walletAddr = await ton.getMyAddress();
+ log.info(`Closing ${direction} ${pair}`);
+
+ // 1. Fetch close fields (source of truth for size)
+ const closeFields = await fetch(
+ `${STORM_API}/close/fields?walletAddress=${encodeURIComponent(walletAddr)}&asset=${pair}&collateral=${collateral}&side=${direction}`
+ ).then((r) => r.json());
+
+ if (closeFields.status !== "OPEN")
+ return { success: false, error: "No open position found" };
+
+ // 2. Build close tx
+ const orderRes = await edgePost("perps-order", {
+ action: "close",
+ traderAddress: walletAddr,
+ baseAssetName: closeFields.baseAssetName,
+ collateralAssetName: closeFields.collateralAssetName,
+ direction: closeFields.direction,
+ size: closeFields.size, // Raw 9-decimal string โ DO NOT convert
+ });
+
+ if (!orderRes.success)
+ return { success: false, error: orderRes.error };
+
+ // 3. Send tx
+ const tx = await ton.sendRaw(orderRes.transaction);
+ log.info(`Close TX sent: ${tx.hash}`);
+ return {
+ success: true,
+ txHash: tx.hash,
+ pair,
+ direction,
+ action: "close",
+ };
+ },
+ },
+
+ // โโ Set TP/SL โโ
+ {
+ name: "perps_set_tp_sl",
+ description:
+ "Set take-profit or stop-loss on an existing position",
+ parameters: {
+ pair: { type: "string", required: true },
+ direction: { type: "string", required: true },
+ collateral: { type: "string", default: "TON" },
+ tp_price: {
+ type: "number",
+ description: "Take profit price USD",
+ },
+ sl_price: {
+ type: "number",
+ description: "Stop loss price USD",
+ },
+ },
+ async execute(
+ { pair, direction, collateral = "TON", tp_price, sl_price },
+ { ton, log }
+ ) {
+ const walletAddr = await ton.getMyAddress();
+
+ // Get position size
+ const closeFields = await fetch(
+ `${STORM_API}/close/fields?walletAddress=${encodeURIComponent(walletAddr)}&asset=${pair}&collateral=${collateral}&side=${direction}`
+ ).then((r) => r.json());
+
+ if (closeFields.status !== "OPEN")
+ return { success: false, error: "No open position" };
+
+ const results = {};
+
+ if (tp_price) {
+ const res = await edgePost("perps-order", {
+ action: "take-profit",
+ traderAddress: walletAddr,
+ baseAssetName: pair,
+ collateralAssetName: collateral,
+ direction,
+ size: closeFields.size,
+ takeProfitPrice: tp_price,
+ });
+ if (res.success) {
+ const tx = await ton.sendRaw(res.transaction);
+ results.takeProfit = {
+ success: true,
+ txHash: tx.hash,
+ price: tp_price,
+ };
+ log.info(`TP set at $${tp_price}`);
+ } else {
+ results.takeProfit = { success: false, error: res.error };
+ }
+ }
+
+ if (sl_price) {
+ const res = await edgePost("perps-order", {
+ action: "stop-loss",
+ traderAddress: walletAddr,
+ baseAssetName: pair,
+ collateralAssetName: collateral,
+ direction,
+ size: closeFields.size,
+ stopLossPrice: sl_price,
+ });
+ if (res.success) {
+ const tx = await ton.sendRaw(res.transaction);
+ results.stopLoss = {
+ success: true,
+ txHash: tx.hash,
+ price: sl_price,
+ };
+ log.info(`SL set at $${sl_price}`);
+ } else {
+ results.stopLoss = { success: false, error: res.error };
+ }
+ }
+
+ return results;
+ },
+ },
+
+ // โโ Cancel Limit Order โโ
+ {
+ name: "perps_cancel_order",
+ description: "Cancel a pending limit order by its order index",
+ parameters: {
+ pair: { type: "string", required: true },
+ direction: { type: "string", required: true },
+ collateral: { type: "string", default: "TON" },
+ order_index: {
+ type: "number",
+ required: true,
+ description: "From pending orders API",
+ },
+ },
+ async execute(
+ { pair, direction, collateral = "TON", order_index },
+ { ton, log }
+ ) {
+ const walletAddr = await ton.getMyAddress();
+ log.info(`Cancelling limit order #${order_index} for ${pair}`);
+
+ const res = await edgePost("perps-order", {
+ action: "cancel",
+ traderAddress: walletAddr,
+ baseAssetName: pair,
+ collateralAssetName: collateral,
+ direction,
+ orderIndex: order_index,
+ });
+
+ if (!res.success)
+ return { success: false, error: res.error };
+
+ const tx = await ton.sendRaw(res.transaction);
+ log.info(`Cancel TX: ${tx.hash}`);
+ return {
+ success: true,
+ txHash: tx.hash,
+ orderIndex: order_index,
+ };
+ },
+ },
+ ],
+};
+```
+
+# mcp-plugin
+
+This section provides MCP-oriented setup guidance for the GroypFi integration and links.
+
+## GroypFi Plugins for Teleton Agent
+
+Enable AI agents to trade tokens on **Groypad** (bonding-curve launchpad) and **GroypFi Perps** (leveraged perpetuals) โ all on **TON**.
+
+- Website: https://groypfi.io
+- Docs: https://groypfi.io/docs/groypad
+- Telegram Bot: https://t.me/groypfi_bot
+- DefiLlama: https://defillama.com/protocol/fees/groypfi
+
+### Quick Start
+
+1. Copy `groypfi-groypad.js` and `groypfi-perps.js` into your Teleton `plugins/` directory.
+2. Set your API key in both files.
+3. Restart Teleton.
+
+### Configuration
+
+Use either:
+
+```javascript
+// Option 1: Teleton secret manager (recommended)
+const API_KEY = secrets.get("GROYPAD_API_KEY");
+
+// Option 2: Inline
+const API_KEY = "";
+```
+
+### Endpoints
+
+- Supabase REST: `https://rcuesqclhdghrqrmwjlk.supabase.co`
+- Edge Functions: `https://rcuesqclhdghrqrmwjlk.supabase.co/functions/v1`
+- Storm API: `https://api.taragodsnode.xyz/api`
+
+### Contract Reference
+
+- MemeFactory: `EQAO4cYqithwdltzmrlal1L5JKLK5Xk76feAJq0VoBC6Fy8T`
+- Deploy opcode: `0x6ff416dc`
+- Buy opcode: `0x742b36d8`
+- Sell opcode: `0x595f07bc`
+- Claim fee opcode: `0xad7269a8`
+- Graduation target: `1,050 TON`
+- Minimum initial buy: `10 TON`
+
+### Perps Order Flow
+
+```text
+Agent (Teleton)
+ โ
+ โโ perps-oracle โ Edge Function โ Storm API โ Oracle price
+ โโ perps-order โ Edge Function โ Storm SDK โ Transaction BOC
+ โ โ
+ โ Agent signs & sends โโโโโโโโ
+ โ โ
+ โโ confirm-perps-order โ Edge Function โ TON API โ Confirmation
+```
+
+All heavy lifting (Storm SDK, transaction building) is handled server-side by Edge Functions. Plugins only need `fetch` and Teleton's built-in `ton` context.
+
+### License
+
+MIT (GroypFi plugin package)
diff --git a/package-lock.json b/package-lock.json
index bd61a9c..842add5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "teleton",
- "version": "0.7.0",
+ "version": "0.7.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "teleton",
- "version": "0.7.0",
+ "version": "0.7.4",
"license": "MIT",
"workspaces": [
"packages/*"
@@ -16,7 +16,7 @@
"@hono/node-server": "^1.19.9",
"@huggingface/transformers": "^3.8.1",
"@inquirer/prompts": "^8.2.1",
- "@mariozechner/pi-ai": "^0.52.12",
+ "@mariozechner/pi-ai": "^0.54.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sinclair/typebox": "^0.34.48",
"@tavily/core": "^0.7.1",
@@ -2530,9 +2530,9 @@
}
},
"node_modules/@mariozechner/pi-ai": {
- "version": "0.52.12",
- "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.52.12.tgz",
- "integrity": "sha512-oF7OMJu1aUx7MXJeJoJ/3JDXzD2a5SqK9nHVK3mCA8DRQaykv9g+wcFZaANcCl0vAR2QSDr5KN3ZMARlFNWiVg==",
+ "version": "0.54.2",
+ "resolved": "https://registry.npmjs.org/@mariozechner/pi-ai/-/pi-ai-0.54.2.tgz",
+ "integrity": "sha512-QKQV8iT7afwdaOiLDPTPyQcsGw4ulxBjAI0GvgvowAuqy9UbDeKFSdQYLmjVt7CtnJD1Z8zMjQQ4SLigdZ6dRQ==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.73.0",
@@ -4497,9 +4497,9 @@
}
},
"node_modules/ajv": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
- "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
+ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -5845,9 +5845,9 @@
}
},
"node_modules/eslint/node_modules/ajv": {
- "version": "6.12.6",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
- "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7068,9 +7068,9 @@
"license": "MIT"
},
"node_modules/hono": {
- "version": "4.11.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
- "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
+ "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
diff --git a/package.json b/package.json
index d187acc..958344e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "teleton",
- "version": "0.7.0",
+ "version": "0.7.4",
"workspaces": [
"packages/*"
],
@@ -63,7 +63,7 @@
"@hono/node-server": "^1.19.9",
"@huggingface/transformers": "^3.8.1",
"@inquirer/prompts": "^8.2.1",
- "@mariozechner/pi-ai": "^0.52.12",
+ "@mariozechner/pi-ai": "^0.54.2",
"@modelcontextprotocol/sdk": "^1.26.0",
"@sinclair/typebox": "^0.34.48",
"@tavily/core": "^0.7.1",
diff --git a/src/agent/__tests__/lifecycle-e2e.test.ts b/src/agent/__tests__/lifecycle-e2e.test.ts
new file mode 100644
index 0000000..b9e047a
--- /dev/null
+++ b/src/agent/__tests__/lifecycle-e2e.test.ts
@@ -0,0 +1,532 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { Hono } from "hono";
+import { streamSSE } from "hono/streaming";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { AgentLifecycle, type AgentState, type StateChangeEvent } from "../lifecycle.js";
+
+// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+/** Parse SSE text into structured events */
+function parseSSE(text: string): Array<{ event?: string; data?: string; id?: string }> {
+ const events: Array<{ event?: string; data?: string; id?: string }> = [];
+ const blocks = text.split("\n\n").filter(Boolean);
+ for (const block of blocks) {
+ const entry: { event?: string; data?: string; id?: string } = {};
+ for (const line of block.split("\n")) {
+ if (line.startsWith("event:")) entry.event = line.slice(6).trim();
+ else if (line.startsWith("data:")) entry.data = line.slice(5).trim();
+ else if (line.startsWith("id:")) entry.id = line.slice(3).trim();
+ }
+ if (entry.event || entry.data) events.push(entry);
+ }
+ return events;
+}
+
+/** Wait for lifecycle to reach a specific state */
+function waitForState(
+ lifecycle: AgentLifecycle,
+ target: AgentState,
+ timeoutMs = 2000
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (lifecycle.getState() === target) {
+ resolve();
+ return;
+ }
+ const timer = setTimeout(() => {
+ lifecycle.off("stateChange", handler);
+ reject(
+ new Error(`Timeout waiting for state "${target}", current: "${lifecycle.getState()}"`)
+ );
+ }, timeoutMs);
+ const handler = (event: StateChangeEvent) => {
+ if (event.state === target) {
+ clearTimeout(timer);
+ lifecycle.off("stateChange", handler);
+ resolve();
+ }
+ };
+ lifecycle.on("stateChange", handler);
+ });
+}
+
+/**
+ * Build a full Hono app mirroring server.ts agent routes + SSE + a mock /health endpoint.
+ * This is the "WebUI" portion for E2E testing.
+ */
+function createE2EApp(lifecycle: AgentLifecycle) {
+ const app = new Hono();
+
+ // Health check (always works, even when agent is stopped)
+ app.get("/health", (c) => c.json({ status: "ok" }));
+
+ // Mock data endpoints (simulate WebUI pages that work when agent is stopped)
+ app.get("/api/status", (c) =>
+ c.json({ success: true, data: { uptime: 42, model: "test", provider: "test" } })
+ );
+ app.get("/api/tools", (c) =>
+ c.json({ success: true, data: [{ name: "test_tool", module: "core" }] })
+ );
+ app.get("/api/memory", (c) => c.json({ success: true, data: { messages: 10, knowledge: 5 } }));
+ app.get("/api/config", (c) =>
+ c.json({ success: true, data: { agent: { model: "test-model" } } })
+ );
+
+ // Agent lifecycle REST routes
+ app.post("/api/agent/start", async (c) => {
+ 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);
+ }
+ lifecycle.start().catch(() => {});
+ return c.json({ state: "starting" });
+ });
+
+ app.post("/api/agent/stop", async (c) => {
+ 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);
+ }
+ lifecycle.stop().catch(() => {});
+ return c.json({ state: "stopping" });
+ });
+
+ app.get("/api/agent/status", (c) => {
+ if (!lifecycle) {
+ return c.json({ error: "Agent lifecycle not available" }, 503);
+ }
+ return c.json({
+ state: lifecycle.getState(),
+ uptime: lifecycle.getUptime(),
+ error: lifecycle.getError() ?? null,
+ });
+ });
+
+ // SSE endpoint
+ app.get("/api/agent/events", (c) => {
+ 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;
+ 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);
+
+ // Short sleep for E2E tests (don't loop forever)
+ await stream.sleep(100);
+
+ lifecycle.off("stateChange", onStateChange);
+ });
+ });
+
+ return app;
+}
+
+// โโ E2E Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("Agent Lifecycle E2E", () => {
+ let lifecycle: AgentLifecycle;
+ let app: Hono;
+ let startCallCount: number;
+ let stopCallCount: number;
+ let startFn: () => Promise;
+ let stopFn: () => Promise;
+
+ beforeEach(() => {
+ startCallCount = 0;
+ stopCallCount = 0;
+
+ startFn = async () => {
+ startCallCount++;
+ };
+ stopFn = async () => {
+ stopCallCount++;
+ };
+
+ lifecycle = new AgentLifecycle();
+ lifecycle.registerCallbacks(startFn, stopFn);
+ app = createE2EApp(lifecycle);
+ });
+
+ afterEach(async () => {
+ // Ensure lifecycle is stopped to clean up listeners
+ if (lifecycle.getState() === "running") {
+ await lifecycle.stop();
+ }
+ });
+
+ // โโ Scenario 1: Full lifecycle start โ stop โ restart โโ
+
+ it("full lifecycle: start โ stop โ restart (WebUI survives)", async () => {
+ // 1. Initial state: stopped
+ let res = await app.request("/api/agent/status");
+ let data = await res.json();
+ expect(data.state).toBe("stopped");
+
+ // 2. Start agent via API
+ res = await app.request("/api/agent/start", { method: "POST" });
+ data = await res.json();
+ expect(res.status).toBe(200);
+ expect(data.state).toBe("starting");
+
+ // Wait for start to complete
+ await waitForState(lifecycle, "running");
+ expect(lifecycle.getState()).toBe("running");
+ expect(startCallCount).toBe(1);
+
+ // 3. Verify status shows running with uptime
+ res = await app.request("/api/agent/status");
+ data = await res.json();
+ expect(data.state).toBe("running");
+ expect(typeof data.uptime).toBe("number");
+
+ // 4. Stop agent via API
+ res = await app.request("/api/agent/stop", { method: "POST" });
+ data = await res.json();
+ expect(res.status).toBe(200);
+ expect(data.state).toBe("stopping");
+
+ await waitForState(lifecycle, "stopped");
+ expect(lifecycle.getState()).toBe("stopped");
+ expect(stopCallCount).toBe(1);
+
+ // 5. WebUI still responds (health check)
+ res = await app.request("/health");
+ expect(res.status).toBe(200);
+ data = await res.json();
+ expect(data.status).toBe("ok");
+
+ // 6. Restart agent
+ res = await app.request("/api/agent/start", { method: "POST" });
+ expect(res.status).toBe(200);
+
+ await waitForState(lifecycle, "running");
+ expect(lifecycle.getState()).toBe("running");
+ expect(startCallCount).toBe(2);
+
+ // 7. Stop again for cleanup
+ await lifecycle.stop();
+ expect(stopCallCount).toBe(2);
+ });
+
+ // โโ Scenario 2: Stop during active processing (graceful drain) โโ
+
+ it("stop waits for start to complete before stopping", async () => {
+ // Simulate a slow start (like connecting to Telegram)
+ let resolveStart!: () => void;
+ lifecycle.registerCallbacks(
+ () =>
+ new Promise((resolve) => {
+ resolveStart = resolve;
+ }),
+ stopFn
+ );
+
+ // Start agent (will be pending)
+ const startRes = await app.request("/api/agent/start", { method: "POST" });
+ expect(startRes.status).toBe(200);
+ expect(lifecycle.getState()).toBe("starting");
+
+ // Try to stop while starting โ should get 409
+ const stopRes = await app.request("/api/agent/stop", { method: "POST" });
+ expect(stopRes.status).toBe(409);
+
+ // Complete the start
+ resolveStart();
+ await waitForState(lifecycle, "running");
+
+ // Now stop works
+ const stopRes2 = await app.request("/api/agent/stop", { method: "POST" });
+ expect(stopRes2.status).toBe(200);
+ await waitForState(lifecycle, "stopped");
+ });
+
+ // โโ Scenario 3: Start failure โโ
+
+ it("start failure sets error and allows retry", async () => {
+ let callCount = 0;
+ lifecycle.registerCallbacks(async () => {
+ callCount++;
+ if (callCount <= 2) {
+ throw new Error(`Telegram auth expired (attempt ${callCount})`);
+ }
+ // Third attempt succeeds
+ }, stopFn);
+
+ // First attempt: fails
+ const res1 = await app.request("/api/agent/start", { method: "POST" });
+ expect(res1.status).toBe(200);
+
+ await waitForState(lifecycle, "stopped");
+ expect(lifecycle.getError()).toContain("Telegram auth expired (attempt 1)");
+
+ // Status shows error
+ const statusRes = await app.request("/api/agent/status");
+ const status = await statusRes.json();
+ expect(status.state).toBe("stopped");
+ expect(status.error).toContain("attempt 1");
+
+ // Second attempt: fails
+ const res2 = await app.request("/api/agent/start", { method: "POST" });
+ expect(res2.status).toBe(200);
+ await waitForState(lifecycle, "stopped");
+ expect(lifecycle.getError()).toContain("attempt 2");
+
+ // Third attempt: succeeds
+ const res3 = await app.request("/api/agent/start", { method: "POST" });
+ expect(res3.status).toBe(200);
+ await waitForState(lifecycle, "running");
+ expect(lifecycle.getError()).toBeUndefined();
+ expect(lifecycle.getState()).toBe("running");
+
+ // Cleanup
+ await lifecycle.stop();
+ });
+
+ // โโ Scenario 4: SSE delivers correct state on reconnection โโ
+
+ it("SSE reconnection delivers correct state", async () => {
+ // Start agent
+ await lifecycle.start();
+ expect(lifecycle.getState()).toBe("running");
+
+ // Connect SSE โ should get "running" as initial state
+ let res = await app.request("/api/agent/events");
+ let text = await res.text();
+ let events = parseSSE(text);
+ expect(events.length).toBeGreaterThanOrEqual(1);
+ let firstData = JSON.parse(events[0].data!);
+ expect(firstData.state).toBe("running");
+
+ // Stop agent
+ await lifecycle.stop();
+ expect(lifecycle.getState()).toBe("stopped");
+
+ // "Reconnect" SSE โ should get "stopped" as initial state
+ res = await app.request("/api/agent/events");
+ text = await res.text();
+ events = parseSSE(text);
+ expect(events.length).toBeGreaterThanOrEqual(1);
+ firstData = JSON.parse(events[0].data!);
+ expect(firstData.state).toBe("stopped");
+ });
+
+ // โโ Scenario 5: Concurrent start/stop calls are safe โโ
+
+ it("concurrent start calls return same promise (no race)", async () => {
+ // Fire two starts simultaneously
+ const [res1, res2] = await Promise.all([
+ app.request("/api/agent/start", { method: "POST" }),
+ app.request("/api/agent/start", { method: "POST" }),
+ ]);
+
+ const data1 = await res1.json();
+ const data2 = await res2.json();
+
+ // First gets 200 starting, second should get 200 starting or 409 running
+ // (depends on timing โ both are valid)
+ expect([200, 409]).toContain(res1.status);
+ expect([200, 409]).toContain(res2.status);
+
+ await waitForState(lifecycle, "running");
+
+ // Agent started exactly once
+ expect(startCallCount).toBe(1);
+
+ // Cleanup
+ await lifecycle.stop();
+ });
+
+ it("concurrent stop calls after running are safe", async () => {
+ await lifecycle.start();
+
+ const [res1, res2] = await Promise.all([
+ app.request("/api/agent/stop", { method: "POST" }),
+ app.request("/api/agent/stop", { method: "POST" }),
+ ]);
+
+ // One should get 200, the other might get 200 or 409 (already stopping)
+ expect([200, 409]).toContain(res1.status);
+ expect([200, 409]).toContain(res2.status);
+
+ await waitForState(lifecycle, "stopped");
+
+ // Agent stopped exactly once
+ expect(stopCallCount).toBe(1);
+ });
+
+ // โโ Scenario 6: Config reload on restart โโ
+
+ it("startFn is called on each start (config reload opportunity)", async () => {
+ const models: string[] = [];
+ let currentModel = "gpt-4";
+
+ lifecycle.registerCallbacks(async () => {
+ // Simulate reading config from disk on each start
+ models.push(currentModel);
+ }, stopFn);
+
+ // First start: uses gpt-4
+ await lifecycle.start();
+ expect(models).toEqual(["gpt-4"]);
+
+ await lifecycle.stop();
+
+ // "Edit config" while stopped
+ currentModel = "claude-opus-4-6";
+
+ // Second start: picks up new config
+ await lifecycle.start();
+ expect(models).toEqual(["gpt-4", "claude-opus-4-6"]);
+
+ await lifecycle.stop();
+ });
+
+ // โโ Scenario 7: Graceful shutdown (lifecycle + WebUI) โโ
+
+ it("full stop tears down agent then WebUI stays up", async () => {
+ const teardownOrder: string[] = [];
+
+ lifecycle.registerCallbacks(startFn, async () => {
+ teardownOrder.push("agent-stopped");
+ });
+
+ await lifecycle.start();
+ expect(lifecycle.getState()).toBe("running");
+
+ // Simulate graceful shutdown: stop lifecycle first
+ await lifecycle.stop();
+ teardownOrder.push("webui-still-up");
+
+ // WebUI is still responding
+ const res = await app.request("/health");
+ expect(res.status).toBe(200);
+
+ expect(teardownOrder).toEqual(["agent-stopped", "webui-still-up"]);
+ expect(lifecycle.getState()).toBe("stopped");
+ });
+
+ // โโ Scenario 8: WebUI pages accessible while agent stopped โโ
+
+ it("all WebUI data endpoints respond while agent is stopped", async () => {
+ // Agent is stopped โ verify all data endpoints still work
+ expect(lifecycle.getState()).toBe("stopped");
+
+ const endpoints = [
+ "/health",
+ "/api/status",
+ "/api/tools",
+ "/api/memory",
+ "/api/config",
+ "/api/agent/status",
+ ];
+
+ for (const endpoint of endpoints) {
+ const res = await app.request(endpoint);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data).toBeDefined();
+ }
+
+ // Agent lifecycle routes also work
+ const statusRes = await app.request("/api/agent/status");
+ const status = await statusRes.json();
+ expect(status.state).toBe("stopped");
+ expect(status.uptime).toBeNull();
+ });
+
+ // โโ Extra: SSE emits events during startโstop sequence โโ
+
+ it("SSE captures full startโstop state transition sequence", async () => {
+ // Build a custom SSE app that collects events during a startโstop cycle
+ const sseApp = new Hono();
+ sseApp.get("/events", (c) => {
+ return streamSSE(c, async (stream) => {
+ let aborted = false;
+ stream.onAbort(() => {
+ aborted = true;
+ });
+
+ const collected: StateChangeEvent[] = [];
+
+ // Push initial
+ await stream.writeSSE({
+ event: "status",
+ data: JSON.stringify({ state: lifecycle.getState() }),
+ });
+
+ const onStateChange = (event: StateChangeEvent) => {
+ if (aborted) return;
+ collected.push(event);
+ stream.writeSSE({
+ event: "status",
+ data: JSON.stringify({ state: event.state, error: event.error ?? null }),
+ });
+ };
+
+ lifecycle.on("stateChange", onStateChange);
+
+ // Trigger start โ stop during stream
+ await lifecycle.start();
+ await lifecycle.stop();
+
+ await stream.sleep(50);
+ lifecycle.off("stateChange", onStateChange);
+ });
+ });
+
+ const res = await sseApp.request("/events");
+ const text = await res.text();
+ const events = parseSSE(text);
+ const states = events.map((e) => JSON.parse(e.data!).state);
+
+ // Should capture: stopped (initial) โ starting โ running โ stopping โ stopped
+ expect(states).toEqual(["stopped", "starting", "running", "stopping", "stopped"]);
+ });
+});
diff --git a/src/agent/__tests__/lifecycle.test.ts b/src/agent/__tests__/lifecycle.test.ts
new file mode 100644
index 0000000..fc24afc
--- /dev/null
+++ b/src/agent/__tests__/lifecycle.test.ts
@@ -0,0 +1,321 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { AgentLifecycle, type StateChangeEvent } from "../lifecycle.js";
+
+describe("AgentLifecycle", () => {
+ let lifecycle: AgentLifecycle;
+
+ beforeEach(() => {
+ lifecycle = new AgentLifecycle();
+ });
+
+ // 1. Initial state is stopped
+ it("initial state is stopped", () => {
+ expect(lifecycle.getState()).toBe("stopped");
+ });
+
+ // 2. start() transitions to starting then running
+ it("start() transitions to starting then running", async () => {
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await lifecycle.start(async () => {});
+
+ expect(events).toHaveLength(2);
+ expect(events[0].state).toBe("starting");
+ expect(events[1].state).toBe("running");
+ expect(lifecycle.getState()).toBe("running");
+ });
+
+ // 3. start() when already running is no-op
+ it("start() when already running is no-op", async () => {
+ await lifecycle.start(async () => {});
+ expect(lifecycle.getState()).toBe("running");
+
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await lifecycle.start(async () => {});
+ expect(events).toHaveLength(0);
+ });
+
+ // 4. start() when already starting returns same promise
+ it("start() when already starting returns same promise", async () => {
+ let resolveStart!: () => void;
+ const startFn = () =>
+ new Promise((resolve) => {
+ resolveStart = resolve;
+ });
+
+ const p1 = lifecycle.start(startFn);
+ const p2 = lifecycle.start(async () => {});
+
+ resolveStart();
+ await p1;
+ await p2;
+
+ expect(lifecycle.getState()).toBe("running");
+ });
+
+ // 5. start() when stopping throws
+ it("start() when stopping throws", async () => {
+ let resolveStop!: () => void;
+ await lifecycle.start(async () => {});
+
+ const stopPromise = lifecycle.stop(
+ () =>
+ new Promise((resolve) => {
+ resolveStop = resolve;
+ })
+ );
+
+ await expect(lifecycle.start(async () => {})).rejects.toThrow(
+ "Cannot start while agent is stopping"
+ );
+
+ resolveStop();
+ await stopPromise;
+ });
+
+ // 6. stop() transitions to stopping then stopped
+ it("stop() transitions to stopping then stopped", async () => {
+ await lifecycle.start(async () => {});
+
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await lifecycle.stop(async () => {});
+
+ expect(events).toHaveLength(2);
+ expect(events[0].state).toBe("stopping");
+ expect(events[1].state).toBe("stopped");
+ expect(lifecycle.getState()).toBe("stopped");
+ });
+
+ // 7. stop() when already stopped is no-op
+ it("stop() when already stopped is no-op", async () => {
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await lifecycle.stop(async () => {});
+ expect(events).toHaveLength(0);
+ });
+
+ // 8. stop() when already stopping returns same promise
+ it("stop() when already stopping returns same promise", async () => {
+ await lifecycle.start(async () => {});
+
+ let resolveStop!: () => void;
+ const stopFn = () =>
+ new Promise((resolve) => {
+ resolveStop = resolve;
+ });
+
+ const p1 = lifecycle.stop(stopFn);
+ const p2 = lifecycle.stop(async () => {});
+
+ resolveStop();
+ await p1;
+ await p2;
+
+ expect(lifecycle.getState()).toBe("stopped");
+ });
+
+ // 9. stop() when starting waits for start then stops
+ it("stop() when starting waits for start then stops", async () => {
+ let resolveStart!: () => void;
+ const startFn = () =>
+ new Promise((resolve) => {
+ resolveStart = resolve;
+ });
+
+ const startPromise = lifecycle.start(startFn);
+
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ const stopPromise = lifecycle.stop(async () => {});
+
+ // Start hasn't resolved yet, lifecycle should still be starting
+ expect(lifecycle.getState()).toBe("starting");
+
+ resolveStart();
+ await startPromise;
+ await stopPromise;
+
+ expect(lifecycle.getState()).toBe("stopped");
+ // Events should show: running, stopping, stopped (starting was already emitted before listener)
+ expect(events.map((e) => e.state)).toEqual(["running", "stopping", "stopped"]);
+ });
+
+ // 10. Failed start() reverts to stopped with error
+ it("failed start() reverts to stopped with error", async () => {
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await expect(
+ lifecycle.start(async () => {
+ throw new Error("Telegram auth expired");
+ })
+ ).rejects.toThrow("Telegram auth expired");
+
+ expect(lifecycle.getState()).toBe("stopped");
+ expect(lifecycle.getError()).toBe("Telegram auth expired");
+ expect(events).toHaveLength(2);
+ expect(events[0].state).toBe("starting");
+ expect(events[1].state).toBe("stopped");
+ expect(events[1].error).toBe("Telegram auth expired");
+ });
+
+ // 11. start() after failed start works and clears error
+ it("start() after failed start works and clears error", async () => {
+ await lifecycle
+ .start(async () => {
+ throw new Error("fail");
+ })
+ .catch(() => {});
+
+ expect(lifecycle.getError()).toBe("fail");
+
+ await lifecycle.start(async () => {});
+
+ expect(lifecycle.getState()).toBe("running");
+ expect(lifecycle.getError()).toBeUndefined();
+ });
+
+ // 12. stateChange events include correct payload
+ it("stateChange events include correct payload", async () => {
+ const events: StateChangeEvent[] = [];
+ lifecycle.on("stateChange", (e: StateChangeEvent) => events.push(e));
+
+ await lifecycle.start(async () => {});
+
+ for (const event of events) {
+ expect(event).toHaveProperty("state");
+ expect(event).toHaveProperty("timestamp");
+ expect(typeof event.timestamp).toBe("number");
+ expect(event.timestamp).toBeGreaterThan(0);
+ }
+ });
+
+ // 13. Subsystems are started in correct order (mock tracks call order)
+ it("subsystems are started in correct order", async () => {
+ const order: string[] = [];
+ const startFn = async () => {
+ order.push("plugins");
+ order.push("mcp");
+ order.push("telegram");
+ order.push("modules");
+ order.push("debouncer");
+ };
+
+ await lifecycle.start(startFn);
+ expect(order).toEqual(["plugins", "mcp", "telegram", "modules", "debouncer"]);
+ });
+
+ // 14. Subsystems are stopped in reverse order
+ it("subsystems are stopped in reverse order", async () => {
+ await lifecycle.start(async () => {});
+
+ const order: string[] = [];
+ const stopFn = async () => {
+ order.push("watcher");
+ order.push("mcp");
+ order.push("debouncer");
+ order.push("handler");
+ order.push("modules");
+ order.push("bridge");
+ };
+
+ await lifecycle.stop(stopFn);
+ expect(order).toEqual(["watcher", "mcp", "debouncer", "handler", "modules", "bridge"]);
+ });
+
+ // 15. Individual subsystem failure during stop doesn't cascade
+ it("individual subsystem failure during stop does not cascade", async () => {
+ await lifecycle.start(async () => {});
+
+ const completed: string[] = [];
+ const stopFn = async () => {
+ completed.push("step1");
+ // Simulate a failure in one subsystem
+ try {
+ throw new Error("MCP close failed");
+ } catch {
+ // Error handled internally
+ }
+ completed.push("step2");
+ completed.push("step3");
+ };
+
+ await lifecycle.stop(stopFn);
+ expect(lifecycle.getState()).toBe("stopped");
+ expect(completed).toEqual(["step1", "step2", "step3"]);
+ });
+
+ // 16. getUptime() returns seconds when running, null when stopped
+ it("getUptime() returns seconds when running, null when stopped", async () => {
+ expect(lifecycle.getUptime()).toBeNull();
+
+ await lifecycle.start(async () => {});
+
+ const uptime = lifecycle.getUptime();
+ expect(uptime).not.toBeNull();
+ expect(typeof uptime).toBe("number");
+ expect(uptime).toBeGreaterThanOrEqual(0);
+
+ await lifecycle.stop(async () => {});
+ expect(lifecycle.getUptime()).toBeNull();
+ });
+
+ // 17. getError() returns null after successful start
+ it("getError() returns undefined after successful start", async () => {
+ // First, fail a start
+ await lifecycle
+ .start(async () => {
+ throw new Error("initial failure");
+ })
+ .catch(() => {});
+
+ expect(lifecycle.getError()).toBe("initial failure");
+
+ // Successful start clears error
+ await lifecycle.start(async () => {});
+ expect(lifecycle.getError()).toBeUndefined();
+ });
+
+ // Extra: registerCallbacks + no-arg start/stop
+ it("start()/stop() work with registered callbacks", async () => {
+ const startFn = vi.fn(async () => {});
+ const stopFn = vi.fn(async () => {});
+ lifecycle.registerCallbacks(startFn, stopFn);
+
+ await lifecycle.start();
+ expect(startFn).toHaveBeenCalledOnce();
+ expect(lifecycle.getState()).toBe("running");
+
+ await lifecycle.stop();
+ expect(stopFn).toHaveBeenCalledOnce();
+ expect(lifecycle.getState()).toBe("stopped");
+ });
+
+ it("start() without callback or registration throws", async () => {
+ await expect(lifecycle.start()).rejects.toThrow("No start function provided or registered");
+ });
+
+ it("stop() without callback or registration throws when not stopped", async () => {
+ await lifecycle.start(async () => {});
+ // Now try stop() with no registered callback
+ lifecycle["registeredStopFn"] = null;
+ await expect(lifecycle.stop()).rejects.toThrow("No stop function provided or registered");
+ });
+});
diff --git a/src/agent/client.ts b/src/agent/client.ts
index 3469deb..1a5f454 100644
--- a/src/agent/client.ts
+++ b/src/agent/client.ts
@@ -17,11 +17,15 @@ import { getProviderMetadata, type SupportedProvider } from "../config/providers
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";
const log = createLogger("LLM");
export function isOAuthToken(apiKey: string, provider?: string): boolean {
- if (provider && provider !== "anthropic") return false;
+ if (provider && provider !== "anthropic" && provider !== "claude-code") return false;
return apiKey.startsWith("sk-ant-oat01-");
}
@@ -29,6 +33,7 @@ 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);
return rawKey;
}
@@ -292,7 +297,23 @@ export async function chatWithContext(
completeOptions.onPayload = stripCocoonPayload;
}
- const response = await complete(model, context, completeOptions as ProviderStreamOptions);
+ 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 = refreshClaudeCodeApiKey();
+ if (refreshedKey) {
+ completeOptions.apiKey = refreshedKey;
+ response = await complete(model, context, completeOptions as ProviderStreamOptions);
+ }
+ }
// Cocoon: parse from text response
if (isCocoon) {
diff --git a/src/agent/lifecycle.ts b/src/agent/lifecycle.ts
new file mode 100644
index 0000000..f25dbfb
--- /dev/null
+++ b/src/agent/lifecycle.ts
@@ -0,0 +1,151 @@
+import { EventEmitter } from "node:events";
+import { createLogger } from "../utils/logger.js";
+
+const log = createLogger("Lifecycle");
+
+export type AgentState = "stopped" | "starting" | "running" | "stopping";
+
+export interface StateChangeEvent {
+ state: AgentState;
+ error?: string;
+ timestamp: number;
+}
+
+export class AgentLifecycle extends EventEmitter {
+ private state: AgentState = "stopped";
+ private error: string | undefined;
+ private startPromise: Promise | null = null;
+ private stopPromise: Promise | null = null;
+ private runningSince: number | null = null;
+ private registeredStartFn: (() => Promise) | null = null;
+ private registeredStopFn: (() => Promise) | null = null;
+
+ getState(): AgentState {
+ return this.state;
+ }
+
+ getError(): string | undefined {
+ return this.error;
+ }
+
+ getUptime(): number | null {
+ if (this.state !== "running" || this.runningSince === null) {
+ return null;
+ }
+ return Math.floor((Date.now() - this.runningSince) / 1000);
+ }
+
+ /**
+ * Register the start/stop callbacks so start()/stop() can be called without args.
+ */
+ registerCallbacks(startFn: () => Promise, stopFn: () => Promise): void {
+ this.registeredStartFn = startFn;
+ this.registeredStopFn = stopFn;
+ }
+
+ /**
+ * Start the agent. Uses the provided callback or falls back to registered one.
+ * - No-op if already running
+ * - Returns existing promise if already starting
+ * - Throws if currently stopping
+ */
+ async start(startFn?: () => Promise): Promise {
+ const fn = startFn ?? this.registeredStartFn;
+ if (!fn) {
+ throw new Error("No start function provided or registered");
+ }
+
+ if (this.state === "running") {
+ return;
+ }
+
+ if (this.state === "starting") {
+ return this.startPromise!;
+ }
+
+ if (this.state === "stopping") {
+ throw new Error("Cannot start while agent is stopping");
+ }
+
+ this.transition("starting");
+
+ this.startPromise = (async () => {
+ try {
+ await fn();
+ this.error = undefined;
+ this.runningSince = Date.now();
+ this.transition("running");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ this.error = message;
+ this.runningSince = null;
+ this.transition("stopped", message);
+ throw err;
+ } finally {
+ this.startPromise = null;
+ }
+ })();
+
+ return this.startPromise;
+ }
+
+ /**
+ * Stop the agent. Uses the provided callback or falls back to registered one.
+ * - No-op if already stopped
+ * - Returns existing promise if already stopping
+ * - If starting, waits for start to complete then stops
+ */
+ async stop(stopFn?: () => Promise): Promise {
+ const fn = stopFn ?? this.registeredStopFn;
+ if (!fn) {
+ throw new Error("No stop function provided or registered");
+ }
+
+ if (this.state === "stopped") {
+ return;
+ }
+
+ if (this.state === "stopping") {
+ return this.stopPromise!;
+ }
+
+ // If currently starting, wait for start to finish first
+ if (this.state === "starting" && this.startPromise) {
+ try {
+ await this.startPromise;
+ } catch {
+ // Start failed โ agent is already stopped
+ return;
+ }
+ }
+
+ this.transition("stopping");
+
+ this.stopPromise = (async () => {
+ try {
+ await fn();
+ } catch (err) {
+ log.error({ err }, "Error during agent stop");
+ } finally {
+ this.runningSince = null;
+ this.transition("stopped");
+ this.stopPromise = null;
+ }
+ })();
+
+ return this.stopPromise;
+ }
+
+ private transition(newState: AgentState, error?: string): void {
+ this.state = newState;
+ const event: StateChangeEvent = {
+ state: newState,
+ timestamp: Date.now(),
+ };
+ if (error !== undefined) {
+ event.error = error;
+ }
+ log.info(`Agent state: ${newState}${error ? ` (${error})` : ""}`);
+ this.emit("stateChange", event);
+ }
+}
diff --git a/src/agent/runtime.ts b/src/agent/runtime.ts
index e9fc47e..5097618 100644
--- a/src/agent/runtime.ts
+++ b/src/agent/runtime.ts
@@ -9,6 +9,7 @@ import {
CONTEXT_MAX_RELEVANT_CHUNKS,
CONTEXT_OVERFLOW_SUMMARY_MESSAGES,
RATE_LIMIT_MAX_RETRIES,
+ SERVER_ERROR_MAX_RETRIES,
} from "../constants/limits.js";
import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js";
import {
@@ -75,7 +76,8 @@ function isContextOverflowError(errorMessage?: string): boolean {
function isTrivialMessage(text: string): boolean {
const stripped = text.trim();
- if (stripped.length > 0 && !/[a-zA-Z0-9ะฐ-ัะ-ะฏัะ]/.test(stripped)) return true;
+ if (!stripped) return true;
+ if (!/[a-zA-Z0-9ะฐ-ัะ-ะฏัะ]/.test(stripped)) return true;
const trivial =
/^(ok|okay|k|oui|non|yes|no|yep|nope|sure|thanks|merci|thx|ty|lol|haha|cool|nice|wow|bravo|top|parfait|d'accord|alright|fine|got it|np|gg)\.?!?$/i;
return trivial.test(stripped);
@@ -176,7 +178,8 @@ export class AgentRuntime {
senderUsername?: string,
hasMedia?: boolean,
mediaType?: string,
- messageId?: number
+ messageId?: number,
+ replyContext?: { senderName?: string; text: string; isAgent?: boolean }
): Promise {
try {
let session = getOrCreateSession(chatId);
@@ -231,6 +234,7 @@ export class AgentRuntime {
hasMedia,
mediaType,
messageId,
+ replyContext,
});
if (pendingContext) {
@@ -324,7 +328,7 @@ export class AgentRuntime {
const preemptiveCompaction = await this.compactionManager.checkAndCompact(
session.sessionId,
context,
- this.config.agent.api_key,
+ getEffectiveApiKey(this.config.agent.provider, this.config.agent.api_key),
chatId,
this.config.agent.provider as SupportedProvider,
this.config.agent.utility_model
@@ -380,6 +384,7 @@ export class AgentRuntime {
let iteration = 0;
let overflowResets = 0;
let rateLimitRetries = 0;
+ let serverErrorRetries = 0;
let finalResponse: ChatResponse | null = null;
const totalToolCalls: Array<{ name: string; input: Record }> = [];
const accumulatedTexts: string[] = [];
@@ -453,6 +458,26 @@ export class AgentRuntime {
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")
+ ) {
+ 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));
+ iteration--;
+ 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"}`);
@@ -594,7 +619,7 @@ export class AgentRuntime {
const newSessionId = await this.compactionManager.checkAndCompact(
session.sessionId,
context,
- this.config.agent.api_key,
+ getEffectiveApiKey(this.config.agent.provider, this.config.agent.api_key),
chatId,
this.config.agent.provider as SupportedProvider,
this.config.agent.utility_model
diff --git a/src/agent/tools/deals/cancel.ts b/src/agent/tools/deals/cancel.ts
index 0eb47d8..a64b981 100644
--- a/src/agent/tools/deals/cancel.ts
+++ b/src/agent/tools/deals/cancel.ts
@@ -13,19 +13,7 @@ interface DealCancelParams {
export const dealCancelTool: Tool = {
name: "deal_cancel",
- description: `Cancel an active deal (proposed or accepted status only).
-
-IMPORTANT: Cannot cancel deals that are:
-- Already verified (payment received)
-- Already completed
-- Already declined, expired, or failed
-
-Use this when:
-- User explicitly asks to cancel
-- Deal terms change before verification
-- External circumstances make deal impossible
-
-The deal status will be set to 'cancelled' and cannot be resumed.`,
+ description: "Cancel a deal. Only works for 'proposed' or 'accepted' status. Irreversible.",
parameters: Type.Object({
dealId: Type.String({ description: "Deal ID to cancel" }),
reason: Type.Optional(Type.String({ description: "Reason for cancellation (optional)" })),
diff --git a/src/agent/tools/deals/list.ts b/src/agent/tools/deals/list.ts
index 5d4da15..e41ee91 100644
--- a/src/agent/tools/deals/list.ts
+++ b/src/agent/tools/deals/list.ts
@@ -15,14 +15,7 @@ interface DealListParams {
export const dealListTool: Tool = {
name: "deal_list",
- description: `List recent deals with optional filters.
-
-Filters:
-- status: Filter by status (proposed, accepted, verified, completed, declined, expired, cancelled, failed)
-- userId: Filter by user's Telegram ID
-- limit: Max results (default 20)
-
-Returns summary of each deal with ID, status, parties, trade details, timestamps.`,
+ description: "List recent deals. Filter by status or user. Non-admins see only their own deals.",
category: "data-bearing",
parameters: Type.Object({
status: Type.Optional(
diff --git a/src/agent/tools/deals/propose.ts b/src/agent/tools/deals/propose.ts
index 435a8c3..5647c20 100644
--- a/src/agent/tools/deals/propose.ts
+++ b/src/agent/tools/deals/propose.ts
@@ -30,28 +30,8 @@ interface DealProposeParams {
export const dealProposeTool: Tool = {
name: "deal_propose",
- description: `Create a trade deal proposal with interactive Accept/Decline buttons.
-
-Automatically sends an inline bot message with buttons in the chat.
-The user can Accept or Decline directly from the message.
-
-IMPORTANT - MESSAGE FLOW:
-- Send your message BEFORE calling this tool (e.g. "I'll create a deal for you")
-- Do NOT send any message after this tool returns โ the deal card already contains all info
-- The inline bot message IS the proposal, no need to repeat deal details
-
-CRITICAL - STRATEGY.md ENFORCEMENT:
-- When BUYING (you buy their gift): Pay max 80% of floor price
-- When SELLING (you sell your gift): Charge min 115% of floor price
-- Gift swaps: Must receive equal or more value
-- User ALWAYS sends first (TON or gift)
-
-BEFORE proposing:
-1. Check gift floor price if market plugin is available
-2. Calculate values in TON
-3. This tool will REJECT deals that violate strategy
-
-Deal expires in 2 minutes if not accepted.`,
+ description:
+ "Create a trade deal with Accept/Decline buttons. Sends an inline bot message โ do NOT send another message after. Strategy compliance is enforced automatically (will reject bad deals). User always sends first. Expires in 2 minutes.",
parameters: Type.Object({
chatId: Type.String({ description: "Chat ID where to send proposal" }),
userId: Type.Number({ description: "Telegram user ID" }),
diff --git a/src/agent/tools/deals/status.ts b/src/agent/tools/deals/status.ts
index 9d57107..ff4bed5 100644
--- a/src/agent/tools/deals/status.ts
+++ b/src/agent/tools/deals/status.ts
@@ -13,15 +13,8 @@ interface DealStatusParams {
export const dealStatusTool: Tool = {
name: "deal_status",
- description: `Check the status and details of a deal by ID.
-
-Shows:
-- Deal parties (user, agent)
-- What each side gives/receives
-- Current status (proposed, accepted, verified, completed, etc.)
-- Timestamps (created, expires, verified, completed)
-- Payment/transfer tracking info (TX hashes, msgIds)
-- Profit calculation`,
+ description:
+ "Get full details of a deal by ID: status, parties, assets, payment tracking, profit.",
category: "data-bearing",
parameters: Type.Object({
dealId: Type.String({ description: "Deal ID to check status for" }),
diff --git a/src/agent/tools/deals/verify-payment.ts b/src/agent/tools/deals/verify-payment.ts
index 7244a85..4c7da85 100644
--- a/src/agent/tools/deals/verify-payment.ts
+++ b/src/agent/tools/deals/verify-payment.ts
@@ -16,15 +16,8 @@ interface DealVerifyPaymentParams {
export const dealVerifyPaymentTool: Tool = {
name: "deal_verify_payment",
- description: `Verify payment/gift for an ACCEPTED deal.
-
-For TON payments: Checks blockchain for transaction with memo = dealId
-For gift transfers: Polls telegram_get_my_gifts for newly received gift
-
-Updates deal status to 'verified' if successful.
-Auto-triggers executor after verification.
-
-IMPORTANT: Only call this for deals with status = 'accepted'.`,
+ description:
+ "Verify payment/gift for an accepted deal. Checks blockchain (TON) or gift inbox. Auto-executes on success. Only for status='accepted'.",
parameters: Type.Object({
dealId: Type.String({ description: "Deal ID to verify payment for" }),
}),
diff --git a/src/agent/tools/dedust/pools.ts b/src/agent/tools/dedust/pools.ts
index 1b0b876..d6c540b 100644
--- a/src/agent/tools/dedust/pools.ts
+++ b/src/agent/tools/dedust/pools.ts
@@ -44,8 +44,7 @@ interface DedustPoolResponse {
}
export const dedustPoolsTool: Tool = {
name: "dedust_pools",
- description:
- "List liquidity pools on DeDust DEX. Can filter by jetton address or pool type. Shows reserves, fees, and trading volume.",
+ description: "List DeDust liquidity pools. Filter by jetton address or pool type.",
category: "data-bearing",
parameters: Type.Object({
jetton_address: Type.Optional(
diff --git a/src/agent/tools/dedust/prices.ts b/src/agent/tools/dedust/prices.ts
index f5b45ec..fb9201d 100644
--- a/src/agent/tools/dedust/prices.ts
+++ b/src/agent/tools/dedust/prices.ts
@@ -20,8 +20,7 @@ interface PriceEntry {
}
export const dedustPricesTool: Tool = {
name: "dedust_prices",
- description:
- "Get real-time token prices from DeDust DEX. Returns USD prices for TON, BTC, ETH, USDT, and other listed tokens. Optionally filter by symbol(s).",
+ description: "Get real-time token prices from DeDust. Optionally filter by symbol(s).",
category: "data-bearing",
parameters: Type.Object({
symbols: Type.Optional(
diff --git a/src/agent/tools/dedust/quote.ts b/src/agent/tools/dedust/quote.ts
index fd77c70..d729aa1 100644
--- a/src/agent/tools/dedust/quote.ts
+++ b/src/agent/tools/dedust/quote.ts
@@ -1,8 +1,7 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { TonClient } from "@ton/ton";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
+import { getCachedTonClient } from "../../../ton/wallet-service.js";
import { Factory, Asset, PoolType, ReadinessStatus } from "@dedust/sdk";
import { DEDUST_FACTORY_MAINNET, NATIVE_TON_ADDRESS } from "./constants.js";
import { getDecimals, toUnits, fromUnits } from "./asset-cache.js";
@@ -20,14 +19,16 @@ interface DedustQuoteParams {
export const dedustQuoteTool: Tool = {
name: "dedust_quote",
description:
- "Get a price quote for a token swap on DeDust DEX WITHOUT executing it. Shows expected output, minimum output, and pool info. Use 'ton' as from_asset for TON, or jetton master address. Pool types: 'volatile' (default) or 'stable' (for stablecoins).",
+ "Get a price quote for a token swap on DeDust DEX without executing it. Use 'ton' for TON or jetton master address.",
category: "data-bearing",
parameters: Type.Object({
from_asset: Type.String({
- description: "Source asset: 'ton' for TON, or jetton master address (EQ... format)",
+ description:
+ "Source asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
to_asset: Type.String({
- description: "Destination asset: 'ton' for TON, or jetton master address (EQ... format)",
+ description:
+ "Destination asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
amount: Type.Number({
description: "Amount to swap in human-readable units",
@@ -85,8 +86,7 @@ export const dedustQuoteExecutor: ToolExecutor = async (
}
}
- const endpoint = await getCachedHttpEndpoint();
- const tonClient = new TonClient({ endpoint });
+ const tonClient = await getCachedTonClient();
const factory = tonClient.open(
Factory.createFromAddress(Address.parse(DEDUST_FACTORY_MAINNET))
diff --git a/src/agent/tools/dedust/swap.ts b/src/agent/tools/dedust/swap.ts
index 7564640..fa117d5 100644
--- a/src/agent/tools/dedust/swap.ts
+++ b/src/agent/tools/dedust/swap.ts
@@ -1,12 +1,17 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, fromNano } from "@ton/ton";
+import {
+ loadWallet,
+ getKeyPair,
+ getCachedTonClient,
+ invalidateTonClientCache,
+} from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, fromNano } from "@ton/ton";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
import { Factory, Asset, PoolType, ReadinessStatus, JettonRoot, VaultJetton } from "@dedust/sdk";
import { DEDUST_FACTORY_MAINNET, DEDUST_GAS, NATIVE_TON_ADDRESS } from "./constants.js";
import { getDecimals, toUnits, fromUnits } from "./asset-cache.js";
+import { withTxLock } from "../../../ton/tx-lock.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
@@ -21,13 +26,15 @@ interface DedustSwapParams {
export const dedustSwapTool: Tool = {
name: "dedust_swap",
description:
- "Execute a token swap on DeDust DEX. Supports TON->Jetton and Jetton->TON/Jetton swaps. Use 'ton' as from_asset or to_asset for TON. Pool types: 'volatile' (default) or 'stable' (for stablecoins like USDT/USDC). Use dedust_quote first to preview the swap.",
+ "Execute a token swap on DeDust. Supports TON<->jetton and jetton<->jetton. Use dedust_quote first to preview.",
parameters: Type.Object({
from_asset: Type.String({
- description: "Source asset: 'ton' for TON, or jetton master address (EQ... format)",
+ description:
+ "Source asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
to_asset: Type.String({
- description: "Destination asset: 'ton' for TON, or jetton master address (EQ... format)",
+ description:
+ "Destination asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
amount: Type.Number({
description: "Amount to swap in human-readable units (e.g., 10 for 10 TON or 10 tokens)",
@@ -93,8 +100,7 @@ export const dedustSwapExecutor: ToolExecutor = async (
}
}
- const endpoint = await getCachedHttpEndpoint();
- const tonClient = new TonClient({ endpoint });
+ const tonClient = await getCachedTonClient();
const factory = tonClient.open(
Factory.createFromAddress(Address.parse(DEDUST_FACTORY_MAINNET))
@@ -130,108 +136,115 @@ export const dedustSwapExecutor: ToolExecutor = async (
// Calculate minimum output with slippage
const minAmountOut = amountOut - (amountOut * BigInt(Math.floor(slippage * 10000))) / 10000n;
- // Prepare wallet and sender
- const keyPair = await getKeyPair();
- if (!keyPair) {
- return { success: false, error: "Wallet key derivation failed." };
- }
- const wallet = WalletContractV5R1.create({
- workchain: 0,
- publicKey: keyPair.publicKey,
- });
- const walletContract = tonClient.open(wallet);
- const sender = walletContract.sender(keyPair.secretKey);
-
- if (isTonInput) {
- // Check balance for TON swaps
- const balance = await tonClient.getBalance(Address.parse(walletData.address));
- const requiredAmount = amountIn + toNano(DEDUST_GAS.SWAP_TON_TO_JETTON);
- if (balance < requiredAmount) {
- return {
- success: false,
- error: `Insufficient balance. Have ${fromNano(balance)} TON, need ~${fromNano(requiredAmount)} TON (including gas).`,
- };
- }
-
- // TON -> Jetton swap using SDK's sendSwap method
- const tonVault = tonClient.open(await factory.getNativeVault());
-
- // Check vault readiness
- const vaultStatus = await tonVault.getReadinessStatus();
- if (vaultStatus !== ReadinessStatus.READY) {
- return {
- success: false,
- error: "TON vault not ready",
- };
+ // 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) {
+ return { success: false, error: "Wallet key derivation failed." };
}
-
- // Use SDK's sendSwap method
- await tonVault.sendSwap(sender, {
- poolAddress: pool.address,
- amount: amountIn,
- limit: minAmountOut,
- gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON),
+ const wallet = WalletContractV5R1.create({
+ workchain: 0,
+ publicKey: keyPair.publicKey,
});
- } else {
- // Jetton -> TON/Jetton swap (use normalized address)
- const jettonAddress = Address.parse(fromAssetAddr);
- const jettonVault = tonClient.open(await factory.getJettonVault(jettonAddress));
-
- // Check vault readiness
- const vaultStatus = await jettonVault.getReadinessStatus();
- if (vaultStatus !== ReadinessStatus.READY) {
- return {
- success: false,
- error: "Jetton vault not ready. The jetton may not be supported on DeDust.",
- };
+ const walletContract = tonClient.open(wallet);
+ const sender = walletContract.sender(keyPair.secretKey);
+
+ if (isTonInput) {
+ // Check balance for TON swaps
+ const balance = await tonClient.getBalance(Address.parse(walletData.address));
+ const requiredAmount = amountIn + toNano(DEDUST_GAS.SWAP_TON_TO_JETTON);
+ if (balance < requiredAmount) {
+ return {
+ success: false,
+ error: `Insufficient balance. Have ${fromNano(balance)} TON, need ~${fromNano(requiredAmount)} TON (including gas).`,
+ };
+ }
+
+ // TON -> Jetton swap using SDK's sendSwap method
+ const tonVault = tonClient.open(await factory.getNativeVault());
+
+ // Check vault readiness
+ const vaultStatus = await tonVault.getReadinessStatus();
+ if (vaultStatus !== ReadinessStatus.READY) {
+ return {
+ success: false,
+ error: "TON vault not ready",
+ };
+ }
+
+ // Use SDK's sendSwap method
+ await tonVault.sendSwap(sender, {
+ poolAddress: pool.address,
+ amount: amountIn,
+ limit: minAmountOut,
+ gasAmount: toNano(DEDUST_GAS.SWAP_TON_TO_JETTON),
+ });
+ } else {
+ // Jetton -> TON/Jetton swap (use normalized address)
+ const jettonAddress = Address.parse(fromAssetAddr);
+ const jettonVault = tonClient.open(await factory.getJettonVault(jettonAddress));
+
+ // Check vault readiness
+ const vaultStatus = await jettonVault.getReadinessStatus();
+ if (vaultStatus !== ReadinessStatus.READY) {
+ return {
+ success: false,
+ error: "Jetton vault not ready. The jetton may not be supported on DeDust.",
+ };
+ }
+
+ const jettonRoot = tonClient.open(JettonRoot.createFromAddress(jettonAddress));
+ const jettonWallet = tonClient.open(
+ await jettonRoot.getWallet(Address.parse(walletData.address))
+ );
+
+ // Build swap payload using SDK
+ const swapPayload = VaultJetton.createSwapPayload({
+ poolAddress: pool.address,
+ limit: minAmountOut,
+ });
+
+ // 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,
+ });
}
- const jettonRoot = tonClient.open(JettonRoot.createFromAddress(jettonAddress));
- const jettonWallet = tonClient.open(
- await jettonRoot.getWallet(Address.parse(walletData.address))
- );
+ // Calculate expected output for display using correct decimals
+ const expectedOutput = fromUnits(amountOut, toDecimals);
+ const minOutput = fromUnits(minAmountOut, toDecimals);
+ const feeAmount = fromUnits(tradeFee, toDecimals);
- // Build swap payload using SDK
- const swapPayload = VaultJetton.createSwapPayload({
- poolAddress: pool.address,
- limit: minAmountOut,
- });
+ const fromSymbol = isTonInput ? "TON" : "Token";
+ const toSymbol = isTonOutput ? "TON" : "Token";
- // 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,
- });
+ return {
+ success: true,
+ data: {
+ dex: "DeDust",
+ from: isTonInput ? NATIVE_TON_ADDRESS : fromAssetAddr,
+ to: isTonOutput ? NATIVE_TON_ADDRESS : toAssetAddr,
+ amountIn: amount.toString(),
+ expectedOutput: expectedOutput.toFixed(6),
+ minOutput: minOutput.toFixed(6),
+ slippage: `${(slippage * 100).toFixed(2)}%`,
+ 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)`,
+ },
+ };
+ }); // withTxLock
+ } catch (error: any) {
+ const status = error?.status || error?.response?.status;
+ if (status === 429 || status >= 500) {
+ invalidateTonClientCache();
}
-
- // Calculate expected output for display using correct decimals
- const expectedOutput = fromUnits(amountOut, toDecimals);
- const minOutput = fromUnits(minAmountOut, toDecimals);
- const feeAmount = fromUnits(tradeFee, toDecimals);
-
- const fromSymbol = isTonInput ? "TON" : "Token";
- const toSymbol = isTonOutput ? "TON" : "Token";
-
- return {
- success: true,
- data: {
- dex: "DeDust",
- from: isTonInput ? NATIVE_TON_ADDRESS : fromAssetAddr,
- to: isTonOutput ? NATIVE_TON_ADDRESS : toAssetAddr,
- amountIn: amount.toString(),
- expectedOutput: expectedOutput.toFixed(6),
- minOutput: minOutput.toFixed(6),
- slippage: `${(slippage * 100).toFixed(2)}%`,
- 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)`,
- },
- };
- } catch (error) {
log.error({ err: error }, "Error in dedust_swap");
return {
success: false,
diff --git a/src/agent/tools/dedust/token-info.ts b/src/agent/tools/dedust/token-info.ts
index 0ad7c6d..13da5ad 100644
--- a/src/agent/tools/dedust/token-info.ts
+++ b/src/agent/tools/dedust/token-info.ts
@@ -13,7 +13,7 @@ interface DedustTokenInfoParams {
export const dedustTokenInfoTool: Tool = {
name: "dedust_token_info",
description:
- "Get detailed information about a jetton on DeDust: on-chain metadata (name, symbol, decimals, image), top holders, top traders by volume, and largest recent buys. Accepts a jetton master address (EQ...) or a symbol like 'USDT'.",
+ "Get jetton info from DeDust: metadata, top holders, top traders, largest buys. Accepts address or symbol.",
category: "data-bearing",
parameters: Type.Object({
token: Type.String({
diff --git a/src/agent/tools/dns/auctions.ts b/src/agent/tools/dns/auctions.ts
index df19490..b47e0f5 100644
--- a/src/agent/tools/dns/auctions.ts
+++ b/src/agent/tools/dns/auctions.ts
@@ -10,8 +10,7 @@ interface DnsAuctionsParams {
}
export const dnsAuctionsTool: Tool = {
name: "dns_auctions",
- description:
- "List all active .ton domain auctions. Returns domains currently in auction with current bid prices, number of bids, and end times.",
+ description: "List active .ton domain auctions with current bids and end times.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
diff --git a/src/agent/tools/dns/bid.ts b/src/agent/tools/dns/bid.ts
index 35d31b1..a403447 100644
--- a/src/agent/tools/dns/bid.ts
+++ b/src/agent/tools/dns/bid.ts
@@ -1,12 +1,12 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton";
+import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, fromNano, internal } from "@ton/ton";
import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.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";
const log = createLogger("Tools");
interface DnsBidParams {
@@ -16,7 +16,7 @@ interface DnsBidParams {
export const dnsBidTool: Tool = {
name: "dns_bid",
description:
- "Place a bid on an existing .ton domain auction. Bid must be at least 5% higher than current bid. The domain must already be in auction (use dns_check first to verify status and get current bid).",
+ "Place a bid on a .ton domain auction. Bid must be >= 105% of current bid. Use dns_check first.",
parameters: Type.Object({
domain: Type.String({
description: "Domain name (with or without .ton extension)",
@@ -81,7 +81,7 @@ export const dnsBidExecutor: ToolExecutor = async (
const auction = auctions.data?.find((a: any) => a.domain === fullDomain);
if (auction) {
- const currentBid = Number(BigInt(auction.price) / BigInt(1_000_000_000));
+ const currentBid = parseFloat(fromNano(auction.price));
const minBid = currentBid * 1.05;
if (amount < minBid) {
@@ -111,25 +111,26 @@ export const dnsBidExecutor: ToolExecutor = async (
publicKey: keyPair.publicKey,
});
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const contract = client.open(wallet);
- const seqno = await contract.getSeqno();
-
- // Send bid (just TON, no body needed for bids - op=0 is implicit)
- await contract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(nftAddress),
- value: toNano(amount),
- body: "", // Empty body for bid
- bounce: true,
- }),
- ],
+ await withTxLock(async () => {
+ const seqno = await contract.getSeqno();
+
+ // Send bid (just TON, no body needed for bids - op=0 is implicit)
+ await contract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: Address.parse(nftAddress),
+ value: toNano(amount),
+ body: "", // Empty body for bid
+ bounce: true,
+ }),
+ ],
+ });
});
return {
diff --git a/src/agent/tools/dns/check.ts b/src/agent/tools/dns/check.ts
index 92c9eb4..49d6176 100644
--- a/src/agent/tools/dns/check.ts
+++ b/src/agent/tools/dns/check.ts
@@ -10,8 +10,7 @@ interface DnsCheckParams {
}
export const dnsCheckTool: Tool = {
name: "dns_check",
- description:
- "Check if a .ton domain is available, in auction, or already owned. Returns status with relevant details (price estimates, current bids, owner info).",
+ description: "Check .ton domain status: available, in auction, or owned.",
category: "data-bearing",
parameters: Type.Object({
domain: Type.String({
diff --git a/src/agent/tools/dns/link.ts b/src/agent/tools/dns/link.ts
index da68dca..1a938be 100644
--- a/src/agent/tools/dns/link.ts
+++ b/src/agent/tools/dns/link.ts
@@ -1,12 +1,12 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal, beginCell } from "@ton/ton";
+import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton";
import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.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";
const log = createLogger("Tools");
@@ -26,8 +26,7 @@ interface DnsLinkParams {
}
export const dnsLinkTool: Tool = {
name: "dns_link",
- description:
- "Link a wallet address to a .ton domain you own. This sets the wallet record so the domain resolves to the specified address. If no wallet_address is provided, links to your own wallet.",
+ description: "Link a wallet address to a .ton domain you own. Defaults to your own wallet.",
parameters: Type.Object({
domain: Type.String({
description: "Domain name (with or without .ton extension)",
@@ -129,40 +128,41 @@ export const dnsLinkExecutor: ToolExecutor = async (
publicKey: keyPair.publicKey,
});
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const contract = client.open(wallet);
- const seqno = await contract.getSeqno();
-
- // Build wallet record value cell: dns_smc_address#9fd3 + address + flags
- const valueCell = beginCell()
- .storeUint(DNS_SMC_ADDRESS_PREFIX, 16) // #9fd3
- .storeAddress(Address.parse(targetAddress)) // MsgAddressInt
- .storeUint(0, 8) // flags = 0 (simple wallet)
- .endCell();
-
- // Build change_dns_record message body
- const body = beginCell()
- .storeUint(DNS_CHANGE_RECORD_OP, 32) // op = change_dns_record
- .storeUint(0, 64) // query_id
- .storeUint(WALLET_RECORD_KEY, 256) // key = sha256("wallet")
- .storeRef(valueCell) // value cell reference
- .endCell();
-
- // Send transaction to NFT address
- await contract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(nftAddress),
- value: toNano("0.05"), // Gas for DNS record update
- body,
- bounce: true,
- }),
- ],
+ await withTxLock(async () => {
+ const seqno = await contract.getSeqno();
+
+ // Build wallet record value cell: dns_smc_address#9fd3 + address + flags
+ const valueCell = beginCell()
+ .storeUint(DNS_SMC_ADDRESS_PREFIX, 16) // #9fd3
+ .storeAddress(Address.parse(targetAddress)) // MsgAddressInt
+ .storeUint(0, 8) // flags = 0 (simple wallet)
+ .endCell();
+
+ // Build change_dns_record message body
+ const body = beginCell()
+ .storeUint(DNS_CHANGE_RECORD_OP, 32) // op = change_dns_record
+ .storeUint(0, 64) // query_id
+ .storeUint(WALLET_RECORD_KEY, 256) // key = sha256("wallet")
+ .storeRef(valueCell) // value cell reference
+ .endCell();
+
+ // Send transaction to NFT address
+ await contract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: Address.parse(nftAddress),
+ value: toNano("0.05"), // Gas for DNS record update
+ body,
+ bounce: true,
+ }),
+ ],
+ });
});
return {
diff --git a/src/agent/tools/dns/resolve.ts b/src/agent/tools/dns/resolve.ts
index 605a8bc..99bed6a 100644
--- a/src/agent/tools/dns/resolve.ts
+++ b/src/agent/tools/dns/resolve.ts
@@ -1,4 +1,5 @@
import { Type } from "@sinclair/typebox";
+import { Address } from "@ton/core";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
import { tonapiFetch } from "../../../constants/api-endpoints.js";
import { getErrorMessage } from "../../../utils/errors.js";
@@ -10,8 +11,7 @@ interface DnsResolveParams {
}
export const dnsResolveTool: Tool = {
name: "dns_resolve",
- description:
- "Resolve a .ton domain to its associated wallet address. Only works for domains that are already owned (not available or in auction).",
+ description: "Resolve a .ton domain to its wallet address. Only works for owned domains.",
category: "data-bearing",
parameters: Type.Object({
domain: Type.String({
@@ -57,8 +57,12 @@ export const dnsResolveExecutor: ToolExecutor = async (
};
}
- const walletAddress = dnsInfo.item.owner.address;
- const nftAddress = dnsInfo.item.address;
+ // TonAPI returns raw format (0:hex) โ convert to friendly format
+ // so the LLM doesn't hallucinate the CRC16 checksum
+ const rawWallet = dnsInfo.item.owner.address;
+ const rawNft = dnsInfo.item.address;
+ const walletAddress = Address.parse(rawWallet).toString({ bounceable: false });
+ const nftAddress = Address.parse(rawNft).toString({ bounceable: true });
const expiryDate = new Date(dnsInfo.expiring_at * 1000).toISOString().split("T")[0];
return {
diff --git a/src/agent/tools/dns/start-auction.ts b/src/agent/tools/dns/start-auction.ts
index 9a19ef2..cf9fad0 100644
--- a/src/agent/tools/dns/start-auction.ts
+++ b/src/agent/tools/dns/start-auction.ts
@@ -1,11 +1,11 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal, beginCell } from "@ton/ton";
+import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton";
import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
+import { withTxLock } from "../../../ton/tx-lock.js";
const log = createLogger("Tools");
@@ -17,7 +17,7 @@ interface DnsStartAuctionParams {
export const dnsStartAuctionTool: Tool = {
name: "dns_start_auction",
description:
- "Start an auction for an unminted .ton domain. Sends TON to the DNS collection contract to mint a new domain NFT. Domain must be 4-126 characters, available (not minted), and amount must meet minimum price.",
+ "Start an auction for an unminted .ton domain. Amount must meet minimum price for domain length.",
parameters: Type.Object({
domain: Type.String({
description: "Domain name to mint (without .ton extension, 4-126 chars)",
@@ -71,31 +71,32 @@ export const dnsStartAuctionExecutor: ToolExecutor = asyn
publicKey: keyPair.publicKey,
});
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const contract = client.open(wallet);
- const seqno = await contract.getSeqno();
+ await withTxLock(async () => {
+ const seqno = await contract.getSeqno();
- // Build message body: op=0, domain as UTF-8 string
- const body = beginCell()
- .storeUint(0, 32) // op = 0
- .storeStringTail(domain) // domain without .ton
- .endCell();
+ // 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,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(DNS_COLLECTION),
- value: toNano(amount),
- body,
- bounce: true,
- }),
- ],
+ // Send transaction to DNS collection
+ await contract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: Address.parse(DNS_COLLECTION),
+ value: toNano(amount),
+ body,
+ bounce: true,
+ }),
+ ],
+ });
});
return {
diff --git a/src/agent/tools/dns/unlink.ts b/src/agent/tools/dns/unlink.ts
index 9e4316f..acbd885 100644
--- a/src/agent/tools/dns/unlink.ts
+++ b/src/agent/tools/dns/unlink.ts
@@ -1,12 +1,12 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal, beginCell } from "@ton/ton";
+import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, internal, beginCell } from "@ton/ton";
import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.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";
const log = createLogger("Tools");
@@ -22,8 +22,7 @@ interface DnsUnlinkParams {
}
export const dnsUnlinkTool: Tool = {
name: "dns_unlink",
- description:
- "Remove the wallet link from a .ton domain you own. This deletes the wallet record so the domain no longer resolves to any address.",
+ description: "Remove the wallet link from a .ton domain you own.",
parameters: Type.Object({
domain: Type.String({
description: "Domain name (with or without .ton extension)",
@@ -107,34 +106,35 @@ export const dnsUnlinkExecutor: ToolExecutor = async (
publicKey: keyPair.publicKey,
});
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const contract = client.open(wallet);
- const seqno = await contract.getSeqno();
-
- // 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()
- .storeUint(DNS_CHANGE_RECORD_OP, 32) // op = change_dns_record
- .storeUint(0, 64) // query_id
- .storeUint(WALLET_RECORD_KEY, 256) // key = sha256("wallet")
- // NO storeRef() - absence of value cell triggers deletion
- .endCell();
-
- // Send transaction to NFT address
- await contract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(nftAddress),
- value: toNano("0.05"), // Gas for DNS record update
- body,
- bounce: true,
- }),
- ],
+ await withTxLock(async () => {
+ const seqno = await contract.getSeqno();
+
+ // 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()
+ .storeUint(DNS_CHANGE_RECORD_OP, 32) // op = change_dns_record
+ .storeUint(0, 64) // query_id
+ .storeUint(WALLET_RECORD_KEY, 256) // key = sha256("wallet")
+ // NO storeRef() - absence of value cell triggers deletion
+ .endCell();
+
+ // Send transaction to NFT address
+ await contract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: Address.parse(nftAddress),
+ value: toNano("0.05"), // Gas for DNS record update
+ body,
+ bounce: true,
+ }),
+ ],
+ });
});
return {
diff --git a/src/agent/tools/journal/log.ts b/src/agent/tools/journal/log.ts
index 56ac090..8f23713 100644
--- a/src/agent/tools/journal/log.ts
+++ b/src/agent/tools/journal/log.ts
@@ -25,21 +25,8 @@ interface JournalLogParams {
export const journalLogTool: Tool = {
name: "journal_log",
- description: `Log a business operation to the trading journal.
-
-Use this to record:
-- **trade**: Crypto swaps, buy/sell operations
-- **gift**: Gift purchases, sales, or exchanges
-- **middleman**: Escrow services (record both sides if applicable)
-- **kol**: Posts, moderation, bullposting services
-
-ALWAYS include 'reasoning' to explain WHY you took this action.
-
-Examples:
-- trade: "Bought TON dip (-28% in 72h), community active, narrative fits ecosystem"
-- gift: "Sold Deluxe Heart at 120% floor - buyer was eager"
-- middleman: "Escrow for 150 TON gift trade - 3% fee"
-- kol: "Posted project review in channel - 75 TON fee"`,
+ description:
+ "Log a business operation (trade, gift, middleman, kol) to the journal. Always include reasoning.",
parameters: Type.Object({
type: Type.Union(
diff --git a/src/agent/tools/journal/query.ts b/src/agent/tools/journal/query.ts
index fc5d8dc..a4f697d 100644
--- a/src/agent/tools/journal/query.ts
+++ b/src/agent/tools/journal/query.ts
@@ -18,19 +18,8 @@ interface JournalQueryParams {
export const journalQueryTool: Tool = {
name: "journal_query",
- description: `Query the trading journal to analyze past operations.
-
-Use this to:
-- Review recent trades, gifts, or services
-- Analyze performance (win rate, P&L)
-- Find specific operations by asset or outcome
-- Learn from past decisions (read the 'reasoning' field!)
-
-Examples:
-- "Show me my last 10 trades"
-- "What gifts did I sell this week?"
-- "Show all profitable TON trades"
-- "What's my win rate on crypto trades?"`,
+ description:
+ "Query the trading journal. Filter by type/asset/outcome/period. Includes P&L summary.",
category: "data-bearing",
parameters: Type.Object({
type: Type.Optional(
diff --git a/src/agent/tools/journal/update.ts b/src/agent/tools/journal/update.ts
index a762b37..e869f21 100644
--- a/src/agent/tools/journal/update.ts
+++ b/src/agent/tools/journal/update.ts
@@ -18,22 +18,8 @@ interface JournalUpdateParams {
export const journalUpdateTool: Tool = {
name: "journal_update",
- description: `Update a journal entry with outcome and P&L.
-
-Use this to:
-- Close pending operations with final results
-- Record profit/loss after selling or closing a position
-- Update transaction hash after confirmation
-- Mark operations as cancelled
-
-ALWAYS calculate P&L when closing trades:
-- pnl_ton = final value in TON - initial cost in TON
-- pnl_pct = (pnl_ton / initial_cost) * 100
-
-Examples:
-- "Close trade #42 - sold at profit"
-- "Mark gift sale #38 as complete"
-- "Update escrow #55 with tx hash"`,
+ description:
+ "Update a journal entry with outcome, P&L, or tx_hash. Auto-sets closed_at when outcome changes from pending.",
parameters: Type.Object({
id: Type.Number({ description: "Journal entry ID to update" }),
diff --git a/src/agent/tools/mcp-loader.ts b/src/agent/tools/mcp-loader.ts
index 6993709..0d3f43c 100644
--- a/src/agent/tools/mcp-loader.ts
+++ b/src/agent/tools/mcp-loader.ts
@@ -8,6 +8,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { sanitizeForContext } from "../../utils/sanitize.js";
import type { Tool, ToolExecutor, ToolResult, ToolScope } from "./types.js";
import type { ToolRegistry } from "./registry.js";
@@ -95,24 +96,53 @@ export async function loadMcpServers(config: McpConfig): Promise;
- await Promise.race([
- client.connect(transport),
- new Promise((_, reject) => {
- timeoutHandle = setTimeout(
- () => reject(new Error(`Connection timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`)),
- MCP_CONNECT_TIMEOUT_MS
- );
- }),
- ]).finally(() => clearTimeout(timeoutHandle));
+ try {
+ await Promise.race([
+ client.connect(transport),
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () =>
+ reject(new Error(`Connection timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`)),
+ MCP_CONNECT_TIMEOUT_MS
+ );
+ }),
+ ]).finally(() => clearTimeout(timeoutHandle));
+ } catch (err) {
+ // If Streamable HTTP failed on a URL server, retry with SSE
+ if (serverConfig.url && transport instanceof StreamableHTTPClientTransport) {
+ await client.close().catch(() => {});
+ log.info({ server: name }, "Streamable HTTP failed, falling back to SSE");
+ transport = new SSEClientTransport(new URL(serverConfig.url));
+ const fallbackClient = new Client({ name: `teleton-${name}`, version: "1.0.0" });
+ await Promise.race([
+ fallbackClient.connect(transport),
+ new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () =>
+ reject(
+ new Error(`SSE fallback timed out after ${MCP_CONNECT_TIMEOUT_MS / 1000}s`)
+ ),
+ MCP_CONNECT_TIMEOUT_MS
+ );
+ }),
+ ]).finally(() => clearTimeout(timeoutHandle));
+ return {
+ serverName: name,
+ client: fallbackClient,
+ scope: serverConfig.scope ?? "always",
+ };
+ }
+ throw err;
+ }
return { serverName: name, client, scope: serverConfig.scope ?? "always" };
})
@@ -125,9 +155,11 @@ export async function loadMcpServers(config: McpConfig): Promise = async (
const { from_asset, to_asset, amount, slippage = 0.01 } = params;
// STON.fi API requires the native TON address, not the string "ton"
- const isTonInput = from_asset.toLowerCase() === "ton";
+ const isTonInput = from_asset.toLowerCase() === "ton" || from_asset === NATIVE_TON_ADDRESS;
+ const isTonOutput = to_asset.toLowerCase() === "ton" || to_asset === NATIVE_TON_ADDRESS;
const fromAddress = isTonInput ? NATIVE_TON_ADDRESS : from_asset;
- const toAddress = to_asset;
+ const toAddress = isTonOutput ? NATIVE_TON_ADDRESS : to_asset;
// Initialize STON.fi API client
const stonApiClient = new StonApiClient();
@@ -95,7 +97,7 @@ export const stonfiQuoteExecutor: ToolExecutor = async (
// Get asset names if possible
const fromSymbol = isTonInput ? "TON" : "Token";
- const toSymbol = "Token";
+ const toSymbol = isTonOutput ? "TON" : "Token";
// Build quote response
const quote = {
diff --git a/src/agent/tools/stonfi/search.ts b/src/agent/tools/stonfi/search.ts
index ee23236..0b6e92e 100644
--- a/src/agent/tools/stonfi/search.ts
+++ b/src/agent/tools/stonfi/search.ts
@@ -26,7 +26,7 @@ interface SearchResult {
export const stonfiSearchTool: Tool = {
name: "stonfi_search",
description:
- "Search for Jettons (tokens) by name or symbol. Returns a list of matching tokens with their addresses, useful for finding a token's address before swapping or checking prices. Search is case-insensitive.",
+ "Search for jettons by name or symbol. Returns addresses for use in swap/price tools.",
category: "data-bearing",
parameters: Type.Object({
query: Type.String({
diff --git a/src/agent/tools/stonfi/swap.ts b/src/agent/tools/stonfi/swap.ts
index e07e6f2..d0adcf9 100644
--- a/src/agent/tools/stonfi/swap.ts
+++ b/src/agent/tools/stonfi/swap.ts
@@ -1,11 +1,16 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, fromNano, internal } from "@ton/ton";
+import {
+ loadWallet,
+ getKeyPair,
+ getCachedTonClient,
+ invalidateTonClientCache,
+} from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, fromNano, internal } from "@ton/ton";
import { SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
-import { DEX, pTON } from "@ston-fi/sdk";
+import { dexFactory } from "@ston-fi/sdk";
import { StonApiClient } from "@ston-fi/api";
+import { withTxLock } from "../../../ton/tx-lock.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
@@ -22,13 +27,15 @@ interface JettonSwapParams {
export const stonfiSwapTool: Tool = {
name: "stonfi_swap",
description:
- "Swap tokens on STON.fi DEX. Supports TONโJetton and JettonโJetton swaps. Use 'ton' as from_asset to buy jettons with TON, or provide jetton master address. Amount is in human-readable units (will be converted based on decimals). Example: swap 10 TON for USDT, or swap USDT for SCALE.",
+ "Execute a token swap on STON.fi. Supports TON<->jetton and jetton<->jetton. Use stonfi_quote first to preview.",
parameters: Type.Object({
from_asset: Type.String({
- description: "Source asset: 'ton' for TON, or jetton master address (EQ... format)",
+ description:
+ "Source asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
to_asset: Type.String({
- description: "Destination jetton master address (EQ... format)",
+ description:
+ "Destination asset: 'ton' for native TON, or jetton master address (EQ... format). Always pass 'ton' as a string, never an address.",
}),
amount: Type.Number({
description: "Amount to swap in human-readable units (e.g., 10 for 10 TON or 10 tokens)",
@@ -59,9 +66,10 @@ export const stonfiSwapExecutor: ToolExecutor = async (
}
// STON.fi API requires the native TON address, not the string "ton"
- const isTonInput = from_asset.toLowerCase() === "ton";
+ const isTonInput = from_asset.toLowerCase() === "ton" || from_asset === NATIVE_TON_ADDRESS;
+ const isTonOutput = to_asset.toLowerCase() === "ton" || to_asset === NATIVE_TON_ADDRESS;
const fromAddress = isTonInput ? NATIVE_TON_ADDRESS : from_asset;
- const toAddress = to_asset;
+ const toAddress = isTonOutput ? NATIVE_TON_ADDRESS : to_asset;
if (!isTonInput && !fromAddress.match(/^[EUe][Qq][A-Za-z0-9_-]{46}$/)) {
return {
@@ -69,15 +77,14 @@ export const stonfiSwapExecutor: ToolExecutor = async (
error: `Invalid from_asset address: ${from_asset}`,
};
}
- if (!toAddress.match(/^[EUe][Qq][A-Za-z0-9_-]{46}$/)) {
+ if (!isTonOutput && !toAddress.match(/^[EUe][Qq][A-Za-z0-9_-]{46}$/)) {
return {
success: false,
- error: `Invalid to_asset address: ${toAddress}`,
+ error: `Invalid to_asset address: ${to_asset}`,
};
}
- const endpoint = await getCachedHttpEndpoint();
- const tonClient = new TonClient({ endpoint });
+ const tonClient = await getCachedTonClient();
const stonApiClient = new StonApiClient();
// Fetch decimals for accurate conversion (TON=9, USDT=6, WBTC=8, etc.)
@@ -106,88 +113,107 @@ export const stonfiSwapExecutor: ToolExecutor = async (
}
const { router: routerInfo } = simulationResult;
- const router = tonClient.open(new DEX.v1.Router(routerInfo.address));
+ const contracts = dexFactory(routerInfo);
+ const router = tonClient.open(contracts.Router.create(routerInfo.address));
- const keyPair = await getKeyPair();
- if (!keyPair) {
- 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();
-
- let txParams;
-
- if (isTonInput) {
- // Check balance for TON swaps
- const balance = await tonClient.getBalance(wallet.address);
- const requiredAmount = BigInt(simulationResult.offerUnits) + toNano("0.3"); // 0.3 TON for gas
- if (balance < requiredAmount) {
- return {
- success: false,
- error: `Insufficient balance. Have ${fromNano(balance)} TON, need ~${fromNano(requiredAmount)} TON (including gas).`,
- };
+ return withTxLock(async () => {
+ const keyPair = await getKeyPair();
+ if (!keyPair) {
+ return { success: false, error: "Wallet key derivation failed." };
}
-
- // TON โ Jetton swap
- const proxyTon = new pTON.v1(routerInfo.ptonMasterAddress);
-
- txParams = await router.getSwapTonToJettonTxParams({
- userWalletAddress: walletData.address,
- proxyTon,
- askJettonAddress: toAddress,
- offerAmount: BigInt(simulationResult.offerUnits),
- minAskAmount: BigInt(simulationResult.minAskUnits),
+ const wallet = WalletContractV5R1.create({
+ workchain: 0,
+ publicKey: keyPair.publicKey,
});
- } else {
- // Jetton โ Jetton or Jetton โ TON swap
- txParams = await router.getSwapJettonToJettonTxParams({
- userWalletAddress: walletData.address,
- offerJettonAddress: fromAddress,
- askJettonAddress: toAddress,
- offerAmount: BigInt(simulationResult.offerUnits),
- minAskAmount: BigInt(simulationResult.minAskUnits),
- });
- }
+ const walletContract = tonClient.open(wallet);
+ const seqno = await walletContract.getSeqno();
+
+ let txParams;
+ const proxyTon = contracts.pTON.create(routerInfo.ptonMasterAddress);
+
+ if (isTonInput) {
+ // Check balance for TON swaps with dynamic gas
+ const balance = await tonClient.getBalance(wallet.address);
+ const gasReserve =
+ BigInt(simulationResult.gasParams?.forwardGas || "300000000") +
+ BigInt(simulationResult.gasParams?.estimatedGasConsumption || "50000000");
+ const requiredAmount = BigInt(simulationResult.offerUnits) + gasReserve;
+ if (balance < requiredAmount) {
+ return {
+ success: false,
+ error: `Insufficient balance. Have ${fromNano(balance)} TON, need ~${fromNano(requiredAmount)} TON (including gas).`,
+ };
+ }
+
+ // TON -> Jetton
+ txParams = await router.getSwapTonToJettonTxParams({
+ userWalletAddress: walletData.address,
+ proxyTon,
+ askJettonAddress: toAddress,
+ offerAmount: BigInt(simulationResult.offerUnits),
+ minAskAmount: BigInt(simulationResult.minAskUnits),
+ });
+ } else if (isTonOutput) {
+ // Jetton -> TON
+ txParams = await router.getSwapJettonToTonTxParams({
+ userWalletAddress: walletData.address,
+ proxyTon,
+ offerJettonAddress: fromAddress,
+ offerAmount: BigInt(simulationResult.offerUnits),
+ minAskAmount: BigInt(simulationResult.minAskUnits),
+ });
+ } else {
+ // Jetton -> Jetton
+ txParams = await router.getSwapJettonToJettonTxParams({
+ userWalletAddress: walletData.address,
+ offerJettonAddress: fromAddress,
+ askJettonAddress: toAddress,
+ offerAmount: BigInt(simulationResult.offerUnits),
+ minAskAmount: BigInt(simulationResult.minAskUnits),
+ });
+ }
- await walletContract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: txParams.to,
- value: txParams.value,
- body: txParams.body,
- bounce: true,
- }),
- ],
- });
+ await walletContract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: txParams.to,
+ value: txParams.value,
+ body: txParams.body,
+ bounce: true,
+ }),
+ ],
+ });
- // Fetch ask asset decimals for accurate output conversion
- const toAssetInfo = await stonApiClient.getAsset(toAddress);
- const askDecimals = toAssetInfo?.decimals ?? 9;
- const expectedOutput = Number(simulationResult.askUnits) / 10 ** askDecimals;
- const minOutput = Number(simulationResult.minAskUnits) / 10 ** askDecimals;
+ // Fetch ask asset decimals for accurate output conversion
+ const toAssetInfo = await stonApiClient.getAsset(toAddress);
+ const askDecimals = toAssetInfo?.decimals ?? 9;
+ const expectedOutput = Number(simulationResult.askUnits) / 10 ** askDecimals;
+ const minOutput = Number(simulationResult.minAskUnits) / 10 ** askDecimals;
- return {
- success: true,
- data: {
- from: fromAddress,
- to: toAddress,
- amountIn: amount.toString(),
- expectedOutput: expectedOutput.toFixed(6),
- minOutput: minOutput.toFixed(6),
- slippage: `${(slippage * 100).toFixed(2)}%`,
- priceImpact: simulationResult.priceImpact || "N/A",
- router: routerInfo.address,
- message: `Swapped ${amount} ${isTonInput ? "TON" : "tokens"} for ~${expectedOutput.toFixed(4)} tokens\n Minimum output: ${minOutput.toFixed(4)}\n Slippage: ${(slippage * 100).toFixed(2)}%\n Transaction sent (check balance in ~30 seconds)`,
- },
- };
- } catch (error) {
+ return {
+ success: true,
+ data: {
+ from: fromAddress,
+ to: toAddress,
+ amountIn: amount.toString(),
+ expectedOutput: expectedOutput.toFixed(6),
+ minOutput: minOutput.toFixed(6),
+ 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)`,
+ },
+ };
+ }); // withTxLock
+ } catch (error: any) {
+ // Invalidate node cache on 429/5xx so next attempt picks a fresh node
+ const status = error?.status || error?.response?.status;
+ if (status === 429 || status >= 500) {
+ invalidateTonClientCache();
+ }
log.error({ err: error }, "Error in stonfi_swap");
return {
success: false,
diff --git a/src/agent/tools/stonfi/trending.ts b/src/agent/tools/stonfi/trending.ts
index a211a6c..a14488f 100644
--- a/src/agent/tools/stonfi/trending.ts
+++ b/src/agent/tools/stonfi/trending.ts
@@ -11,8 +11,7 @@ interface JettonTrendingParams {
}
export const stonfiTrendingTool: Tool = {
name: "stonfi_trending",
- description:
- "Get trending/popular Jettons on the TON blockchain. Shows tokens ranked by trading volume and liquidity. Useful for discovering popular tokens.",
+ description: "Get trending jettons ranked by popularity on STON.fi.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
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
new file mode 100644
index 0000000..f9c87bc
--- /dev/null
+++ b/src/agent/tools/telegram/chats/__tests__/check-channel-username.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { telegramCheckChannelUsernameExecutor } from "../check-channel-username.js";
+import type { ToolContext } from "../../../types.js";
+
+const mockInvoke = vi.fn();
+const mockGetEntity = vi.fn();
+
+const mockContext = {
+ bridge: {
+ getClient: () => ({
+ getClient: () => ({
+ invoke: mockInvoke,
+ getEntity: mockGetEntity,
+ }),
+ }),
+ },
+ chatId: "123",
+ senderId: 456,
+ isGroup: false,
+} as unknown as ToolContext;
+
+describe("telegram_check_channel_username", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns available: true when username is free", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(true);
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).available).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ });
+
+ it("returns available: false when username is taken", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(false);
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "taken_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).available).toBe(false);
+ });
+
+ it("strips @ prefix from username", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(true);
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "@my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ });
+
+ it("rejects invalid username format (too short)", async () => {
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "ab" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Invalid username format");
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it("rejects username starting with underscore", async () => {
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "_bad_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Invalid username format");
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it("rejects non-channel entity", async () => {
+ mockGetEntity.mockResolvedValue({ className: "User", id: 100n });
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("not a channel/group");
+ });
+
+ it("handles CHANNELS_ADMIN_PUBLIC_TOO_MUCH error", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("CHANNELS_ADMIN_PUBLIC_TOO_MUCH"));
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("too many public channels");
+ });
+
+ it("handles USERNAME_PURCHASE_AVAILABLE error", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("USERNAME_PURCHASE_AVAILABLE"));
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "premium_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).purchaseAvailable).toBe(true);
+ expect((result.data as any).message).toContain("fragment.com");
+ });
+
+ it("handles USERNAME_INVALID error from API", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("USERNAME_INVALID"));
+
+ const result = await telegramCheckChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Invalid username format");
+ });
+});
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
new file mode 100644
index 0000000..7fab63e
--- /dev/null
+++ b/src/agent/tools/telegram/chats/__tests__/create-channel-username.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { telegramCreateChannelExecutor } from "../create-channel.js";
+import type { ToolContext } from "../../../types.js";
+
+const mockInvoke = vi.fn();
+
+const mockContext = {
+ bridge: {
+ getClient: () => ({
+ getClient: () => ({
+ invoke: mockInvoke,
+ }),
+ }),
+ },
+ chatId: "123",
+ senderId: 456,
+ isGroup: false,
+} as unknown as ToolContext;
+
+const fakeCreateResult = {
+ chats: [{ id: 200n, accessHash: 999n }],
+};
+
+describe("telegram_create_channel with username", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates channel without username (unchanged behavior)", async () => {
+ mockInvoke.mockResolvedValue(fakeCreateResult);
+
+ const result = await telegramCreateChannelExecutor({ title: "Test Channel" }, mockContext);
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).title).toBe("Test Channel");
+ expect((result.data as any).username).toBeUndefined();
+ expect(mockInvoke).toHaveBeenCalledTimes(1);
+ });
+
+ it("creates channel and sets username successfully", async () => {
+ mockInvoke
+ .mockResolvedValueOnce(fakeCreateResult) // CreateChannel
+ .mockResolvedValueOnce(true); // UpdateUsername
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test Channel", username: "my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ expect((result.data as any).link).toBe("https://t.me/my_channel");
+ expect((result.data as any).usernameError).toBeUndefined();
+ expect(mockInvoke).toHaveBeenCalledTimes(2);
+ });
+
+ it("strips @ from username param", async () => {
+ mockInvoke.mockResolvedValueOnce(fakeCreateResult).mockResolvedValueOnce(true);
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test", username: "@my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ });
+
+ it("creates channel but reports username error when taken", async () => {
+ mockInvoke
+ .mockResolvedValueOnce(fakeCreateResult) // CreateChannel succeeds
+ .mockRejectedValueOnce(new Error("USERNAME_OCCUPIED")); // UpdateUsername fails
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test Channel", username: "taken_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true); // Channel still created
+ expect((result.data as any).channelId).toBe("200");
+ expect((result.data as any).usernameError).toContain("already taken");
+ expect((result.data as any).username).toBeUndefined();
+ });
+
+ it("creates channel but reports validation error for bad username", async () => {
+ mockInvoke.mockResolvedValueOnce(fakeCreateResult);
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test Channel", username: "ab" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true); // Channel still created
+ expect((result.data as any).usernameError).toContain("Invalid username format");
+ expect(mockInvoke).toHaveBeenCalledTimes(1); // Only CreateChannel, no UpdateUsername
+ });
+
+ it("creates channel but reports CHANNELS_ADMIN_PUBLIC_TOO_MUCH", async () => {
+ mockInvoke
+ .mockResolvedValueOnce(fakeCreateResult)
+ .mockRejectedValueOnce(new Error("CHANNELS_ADMIN_PUBLIC_TOO_MUCH"));
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).usernameError).toContain("Too many public channels");
+ });
+
+ it("creates channel but reports USERNAME_PURCHASE_AVAILABLE", async () => {
+ mockInvoke
+ .mockResolvedValueOnce(fakeCreateResult)
+ .mockRejectedValueOnce(new Error("USERNAME_PURCHASE_AVAILABLE"));
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test", username: "premium_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).usernameError).toContain("fragment.com");
+ });
+
+ it("rejects username ending with underscore", async () => {
+ mockInvoke.mockResolvedValueOnce(fakeCreateResult);
+
+ const result = await telegramCreateChannelExecutor(
+ { title: "Test", username: "bad_name_" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).usernameError).toContain("Invalid username format");
+ });
+});
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
new file mode 100644
index 0000000..b74ebb7
--- /dev/null
+++ b/src/agent/tools/telegram/chats/__tests__/set-channel-username.test.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { telegramSetChannelUsernameExecutor } from "../set-channel-username.js";
+import type { ToolContext } from "../../../types.js";
+
+const mockInvoke = vi.fn();
+const mockGetEntity = vi.fn();
+
+const mockContext = {
+ bridge: {
+ getClient: () => ({
+ getClient: () => ({
+ invoke: mockInvoke,
+ getEntity: mockGetEntity,
+ }),
+ }),
+ },
+ chatId: "123",
+ senderId: 456,
+ isGroup: false,
+} as unknown as ToolContext;
+
+describe("telegram_set_channel_username", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("sets username successfully", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(true);
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ expect((result.data as any).link).toBe("https://t.me/my_channel");
+ });
+
+ it("strips @ prefix", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(true);
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "@my_channel" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBe("my_channel");
+ });
+
+ it("removes username with empty string", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockResolvedValue(true);
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).username).toBeNull();
+ expect((result.data as any).link).toBeNull();
+ });
+
+ it("rejects invalid username format", async () => {
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "ab" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Invalid username format");
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it("rejects non-channel entity", async () => {
+ mockGetEntity.mockResolvedValue({ className: "User", id: 100n });
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("not a channel/group");
+ });
+
+ it("handles USERNAME_OCCUPIED", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("USERNAME_OCCUPIED"));
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "taken_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("already taken");
+ });
+
+ it("treats USERNAME_NOT_MODIFIED as success", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("USERNAME_NOT_MODIFIED"));
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "same_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(true);
+ expect((result.data as any).message).toContain("No changes");
+ });
+
+ it("handles CHAT_ADMIN_REQUIRED", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("CHAT_ADMIN_REQUIRED"));
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("admin rights");
+ });
+
+ it("handles CHANNELS_ADMIN_PUBLIC_TOO_MUCH", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("CHANNELS_ADMIN_PUBLIC_TOO_MUCH"));
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "valid_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("too many public channels");
+ });
+
+ it("handles USERNAME_PURCHASE_AVAILABLE", async () => {
+ mockGetEntity.mockResolvedValue({ className: "Channel", id: 100n });
+ mockInvoke.mockRejectedValue(new Error("USERNAME_PURCHASE_AVAILABLE"));
+
+ const result = await telegramSetChannelUsernameExecutor(
+ { channelId: "100", username: "premium_name" },
+ mockContext
+ );
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("fragment.com");
+ });
+});
diff --git a/src/agent/tools/telegram/chats/check-channel-username.ts b/src/agent/tools/telegram/chats/check-channel-username.ts
new file mode 100644
index 0000000..9c5df46
--- /dev/null
+++ b/src/agent/tools/telegram/chats/check-channel-username.ts
@@ -0,0 +1,110 @@
+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";
+
+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;
+}
+
+export const telegramCheckChannelUsernameTool: Tool = {
+ name: "telegram_check_channel_username",
+ description:
+ "Check if a username is available for a specific channel/group you admin. Verifies the username can be assigned to that channel.",
+ parameters: Type.Object({
+ channelId: Type.String({
+ description: "Channel or group ID to check availability for",
+ }),
+ username: Type.String({
+ description:
+ "Username to check (5-32 chars, letters/numbers/underscores, no @ symbol). Example: 'my_channel'",
+ }),
+ }),
+};
+
+export const telegramCheckChannelUsernameExecutor: ToolExecutor<
+ CheckChannelUsernameParams
+> = 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 gramJsClient = context.bridge.getClient().getClient();
+ 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 available = await gramJsClient.invoke(
+ new Api.channels.CheckUsername({
+ channel,
+ username: clean,
+ })
+ );
+
+ return {
+ success: true,
+ data: {
+ channelId: channel.id.toString(),
+ username: clean,
+ available: !!available,
+ },
+ };
+ } catch (error: any) {
+ 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")) {
+ return {
+ success: true,
+ data: {
+ channelId: params.channelId,
+ username: params.username.replace(/^@/, ""),
+ available: false,
+ purchaseAvailable: true,
+ message: "This username is available for purchase on fragment.com",
+ },
+ };
+ }
+
+ return {
+ success: false,
+ error: msg,
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/chats/create-channel.ts b/src/agent/tools/telegram/chats/create-channel.ts
index b691543..948c014 100644
--- a/src/agent/tools/telegram/chats/create-channel.ts
+++ b/src/agent/tools/telegram/chats/create-channel.ts
@@ -9,10 +9,13 @@ 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;
megagroup?: boolean;
+ username?: string;
}
/**
@@ -21,7 +24,7 @@ interface CreateChannelParams {
export const telegramCreateChannelTool: Tool = {
name: "telegram_create_channel",
description:
- "Create a new Telegram channel or megagroup. Channels are one-way broadcast tools where only admins can post, ideal for announcements or content distribution. Megagroups are large groups supporting up to 200k members with admin controls. Use this to establish a new communication platform for announcements, communities, or projects.",
+ "Create a new Telegram channel (broadcast) or megagroup (chat). Set megagroup=true for group mode.",
parameters: Type.Object({
title: Type.String({
description: "Name of the channel/megagroup (max 128 characters)",
@@ -39,6 +42,13 @@ export const telegramCreateChannelTool: Tool = {
"Create as megagroup (large group with chat) instead of broadcast channel. Default: false (creates broadcast channel).",
})
),
+ username: Type.Optional(
+ Type.String({
+ description:
+ "Public username for the channel (5-32 chars, letters/numbers/underscores, no @). Makes the channel publicly discoverable at t.me/. If the username is unavailable, the channel is still created without it.",
+ maxLength: 32,
+ })
+ ),
}),
};
@@ -50,7 +60,7 @@ export const telegramCreateChannelExecutor: ToolExecutor =
context
): Promise => {
try {
- const { title, about = "", megagroup = false } = params;
+ const { title, about = "", megagroup = false, username } = params;
// Get underlying GramJS client
const gramJsClient = context.bridge.getClient().getClient();
@@ -68,14 +78,49 @@ export const telegramCreateChannelExecutor: ToolExecutor =
// Extract channel info from updates
const channel = result.chats?.[0];
+ const data: Record = {
+ channelId: channel?.id?.toString() || "unknown",
+ title,
+ type: megagroup ? "megagroup" : "channel",
+ accessHash: channel?.accessHash?.toString(),
+ };
+
+ // Set username if provided (best-effort โ creation still succeeds on failure)
+ if (username && channel) {
+ const clean = username.replace(/^@/, "");
+
+ if (!USERNAME_REGEX.test(clean)) {
+ data.usernameError =
+ "Invalid username format. Must be 5-32 characters, alphanumeric and underscores only, cannot start/end with underscore.";
+ } else {
+ try {
+ await gramJsClient.invoke(
+ new Api.channels.UpdateUsername({
+ channel: channel as Api.Channel,
+ username: clean,
+ })
+ );
+ data.username = clean;
+ data.link = `https://t.me/${clean}`;
+ } catch (usernameError: any) {
+ const msg = getErrorMessage(usernameError);
+ if (msg.includes("USERNAME_OCCUPIED")) {
+ data.usernameError = `Username @${clean} is already taken.`;
+ } else if (msg.includes("CHANNELS_ADMIN_PUBLIC_TOO_MUCH")) {
+ data.usernameError = "Too many public channels. Make some private first.";
+ } else if (msg.includes("USERNAME_PURCHASE_AVAILABLE")) {
+ data.usernameError = `Username @${clean} is available for purchase on fragment.com.`;
+ } else {
+ data.usernameError = msg;
+ }
+ log.warn({ err: usernameError }, "Failed to set username on new channel");
+ }
+ }
+ }
+
return {
success: true,
- data: {
- channelId: channel?.id?.toString() || "unknown",
- title,
- type: megagroup ? "megagroup" : "channel",
- accessHash: channel?.accessHash?.toString(),
- },
+ data,
};
} catch (error) {
log.error({ err: error }, "Error creating channel");
diff --git a/src/agent/tools/telegram/chats/edit-channel-info.ts b/src/agent/tools/telegram/chats/edit-channel-info.ts
index 97d734e..5da03d7 100644
--- a/src/agent/tools/telegram/chats/edit-channel-info.ts
+++ b/src/agent/tools/telegram/chats/edit-channel-info.ts
@@ -20,19 +20,7 @@ interface EditChannelInfoParams {
*/
export const telegramEditChannelInfoTool: Tool = {
name: "telegram_edit_channel_info",
- description: `Edit a channel or group's information.
-
-USAGE:
-- Pass the channelId and any fields to update
-- You must be an admin with the appropriate rights
-
-FIELDS:
-- title: Channel/group name (1-255 characters)
-- about: Description/bio (0-255 characters)
-
-NOTE: To change the photo, use a separate photo upload tool.
-
-Example: Update your channel @my_channel with a new description.`,
+ description: "Edit a channel or group's title and/or description. Requires admin rights.",
parameters: Type.Object({
channelId: Type.String({
description: "Channel or group ID to edit",
diff --git a/src/agent/tools/telegram/chats/get-admined-channels.ts b/src/agent/tools/telegram/chats/get-admined-channels.ts
new file mode 100644
index 0000000..cab9981
--- /dev/null
+++ b/src/agent/tools/telegram/chats/get-admined-channels.ts
@@ -0,0 +1,69 @@
+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";
+
+const log = createLogger("Tools");
+
+interface GetAdminedChannelsParams {
+ forPersonal?: boolean;
+}
+
+export const telegramGetAdminedChannelsTool: Tool = {
+ name: "telegram_get_admined_channels",
+ description:
+ "List public channels where the current account has admin rights. Returns channel IDs, titles, usernames, and participant counts.",
+ category: "data-bearing",
+ parameters: Type.Object({
+ forPersonal: Type.Optional(
+ Type.Boolean({
+ description: "If true, filter for channels suitable as personal channel",
+ })
+ ),
+ }),
+};
+
+export const telegramGetAdminedChannelsExecutor: ToolExecutor = async (
+ params,
+ context
+): Promise => {
+ try {
+ const gramJsClient = context.bridge.getClient().getClient();
+
+ const result = await gramJsClient.invoke(
+ new Api.channels.GetAdminedPublicChannels({
+ byLocation: false,
+ checkLimit: false,
+ forPersonal: params.forPersonal,
+ })
+ );
+
+ const chats = ("chats" in result ? result.chats : []) as Api.Channel[];
+
+ const channels = chats.map((ch) => ({
+ id: ch.id?.toString(),
+ title: ch.title,
+ username: ch.username || null,
+ participantsCount: ch.participantsCount || null,
+ isMegagroup: ch.megagroup || false,
+ isBroadcast: ch.broadcast || false,
+ }));
+
+ log.info(`๐ก get_admined_channels: ${channels.length} channels found`);
+
+ return {
+ success: true,
+ data: {
+ count: channels.length,
+ channels,
+ },
+ };
+ } catch (error) {
+ log.error({ err: error }, "Error getting admined channels");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/chats/get-chat-info.ts b/src/agent/tools/telegram/chats/get-chat-info.ts
index 87f9379..f75b796 100644
--- a/src/agent/tools/telegram/chats/get-chat-info.ts
+++ b/src/agent/tools/telegram/chats/get-chat-info.ts
@@ -19,7 +19,7 @@ interface GetChatInfoParams {
export const telegramGetChatInfoTool: Tool = {
name: "telegram_get_chat_info",
description:
- "Get detailed information about a Telegram chat, group, or channel. Returns title, description, member count, and other metadata. Use this to understand the context of a conversation.",
+ "Get detailed info about a chat, group, channel, or user. Returns title, description, member count, and metadata.",
category: "data-bearing",
parameters: Type.Object({
chatId: Type.String({
diff --git a/src/agent/tools/telegram/chats/get-dialogs.ts b/src/agent/tools/telegram/chats/get-dialogs.ts
index 4ff0308..1aff19d 100644
--- a/src/agent/tools/telegram/chats/get-dialogs.ts
+++ b/src/agent/tools/telegram/chats/get-dialogs.ts
@@ -20,7 +20,7 @@ interface GetDialogsParams {
export const telegramGetDialogsTool: Tool = {
name: "telegram_get_dialogs",
description:
- "Get the list of all your Telegram conversations (DMs, groups, channels). Returns chat info with unread message counts. Use this to see your inbox and find chats that need attention.",
+ "List all conversations (DMs, groups, channels) with unread counts. Use to find chat IDs and check inbox.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
diff --git a/src/agent/tools/telegram/chats/index.ts b/src/agent/tools/telegram/chats/index.ts
index cb3b32a..6db7a96 100644
--- a/src/agent/tools/telegram/chats/index.ts
+++ b/src/agent/tools/telegram/chats/index.ts
@@ -13,6 +13,18 @@ import {
telegramInviteToChannelTool,
telegramInviteToChannelExecutor,
} from "./invite-to-channel.js";
+import {
+ telegramGetAdminedChannelsTool,
+ telegramGetAdminedChannelsExecutor,
+} from "./get-admined-channels.js";
+import {
+ telegramCheckChannelUsernameTool,
+ telegramCheckChannelUsernameExecutor,
+} from "./check-channel-username.js";
+import {
+ telegramSetChannelUsernameTool,
+ telegramSetChannelUsernameExecutor,
+} from "./set-channel-username.js";
import type { ToolEntry } from "../../types.js";
export { telegramGetDialogsTool, telegramGetDialogsExecutor };
@@ -24,6 +36,9 @@ export { telegramLeaveChannelTool, telegramLeaveChannelExecutor };
export { telegramCreateChannelTool, telegramCreateChannelExecutor };
export { telegramEditChannelInfoTool, telegramEditChannelInfoExecutor };
export { telegramInviteToChannelTool, telegramInviteToChannelExecutor };
+export { telegramGetAdminedChannelsTool, telegramGetAdminedChannelsExecutor };
+export { telegramCheckChannelUsernameTool, telegramCheckChannelUsernameExecutor };
+export { telegramSetChannelUsernameTool, telegramSetChannelUsernameExecutor };
export const tools: ToolEntry[] = [
{ tool: telegramGetDialogsTool, executor: telegramGetDialogsExecutor },
@@ -43,4 +58,19 @@ export const tools: ToolEntry[] = [
executor: telegramInviteToChannelExecutor,
scope: "dm-only",
},
+ {
+ tool: telegramGetAdminedChannelsTool,
+ executor: telegramGetAdminedChannelsExecutor,
+ scope: "dm-only",
+ },
+ {
+ tool: telegramCheckChannelUsernameTool,
+ executor: telegramCheckChannelUsernameExecutor,
+ scope: "dm-only",
+ },
+ {
+ tool: telegramSetChannelUsernameTool,
+ executor: telegramSetChannelUsernameExecutor,
+ scope: "dm-only",
+ },
];
diff --git a/src/agent/tools/telegram/chats/invite-to-channel.ts b/src/agent/tools/telegram/chats/invite-to-channel.ts
index b9bb498..bff9f14 100644
--- a/src/agent/tools/telegram/chats/invite-to-channel.ts
+++ b/src/agent/tools/telegram/chats/invite-to-channel.ts
@@ -21,18 +21,8 @@ interface InviteToChannelParams {
*/
export const telegramInviteToChannelTool: Tool = {
name: "telegram_invite_to_channel",
- description: `Invite users to a channel or group.
-
-USAGE:
-- Pass channelId and either userIds or usernames (or both)
-- You must be an admin with invite rights
-- Users must allow being added to groups (privacy settings)
-
-LIMITS:
-- Can invite up to 200 users at once
-- Some users may have privacy settings preventing invites
-
-For public channels, you can also share the invite link instead.`,
+ description:
+ "Invite users to a channel or group by userIds or usernames. Requires admin invite rights.",
parameters: Type.Object({
channelId: Type.String({
description: "Channel or group ID to invite users to",
diff --git a/src/agent/tools/telegram/chats/join-channel.ts b/src/agent/tools/telegram/chats/join-channel.ts
index 9da4889..1df3e4e 100644
--- a/src/agent/tools/telegram/chats/join-channel.ts
+++ b/src/agent/tools/telegram/chats/join-channel.ts
@@ -28,8 +28,7 @@ interface JoinChannelParams {
*/
export const telegramJoinChannelTool: Tool = {
name: "telegram_join_channel",
- description:
- "Join a Telegram channel or group. Supports public channels (username/@channelname), channel IDs, and private invite links (t.me/+XXXX, t.me/joinchat/XXXX).",
+ description: "Join a channel or group. Accepts username, channel ID, or private invite link.",
parameters: Type.Object({
channel: Type.String({
description:
diff --git a/src/agent/tools/telegram/chats/leave-channel.ts b/src/agent/tools/telegram/chats/leave-channel.ts
index 177c46b..ffd6a0f 100644
--- a/src/agent/tools/telegram/chats/leave-channel.ts
+++ b/src/agent/tools/telegram/chats/leave-channel.ts
@@ -18,8 +18,7 @@ interface LeaveChannelParams {
*/
export const telegramLeaveChannelTool: Tool = {
name: "telegram_leave_channel",
- description:
- "Leave a Telegram channel or group that you're currently a member of. Use this to unsubscribe from channels or exit groups you no longer wish to participate in. Accepts username or channel ID.",
+ description: "Leave a channel or group you are a member of.",
parameters: Type.Object({
channel: Type.String({
description:
diff --git a/src/agent/tools/telegram/chats/mark-as-read.ts b/src/agent/tools/telegram/chats/mark-as-read.ts
index 9955115..68c528d 100644
--- a/src/agent/tools/telegram/chats/mark-as-read.ts
+++ b/src/agent/tools/telegram/chats/mark-as-read.ts
@@ -20,7 +20,7 @@ interface MarkAsReadParams {
export const telegramMarkAsReadTool: Tool = {
name: "telegram_mark_as_read",
description:
- "Mark messages as read in a Telegram chat. Can mark up to a specific message or clear all unread. Use this to manage your inbox and acknowledge messages.",
+ "Mark messages as read in a chat. Can mark up to a specific message or clear all unread.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to mark as read",
diff --git a/src/agent/tools/telegram/chats/set-channel-username.ts b/src/agent/tools/telegram/chats/set-channel-username.ts
new file mode 100644
index 0000000..af64176
--- /dev/null
+++ b/src/agent/tools/telegram/chats/set-channel-username.ts
@@ -0,0 +1,129 @@
+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";
+
+const log = createLogger("Tools");
+
+const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_]{3,30}[a-zA-Z0-9]$/;
+
+interface SetChannelUsernameParams {
+ channelId: string;
+ username: string;
+}
+
+export const telegramSetChannelUsernameTool: Tool = {
+ name: "telegram_set_channel_username",
+ description:
+ "Set or remove the public username of a channel/group you admin. Makes it discoverable at t.me/. Empty string removes the username (makes channel private). Requires admin rights.",
+ parameters: Type.Object({
+ channelId: Type.String({
+ description: "Channel or group ID to update",
+ }),
+ username: Type.String({
+ description:
+ "New username (5-32 chars, letters/numbers/underscores, no @). Example: 'my_channel'. Empty string '' to remove username and make channel private.",
+ minLength: 0,
+ maxLength: 32,
+ }),
+ }),
+};
+
+export const telegramSetChannelUsernameExecutor: ToolExecutor = async (
+ params,
+ context
+): Promise => {
+ 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 gramJsClient = context.bridge.getClient().getClient();
+ 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;
+ await gramJsClient.invoke(
+ new Api.channels.UpdateUsername({
+ channel,
+ username: clean || "",
+ })
+ );
+
+ return {
+ success: true,
+ data: {
+ channelId: channel.id.toString(),
+ username: clean || null,
+ link: clean ? `https://t.me/${clean}` : null,
+ },
+ };
+ } catch (error: any) {
+ log.error({ err: error }, "Error setting channel username");
+
+ const msg = getErrorMessage(error);
+
+ if (msg.includes("USERNAME_OCCUPIED")) {
+ return {
+ success: false,
+ error: "Username is already taken. Please choose another.",
+ };
+ }
+
+ if (msg.includes("USERNAME_NOT_MODIFIED")) {
+ return {
+ success: true,
+ data: {
+ message: "No changes made (username is the same)",
+ },
+ };
+ }
+
+ if (msg.includes("CHAT_ADMIN_REQUIRED")) {
+ return {
+ success: false,
+ error: "You need admin rights to change this channel's username.",
+ };
+ }
+
+ if (msg.includes("CHANNELS_ADMIN_PUBLIC_TOO_MUCH")) {
+ return {
+ success: false,
+ error: "You admin too many public channels. Make some channels private first.",
+ };
+ }
+
+ if (msg.includes("USERNAME_INVALID")) {
+ return {
+ success: false,
+ error: `Invalid username format: "${params.username}"`,
+ };
+ }
+
+ if (msg.includes("USERNAME_PURCHASE_AVAILABLE")) {
+ return {
+ success: false,
+ error: `Username @${params.username.replace(/^@/, "")} is available for purchase on fragment.com, not for free assignment.`,
+ };
+ }
+
+ return {
+ success: false,
+ error: msg,
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/contacts/block-user.ts b/src/agent/tools/telegram/contacts/block-user.ts
index 65f7c2a..7c24db2 100644
--- a/src/agent/tools/telegram/contacts/block-user.ts
+++ b/src/agent/tools/telegram/contacts/block-user.ts
@@ -19,7 +19,7 @@ interface BlockUserParams {
export const telegramBlockUserTool: Tool = {
name: "telegram_block_user",
description:
- "Block a Telegram user to prevent them from sending you messages or adding you to groups. Use this for spam protection, harassment prevention, or managing unwanted contacts. The blocked user will not be notified.",
+ "Block a user. They won't be able to message you or add you to groups. Not notified.",
parameters: Type.Object({
userId: Type.String({
description: "The user ID or username to block (e.g., '123456789' or '@username')",
diff --git a/src/agent/tools/telegram/contacts/check-username.ts b/src/agent/tools/telegram/contacts/check-username.ts
index 59680a4..0425899 100644
--- a/src/agent/tools/telegram/contacts/check-username.ts
+++ b/src/agent/tools/telegram/contacts/check-username.ts
@@ -18,20 +18,8 @@ interface CheckUsernameParams {
*/
export const telegramCheckUsernameTool: Tool = {
name: "telegram_check_username",
- description: `Check if a Telegram username exists and get basic info about it.
-
-USAGE:
-- Pass a username (with or without @)
-
-RETURNS:
-- exists: whether the username is taken
-- type: "user", "channel", "group", or null if not found
-- Basic info about the entity if it exists
-
-Use this to:
-- Check if a trader's username is valid
-- Verify channel/group names
-- See if a username is available`,
+ description:
+ "Check if a username exists and get basic info (type, ID). Also reveals if a username is available.",
category: "data-bearing",
parameters: Type.Object({
username: Type.String({
diff --git a/src/agent/tools/telegram/contacts/get-blocked.ts b/src/agent/tools/telegram/contacts/get-blocked.ts
index 5d3f127..fcf8b28 100644
--- a/src/agent/tools/telegram/contacts/get-blocked.ts
+++ b/src/agent/tools/telegram/contacts/get-blocked.ts
@@ -18,8 +18,7 @@ interface GetBlockedParams {
*/
export const telegramGetBlockedTool: Tool = {
name: "telegram_get_blocked",
- description:
- "Get list of users you have blocked on Telegram. Use this to see who's blocked, manage your block list, or identify users to unblock. Returns user information for each blocked contact.",
+ description: "Get your list of blocked users.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
diff --git a/src/agent/tools/telegram/contacts/get-common-chats.ts b/src/agent/tools/telegram/contacts/get-common-chats.ts
index 2e6f02c..705b5df 100644
--- a/src/agent/tools/telegram/contacts/get-common-chats.ts
+++ b/src/agent/tools/telegram/contacts/get-common-chats.ts
@@ -20,8 +20,7 @@ interface GetCommonChatsParams {
*/
export const telegramGetCommonChatsTool: Tool = {
name: "telegram_get_common_chats",
- description:
- "Find groups and channels that you share with another Telegram user. Use this to understand mutual connections, verify relationships, or discover shared communities. Returns list of common chats with their names and IDs.",
+ description: "Find groups and channels you share with another user.",
category: "data-bearing",
parameters: Type.Object({
userId: Type.String({
diff --git a/src/agent/tools/telegram/contacts/get-user-info.ts b/src/agent/tools/telegram/contacts/get-user-info.ts
index 906c3d3..25ed4f4 100644
--- a/src/agent/tools/telegram/contacts/get-user-info.ts
+++ b/src/agent/tools/telegram/contacts/get-user-info.ts
@@ -19,20 +19,8 @@ interface GetUserInfoParams {
*/
export const telegramGetUserInfoTool: Tool = {
name: "telegram_get_user_info",
- description: `Get detailed information about a Telegram user.
-
-USAGE:
-- By username: pass username (with or without @)
-- By ID: pass userId
-
-RETURNS:
-- Basic info: id, username, firstName, lastName, phone (if visible)
-- Status: isBot, isPremium, isVerified, isScam, isFake
-- Bio/about (if public)
-- Photo info (if available)
-- Common chats count
-
-Use this to learn about traders, verify users, or gather intel.`,
+ description:
+ "Get detailed info about a Telegram user by username or userId. Returns profile, status, bio, and common chats.",
category: "data-bearing",
parameters: Type.Object({
userId: Type.Optional(
diff --git a/src/agent/tools/telegram/folders/add-chat-to-folder.ts b/src/agent/tools/telegram/folders/add-chat-to-folder.ts
index bc00042..751fd5f 100644
--- a/src/agent/tools/telegram/folders/add-chat-to-folder.ts
+++ b/src/agent/tools/telegram/folders/add-chat-to-folder.ts
@@ -20,7 +20,7 @@ interface AddChatToFolderParams {
export const telegramAddChatToFolderTool: Tool = {
name: "telegram_add_chat_to_folder",
description:
- "Add a specific chat to an existing folder. The chat will appear in that folder's view for easy access. Use telegram_get_folders first to see available folder IDs. This helps organize important or related conversations together. Example: Add a project group to your 'Work' folder.",
+ "Add a chat to an existing folder. Use telegram_get_folders first to get folder IDs.",
parameters: Type.Object({
folderId: Type.Number({
description:
diff --git a/src/agent/tools/telegram/folders/create-folder.ts b/src/agent/tools/telegram/folders/create-folder.ts
index 3c00c6f..225b1d0 100644
--- a/src/agent/tools/telegram/folders/create-folder.ts
+++ b/src/agent/tools/telegram/folders/create-folder.ts
@@ -25,7 +25,7 @@ interface CreateFolderParams {
export const telegramCreateFolderTool: Tool = {
name: "telegram_create_folder",
description:
- "Create a new chat folder to organize your conversations. Folders can auto-include chat types (contacts, groups, bots, etc.) or specific chats added later with telegram_add_chat_to_folder. Use this to categorize chats by topic, importance, or type. Examples: 'Work', 'Family', 'Projects', 'Crypto'.",
+ "Create a new chat folder. Can auto-include chat types or add specific chats later with telegram_add_chat_to_folder.",
parameters: Type.Object({
title: Type.String({
description: "Name of the folder (e.g., 'Work', 'Family', 'Projects'). Max 12 characters.",
diff --git a/src/agent/tools/telegram/folders/get-folders.ts b/src/agent/tools/telegram/folders/get-folders.ts
index c4f6236..ba486dc 100644
--- a/src/agent/tools/telegram/folders/get-folders.ts
+++ b/src/agent/tools/telegram/folders/get-folders.ts
@@ -11,8 +11,7 @@ const log = createLogger("Tools");
*/
export const telegramGetFoldersTool: Tool = {
name: "telegram_get_folders",
- description:
- "List all your chat folders (also called 'filters' in Telegram). Folders organize chats into categories like 'Work', 'Personal', 'Groups', etc. Returns folder IDs, names, and included chat types. Use this to see your organization structure before adding chats to folders with telegram_add_chat_to_folder.",
+ description: "List all your chat folders with IDs, names, and included chat types.",
category: "data-bearing",
parameters: Type.Object({}), // No parameters needed
};
diff --git a/src/agent/tools/telegram/gifts/buy-resale-gift.ts b/src/agent/tools/telegram/gifts/buy-resale-gift.ts
index 6968a56..74ca058 100644
--- a/src/agent/tools/telegram/gifts/buy-resale-gift.ts
+++ b/src/agent/tools/telegram/gifts/buy-resale-gift.ts
@@ -19,7 +19,7 @@ interface BuyResaleGiftParams {
export const telegramBuyResaleGiftTool: Tool = {
name: "telegram_buy_resale_gift",
description:
- "Purchase a collectible gift from the resale marketplace. Uses Stars from your balance to buy at the listed price. After purchase, the collectible becomes yours. Use telegram_get_resale_gifts to browse available listings and find odayId.",
+ "Buy a collectible from the resale marketplace using Stars. Get odayId from telegram_get_resale_gifts.",
parameters: Type.Object({
odayId: Type.String({
description: "The odayId of the listing to purchase (from telegram_get_resale_gifts)",
diff --git a/src/agent/tools/telegram/gifts/get-available-gifts.ts b/src/agent/tools/telegram/gifts/get-available-gifts.ts
index e262058..1ee8fdf 100644
--- a/src/agent/tools/telegram/gifts/get-available-gifts.ts
+++ b/src/agent/tools/telegram/gifts/get-available-gifts.ts
@@ -20,7 +20,7 @@ interface GetAvailableGiftsParams {
export const telegramGetAvailableGiftsTool: Tool = {
name: "telegram_get_available_gifts",
description:
- "Get all Star Gifts available for purchase. There are two types: LIMITED gifts (rare, can become collectibles, may sell out) and UNLIMITED gifts (always available). Use filter to see specific types. Returns gift ID, name, stars cost, and availability. Use the gift ID with telegram_send_gift to send one.",
+ "Get Star Gifts available for purchase. Filterable by limited/unlimited. Use gift ID with telegram_send_gift.",
category: "data-bearing",
parameters: Type.Object({
filter: Type.Optional(
diff --git a/src/agent/tools/telegram/gifts/get-collectible-info.ts b/src/agent/tools/telegram/gifts/get-collectible-info.ts
new file mode 100644
index 0000000..784bdec
--- /dev/null
+++ b/src/agent/tools/telegram/gifts/get-collectible-info.ts
@@ -0,0 +1,77 @@
+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";
+
+const log = createLogger("Tools");
+
+interface GetCollectibleInfoParams {
+ type: "username" | "phone";
+ value: string;
+}
+
+export const telegramGetCollectibleInfoTool: Tool = {
+ name: "telegram_get_collectible_info",
+ description:
+ "Get info about a Fragment collectible (username or phone number). Returns purchase date, price (fiat + crypto), and Fragment URL.",
+ category: "data-bearing",
+ parameters: Type.Object({
+ type: Type.Union([Type.Literal("username"), Type.Literal("phone")], {
+ description:
+ "Type of collectible: 'username' for a Telegram username, 'phone' for a phone number",
+ }),
+ value: Type.String({
+ description: "The username (without @) or phone number (with country code, e.g. +888...)",
+ }),
+ }),
+};
+
+export const telegramGetCollectibleInfoExecutor: ToolExecutor = async (
+ params,
+ context
+): Promise => {
+ try {
+ const { type, value } = params;
+ const gramJsClient = context.bridge.getClient().getClient();
+
+ const collectible =
+ type === "username"
+ ? new Api.InputCollectibleUsername({ username: value.replace("@", "") })
+ : new Api.InputCollectiblePhone({ phone: value });
+
+ const result = await gramJsClient.invoke(new Api.fragment.GetCollectibleInfo({ collectible }));
+
+ log.info(`๐ get_collectible_info: ${type}=${value}`);
+
+ return {
+ success: true,
+ data: {
+ type,
+ value,
+ purchaseDate: new Date(result.purchaseDate * 1000).toISOString(),
+ currency: result.currency,
+ amount: result.amount?.toString(),
+ cryptoCurrency: result.cryptoCurrency,
+ cryptoAmount: result.cryptoAmount?.toString(),
+ url: result.url,
+ },
+ };
+ } catch (error: any) {
+ if (
+ error.errorMessage === "PHONE_NOT_OCCUPIED" ||
+ error.errorMessage === "USERNAME_NOT_OCCUPIED"
+ ) {
+ return {
+ success: false,
+ error: `Collectible not found: ${params.type} "${params.value}" is not a Fragment collectible.`,
+ };
+ }
+
+ log.error({ err: error }, "Error getting collectible info");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/gifts/get-my-gifts.ts b/src/agent/tools/telegram/gifts/get-my-gifts.ts
index a00786a..156d934 100644
--- a/src/agent/tools/telegram/gifts/get-my-gifts.ts
+++ b/src/agent/tools/telegram/gifts/get-my-gifts.ts
@@ -43,24 +43,8 @@ interface GetMyGiftsParams {
*/
export const telegramGetMyGiftsTool: Tool = {
name: "telegram_get_my_gifts",
- description: `Get Star Gifts you or another user has received.
-
-USAGE:
-- To view YOUR OWN gifts: omit both userId and viewSender
-- To view the SENDER's gifts (when user says "show me MY gifts"): set viewSender=true
-- To view a specific user's gifts: pass their userId
-
-PRESENTATION GUIDE:
-- For collectibles: Use "title + model" as display name (e.g., "Hypno Lollipop Telegram")
-- NFT link: t.me/nft/{slug} (e.g., t.me/nft/HypnoLollipop-63414)
-- Respond concisely: "You have a Hypno Lollipop Telegram ๐ญ"
-- Only give details (rarity, backdrop, pattern) when specifically asked
-- attributes.model.name = model, attributes.pattern.name = pattern, attributes.backdrop.name = backdrop
-- rarityPermille: divide by 10 to get percentage (7 = 0.7%)
-
-TRANSFER: Use msgId (for your own gifts) to transfer collectibles via telegram_transfer_collectible.
-
-NEVER dump all raw data. Keep responses natural and concise.`,
+ description:
+ "Get Star Gifts received by you or another user. Set viewSender=true when sender says 'show MY gifts'. For collectibles: display as 'title + model', link as t.me/nft/{slug}. rarityPermille / 10 = %. Use msgId for transfers.",
parameters: Type.Object({
userId: Type.Optional(
Type.String({
diff --git a/src/agent/tools/telegram/gifts/get-resale-gifts.ts b/src/agent/tools/telegram/gifts/get-resale-gifts.ts
index 8a61aeb..b5ccd05 100644
--- a/src/agent/tools/telegram/gifts/get-resale-gifts.ts
+++ b/src/agent/tools/telegram/gifts/get-resale-gifts.ts
@@ -21,7 +21,7 @@ interface GetResaleGiftsParams {
export const telegramGetResaleGiftsTool: Tool = {
name: "telegram_get_resale_gifts",
description:
- "Browse the collectible gifts marketplace. Shows all collectibles currently listed for sale by other users. Can filter by specific gift type or browse all. Returns prices in Stars and seller info. Use telegram_buy_resale_gift to purchase.",
+ "Browse collectible gifts listed for resale. Filterable by gift type. Use telegram_buy_resale_gift to purchase.",
category: "data-bearing",
parameters: Type.Object({
giftId: Type.Optional(
diff --git a/src/agent/tools/telegram/gifts/index.ts b/src/agent/tools/telegram/gifts/index.ts
index 983f40b..a861a43 100644
--- a/src/agent/tools/telegram/gifts/index.ts
+++ b/src/agent/tools/telegram/gifts/index.ts
@@ -15,6 +15,10 @@ import {
import { telegramGetResaleGiftsTool, telegramGetResaleGiftsExecutor } from "./get-resale-gifts.js";
import { telegramBuyResaleGiftTool, telegramBuyResaleGiftExecutor } from "./buy-resale-gift.js";
import { telegramSetGiftStatusTool, telegramSetGiftStatusExecutor } from "./set-gift-status.js";
+import {
+ telegramGetCollectibleInfoTool,
+ telegramGetCollectibleInfoExecutor,
+} from "./get-collectible-info.js";
import type { ToolEntry } from "../../types.js";
export { telegramGetAvailableGiftsTool, telegramGetAvailableGiftsExecutor };
@@ -25,6 +29,7 @@ export { telegramSetCollectiblePriceTool, telegramSetCollectiblePriceExecutor };
export { telegramGetResaleGiftsTool, telegramGetResaleGiftsExecutor };
export { telegramBuyResaleGiftTool, telegramBuyResaleGiftExecutor };
export { telegramSetGiftStatusTool, telegramSetGiftStatusExecutor };
+export { telegramGetCollectibleInfoTool, telegramGetCollectibleInfoExecutor };
export const tools: ToolEntry[] = [
{ tool: telegramGetAvailableGiftsTool, executor: telegramGetAvailableGiftsExecutor },
@@ -43,4 +48,5 @@ export const tools: ToolEntry[] = [
{ tool: telegramGetResaleGiftsTool, executor: telegramGetResaleGiftsExecutor },
{ tool: telegramBuyResaleGiftTool, executor: telegramBuyResaleGiftExecutor, scope: "dm-only" },
{ tool: telegramSetGiftStatusTool, executor: telegramSetGiftStatusExecutor, scope: "dm-only" },
+ { tool: telegramGetCollectibleInfoTool, executor: telegramGetCollectibleInfoExecutor },
];
diff --git a/src/agent/tools/telegram/gifts/send-gift.ts b/src/agent/tools/telegram/gifts/send-gift.ts
index d3b286b..b62701a 100644
--- a/src/agent/tools/telegram/gifts/send-gift.ts
+++ b/src/agent/tools/telegram/gifts/send-gift.ts
@@ -23,7 +23,7 @@ interface SendGiftParams {
export const telegramSendGiftTool: Tool = {
name: "telegram_send_gift",
description:
- "Send a Star Gift to another user. First use telegram_get_available_gifts to see available gifts and their IDs. Limited gifts are rare and can become collectibles. The gift will appear on the recipient's profile unless they hide it. Costs Stars from your balance.",
+ "Send a Star Gift to a user. Costs Stars. Requires a verified deal (use deal_propose first).",
parameters: Type.Object({
userId: Type.String({
description: "User ID or @username to send the gift to",
diff --git a/src/agent/tools/telegram/gifts/set-collectible-price.ts b/src/agent/tools/telegram/gifts/set-collectible-price.ts
index 56ce959..0129d42 100644
--- a/src/agent/tools/telegram/gifts/set-collectible-price.ts
+++ b/src/agent/tools/telegram/gifts/set-collectible-price.ts
@@ -20,7 +20,7 @@ interface SetCollectiblePriceParams {
export const telegramSetCollectiblePriceTool: Tool = {
name: "telegram_set_collectible_price",
description:
- "List or unlist a collectible gift for sale on the Telegram marketplace. Set a price in Stars to list it for sale. Omit price or set to 0 to remove from sale. Only works with upgraded collectible gifts you own.",
+ "List/unlist a collectible for sale. Set price in Stars to list, omit or 0 to unlist. Collectibles only.",
parameters: Type.Object({
odayId: Type.String({
description: "The odayId of the collectible to list/unlist (from telegram_get_my_gifts)",
diff --git a/src/agent/tools/telegram/gifts/set-gift-status.ts b/src/agent/tools/telegram/gifts/set-gift-status.ts
index 9995112..4c1b16b 100644
--- a/src/agent/tools/telegram/gifts/set-gift-status.ts
+++ b/src/agent/tools/telegram/gifts/set-gift-status.ts
@@ -20,18 +20,8 @@ interface SetGiftStatusParams {
*/
export const telegramSetGiftStatusTool: Tool = {
name: "telegram_set_gift_status",
- description: `Set a Collectible Gift as your Emoji Status (the icon next to your name).
-
-USAGE:
-- Set status: telegram_set_gift_status({ collectibleId: "123456789" })
-- Clear status: telegram_set_gift_status({ clear: true })
-
-IMPORTANT:
-- Only COLLECTIBLE gifts (isCollectible: true) can be used as emoji status
-- Use the "collectibleId" field from telegram_get_my_gifts (NOT the slug!)
-- collectibleId is a numeric string like "6219780841349758977"
-
-The emoji status appears next to your name in chats and your profile.`,
+ description:
+ "Set a collectible gift as your emoji status (icon next to your name). Use collectibleId from telegram_get_my_gifts (not slug). Set clear=true to remove.",
parameters: Type.Object({
collectibleId: Type.Optional(
Type.String({
diff --git a/src/agent/tools/telegram/gifts/transfer-collectible.ts b/src/agent/tools/telegram/gifts/transfer-collectible.ts
index d021af9..ea52450 100644
--- a/src/agent/tools/telegram/gifts/transfer-collectible.ts
+++ b/src/agent/tools/telegram/gifts/transfer-collectible.ts
@@ -20,13 +20,8 @@ interface TransferCollectibleParams {
*/
export const telegramTransferCollectibleTool: Tool = {
name: "telegram_transfer_collectible",
- description: `Transfer a collectible gift you own to another user. Only works with upgraded collectible gifts (starGiftUnique), not regular gifts. The recipient will become the new owner.
-
-IMPORTANT: Some collectibles require a Star fee to transfer (shown as transferStars in telegram_get_my_gifts).
-- If transferStars is null/0: Transfer is FREE
-- If transferStars has a value: That amount of Stars will be deducted from your balance
-
-Use telegram_get_my_gifts to find your collectibles and their msgId.`,
+ description:
+ "Transfer a collectible gift to another user. Requires verified deal. May cost Stars (see transferStars in telegram_get_my_gifts). Collectibles only.",
parameters: Type.Object({
msgId: Type.Number({
description:
diff --git a/src/agent/tools/telegram/groups/get-me.ts b/src/agent/tools/telegram/groups/get-me.ts
index a35bddf..5050075 100644
--- a/src/agent/tools/telegram/groups/get-me.ts
+++ b/src/agent/tools/telegram/groups/get-me.ts
@@ -11,8 +11,7 @@ const log = createLogger("Tools");
*/
export const telegramGetMeTool: Tool = {
name: "telegram_get_me",
- description:
- "Get information about yourself (the currently authenticated Telegram account). Returns your user ID, username, name, phone number, and whether you're a bot. Use this for self-awareness and to understand your own account details.",
+ description: "Get your own Telegram account info (user ID, username, name, phone).",
category: "data-bearing",
parameters: Type.Object({}), // No parameters needed
};
diff --git a/src/agent/tools/telegram/groups/get-participants.ts b/src/agent/tools/telegram/groups/get-participants.ts
index 0807cfa..f673630 100644
--- a/src/agent/tools/telegram/groups/get-participants.ts
+++ b/src/agent/tools/telegram/groups/get-participants.ts
@@ -22,7 +22,7 @@ interface GetParticipantsParams {
export const telegramGetParticipantsTool: Tool = {
name: "telegram_get_participants",
description:
- "Get list of participants (members) in a Telegram group or channel. Use this to see who's in a chat, identify admins, check banned users, or find bots. Useful for moderation, member management, and group analytics.",
+ "Get participants of a group or channel. Filterable by all, admins, banned, or bots.",
category: "data-bearing",
parameters: Type.Object({
chatId: Type.String({
diff --git a/src/agent/tools/telegram/groups/set-chat-photo.ts b/src/agent/tools/telegram/groups/set-chat-photo.ts
index 3680038..4339014 100644
--- a/src/agent/tools/telegram/groups/set-chat-photo.ts
+++ b/src/agent/tools/telegram/groups/set-chat-photo.ts
@@ -21,7 +21,7 @@ interface SetChatPhotoParams {
export const telegramSetChatPhotoTool: Tool = {
name: "telegram_set_chat_photo",
- description: `Set or delete a group/channel profile photo. You need admin rights with change info permission. Provide a local image path to set, or use delete_photo to remove.`,
+ description: `Set or delete a group/channel profile photo. Requires admin rights with change-info permission.`,
parameters: Type.Object({
chat_id: Type.String({
description: "Group/channel ID or username",
diff --git a/src/agent/tools/telegram/interactive/create-poll.ts b/src/agent/tools/telegram/interactive/create-poll.ts
index e89a871..4154039 100644
--- a/src/agent/tools/telegram/interactive/create-poll.ts
+++ b/src/agent/tools/telegram/interactive/create-poll.ts
@@ -28,7 +28,7 @@ interface CreatePollParams {
export const telegramCreatePollTool: Tool = {
name: "telegram_create_poll",
description:
- "Create a poll in a Telegram chat to gather opinions or votes from users. Polls can be anonymous or public, allow single or multiple answers. Use this to make group decisions, conduct surveys, or engage users with questions. For quizzes with correct answers, use telegram_create_quiz instead.",
+ "Create a poll in a chat. For quizzes with a correct answer, use telegram_create_quiz instead.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the poll will be created",
diff --git a/src/agent/tools/telegram/interactive/create-quiz.ts b/src/agent/tools/telegram/interactive/create-quiz.ts
index 6f7445c..0531c08 100644
--- a/src/agent/tools/telegram/interactive/create-quiz.ts
+++ b/src/agent/tools/telegram/interactive/create-quiz.ts
@@ -26,7 +26,7 @@ interface CreateQuizParams {
export const telegramCreateQuizTool: Tool = {
name: "telegram_create_quiz",
description:
- "Create a quiz (poll with a correct answer) in a Telegram chat. Unlike regular polls, quizzes have one correct answer that gets revealed when users vote. Optionally add an explanation. Use this for educational content, trivia games, or testing knowledge. For opinion polls without correct answers, use telegram_create_poll instead.",
+ "Create a quiz (poll with one correct answer revealed on vote). For opinion polls, use telegram_create_poll.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the quiz will be created",
diff --git a/src/agent/tools/telegram/interactive/react.ts b/src/agent/tools/telegram/interactive/react.ts
index e1c44d6..fdda1b1 100644
--- a/src/agent/tools/telegram/interactive/react.ts
+++ b/src/agent/tools/telegram/interactive/react.ts
@@ -19,8 +19,7 @@ interface ReactParams {
*/
export const telegramReactTool: Tool = {
name: "telegram_react",
- description:
- "Add an emoji reaction to a Telegram message. Use this to quickly acknowledge, approve, or express emotions without sending a full message. Common reactions: ๐ (like/approve), โค๏ธ (love), ๐ฅ (fire/hot), ๐ (funny), ๐ข (sad), ๐ (celebrate), ๐ (dislike), ๐ค (thinking). The message ID comes from the current conversation context or from telegram_get_history.",
+ description: "Add an emoji reaction to a message.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the message is located",
diff --git a/src/agent/tools/telegram/interactive/reply-keyboard.ts b/src/agent/tools/telegram/interactive/reply-keyboard.ts
index 23c8d17..8dddeb7 100644
--- a/src/agent/tools/telegram/interactive/reply-keyboard.ts
+++ b/src/agent/tools/telegram/interactive/reply-keyboard.ts
@@ -25,7 +25,7 @@ interface ReplyKeyboardParams {
export const telegramReplyKeyboardTool: Tool = {
name: "telegram_reply_keyboard",
description:
- "Send a message with a custom reply keyboard that replaces the user's regular keyboard. Users can tap buttons to quickly send predefined responses. Each button sends its text as a message. Use this to create menus, quick replies, or guided conversations. Buttons are arranged in rows. Example: [['Yes', 'No'], ['Maybe']] creates 2 rows.",
+ "Send a message with a custom reply keyboard. Buttons are arranged in rows; each button sends its label as a message.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the message with keyboard to",
diff --git a/src/agent/tools/telegram/interactive/send-dice.ts b/src/agent/tools/telegram/interactive/send-dice.ts
index d587ce2..1dd9866 100644
--- a/src/agent/tools/telegram/interactive/send-dice.ts
+++ b/src/agent/tools/telegram/interactive/send-dice.ts
@@ -19,17 +19,7 @@ interface SendDiceParams {
export const telegramSendDiceTool: Tool = {
name: "telegram_send_dice",
- description: `Send an animated dice/game message. The result is random and determined by Telegram servers.
-
-Available games:
-- ๐ฒ Dice (1-6)
-- ๐ฏ Darts (1-6, 6 = bullseye)
-- ๐ Basketball (1-5, 4-5 = score)
-- โฝ Football (1-5, 4-5 = goal)
-- ๐ฐ Slot machine (1-64, 64 = jackpot 777)
-- ๐ณ Bowling (1-6, 6 = strike)
-
-Use for games, decisions, or fun interactions.`,
+ description: `Send an animated dice/game message. Result is random, determined by Telegram servers.`,
parameters: Type.Object({
chat_id: Type.String({
diff --git a/src/agent/tools/telegram/media/download-media.ts b/src/agent/tools/telegram/media/download-media.ts
index 4e3c55c..0a088cd 100644
--- a/src/agent/tools/telegram/media/download-media.ts
+++ b/src/agent/tools/telegram/media/download-media.ts
@@ -28,7 +28,7 @@ interface DownloadMediaParams {
export const telegramDownloadMediaTool: Tool = {
name: "telegram_download_media",
description:
- "Download media (photo, video, document, voice, sticker, etc.) from a Telegram message. The file will be saved to ~/.teleton/downloads/. Use this to retrieve images, documents, or other files sent in conversations. Returns the local file path after download.",
+ "Download media from a Telegram message to ~/.teleton/downloads/. Returns the local file path.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the message with media is located",
diff --git a/src/agent/tools/telegram/media/index.ts b/src/agent/tools/telegram/media/index.ts
index c1fb9bf..fefc710 100644
--- a/src/agent/tools/telegram/media/index.ts
+++ b/src/agent/tools/telegram/media/index.ts
@@ -4,6 +4,10 @@ import { telegramSendStickerTool, telegramSendStickerExecutor } from "./send-sti
import { telegramSendGifTool, telegramSendGifExecutor } from "./send-gif.js";
import { telegramDownloadMediaTool, telegramDownloadMediaExecutor } from "./download-media.js";
import { visionAnalyzeTool, visionAnalyzeExecutor } from "./vision-analyze.js";
+import {
+ telegramTranscribeAudioTool,
+ telegramTranscribeAudioExecutor,
+} from "./transcribe-audio.js";
import type { ToolEntry } from "../../types.js";
export { telegramSendPhotoTool, telegramSendPhotoExecutor };
@@ -12,6 +16,7 @@ export { telegramSendStickerTool, telegramSendStickerExecutor };
export { telegramSendGifTool, telegramSendGifExecutor };
export { telegramDownloadMediaTool, telegramDownloadMediaExecutor };
export { visionAnalyzeTool, visionAnalyzeExecutor };
+export { telegramTranscribeAudioTool, telegramTranscribeAudioExecutor };
export const tools: ToolEntry[] = [
{ tool: telegramSendPhotoTool, executor: telegramSendPhotoExecutor },
@@ -20,4 +25,5 @@ export const tools: ToolEntry[] = [
{ tool: telegramSendGifTool, executor: telegramSendGifExecutor },
{ tool: telegramDownloadMediaTool, executor: telegramDownloadMediaExecutor },
{ tool: visionAnalyzeTool, executor: visionAnalyzeExecutor },
+ { tool: telegramTranscribeAudioTool, executor: telegramTranscribeAudioExecutor },
];
diff --git a/src/agent/tools/telegram/media/send-gif.ts b/src/agent/tools/telegram/media/send-gif.ts
index 799ef4e..8dccd85 100644
--- a/src/agent/tools/telegram/media/send-gif.ts
+++ b/src/agent/tools/telegram/media/send-gif.ts
@@ -26,7 +26,7 @@ interface SendGifParams {
export const telegramSendGifTool: Tool = {
name: "telegram_send_gif",
description:
- "Send an animated GIF to a Telegram chat. You can either: 1) Use queryId + resultId from telegram_search_gifs to send a GIF from Telegram's library, or 2) Provide a local file path to a GIF/MP4. For online GIFs, always use the search + send workflow.",
+ "Send a GIF via queryId+resultId (from telegram_search_gifs) or a local GIF/MP4 file path.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the GIF to",
diff --git a/src/agent/tools/telegram/media/send-photo.ts b/src/agent/tools/telegram/media/send-photo.ts
index f1c00ea..4304ce6 100644
--- a/src/agent/tools/telegram/media/send-photo.ts
+++ b/src/agent/tools/telegram/media/send-photo.ts
@@ -21,8 +21,7 @@ interface SendPhotoParams {
*/
export const telegramSendPhotoTool: Tool = {
name: "telegram_send_photo",
- description:
- "Send a photo/image to a Telegram chat. Provide the local file path to the image. Supports JPG, PNG, WEBP formats. Use this to share visual content, screenshots, or images with users.",
+ description: "Send a photo from a local file path to a Telegram chat. Supports JPG, PNG, WEBP.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the photo to",
diff --git a/src/agent/tools/telegram/media/send-sticker.ts b/src/agent/tools/telegram/media/send-sticker.ts
index 30387e5..1ea19b6 100644
--- a/src/agent/tools/telegram/media/send-sticker.ts
+++ b/src/agent/tools/telegram/media/send-sticker.ts
@@ -25,7 +25,7 @@ interface SendStickerParams {
export const telegramSendStickerTool: Tool = {
name: "telegram_send_sticker",
description:
- "Send a sticker to a Telegram chat. You can either provide a sticker set's short name + index (position in the pack, starting from 0), or a local path to a WEBP/TGS file. To find sticker sets, use telegram_search_stickers first to get the shortName, then choose which sticker (by index 0-N) to send.",
+ "Send a sticker via stickerSetShortName+stickerIndex (from telegram_search_stickers) or a local WEBP/TGS file path.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the sticker to",
diff --git a/src/agent/tools/telegram/media/send-voice.ts b/src/agent/tools/telegram/media/send-voice.ts
index d9b4b4d..1b53f56 100644
--- a/src/agent/tools/telegram/media/send-voice.ts
+++ b/src/agent/tools/telegram/media/send-voice.ts
@@ -37,30 +37,8 @@ interface SendVoiceParams {
export const telegramSendVoiceTool: Tool = {
name: "telegram_send_voice",
- description: `Send a voice message to a Telegram chat.
-
-**Two modes:**
-1. **File mode**: Provide \`voicePath\` to send an existing audio file
-2. **TTS mode**: Provide \`text\` to generate speech and send as voice note
-
-**TTS Providers:**
-- \`piper\` (default): Offline neural TTS with Trump voice
-- \`edge\`: Free Microsoft Edge TTS - cloud, many voices
-- \`openai\`: OpenAI TTS API (requires OPENAI_API_KEY)
-- \`elevenlabs\`: ElevenLabs API (requires ELEVENLABS_API_KEY)
-
-**Piper voices (default):**
-- \`trump\`: Trump voice (default, en-US)
-- \`dmitri\` / \`ru-ru\`: Russian male
-
-**Edge voices (fallback):**
-- English: en-us-male, en-us-female, en-gb-male
-- Russian: ru-ru-male, ru-ru-female
-
-**Examples:**
-- Default Trump voice: text="This is tremendous, believe me!"
-- Russian: text="ะัะธะฒะตั!", voice="dmitri", ttsProvider="piper"
-- Edge fallback: text="Hello!", ttsProvider="edge"`,
+ description:
+ "Send a voice message. Either provide voicePath for an existing file, or text for TTS generation. Default TTS: piper (Trump voice). Available providers: piper, edge, openai, elevenlabs.",
parameters: Type.Object({
chatId: Type.String({
diff --git a/src/agent/tools/telegram/media/transcribe-audio.ts b/src/agent/tools/telegram/media/transcribe-audio.ts
new file mode 100644
index 0000000..dd5e913
--- /dev/null
+++ b/src/agent/tools/telegram/media/transcribe-audio.ts
@@ -0,0 +1,124 @@
+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";
+
+const log = createLogger("Tools");
+
+interface TranscribeAudioParams {
+ chatId: string;
+ messageId: number;
+}
+
+export const telegramTranscribeAudioTool: Tool = {
+ name: "telegram_transcribe_audio",
+ description:
+ "Transcribe a voice or audio message to text using Telegram's native transcription. Requires the message to be a voice/audio type. May require Telegram Premium.",
+ category: "data-bearing",
+ parameters: Type.Object({
+ chatId: Type.String({
+ description: "The chat ID where the voice/audio message is",
+ }),
+ messageId: Type.Number({
+ description: "The message ID of the voice/audio message to transcribe",
+ }),
+ }),
+};
+
+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 = context.bridge.getClient().getClient();
+ 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: any) {
+ // On transient errors (FLOOD_WAIT, network), keep polling
+ log.warn(
+ `โ ๏ธ Transcription poll ${retries} failed: ${pollError.errorMessage || pollError.message}`
+ );
+ continue;
+ }
+ }
+
+ if (result.pending) {
+ log.warn(`Transcription still pending after ${MAX_POLL_RETRIES} retries`);
+ return {
+ success: true,
+ data: {
+ transcriptionId: result.transcriptionId?.toString(),
+ text: result.text || null,
+ pending: true,
+ message: "Transcription is still processing. Try again later.",
+ },
+ };
+ }
+
+ log.info(`๐ค transcribe_audio: msg ${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,
+ }),
+ },
+ };
+ } catch (error: any) {
+ // Handle specific Telegram errors
+ if (error.errorMessage === "PREMIUM_ACCOUNT_REQUIRED") {
+ return {
+ success: false,
+ error: "Telegram Premium is required to transcribe audio messages.",
+ };
+ }
+ if (error.errorMessage === "MSG_ID_INVALID") {
+ return {
+ success: false,
+ error: "Invalid message ID โ the message may not exist or is not a voice/audio message.",
+ };
+ }
+
+ log.error({ err: error }, "Error transcribing audio");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/media/vision-analyze.ts b/src/agent/tools/telegram/media/vision-analyze.ts
index fcd1fa4..b8fb84e 100644
--- a/src/agent/tools/telegram/media/vision-analyze.ts
+++ b/src/agent/tools/telegram/media/vision-analyze.ts
@@ -33,7 +33,7 @@ interface VisionAnalyzeParams {
export const visionAnalyzeTool: Tool = {
name: "vision_analyze",
description:
- "Analyze an image using Claude's vision capabilities. Can analyze images from Telegram messages OR from local workspace files. Use this when a user sends an image and asks you to describe, analyze, or understand its content. Returns Claude's analysis of the image.",
+ "Analyze an image using vision LLM. Provide chatId+messageId for Telegram images or filePath for local files.",
category: "data-bearing",
parameters: Type.Object({
chatId: Type.Optional(
diff --git a/src/agent/tools/telegram/memory/memory-read.ts b/src/agent/tools/telegram/memory/memory-read.ts
index b7cbee0..d33750c 100644
--- a/src/agent/tools/telegram/memory/memory-read.ts
+++ b/src/agent/tools/telegram/memory/memory-read.ts
@@ -25,7 +25,7 @@ interface MemoryReadParams {
export const memoryReadTool: Tool = {
name: "memory_read",
description:
- "Read your memory files. Use 'persistent' for MEMORY.md, 'daily' for today's log, 'recent' for today+yesterday, or 'list' to see all available memory files.",
+ "Read your memory files: persistent (MEMORY.md), daily (today's log), recent (today+yesterday), or list all.",
category: "data-bearing",
parameters: Type.Object({
target: Type.String({
diff --git a/src/agent/tools/telegram/memory/memory-write.ts b/src/agent/tools/telegram/memory/memory-write.ts
index 416e999..38d49cc 100644
--- a/src/agent/tools/telegram/memory/memory-write.ts
+++ b/src/agent/tools/telegram/memory/memory-write.ts
@@ -37,7 +37,7 @@ interface MemoryWriteParams {
export const memoryWriteTool: Tool = {
name: "memory_write",
description:
- "Write important information to your persistent memory. Use this to remember facts, lessons learned, decisions, preferences, or anything you want to recall in future sessions. 'persistent' writes to MEMORY.md (long-term), 'daily' writes to today's log (short-term notes).",
+ "Save to agent memory. Use 'persistent' for long-term facts, preferences, contacts, rules โ MEMORY.md. Use 'daily' for session notes, events, temporary context โ today's log. Disabled in group chats.",
parameters: Type.Object({
content: Type.String({
description: "The content to write to memory. Be concise but complete.",
diff --git a/src/agent/tools/telegram/messaging/delete-message.ts b/src/agent/tools/telegram/messaging/delete-message.ts
index 90b4112..286029a 100644
--- a/src/agent/tools/telegram/messaging/delete-message.ts
+++ b/src/agent/tools/telegram/messaging/delete-message.ts
@@ -21,7 +21,7 @@ interface DeleteMessageParams {
export const telegramDeleteMessageTool: Tool = {
name: "telegram_delete_message",
description:
- "Delete one or more messages from a chat. Can delete your own messages in any chat, or any message in groups where you have admin rights. Use 'revoke: true' to delete for everyone (not just yourself). Be careful - deletion is permanent!",
+ "Delete messages from a chat. Own messages in any chat, or any message with admin rights. Deletion is permanent.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the messages are located",
diff --git a/src/agent/tools/telegram/messaging/delete-scheduled-message.ts b/src/agent/tools/telegram/messaging/delete-scheduled-message.ts
new file mode 100644
index 0000000..e6f7589
--- /dev/null
+++ b/src/agent/tools/telegram/messaging/delete-scheduled-message.ts
@@ -0,0 +1,68 @@
+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";
+
+const log = createLogger("Tools");
+
+interface DeleteScheduledMessageParams {
+ chatId: string;
+ messageIds: number[];
+}
+
+export const telegramDeleteScheduledMessageTool: Tool = {
+ name: "telegram_delete_scheduled_message",
+ description:
+ "Cancel one or more scheduled messages by their IDs. Use telegram_get_scheduled_messages first to find message IDs.",
+ parameters: Type.Object({
+ chatId: Type.String({
+ description: "The chat ID where the scheduled messages are",
+ }),
+ messageIds: Type.Array(Type.Number(), {
+ description: "Array of scheduled message IDs to cancel",
+ minItems: 1,
+ maxItems: 30,
+ }),
+ }),
+};
+
+export const telegramDeleteScheduledMessageExecutor: ToolExecutor<
+ DeleteScheduledMessageParams
+> = async (params, context): Promise => {
+ try {
+ const { chatId, messageIds } = params;
+ const gramJsClient = context.bridge.getClient().getClient();
+ const entity = await gramJsClient.getEntity(chatId);
+
+ await gramJsClient.invoke(
+ new Api.messages.DeleteScheduledMessages({
+ peer: entity,
+ id: messageIds,
+ })
+ );
+
+ log.info(`๐๏ธ delete_scheduled: ${messageIds.length} messages cancelled in ${chatId}`);
+
+ return {
+ success: true,
+ data: {
+ chatId,
+ deletedIds: messageIds,
+ deletedCount: messageIds.length,
+ },
+ };
+ } catch (error: any) {
+ if (error.errorMessage === "MESSAGE_ID_INVALID") {
+ return {
+ success: false,
+ error: "One or more message IDs are invalid or not scheduled messages.",
+ };
+ }
+ log.error({ err: error }, "Error deleting scheduled messages");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/messaging/edit-message.ts b/src/agent/tools/telegram/messaging/edit-message.ts
index 1dfcdaa..5b4bdc6 100644
--- a/src/agent/tools/telegram/messaging/edit-message.ts
+++ b/src/agent/tools/telegram/messaging/edit-message.ts
@@ -21,8 +21,7 @@ interface EditMessageParams {
*/
export const telegramEditMessageTool: Tool = {
name: "telegram_edit_message",
- description:
- "Edit a previously sent message in a Telegram chat. Use this to correct errors, update information, or modify content without deleting and resending. Only messages sent by the bot can be edited. Text messages can be edited within 48 hours of being sent.",
+ description: "Edit a previously sent message. Only your own messages can be edited, within 48h.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the message was sent",
diff --git a/src/agent/tools/telegram/messaging/forward-message.ts b/src/agent/tools/telegram/messaging/forward-message.ts
index 153dab0..5c97adf 100644
--- a/src/agent/tools/telegram/messaging/forward-message.ts
+++ b/src/agent/tools/telegram/messaging/forward-message.ts
@@ -24,7 +24,7 @@ interface ForwardMessageParams {
export const telegramForwardMessageTool: Tool = {
name: "telegram_forward_message",
description:
- "Forward one or more messages from one chat to another. Useful for sharing messages, quotes, or content between conversations. The forwarded messages will show their original sender unless sent silently.",
+ "Forward one or more messages from one chat to another. Shows original sender attribution.",
parameters: Type.Object({
fromChatId: Type.String({
description: "The chat ID where the original message(s) are located",
diff --git a/src/agent/tools/telegram/messaging/get-replies.ts b/src/agent/tools/telegram/messaging/get-replies.ts
index 163e801..289e216 100644
--- a/src/agent/tools/telegram/messaging/get-replies.ts
+++ b/src/agent/tools/telegram/messaging/get-replies.ts
@@ -22,7 +22,7 @@ interface GetRepliesParams {
export const telegramGetRepliesTool: Tool = {
name: "telegram_get_replies",
description:
- "Get all replies to a specific message (reply thread/chain). Useful for reading conversation threads, forum discussions, or comment sections under channel posts. Returns messages sorted from oldest to newest.",
+ "Get all replies to a specific message (thread/chain). Returns messages oldest-first.",
category: "data-bearing",
parameters: Type.Object({
chatId: Type.String({
diff --git a/src/agent/tools/telegram/messaging/get-scheduled-messages.ts b/src/agent/tools/telegram/messaging/get-scheduled-messages.ts
new file mode 100644
index 0000000..30aea46
--- /dev/null
+++ b/src/agent/tools/telegram/messaging/get-scheduled-messages.ts
@@ -0,0 +1,67 @@
+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 bigInt from "big-integer";
+
+const log = createLogger("Tools");
+
+interface GetScheduledMessagesParams {
+ chatId: string;
+}
+
+export const telegramGetScheduledMessagesTool: Tool = {
+ name: "telegram_get_scheduled_messages",
+ description:
+ "List all scheduled (pending) messages in a chat. Shows message text, scheduled send date, and message IDs for management.",
+ category: "data-bearing",
+ parameters: Type.Object({
+ chatId: Type.String({
+ description: "The chat ID to list scheduled messages for",
+ }),
+ }),
+};
+
+export const telegramGetScheduledMessagesExecutor: ToolExecutor<
+ GetScheduledMessagesParams
+> = async (params, context): Promise => {
+ try {
+ const { chatId } = params;
+ const gramJsClient = context.bridge.getClient().getClient();
+ const entity = await gramJsClient.getEntity(chatId);
+
+ const result = await gramJsClient.invoke(
+ new Api.messages.GetScheduledHistory({
+ peer: entity,
+ hash: bigInt(0),
+ })
+ );
+
+ const messages = ("messages" in result ? result.messages : []) as Api.Message[];
+
+ const scheduled = messages.map((msg) => ({
+ id: msg.id,
+ text: msg.message || null,
+ scheduledFor: msg.date ? new Date(msg.date * 1000).toISOString() : null,
+ hasMedia: !!msg.media,
+ }));
+
+ log.info(`๐ get_scheduled_messages: ${scheduled.length} scheduled in ${chatId}`);
+
+ return {
+ success: true,
+ data: {
+ chatId,
+ count: scheduled.length,
+ messages: scheduled,
+ },
+ };
+ } catch (error) {
+ log.error({ err: error }, "Error getting scheduled messages");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/messaging/index.ts b/src/agent/tools/telegram/messaging/index.ts
index 7886da6..630c26a 100644
--- a/src/agent/tools/telegram/messaging/index.ts
+++ b/src/agent/tools/telegram/messaging/index.ts
@@ -15,6 +15,18 @@ import {
} from "./pin.js";
import { telegramQuoteReplyTool, telegramQuoteReplyExecutor } from "./quote-reply.js";
import { telegramGetRepliesTool, telegramGetRepliesExecutor } from "./get-replies.js";
+import {
+ telegramGetScheduledMessagesTool,
+ telegramGetScheduledMessagesExecutor,
+} from "./get-scheduled-messages.js";
+import {
+ telegramDeleteScheduledMessageTool,
+ telegramDeleteScheduledMessageExecutor,
+} from "./delete-scheduled-message.js";
+import {
+ telegramSendScheduledNowTool,
+ telegramSendScheduledNowExecutor,
+} from "./send-scheduled-now.js";
import type { ToolEntry } from "../../types.js";
export { telegramSendMessageTool, telegramSendMessageExecutor };
@@ -31,6 +43,9 @@ export {
};
export { telegramQuoteReplyTool, telegramQuoteReplyExecutor };
export { telegramGetRepliesTool, telegramGetRepliesExecutor };
+export { telegramGetScheduledMessagesTool, telegramGetScheduledMessagesExecutor };
+export { telegramDeleteScheduledMessageTool, telegramDeleteScheduledMessageExecutor };
+export { telegramSendScheduledNowTool, telegramSendScheduledNowExecutor };
export const tools: ToolEntry[] = [
{ tool: telegramSendMessageTool, executor: telegramSendMessageExecutor },
@@ -38,6 +53,9 @@ export const tools: ToolEntry[] = [
{ tool: telegramGetRepliesTool, executor: telegramGetRepliesExecutor },
{ tool: telegramEditMessageTool, executor: telegramEditMessageExecutor },
{ tool: telegramScheduleMessageTool, executor: telegramScheduleMessageExecutor },
+ { tool: telegramGetScheduledMessagesTool, executor: telegramGetScheduledMessagesExecutor },
+ { tool: telegramDeleteScheduledMessageTool, executor: telegramDeleteScheduledMessageExecutor },
+ { tool: telegramSendScheduledNowTool, executor: telegramSendScheduledNowExecutor },
{ tool: telegramSearchMessagesTool, executor: telegramSearchMessagesExecutor },
{ tool: telegramPinMessageTool, executor: telegramPinMessageExecutor },
{ tool: telegramUnpinMessageTool, executor: telegramUnpinMessageExecutor },
diff --git a/src/agent/tools/telegram/messaging/quote-reply.ts b/src/agent/tools/telegram/messaging/quote-reply.ts
index c53bb1c..809f442 100644
--- a/src/agent/tools/telegram/messaging/quote-reply.ts
+++ b/src/agent/tools/telegram/messaging/quote-reply.ts
@@ -24,7 +24,7 @@ interface QuoteReplyParams {
export const telegramQuoteReplyTool: Tool = {
name: "telegram_quote_reply",
description:
- "Reply to a message while quoting a specific part of it. The quoted text will be highlighted in the reply. Use this when you want to respond to a specific part of someone's message.",
+ "Reply to a message while quoting a specific part of it. quoteText must match the original exactly.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID where the message is",
diff --git a/src/agent/tools/telegram/messaging/schedule-message.ts b/src/agent/tools/telegram/messaging/schedule-message.ts
index a9a5b88..4be0ba5 100644
--- a/src/agent/tools/telegram/messaging/schedule-message.ts
+++ b/src/agent/tools/telegram/messaging/schedule-message.ts
@@ -23,7 +23,7 @@ interface ScheduleMessageParams {
export const telegramScheduleMessageTool: Tool = {
name: "telegram_schedule_message",
description:
- "Schedule a message to be sent at a specific future time in a Telegram chat. Useful for reminders, delayed announcements, or time-sensitive messages. The message will be sent automatically at the scheduled time.",
+ "Schedule a message to be sent at a future time. scheduleDate must be in the future.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the scheduled message to",
diff --git a/src/agent/tools/telegram/messaging/search-messages.ts b/src/agent/tools/telegram/messaging/search-messages.ts
index c9f5cef..00a44f2 100644
--- a/src/agent/tools/telegram/messaging/search-messages.ts
+++ b/src/agent/tools/telegram/messaging/search-messages.ts
@@ -21,7 +21,7 @@ interface SearchMessagesParams {
export const telegramSearchMessagesTool: Tool = {
name: "telegram_search_messages",
description:
- "Search for messages in a Telegram chat by text query. Use this to find past conversations, retrieve specific information, or locate messages containing keywords. Returns matching messages with their content and metadata.",
+ "Search for messages in a chat by text query. Returns matching messages with content and metadata.",
category: "data-bearing",
parameters: Type.Object({
chatId: Type.String({
diff --git a/src/agent/tools/telegram/messaging/send-message.ts b/src/agent/tools/telegram/messaging/send-message.ts
index e721796..5858010 100644
--- a/src/agent/tools/telegram/messaging/send-message.ts
+++ b/src/agent/tools/telegram/messaging/send-message.ts
@@ -21,7 +21,7 @@ interface SendMessageParams {
export const telegramSendMessageTool: Tool = {
name: "telegram_send_message",
description:
- "Send a text message to a Telegram chat. Supports up to 4096 characters. Use this for standard text responses in DMs or groups. For messages with custom keyboards, use telegram_reply_keyboard. For media, use specific media tools (telegram_send_photo, etc.).",
+ "Send a text message to a Telegram chat. For custom keyboards use telegram_reply_keyboard; for media use telegram_send_photo/gif/sticker.",
parameters: Type.Object({
chatId: Type.String({
description: "The chat ID to send the message to",
diff --git a/src/agent/tools/telegram/messaging/send-scheduled-now.ts b/src/agent/tools/telegram/messaging/send-scheduled-now.ts
new file mode 100644
index 0000000..b59d1ad
--- /dev/null
+++ b/src/agent/tools/telegram/messaging/send-scheduled-now.ts
@@ -0,0 +1,69 @@
+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";
+
+const log = createLogger("Tools");
+
+interface SendScheduledNowParams {
+ chatId: string;
+ messageIds: number[];
+}
+
+export const telegramSendScheduledNowTool: Tool = {
+ name: "telegram_send_scheduled_now",
+ description:
+ "Send one or more scheduled messages immediately instead of waiting for their scheduled time.",
+ parameters: Type.Object({
+ chatId: Type.String({
+ description: "The chat ID where the scheduled messages are",
+ }),
+ messageIds: Type.Array(Type.Number(), {
+ description: "Array of scheduled message IDs to send immediately",
+ minItems: 1,
+ maxItems: 30,
+ }),
+ }),
+};
+
+export const telegramSendScheduledNowExecutor: ToolExecutor = async (
+ params,
+ context
+): Promise => {
+ try {
+ const { chatId, messageIds } = params;
+ const gramJsClient = context.bridge.getClient().getClient();
+ const entity = await gramJsClient.getEntity(chatId);
+
+ await gramJsClient.invoke(
+ new Api.messages.SendScheduledMessages({
+ peer: entity,
+ id: messageIds,
+ })
+ );
+
+ log.info(`๐ send_scheduled_now: ${messageIds.length} messages sent in ${chatId}`);
+
+ return {
+ success: true,
+ data: {
+ chatId,
+ sentIds: messageIds,
+ sentCount: messageIds.length,
+ },
+ };
+ } catch (error: any) {
+ if (error.errorMessage === "MESSAGE_ID_INVALID") {
+ return {
+ success: false,
+ error: "One or more message IDs are invalid or not scheduled messages.",
+ };
+ }
+ log.error({ err: error }, "Error sending scheduled messages now");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/profile/index.ts b/src/agent/tools/telegram/profile/index.ts
index bc267d2..91e7c9f 100644
--- a/src/agent/tools/telegram/profile/index.ts
+++ b/src/agent/tools/telegram/profile/index.ts
@@ -1,14 +1,24 @@
import { telegramUpdateProfileTool, telegramUpdateProfileExecutor } from "./update-profile.js";
import { telegramSetBioTool, telegramSetBioExecutor } from "./set-bio.js";
import { telegramSetUsernameTool, telegramSetUsernameExecutor } from "./set-username.js";
+import {
+ telegramSetPersonalChannelTool,
+ telegramSetPersonalChannelExecutor,
+} from "./set-personal-channel.js";
import type { ToolEntry } from "../../types.js";
export { telegramUpdateProfileTool, telegramUpdateProfileExecutor };
export { telegramSetBioTool, telegramSetBioExecutor };
export { telegramSetUsernameTool, telegramSetUsernameExecutor };
+export { telegramSetPersonalChannelTool, telegramSetPersonalChannelExecutor };
export const tools: ToolEntry[] = [
{ tool: telegramUpdateProfileTool, executor: telegramUpdateProfileExecutor, scope: "dm-only" },
{ tool: telegramSetBioTool, executor: telegramSetBioExecutor, scope: "dm-only" },
{ tool: telegramSetUsernameTool, executor: telegramSetUsernameExecutor, scope: "dm-only" },
+ {
+ tool: telegramSetPersonalChannelTool,
+ executor: telegramSetPersonalChannelExecutor,
+ scope: "dm-only",
+ },
];
diff --git a/src/agent/tools/telegram/profile/set-bio.ts b/src/agent/tools/telegram/profile/set-bio.ts
index 5004bdc..2f13859 100644
--- a/src/agent/tools/telegram/profile/set-bio.ts
+++ b/src/agent/tools/telegram/profile/set-bio.ts
@@ -19,7 +19,7 @@ interface SetBioParams {
export const telegramSetBioTool: Tool = {
name: "telegram_set_bio",
description:
- "Set or update your Telegram bio (the 'About' section in your profile). This short text describes who you are or what you do, visible to anyone who views your profile. Max 70 characters. Use this to share a tagline, status, or brief description. Leave empty to remove bio entirely.",
+ "Set or update your Telegram bio (About section). Max 70 chars. Empty string to remove.",
parameters: Type.Object({
bio: Type.String({
description:
diff --git a/src/agent/tools/telegram/profile/set-personal-channel.ts b/src/agent/tools/telegram/profile/set-personal-channel.ts
new file mode 100644
index 0000000..f77a17a
--- /dev/null
+++ b/src/agent/tools/telegram/profile/set-personal-channel.ts
@@ -0,0 +1,75 @@
+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";
+
+const log = createLogger("Tools");
+
+interface SetPersonalChannelParams {
+ channelId?: string;
+}
+
+export const telegramSetPersonalChannelTool: Tool = {
+ name: "telegram_set_personal_channel",
+ description:
+ "Set or remove the personal channel displayed on your Telegram profile. Provide a channel ID to set, or omit to remove.",
+ parameters: Type.Object({
+ channelId: Type.Optional(
+ Type.String({
+ description: "Channel ID or username to set as personal channel. Omit to remove.",
+ })
+ ),
+ }),
+};
+
+export const telegramSetPersonalChannelExecutor: ToolExecutor = async (
+ params,
+ context
+): Promise => {
+ try {
+ const gramJsClient = context.bridge.getClient().getClient();
+
+ let channel: Api.TypeEntityLike;
+ let action: "set" | "removed";
+
+ if (params.channelId) {
+ channel = await gramJsClient.getEntity(params.channelId);
+ action = "set";
+ } else {
+ channel = new Api.InputChannelEmpty();
+ action = "removed";
+ }
+
+ await gramJsClient.invoke(new Api.account.UpdatePersonalChannel({ channel }));
+
+ log.info(`๐ค set_personal_channel: ${action} (${params.channelId || "empty"})`);
+
+ return {
+ success: true,
+ data: {
+ action,
+ channelId: params.channelId || null,
+ },
+ };
+ } catch (error: any) {
+ if (error.errorMessage === "CHANNEL_INVALID") {
+ return {
+ success: false,
+ error: "Invalid channel โ make sure you are an admin of this public channel.",
+ };
+ }
+ if (error.errorMessage === "CHANNELS_ADMIN_PUBLIC_TOO_MUCH") {
+ return {
+ success: false,
+ error: "You administer too many public channels to set a personal channel.",
+ };
+ }
+
+ log.error({ err: error }, "Error setting personal channel");
+ return {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ }
+};
diff --git a/src/agent/tools/telegram/profile/set-username.ts b/src/agent/tools/telegram/profile/set-username.ts
index acba4f8..662ca66 100644
--- a/src/agent/tools/telegram/profile/set-username.ts
+++ b/src/agent/tools/telegram/profile/set-username.ts
@@ -19,7 +19,7 @@ interface SetUsernameParams {
export const telegramSetUsernameTool: Tool = {
name: "telegram_set_username",
description:
- "Set or change your Telegram username (the @handle people use to find you). Usernames are unique across Telegram - must be 5-32 characters, alphanumeric plus underscores, and available. Use this to claim a memorable handle or update your public identifier. Empty string removes username. Warning: Changing username breaks existing t.me/username links.",
+ "Set or change your Telegram @username. Must be 5-32 chars, alphanumeric + underscores. Empty string removes it. Warning: breaks existing t.me/ links.",
parameters: Type.Object({
username: Type.String({
description:
diff --git a/src/agent/tools/telegram/profile/update-profile.ts b/src/agent/tools/telegram/profile/update-profile.ts
index 0a05537..df582b7 100644
--- a/src/agent/tools/telegram/profile/update-profile.ts
+++ b/src/agent/tools/telegram/profile/update-profile.ts
@@ -21,7 +21,7 @@ interface UpdateProfileParams {
export const telegramUpdateProfileTool: Tool = {
name: "telegram_update_profile",
description:
- "Update your Telegram profile information including first name, last name, and bio (about text). Changes are visible to all users who view your profile. Use this to keep your public identity current, reflect life changes, or update your description. Leave fields undefined to keep current values.",
+ "Update your profile (first name, last name, bio). Omit fields to keep current values.",
parameters: Type.Object({
firstName: Type.Optional(
Type.String({
diff --git a/src/agent/tools/telegram/stars/get-balance.ts b/src/agent/tools/telegram/stars/get-balance.ts
index ab2d189..87c769c 100644
--- a/src/agent/tools/telegram/stars/get-balance.ts
+++ b/src/agent/tools/telegram/stars/get-balance.ts
@@ -11,8 +11,7 @@ const log = createLogger("Tools");
*/
export const telegramGetStarsBalanceTool: Tool = {
name: "telegram_get_stars_balance",
- description:
- "Get your current Telegram Stars balance. Stars are Telegram's virtual currency used to buy gifts, tip creators, and purchase digital goods. Returns your total balance and any pending/withdrawable amounts.",
+ description: "Get your current Telegram Stars balance.",
category: "data-bearing",
parameters: Type.Object({}),
};
diff --git a/src/agent/tools/telegram/stars/get-transactions.ts b/src/agent/tools/telegram/stars/get-transactions.ts
index 4787569..fb506c5 100644
--- a/src/agent/tools/telegram/stars/get-transactions.ts
+++ b/src/agent/tools/telegram/stars/get-transactions.ts
@@ -20,8 +20,7 @@ interface GetTransactionsParams {
*/
export const telegramGetStarsTransactionsTool: Tool = {
name: "telegram_get_stars_transactions",
- description:
- "Get your Telegram Stars transaction history. Shows all purchases, gifts sent/received, and other Star movements. Can filter by inbound (received) or outbound (spent) transactions.",
+ description: "Get your Stars transaction history. Filterable by inbound/outbound.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
diff --git a/src/agent/tools/telegram/stickers/add-sticker-set.ts b/src/agent/tools/telegram/stickers/add-sticker-set.ts
index e1f7198..4ef07b4 100644
--- a/src/agent/tools/telegram/stickers/add-sticker-set.ts
+++ b/src/agent/tools/telegram/stickers/add-sticker-set.ts
@@ -18,8 +18,7 @@ interface AddStickerSetParams {
*/
export const telegramAddStickerSetTool: Tool = {
name: "telegram_add_sticker_set",
- description:
- "Add/install a sticker pack to your account by its short name. Once added, you can use the stickers from this pack in conversations. The short name is the part after t.me/addstickers/ in a sticker pack link, or can be found via telegram_search_stickers. Use this to build your sticker collection.",
+ description: "Install a sticker pack to your account by its short name.",
parameters: Type.Object({
shortName: Type.String({
description:
diff --git a/src/agent/tools/telegram/stickers/get-my-stickers.ts b/src/agent/tools/telegram/stickers/get-my-stickers.ts
index 5bd7105..a61a92a 100644
--- a/src/agent/tools/telegram/stickers/get-my-stickers.ts
+++ b/src/agent/tools/telegram/stickers/get-my-stickers.ts
@@ -20,7 +20,7 @@ interface GetMyStickersParams {
export const telegramGetMyStickersTool: Tool = {
name: "telegram_get_my_stickers",
description:
- "List all sticker packs that are installed/saved to your account. Returns your personal sticker collection with shortName, title, and count for each pack. Use this to see what stickers you already have before sending. To send a sticker from your collection: use telegram_send_sticker with the shortName + stickerIndex.",
+ "List all sticker packs installed on your account. Returns shortName, title, and count per pack.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
diff --git a/src/agent/tools/telegram/stickers/search-gifs.ts b/src/agent/tools/telegram/stickers/search-gifs.ts
index e6cc0de..10c18ce 100644
--- a/src/agent/tools/telegram/stickers/search-gifs.ts
+++ b/src/agent/tools/telegram/stickers/search-gifs.ts
@@ -20,7 +20,7 @@ interface SearchGifsParams {
export const telegramSearchGifsTool: Tool = {
name: "telegram_search_gifs",
description:
- "Search for GIF animations using Telegram's built-in GIF search (@gif bot). Returns GIF results with IDs and query_id. To send a GIF: 1) Use this tool to search, 2) Note the queryId and a result's id, 3) Use telegram_send_gif with queryId + resultId. This ensures proper sending via Telegram's inline bot system.",
+ "Search for GIFs via @gif bot. Returns queryId + result IDs needed by telegram_send_gif.",
parameters: Type.Object({
query: Type.String({
description: "Search query for GIFs. Example: 'happy', 'dancing', 'thumbs up', 'laughing'",
diff --git a/src/agent/tools/telegram/stickers/search-stickers.ts b/src/agent/tools/telegram/stickers/search-stickers.ts
index b2c240a..54a6768 100644
--- a/src/agent/tools/telegram/stickers/search-stickers.ts
+++ b/src/agent/tools/telegram/stickers/search-stickers.ts
@@ -20,7 +20,7 @@ interface SearchStickersParams {
export const telegramSearchStickersTool: Tool = {
name: "telegram_search_stickers",
description:
- "Search for sticker packs globally in Telegram's catalog by keyword or emoji. Returns both installed and uninstalled packs with their installation status. Use this to discover new packs or find specific ones. For a focused view of ONLY your installed packs, use telegram_get_my_stickers instead. Results include shortName and count. To send: telegram_send_sticker(chatId, stickerSetShortName, stickerIndex 0 to count-1).",
+ "Search sticker packs globally by keyword or emoji. Returns packs with shortName, count, and install status. For installed-only, use telegram_get_my_stickers.",
parameters: Type.Object({
query: Type.String({
description:
diff --git a/src/agent/tools/telegram/stories/send-story.ts b/src/agent/tools/telegram/stories/send-story.ts
index 219a5dc..f7f4c5f 100644
--- a/src/agent/tools/telegram/stories/send-story.ts
+++ b/src/agent/tools/telegram/stories/send-story.ts
@@ -25,8 +25,7 @@ interface SendStoryParams {
*/
export const telegramSendStoryTool: Tool = {
name: "telegram_send_story",
- description:
- "Post a story (ephemeral photo/video) to Telegram that disappears after 24 hours. Stories are displayed at the top of chats and provide high visibility. Use this for announcements, updates, or time-sensitive visual content. Supports photos (JPG, PNG) and videos (MP4).",
+ description: "Post a story (photo/video) that disappears after 24h. Supports JPG, PNG, MP4.",
parameters: Type.Object({
mediaPath: Type.String({
description:
diff --git a/src/agent/tools/telegram/tasks/create-scheduled-task.ts b/src/agent/tools/telegram/tasks/create-scheduled-task.ts
index 3ad5cf0..9f553c3 100644
--- a/src/agent/tools/telegram/tasks/create-scheduled-task.ts
+++ b/src/agent/tools/telegram/tasks/create-scheduled-task.ts
@@ -51,7 +51,7 @@ interface CreateScheduledTaskParams {
export const telegramCreateScheduledTaskTool: Tool = {
name: "telegram_create_scheduled_task",
description:
- "Create a scheduled task that will be executed at a specific time. The task will be stored in the database and a reminder message will be scheduled in Saved Messages. When the time comes, you'll receive the task context and can execute it with full agent capabilities. Supports both simple tool calls and complex multi-step tasks.",
+ "Schedule a task for future execution. Stores in DB and schedules a reminder in Saved Messages. Supports tool_call, agent_task payloads, or simple reminders. Can depend on other tasks.",
parameters: Type.Object({
description: Type.String({
description: "What the task is about (e.g., 'Check TON price and alert if > $5')",
diff --git a/src/agent/tools/ton/chart.ts b/src/agent/tools/ton/chart.ts
index 2ae757f..edece88 100644
--- a/src/agent/tools/ton/chart.ts
+++ b/src/agent/tools/ton/chart.ts
@@ -13,8 +13,7 @@ interface ChartParams {
export const tonChartTool: Tool = {
name: "ton_chart",
- description:
- "Get price history chart for TON or any jetton. Returns price points over a time period with stats (min, max, change %). Use token param for jettons (master contract address).",
+ description: "Get price history chart for TON or any jetton over a configurable time period.",
parameters: Type.Object({
token: Type.Optional(
Type.String({
diff --git a/src/agent/tools/ton/dex-quote.ts b/src/agent/tools/ton/dex-quote.ts
index 5a17ef7..6d1cd44 100644
--- a/src/agent/tools/ton/dex-quote.ts
+++ b/src/agent/tools/ton/dex-quote.ts
@@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
import { TonClient } from "@ton/ton";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
+import { getCachedTonClient } from "../../../ton/wallet-service.js";
import { StonApiClient } from "@ston-fi/api";
import { Factory, Asset, PoolType, ReadinessStatus } from "@dedust/sdk";
import { DEDUST_FACTORY_MAINNET, NATIVE_TON_ADDRESS } from "../dedust/constants.js";
@@ -35,7 +35,7 @@ interface DexQuoteResult {
export const dexQuoteTool: Tool = {
name: "dex_quote",
description:
- "Smart router that compares quotes from STON.fi and DeDust DEX to find the best price. Returns comparison table with expected outputs, fees, and recommends the best DEX for your swap. Use 'ton' for TON or jetton master address.",
+ "Compare swap quotes from STON.fi and DeDust to find the best price. Does not execute.",
category: "data-bearing",
parameters: Type.Object({
from_asset: Type.String({
@@ -227,8 +227,7 @@ export const dexQuoteExecutor: ToolExecutor = async (
const { from_asset, to_asset, amount, slippage = 0.01 } = params;
// Initialize TON client for DeDust
- const endpoint = await getCachedHttpEndpoint();
- const tonClient = new TonClient({ endpoint });
+ const tonClient = await getCachedTonClient();
const [stonfiQuote, dedustQuote] = await Promise.all([
getStonfiQuote(from_asset, to_asset, amount, slippage),
diff --git a/src/agent/tools/ton/get-address.ts b/src/agent/tools/ton/get-address.ts
index 151b5b0..bb59390 100644
--- a/src/agent/tools/ton/get-address.ts
+++ b/src/agent/tools/ton/get-address.ts
@@ -1,4 +1,5 @@
import { Type } from "@sinclair/typebox";
+import { Address } from "@ton/core";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
import { getWalletAddress } from "../../../ton/wallet-service.js";
import { getErrorMessage } from "../../../utils/errors.js";
@@ -7,8 +8,7 @@ import { createLogger } from "../../../utils/logger.js";
const log = createLogger("Tools");
export const tonGetAddressTool: Tool = {
name: "ton_get_address",
- description:
- "Get your TON wallet address. Returns the address where you can receive TON cryptocurrency.",
+ description: "Get your TON wallet address.",
parameters: Type.Object({}),
};
export const tonGetAddressExecutor: ToolExecutor<{}> = async (
@@ -25,11 +25,14 @@ export const tonGetAddressExecutor: ToolExecutor<{}> = async (
};
}
+ // Display wallet in non-bounceable (UQ...) format โ standard for user wallets
+ const friendly = Address.parse(address).toString({ bounceable: false });
+
return {
success: true,
data: {
- address,
- message: `Your TON wallet address: ${address}`,
+ address: friendly,
+ message: `Your TON wallet address: ${friendly}`,
},
};
} catch (error) {
diff --git a/src/agent/tools/ton/get-balance.ts b/src/agent/tools/ton/get-balance.ts
index abde5ad..8146a04 100644
--- a/src/agent/tools/ton/get-balance.ts
+++ b/src/agent/tools/ton/get-balance.ts
@@ -7,7 +7,7 @@ import { createLogger } from "../../../utils/logger.js";
const log = createLogger("Tools");
export const tonGetBalanceTool: Tool = {
name: "ton_get_balance",
- description: "Get your current TON wallet balance. Returns the balance in TON and nanoTON.",
+ description: "Get your current TON wallet balance.",
parameters: Type.Object({}),
category: "data-bearing",
};
diff --git a/src/agent/tools/ton/get-price.ts b/src/agent/tools/ton/get-price.ts
index 9e69a10..ef4fd99 100644
--- a/src/agent/tools/ton/get-price.ts
+++ b/src/agent/tools/ton/get-price.ts
@@ -7,7 +7,7 @@ import { createLogger } from "../../../utils/logger.js";
const log = createLogger("Tools");
export const tonPriceTool: Tool = {
name: "ton_price",
- description: "Get current TON cryptocurrency price in USD. Returns real-time market price.",
+ description: "Get current TON price in USD.",
category: "data-bearing",
parameters: Type.Object({}),
};
diff --git a/src/agent/tools/ton/get-transactions.ts b/src/agent/tools/ton/get-transactions.ts
index 01c3ddb..a0a6549 100644
--- a/src/agent/tools/ton/get-transactions.ts
+++ b/src/agent/tools/ton/get-transactions.ts
@@ -1,8 +1,7 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { TonClient } from "@ton/ton";
+import { getCachedTonClient } from "../../../ton/wallet-service.js";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
import { formatTransactions } from "../../../ton/format-transactions.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
@@ -14,8 +13,7 @@ interface GetTransactionsParams {
}
export const tonGetTransactionsTool: Tool = {
name: "ton_get_transactions",
- description:
- "Get transaction history for any TON address. Returns transactions with type (ton_received, ton_sent, jetton_received, jetton_sent, nft_received, nft_sent, gas_refund), amount, counterparty, and explorer link.",
+ description: "Get transaction history for any TON address.",
category: "data-bearing",
parameters: Type.Object({
address: Type.String({
@@ -47,8 +45,7 @@ export const tonGetTransactionsExecutor: ToolExecutor = a
};
}
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const transactions = await client.getTransactions(addressObj, {
limit: Math.min(limit, 50),
diff --git a/src/agent/tools/ton/jetton-balances.ts b/src/agent/tools/ton/jetton-balances.ts
index 73a0bad..7bd3ff6 100644
--- a/src/agent/tools/ton/jetton-balances.ts
+++ b/src/agent/tools/ton/jetton-balances.ts
@@ -27,8 +27,7 @@ interface JettonBalance {
}
export const jettonBalancesTool: Tool = {
name: "jetton_balances",
- description:
- "Get all Jetton (token) balances owned by the agent. Returns a list of all tokens with their balances, names, symbols, and verification status. Useful to check what tokens you currently hold.",
+ description: "Get all jetton balances owned by the agent. Filters out blacklisted tokens.",
parameters: Type.Object({}),
category: "data-bearing",
};
diff --git a/src/agent/tools/ton/jetton-history.ts b/src/agent/tools/ton/jetton-history.ts
index 3c1d575..8d03181 100644
--- a/src/agent/tools/ton/jetton-history.ts
+++ b/src/agent/tools/ton/jetton-history.ts
@@ -13,8 +13,7 @@ interface JettonHistoryParams {
export const jettonHistoryTool: Tool = {
name: "jetton_history",
- description:
- "Get price history and performance data for a Jetton. Shows price changes over 24h, 7d, 30d periods, along with volume and market data. Useful for analyzing token trends.",
+ description: "Get jetton price history: 24h/7d/30d changes, volume, FDV, and holder count.",
category: "data-bearing",
parameters: Type.Object({
jetton_address: Type.String({
diff --git a/src/agent/tools/ton/jetton-holders.ts b/src/agent/tools/ton/jetton-holders.ts
index e0792bb..52161a0 100644
--- a/src/agent/tools/ton/jetton-holders.ts
+++ b/src/agent/tools/ton/jetton-holders.ts
@@ -11,8 +11,7 @@ interface JettonHoldersParams {
}
export const jettonHoldersTool: Tool = {
name: "jetton_holders",
- description:
- "Get the top holders of a Jetton (token). Shows wallet addresses and their balances. Useful to analyze token distribution and identify whale wallets.",
+ description: "Get top holders of a jetton with their balances.",
category: "data-bearing",
parameters: Type.Object({
jetton_address: Type.String({
diff --git a/src/agent/tools/ton/jetton-info.ts b/src/agent/tools/ton/jetton-info.ts
index e633f86..6172c9c 100644
--- a/src/agent/tools/ton/jetton-info.ts
+++ b/src/agent/tools/ton/jetton-info.ts
@@ -13,7 +13,7 @@ interface JettonInfoParams {
export const jettonInfoTool: Tool = {
name: "jetton_info",
description:
- "Get detailed information about a Jetton (token) by its master contract address. Returns name, symbol, decimals, total supply, holders count, and verification status. Useful to research a token before buying or sending.",
+ "Get jetton metadata: name, symbol, decimals, total supply, holders, verification status.",
category: "data-bearing",
parameters: Type.Object({
jetton_address: Type.String({
diff --git a/src/agent/tools/ton/jetton-price.ts b/src/agent/tools/ton/jetton-price.ts
index 2bfdaf8..e8885d9 100644
--- a/src/agent/tools/ton/jetton-price.ts
+++ b/src/agent/tools/ton/jetton-price.ts
@@ -12,8 +12,7 @@ interface JettonPriceParams {
export const jettonPriceTool: Tool = {
name: "jetton_price",
- description:
- "Get the current price of a Jetton (token) in USD and TON, along with 24h, 7d, and 30d price changes. Useful to check token value before swapping or to monitor investments.",
+ description: "Get current jetton price in USD/TON with 24h, 7d, 30d changes.",
category: "data-bearing",
parameters: Type.Object({
jetton_address: Type.String({
diff --git a/src/agent/tools/ton/jetton-send.ts b/src/agent/tools/ton/jetton-send.ts
index f4ed98e..53fd8e5 100644
--- a/src/agent/tools/ton/jetton-send.ts
+++ b/src/agent/tools/ton/jetton-send.ts
@@ -1,12 +1,12 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton";
+import { loadWallet, getKeyPair, getCachedTonClient } from "../../../ton/wallet-service.js";
+import { WalletContractV5R1, toNano, internal } from "@ton/ton";
import { Address, SendMode, beginCell } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.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";
const log = createLogger("Tools");
@@ -21,7 +21,7 @@ interface JettonSendParams {
export const jettonSendTool: Tool = {
name: "jetton_send",
description:
- "Send Jettons (tokens) to another address. Requires the jetton master address, recipient address, and amount. Amount is in human-readable units (e.g., 10 for 10 USDT). Use jetton_balances first to see what tokens you own and their addresses.",
+ "Send jettons to another address. Amount in human-readable units. Use jetton_balances first to find addresses.",
parameters: Type.Object({
jetton_address: Type.String({
description: "Jetton master contract address (EQ... or 0:... format)",
@@ -31,7 +31,7 @@ export const jettonSendTool: Tool = {
}),
amount: Type.Number({
description: "Amount to send in human-readable units (e.g., 10 for 10 tokens)",
- minimum: 0,
+ exclusiveMinimum: 0,
}),
comment: Type.Optional(
Type.String({
@@ -65,7 +65,9 @@ export const jettonSendExecutor: ToolExecutor = async (
}
// Get sender's jetton wallet address from TonAPI
- const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`);
+ const jettonsResponse = await tonapiFetch(
+ `/accounts/${encodeURIComponent(walletData.address)}/jettons`
+ );
if (!jettonsResponse.ok) {
return {
@@ -76,12 +78,17 @@ export const jettonSendExecutor: ToolExecutor = async (
const jettonsData = await jettonsResponse.json();
- // Find the jetton in our balances
- const jettonBalance = jettonsData.balances?.find(
- (b: any) =>
- b.jetton.address.toLowerCase() === jetton_address.toLowerCase() ||
- Address.parse(b.jetton.address).toString() === Address.parse(jetton_address).toString()
- );
+ // Find the jetton in our balances (safe: skip entries with malformed addresses)
+ const jettonBalance = jettonsData.balances?.find((b: any) => {
+ if (b.jetton.address.toLowerCase() === jetton_address.toLowerCase()) return true;
+ try {
+ return (
+ Address.parse(b.jetton.address).toString() === Address.parse(jetton_address).toString()
+ );
+ } catch {
+ return false;
+ }
+ });
if (!jettonBalance) {
return {
@@ -127,8 +134,8 @@ export const jettonSendExecutor: ToolExecutor = async (
.storeAddress(Address.parse(walletData.address)) // response_destination (excess returns here)
.storeBit(false) // no custom_payload
.storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount (for notification)
- .storeBit(comment ? true : false) // forward_payload flag
- .storeMaybeRef(comment ? forwardPayload : null) // forward_payload
+ .storeBit(comment ? 1 : 0) // forward_payload: Either tag (0=inline, 1=ref)
+ .storeRef(comment ? forwardPayload : beginCell().endCell()) // forward_payload
.endCell();
const keyPair = await getKeyPair();
@@ -140,39 +147,40 @@ export const jettonSendExecutor: ToolExecutor = async (
publicKey: keyPair.publicKey,
});
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const walletContract = client.open(wallet);
- const seqno = await walletContract.getSeqno();
-
- // Send transfer to our jetton wallet (NOT to recipient!)
- await walletContract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(senderJettonWallet),
- value: toNano("0.05"), // Gas for jetton transfer
- body: messageBody,
- bounce: true,
- }),
- ],
- });
+ return withTxLock(async () => {
+ const seqno = await walletContract.getSeqno();
+
+ // Send transfer to our jetton wallet (NOT to recipient!)
+ await walletContract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: Address.parse(senderJettonWallet),
+ value: toNano("0.05"), // Gas for jetton transfer
+ body: messageBody,
+ bounce: true,
+ }),
+ ],
+ });
- return {
- success: true,
- data: {
- jetton: symbol,
- jettonAddress: jetton_address,
- amount: amount.toString(),
- to,
- from: walletData.address,
- comment: comment || null,
- message: `Sent ${amount} ${symbol} to ${to}${comment ? ` (${comment})` : ""}\n Transaction sent (check balance in ~30 seconds)`,
- },
- };
+ return {
+ success: true,
+ data: {
+ jetton: symbol,
+ jettonAddress: jetton_address,
+ amount: amount.toString(),
+ to,
+ from: walletData.address,
+ comment: comment || null,
+ message: `Sent ${amount} ${symbol} to ${to}${comment ? ` (${comment})` : ""}\n Transaction sent (check balance in ~30 seconds)`,
+ },
+ };
+ });
} catch (error) {
log.error({ err: error }, "Error in jetton_send");
return {
diff --git a/src/agent/tools/ton/my-transactions.ts b/src/agent/tools/ton/my-transactions.ts
index e0ec943..2448f34 100644
--- a/src/agent/tools/ton/my-transactions.ts
+++ b/src/agent/tools/ton/my-transactions.ts
@@ -1,9 +1,7 @@
import { Type } from "@sinclair/typebox";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet } from "../../../ton/wallet-service.js";
-import { TonClient } from "@ton/ton";
+import { loadWallet, getCachedTonClient } from "../../../ton/wallet-service.js";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
import { formatTransactions } from "../../../ton/format-transactions.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
@@ -16,8 +14,7 @@ interface MyTransactionsParams {
export const tonMyTransactionsTool: Tool = {
name: "ton_my_transactions",
- description:
- "Get your own wallet's transaction history. Returns transactions with type (ton_received, ton_sent, jetton_received, jetton_sent, nft_received, nft_sent, gas_refund), amount, counterparty, and explorer link.",
+ description: "Get your own wallet's transaction history.",
category: "data-bearing",
parameters: Type.Object({
limit: Type.Optional(
@@ -47,8 +44,7 @@ export const tonMyTransactionsExecutor: ToolExecutor = asy
const addressObj = Address.parse(walletData.address);
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const transactions = await client.getTransactions(addressObj, {
limit: Math.min(limit, 50),
diff --git a/src/agent/tools/ton/send.ts b/src/agent/tools/ton/send.ts
index bbb81a8..f32c0b2 100644
--- a/src/agent/tools/ton/send.ts
+++ b/src/agent/tools/ton/send.ts
@@ -1,9 +1,8 @@
import { Type } from "@sinclair/typebox";
+import { Address } from "@ton/core";
import type { Tool, ToolExecutor, ToolResult } from "../types.js";
-import { loadWallet, getKeyPair } from "../../../ton/wallet-service.js";
-import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton";
-import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "../../../ton/endpoint.js";
+import { loadWallet } from "../../../ton/wallet-service.js";
+import { sendTon } from "../../../ton/transfer.js";
import { getErrorMessage } from "../../../utils/errors.js";
import { createLogger } from "../../../utils/logger.js";
@@ -16,10 +15,11 @@ interface SendParams {
export const tonSendTool: Tool = {
name: "ton_send",
description:
- "Send TON cryptocurrency to an address. Requires wallet to be initialized. Amount is in TON (not nanoTON). Example: amount 1.5 = 1.5 TON. Always confirm the transaction details before sending.",
+ "Send TON to an address. Amount in TON (not nanoTON). Use a REAL address from the user or from ton_address_book โ never guess addresses. Confirm details before sending.",
parameters: Type.Object({
to: Type.String({
- description: "Recipient TON address (EQ... or UQ... format)",
+ description:
+ "Recipient TON address (EQ... or UQ... format). Must be a real, valid address โ do not fabricate.",
}),
amount: Type.Number({
description: "Amount to send in TON (e.g., 1.5 for 1.5 TON)",
@@ -34,11 +34,21 @@ export const tonSendTool: Tool = {
};
export const tonSendExecutor: ToolExecutor = async (
params,
- context
+ _context
): Promise => {
try {
const { to, amount, comment } = params;
+ // Validate address format before attempting transfer
+ try {
+ Address.parse(to);
+ } catch {
+ return {
+ success: false,
+ error: `Invalid recipient address: ${to}. TON addresses must have a valid checksum. Ask the user for the correct address.`,
+ };
+ }
+
const walletData = loadWallet();
if (!walletData) {
return {
@@ -47,47 +57,15 @@ export const tonSendExecutor: ToolExecutor = async (
};
}
- try {
- Address.parse(to);
- } catch (e) {
+ const txRef = await sendTon({ toAddress: to, amount, comment });
+
+ if (!txRef) {
return {
success: false,
- error: `Invalid recipient address: ${to}`,
+ error: "TON transfer failed โ check blockchain node connectivity.",
};
}
- const keyPair = await getKeyPair();
- if (!keyPair) {
- return { success: false, error: "Wallet key derivation failed." };
- }
-
- const wallet = WalletContractV5R1.create({
- workchain: 0,
- publicKey: keyPair.publicKey,
- });
-
- // Get decentralized endpoint from orbs network (no rate limits)
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
-
- const contract = client.open(wallet);
-
- const seqno = await contract.getSeqno();
-
- await contract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY,
- messages: [
- internal({
- to: Address.parse(to),
- value: toNano(amount),
- body: comment || "",
- bounce: false,
- }),
- ],
- });
-
return {
success: true,
data: {
diff --git a/src/agent/tools/web/fetch.ts b/src/agent/tools/web/fetch.ts
index 965e7b8..554c991 100644
--- a/src/agent/tools/web/fetch.ts
+++ b/src/agent/tools/web/fetch.ts
@@ -16,14 +16,7 @@ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
export const webFetchTool: Tool = {
name: "web_fetch",
- description: `Fetch a web page and extract its readable text content using Tavily Extract.
-
-Returns clean, readable text extracted from the page โ ideal for reading articles, docs, or links shared by users.
-Only http/https URLs are allowed. Content is truncated to max_length characters.
-
-Examples:
-- url="https://docs.ton.org/develop/overview"
-- url="https://example.com/article", max_length=10000`,
+ description: "Fetch a web page and extract readable text. HTTP/HTTPS only.",
category: "data-bearing",
parameters: Type.Object({
url: Type.String({ description: "URL to fetch (http or https only)" }),
diff --git a/src/agent/tools/web/search.ts b/src/agent/tools/web/search.ts
index 83c1b67..3a172f6 100644
--- a/src/agent/tools/web/search.ts
+++ b/src/agent/tools/web/search.ts
@@ -15,18 +15,7 @@ interface WebSearchParams {
export const webSearchTool: Tool = {
name: "web_search",
- description: `Search the web using Tavily. Returns results with title, URL, content snippet, and relevance score.
-
-Use this to find up-to-date information, verify facts, research topics, or get news.
-
-Parameters:
-- query: search query string
-- count: number of results (default 5, max ${WEB_SEARCH_MAX_RESULTS})
-- topic: "general" (default), "news", or "finance"
-
-Examples:
-- query="TON blockchain latest news", topic="news"
-- query="bitcoin price today", count=3, topic="finance"`,
+ description: "Search the web. Returns results with title, URL, and content snippet.",
category: "data-bearing",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
diff --git a/src/agent/tools/workspace/delete.ts b/src/agent/tools/workspace/delete.ts
index 7297ac8..cffd011 100644
--- a/src/agent/tools/workspace/delete.ts
+++ b/src/agent/tools/workspace/delete.ts
@@ -23,16 +23,8 @@ const PROTECTED_WORKSPACE_FILES = [
export const workspaceDeleteTool: Tool = {
name: "workspace_delete",
- description: `Delete a file or directory from your workspace.
-
-PROTECTED FILES (cannot delete):
-- SOUL.md, MEMORY.md, IDENTITY.md, USER.md
-
-You CAN delete:
-- Files in memory/, downloads/, uploads/, temp/
-- Custom files you've created
-
-Use recursive=true to delete non-empty directories.`,
+ description:
+ "Delete a file or directory from workspace. Cannot delete SOUL.md, MEMORY.md, IDENTITY.md, USER.md.",
parameters: Type.Object({
path: Type.String({
diff --git a/src/agent/tools/workspace/info.ts b/src/agent/tools/workspace/info.ts
index 13e5fc5..17a22b9 100644
--- a/src/agent/tools/workspace/info.ts
+++ b/src/agent/tools/workspace/info.ts
@@ -15,13 +15,7 @@ interface WorkspaceInfoParams {
export const workspaceInfoTool: Tool = {
name: "workspace_info",
- description: `Get information about your workspace structure and usage.
-
-Returns:
-- Workspace root path
-- Directory structure
-- File counts and sizes
-- Usage limits`,
+ description: "Get workspace structure, file counts, sizes, and usage limits.",
category: "data-bearing",
parameters: Type.Object({
detailed: Type.Optional(
diff --git a/src/agent/tools/workspace/list.ts b/src/agent/tools/workspace/list.ts
index 35cb922..5d74c6c 100644
--- a/src/agent/tools/workspace/list.ts
+++ b/src/agent/tools/workspace/list.ts
@@ -19,16 +19,7 @@ interface WorkspaceListParams {
export const workspaceListTool: Tool = {
name: "workspace_list",
- description: `List files and directories in your workspace.
-
-Your workspace is at ~/.teleton/workspace/ and contains:
-- SOUL.md, MEMORY.md, IDENTITY.md (config files)
-- memory/ (daily logs)
-- downloads/ (downloaded media)
-- uploads/ (files to send)
-- temp/ (temporary files)
-
-You can ONLY access files within this workspace. Files outside (config.yaml, wallet.json, etc.) are protected.`,
+ description: "List files and directories in the workspace.",
category: "data-bearing",
parameters: Type.Object({
path: Type.Optional(
diff --git a/src/agent/tools/workspace/read.ts b/src/agent/tools/workspace/read.ts
index 27df65d..e6a67f9 100644
--- a/src/agent/tools/workspace/read.ts
+++ b/src/agent/tools/workspace/read.ts
@@ -14,18 +14,8 @@ interface WorkspaceReadParams {
export const workspaceReadTool: Tool = {
name: "workspace_read",
- description: `Read a file from your workspace.
-
-You can ONLY read files within ~/.teleton/workspace/. Protected files like config.yaml, wallet.json, and telegram_session.txt are NOT accessible.
-
-Supported files:
-- Text files (.md, .txt, .json, .csv)
-- Use encoding="base64" for binary files
-
-Examples:
-- Read your memory: path="MEMORY.md"
-- Read today's log: path="memory/2024-01-15.md"
-- Read downloaded image info: path="downloads/image.jpg" (will return metadata only)`,
+ description:
+ "Read a file from workspace. Only ~/.teleton/workspace/ is accessible. Use encoding='base64' for binary files.",
category: "data-bearing",
parameters: Type.Object({
path: Type.String({
diff --git a/src/agent/tools/workspace/rename.ts b/src/agent/tools/workspace/rename.ts
index 8a34c33..b9a18c9 100644
--- a/src/agent/tools/workspace/rename.ts
+++ b/src/agent/tools/workspace/rename.ts
@@ -16,19 +16,7 @@ interface WorkspaceRenameParams {
export const workspaceRenameTool: Tool = {
name: "workspace_rename",
- description: `Rename or move a file within your workspace.
-
-Use this to:
-- Give meaningful names to downloaded files
-- Organize files into subdirectories
-- Rename Telegram downloads (default names like "123_456_789.jpg" are hard to track)
-
-Examples:
-- Rename: from="downloads/123_456_789.jpg", to="downloads/alice_profile.jpg"
-- Move: from="downloads/photo.jpg", to="uploads/photo.jpg"
-- Organize: from="downloads/doc.pdf", to="downloads/contracts/2026/lease.pdf"
-
-CANNOT move/rename files outside workspace or to protected locations.`,
+ description: "Rename or move a file within workspace. Creates parent directories as needed.",
parameters: Type.Object({
from: Type.String({
diff --git a/src/agent/tools/workspace/write.ts b/src/agent/tools/workspace/write.ts
index 9a125a6..e06d4c1 100644
--- a/src/agent/tools/workspace/write.ts
+++ b/src/agent/tools/workspace/write.ts
@@ -18,18 +18,8 @@ interface WorkspaceWriteParams {
export const workspaceWriteTool: Tool = {
name: "workspace_write",
- description: `Write a file to your workspace.
-
-You can ONLY write files within ~/.teleton/workspace/. This includes:
-- memory/ - Daily logs and notes
-- uploads/ - Files to send
-- temp/ - Temporary files
-
-You CANNOT write to protected locations like config.yaml, wallet.json, etc.
-
-Examples:
-- Save a note: path="memory/note.md", content="..."
-- Prepare upload: path="uploads/message.txt", content="..."`,
+ description:
+ "Write a file to workspace. Only ~/.teleton/workspace/ is writable. Cannot write to protected locations.",
parameters: Type.Object({
path: Type.String({
diff --git a/src/bot/gramjs-bot.ts b/src/bot/gramjs-bot.ts
index b0cb530..9cc5e8a 100644
--- a/src/bot/gramjs-bot.ts
+++ b/src/bot/gramjs-bot.ts
@@ -14,7 +14,10 @@ import { TelegramClient, Api } from "telegram";
import { StringSession } from "telegram/sessions/index.js";
import { Logger, LogLevel } from "telegram/extensions/Logger.js";
import bigInt from "big-integer";
-import { GRAMJS_RETRY_DELAY_MS } from "../constants/timeouts.js";
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
+import { dirname } from "path";
+import { GRAMJS_RETRY_DELAY_MS, GRAMJS_CONNECT_RETRY_DELAY_MS } from "../constants/timeouts.js";
+import { TELEGRAM_CONNECTION_RETRIES } from "../constants/limits.js";
import { withFloodRetry } from "../telegram/flood-retry.js";
import { createLogger } from "../utils/logger.js";
@@ -51,10 +54,13 @@ export function decodeInlineMessageId(encoded: string): Api.TypeInputBotInlineMe
export class GramJSBotClient {
private client: TelegramClient;
private connected = false;
+ private sessionPath: string | undefined;
- constructor(apiId: number, apiHash: string) {
+ constructor(apiId: number, apiHash: string, sessionPath?: string) {
+ this.sessionPath = sessionPath;
+ const sessionString = this.loadSession();
const logger = new Logger(LogLevel.NONE);
- this.client = new TelegramClient(new StringSession(""), apiId, apiHash, {
+ this.client = new TelegramClient(new StringSession(sessionString), apiId, apiHash, {
connectionRetries: 3,
retryDelay: GRAMJS_RETRY_DELAY_MS,
autoReconnect: true,
@@ -62,17 +68,58 @@ export class GramJSBotClient {
});
}
+ private loadSession(): string {
+ if (!this.sessionPath) return "";
+ try {
+ if (existsSync(this.sessionPath)) {
+ return readFileSync(this.sessionPath, "utf-8").trim();
+ }
+ } catch (error) {
+ log.warn({ err: error }, "[GramJS Bot] Failed to load session");
+ }
+ return "";
+ }
+
+ private saveSession(): void {
+ if (!this.sessionPath) return;
+ try {
+ const sessionString = this.client.session.save() as string | undefined;
+ if (typeof sessionString !== "string" || !sessionString) return;
+ const dir = dirname(this.sessionPath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+ writeFileSync(this.sessionPath, sessionString, { encoding: "utf-8", mode: 0o600 });
+ log.debug("[GramJS Bot] Session saved");
+ } catch (error) {
+ log.error({ err: error }, "[GramJS Bot] Failed to save session");
+ }
+ }
+
/**
- * Connect and authenticate as bot via MTProto
+ * Connect and authenticate as bot via MTProto.
+ * Retries on transient -500 "No workers running" errors (DC overload).
*/
async connect(botToken: string): Promise {
- try {
- await this.client.start({ botAuthToken: botToken });
- this.connected = true;
- // Styled buttons ready (MTProto connected)
- } catch (error) {
- log.error({ err: error }, "[GramJS Bot] Connection failed");
- throw error;
+ for (let attempt = 1; attempt <= TELEGRAM_CONNECTION_RETRIES; attempt++) {
+ try {
+ await this.client.start({ botAuthToken: botToken });
+ this.connected = true;
+ this.saveSession();
+ return;
+ } catch (error: any) {
+ const isTransient = error?.code === -500;
+ if (isTransient && attempt < TELEGRAM_CONNECTION_RETRIES) {
+ const delay = GRAMJS_CONNECT_RETRY_DELAY_MS * attempt;
+ log.warn(
+ `[GramJS Bot] Transient -500 error, retrying in ${delay / 1000}s (attempt ${attempt}/${TELEGRAM_CONNECTION_RETRIES})`
+ );
+ await new Promise((r) => setTimeout(r, delay));
+ continue;
+ }
+ log.error({ err: error }, "[GramJS Bot] Connection failed");
+ throw error;
+ }
}
}
@@ -88,7 +135,7 @@ export class GramJSBotClient {
results: Api.TypeInputBotInlineResult[];
cacheTime?: number;
}): Promise {
- if (!this.connected) throw new Error("GramJS bot not connected");
+ if (!this.isConnected()) throw new Error("GramJS bot not connected");
await withFloodRetry(() =>
this.client.invoke(
@@ -111,7 +158,7 @@ export class GramJSBotClient {
entities?: Api.TypeMessageEntity[];
replyMarkup?: Api.TypeReplyMarkup;
}): Promise {
- if (!this.connected) throw new Error("GramJS bot not connected");
+ if (!this.isConnected()) throw new Error("GramJS bot not connected");
const id = decodeInlineMessageId(params.inlineMessageId);
const dcId = "dcId" in id ? (id.dcId as number) : undefined;
diff --git a/src/bot/index.ts b/src/bot/index.ts
index 10b04c8..cd0bc66 100644
--- a/src/bot/index.ts
+++ b/src/bot/index.ts
@@ -54,7 +54,7 @@ export class DealBot {
this.bot = new Bot(config.token);
if (config.apiId && config.apiHash) {
- this.gramjsBot = new GramJSBotClient(config.apiId, config.apiHash);
+ this.gramjsBot = new GramJSBotClient(config.apiId, config.apiHash, config.gramjsSessionPath);
}
this.setupHandlers();
diff --git a/src/bot/types.ts b/src/bot/types.ts
index a8705e9..e40aac8 100644
--- a/src/bot/types.ts
+++ b/src/bot/types.ts
@@ -7,6 +7,7 @@ export interface BotConfig {
username: string;
apiId?: number;
apiHash?: string;
+ gramjsSessionPath?: string;
}
export interface DealContext {
diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts
index c4aac2d..2da5ae1 100644
--- a/src/cli/commands/onboard.ts
+++ b/src/cli/commands/onboard.ts
@@ -36,6 +36,7 @@ import { TELETON_ROOT } from "../../workspace/paths.js";
import { TelegramUserClient } from "../../telegram/client.js";
import YAML from "yaml";
import { type Config, DealsConfigSchema } from "../../config/schema.js";
+import { getModelsForProvider } from "../../config/model-catalog.js";
import {
generateWallet,
importWallet,
@@ -52,6 +53,10 @@ import {
import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../../constants/limits.js";
import { fetchWithTimeout } from "../../utils/fetch.js";
import ora from "ora";
+import {
+ getClaudeCodeApiKey,
+ isClaudeCodeTokenValid,
+} from "../../providers/claude-code-credentials.js";
export interface OnboardOptions {
workspace?: string;
@@ -71,12 +76,12 @@ export interface OnboardOptions {
// โโ Progress steps โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const STEPS: StepDef[] = [
- { label: "Agent", desc: "Name & mode" },
- { label: "Provider", desc: "LLM & API key" },
- { label: "Telegram", desc: "Credentials" },
- { label: "Config", desc: "Model & policies" },
- { label: "Modules", desc: "Optional features" },
+ { label: "Agent", desc: "Name" },
+ { label: "Provider", desc: "LLM, key & model" },
+ { label: "Config", desc: "Policies" },
+ { label: "Modules", desc: "Optional API keys" },
{ label: "Wallet", desc: "TON blockchain" },
+ { label: "Telegram", desc: "Credentials" },
{ label: "Connect", desc: "Telegram auth" },
];
@@ -93,108 +98,7 @@ function sleep(ms: number): Promise {
return new Promise((r) => setTimeout(r, ms));
}
-// โโ Model catalogs (per provider) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const MODEL_OPTIONS: Record> = {
- anthropic: [
- {
- value: "claude-opus-4-5-20251101",
- name: "Claude Opus 4.5",
- description: "Most capable, $5/M",
- },
- { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" },
- {
- value: "claude-haiku-4-5-20251001",
- name: "Claude Haiku 4.5",
- description: "Fast & cheap, $1/M",
- },
- {
- value: "claude-3-5-haiku-20241022",
- name: "Claude 3.5 Haiku",
- description: "Cheapest, $0.80/M",
- },
- ],
- openai: [
- { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" },
- { 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: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" },
- ],
- google: [
- { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" },
- {
- value: "gemini-2.5-pro",
- name: "Gemini 2.5 Pro",
- description: "Most capable, 1M ctx, $1.25/M",
- },
- { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" },
- ],
- xai: [
- { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" },
- { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" },
- { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" },
- ],
- groq: [
- {
- value: "meta-llama/llama-4-maverick-17b-128e-instruct",
- name: "Llama 4 Maverick",
- description: "Vision, 131K ctx, $0.20/M",
- },
- { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" },
- {
- value: "deepseek-r1-distill-llama-70b",
- name: "DeepSeek R1 70B",
- description: "Reasoning, 131K ctx, $0.75/M",
- },
- {
- value: "llama-3.3-70b-versatile",
- name: "Llama 3.3 70B",
- description: "General purpose, 131K ctx, $0.59/M",
- },
- ],
- openrouter: [
- { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" },
- { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" },
- { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" },
- {
- value: "deepseek/deepseek-r1",
- name: "DeepSeek R1",
- description: "Reasoning, 64K ctx, $0.70/M",
- },
- { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" },
- ],
- moonshot: [
- { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" },
- {
- value: "kimi-k2-thinking",
- name: "Kimi K2 Thinking",
- description: "Free, 256K ctx, reasoning",
- },
- ],
- mistral: [
- {
- value: "devstral-small-2507",
- name: "Devstral Small",
- description: "Coding, 128K ctx, $0.10/M",
- },
- {
- value: "devstral-medium-latest",
- name: "Devstral Medium",
- description: "Coding, 262K ctx, $0.40/M",
- },
- {
- value: "mistral-large-latest",
- name: "Mistral Large",
- description: "General, 128K ctx, $2/M",
- },
- {
- value: "magistral-small",
- name: "Magistral Small",
- description: "Reasoning, 128K ctx, $0.50/M",
- },
- ],
-};
+// Model catalog imported from shared source (see src/config/model-catalog.ts)
/**
* Main onboard command
@@ -274,9 +178,7 @@ async function runInteractiveOnboarding(
prompter: ReturnType
): Promise {
// โโ Shared state โโ
- let selectedFlow: "quick" | "advanced" = "quick";
let selectedProvider: SupportedProvider = "anthropic";
- const dealsEnabled = true;
let selectedModel = "";
let apiKey = "";
let apiId = 0;
@@ -284,16 +186,15 @@ async function runInteractiveOnboarding(
let phone = "";
let userId = 0;
let tonapiKey: string | undefined;
+ let toncenterApiKey: string | undefined;
let tavilyApiKey: string | undefined;
let botToken: string | undefined;
let botUsername: string | undefined;
- let dmPolicy: "open" | "allowlist" | "pairing" | "disabled" = "open";
+ let dmPolicy: "open" | "allowlist" | "disabled" = "open";
let groupPolicy: "open" | "allowlist" | "disabled" = "open";
let requireMention = true;
let maxAgenticIterations = "5";
let cocoonInstance = 10000;
- let buyMaxFloorPercent = 100;
- let sellMinFloorPercent = 105;
// Intro
console.clear();
@@ -338,6 +239,7 @@ async function runInteractiveOnboarding(
const workspace = await ensureWorkspace({
workspaceDir: options.workspace,
ensureTemplates: true,
+ silent: true,
});
const isNew = isNewWorkspace(workspace);
spinner.succeed(DIM(`Workspace: ${workspace.root}`));
@@ -368,22 +270,7 @@ async function runInteractiveOnboarding(
writeFileSync(workspace.identityPath, updated, "utf-8");
}
- // Installation mode
- selectedFlow = await select({
- message: "Installation mode",
- default: "quick",
- theme,
- choices: [
- {
- value: "quick" as const,
- name: "โก QuickStart",
- description: "Minimal configuration (recommended)",
- },
- { value: "advanced" as const, name: "โ Advanced", description: "Detailed configuration" },
- ],
- });
-
- STEPS[0].value = `${agentName} (${selectedFlow})`;
+ STEPS[0].value = agentName;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 1: Provider โ select + tool limit warning + API key
@@ -469,6 +356,58 @@ 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 (err) {
+ if (err instanceof CancelledError) throw err;
+ 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 {
// Standard providers โ API key required
const envApiKey = process.env.TELETON_API_KEY;
@@ -504,60 +443,44 @@ async function runInteractiveOnboarding(
STEPS[1].value = `${providerMeta.displayName} ${DIM(maskedKey)}`;
}
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Step 2: Telegram โ credentials
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- redraw(2);
+ // Model selection (advanced mode only, after provider + API key)
+ selectedModel = providerMeta.defaultModel;
- noteBox(
- "You need Telegram credentials from https://my.telegram.org/apps\n" +
- "Create an application and note the API ID and API Hash",
- "Telegram",
- TON
- );
+ if (selectedProvider !== "cocoon" && selectedProvider !== "local") {
+ const providerModels = getModelsForProvider(selectedProvider);
+ const modelChoices = [
+ ...providerModels,
+ { value: "__custom__", name: "Custom", description: "Enter a model ID manually" },
+ ];
- const envApiId = process.env.TELETON_TG_API_ID;
- const envApiHash = process.env.TELETON_TG_API_HASH;
- const envPhone = process.env.TELETON_TG_PHONE;
+ const modelChoice = await select({
+ message: "Model",
+ default: providerMeta.defaultModel,
+ theme,
+ choices: modelChoices,
+ });
- const apiIdStr = options.apiId
- ? options.apiId.toString()
- : await input({
- message: envApiId ? "API ID (from env)" : "API ID (from my.telegram.org)",
- default: envApiId,
+ if (modelChoice === "__custom__") {
+ const customModel = await input({
+ message: "Model ID",
+ default: providerMeta.defaultModel,
theme,
- validate: (value) => {
- if (!value || isNaN(parseInt(value))) return "Invalid API ID (must be a number)";
- return true;
- },
});
- apiId = parseInt(apiIdStr);
+ if (customModel?.trim()) selectedModel = customModel.trim();
+ } else {
+ selectedModel = modelChoice;
+ }
- 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 modelLabel = providerModels.find((m) => m.value === selectedModel)?.name ?? selectedModel;
+ STEPS[1].value = `${STEPS[1].value ?? providerMeta.displayName}, ${modelLabel}`;
+ }
- 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 2: Config โ admin + policies
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ redraw(2);
- // User ID
+ // Admin User ID
noteBox(
"To get your Telegram User ID:\n" +
"1. Open @userinfobot on Telegram\n" +
@@ -579,134 +502,67 @@ async function runInteractiveOnboarding(
});
userId = parseInt(userIdStr);
- STEPS[2].value = `${phone} (ID: ${userId})`;
-
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Step 3: Config โ model + policies (advanced only)
- // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- redraw(3);
-
- selectedModel = providerMeta.defaultModel;
-
- if (
- selectedFlow === "advanced" &&
- selectedProvider !== "cocoon" &&
- selectedProvider !== "local"
- ) {
- const providerModels = MODEL_OPTIONS[selectedProvider] || [];
- const modelChoices = [
- ...providerModels,
- { value: "__custom__", name: "Custom", description: "Enter a model ID manually" },
- ];
-
- const modelChoice = await select({
- message: "Model",
- default: providerMeta.defaultModel,
- theme,
- choices: modelChoices,
- });
-
- if (modelChoice === "__custom__") {
- const customModel = await input({
- message: "Model ID",
- default: providerMeta.defaultModel,
- theme,
- });
- if (customModel?.trim()) selectedModel = customModel.trim();
- } else {
- selectedModel = modelChoice;
- }
-
- dmPolicy = await select({
- message: "DM policy (private messages)",
- default: "open",
- theme,
- choices: [
- { value: "open" as const, name: "Open", description: "Reply to everyone" },
- { value: "allowlist" as const, name: "Allowlist", description: "Only specific users" },
- { value: "disabled" as const, name: "Disabled", description: "No DM replies" },
- ],
- });
+ dmPolicy = await select({
+ message: "DM policy (private messages)",
+ default: "open",
+ theme,
+ choices: [
+ { value: "open" as const, name: "Open", description: "Reply to everyone" },
+ { value: "allowlist" as const, name: "Allowlist", description: "Only specific users" },
+ { value: "disabled" as const, name: "Disabled", description: "No DM replies" },
+ ],
+ });
- groupPolicy = await select({
- message: "Group policy",
- default: "open",
- theme,
- choices: [
- { value: "open" as const, name: "Open", description: "Reply in all groups" },
- { value: "allowlist" as const, name: "Allowlist", description: "Only specific groups" },
- { value: "disabled" as const, name: "Disabled", description: "No group replies" },
- ],
- });
+ groupPolicy = await select({
+ message: "Group policy",
+ default: "open",
+ theme,
+ choices: [
+ { value: "open" as const, name: "Open", description: "Reply in all groups" },
+ { value: "allowlist" as const, name: "Allowlist", description: "Only specific groups" },
+ { value: "disabled" as const, name: "Disabled", description: "No group replies" },
+ ],
+ });
- requireMention = await confirm({
- message: "Require @mention in groups?",
- default: true,
- theme,
- });
+ requireMention = await confirm({
+ message: "Require @mention in groups?",
+ default: true,
+ theme,
+ });
- maxAgenticIterations = await input({
- message: "Max agentic iterations (tool call loops per message)",
- default: "5",
- theme,
- validate: (v) => {
- const n = parseInt(v, 10);
- return !isNaN(n) && n >= 1 && n <= 50 ? true : "Must be 1โ50";
- },
- });
+ maxAgenticIterations = await input({
+ message: "Max agentic iterations (tool call loops per message)",
+ default: "5",
+ theme,
+ validate: (v) => {
+ const n = parseInt(v, 10);
+ return !isNaN(n) && n >= 1 && n <= 50 ? true : "Must be 1โ50";
+ },
+ });
- const modelLabel = providerModels.find((m) => m.value === selectedModel)?.name ?? selectedModel;
- STEPS[3].value = `${modelLabel}, ${dmPolicy}/${groupPolicy}`;
- } else {
- STEPS[3].value = `${selectedModel} (defaults)`;
- }
+ STEPS[2].value = `${dmPolicy}/${groupPolicy}`;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Step 4: Modules โ deals bot + TonAPI + Tavily
+ // Step 3: Modules โ optional API keys
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- redraw(4);
+ redraw(3);
const extras: string[] = [];
- if (dealsEnabled) {
- // Trading thresholds
- const customizeStrategy = await confirm({
- message: `Customize trading thresholds? ${DIM("(default: buy โค floor, sell โฅ floor +5%)")}`,
- default: false,
- theme,
- });
-
- if (customizeStrategy) {
- const buyInput = await input({
- message: "Max buy price (% of floor price)",
- default: "100",
- theme,
- validate: (v) => {
- const n = parseInt(v, 10);
- return !isNaN(n) && n >= 50 && n <= 150 ? true : "Must be 50โ150";
- },
- });
- buyMaxFloorPercent = parseInt(buyInput, 10);
-
- const sellInput = await input({
- message: "Min sell price (% of floor price)",
- default: "105",
- theme,
- validate: (v) => {
- const n = parseInt(v, 10);
- return !isNaN(n) && n >= 100 && n <= 200 ? true : "Must be 100โ200";
- },
- });
- sellMinFloorPercent = parseInt(sellInput, 10);
- }
+ // Bot token (recommended โ required for deals module)
+ const setupBot = await confirm({
+ message: `Add a Telegram bot token? ${DIM("(recommended โ enables deals & inline buttons)")}`,
+ default: true,
+ theme,
+ });
- // Bot setup
+ if (setupBot) {
noteBox(
"Create a bot with @BotFather on Telegram:\n" +
"1. Send /newbot and follow the instructions\n" +
"2. Copy the bot token\n" +
"3. Enable inline mode: /setinline on the bot",
- "Deals Bot",
+ "Bot Token",
TON
);
@@ -730,6 +586,7 @@ async function runInteractiveOnboarding(
botToken = tokenInput;
botUsername = data.result.username;
spinner.succeed(DIM(`Bot verified: @${botUsername}`));
+ extras.push("Bot");
}
} catch {
spinner.warn(DIM("Could not validate bot token (network error) โ saving anyway"));
@@ -743,24 +600,24 @@ async function runInteractiveOnboarding(
},
});
botUsername = usernameInput;
+ extras.push("Bot");
}
-
- extras.push("Deals");
}
// TonAPI key
const setupTonapi = await confirm({
- message: `Add a TonAPI key? ${DIM("(optional, recommended for 10x rate limits)")}`,
+ message: `Add a TonAPI key? ${DIM("(strongly recommended for TON features)")}`,
default: false,
theme,
});
if (setupTonapi) {
noteBox(
- "Without key: 1 req/s (you will hit rate limits)\n" +
- "With free key: 10 req/s (recommended)\n" +
+ "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 โ tap the mini app โ generate a server key",
+ "Open @tonapibot on Telegram โ mini app โ generate a server key",
"TonAPI",
TON
);
@@ -776,6 +633,35 @@ async function runInteractiveOnboarding(
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(
+ "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)")}`,
@@ -810,12 +696,12 @@ async function runInteractiveOnboarding(
extras.push("Tavily");
}
- STEPS[4].value = extras.length ? extras.join(", ") : "defaults";
+ STEPS[3].value = extras.length ? extras.join(", ") : "defaults";
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Step 5: Wallet โ generate / import / keep
+ // Step 4: Wallet โ generate / import / keep
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- redraw(5);
+ redraw(4);
let wallet;
const existingWallet = walletExists() ? loadWallet() : null;
@@ -860,10 +746,39 @@ async function runInteractiveOnboarding(
spinner.succeed(DIM("New TON wallet generated"));
}
} else {
- spinner.start(DIM("Generating TON wallet..."));
- wallet = await generateWallet();
- saveWallet(wallet);
- spinner.succeed(DIM("TON wallet generated"));
+ const walletAction = await select({
+ message: "TON Wallet",
+ default: "generate",
+ theme,
+ choices: [
+ {
+ value: "generate",
+ name: "Generate new wallet",
+ description: "Create a fresh TON wallet",
+ },
+ { value: "import", name: "Import from mnemonic", description: "Restore from 24-word seed" },
+ ],
+ });
+
+ 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}`));
+ } else {
+ spinner.start(DIM("Generating TON wallet..."));
+ wallet = await generateWallet();
+ saveWallet(wallet);
+ spinner.succeed(DIM("TON wallet generated"));
+ }
}
// Display mnemonic for new/regenerated wallets
@@ -909,9 +824,77 @@ async function runInteractiveOnboarding(
console.log(RED(" โ") + " ".repeat(W) + RED("โ"));
console.log(RED(` โ${"โ".repeat(W)}โ`));
console.log();
+
+ await confirm({
+ message: "I have written down my seed phrase",
+ default: true,
+ theme,
+ });
}
- STEPS[5].value = `${wallet.address.slice(0, 8)}...${wallet.address.slice(-4)}`;
+ STEPS[4].value = `${wallet.address.slice(0, 8)}...${wallet.address.slice(-4)}`;
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // Step 5: Telegram โ credentials
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ redraw(5);
+
+ 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 envApiId = process.env.TELETON_TG_API_ID;
+ const envApiHash = process.env.TELETON_TG_API_HASH;
+ const envPhone = process.env.TELETON_TG_PHONE;
+
+ 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);
+
+ 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;
+ },
+ });
+
+ 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;
+ },
+ });
+
+ STEPS[5].value = phone;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Step 6: Connect โ save config + Telegram auth
@@ -965,16 +948,11 @@ async function runInteractiveOnboarding(
},
storage: {
sessions_file: `${workspace.root}/sessions.json`,
- pairing_file: `${workspace.root}/pairing.json`,
memory_file: `${workspace.root}/memory.json`,
history_limit: 100,
},
embedding: { provider: "local" },
- deals: DealsConfigSchema.parse({
- enabled: dealsEnabled,
- buy_max_floor_percent: buyMaxFloorPercent,
- sell_min_floor_percent: sellMinFloorPercent,
- }),
+ deals: DealsConfigSchema.parse({ enabled: !!botToken }),
webui: {
enabled: false,
port: 7777,
@@ -1002,6 +980,7 @@ async function runInteractiveOnboarding(
plugins: {},
...(selectedProvider === "cocoon" ? { cocoon: { port: cocoonInstance } } : {}),
tonapi_key: tonapiKey,
+ toncenter_api_key: toncenterApiKey,
tavily_api_key: tavilyApiKey,
};
@@ -1140,7 +1119,6 @@ async function runNonInteractiveOnboarding(
},
storage: {
sessions_file: `${workspace.root}/sessions.json`,
- pairing_file: `${workspace.root}/pairing.json`,
memory_file: `${workspace.root}/memory.json`,
history_limit: 100,
},
diff --git a/src/config/__tests__/configurable-keys.test.ts b/src/config/__tests__/configurable-keys.test.ts
new file mode 100644
index 0000000..ce41ad2
--- /dev/null
+++ b/src/config/__tests__/configurable-keys.test.ts
@@ -0,0 +1,261 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { CONFIGURABLE_KEYS } from "../configurable-keys.js";
+
+// โโ New scalar keys โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("CONFIGURABLE_KEYS โ new scalar entries", () => {
+ describe("agent.base_url", () => {
+ const meta = CONFIGURABLE_KEYS["agent.base_url"];
+
+ it("accepts valid URL", () => {
+ expect(meta.validate("https://localhost:11434")).toBeUndefined();
+ });
+
+ it("accepts empty string (reset)", () => {
+ expect(meta.validate("")).toBeUndefined();
+ });
+
+ it("rejects invalid URL", () => {
+ expect(meta.validate("not-a-url")).toBeDefined();
+ });
+ });
+
+ describe("telegram.owner_id", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.owner_id"];
+
+ it("accepts positive integer", () => {
+ expect(meta.validate("123456789")).toBeUndefined();
+ });
+
+ it("rejects negative number", () => {
+ expect(meta.validate("-1")).toBeDefined();
+ });
+
+ it("rejects non-numeric", () => {
+ expect(meta.validate("abc")).toBeDefined();
+ });
+
+ it("parses to number", () => {
+ expect(meta.parse("123456789")).toBe(123456789);
+ });
+ });
+
+ describe("telegram.max_message_length", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.max_message_length"];
+
+ it("accepts within range 1-32768", () => {
+ expect(meta.validate("4096")).toBeUndefined();
+ });
+
+ it("rejects zero", () => {
+ expect(meta.validate("0")).toBeDefined();
+ });
+
+ it("rejects above max", () => {
+ expect(meta.validate("99999")).toBeDefined();
+ });
+ });
+
+ describe("telegram.rate_limit_messages_per_second", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.rate_limit_messages_per_second"];
+
+ it("accepts 0.1-10 range", () => {
+ expect(meta.validate("1.5")).toBeUndefined();
+ });
+
+ it("rejects zero", () => {
+ expect(meta.validate("0")).toBeDefined();
+ });
+
+ it("description contains 'requires restart'", () => {
+ expect(meta.description).toContain("requires restart");
+ });
+ });
+
+ describe("telegram.rate_limit_groups_per_minute", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.rate_limit_groups_per_minute"];
+
+ it("accepts 1-60 range", () => {
+ expect(meta.validate("20")).toBeUndefined();
+ });
+
+ it("rejects zero", () => {
+ expect(meta.validate("0")).toBeDefined();
+ });
+
+ it("description contains 'requires restart'", () => {
+ expect(meta.description).toContain("requires restart");
+ });
+ });
+
+ describe("embedding.model", () => {
+ const meta = CONFIGURABLE_KEYS["embedding.model"];
+
+ it("accepts any non-empty string", () => {
+ expect(meta.validate("all-MiniLM-L6-v2")).toBeUndefined();
+ });
+
+ it("accepts empty (reset to default)", () => {
+ expect(meta.validate("")).toBeUndefined();
+ });
+
+ it("description contains 'requires restart'", () => {
+ expect(meta.description).toContain("requires restart");
+ });
+ });
+
+ describe("deals.expiry_seconds", () => {
+ const meta = CONFIGURABLE_KEYS["deals.expiry_seconds"];
+
+ it("accepts 10-3600", () => {
+ expect(meta.validate("120")).toBeUndefined();
+ });
+
+ it("rejects below min", () => {
+ expect(meta.validate("5")).toBeDefined();
+ });
+ });
+
+ describe("deals.buy_max_floor_percent", () => {
+ const meta = CONFIGURABLE_KEYS["deals.buy_max_floor_percent"];
+
+ it("accepts 1-100", () => {
+ expect(meta.validate("95")).toBeUndefined();
+ });
+
+ it("rejects above 100", () => {
+ expect(meta.validate("101")).toBeDefined();
+ });
+ });
+
+ describe("deals.sell_min_floor_percent", () => {
+ const meta = CONFIGURABLE_KEYS["deals.sell_min_floor_percent"];
+
+ it("accepts 100-500", () => {
+ expect(meta.validate("105")).toBeUndefined();
+ });
+
+ it("rejects below 100", () => {
+ expect(meta.validate("99")).toBeDefined();
+ });
+ });
+
+ describe("cocoon.port", () => {
+ const meta = CONFIGURABLE_KEYS["cocoon.port"];
+
+ it("accepts 1-65535", () => {
+ expect(meta.validate("10000")).toBeUndefined();
+ });
+
+ it("rejects 0", () => {
+ expect(meta.validate("0")).toBeDefined();
+ });
+
+ it("description contains 'requires restart'", () => {
+ expect(meta.description).toContain("requires restart");
+ });
+ });
+});
+
+// โโ Array keys โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("CONFIGURABLE_KEYS โ array entries", () => {
+ describe("telegram.admin_ids", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.admin_ids"];
+
+ it("has type 'array'", () => {
+ expect(meta.type).toBe("array");
+ });
+
+ it("has itemType 'number'", () => {
+ expect(meta.itemType).toBe("number");
+ });
+
+ it("validates positive integer per item", () => {
+ expect(meta.validate("123456")).toBeUndefined();
+ });
+
+ it("rejects non-numeric item", () => {
+ expect(meta.validate("abc")).toBeDefined();
+ });
+
+ it("rejects negative item", () => {
+ expect(meta.validate("-5")).toBeDefined();
+ });
+
+ it("parses string to number", () => {
+ expect(meta.parse("123456")).toBe(123456);
+ });
+ });
+
+ describe("telegram.allow_from", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.allow_from"];
+
+ it("has type 'array' with itemType 'number'", () => {
+ expect(meta.type).toBe("array");
+ expect(meta.itemType).toBe("number");
+ });
+
+ it("validates positive integer per item", () => {
+ expect(meta.validate("999")).toBeUndefined();
+ });
+
+ it("rejects non-numeric item", () => {
+ expect(meta.validate("xyz")).toBeDefined();
+ });
+
+ it("parses string to number", () => {
+ expect(meta.parse("999")).toBe(999);
+ });
+ });
+
+ describe("telegram.group_allow_from", () => {
+ const meta = CONFIGURABLE_KEYS["telegram.group_allow_from"];
+
+ it("has type 'array' with itemType 'number'", () => {
+ expect(meta.type).toBe("array");
+ expect(meta.itemType).toBe("number");
+ });
+
+ it("validates positive integer per item", () => {
+ expect(meta.validate("777")).toBeUndefined();
+ });
+
+ it("rejects non-numeric item", () => {
+ expect(meta.validate("bad")).toBeDefined();
+ });
+
+ it("parses string to number", () => {
+ expect(meta.parse("777")).toBe(777);
+ });
+ });
+});
+
+// โโ Existing keys not broken โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("existing keys unchanged", () => {
+ it("all original keys still present (at least 27)", () => {
+ expect(Object.keys(CONFIGURABLE_KEYS).length).toBeGreaterThanOrEqual(27);
+ });
+
+ it("agent.api_key still validates >= 10 chars", () => {
+ const meta = CONFIGURABLE_KEYS["agent.api_key"];
+ expect(meta.validate("short")).toBeDefined();
+ expect(meta.validate("long-enough-key-here")).toBeUndefined();
+ });
+
+ it("agent.provider still has all 11 options", () => {
+ const meta = CONFIGURABLE_KEYS["agent.provider"];
+ expect(meta.options).toHaveLength(11);
+ });
+});
diff --git a/src/config/__tests__/loader.test.ts b/src/config/__tests__/loader.test.ts
index 07a68d0..34386ab 100644
--- a/src/config/__tests__/loader.test.ts
+++ b/src/config/__tests__/loader.test.ts
@@ -38,7 +38,7 @@ agent:
provider: anthropic
api_key: sk-ant-api03-fulltest456
model: claude-opus-4-5-20251101
- utility_model: claude-3-5-haiku-20241022
+ utility_model: claude-haiku-4-5-20251001
max_tokens: 8192
temperature: 0.8
system_prompt: "Custom system prompt"
@@ -75,7 +75,6 @@ telegram:
storage:
sessions_file: "~/custom_sessions.json"
- pairing_file: "~/custom_pairing.json"
memory_file: "~/custom_memory.json"
history_limit: 50
@@ -281,7 +280,7 @@ describe("Config Loader", () => {
// Agent
expect(config.agent.model).toBe("claude-opus-4-5-20251101");
- expect(config.agent.utility_model).toBe("claude-3-5-haiku-20241022");
+ expect(config.agent.utility_model).toBe("claude-haiku-4-5-20251001");
expect(config.agent.max_tokens).toBe(8192);
expect(config.agent.temperature).toBe(0.8);
expect(config.agent.max_agentic_iterations).toBe(10);
@@ -350,7 +349,7 @@ describe("Config Loader", () => {
const config = loadConfig(TEST_CONFIG_PATH);
// Agent defaults
- expect(config.agent.model).toBe("claude-opus-4-5-20251101");
+ expect(config.agent.model).toBe("claude-opus-4-6");
expect(config.agent.max_tokens).toBe(4096);
expect(config.agent.temperature).toBe(0.7);
expect(config.agent.max_agentic_iterations).toBe(5);
@@ -361,7 +360,7 @@ describe("Config Loader", () => {
// Telegram defaults
expect(config.telegram.session_name).toBe("teleton_session");
- expect(config.telegram.dm_policy).toBe("pairing");
+ expect(config.telegram.dm_policy).toBe("allowlist");
expect(config.telegram.group_policy).toBe("open");
expect(config.telegram.require_mention).toBe(true);
expect(config.telegram.typing_simulation).toBe(true);
@@ -526,7 +525,6 @@ telegram:
const config = loadConfig(TEST_CONFIG_PATH);
expect(config.storage.sessions_file).toBe(join(homedir(), ".teleton/sessions.json"));
- expect(config.storage.pairing_file).toBe(join(homedir(), ".teleton/pairing.json"));
expect(config.storage.memory_file).toBe(join(homedir(), ".teleton/memory.json"));
});
@@ -542,7 +540,6 @@ telegram:
session_path: "~/custom/session"
storage:
sessions_file: "~/custom/sessions.json"
- pairing_file: "~/custom/pairing.json"
memory_file: "~/custom/memory.json"
`;
writeTestConfig(customPathConfig);
diff --git a/src/config/configurable-keys.ts b/src/config/configurable-keys.ts
index fc0087b..05bf39b 100644
--- a/src/config/configurable-keys.ts
+++ b/src/config/configurable-keys.ts
@@ -5,7 +5,7 @@ import { ConfigSchema } from "./schema.js";
// โโ Types โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-export type ConfigKeyType = "string" | "number" | "boolean" | "enum";
+export type ConfigKeyType = "string" | "number" | "boolean" | "enum" | "array";
export type ConfigCategory =
| "API Keys"
@@ -20,12 +20,16 @@ export type ConfigCategory =
export interface ConfigKeyMeta {
type: ConfigKeyType;
category: ConfigCategory;
+ label: string;
description: string;
sensitive: boolean;
+ hotReload: "instant" | "restart";
validate: (v: string) => string | undefined;
mask: (v: string) => string;
parse: (v: string) => unknown;
options?: string[];
+ optionLabels?: Record;
+ itemType?: "string" | "number";
}
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -47,6 +51,18 @@ function enumValidator(options: string[]) {
return (v: string) => (options.includes(v) ? undefined : `Must be one of: ${options.join(", ")}`);
}
+function positiveInteger(v: string) {
+ const n = Number(v);
+ if (!Number.isInteger(n) || n <= 0) return "Must be a positive integer";
+ return undefined;
+}
+
+function validateUrl(v: string) {
+ if (v === "") return undefined; // empty to reset
+ if (v.startsWith("http://") || v.startsWith("https://")) return undefined;
+ return "Must be empty or start with http:// or https://";
+}
+
// โโ Whitelist โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
export const CONFIGURABLE_KEYS: Record = {
@@ -54,8 +70,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.api_key": {
type: "string",
category: "API Keys",
+ label: "LLM API Key",
description: "LLM provider API key",
sensitive: true,
+ hotReload: "instant",
validate: (v) => (v.length >= 10 ? undefined : "Must be at least 10 characters"),
mask: (v) => v.slice(0, 8) + "****",
parse: identity,
@@ -63,8 +81,10 @@ export const CONFIGURABLE_KEYS: Record = {
tavily_api_key: {
type: "string",
category: "API Keys",
+ label: "Tavily API Key",
description: "Tavily API key for web search",
sensitive: true,
+ hotReload: "instant",
validate: (v) => (v.startsWith("tvly-") ? undefined : "Must start with 'tvly-'"),
mask: (v) => v.slice(0, 9) + "****",
parse: identity,
@@ -72,8 +92,21 @@ export const CONFIGURABLE_KEYS: Record = {
tonapi_key: {
type: "string",
category: "API Keys",
+ label: "TonAPI Key",
description: "TonAPI key for higher rate limits",
sensitive: true,
+ hotReload: "instant",
+ validate: (v) => (v.length >= 10 ? undefined : "Must be at least 10 characters"),
+ mask: (v) => v.slice(0, 10) + "****",
+ parse: identity,
+ },
+ toncenter_api_key: {
+ type: "string",
+ category: "API Keys",
+ label: "TonCenter API Key",
+ description: "TonCenter API key for dedicated RPC endpoint (free at toncenter.com)",
+ sensitive: true,
+ hotReload: "instant",
validate: (v) => (v.length >= 10 ? undefined : "Must be at least 10 characters"),
mask: (v) => v.slice(0, 10) + "****",
parse: identity,
@@ -81,8 +114,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.bot_token": {
type: "string",
category: "API Keys",
+ label: "Bot Token",
description: "Bot token from @BotFather",
sensitive: true,
+ hotReload: "instant",
validate: (v) => (v.includes(":") ? undefined : "Must contain ':' (e.g., 123456:ABC...)"),
mask: (v) => v.split(":")[0] + ":****",
parse: identity,
@@ -92,10 +127,13 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.provider": {
type: "enum",
category: "Agent",
+ label: "Provider",
description: "LLM provider",
sensitive: false,
+ hotReload: "instant",
options: [
"anthropic",
+ "claude-code",
"openai",
"google",
"xai",
@@ -108,6 +146,7 @@ export const CONFIGURABLE_KEYS: Record = {
],
validate: enumValidator([
"anthropic",
+ "claude-code",
"openai",
"google",
"xai",
@@ -124,8 +163,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.model": {
type: "string",
category: "Agent",
+ label: "Model",
description: "Main LLM model ID",
sensitive: false,
+ hotReload: "instant",
validate: nonEmpty,
mask: identity,
parse: identity,
@@ -133,8 +174,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.utility_model": {
type: "string",
category: "Agent",
+ label: "Utility Model",
description: "Cheap model for summarization (auto-detected if empty)",
sensitive: false,
+ hotReload: "instant",
validate: noValidation,
mask: identity,
parse: identity,
@@ -142,8 +185,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.temperature": {
type: "number",
category: "Agent",
+ label: "Temperature",
description: "Response creativity (0.0 = deterministic, 2.0 = max)",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(0, 2),
mask: identity,
parse: (v) => Number(v),
@@ -151,8 +196,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.max_tokens": {
type: "number",
category: "Agent",
+ label: "Max Tokens",
description: "Maximum response length in tokens",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(256, 128000),
mask: identity,
parse: (v) => Number(v),
@@ -160,19 +207,45 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.max_agentic_iterations": {
type: "number",
category: "Agent",
+ label: "Max Iterations",
description: "Max tool-call loop iterations per message",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(1, 20),
mask: identity,
parse: (v) => Number(v),
},
+ "agent.base_url": {
+ type: "string",
+ category: "Agent",
+ label: "API Base URL",
+ description: "Base URL for local LLM server (requires restart)",
+ sensitive: false,
+ hotReload: "restart",
+ validate: validateUrl,
+ mask: identity,
+ parse: identity,
+ },
+ "cocoon.port": {
+ type: "number",
+ category: "Agent",
+ label: "Cocoon Port",
+ description: "Cocoon proxy port (requires restart)",
+ sensitive: false,
+ hotReload: "restart",
+ validate: numberInRange(1, 65535),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
// โโโ Session โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
"agent.session_reset_policy.daily_reset_enabled": {
type: "boolean",
category: "Session",
+ label: "Daily Reset",
description: "Enable daily session reset at specified hour",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
@@ -180,8 +253,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.session_reset_policy.daily_reset_hour": {
type: "number",
category: "Session",
+ label: "Reset Hour",
description: "Hour (0-23 UTC) for daily session reset",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(0, 23),
mask: identity,
parse: (v) => Number(v),
@@ -189,8 +264,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.session_reset_policy.idle_expiry_enabled": {
type: "boolean",
category: "Session",
+ label: "Idle Expiry",
description: "Enable automatic session expiry after idle period",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
@@ -198,8 +275,10 @@ export const CONFIGURABLE_KEYS: Record = {
"agent.session_reset_policy.idle_expiry_minutes": {
type: "number",
category: "Session",
+ label: "Idle Minutes",
description: "Idle minutes before session expires (minimum 1)",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(1, Number.MAX_SAFE_INTEGER),
mask: identity,
parse: (v) => Number(v),
@@ -209,8 +288,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.bot_username": {
type: "string",
category: "Telegram",
+ label: "Bot Username",
description: "Bot username without @",
sensitive: false,
+ hotReload: "instant",
validate: (v) => (v.length >= 3 ? undefined : "Must be at least 3 characters"),
mask: identity,
parse: identity,
@@ -218,19 +299,25 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.dm_policy": {
type: "enum",
category: "Telegram",
- description: "DM access policy",
+ label: "DM Policy",
+ description: "Who can message the bot in private",
sensitive: false,
- options: ["pairing", "allowlist", "open", "disabled"],
- validate: enumValidator(["pairing", "allowlist", "open", "disabled"]),
+ hotReload: "instant",
+ options: ["open", "allowlist", "disabled"],
+ optionLabels: { open: "Open", allowlist: "Allow Users", disabled: "Admin Only" },
+ validate: enumValidator(["allowlist", "open", "disabled"]),
mask: identity,
parse: identity,
},
"telegram.group_policy": {
type: "enum",
category: "Telegram",
- description: "Group access policy",
+ label: "Group Policy",
+ description: "Which groups the bot can respond in",
sensitive: false,
+ hotReload: "instant",
options: ["open", "allowlist", "disabled"],
+ optionLabels: { open: "Open", allowlist: "Allow Groups", disabled: "Disabled" },
validate: enumValidator(["open", "allowlist", "disabled"]),
mask: identity,
parse: identity,
@@ -238,8 +325,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.require_mention": {
type: "boolean",
category: "Telegram",
+ label: "Require Mention",
description: "Require @mention in groups to respond",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
@@ -247,8 +336,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.owner_name": {
type: "string",
category: "Telegram",
+ label: "Owner Name",
description: "Owner's first name (used in system prompt)",
sensitive: false,
+ hotReload: "instant",
validate: noValidation,
mask: identity,
parse: identity,
@@ -256,8 +347,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.owner_username": {
type: "string",
category: "Telegram",
+ label: "Owner Username",
description: "Owner's Telegram username (without @)",
sensitive: false,
+ hotReload: "instant",
validate: noValidation,
mask: identity,
parse: identity,
@@ -265,8 +358,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.debounce_ms": {
type: "number",
category: "Telegram",
+ label: "Debounce (ms)",
description: "Group message debounce delay in ms (0 = disabled)",
sensitive: false,
+ hotReload: "instant",
validate: numberInRange(0, 10000),
mask: identity,
parse: (v) => Number(v),
@@ -274,8 +369,10 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.agent_channel": {
type: "string",
category: "Telegram",
+ label: "Agent Channel",
description: "Channel username for auto-publishing",
sensitive: false,
+ hotReload: "instant",
validate: noValidation,
mask: identity,
parse: identity,
@@ -283,31 +380,128 @@ export const CONFIGURABLE_KEYS: Record = {
"telegram.typing_simulation": {
type: "boolean",
category: "Telegram",
+ label: "Typing Simulation",
description: "Simulate typing indicator before sending replies",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
},
+ "telegram.owner_id": {
+ type: "number",
+ category: "Telegram",
+ label: "Admin ID",
+ description: "Primary admin Telegram user ID (auto-added to Admin IDs)",
+ sensitive: false,
+ hotReload: "instant",
+ validate: positiveInteger,
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.max_message_length": {
+ type: "number",
+ category: "Telegram",
+ label: "Max Message Length",
+ description: "Maximum message length in characters",
+ sensitive: false,
+ hotReload: "instant",
+ validate: numberInRange(1, 32768),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.rate_limit_messages_per_second": {
+ type: "number",
+ category: "Telegram",
+ label: "Rate Limit โ Messages/sec",
+ description: "Rate limit: messages per second (requires restart)",
+ sensitive: false,
+ hotReload: "restart",
+ validate: numberInRange(0.1, 10),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.rate_limit_groups_per_minute": {
+ type: "number",
+ category: "Telegram",
+ label: "Rate Limit โ Groups/min",
+ description: "Rate limit: groups per minute (requires restart)",
+ sensitive: false,
+ hotReload: "restart",
+ validate: numberInRange(1, 60),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.admin_ids": {
+ type: "array",
+ itemType: "number",
+ category: "Telegram",
+ label: "Admin IDs",
+ description: "Admin user IDs with elevated access",
+ sensitive: false,
+ hotReload: "instant",
+ validate: positiveInteger,
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.allow_from": {
+ type: "array",
+ itemType: "number",
+ category: "Telegram",
+ label: "Allowed Users",
+ description: "User IDs allowed for DM access",
+ sensitive: false,
+ hotReload: "instant",
+ validate: positiveInteger,
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "telegram.group_allow_from": {
+ type: "array",
+ itemType: "number",
+ category: "Telegram",
+ label: "Allowed Groups",
+ description: "Group IDs allowed for group access",
+ sensitive: false,
+ hotReload: "instant",
+ validate: positiveInteger,
+ mask: identity,
+ parse: (v) => Number(v),
+ },
// โโโ Embedding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
"embedding.provider": {
type: "enum",
category: "Embedding",
+ label: "Embedding Provider",
description: "Embedding provider for RAG",
sensitive: false,
+ hotReload: "instant",
options: ["local", "anthropic", "none"],
validate: enumValidator(["local", "anthropic", "none"]),
mask: identity,
parse: identity,
},
+ "embedding.model": {
+ type: "string",
+ category: "Embedding",
+ label: "Embedding Model",
+ description: "Embedding model ID (requires restart)",
+ sensitive: false,
+ hotReload: "restart",
+ validate: noValidation,
+ mask: identity,
+ parse: identity,
+ },
// โโโ WebUI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
"webui.port": {
type: "number",
category: "WebUI",
+ label: "WebUI Port",
description: "HTTP server port (requires restart)",
sensitive: false,
+ hotReload: "restart",
validate: numberInRange(1024, 65535),
mask: identity,
parse: (v) => Number(v),
@@ -315,8 +509,10 @@ export const CONFIGURABLE_KEYS: Record = {
"webui.log_requests": {
type: "boolean",
category: "WebUI",
+ label: "Log HTTP Requests",
description: "Log all HTTP requests to console",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
@@ -326,19 +522,56 @@ export const CONFIGURABLE_KEYS: Record = {
"deals.enabled": {
type: "boolean",
category: "Deals",
+ label: "Deals Enabled",
description: "Enable the deals/escrow module",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
},
+ "deals.expiry_seconds": {
+ type: "number",
+ category: "Deals",
+ label: "Deal Expiry",
+ description: "Deal expiry timeout in seconds",
+ sensitive: false,
+ hotReload: "instant",
+ validate: numberInRange(10, 3600),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "deals.buy_max_floor_percent": {
+ type: "number",
+ category: "Deals",
+ label: "Buy Max Floor %",
+ description: "Maximum floor % for buy deals",
+ sensitive: false,
+ hotReload: "instant",
+ validate: numberInRange(1, 100),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
+ "deals.sell_min_floor_percent": {
+ type: "number",
+ category: "Deals",
+ label: "Sell Min Floor %",
+ description: "Minimum floor % for sell deals",
+ sensitive: false,
+ hotReload: "instant",
+ validate: numberInRange(100, 500),
+ mask: identity,
+ parse: (v) => Number(v),
+ },
// โโโ Developer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
"dev.hot_reload": {
type: "boolean",
category: "Developer",
+ label: "Hot Reload",
description: "Watch ~/.teleton/plugins/ for live changes",
sensitive: false,
+ hotReload: "instant",
validate: enumValidator(["true", "false"]),
mask: identity,
parse: (v) => v === "true",
diff --git a/src/config/loader.ts b/src/config/loader.ts
index a70c9c3..be10f79 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -52,14 +52,17 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
const config = result.data;
const provider = config.agent.provider as SupportedProvider;
- if (provider !== "anthropic" && !(raw as Record>).agent?.model) {
+ if (
+ provider !== "anthropic" &&
+ provider !== "claude-code" &&
+ !(raw as Record>).agent?.model
+ ) {
const meta = getProviderMetadata(provider);
config.agent.model = meta.defaultModel;
}
config.telegram.session_path = expandPath(config.telegram.session_path);
config.storage.sessions_file = expandPath(config.storage.sessions_file);
- config.storage.pairing_file = expandPath(config.storage.pairing_file);
config.storage.memory_file = expandPath(config.storage.memory_file);
if (process.env.TELETON_API_KEY) {
@@ -120,6 +123,9 @@ export function loadConfig(configPath: string = DEFAULT_CONFIG_PATH): Config {
if (process.env.TELETON_TONAPI_KEY) {
config.tonapi_key = process.env.TELETON_TONAPI_KEY;
}
+ if (process.env.TELETON_TONCENTER_API_KEY) {
+ config.toncenter_api_key = process.env.TELETON_TONCENTER_API_KEY;
+ }
return config;
}
diff --git a/src/config/model-catalog.ts b/src/config/model-catalog.ts
new file mode 100644
index 0000000..18cd7f5
--- /dev/null
+++ b/src/config/model-catalog.ts
@@ -0,0 +1,167 @@
+/**
+ * Shared model catalog used by WebUI setup, CLI onboard, and config routes.
+ * To add a model, add it here โ it will appear in all UIs automatically.
+ * Models must exist in pi-ai's registry (or be entered as custom).
+ */
+
+export interface ModelOption {
+ value: string;
+ name: string;
+ description: string;
+}
+
+export const MODEL_OPTIONS: Record = {
+ anthropic: [
+ {
+ value: "claude-opus-4-6",
+ name: "Claude Opus 4.6",
+ description: "Most capable, 1M ctx, $5/M",
+ },
+ {
+ value: "claude-opus-4-5-20251101",
+ name: "Claude Opus 4.5",
+ description: "Previous gen, 200K ctx, $5/M",
+ },
+ {
+ value: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ description: "Balanced, 200K ctx, $3/M",
+ },
+ {
+ value: "claude-haiku-4-5-20251001",
+ name: "Claude Haiku 4.5",
+ description: "Fast & cheap, $1/M",
+ },
+ ],
+ openai: [
+ { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" },
+ { value: "gpt-5-pro", name: "GPT-5 Pro", description: "Extended thinking, 400K ctx" },
+ { value: "gpt-5-mini", name: "GPT-5 Mini", description: "Fast & cheap, 400K ctx" },
+ { value: "gpt-5.1", name: "GPT-5.1", description: "Latest gen, 400K ctx" },
+ { 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: "o4-mini", name: "o4 Mini", description: "Reasoning, fast, 200K ctx" },
+ { value: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" },
+ { value: "codex-mini-latest", name: "Codex Mini", description: "Coding specialist" },
+ ],
+ google: [
+ { value: "gemini-3-pro-preview", name: "Gemini 3 Pro", description: "Preview, most capable" },
+ { value: "gemini-3-flash-preview", name: "Gemini 3 Flash", description: "Preview, fast" },
+ { 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",
+ },
+ { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" },
+ ],
+ xai: [
+ { value: "grok-4-1-fast", name: "Grok 4.1 Fast", description: "Latest, vision, 2M ctx" },
+ { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" },
+ { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" },
+ { value: "grok-code-fast-1", name: "Grok Code", description: "Coding specialist, fast" },
+ { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" },
+ ],
+ groq: [
+ {
+ value: "meta-llama/llama-4-maverick-17b-128e-instruct",
+ name: "Llama 4 Maverick",
+ description: "Vision, 131K ctx, $0.20/M",
+ },
+ { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" },
+ {
+ value: "deepseek-r1-distill-llama-70b",
+ name: "DeepSeek R1 70B",
+ description: "Reasoning, 131K ctx, $0.75/M",
+ },
+ {
+ value: "llama-3.3-70b-versatile",
+ name: "Llama 3.3 70B",
+ description: "General purpose, 131K ctx, $0.59/M",
+ },
+ ],
+ openrouter: [
+ { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" },
+ {
+ value: "anthropic/claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ description: "200K ctx, $3/M",
+ },
+ { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" },
+ { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" },
+ {
+ value: "deepseek/deepseek-r1",
+ name: "DeepSeek R1",
+ description: "Reasoning, 64K ctx, $0.70/M",
+ },
+ {
+ value: "deepseek/deepseek-r1-0528",
+ name: "DeepSeek R1 0528",
+ description: "Reasoning improved, 64K ctx",
+ },
+ {
+ value: "deepseek/deepseek-v3.2",
+ name: "DeepSeek V3.2",
+ description: "Latest, general, 64K ctx",
+ },
+ { value: "deepseek/deepseek-v3.1", name: "DeepSeek V3.1", description: "General, 64K ctx" },
+ {
+ value: "deepseek/deepseek-v3-0324",
+ name: "DeepSeek V3",
+ description: "General, 64K ctx, $0.30/M",
+ },
+ { value: "qwen/qwen3-coder", name: "Qwen3 Coder", description: "Coding specialist" },
+ { value: "qwen/qwen3-max", name: "Qwen3 Max", description: "Most capable Qwen" },
+ { value: "qwen/qwen3-235b-a22b", name: "Qwen3 235B", description: "235B params, MoE" },
+ {
+ value: "nvidia/nemotron-nano-9b-v2",
+ name: "Nemotron Nano 9B",
+ description: "Small & fast, Nvidia",
+ },
+ {
+ value: "perplexity/sonar-pro",
+ name: "Perplexity Sonar Pro",
+ description: "Web search integrated",
+ },
+ { value: "minimax/minimax-m2.5", name: "MiniMax M2.5", description: "Latest MiniMax" },
+ { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" },
+ ],
+ moonshot: [
+ { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" },
+ {
+ value: "kimi-k2-thinking",
+ name: "Kimi K2 Thinking",
+ description: "Free, 256K ctx, reasoning",
+ },
+ ],
+ mistral: [
+ {
+ value: "devstral-small-2507",
+ name: "Devstral Small",
+ description: "Coding, 128K ctx, $0.10/M",
+ },
+ {
+ value: "devstral-medium-latest",
+ name: "Devstral Medium",
+ description: "Coding, 262K ctx, $0.40/M",
+ },
+ {
+ value: "mistral-large-latest",
+ name: "Mistral Large",
+ description: "General, 128K ctx, $2/M",
+ },
+ {
+ value: "magistral-small",
+ name: "Magistral Small",
+ description: "Reasoning, 128K ctx, $0.50/M",
+ },
+ ],
+};
+
+/** Get models for a provider (claude-code maps to anthropic) */
+export function getModelsForProvider(provider: string): ModelOption[] {
+ const key = provider === "claude-code" ? "anthropic" : provider;
+ return MODEL_OPTIONS[key] || [];
+}
diff --git a/src/config/providers.ts b/src/config/providers.ts
index e0c530d..97cfd5f 100644
--- a/src/config/providers.ts
+++ b/src/config/providers.ts
@@ -1,5 +1,6 @@
export type SupportedProvider =
| "anthropic"
+ | "claude-code"
| "openai"
| "google"
| "xai"
@@ -31,8 +32,20 @@ const PROVIDER_REGISTRY: Record = {
keyPrefix: "sk-ant-",
keyHint: "sk-ant-api03-...",
consoleUrl: "https://console.anthropic.com/",
- defaultModel: "claude-opus-4-5-20251101",
- utilityModel: "claude-3-5-haiku-20241022",
+ defaultModel: "claude-opus-4-6",
+ utilityModel: "claude-haiku-4-5-20251001",
+ toolLimit: null,
+ piAiProvider: "anthropic",
+ },
+ "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-opus-4-6",
+ utilityModel: "claude-haiku-4-5-20251001",
toolLimit: null,
piAiProvider: "anthropic",
},
@@ -161,7 +174,7 @@ export function getSupportedProviders(): ProviderMetadata[] {
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") return undefined; // No API key needed
+ if (provider === "cocoon" || provider === "local" || provider === "claude-code") return undefined; // No API key needed (claude-code auto-detects)
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 926380f..ec235c1 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { TELEGRAM_MAX_MESSAGE_LENGTH } from "../constants/limits.js";
-export const DMPolicy = z.enum(["pairing", "allowlist", "open", "disabled"]);
+export const DMPolicy = z.enum(["allowlist", "open", "disabled"]);
export const GroupPolicy = z.enum(["open", "allowlist", "disabled"]);
export const SessionResetPolicySchema = z.object({
@@ -23,6 +23,7 @@ export const AgentConfigSchema = z.object({
provider: z
.enum([
"anthropic",
+ "claude-code",
"openai",
"google",
"xai",
@@ -40,7 +41,7 @@ export const AgentConfigSchema = z.object({
.url()
.optional()
.describe("Base URL for local LLM server (e.g. http://localhost:11434/v1)"),
- model: z.string().default("claude-opus-4-5-20251101"),
+ model: z.string().default("claude-opus-4-6"),
utility_model: z
.string()
.optional()
@@ -61,7 +62,7 @@ export const TelegramConfigSchema = z.object({
phone: z.string(),
session_name: z.string().default("teleton_session"),
session_path: z.string().default("~/.teleton"),
- dm_policy: DMPolicy.default("pairing"),
+ dm_policy: DMPolicy.default("allowlist"),
allow_from: z.array(z.number()).default([]),
group_policy: GroupPolicy.default("open"),
group_allow_from: z.array(z.number()).default([]),
@@ -91,7 +92,6 @@ export const TelegramConfigSchema = z.object({
export const StorageConfigSchema = z.object({
sessions_file: z.string().default("~/.teleton/sessions.json"),
- pairing_file: z.string().default("~/.teleton/pairing.json"),
memory_file: z.string().default("~/.teleton/memory.json"),
history_limit: z.number().default(100),
});
@@ -106,7 +106,7 @@ export const MetaConfigSchema = z.object({
const _DealsObject = z.object({
enabled: z.boolean().default(true),
expiry_seconds: z.number().default(120),
- buy_max_floor_percent: z.number().default(100),
+ buy_max_floor_percent: z.number().default(95),
sell_min_floor_percent: z.number().default(105),
poll_interval_ms: z.number().default(5000),
max_verification_retries: z.number().default(12),
@@ -245,6 +245,10 @@ export const ConfigSchema = z.object({
.string()
.optional()
.describe("TonAPI key for higher rate limits (from @tonapi_bot)"),
+ toncenter_api_key: z
+ .string()
+ .optional()
+ .describe("TonCenter API key for dedicated RPC endpoint (free at https://toncenter.com)"),
tavily_api_key: z
.string()
.optional()
diff --git a/src/constants/api-endpoints.ts b/src/constants/api-endpoints.ts
index 4e9c197..d3cf5dc 100644
--- a/src/constants/api-endpoints.ts
+++ b/src/constants/api-endpoints.ts
@@ -14,10 +14,12 @@ export function tonapiHeaders(): Record {
return headers;
}
-const TONAPI_MAX_RPS = 5;
+const TONAPI_RPS_WITH_KEY = 5;
+const TONAPI_RPS_WITHOUT_KEY = 1;
const _tonapiTimestamps: number[] = [];
async function waitForTonapiSlot(): Promise {
+ const maxRps = _tonapiKey ? TONAPI_RPS_WITH_KEY : TONAPI_RPS_WITHOUT_KEY;
const clean = () => {
const cutoff = Date.now() - 1000;
while (_tonapiTimestamps.length > 0 && _tonapiTimestamps[0] <= cutoff) {
@@ -26,7 +28,7 @@ async function waitForTonapiSlot(): Promise {
};
clean();
- if (_tonapiTimestamps.length >= TONAPI_MAX_RPS) {
+ if (_tonapiTimestamps.length >= maxRps) {
const waitMs = _tonapiTimestamps[0] + 1000 - Date.now() + 50;
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
clean();
diff --git a/src/constants/limits.ts b/src/constants/limits.ts
index f7b9ff0..7ac58cf 100644
--- a/src/constants/limits.ts
+++ b/src/constants/limits.ts
@@ -25,6 +25,7 @@ export const CONTEXT_MAX_RELEVANT_CHUNKS = 5;
export const HYBRID_SEARCH_MIN_SCORE = 0.15;
export const CONTEXT_OVERFLOW_SUMMARY_MESSAGES = 15;
export const RATE_LIMIT_MAX_RETRIES = 3;
+export const SERVER_ERROR_MAX_RETRIES = 3;
export const KNOWLEDGE_CHUNK_SIZE = 500;
export const PAYMENT_TOLERANCE_RATIO = 0.99;
export const TELEGRAM_CONNECTION_RETRIES = 5;
diff --git a/src/constants/timeouts.ts b/src/constants/timeouts.ts
index af19660..16059be 100644
--- a/src/constants/timeouts.ts
+++ b/src/constants/timeouts.ts
@@ -11,5 +11,7 @@ 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;
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;
export const SHUTDOWN_TIMEOUT_MS = 10_000;
+export const TYPING_REFRESH_MS = 4_000;
diff --git a/src/deals/executor.ts b/src/deals/executor.ts
index d33e002..9bb85b4 100644
--- a/src/deals/executor.ts
+++ b/src/deals/executor.ts
@@ -95,14 +95,7 @@ export async function executeDeal(
});
if (!txHash) {
- // Release lock since send failed
- db.prepare(
- `UPDATE deals SET agent_sent_at = NULL, status = 'failed', notes = 'TON transfer returned no tx hash' WHERE id = ?`
- ).run(dealId);
- return {
- success: false,
- error: "TON transfer failed (no tx hash returned)",
- };
+ throw new Error("TON transfer failed (wallet not initialized or invalid parameters)");
}
// Update deal: mark as completed (agent_sent_at already set by lock)
diff --git a/src/deals/module.ts b/src/deals/module.ts
index 7e9a95d..e8cec9c 100644
--- a/src/deals/module.ts
+++ b/src/deals/module.ts
@@ -1,8 +1,10 @@
+import { join } from "path";
import type { PluginModule } from "../agent/tools/types.js";
import { initDealsConfig, DEALS_CONFIG } from "./config.js";
import { DealBot, VerificationPoller } from "../bot/index.js";
import { createLogger } from "../utils/logger.js";
import { openDealsDb, closeDealsDb, getDealsDb } from "./db.js";
+import { TELETON_ROOT } from "../workspace/paths.js";
const log = createLogger("Deal");
import { createDbWrapper } from "../utils/module-db.js";
@@ -74,6 +76,7 @@ const dealsModule: PluginModule = {
username: botUsername || "deals_bot",
apiId: config.telegram.api_id,
apiHash: config.telegram.api_hash,
+ gramjsSessionPath: join(TELETON_ROOT, "gramjs_bot_session.txt"),
},
dealsDb
);
diff --git a/src/index.ts b/src/index.ts
index 3925439..a3d02c9 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,7 @@ import { MessageDebouncer } from "./telegram/debounce.js";
import { getDatabase, closeDatabase, initializeMemory, type MemorySystem } from "./memory/index.js";
import { getWalletAddress } from "./ton/wallet-service.js";
import { setTonapiKey } from "./constants/api-endpoints.js";
+import { setToncenterApiKey } from "./ton/endpoint.js";
import { TELETON_ROOT } from "./workspace/paths.js";
import { TELEGRAM_CONNECTION_RETRIES, TELEGRAM_FLOOD_SLEEP_THRESHOLD } from "./constants/limits.js";
import { join } from "path";
@@ -31,6 +32,7 @@ import {
} from "./agent/tools/mcp-loader.js";
import { getErrorMessage } from "./utils/errors.js";
import { createLogger, initLoggerFromConfig } from "./utils/logger.js";
+import { AgentLifecycle } from "./agent/lifecycle.js";
const log = createLogger("App");
@@ -51,6 +53,7 @@ export class TeletonApp {
private pluginWatcher: PluginWatcher | null = null;
private mcpConnections: McpConnection[] = [];
private callbackHandlerRegistered = false;
+ private lifecycle = new AgentLifecycle();
private configPath: string;
@@ -64,6 +67,9 @@ export class TeletonApp {
if (this.config.tonapi_key) {
setTonapiKey(this.config.tonapi_key);
}
+ if (this.config.toncenter_api_key) {
+ setToncenterApiKey(this.config.toncenter_api_key);
+ }
const soul = loadSoul();
@@ -126,6 +132,13 @@ export class TeletonApp {
);
}
+ /**
+ * Get the lifecycle state machine for WebUI integration
+ */
+ getLifecycle(): AgentLifecycle {
+ return this.lifecycle;
+ }
+
/**
* Start the agent
*/
@@ -145,6 +158,88 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ DEV: ZKPROOF.T.ME โโโ${reset}
`);
+ // Register lifecycle callbacks so WebUI routes can call start()/stop() without args
+ this.lifecycle.registerCallbacks(
+ () => this.startAgent(),
+ () => this.stopAgent()
+ );
+
+ // Start WebUI server if enabled (before agent โ survives agent stop/restart)
+ if (this.config.webui.enabled) {
+ try {
+ const { WebUIServer } = await import("./webui/server.js");
+ // Build MCP server info getter for WebUI (live status, not a snapshot)
+ const mcpServers = () =>
+ Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => {
+ const type = serverConfig.command
+ ? ("stdio" as const)
+ : serverConfig.url
+ ? ("streamable-http" as const)
+ : ("sse" as const);
+ const target = serverConfig.command ?? serverConfig.url ?? "";
+ const connected = this.mcpConnections.some((c) => c.serverName === name);
+ const moduleName = `mcp_${name}`;
+ const moduleTools = this.toolRegistry.getModuleTools(moduleName);
+ return {
+ name,
+ type,
+ target,
+ scope: serverConfig.scope ?? "always",
+ enabled: serverConfig.enabled ?? true,
+ connected,
+ toolCount: moduleTools.length,
+ tools: moduleTools.map((t) => t.name),
+ envKeys: Object.keys(serverConfig.env ?? {}),
+ };
+ });
+
+ const builtinNames = this.modules.map((m) => m.name);
+ const pluginContext: PluginContext = {
+ bridge: this.bridge,
+ db: getDatabase().getDb(),
+ config: this.config,
+ };
+
+ this.webuiServer = new WebUIServer({
+ agent: this.agent,
+ bridge: this.bridge,
+ memory: this.memory,
+ toolRegistry: this.toolRegistry,
+ plugins: this.modules
+ .filter((m) => this.toolRegistry.isPluginModule(m.name))
+ .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" })),
+ mcpServers,
+ config: this.config.webui,
+ configPath: this.configPath,
+ lifecycle: this.lifecycle,
+ marketplace: {
+ modules: this.modules,
+ config: this.config,
+ sdkDeps: this.sdkDeps,
+ pluginContext,
+ loadedModuleNames: builtinNames,
+ rewireHooks: () => this.wirePluginEventHooks(),
+ },
+ });
+ await this.webuiServer.start();
+ } catch (error) {
+ log.error({ err: error }, "โ Failed to start WebUI server");
+ log.warn("โ ๏ธ Continuing without WebUI...");
+ }
+ }
+
+ // Start agent subsystems via lifecycle
+ await this.lifecycle.start(() => this.startAgent());
+
+ // Keep process alive
+ await new Promise(() => {});
+ }
+
+ /**
+ * Start agent subsystems (Telegram, plugins, MCP, modules, debouncer, handler).
+ * Called by lifecycle.start() โ do NOT call directly.
+ */
+ private async startAgent(): Promise {
// Load modules
const moduleNames = this.modules
.filter((m) => m.tools(this.config).length > 0)
@@ -275,14 +370,15 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
`Cocoon Network unavailable on port ${this.config.cocoon?.port ?? 10000}: ${getErrorMessage(err)}`
);
log.error("Start the Cocoon client first: cocoon start");
- process.exit(1);
+ throw new Error(`Cocoon Network unavailable: ${getErrorMessage(err)}`);
}
}
// Local LLM โ register models from OpenAI-compatible server
if (this.config.agent.provider === "local" && !this.config.agent.base_url) {
- log.error("Local provider requires base_url in config (e.g. http://localhost:11434/v1)");
- process.exit(1);
+ throw new Error(
+ "Local provider requires base_url in config (e.g. http://localhost:11434/v1)"
+ );
}
if (this.config.agent.provider === "local" && this.config.agent.base_url) {
try {
@@ -302,7 +398,7 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
`Local LLM server unavailable at ${this.config.agent.base_url}: ${getErrorMessage(err)}`
);
log.error("Start the LLM server first (e.g. ollama serve)");
- process.exit(1);
+ throw new Error(`Local LLM server unavailable: ${getErrorMessage(err)}`);
}
}
@@ -310,8 +406,7 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
await this.bridge.connect();
if (!this.bridge.isAvailable()) {
- log.error("โ Failed to connect to Telegram");
- process.exit(1);
+ throw new Error("Failed to connect to Telegram");
}
// Resolve owner name/username from Telegram if not already set
@@ -385,57 +480,6 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
log.info("Teleton Agent is running! Press Ctrl+C to stop.");
- // Start WebUI server if enabled
- if (this.config.webui.enabled) {
- try {
- const { WebUIServer } = await import("./webui/server.js");
- // Build MCP server info for WebUI
- const mcpServers = Object.entries(this.config.mcp.servers).map(([name, serverConfig]) => {
- const type = serverConfig.command ? ("stdio" as const) : ("sse" as const);
- const target = serverConfig.command ?? serverConfig.url ?? "";
- const connected = this.mcpConnections.some((c) => c.serverName === name);
- const moduleName = `mcp_${name}`;
- const moduleTools = this.toolRegistry.getModuleTools(moduleName);
- return {
- name,
- type,
- target,
- scope: serverConfig.scope ?? "always",
- enabled: serverConfig.enabled ?? true,
- connected,
- toolCount: moduleTools.length,
- tools: moduleTools.map((t) => t.name),
- envKeys: Object.keys(serverConfig.env ?? {}),
- };
- });
-
- this.webuiServer = new WebUIServer({
- agent: this.agent,
- bridge: this.bridge,
- memory: this.memory,
- toolRegistry: this.toolRegistry,
- plugins: this.modules
- .filter((m) => this.toolRegistry.isPluginModule(m.name))
- .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" })),
- mcpServers,
- config: this.config.webui,
- configPath: this.configPath,
- marketplace: {
- modules: this.modules,
- config: this.config,
- sdkDeps: this.sdkDeps,
- pluginContext,
- loadedModuleNames: builtinNames,
- rewireHooks: () => this.wirePluginEventHooks(),
- },
- });
- await this.webuiServer.start();
- } catch (error) {
- log.error({ err: error }, "โ Failed to start WebUI server");
- log.warn("โ ๏ธ Continuing without WebUI...");
- }
- }
-
// Initialize message debouncer with bypass logic
this.debouncer = new MessageDebouncer(
{
@@ -474,9 +518,6 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
log.error({ err: error }, "Error enqueueing message");
}
});
-
- // Keep process alive
- await new Promise(() => {});
}
/**
@@ -859,7 +900,10 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
async stop(): Promise {
log.info("๐ Stopping Teleton AI...");
- // Stop WebUI server first (if running)
+ // Stop agent subsystems via lifecycle
+ await this.lifecycle.stop(() => this.stopAgent());
+
+ // Stop WebUI server (if running)
if (this.webuiServer) {
try {
await this.webuiServer.stop();
@@ -868,6 +912,19 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
}
}
+ // Close database last (shared with WebUI)
+ try {
+ closeDatabase();
+ } catch (e) {
+ log.error({ err: e }, "โ ๏ธ Database close failed");
+ }
+ }
+
+ /**
+ * Stop agent subsystems (watcher, MCP, debouncer, handler, modules, bridge).
+ * Called by lifecycle.stop() โ do NOT call directly.
+ */
+ private async stopAgent(): Promise {
// Stop plugin watcher first
if (this.pluginWatcher) {
try {
@@ -915,12 +972,6 @@ ${blue} โโโโโโโโโโโโโโโโโโโโโโโ
} catch (e) {
log.error({ err: e }, "โ ๏ธ Bridge disconnect failed");
}
-
- try {
- closeDatabase();
- } catch (e) {
- log.error({ err: e }, "โ ๏ธ Database close failed");
- }
}
}
diff --git a/src/memory/__tests__/envelope-reply.test.ts b/src/memory/__tests__/envelope-reply.test.ts
new file mode 100644
index 0000000..02eaa2b
--- /dev/null
+++ b/src/memory/__tests__/envelope-reply.test.ts
@@ -0,0 +1,140 @@
+import { describe, it, expect } from "vitest";
+import { formatMessageEnvelope } from "../envelope.js";
+
+const BASE_PARAMS = {
+ channel: "Telegram",
+ senderId: "12345",
+ senderName: "Alice",
+ senderUsername: "alice",
+ timestamp: new Date("2026-02-24T14:30:00Z").getTime(),
+ body: "Hello world",
+ isGroup: false,
+} as const;
+
+describe("formatMessageEnvelope โ reply context", () => {
+ it("renders reply annotation for DM", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ senderName: "Bob",
+ text: "Hey, did you check the logs?",
+ isAgent: false,
+ },
+ });
+
+ expect(result).toContain("[โฉ reply to Bob:");
+ expect(result).toContain('"Hey, did you check the logs?"');
+ expect(result).toContain("Hello world");
+ // Multi-line format
+ expect(result).toMatch(/\]\n\[โฉ reply to/);
+ });
+
+ it("renders reply annotation for group message", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ isGroup: true,
+ replyContext: {
+ senderName: "Bob",
+ text: "Original message",
+ isAgent: false,
+ },
+ });
+
+ expect(result).toContain("[โฉ reply to Bob:");
+ expect(result).toContain("Alice (@alice, id:12345): ");
+ });
+
+ it("shows 'agent' as sender when isAgent is true", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ senderName: "BotName",
+ text: "Your balance is 100 TON",
+ isAgent: true,
+ },
+ });
+
+ expect(result).toContain("[โฉ reply to agent:");
+ expect(result).not.toContain("BotName");
+ });
+
+ it("shows 'unknown' when senderName is missing", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ text: "Some message",
+ },
+ });
+
+ expect(result).toContain("[โฉ reply to unknown:");
+ });
+
+ it("truncates quoted text to 200 chars with ellipsis", () => {
+ const longText = "A".repeat(300);
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ senderName: "Bob",
+ text: longText,
+ },
+ });
+
+ // After sanitize, text is truncated to 200 + "..."
+ expect(result).toContain("..." + '"');
+ // Should NOT contain the full 300-char string
+ expect(result).not.toContain("A".repeat(300));
+ });
+
+ it("does not truncate text exactly 200 chars", () => {
+ const exactText = "B".repeat(200);
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ senderName: "Bob",
+ text: exactText,
+ },
+ });
+
+ expect(result).toContain("B".repeat(200));
+ expect(result).not.toContain("...");
+ });
+
+ it("keeps single-line format when no reply context", () => {
+ const result = formatMessageEnvelope(BASE_PARAMS);
+
+ // No newlines in the output (single line)
+ expect(result).not.toContain("\n");
+ expect(result).not.toContain("โฉ");
+ });
+
+ it("renders reply context + media annotation together", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ hasMedia: true,
+ mediaType: "photo",
+ messageId: 999,
+ replyContext: {
+ senderName: "Bob",
+ text: "Check this out",
+ isAgent: false,
+ },
+ });
+
+ expect(result).toContain("[โฉ reply to Bob:");
+ expect(result).toContain("[๐ท photo msg_id=999]");
+ expect(result).toContain("Hello world");
+ });
+
+ it("sanitizes special characters in quoted text", () => {
+ const result = formatMessageEnvelope({
+ ...BASE_PARAMS,
+ replyContext: {
+ senderName: "Bob",
+ text: "Test injected text",
+ },
+ });
+
+ // sanitizeForPrompt should strip or escape the tags
+ expect(result).not.toContain("injected");
+ });
+});
diff --git a/src/memory/embeddings/local.ts b/src/memory/embeddings/local.ts
index 7c3d61c..5289040 100644
--- a/src/memory/embeddings/local.ts
+++ b/src/memory/embeddings/local.ts
@@ -1,5 +1,6 @@
import { pipeline, env, type FeatureExtractionPipeline } from "@huggingface/transformers";
import { join } from "node:path";
+import { mkdirSync } from "node:fs";
import type { EmbeddingProvider } from "./provider.js";
import { TELETON_ROOT } from "../../workspace/paths.js";
import { createLogger } from "../../utils/logger.js";
@@ -7,15 +8,23 @@ import { createLogger } from "../../utils/logger.js";
const log = createLogger("Memory");
// Force model cache into ~/.teleton/models/ (writable even with npm install -g)
-env.cacheDir = join(TELETON_ROOT, "models");
+const modelCacheDir = join(TELETON_ROOT, "models");
+try {
+ mkdirSync(modelCacheDir, { recursive: true });
+} catch {
+ // Will fail later with a clear error during warmup
+}
+env.cacheDir = modelCacheDir;
let extractorPromise: Promise | null = null;
function getExtractor(model: string): Promise {
if (!extractorPromise) {
- log.info(`Loading local embedding model: ${model} (cache: ${env.cacheDir})`);
+ log.info(`Loading local embedding model: ${model} (cache: ${modelCacheDir})`);
extractorPromise = pipeline("feature-extraction", model, {
dtype: "fp32",
+ // Explicit cache_dir to avoid any env race condition
+ cache_dir: modelCacheDir,
})
.then((ext) => {
log.info(`Local embedding model ready`);
@@ -47,21 +56,30 @@ export class LocalEmbeddingProvider implements EmbeddingProvider {
/**
* Pre-download and load the model at startup.
- * If loading fails, marks this provider as disabled (returns empty embeddings).
+ * If loading fails, retries once then marks provider as disabled (FTS5-only).
* Call this once during app init โ avoids retry spam on every message.
* @returns true if model loaded successfully, false if fallback to noop
*/
async warmup(): Promise {
- try {
- await getExtractor(this.model);
- return true;
- } catch (err) {
- log.warn(
- `Local embedding model unavailable โ falling back to FTS5-only search (no vector embeddings)`
- );
- this._disabled = true;
- return false;
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ try {
+ await getExtractor(this.model);
+ return true;
+ } catch (err) {
+ if (attempt === 1) {
+ log.warn(`Embedding model load failed (attempt 1), retrying...`);
+ // Small delay before retry
+ await new Promise((r) => setTimeout(r, 1000));
+ } else {
+ log.warn(
+ `Local embedding model unavailable โ falling back to FTS5-only search (no vector embeddings)`
+ );
+ this._disabled = true;
+ return false;
+ }
+ }
}
+ return false;
}
async embedQuery(text: string): Promise {
diff --git a/src/memory/envelope.ts b/src/memory/envelope.ts
index 4d37b34..40c562a 100644
--- a/src/memory/envelope.ts
+++ b/src/memory/envelope.ts
@@ -1,4 +1,4 @@
-import { sanitizeForPrompt } from "../utils/sanitize.js";
+import { sanitizeForPrompt, sanitizeForContext } from "../utils/sanitize.js";
export interface EnvelopeParams {
channel: string;
@@ -14,6 +14,11 @@ export interface EnvelopeParams {
hasMedia?: boolean;
mediaType?: string;
messageId?: number; // For media download reference
+ replyContext?: {
+ senderName?: string;
+ text: string;
+ isAgent?: boolean;
+ };
}
function formatElapsed(elapsedMs: number): string {
@@ -117,6 +122,14 @@ export function formatMessageEnvelope(params: EnvelopeParams): string {
body = `[${mediaEmoji} ${params.mediaType}${msgIdHint}] ${body}`;
}
+ if (params.replyContext) {
+ const sender = params.replyContext.isAgent
+ ? "agent"
+ : sanitizeForPrompt(params.replyContext.senderName ?? "unknown");
+ let quotedText = sanitizeForContext(params.replyContext.text);
+ if (quotedText.length > 200) quotedText = quotedText.slice(0, 200) + "...";
+ return `${header}\n[โฉ reply to ${sender}: "${quotedText}"]\n${body}`;
+ }
return `${header} ${body}`;
}
diff --git a/src/providers/__tests__/claude-code-credentials.test.ts b/src/providers/__tests__/claude-code-credentials.test.ts
new file mode 100644
index 0000000..2f6d6b5
--- /dev/null
+++ b/src/providers/__tests__/claude-code-credentials.test.ts
@@ -0,0 +1,197 @@
+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);
+});
+
+afterEach(async () => {
+ for (const key of envKeysToClean) delete process.env[key];
+ envKeysToClean.length = 0;
+ try {
+ rmSync(TEST_DIR, { recursive: true, force: true });
+ } catch {}
+ // 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
+ writeCredsFile(validCredentials({ accessToken: "sk-ant-oat01-new-token" }));
+ const key2 = 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 = 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
new file mode 100644
index 0000000..a631c8b
--- /dev/null
+++ b/src/providers/__tests__/claude-code-provider.test.ts
@@ -0,0 +1,56 @@
+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-opus-4-6");
+ 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
new file mode 100644
index 0000000..20f0c0d
--- /dev/null
+++ b/src/providers/__tests__/claude-code-retry.test.ts
@@ -0,0 +1,152 @@
+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
new file mode 100644
index 0000000..f5777b5
--- /dev/null
+++ b/src/providers/claude-code-credentials.ts
@@ -0,0 +1,177 @@
+/**
+ * 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, 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");
+
+// โโ Types โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+interface ClaudeOAuthCredentials {
+ claudeAiOauth?: {
+ accessToken?: string;
+ refreshToken?: string;
+ expiresAt?: number;
+ scopes?: string[];
+ };
+}
+
+// โโ Module-level cache โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+let cachedToken: string | null = null;
+let cachedExpiresAt = 0;
+
+// โโ 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 (e) {
+ log.warn({ err: e, 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;
+} | 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,
+ };
+}
+
+// โโ 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;
+ 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.");
+}
+
+/**
+ * Force re-read credentials from disk (called on 401 or manual refresh).
+ * Returns the new token or null if unavailable.
+ */
+export function refreshClaudeCodeApiKey(): string | null {
+ // Clear cache
+ cachedToken = null;
+ cachedExpiresAt = 0;
+
+ const creds = readCredentials();
+ if (creds) {
+ const extracted = extractToken(creds);
+ if (extracted) {
+ cachedToken = extracted.token;
+ cachedExpiresAt = extracted.expiresAt;
+ log.info("Claude Code credentials refreshed from disk");
+ return cachedToken;
+ }
+ }
+
+ log.warn("Failed to refresh Claude Code credentials from disk");
+ 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;
+}
diff --git a/src/sdk/__tests__/ton-utils.real.test.ts b/src/sdk/__tests__/ton-utils.real.test.ts
new file mode 100644
index 0000000..dcdb2b7
--- /dev/null
+++ b/src/sdk/__tests__/ton-utils.real.test.ts
@@ -0,0 +1,199 @@
+/**
+ * ton-utils.real.test.ts
+ *
+ * Tests for toNano / fromNano / validateAddress using the REAL @ton/ton and
+ * @ton/core implementations โ no mock for those two packages.
+ *
+ * Only infrastructure modules (wallet-service, transfer, http clientsโฆ) are
+ * mocked so we can instantiate createTonSDK without side-effects.
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { PluginSDKError } from "@teleton-agent/sdk";
+
+// โโโ Mock infrastructure โ NOT @ton/ton or @ton/core โโโโโโโโโโโโโโโโโโโโโโโโโ
+
+vi.mock("../../ton/wallet-service.js", () => ({
+ getWalletAddress: vi.fn(),
+ getWalletBalance: vi.fn(),
+ getTonPrice: vi.fn(),
+ loadWallet: vi.fn(),
+ getKeyPair: vi.fn(),
+}));
+
+vi.mock("../../ton/transfer.js", () => ({
+ sendTon: vi.fn(),
+}));
+
+vi.mock("../../constants/limits.js", () => ({
+ PAYMENT_TOLERANCE_RATIO: 0.99,
+}));
+
+vi.mock("../../utils/retry.js", () => ({
+ withBlockchainRetry: vi.fn(),
+}));
+
+vi.mock("../../constants/api-endpoints.js", () => ({
+ tonapiFetch: vi.fn(),
+}));
+
+vi.mock("../../ton/endpoint.js", () => ({
+ getCachedHttpEndpoint: vi.fn().mockResolvedValue("https://toncenter.test"),
+}));
+
+vi.mock("../../ton/format-transactions.js", () => ({
+ formatTransactions: vi.fn((txs: any[]) => txs),
+}));
+
+// withTxLock is a passthrough in this context (no real transactions sent)
+vi.mock("../../ton/tx-lock.js", () => ({
+ withTxLock: vi.fn((fn: () => Promise) => fn()),
+}));
+
+// โโโ Subject under test โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+import { createTonSDK } from "../ton.js";
+
+const mockLog = {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+};
+
+// SDK instance โ db is null (utilities do not require a database)
+const sdk = createTonSDK(mockLog as any, null);
+
+// โโโ Known-valid TON address (EQ bounceable, verified on mainnet) โโโโโโโโโโโโโ
+const VALID_BOUNCEABLE = "EQDtFpEwcFAEcRe5mLVh2N6C0x-_hJEM7W61_JLnSF74p4q2";
+// Same address in raw hex format (both should parse correctly)
+const VALID_RAW_HEX = "0:ed169130705004711b99c35615c6fd41a16e7b52bea6dcb87f6f84d3e6b57f7e";
+
+// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("TonSDK utility methods โ real @ton/ton + @ton/core", () => {
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // toNano()
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe("toNano()", () => {
+ it("converts 1.5 (number) โ 1_500_000_000n", () => {
+ expect(sdk.toNano(1.5)).toBe(BigInt("1500000000"));
+ });
+
+ it("converts '1.5' (string) โ 1_500_000_000n", () => {
+ expect(sdk.toNano("1.5")).toBe(BigInt("1500000000"));
+ });
+
+ it("converts integer 2 โ 2_000_000_000n", () => {
+ expect(sdk.toNano(2)).toBe(BigInt("2000000000"));
+ });
+
+ it("converts 0 โ 0n", () => {
+ expect(sdk.toNano(0)).toBe(BigInt(0));
+ });
+
+ it("converts sub-nano precision '0.5' โ 500_000_000n", () => {
+ expect(sdk.toNano("0.5")).toBe(BigInt("500000000"));
+ });
+
+ it("converts large amount 1_000_000 โ 1_000_000_000_000_000n", () => {
+ expect(sdk.toNano(1_000_000)).toBe(BigInt("1000000000000000"));
+ });
+
+ // The library allows negative values โ SDK utility does not add extra guard
+ // (amount validation is the responsibility of sendTON / sendJetton)
+ it("accepts negative values (library behaviour) โ negative bigint", () => {
+ expect(sdk.toNano(-1)).toBe(BigInt("-1000000000"));
+ });
+
+ it("throws PluginSDKError on non-numeric string 'not_a_number'", () => {
+ expect(() => sdk.toNano("not_a_number")).toThrow(PluginSDKError);
+ });
+
+ it("throws PluginSDKError on NaN", () => {
+ expect(() => sdk.toNano(NaN)).toThrow(PluginSDKError);
+ });
+
+ it("throws PluginSDKError on Infinity", () => {
+ expect(() => sdk.toNano(Infinity)).toThrow(PluginSDKError);
+ });
+
+ it("throws PluginSDKError on scientific notation string '1e9'", () => {
+ // @ton/core's parser does not support 'e' notation
+ expect(() => sdk.toNano("1e9")).toThrow(PluginSDKError);
+ });
+ });
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // fromNano()
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe("fromNano()", () => {
+ it("converts 1_500_000_000n โ '1.5'", () => {
+ expect(sdk.fromNano(BigInt("1500000000"))).toBe("1.5");
+ });
+
+ it("converts 3_000_000_000n โ '3'", () => {
+ expect(sdk.fromNano(BigInt("3000000000"))).toBe("3");
+ });
+
+ it("converts 0n โ '0'", () => {
+ expect(sdk.fromNano(BigInt(0))).toBe("0");
+ });
+
+ it("accepts string input '1500000000' โ '1.5'", () => {
+ expect(sdk.fromNano("1500000000")).toBe("1.5");
+ });
+
+ it("preserves precision: 1n โ '0.000000001'", () => {
+ expect(sdk.fromNano(BigInt(1))).toBe("0.000000001");
+ });
+
+ it("preserves precision: 999_999_999n โ '0.999999999'", () => {
+ expect(sdk.fromNano(BigInt("999999999"))).toBe("0.999999999");
+ });
+
+ it("round-trips with toNano for 2.5", () => {
+ expect(sdk.fromNano(sdk.toNano(2.5))).toBe("2.5");
+ });
+
+ it("round-trips with toNano for 0.1", () => {
+ expect(sdk.fromNano(sdk.toNano("0.1"))).toBe("0.1");
+ });
+ });
+
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ // validateAddress()
+ // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+ describe("validateAddress()", () => {
+ it("returns true for a valid bounceable address (EQโฆ)", () => {
+ expect(sdk.validateAddress(VALID_BOUNCEABLE)).toBe(true);
+ });
+
+ it("returns true for a valid raw hex address (0:โฆ)", () => {
+ expect(sdk.validateAddress(VALID_RAW_HEX)).toBe(true);
+ });
+
+ it("returns false for an empty string", () => {
+ expect(sdk.validateAddress("")).toBe(false);
+ });
+
+ it("returns false for a random alphanumeric string", () => {
+ expect(sdk.validateAddress("not-an-address")).toBe(false);
+ });
+
+ it("returns false for a truncated address", () => {
+ expect(sdk.validateAddress("EQDtFpEwcFAEc")).toBe(false);
+ });
+
+ it("returns false for a raw address with short hex payload", () => {
+ expect(sdk.validateAddress("0:abc123")).toBe(false);
+ });
+
+ it("returns false for a URL that looks like an address", () => {
+ expect(sdk.validateAddress("https://ton.org/wallet")).toBe(false);
+ });
+ });
+});
diff --git a/src/sdk/__tests__/ton.test.ts b/src/sdk/__tests__/ton.test.ts
index 3c4c3f8..e5e8e3b 100644
--- a/src/sdk/__tests__/ton.test.ts
+++ b/src/sdk/__tests__/ton.test.ts
@@ -10,6 +10,7 @@ vi.mock("../../ton/wallet-service.js", () => ({
getTonPrice: vi.fn(),
loadWallet: vi.fn(),
getKeyPair: vi.fn(),
+ getCachedTonClient: vi.fn(),
}));
vi.mock("../../ton/transfer.js", () => ({
@@ -73,6 +74,7 @@ import {
getTonPrice,
loadWallet,
getKeyPair,
+ getCachedTonClient,
} from "../../ton/wallet-service.js";
import { sendTon } from "../../ton/transfer.js";
import { tonapiFetch } from "../../constants/api-endpoints.js";
@@ -295,9 +297,7 @@ describe("createTonSDK", () => {
it("returns formatted transactions", async () => {
const mockTxs = [{ hash: "abc", type: "ton_received" }];
const mockGetTx = vi.fn().mockResolvedValue(mockTxs);
- mocks.tonClient.mockImplementation(function (this: any) {
- this.getTransactions = mockGetTx;
- });
+ (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx });
mocks.formatTransactions.mockReturnValue(mockTxs);
const result = await sdk.getTransactions(VALID_ADDRESS, 5);
@@ -306,9 +306,7 @@ describe("createTonSDK", () => {
it("caps limit at 50", async () => {
const mockGetTx = vi.fn().mockResolvedValue([]);
- mocks.tonClient.mockImplementation(function (this: any) {
- this.getTransactions = mockGetTx;
- });
+ (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx });
mocks.formatTransactions.mockReturnValue([]);
await sdk.getTransactions(VALID_ADDRESS, 999);
@@ -320,9 +318,7 @@ describe("createTonSDK", () => {
it("defaults limit to 10 when not specified", async () => {
const mockGetTx = vi.fn().mockResolvedValue([]);
- mocks.tonClient.mockImplementation(function (this: any) {
- this.getTransactions = mockGetTx;
- });
+ (getCachedTonClient as Mock).mockResolvedValue({ getTransactions: mockGetTx });
mocks.formatTransactions.mockReturnValue([]);
await sdk.getTransactions(VALID_ADDRESS);
@@ -333,9 +329,7 @@ describe("createTonSDK", () => {
});
it("returns empty array on error", async () => {
- mocks.tonClient.mockImplementation(function () {
- throw new Error("connection failed");
- });
+ (getCachedTonClient as Mock).mockRejectedValue(new Error("connection failed"));
const result = await sdk.getTransactions(VALID_ADDRESS);
expect(result).toEqual([]);
@@ -601,6 +595,7 @@ describe("createTonSDK", () => {
storeCoins: vi.fn().mockReturnThis(),
storeAddress: vi.fn().mockReturnThis(),
storeBit: vi.fn().mockReturnThis(),
+ storeRef: vi.fn().mockReturnThis(),
storeMaybeRef: vi.fn().mockReturnThis(),
storeStringTail: vi.fn().mockReturnThis(),
endCell: vi.fn().mockReturnValue(cellMock),
@@ -613,13 +608,13 @@ describe("createTonSDK", () => {
// Mock toNano (used in TEP-74 transfer body)
mocks.toNano.mockReturnValue(BigInt(1));
- // Mock TonClient (must use regular function for `new` constructor)
+ // Mock getCachedTonClient โ returns a client with an open() method
const mockWalletContract = {
getSeqno: vi.fn().mockResolvedValue(42),
sendTransfer: vi.fn().mockResolvedValue(undefined),
};
- mocks.tonClient.mockImplementation(function (this: any) {
- this.open = vi.fn().mockReturnValue(mockWalletContract);
+ (getCachedTonClient as Mock).mockResolvedValue({
+ open: vi.fn().mockReturnValue(mockWalletContract),
});
});
@@ -963,57 +958,73 @@ describe("createTonSDK", () => {
// UTILITY METHODS
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- // Note: toNano/fromNano/validateAddress use require() at runtime,
- // which loads the real @ton/ton and @ton/core modules (not mocked).
- // We test them against the real implementations.
+ // These now use top-level ESM imports (mocked by vi.mock).
+ // We configure the mock return values to match the real behaviour.
describe("Utility methods", () => {
describe("toNano()", () => {
it("converts a number to nanoTON", () => {
+ mocks.toNano.mockReturnValue(BigInt("1500000000"));
const result = sdk.toNano(1.5);
+ expect(mocks.toNano).toHaveBeenCalledWith("1.5");
expect(result).toBe(BigInt("1500000000"));
});
it("converts a string to nanoTON", () => {
+ mocks.toNano.mockReturnValue(BigInt("2000000000"));
const result = sdk.toNano("2");
+ expect(mocks.toNano).toHaveBeenCalledWith("2");
expect(result).toBe(BigInt("2000000000"));
});
it("converts zero", () => {
+ mocks.toNano.mockReturnValue(BigInt(0));
expect(sdk.toNano(0)).toBe(BigInt(0));
});
it("throws PluginSDKError on invalid input", () => {
+ mocks.toNano.mockImplementation(() => {
+ throw new Error("Invalid number");
+ });
expect(() => sdk.toNano("not_a_number")).toThrow(PluginSDKError);
});
});
describe("fromNano()", () => {
it("converts nanoTON bigint to string", () => {
+ mocks.fromNano.mockReturnValue("1.5");
const result = sdk.fromNano(BigInt("1500000000"));
expect(result).toBe("1.5");
});
it("converts nanoTON string to string", () => {
+ mocks.fromNano.mockReturnValue("3");
const result = sdk.fromNano("3000000000");
expect(result).toBe("3");
});
it("converts zero", () => {
+ mocks.fromNano.mockReturnValue("0");
expect(sdk.fromNano(BigInt(0))).toBe("0");
});
});
describe("validateAddress()", () => {
it("returns true for a valid TON address", () => {
- // Use the real @ton/core Address.parse
+ mocks.addressParse.mockReturnValue({});
expect(sdk.validateAddress(VALID_ADDRESS)).toBe(true);
});
it("returns false for an invalid address", () => {
+ mocks.addressParse.mockImplementation(() => {
+ throw new Error("Invalid");
+ });
expect(sdk.validateAddress("not-an-address")).toBe(false);
});
it("returns false for empty string", () => {
+ mocks.addressParse.mockImplementation(() => {
+ throw new Error("Invalid");
+ });
expect(sdk.validateAddress("")).toBe(false);
});
});
diff --git a/src/sdk/ton.ts b/src/sdk/ton.ts
index 6a9fd6a..f5ac7a8 100644
--- a/src/sdk/ton.ts
+++ b/src/sdk/ton.ts
@@ -20,11 +20,21 @@ import {
getTonPrice,
loadWallet,
getKeyPair,
+ getCachedTonClient,
} from "../ton/wallet-service.js";
import { sendTon } from "../ton/transfer.js";
import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js";
import { withBlockchainRetry } from "../utils/retry.js";
import { tonapiFetch } from "../constants/api-endpoints.js";
+import {
+ toNano as tonToNano,
+ fromNano as tonFromNano,
+ WalletContractV5R1,
+ internal,
+} from "@ton/ton";
+import { Address as TonAddress, beginCell, SendMode } from "@ton/core";
+import { withTxLock } from "../ton/tx-lock.js";
+import { formatTransactions } from "../ton/format-transactions.js";
const DEFAULT_MAX_AGE_MINUTES = 10;
@@ -32,6 +42,20 @@ const DEFAULT_TX_RETENTION_DAYS = 30;
const CLEANUP_PROBABILITY = 0.1;
+/** Match a jetton in a balances array by raw address or parsed canonical form. */
+function findJettonBalance(balances: any[], jettonAddress: string): any | undefined {
+ return balances.find((b: any) => {
+ if (b.jetton.address.toLowerCase() === jettonAddress.toLowerCase()) return true;
+ try {
+ return (
+ TonAddress.parse(b.jetton.address).toString() === TonAddress.parse(jettonAddress).toString()
+ );
+ } catch {
+ return false;
+ }
+ });
+}
+
function cleanupOldTransactions(
db: Database.Database,
retentionDays: number,
@@ -94,8 +118,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
}
try {
- const { Address } = await import("@ton/core");
- Address.parse(to);
+ TonAddress.parse(to);
} catch {
throw new PluginSDKError("Invalid TON address format", "INVALID_ADDRESS");
}
@@ -127,14 +150,8 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
async getTransactions(address: string, limit?: number): Promise {
try {
- const { TonClient } = await import("@ton/ton");
- const { Address } = await import("@ton/core");
- const { getCachedHttpEndpoint } = await import("../ton/endpoint.js");
- const { formatTransactions } = await import("../ton/format-transactions.js");
-
- const addressObj = Address.parse(address);
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const addressObj = TonAddress.parse(address);
+ const client = await getCachedTonClient();
const transactions = await withBlockchainRetry(
() =>
@@ -233,7 +250,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
const addr = ownerAddress ?? getWalletAddress();
if (!addr) return [];
- const response = await tonapiFetch(`/accounts/${addr}/jettons`);
+ const response = await tonapiFetch(`/accounts/${encodeURIComponent(addr)}/jettons`);
if (!response.ok) {
log.error(`ton.getJettonBalances() TonAPI error: ${response.status}`);
return [];
@@ -246,7 +263,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
const { balance, wallet_address, jetton } = item;
if (jetton.verification === "blacklist") continue;
- const decimals = jetton.decimals || 9;
+ const decimals = jetton.decimals ?? 9;
const rawBalance = BigInt(balance);
const divisor = BigInt(10 ** decimals);
const wholePart = rawBalance / divisor;
@@ -279,7 +296,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
async getJettonInfo(jettonAddress: string): Promise {
try {
- const response = await tonapiFetch(`/jettons/${jettonAddress}`);
+ const response = await tonapiFetch(`/jettons/${encodeURIComponent(jettonAddress)}`);
if (response.status === 404) return null;
if (!response.ok) {
log.error(`ton.getJettonInfo() TonAPI error: ${response.status}`);
@@ -313,10 +330,6 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
amount: number,
opts?: { comment?: string }
): Promise {
- const { Address, beginCell, SendMode } = await import("@ton/core");
- const { WalletContractV5R1, TonClient, toNano, internal } = await import("@ton/ton");
- const { getCachedHttpEndpoint } = await import("../ton/endpoint.js");
-
const walletData = loadWallet();
if (!walletData) {
throw new PluginSDKError("Wallet not initialized", "WALLET_NOT_INITIALIZED");
@@ -327,100 +340,109 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
}
try {
- Address.parse(to);
+ TonAddress.parse(to);
} catch {
throw new PluginSDKError("Invalid recipient address", "INVALID_ADDRESS");
}
- // Get sender's jetton wallet from balances
- const jettonsResponse = await tonapiFetch(`/accounts/${walletData.address}/jettons`);
- if (!jettonsResponse.ok) {
- throw new PluginSDKError(
- `Failed to fetch jetton balances: ${jettonsResponse.status}`,
- "OPERATION_FAILED"
+ try {
+ // Get sender's jetton wallet from balances
+ const jettonsResponse = await tonapiFetch(
+ `/accounts/${encodeURIComponent(walletData.address)}/jettons`
);
- }
+ if (!jettonsResponse.ok) {
+ throw new PluginSDKError(
+ `Failed to fetch jetton balances: ${jettonsResponse.status}`,
+ "OPERATION_FAILED"
+ );
+ }
- const jettonsData = await jettonsResponse.json();
- const jettonBalance = jettonsData.balances?.find(
- (b: any) =>
- b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() ||
- Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString()
- );
+ const jettonsData = await jettonsResponse.json();
+ const jettonBalance = findJettonBalance(jettonsData.balances ?? [], jettonAddress);
- if (!jettonBalance) {
- throw new PluginSDKError(
- `You don't own any of this jetton: ${jettonAddress}`,
- "OPERATION_FAILED"
- );
- }
+ if (!jettonBalance) {
+ throw new PluginSDKError(
+ `You don't own any of this jetton: ${jettonAddress}`,
+ "OPERATION_FAILED"
+ );
+ }
- const senderJettonWallet = jettonBalance.wallet_address.address;
- const decimals = jettonBalance.jetton.decimals || 9;
- const currentBalance = BigInt(jettonBalance.balance);
- const amountStr = amount.toFixed(decimals);
- const [whole, frac = ""] = amountStr.split(".");
- const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals));
+ const senderJettonWallet = jettonBalance.wallet_address.address;
+ const decimals = jettonBalance.jetton.decimals ?? 9;
+ const currentBalance = BigInt(jettonBalance.balance);
+ const amountStr = amount.toFixed(decimals);
+ const [whole, frac = ""] = amountStr.split(".");
+ const amountInUnits = BigInt(whole + (frac + "0".repeat(decimals)).slice(0, decimals));
- if (amountInUnits > currentBalance) {
- throw new PluginSDKError(
- `Insufficient balance. Have ${Number(currentBalance) / 10 ** decimals}, need ${amount}`,
- "OPERATION_FAILED"
- );
- }
+ if (amountInUnits > currentBalance) {
+ throw new PluginSDKError(
+ `Insufficient balance. Have ${Number(currentBalance) / 10 ** decimals}, need ${amount}`,
+ "OPERATION_FAILED"
+ );
+ }
- const comment = opts?.comment;
+ const comment = opts?.comment;
- // Build forward payload (comment)
- let forwardPayload = beginCell().endCell();
- if (comment) {
- forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell();
- }
+ // Build forward payload (comment)
+ let forwardPayload = beginCell().endCell();
+ if (comment) {
+ forwardPayload = beginCell().storeUint(0, 32).storeStringTail(comment).endCell();
+ }
- // TEP-74 transfer message body
- const JETTON_TRANSFER_OP = 0xf8a7ea5;
- const messageBody = beginCell()
- .storeUint(JETTON_TRANSFER_OP, 32)
- .storeUint(0, 64) // query_id
- .storeCoins(amountInUnits)
- .storeAddress(Address.parse(to))
- .storeAddress(Address.parse(walletData.address)) // response_destination
- .storeBit(false) // no custom_payload
- .storeCoins(comment ? toNano("0.01") : BigInt(1)) // forward_ton_amount
- .storeBit(comment ? true : false)
- .storeMaybeRef(comment ? forwardPayload : null)
- .endCell();
-
- const keyPair = await getKeyPair();
- if (!keyPair) {
- throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED");
- }
+ // TEP-74 transfer message body
+ const JETTON_TRANSFER_OP = 0xf8a7ea5;
+ const messageBody = beginCell()
+ .storeUint(JETTON_TRANSFER_OP, 32)
+ .storeUint(0, 64) // query_id
+ .storeCoins(amountInUnits)
+ .storeAddress(TonAddress.parse(to))
+ .storeAddress(TonAddress.parse(walletData.address)) // response_destination
+ .storeBit(false) // no custom_payload
+ .storeCoins(comment ? tonToNano("0.01") : BigInt(1)) // forward_ton_amount
+ .storeBit(comment ? 1 : 0)
+ .storeRef(comment ? forwardPayload : beginCell().endCell())
+ .endCell();
+
+ const keyPair = await getKeyPair();
+ if (!keyPair) {
+ throw new PluginSDKError("Wallet key derivation failed", "OPERATION_FAILED");
+ }
+
+ const seqno = await withTxLock(async () => {
+ const wallet = WalletContractV5R1.create({
+ workchain: 0,
+ publicKey: keyPair.publicKey,
+ });
- const wallet = WalletContractV5R1.create({
- workchain: 0,
- publicKey: keyPair.publicKey,
- });
-
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
- const walletContract = client.open(wallet);
- const seqno = await walletContract.getSeqno();
-
- await walletContract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY + SendMode.IGNORE_ERRORS,
- messages: [
- internal({
- to: Address.parse(senderJettonWallet),
- value: toNano("0.05"),
- body: messageBody,
- bounce: true,
- }),
- ],
- });
-
- return { success: true, seqno };
+ 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;
+ });
+
+ return { success: true, seqno };
+ } catch (err) {
+ if (err instanceof PluginSDKError) throw err;
+ throw new PluginSDKError(
+ `Failed to send jetton: ${err instanceof Error ? err.message : String(err)}`,
+ "OPERATION_FAILED"
+ );
+ }
},
async getJettonWalletAddress(
@@ -428,21 +450,15 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
jettonAddress: string
): Promise {
try {
- const response = await tonapiFetch(`/accounts/${ownerAddress}/jettons`);
+ const response = await tonapiFetch(`/accounts/${encodeURIComponent(ownerAddress)}/jettons`);
if (!response.ok) {
log.error(`ton.getJettonWalletAddress() TonAPI error: ${response.status}`);
return null;
}
- const { Address } = await import("@ton/core");
const data = await response.json();
- const match = (data.balances || []).find(
- (b: any) =>
- b.jetton.address.toLowerCase() === jettonAddress.toLowerCase() ||
- Address.parse(b.jetton.address).toString() === Address.parse(jettonAddress).toString()
- );
-
+ const match = findJettonBalance(data.balances ?? [], jettonAddress);
return match ? match.wallet_address.address : null;
} catch (err) {
log.error("ton.getJettonWalletAddress() failed:", err);
@@ -479,7 +495,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
async getNftInfo(nftAddress: string): Promise {
try {
- const response = await tonapiFetch(`/nfts/${nftAddress}`);
+ const response = await tonapiFetch(`/nfts/${encodeURIComponent(nftAddress)}`);
if (response.status === 404) return null;
if (!response.ok) {
log.error(`ton.getNftInfo() TonAPI error: ${response.status}`);
@@ -498,8 +514,7 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
toNano(amount: number | string): bigint {
try {
- const { toNano: convert } = require("@ton/ton");
- return convert(String(amount));
+ return tonToNano(String(amount));
} catch (err) {
throw new PluginSDKError(
`toNano conversion failed: ${err instanceof Error ? err.message : String(err)}`,
@@ -509,14 +524,12 @@ export function createTonSDK(log: PluginLogger, db: Database.Database | null): T
},
fromNano(nano: bigint | string): string {
- const { fromNano: convert } = require("@ton/ton");
- return convert(nano);
+ return tonFromNano(nano);
},
validateAddress(address: string): boolean {
try {
- const { Address } = require("@ton/core");
- Address.parse(address);
+ TonAddress.parse(address);
return true;
} catch {
return false;
diff --git a/src/telegram/admin.ts b/src/telegram/admin.ts
index 10d3994..45356ad 100644
--- a/src/telegram/admin.ts
+++ b/src/telegram/admin.ts
@@ -2,6 +2,7 @@ import type { TelegramConfig } from "../config/schema.js";
import type { AgentRuntime } from "../agent/runtime.js";
import { TelegramBridge } from "./bridge.js";
import { getWalletAddress, getWalletBalance } from "../ton/wallet-service.js";
+import { Address } from "@ton/core";
import { getProviderMetadata, type SupportedProvider } from "../config/providers.js";
import { DEALS_CONFIG } from "../deals/config.js";
import { loadTemplate } from "../workspace/manager.js";
@@ -20,7 +21,7 @@ export interface AdminCommand {
senderId: number;
}
-const VALID_DM_POLICIES = ["open", "allowlist", "pairing", "disabled"] as const;
+const VALID_DM_POLICIES = ["open", "allowlist", "disabled"] as const;
const VALID_GROUP_POLICIES = ["open", "allowlist", "disabled"] as const;
const VALID_MODULE_LEVELS = ["open", "admin", "disabled"] as const;
@@ -269,7 +270,8 @@ export class AdminHandler {
const result = await getWalletBalance(address);
if (!result) return "โ Failed to fetch balance.";
- return `๐ **${result.balance} TON**\n๐ \`${address}\``;
+ const friendly = Address.parse(address).toString({ bounceable: false });
+ return `๐ **${result.balance} TON**\n๐ \`${friendly}\``;
}
getBootstrapContent(): string | null {
diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts
index 63aa864..ae4239a 100644
--- a/src/telegram/bridge.ts
+++ b/src/telegram/bridge.ts
@@ -20,6 +20,7 @@ export interface TelegramMessage {
_rawPeer?: Api.TypePeer;
hasMedia: boolean;
mediaType?: "photo" | "document" | "video" | "audio" | "voice" | "sticker";
+ replyToId?: number;
_rawMessage?: Api.Message;
}
@@ -315,6 +316,8 @@ export class TelegramBridge {
else if (msg.sticker) mediaType = "sticker";
else if (msg.document) mediaType = "document";
+ const replyToMsgId = msg.replyToMsgId; // GramJS getter, returns number | undefined
+
let text = msg.message ?? "";
if (!text && msg.media) {
if (msg.media.className === "MessageMediaDice") {
@@ -352,7 +355,8 @@ export class TelegramBridge {
_rawPeer: msg.peerId,
hasMedia,
mediaType,
- _rawMessage: hasMedia ? msg : undefined,
+ replyToId: replyToMsgId,
+ _rawMessage: hasMedia || !!replyToMsgId ? msg : undefined,
};
}
@@ -360,6 +364,45 @@ export class TelegramBridge {
return this.peerCache.get(chatId);
}
+ async fetchReplyContext(
+ rawMsg: Api.Message
+ ): Promise<{ text?: string; senderName?: string; isAgent?: boolean } | undefined> {
+ try {
+ const replyMsg = await Promise.race([
+ rawMsg.getReplyMessage(),
+ new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)),
+ ]);
+ if (!replyMsg) return undefined;
+
+ let senderName: string | undefined;
+ try {
+ const sender = await Promise.race([
+ replyMsg.getSender(),
+ new Promise((resolve) => setTimeout(() => resolve(undefined), 5000)),
+ ]);
+ if (sender && "firstName" in sender) {
+ senderName = (sender.firstName as string) ?? undefined;
+ }
+ if (sender && "username" in sender && !senderName) {
+ senderName = (sender.username as string) ?? undefined;
+ }
+ } catch {
+ // Non-critical
+ }
+
+ const replyMsgSenderId = replyMsg.senderId ? BigInt(replyMsg.senderId.toString()) : undefined;
+ const isAgent = this.ownUserId !== undefined && replyMsgSenderId === this.ownUserId;
+
+ return {
+ text: replyMsg.message || undefined,
+ senderName,
+ isAgent,
+ };
+ } catch {
+ return undefined;
+ }
+ }
+
getClient(): TelegramUserClient {
return this.client;
}
diff --git a/src/telegram/client.ts b/src/telegram/client.ts
index ce3cfa2..3ff73af 100644
--- a/src/telegram/client.ts
+++ b/src/telegram/client.ts
@@ -106,15 +106,80 @@ export class TelegramUserClient {
await this.client.connect();
} else {
log.info("Starting authentication flow...");
- await this.client.start({
- phoneNumber: async () => this.config.phone || (await promptInput("Phone number: ")),
- phoneCode: async () => await promptInput("Verification code: "),
- password: async () => await promptInput("2FA password (if enabled): "),
- onError: (err) => log.error({ err }, "Auth error"),
- });
- log.info("Authenticated");
-
- this.saveSession();
+ const phone = this.config.phone || (await promptInput("Phone number: "));
+
+ await this.client.connect();
+
+ const sendResult = await this.client.invoke(
+ new Api.auth.SendCode({
+ phoneNumber: phone,
+ apiId: this.config.apiId,
+ apiHash: this.config.apiHash,
+ settings: new Api.CodeSettings({}),
+ })
+ );
+
+ // SentCodeSuccess means we're already authorized (e.g. session migration)
+ if (sendResult instanceof Api.auth.SentCodeSuccess) {
+ log.info("Authenticated (SentCodeSuccess)");
+ this.saveSession();
+ } else {
+ const phoneCodeHash = sendResult.phoneCodeHash;
+
+ // Detect Fragment SMS for anonymous numbers (+888)
+ if (sendResult.type instanceof Api.auth.SentCodeTypeFragmentSms) {
+ const url = (sendResult.type as any).url;
+ if (url) {
+ console.log(`\n Anonymous number โ open this URL to get your code:\n ${url}\n`);
+ }
+ }
+
+ let authenticated = false;
+ const maxAttempts = 3;
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const code = await promptInput("Verification code: ");
+
+ try {
+ await this.client.invoke(
+ new Api.auth.SignIn({
+ phoneNumber: phone,
+ phoneCodeHash,
+ phoneCode: code,
+ })
+ );
+ authenticated = true;
+ break;
+ } catch (err: any) {
+ if (err.errorMessage === "PHONE_CODE_INVALID") {
+ const remaining = maxAttempts - attempt - 1;
+ if (remaining > 0) {
+ console.log(`Invalid code. ${remaining} attempt(s) remaining.`);
+ } else {
+ throw new Error("Authentication failed: too many invalid code attempts");
+ }
+ } else if (err.errorMessage === "SESSION_PASSWORD_NEEDED") {
+ // 2FA required
+ const pwd = await promptInput("2FA password: ");
+ const { computeCheck } = await import("telegram/Password.js");
+ const srpResult = await this.client.invoke(new Api.account.GetPassword());
+ const srpCheck = await computeCheck(srpResult, pwd);
+ await this.client.invoke(new Api.auth.CheckPassword({ password: srpCheck }));
+ authenticated = true;
+ break;
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ if (!authenticated) {
+ throw new Error("Authentication failed");
+ }
+
+ log.info("Authenticated");
+ this.saveSession();
+ }
}
const me = (await this.client.getMe()) as Api.User;
diff --git a/src/telegram/handlers.ts b/src/telegram/handlers.ts
index bf5185b..cc34e45 100644
--- a/src/telegram/handlers.ts
+++ b/src/telegram/handlers.ts
@@ -15,6 +15,8 @@ import {
} from "../agent/tools/telegram/index.js";
import type { ToolContext } from "../agent/tools/types.js";
import { TELEGRAM_SEND_TOOLS } from "../constants/tools.js";
+import { telegramTranscribeAudioExecutor } from "../agent/tools/telegram/media/transcribe-audio.js";
+import { TYPING_REFRESH_MS } from "../constants/timeouts.js";
import { createLogger } from "../utils/logger.js";
const log = createLogger("Telegram");
@@ -123,6 +125,8 @@ export class MessageHandler {
private db: Database.Database;
private chatQueue: ChatQueue = new ChatQueue();
private pluginMessageHooks: Array<(e: PluginMessageEvent) => Promise> = [];
+ private recentMessageIds: Set = new Set();
+ private static readonly DEDUP_MAX_SIZE = 500;
constructor(
bridge: TelegramBridge,
@@ -204,16 +208,6 @@ export class MessageHandler {
};
}
break;
- case "pairing":
- if (!this.config.allow_from.includes(message.senderId) && !isAdmin) {
- return {
- message,
- isAdmin,
- shouldRespond: false,
- reason: "Not paired",
- };
- }
- break;
case "open":
break;
}
@@ -264,6 +258,17 @@ export class MessageHandler {
* Process and respond to a message
*/
async handleMessage(message: TelegramMessage): Promise {
+ // 0. Dedup โ GramJS may fire the same event multiple times via different MTProto update channels
+ if (this.recentMessageIds.has(message.id)) {
+ return;
+ }
+ this.recentMessageIds.add(message.id);
+ if (this.recentMessageIds.size > MessageHandler.DEDUP_MAX_SIZE) {
+ // Evict oldest half
+ const ids = [...this.recentMessageIds];
+ this.recentMessageIds = new Set(ids.slice(ids.length >> 1));
+ }
+
const msgType = message.isGroup ? "group" : message.isChannel ? "channel" : "dm";
log.debug(
`๐จ [Handler] Received ${msgType} message ${message.id} from ${message.senderId} (mentions: ${message.mentionsMe})`
@@ -337,89 +342,137 @@ export class MessageHandler {
return;
}
- // 4. Typing simulation if enabled
+ // 4. Persistent typing simulation if enabled
+ let typingInterval: ReturnType | undefined;
if (this.config.typing_simulation) {
await this.bridge.setTyping(message.chatId);
+ typingInterval = setInterval(() => {
+ void this.bridge.setTyping(message.chatId);
+ }, TYPING_REFRESH_MS);
}
- // 5. Get pending history for groups (if any)
- let pendingContext: string | null = null;
- if (message.isGroup) {
- pendingContext = this.pendingHistory.getAndClearPending(message.chatId);
- }
+ try {
+ // 5. Get pending history for groups (if any)
+ let pendingContext: string | null = null;
+ if (message.isGroup) {
+ pendingContext = this.pendingHistory.getAndClearPending(message.chatId);
+ }
- // 6. Build tool context
- const toolContext: Omit = {
- bridge: this.bridge,
- db: this.db,
- senderId: message.senderId,
- config: this.fullConfig,
- };
+ // 5b. Resolve reply context (only for messages we're responding to)
+ let replyContext: { text: string; senderName?: string; isAgent?: boolean } | undefined;
+ if (message.replyToId && message._rawMessage) {
+ const raw = await this.bridge.fetchReplyContext(message._rawMessage);
+ if (raw?.text) {
+ replyContext = { text: raw.text, senderName: raw.senderName, isAgent: raw.isAgent };
+ }
+ }
- // 7. Get response from agent (with tools)
- const userName =
- message.senderFirstName || message.senderUsername || `user:${message.senderId}`;
- const response = await this.agent.processMessage(
- message.chatId,
- message.text,
- userName,
- message.timestamp.getTime(),
- message.isGroup,
- pendingContext,
- toolContext,
- message.senderUsername,
- message.hasMedia,
- message.mediaType,
- message.id
- );
-
- // 8. Handle response based on whether tools were used
- const hasToolCalls = response.toolCalls && response.toolCalls.length > 0;
-
- // Check if agent used any Telegram send tool - it already sent the message
- const telegramSendCalled =
- hasToolCalls && response.toolCalls?.some((tc) => TELEGRAM_SEND_TOOLS.has(tc.name));
-
- if (!telegramSendCalled && response.content && response.content.trim().length > 0) {
- // Agent returned text but didn't use the send tool - send it manually
- let responseText = response.content;
-
- // Truncate if needed
- if (responseText.length > this.config.max_message_length) {
- responseText = responseText.slice(0, this.config.max_message_length - 3) + "...";
+ // 5c. Auto-transcribe voice/audio messages
+ 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,
+ }
+ );
+ if (transcribeResult.success && (transcribeResult.data as any)?.text) {
+ transcriptionText = (transcribeResult.data as any).text;
+ log.info(
+ `๐ค Auto-transcribed voice msg ${message.id}: "${transcriptionText!.substring(0, 80)}..."`
+ );
+ }
+ } catch (err) {
+ log.warn({ err }, `Failed to auto-transcribe voice message ${message.id}`);
+ }
}
- const sentMessage = await this.bridge.sendMessage({
- chatId: message.chatId,
- text: responseText,
- replyToId: message.id,
- });
+ // 6. Build tool context
+ const toolContext: Omit = {
+ bridge: this.bridge,
+ db: this.db,
+ senderId: message.senderId,
+ config: this.fullConfig,
+ };
+
+ // 7. Get response from agent (with tools)
+ const userName =
+ message.senderFirstName || message.senderUsername || `user:${message.senderId}`;
+ // Inject transcription into message text if available
+ const effectiveText = transcriptionText
+ ? `๐ค (voice): ${transcriptionText}${message.text ? `\n${message.text}` : ""}`
+ : message.text;
+ const response = await this.agent.processMessage(
+ message.chatId,
+ effectiveText,
+ userName,
+ message.timestamp.getTime(),
+ message.isGroup,
+ pendingContext,
+ toolContext,
+ message.senderUsername,
+ message.hasMedia,
+ message.mediaType,
+ message.id,
+ replyContext
+ );
+
+ // 8. Handle response based on whether tools were used
+ const hasToolCalls = response.toolCalls && response.toolCalls.length > 0;
+
+ // Check if agent used any Telegram send tool - it already sent the message
+ const telegramSendCalled =
+ hasToolCalls && response.toolCalls?.some((tc) => TELEGRAM_SEND_TOOLS.has(tc.name));
+
+ if (!telegramSendCalled && response.content && response.content.trim().length > 0) {
+ // Agent returned text but didn't use the send tool - send it manually
+ let responseText = response.content;
- // Store agent's response to feed
- await this.storeTelegramMessage(
- {
- id: sentMessage.id,
+ // Truncate if needed
+ if (responseText.length > this.config.max_message_length) {
+ responseText = responseText.slice(0, this.config.max_message_length - 3) + "...";
+ }
+
+ const sentMessage = await this.bridge.sendMessage({
chatId: message.chatId,
- senderId: this.ownUserId ? parseInt(this.ownUserId) : 0,
text: responseText,
- isGroup: message.isGroup,
- isChannel: message.isChannel,
- isBot: false,
- mentionsMe: false,
- timestamp: new Date(sentMessage.date * 1000),
- hasMedia: false,
- },
- true
- );
- }
+ replyToId: message.id,
+ });
+
+ // Store agent's response to feed
+ await this.storeTelegramMessage(
+ {
+ id: sentMessage.id,
+ chatId: message.chatId,
+ senderId: this.ownUserId ? parseInt(this.ownUserId) : 0,
+ text: responseText,
+ isGroup: message.isGroup,
+ isChannel: message.isChannel,
+ isBot: false,
+ mentionsMe: false,
+ timestamp: new Date(sentMessage.date * 1000),
+ hasMedia: false,
+ },
+ true
+ );
+ }
- // 9. Clear pending history after responding (for groups)
- if (message.isGroup) {
- this.pendingHistory.clearPending(message.chatId);
- }
+ // 9. Clear pending history after responding (for groups)
+ if (message.isGroup) {
+ this.pendingHistory.clearPending(message.chatId);
+ }
- // Mark as processed AFTER successful handling (prevents message loss on crash)
- writeOffset(message.id, message.chatId);
+ // Mark as processed AFTER successful handling (prevents message loss on crash)
+ writeOffset(message.id, message.chatId);
+ } finally {
+ if (typingInterval) clearInterval(typingInterval);
+ }
log.debug(`Processed message ${message.id} in chat ${message.chatId}`);
} catch (error) {
@@ -460,7 +513,7 @@ export class MessageHandler {
chatId: message.chatId,
senderId: message.senderId?.toString() ?? null,
text: message.text,
- replyToId: undefined,
+ replyToId: message.replyToId?.toString(),
isFromAgent,
hasMedia: message.hasMedia,
mediaType: message.mediaType,
diff --git a/src/ton/endpoint.ts b/src/ton/endpoint.ts
index 5364e90..0479192 100644
--- a/src/ton/endpoint.ts
+++ b/src/ton/endpoint.ts
@@ -1,9 +1,18 @@
const ENDPOINT_CACHE_TTL_MS = 60_000;
const ORBS_HOST = "ton.access.orbs.network";
const ORBS_TOPOLOGY_URL = `https://${ORBS_HOST}/mngr/nodes?npm_version=2.3.3`;
-const TONCENTER_FALLBACK = `https://toncenter.com/api/v2/jsonRPC`;
+const TONCENTER_URL = `https://toncenter.com/api/v2/jsonRPC`;
let _cache: { url: string; ts: number } | null = null;
+let _toncenterApiKey: string | undefined;
+
+export function setToncenterApiKey(key: string | undefined): void {
+ _toncenterApiKey = key;
+}
+
+export function getToncenterApiKey(): string | undefined {
+ return _toncenterApiKey;
+}
interface OrbsNode {
NodeId: string;
@@ -13,7 +22,7 @@ interface OrbsNode {
}
async function discoverOrbsEndpoint(): Promise {
- const res = await fetch(ORBS_TOPOLOGY_URL);
+ const res = await fetch(ORBS_TOPOLOGY_URL, { signal: AbortSignal.timeout(5_000) });
const nodes: OrbsNode[] = await res.json();
const healthy = nodes.filter(
@@ -35,17 +44,46 @@ async function discoverOrbsEndpoint(): Promise {
return `https://${ORBS_HOST}/${chosen.NodeId}/1/mainnet/toncenter-api-v2/jsonRPC`;
}
+/**
+ * With API key: TonCenter primary โ ORBS fallback.
+ * Without API key: ORBS primary โ TonCenter fallback (too slow for agent).
+ */
export async function getCachedHttpEndpoint(): Promise {
if (_cache && Date.now() - _cache.ts < ENDPOINT_CACHE_TTL_MS) {
return _cache.url;
}
let url: string;
- try {
- url = await discoverOrbsEndpoint();
- } catch {
- url = TONCENTER_FALLBACK;
+ if (_toncenterApiKey) {
+ // API key configured โ TonCenter primary
+ try {
+ const testUrl = `https://toncenter.com/api/v2/getAddressInformation?address=EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c`;
+ const res = await fetch(testUrl, {
+ headers: { "X-API-Key": _toncenterApiKey },
+ signal: AbortSignal.timeout(5_000),
+ });
+ if (!res.ok) throw new Error(`TonCenter ${res.status}`);
+ url = TONCENTER_URL;
+ } catch {
+ try {
+ url = await discoverOrbsEndpoint();
+ } catch {
+ url = TONCENTER_URL;
+ }
+ }
+ } else {
+ // No API key โ ORBS primary, TonCenter fallback
+ try {
+ url = await discoverOrbsEndpoint();
+ } catch {
+ url = TONCENTER_URL;
+ }
}
_cache = { url, ts: Date.now() };
return url;
}
+
+/** Call this when a node returns a 5xx error โ forces re-discovery on next call. */
+export function invalidateEndpointCache(): void {
+ _cache = null;
+}
diff --git a/src/ton/payment-verifier.ts b/src/ton/payment-verifier.ts
index acadf31..031a61a 100644
--- a/src/ton/payment-verifier.ts
+++ b/src/ton/payment-verifier.ts
@@ -1,7 +1,7 @@
import type Database from "better-sqlite3";
-import { TonClient, fromNano } from "@ton/ton";
+import { fromNano } from "@ton/ton";
import { Address } from "@ton/core";
-import { getCachedHttpEndpoint } from "./endpoint.js";
+import { getCachedTonClient } from "./wallet-service.js";
import { withBlockchainRetry } from "../utils/retry.js";
import { PAYMENT_TOLERANCE_RATIO } from "../constants/limits.js";
import { getErrorMessage } from "../utils/errors.js";
@@ -71,8 +71,7 @@ export async function verifyPayment(
maxPaymentAgeMinutes = DEFAULT_MAX_PAYMENT_AGE_MINUTES,
} = params;
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
const botAddress = Address.parse(botWalletAddress);
const transactions = await withBlockchainRetry(
diff --git a/src/ton/transfer.ts b/src/ton/transfer.ts
index af59ddd..87c7060 100644
--- a/src/ton/transfer.ts
+++ b/src/ton/transfer.ts
@@ -1,8 +1,8 @@
-import { WalletContractV5R1, TonClient, toNano, internal } from "@ton/ton";
+import { WalletContractV5R1, toNano, internal } from "@ton/ton";
import { Address, SendMode } from "@ton/core";
-import { getCachedHttpEndpoint } from "./endpoint.js";
-import { getKeyPair } from "./wallet-service.js";
+import { getKeyPair, getCachedTonClient, invalidateTonClientCache } from "./wallet-service.js";
import { createLogger } from "../utils/logger.js";
+import { withTxLock } from "./tx-lock.js";
const log = createLogger("TON");
@@ -14,60 +14,66 @@ export interface SendTonParams {
}
export async function sendTon(params: SendTonParams): Promise {
- try {
- const { toAddress, amount, comment = "", bounce = false } = params;
+ 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;
- }
+ if (!Number.isFinite(amount) || amount <= 0) {
+ log.error({ amount }, "Invalid transfer amount");
+ return null;
+ }
- let recipientAddress: Address;
- try {
- recipientAddress = Address.parse(toAddress);
- } catch (e) {
- log.error({ err: e }, `Invalid recipient address: ${toAddress}`);
- return null;
- }
+ let recipientAddress: Address;
+ try {
+ recipientAddress = Address.parse(toAddress);
+ } catch (e) {
+ log.error({ err: e }, `Invalid recipient address: ${toAddress}`);
+ return null;
+ }
- const keyPair = await getKeyPair();
- if (!keyPair) {
- log.error("Wallet not initialized");
- return null;
- }
+ const keyPair = await getKeyPair();
+ if (!keyPair) {
+ log.error("Wallet not initialized");
+ return null;
+ }
- const wallet = WalletContractV5R1.create({
- workchain: 0,
- publicKey: keyPair.publicKey,
- });
+ const wallet = WalletContractV5R1.create({
+ workchain: 0,
+ publicKey: keyPair.publicKey,
+ });
- const endpoint = await getCachedHttpEndpoint();
- const client = new TonClient({ endpoint });
- const contract = client.open(wallet);
+ const client = await getCachedTonClient();
+ const contract = client.open(wallet);
- const seqno = await contract.getSeqno();
+ const seqno = await contract.getSeqno();
- await contract.sendTransfer({
- seqno,
- secretKey: keyPair.secretKey,
- sendMode: SendMode.PAY_GAS_SEPARATELY,
- messages: [
- internal({
- to: recipientAddress,
- value: toNano(amount),
- body: comment,
- bounce,
- }),
- ],
- });
+ await contract.sendTransfer({
+ seqno,
+ secretKey: keyPair.secretKey,
+ sendMode: SendMode.PAY_GAS_SEPARATELY,
+ messages: [
+ internal({
+ to: recipientAddress,
+ value: toNano(amount),
+ body: comment,
+ bounce,
+ }),
+ ],
+ });
- const pseudoHash = `${seqno}_${Date.now()}_${amount.toFixed(2)}`;
+ const pseudoHash = `${seqno}_${Date.now()}_${amount.toFixed(2)}`;
- log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`);
+ log.info(`Sent ${amount} TON to ${toAddress.slice(0, 8)}... - seqno: ${seqno}`);
- return pseudoHash;
- } catch (error) {
- log.error({ err: error }, "Error sending TON");
- return null;
- }
+ return pseudoHash;
+ } catch (error: any) {
+ // Invalidate node cache on 429/5xx so next attempt picks a fresh node
+ const status = error?.status || error?.response?.status;
+ if (status === 429 || status >= 500) {
+ invalidateTonClientCache();
+ }
+ log.error({ err: error }, "Error sending TON");
+ throw error;
+ }
+ }); // withTxLock
}
diff --git a/src/ton/tx-lock.ts b/src/ton/tx-lock.ts
new file mode 100644
index 0000000..d85a75d
--- /dev/null
+++ b/src/ton/tx-lock.ts
@@ -0,0 +1,15 @@
+/**
+ * Simple async mutex for TON wallet transactions.
+ * Ensures the seqno read โ sendTransfer sequence is atomic,
+ * preventing two concurrent calls from getting the same seqno.
+ */
+let pending: Promise = Promise.resolve();
+
+export function withTxLock(fn: () => Promise): Promise {
+ const execute = pending.then(fn, fn);
+ pending = execute.then(
+ () => {},
+ () => {}
+ );
+ return execute;
+}
diff --git a/src/ton/wallet-service.ts b/src/ton/wallet-service.ts
index 10c742e..f5e522d 100644
--- a/src/ton/wallet-service.ts
+++ b/src/ton/wallet-service.ts
@@ -2,7 +2,7 @@ import { mnemonicNew, mnemonicToPrivateKey, mnemonicValidate } from "@ton/crypto
import { WalletContractV5R1, TonClient, fromNano } from "@ton/ton";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
-import { getCachedHttpEndpoint } from "./endpoint.js";
+import { getCachedHttpEndpoint, invalidateEndpointCache, getToncenterApiKey } from "./endpoint.js";
import { fetchWithTimeout } from "../utils/fetch.js";
import { TELETON_ROOT } from "../workspace/paths.js";
import { tonapiFetch, COINGECKO_API_URL } from "../constants/api-endpoints.js";
@@ -19,6 +19,9 @@ let _walletCache: WalletData | null | undefined; // undefined = not yet loaded
/** Cached key pair derived from mnemonic */
let _keyPairCache: { publicKey: Buffer; secretKey: Buffer } | null = null;
+/** Cached TonClient โ invalidated when endpoint rotates */
+let _tonClientCache: { client: TonClient; endpoint: string } | null = null;
+
export interface WalletData {
version: "w5r1";
address: string;
@@ -138,6 +141,30 @@ export function getWalletAddress(): string | null {
return wallet?.address || null;
}
+/**
+ * Get (or create) a cached TonClient.
+ * Re-creates only when the endpoint URL rotates (60s TTL on endpoint).
+ */
+export async function getCachedTonClient(): Promise {
+ const endpoint = await getCachedHttpEndpoint();
+ if (_tonClientCache && _tonClientCache.endpoint === endpoint) {
+ return _tonClientCache.client;
+ }
+ const apiKey = getToncenterApiKey();
+ const client = new TonClient({ endpoint, ...(apiKey && { apiKey }) });
+ _tonClientCache = { client, endpoint };
+ return client;
+}
+
+/**
+ * Invalidate the TonClient cache and the endpoint cache.
+ * Call this when a node returns a 5xx error so the next call picks a fresh node.
+ */
+export function invalidateTonClientCache(): void {
+ _tonClientCache = null;
+ invalidateEndpointCache();
+}
+
/**
* Get cached KeyPair (derives from mnemonic once, then reuses).
* Returns null if no wallet is configured.
@@ -160,10 +187,7 @@ export async function getWalletBalance(address: string): Promise<{
balanceNano: string;
} | null> {
try {
- // Get decentralized endpoint from orbs network (no rate limits)
- const endpoint = await getCachedHttpEndpoint();
-
- const client = new TonClient({ endpoint });
+ const client = await getCachedTonClient();
// Import Address from @ton/core
const { Address } = await import("@ton/core");
diff --git a/src/webui/__tests__/agent-routes.test.ts b/src/webui/__tests__/agent-routes.test.ts
new file mode 100644
index 0000000..f6265df
--- /dev/null
+++ b/src/webui/__tests__/agent-routes.test.ts
@@ -0,0 +1,196 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { AgentLifecycle } from "../../agent/lifecycle.js";
+
+// Build a minimal Hono app that mirrors the agent routes from server.ts
+function createTestApp(lifecycle?: AgentLifecycle) {
+ const app = new Hono();
+
+ // Simulate auth middleware: all requests are authenticated (we test auth separately)
+ app.post("/api/agent/start", async (c) => {
+ 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);
+ }
+ lifecycle.start().catch(() => {});
+ return c.json({ state: "starting" });
+ });
+
+ app.post("/api/agent/stop", async (c) => {
+ 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);
+ }
+ lifecycle.stop().catch(() => {});
+ return c.json({ state: "stopping" });
+ });
+
+ app.get("/api/agent/status", (c) => {
+ if (!lifecycle) {
+ return c.json({ error: "Agent lifecycle not available" }, 503);
+ }
+ return c.json({
+ state: lifecycle.getState(),
+ uptime: lifecycle.getUptime(),
+ error: lifecycle.getError() ?? null,
+ });
+ });
+
+ return app;
+}
+
+describe("Agent Lifecycle API Routes", () => {
+ let lifecycle: AgentLifecycle;
+ let app: ReturnType;
+
+ beforeEach(() => {
+ lifecycle = new AgentLifecycle();
+ lifecycle.registerCallbacks(
+ async () => {},
+ async () => {}
+ );
+ app = createTestApp(lifecycle);
+ });
+
+ // 1. POST /api/agent/start โ agent stopped
+ it("POST /api/agent/start returns 200 with starting when agent stopped", async () => {
+ const res = await app.request("/api/agent/start", { method: "POST" });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.state).toBe("starting");
+ });
+
+ // 2. POST /api/agent/start โ agent already running
+ it("POST /api/agent/start returns 409 when agent already running", async () => {
+ await lifecycle.start();
+ expect(lifecycle.getState()).toBe("running");
+
+ const res = await app.request("/api/agent/start", { method: "POST" });
+ expect(res.status).toBe(409);
+ const data = await res.json();
+ expect(data.state).toBe("running");
+ });
+
+ // 3. POST /api/agent/start โ agent stopping
+ it("POST /api/agent/start returns 409 when agent stopping", async () => {
+ await lifecycle.start();
+ let resolveStop!: () => void;
+ lifecycle.stop(
+ () =>
+ new Promise((resolve) => {
+ resolveStop = resolve;
+ })
+ );
+
+ const res = await app.request("/api/agent/start", { method: "POST" });
+ expect(res.status).toBe(409);
+ const data = await res.json();
+ expect(data.error).toContain("stopping");
+
+ resolveStop();
+ });
+
+ // 4. POST /api/agent/stop โ agent running
+ it("POST /api/agent/stop returns 200 with stopping when agent running", async () => {
+ await lifecycle.start();
+
+ const res = await app.request("/api/agent/stop", { method: "POST" });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.state).toBe("stopping");
+ });
+
+ // 5. POST /api/agent/stop โ agent already stopped
+ it("POST /api/agent/stop returns 409 when agent already stopped", async () => {
+ const res = await app.request("/api/agent/stop", { method: "POST" });
+ expect(res.status).toBe(409);
+ const data = await res.json();
+ expect(data.state).toBe("stopped");
+ });
+
+ // 6. POST /api/agent/stop โ agent starting
+ it("POST /api/agent/stop returns 409 when agent starting", async () => {
+ let resolveStart!: () => void;
+ lifecycle.start(
+ () =>
+ new Promise((resolve) => {
+ resolveStart = resolve;
+ })
+ );
+
+ const res = await app.request("/api/agent/stop", { method: "POST" });
+ expect(res.status).toBe(409);
+ const data = await res.json();
+ expect(data.error).toContain("starting");
+
+ resolveStart();
+ });
+
+ // 7. GET /api/agent/status โ returns current state
+ it("GET /api/agent/status returns current state", async () => {
+ const res = await app.request("/api/agent/status");
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.state).toBe("stopped");
+ expect(data.uptime).toBeNull();
+ expect(data.error).toBeNull();
+ });
+
+ // 8. All endpoints reject unauthenticated requests
+ // (Auth is handled by WebUIServer middleware, not route-level โ skipped here as
+ // the routes are under /api/* which has auth middleware. Tested via integration.)
+
+ // 9. GET /api/agent/events โ SSE content-type
+ // (Tested in agent-sse.test.ts)
+
+ // 10. POST /api/agent/start โ lifecycle not provided
+ it("returns 503 when lifecycle not provided", async () => {
+ const noLifecycleApp = createTestApp(undefined);
+
+ const startRes = await noLifecycleApp.request("/api/agent/start", { method: "POST" });
+ expect(startRes.status).toBe(503);
+
+ const stopRes = await noLifecycleApp.request("/api/agent/stop", { method: "POST" });
+ expect(stopRes.status).toBe(503);
+
+ const statusRes = await noLifecycleApp.request("/api/agent/status");
+ expect(statusRes.status).toBe(503);
+ });
+
+ // 11. GET /api/agent/status โ uptime is number when running, null when stopped
+ it("status uptime is number when running, null when stopped", async () => {
+ // Stopped
+ let res = await app.request("/api/agent/status");
+ let data = await res.json();
+ expect(data.uptime).toBeNull();
+
+ // Running
+ await lifecycle.start();
+ res = await app.request("/api/agent/status");
+ data = await res.json();
+ expect(typeof data.uptime).toBe("number");
+ expect(data.uptime).toBeGreaterThanOrEqual(0);
+ });
+});
diff --git a/src/webui/__tests__/agent-sse.test.ts b/src/webui/__tests__/agent-sse.test.ts
new file mode 100644
index 0000000..eacd36e
--- /dev/null
+++ b/src/webui/__tests__/agent-sse.test.ts
@@ -0,0 +1,226 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+import { streamSSE } from "hono/streaming";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { AgentLifecycle, type StateChangeEvent } from "../../agent/lifecycle.js";
+
+/** Parse SSE text into structured events */
+function parseSSE(text: string): Array<{ event?: string; data?: string; id?: string }> {
+ const events: Array<{ event?: string; data?: string; id?: string }> = [];
+ const blocks = text.split("\n\n").filter(Boolean);
+ for (const block of blocks) {
+ const entry: { event?: string; data?: string; id?: string } = {};
+ for (const line of block.split("\n")) {
+ if (line.startsWith("event:")) entry.event = line.slice(6).trim();
+ else if (line.startsWith("data:")) entry.data = line.slice(5).trim();
+ else if (line.startsWith("id:")) entry.id = line.slice(3).trim();
+ }
+ if (entry.event || entry.data) events.push(entry);
+ }
+ return events;
+}
+
+/** Build a mini Hono app with the SSE endpoint mirroring server.ts */
+function createSSEApp(lifecycle: AgentLifecycle) {
+ const app = new Hono();
+
+ app.get("/api/agent/events", (c) => {
+ 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;
+ 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);
+
+ // For testing: don't loop forever โ just wait briefly for events to propagate
+ await stream.sleep(50);
+
+ lifecycle.off("stateChange", onStateChange);
+ });
+ });
+
+ return app;
+}
+
+describe("Agent SSE Endpoint", () => {
+ let lifecycle: AgentLifecycle;
+ let app: ReturnType;
+
+ beforeEach(() => {
+ lifecycle = new AgentLifecycle();
+ lifecycle.registerCallbacks(
+ async () => {},
+ async () => {}
+ );
+ app = createSSEApp(lifecycle);
+ });
+
+ // 1. Initial connection pushes current state
+ it("initial connection pushes current state", async () => {
+ const res = await app.request("/api/agent/events");
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
+
+ const text = await res.text();
+ const events = parseSSE(text);
+ expect(events.length).toBeGreaterThanOrEqual(1);
+ expect(events[0].event).toBe("status");
+ const data = JSON.parse(events[0].data!);
+ expect(data.state).toBe("stopped");
+ });
+
+ // 2. State change emits SSE event
+ it("state change emits SSE event", async () => {
+ // Start agent so state is "running" when SSE connects
+ await lifecycle.start();
+
+ const sseApp = new Hono();
+ sseApp.get("/events", (c) => {
+ return streamSSE(c, async (stream) => {
+ let aborted = false;
+ stream.onAbort(() => {
+ aborted = true;
+ });
+
+ // Push current state
+ await stream.writeSSE({
+ event: "status",
+ data: JSON.stringify({ state: lifecycle.getState() }),
+ });
+
+ // Listen for state change then close
+ const onStateChange = (event: StateChangeEvent) => {
+ if (aborted) return;
+ stream.writeSSE({
+ event: "status",
+ data: JSON.stringify({ state: event.state }),
+ });
+ };
+
+ lifecycle.on("stateChange", onStateChange);
+
+ // Trigger a stop during the stream
+ lifecycle.stop().catch(() => {});
+
+ await stream.sleep(50);
+ lifecycle.off("stateChange", onStateChange);
+ });
+ });
+
+ const res = await sseApp.request("/events");
+ const text = await res.text();
+ const events = parseSSE(text);
+
+ // Should have initial "running" and then "stopping" and "stopped"
+ const states = events.map((e) => JSON.parse(e.data!).state);
+ expect(states).toContain("running");
+ expect(states).toContain("stopped");
+ });
+
+ // 3. Heartbeat sent after interval (we use short interval for test)
+ it("heartbeat (ping) is sent", async () => {
+ const sseApp = new Hono();
+ sseApp.get("/events", (c) => {
+ return streamSSE(c, async (stream) => {
+ // Send a ping immediately for test purposes
+ await stream.writeSSE({ event: "ping", data: "" });
+ });
+ });
+
+ const res = await sseApp.request("/events");
+ const text = await res.text();
+ const events = parseSSE(text);
+ const pings = events.filter((e) => e.event === "ping");
+ expect(pings.length).toBeGreaterThanOrEqual(1);
+ });
+
+ // 4. Client disconnect removes listener
+ it("client disconnect removes listener", async () => {
+ const initialListenerCount = lifecycle.listenerCount("stateChange");
+
+ // After SSE stream ends, listeners should be cleaned up
+ const res = await app.request("/api/agent/events");
+ await res.text(); // consume stream
+
+ // Listener should have been removed
+ expect(lifecycle.listenerCount("stateChange")).toBe(initialListenerCount);
+ });
+
+ // 5. Multiple concurrent SSE clients
+ it("multiple concurrent SSE clients receive events independently", async () => {
+ const res1 = app.request("/api/agent/events");
+ const res2 = app.request("/api/agent/events");
+
+ const [r1, r2] = await Promise.all([res1, res2]);
+ const text1 = await r1.text();
+ const text2 = await r2.text();
+
+ // Both should have received the initial status event
+ const events1 = parseSSE(text1);
+ const events2 = parseSSE(text2);
+ expect(events1.length).toBeGreaterThanOrEqual(1);
+ expect(events2.length).toBeGreaterThanOrEqual(1);
+ expect(events1[0].event).toBe("status");
+ expect(events2[0].event).toBe("status");
+ });
+
+ // 6. Error in stream handler doesn't crash server
+ it("error in stream handler does not crash server", async () => {
+ const errorApp = new Hono();
+ errorApp.get("/events", (c) => {
+ return streamSSE(c, async (stream) => {
+ await stream.writeSSE({ event: "status", data: '{"state":"stopped"}' });
+ // Simulate error โ stream closes but server stays up
+ throw new Error("simulated stream error");
+ });
+ });
+
+ // Should not throw
+ const res = await errorApp.request("/events");
+ expect(res.status).toBe(200);
+ // Stream still returned something before the error
+ const text = await res.text();
+ expect(text).toContain("status");
+ });
+
+ // Extra: SSE content-type header
+ it("returns text/event-stream content type", async () => {
+ const res = await app.request("/api/agent/events");
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
+ });
+});
diff --git a/src/webui/__tests__/config-array-routes.test.ts b/src/webui/__tests__/config-array-routes.test.ts
new file mode 100644
index 0000000..ad544da
--- /dev/null
+++ b/src/webui/__tests__/config-array-routes.test.ts
@@ -0,0 +1,205 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+// Mock readRawConfig and writeRawConfig, keep everything else real
+const mockReadRawConfig = vi.fn();
+const mockWriteRawConfig = vi.fn();
+
+vi.mock("../../config/configurable-keys.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ readRawConfig: (...args: any[]) => mockReadRawConfig(...args),
+ writeRawConfig: (...args: any[]) => mockWriteRawConfig(...args),
+ };
+});
+
+import { createConfigRoutes } from "../routes/config.js";
+import type { WebUIServerDeps } from "../types.js";
+
+function createTestApp(mockConfig: Record) {
+ const deps = {
+ configPath: "/tmp/test.yaml",
+ agent: {
+ getConfig: () => mockConfig,
+ },
+ } as unknown as WebUIServerDeps;
+
+ const app = new Hono();
+ app.route("/api/config", createConfigRoutes(deps));
+ return app;
+}
+
+describe("GET /api/config โ array keys", () => {
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = createTestApp({});
+ });
+
+ it("returns array value as JSON string", async () => {
+ mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123, 456] } });
+
+ const res = await app.request("/api/config");
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids");
+ expect(keyData.value).toBe("[123,456]");
+ });
+
+ it("returns type 'array' and itemType 'number'", async () => {
+ mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123] } });
+
+ const res = await app.request("/api/config");
+ const json = await res.json();
+ const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids");
+ expect(keyData.type).toBe("array");
+ expect(keyData.itemType).toBe("number");
+ });
+
+ it("returns null value for unset array", async () => {
+ mockReadRawConfig.mockReturnValue({ telegram: {} });
+
+ const res = await app.request("/api/config");
+ const json = await res.json();
+ const keyData = json.data.find((k: any) => k.key === "telegram.admin_ids");
+ expect(keyData.set).toBe(false);
+ expect(keyData.value).toBeNull();
+ });
+});
+
+describe("PUT /api/config/:key โ arrays", () => {
+ let app: ReturnType;
+ let mockConfig: Record;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockConfig = { telegram: {} };
+ app = createTestApp(mockConfig);
+ mockReadRawConfig.mockReturnValue({ telegram: {} });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("accepts valid array of strings", async () => {
+ const res = await app.request("/api/config/telegram.admin_ids", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: ["123", "456"] }),
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.success).toBe(true);
+
+ // writeRawConfig should be called with parsed numbers
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ const rawArg = mockWriteRawConfig.mock.calls[0][0];
+ expect(rawArg.telegram.admin_ids).toEqual([123, 456]);
+ });
+
+ it("rejects non-array value for array key", async () => {
+ const res = await app.request("/api/config/telegram.admin_ids", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "123" }),
+ });
+ expect(res.status).toBe(400);
+ const json = await res.json();
+ expect(json.error).toContain("must be an array");
+ });
+
+ it("rejects array with invalid item", async () => {
+ const res = await app.request("/api/config/telegram.admin_ids", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: ["123", "abc"] }),
+ });
+ expect(res.status).toBe(400);
+ const json = await res.json();
+ expect(json.success).toBe(false);
+ });
+
+ it("accepts empty array", async () => {
+ const res = await app.request("/api/config/telegram.admin_ids", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: [] }),
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.success).toBe(true);
+
+ const rawArg = mockWriteRawConfig.mock.calls[0][0];
+ expect(rawArg.telegram.admin_ids).toEqual([]);
+ });
+
+ it("updates runtime config for array", async () => {
+ await app.request("/api/config/telegram.admin_ids", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: ["123"] }),
+ });
+
+ expect(mockConfig.telegram.admin_ids).toEqual([123]);
+ });
+});
+
+describe("PUT /api/config/:key โ existing scalars unchanged", () => {
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = createTestApp({ agent: { model: "old-model" } });
+ mockReadRawConfig.mockReturnValue({ agent: { model: "old-model" } });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("still accepts string value for string key", async () => {
+ const res = await app.request("/api/config/agent.model", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "claude-opus-4-6" }),
+ });
+ expect(res.status).toBe(200);
+ });
+
+ it("still rejects non-whitelisted key", async () => {
+ const res = await app.request("/api/config/some.unknown.key", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "x" }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("DELETE /api/config/:key โ arrays", () => {
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = createTestApp({ telegram: { admin_ids: [123] } });
+ mockReadRawConfig.mockReturnValue({ telegram: { admin_ids: [123] } });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("unsets array key", async () => {
+ const res = await app.request("/api/config/telegram.admin_ids", {
+ method: "DELETE",
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.success).toBe(true);
+ expect(json.data.set).toBe(false);
+ expect(json.data.value).toBeNull();
+ });
+});
diff --git a/src/webui/__tests__/config-side-effects.test.ts b/src/webui/__tests__/config-side-effects.test.ts
new file mode 100644
index 0000000..1aedc5e
--- /dev/null
+++ b/src/webui/__tests__/config-side-effects.test.ts
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+// Mock readRawConfig and writeRawConfig, keep everything else real
+const mockReadRawConfig = vi.fn();
+const mockWriteRawConfig = vi.fn();
+
+vi.mock("../../config/configurable-keys.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ readRawConfig: (...args: any[]) => mockReadRawConfig(...args),
+ writeRawConfig: (...args: any[]) => mockWriteRawConfig(...args),
+ };
+});
+
+// Mock the side-effect targets
+const mockSetTonapiKey = vi.fn();
+const mockSetToncenterApiKey = vi.fn();
+const mockInvalidateEndpointCache = vi.fn();
+const mockInvalidateTonClientCache = vi.fn();
+
+vi.mock("../../constants/api-endpoints.js", () => ({
+ setTonapiKey: (...args: any[]) => mockSetTonapiKey(...args),
+}));
+vi.mock("../../ton/endpoint.js", () => ({
+ setToncenterApiKey: (...args: any[]) => mockSetToncenterApiKey(...args),
+ invalidateEndpointCache: (...args: any[]) => mockInvalidateEndpointCache(...args),
+}));
+vi.mock("../../ton/wallet-service.js", () => ({
+ invalidateTonClientCache: (...args: any[]) => mockInvalidateTonClientCache(...args),
+}));
+
+import { createConfigRoutes } from "../routes/config.js";
+import type { WebUIServerDeps } from "../types.js";
+
+function createTestApp(mockConfig: Record) {
+ const deps = {
+ configPath: "/tmp/test.yaml",
+ agent: {
+ getConfig: () => mockConfig,
+ },
+ } as unknown as WebUIServerDeps;
+
+ const app = new Hono();
+ app.route("/api/config", createConfigRoutes(deps));
+ return app;
+}
+
+describe("Config side-effects on PUT/DELETE", () => {
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = createTestApp({ agent: { api_key: "sk-test" } });
+ mockReadRawConfig.mockReturnValue({ agent: { api_key: "sk-test" } });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("PUT tonapi_key calls setTonapiKey", async () => {
+ const res = await app.request("/api/config/tonapi_key", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "test-key-12345" }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockSetTonapiKey).toHaveBeenCalledWith("test-key-12345");
+ });
+
+ it("PUT toncenter_api_key calls all three setters", async () => {
+ const res = await app.request("/api/config/toncenter_api_key", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "toncenter-key-123" }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockSetToncenterApiKey).toHaveBeenCalledWith("toncenter-key-123");
+ expect(mockInvalidateEndpointCache).toHaveBeenCalled();
+ expect(mockInvalidateTonClientCache).toHaveBeenCalled();
+ });
+
+ it("DELETE tonapi_key calls setTonapiKey(undefined)", async () => {
+ const res = await app.request("/api/config/tonapi_key", {
+ method: "DELETE",
+ });
+ expect(res.status).toBe(200);
+ expect(mockSetTonapiKey).toHaveBeenCalledWith(undefined);
+ });
+
+ it("DELETE toncenter_api_key calls all three with undefined", async () => {
+ const res = await app.request("/api/config/toncenter_api_key", {
+ method: "DELETE",
+ });
+ expect(res.status).toBe(200);
+ expect(mockSetToncenterApiKey).toHaveBeenCalledWith(undefined);
+ expect(mockInvalidateEndpointCache).toHaveBeenCalled();
+ expect(mockInvalidateTonClientCache).toHaveBeenCalled();
+ });
+
+ it("PUT a non-side-effect key does NOT call any setter", async () => {
+ const res = await app.request("/api/config/agent.temperature", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ value: "0.7" }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockSetTonapiKey).not.toHaveBeenCalled();
+ expect(mockSetToncenterApiKey).not.toHaveBeenCalled();
+ expect(mockInvalidateEndpointCache).not.toHaveBeenCalled();
+ expect(mockInvalidateTonClientCache).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/webui/__tests__/setup-auth-fragment.test.ts b/src/webui/__tests__/setup-auth-fragment.test.ts
new file mode 100644
index 0000000..0d2d2b3
--- /dev/null
+++ b/src/webui/__tests__/setup-auth-fragment.test.ts
@@ -0,0 +1,241 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+// โโ Mocks (before imports) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+const mockConnect = vi.fn();
+const mockDisconnect = vi.fn();
+const mockInvoke = vi.fn();
+const mockSessionSave = vi.fn(() => "session-string");
+
+vi.mock("telegram", () => {
+ class TelegramClient {
+ session = { save: mockSessionSave };
+ connected = true;
+ connect = mockConnect;
+ disconnect = mockDisconnect;
+ invoke = mockInvoke;
+ }
+
+ // Minimal Api stubs
+ const Api = {
+ auth: {
+ SendCode: class {
+ constructor(public args: unknown) {}
+ },
+ SignIn: class {
+ constructor(public args: unknown) {}
+ },
+ ResendCode: class {
+ constructor(public args: unknown) {}
+ },
+ SentCode: class SentCode {
+ phoneCodeHash: string;
+ type: unknown;
+ constructor(args: { phoneCodeHash: string; type: unknown }) {
+ this.phoneCodeHash = args.phoneCodeHash;
+ this.type = args.type;
+ }
+ },
+ SentCodeSuccess: class SentCodeSuccess {},
+ SentCodeTypeApp: class SentCodeTypeApp {
+ length: number;
+ constructor(args: { length: number }) {
+ this.length = args.length;
+ }
+ },
+ SentCodeTypeFragmentSms: class SentCodeTypeFragmentSms {
+ url: string;
+ length: number;
+ constructor(args: { url: string; length: number }) {
+ this.url = args.url;
+ this.length = args.length;
+ }
+ },
+ SentCodeTypeSms: class SentCodeTypeSms {
+ length: number;
+ constructor(args: { length: number }) {
+ this.length = args.length;
+ }
+ },
+ Authorization: class Authorization {
+ user: unknown;
+ constructor(args: { user: unknown }) {
+ this.user = args.user;
+ }
+ },
+ },
+ CodeSettings: class {
+ constructor(_args?: unknown) {}
+ },
+ User: class User {
+ id: bigint;
+ firstName: string;
+ username?: string;
+ constructor(args: { id: bigint; firstName: string; username?: string }) {
+ this.id = args.id;
+ this.firstName = args.firstName;
+ this.username = args.username;
+ }
+ },
+ account: {
+ GetPassword: class {
+ constructor() {}
+ },
+ },
+ };
+
+ return { TelegramClient, Api };
+});
+
+vi.mock("telegram/sessions/index.js", () => ({
+ StringSession: class {
+ constructor(_s?: string) {}
+ },
+}));
+
+vi.mock("telegram/Password.js", () => ({
+ computeCheck: vi.fn(),
+}));
+
+vi.mock("telegram/extensions/Logger.js", () => ({
+ Logger: class {
+ constructor(_level: unknown) {}
+ },
+ LogLevel: { NONE: 0 },
+}));
+
+vi.mock("fs", () => ({
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ existsSync: vi.fn(() => true),
+}));
+
+vi.mock("../../workspace/paths.js", () => ({
+ TELETON_ROOT: "/tmp/teleton-test",
+}));
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+// โโ Imports (after mocks) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+import { TelegramAuthManager } from "../setup-auth.js";
+import { Api } from "telegram";
+
+// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+function makeSentCode(type: unknown, phoneCodeHash = "hash-abc") {
+ const result = new Api.auth.SentCode({ phoneCodeHash, type });
+ return result;
+}
+
+// โโ Tests โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+describe("TelegramAuthManager โ Fragment support", () => {
+ let manager: TelegramAuthManager;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ manager = new TelegramAuthManager();
+ });
+
+ afterEach(async () => {
+ await manager.cleanup();
+ });
+
+ describe("sendCode", () => {
+ it("detects SentCodeTypeFragmentSms and returns codeDelivery 'fragment' with url", async () => {
+ const fragmentType = new Api.auth.SentCodeTypeFragmentSms({
+ url: "https://fragment.com/number/88812345678",
+ length: 5,
+ });
+ mockInvoke.mockResolvedValue(makeSentCode(fragmentType));
+
+ const result = await manager.sendCode(12345, "abcdef", "+88812345678");
+
+ expect(result.codeDelivery).toBe("fragment");
+ expect(result.fragmentUrl).toBe("https://fragment.com/number/88812345678");
+ expect(result.codeLength).toBe(5);
+ expect(result.authSessionId).toBeTruthy();
+ });
+
+ it("detects SentCodeTypeApp and returns codeDelivery 'app'", async () => {
+ const appType = new Api.auth.SentCodeTypeApp({ length: 5 });
+ mockInvoke.mockResolvedValue(makeSentCode(appType));
+
+ const result = await manager.sendCode(12345, "abcdef", "+1234567890");
+
+ expect(result.codeDelivery).toBe("app");
+ expect(result.fragmentUrl).toBeUndefined();
+ expect(result.codeLength).toBe(5);
+ });
+
+ it("detects SentCodeTypeSms and returns codeDelivery 'sms'", async () => {
+ const smsType = new Api.auth.SentCodeTypeSms({ length: 5 });
+ mockInvoke.mockResolvedValue(makeSentCode(smsType));
+
+ const result = await manager.sendCode(12345, "abcdef", "+1234567890");
+
+ expect(result.codeDelivery).toBe("sms");
+ expect(result.fragmentUrl).toBeUndefined();
+ expect(result.codeLength).toBe(5);
+ });
+ });
+
+ describe("resendCode", () => {
+ it("detects Fragment on resend and returns codeDelivery 'fragment'", async () => {
+ // First, sendCode to create a session
+ const smsType = new Api.auth.SentCodeTypeSms({ length: 5 });
+ mockInvoke.mockResolvedValueOnce(makeSentCode(smsType));
+ const sendResult = await manager.sendCode(12345, "abcdef", "+88812345678");
+
+ // Now resendCode returns Fragment
+ const fragmentType = new Api.auth.SentCodeTypeFragmentSms({
+ url: "https://fragment.com/number/88812345678",
+ length: 5,
+ });
+ mockInvoke.mockResolvedValueOnce(makeSentCode(fragmentType, "hash-new"));
+
+ const result = await manager.resendCode(sendResult.authSessionId);
+
+ expect(result).not.toBeNull();
+ expect(result!.codeDelivery).toBe("fragment");
+ expect(result!.fragmentUrl).toBe("https://fragment.com/number/88812345678");
+ expect(result!.codeLength).toBe(5);
+ });
+ });
+
+ describe("verifyCode", () => {
+ it("verifies code for Fragment number (same path as regular)", async () => {
+ // Send code first
+ const fragmentType = new Api.auth.SentCodeTypeFragmentSms({
+ url: "https://fragment.com/number/88812345678",
+ length: 5,
+ });
+ mockInvoke.mockResolvedValueOnce(makeSentCode(fragmentType));
+ const sendResult = await manager.sendCode(12345, "abcdef", "+88812345678");
+
+ // Verify code โ SignIn returns Authorization
+ const mockUser = new Api.User({
+ id: BigInt(123),
+ firstName: "Fragment",
+ username: "fraguser",
+ });
+ const authResult = new Api.auth.Authorization({ user: mockUser });
+ mockInvoke.mockResolvedValueOnce(authResult);
+
+ const result = await manager.verifyCode(sendResult.authSessionId, "12345");
+
+ expect(result.status).toBe("authenticated");
+ expect(result.user).toBeDefined();
+ expect(result.user!.firstName).toBe("Fragment");
+ expect(result.user!.username).toBe("fraguser");
+ });
+ });
+});
diff --git a/src/webui/__tests__/setup-routes.test.ts b/src/webui/__tests__/setup-routes.test.ts
index 81568ad..588eb36 100644
--- a/src/webui/__tests__/setup-routes.test.ts
+++ b/src/webui/__tests__/setup-routes.test.ts
@@ -51,8 +51,8 @@ vi.mock("../../config/providers.js", () => ({
{
id: "anthropic",
displayName: "Anthropic (Claude)",
- defaultModel: "claude-opus-4-5-20251101",
- utilityModel: "claude-3-5-haiku-20241022",
+ defaultModel: "claude-opus-4-6",
+ utilityModel: "claude-haiku-4-5-20251001",
toolLimit: null,
keyPrefix: "sk-ant-",
consoleUrl: "https://console.anthropic.com/",
@@ -70,7 +70,7 @@ vi.mock("../../config/providers.js", () => ({
getProviderMetadata: vi.fn(() => ({
id: "anthropic",
displayName: "Anthropic (Claude)",
- defaultModel: "claude-opus-4-5-20251101",
+ defaultModel: "claude-opus-4-6",
})),
validateApiKeyFormat: vi.fn(),
}));
@@ -175,8 +175,8 @@ describe("Setup API Routes", () => {
{
id: "anthropic",
displayName: "Anthropic (Claude)",
- defaultModel: "claude-opus-4-5-20251101",
- utilityModel: "claude-3-5-haiku-20241022",
+ defaultModel: "claude-opus-4-6",
+ utilityModel: "claude-haiku-4-5-20251001",
toolLimit: null,
keyPrefix: "sk-ant-",
consoleUrl: "https://console.anthropic.com/",
@@ -194,7 +194,7 @@ describe("Setup API Routes", () => {
(getProviderMetadata as Mock).mockReturnValue({
id: "anthropic",
displayName: "Anthropic (Claude)",
- defaultModel: "claude-opus-4-5-20251101",
+ defaultModel: "claude-opus-4-6",
});
(validateApiKeyFormat as Mock).mockReturnValue(undefined);
(ConfigSchema.parse as Mock).mockImplementation((v: unknown) => v);
@@ -612,6 +612,47 @@ describe("Setup API Routes", () => {
const data = await res.json();
expect(data.error).toBe("PHONE_NUMBER_INVALID");
});
+
+ it("returns codeDelivery: fragment with fragmentUrl for +888 numbers", async () => {
+ mockAuthManager.sendCode.mockResolvedValue({
+ authSessionId: "sess-frag-1",
+ codeDelivery: "fragment",
+ fragmentUrl: "https://fragment.com/number/88812345678",
+ codeLength: 5,
+ expiresAt: Date.now() + 300000,
+ });
+
+ const res = await post(app, "/telegram/send-code", {
+ apiId: 12345,
+ apiHash: "abcdef",
+ phone: "+88812345678",
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.data.codeDelivery).toBe("fragment");
+ expect(data.data.fragmentUrl).toBe("https://fragment.com/number/88812345678");
+ expect(data.data.authSessionId).toBe("sess-frag-1");
+ });
+
+ it("returns codeDelivery: app for Telegram app delivery", async () => {
+ mockAuthManager.sendCode.mockResolvedValue({
+ authSessionId: "sess-app-1",
+ codeDelivery: "app",
+ codeLength: 5,
+ expiresAt: Date.now() + 300000,
+ });
+
+ const res = await post(app, "/telegram/send-code", {
+ apiId: 12345,
+ apiHash: "abcdef",
+ phone: "+1234567890",
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.data.codeDelivery).toBe("app");
+ expect(data.data.fragmentUrl).toBeUndefined();
+ });
});
// โโ POST /telegram/verify-code โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -760,6 +801,23 @@ describe("Setup API Routes", () => {
});
expect(res.status).toBe(429);
});
+
+ it("returns codeDelivery: fragment with fragmentUrl on resend", async () => {
+ mockAuthManager.resendCode.mockResolvedValue({
+ codeDelivery: "fragment",
+ fragmentUrl: "https://fragment.com/number/88812345678",
+ codeLength: 5,
+ });
+
+ const res = await post(app, "/telegram/resend-code", {
+ authSessionId: "sess-frag-1",
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.success).toBe(true);
+ expect(data.data.codeDelivery).toBe("fragment");
+ expect(data.data.fragmentUrl).toBe("https://fragment.com/number/88812345678");
+ });
});
// โโ DELETE /telegram/session โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -803,7 +861,7 @@ describe("Setup API Routes", () => {
agent: {
provider: "anthropic",
api_key: "sk-ant-api03-test",
- model: "claude-opus-4-5-20251101",
+ model: "claude-opus-4-6",
max_agentic_iterations: 5,
},
telegram: {
@@ -874,7 +932,7 @@ describe("Setup API Routes", () => {
const writeCall = (writeFileSync as Mock).mock.calls[0];
// The model should fall back to providerMeta.defaultModel
- expect(writeCall[1]).toContain("claude-opus-4-5-20251101");
+ expect(writeCall[1]).toContain("claude-opus-4-6");
});
it("writes config with restricted permissions (0o600)", async () => {
diff --git a/src/webui/__tests__/tools-rag-persistence.test.ts b/src/webui/__tests__/tools-rag-persistence.test.ts
new file mode 100644
index 0000000..909877f
--- /dev/null
+++ b/src/webui/__tests__/tools-rag-persistence.test.ts
@@ -0,0 +1,202 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+const mockReadRawConfig = vi.fn();
+const mockWriteRawConfig = vi.fn();
+
+vi.mock("../../config/configurable-keys.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ readRawConfig: (...args: any[]) => mockReadRawConfig(...args),
+ writeRawConfig: (...args: any[]) => mockWriteRawConfig(...args),
+ };
+});
+
+import { createToolsRoutes } from "../routes/tools.js";
+import type { WebUIServerDeps } from "../types.js";
+
+function createTestApp(config: Record) {
+ const deps = {
+ configPath: "/tmp/test.yaml",
+ agent: {
+ getConfig: () => config,
+ },
+ toolRegistry: {
+ getAll: () => [],
+ getAvailableModules: () => [],
+ getModuleTools: () => [],
+ getToolConfig: () => null,
+ getToolCategory: () => undefined,
+ getToolIndex: () => ({ isIndexed: true }),
+ count: 50,
+ has: () => false,
+ isPluginModule: () => false,
+ },
+ } as unknown as WebUIServerDeps;
+
+ const app = new Hono();
+ app.route("/api/tools", createToolsRoutes(deps));
+ return app;
+}
+
+function defaultConfig() {
+ return {
+ tool_rag: {
+ enabled: false,
+ top_k: 20,
+ always_include: [] as string[],
+ skip_unlimited_providers: false,
+ },
+ };
+}
+
+describe("PUT /api/tools/rag โ persistence", () => {
+ let config: ReturnType;
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ config = defaultConfig();
+ app = createTestApp(config);
+ mockReadRawConfig.mockReturnValue({ tool_rag: { enabled: false, top_k: 20 } });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("persists enabled change to YAML", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it("persists topK change to YAML", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ topK: 30 }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ // Verify the raw config was updated with top_k
+ const rawArg = mockWriteRawConfig.mock.calls[0][0];
+ expect(rawArg.tool_rag.top_k).toBe(30);
+ });
+
+ it("persists both enabled and topK together", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false, topK: 15 }),
+ });
+ expect(res.status).toBe(200);
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ const rawArg = mockWriteRawConfig.mock.calls[0][0];
+ expect(rawArg.tool_rag.enabled).toBe(false);
+ expect(rawArg.tool_rag.top_k).toBe(15);
+ });
+});
+
+describe("PUT /api/tools/rag โ new fields", () => {
+ let config: ReturnType;
+ let app: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ config = defaultConfig();
+ app = createTestApp(config);
+ mockReadRawConfig.mockReturnValue({ tool_rag: { enabled: false, top_k: 20 } });
+ mockWriteRawConfig.mockImplementation(() => {});
+ });
+
+ it("accepts and persists alwaysInclude", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alwaysInclude: ["telegram_send_*", "journal_*"] }),
+ });
+ expect(res.status).toBe(200);
+ expect(config.tool_rag.always_include).toEqual(["telegram_send_*", "journal_*"]);
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it("accepts and persists skipUnlimitedProviders", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ skipUnlimitedProviders: true }),
+ });
+ expect(res.status).toBe(200);
+ expect(config.tool_rag.skip_unlimited_providers).toBe(true);
+ expect(mockWriteRawConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it("validates alwaysInclude is array of strings", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alwaysInclude: "not-array" }),
+ });
+ expect(res.status).toBe(400);
+ const json = await res.json();
+ expect(json.success).toBe(false);
+ });
+
+ it("validates alwaysInclude items are non-empty", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alwaysInclude: ["valid", ""] }),
+ });
+ expect(res.status).toBe(400);
+ const json = await res.json();
+ expect(json.success).toBe(false);
+ });
+
+ it("returns updated alwaysInclude in response", async () => {
+ const res = await app.request("/api/tools/rag", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alwaysInclude: ["web_*"] }),
+ });
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.alwaysInclude).toEqual(["web_*"]);
+ });
+});
+
+describe("GET /api/tools/rag โ existing behavior preserved", () => {
+ it("returns alwaysInclude from config", async () => {
+ const config = defaultConfig();
+ config.tool_rag.always_include = ["telegram_send_*"];
+ const app = createTestApp(config);
+
+ const res = await app.request("/api/tools/rag");
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.alwaysInclude).toEqual(["telegram_send_*"]);
+ });
+
+ it("returns skipUnlimitedProviders from config", async () => {
+ const config = defaultConfig();
+ config.tool_rag.skip_unlimited_providers = false;
+ const app = createTestApp(config);
+
+ const res = await app.request("/api/tools/rag");
+ expect(res.status).toBe(200);
+ const json = await res.json();
+ expect(json.data.skipUnlimitedProviders).toBe(false);
+ });
+});
diff --git a/src/webui/__tests__/validate-step.test.ts b/src/webui/__tests__/validate-step.test.ts
index 283e7a0..ffe6288 100644
--- a/src/webui/__tests__/validate-step.test.ts
+++ b/src/webui/__tests__/validate-step.test.ts
@@ -25,7 +25,7 @@ function makeData(overrides: Partial = {}): WizardData {
tonapiKey: "",
tavilyKey: "",
customizeThresholds: false,
- buyMaxFloor: 100,
+ buyMaxFloor: 95,
sellMinFloor: 105,
walletAction: "generate",
mnemonic: "",
@@ -101,100 +101,88 @@ describe("validateStep", () => {
});
});
- // โโ Step 2: Telegram โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- describe("step 2 โ Telegram", () => {
- const validTelegram = {
- apiId: 123456,
- apiHash: "abcdef1234",
- phone: "+33612345678",
- userId: 456,
- };
-
- it("returns true with all valid Telegram fields", () => {
- expect(validateStep(2, makeData(validTelegram))).toBe(true);
- });
-
- it("returns false when apiId is 0", () => {
- expect(validateStep(2, makeData({ ...validTelegram, apiId: 0 }))).toBe(false);
- });
-
- it("returns false when apiHash is too short", () => {
- expect(validateStep(2, makeData({ ...validTelegram, apiHash: "short" }))).toBe(false);
- });
-
- it("returns true when apiHash is exactly 10 characters", () => {
- expect(validateStep(2, makeData({ ...validTelegram, apiHash: "1234567890" }))).toBe(true);
- });
-
- it("returns false when phone does not start with +", () => {
- expect(validateStep(2, makeData({ ...validTelegram, phone: "33612345678" }))).toBe(false);
- });
-
- it("returns false when userId is 0", () => {
- expect(validateStep(2, makeData({ ...validTelegram, userId: 0 }))).toBe(false);
- });
- });
-
- // โโ Step 3: Config โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- describe("step 3 โ Config", () => {
- it("returns true with a model selected", () => {
- expect(validateStep(3, makeData({ provider: "anthropic", model: "claude-sonnet" }))).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(3, makeData({ provider: "anthropic", model: "" }))).toBe(false);
+ expect(validateStep(2, makeData({ provider: "anthropic", model: "", userId: 123 }))).toBe(
+ false
+ );
});
it("returns true with __custom__ and customModel set", () => {
expect(
validateStep(
- 3,
- makeData({ provider: "anthropic", model: "__custom__", customModel: "my-model" })
+ 2,
+ makeData({
+ provider: "anthropic",
+ model: "__custom__",
+ customModel: "my-model",
+ userId: 123,
+ })
)
).toBe(true);
});
it("returns false with __custom__ but empty customModel", () => {
expect(
- validateStep(3, makeData({ provider: "anthropic", model: "__custom__", customModel: "" }))
+ validateStep(
+ 2,
+ makeData({ provider: "anthropic", model: "__custom__", customModel: "", userId: 123 })
+ )
).toBe(false);
});
it("returns true for cocoon without model (skips model check)", () => {
- expect(validateStep(3, makeData({ provider: "cocoon", model: "" }))).toBe(true);
+ expect(validateStep(2, makeData({ provider: "cocoon", model: "", userId: 123 }))).toBe(true);
});
it("returns true for local without model (skips model check)", () => {
- expect(validateStep(3, makeData({ provider: "local", model: "" }))).toBe(true);
+ 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(3, makeData({ provider: "cocoon", maxIterations: 0 }))).toBe(false);
+ expect(validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 0 }))).toBe(
+ false
+ );
});
it("returns false when maxIterations exceeds 50", () => {
- expect(validateStep(3, makeData({ provider: "cocoon", maxIterations: 51 }))).toBe(false);
+ expect(
+ validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 51 }))
+ ).toBe(false);
});
it("returns true when maxIterations is 1 (lower bound)", () => {
- expect(validateStep(3, makeData({ provider: "cocoon", maxIterations: 1 }))).toBe(true);
+ expect(validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 1 }))).toBe(
+ true
+ );
});
it("returns true when maxIterations is 50 (upper bound)", () => {
- expect(validateStep(3, makeData({ provider: "cocoon", maxIterations: 50 }))).toBe(true);
+ expect(
+ validateStep(2, makeData({ provider: "cocoon", userId: 123, maxIterations: 50 }))
+ ).toBe(true);
});
});
- // โโ Step 4: Wallet โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- describe("step 4 โ Wallet", () => {
+ // โโ Step 3: Wallet โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ describe("step 3 โ Wallet", () => {
it("returns true when walletAction is keep", () => {
- expect(validateStep(4, makeData({ walletAction: "keep" }))).toBe(true);
+ expect(validateStep(3, makeData({ walletAction: "keep" }))).toBe(true);
});
it("returns false when no walletAddress after generate", () => {
- expect(validateStep(4, makeData({ walletAction: "generate", walletAddress: "" }))).toBe(
+ expect(validateStep(3, makeData({ walletAction: "generate", walletAddress: "" }))).toBe(
false
);
});
@@ -202,7 +190,7 @@ describe("validateStep", () => {
it("returns false when walletAddress set but mnemonicSaved is false", () => {
expect(
validateStep(
- 4,
+ 3,
makeData({ walletAction: "generate", walletAddress: "EQ...", mnemonicSaved: false })
)
).toBe(false);
@@ -211,7 +199,7 @@ describe("validateStep", () => {
it("returns true when walletAddress set and mnemonicSaved is true", () => {
expect(
validateStep(
- 4,
+ 3,
makeData({ walletAction: "generate", walletAddress: "EQ...", mnemonicSaved: true })
)
).toBe(true);
@@ -220,13 +208,46 @@ describe("validateStep", () => {
it("returns true for import with address and mnemonicSaved", () => {
expect(
validateStep(
- 4,
+ 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", () => {
diff --git a/src/webui/__tests__/workspace-raw.test.ts b/src/webui/__tests__/workspace-raw.test.ts
new file mode 100644
index 0000000..937cba9
--- /dev/null
+++ b/src/webui/__tests__/workspace-raw.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { Hono } from "hono";
+
+vi.mock("node:fs", () => ({
+ readFileSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ mkdirSync: vi.fn(),
+ rmSync: vi.fn(),
+ renameSync: vi.fn(),
+ readdirSync: vi.fn(() => []),
+ statSync: vi.fn(),
+ existsSync: vi.fn(() => true),
+ lstatSync: vi.fn(),
+}));
+
+vi.mock("../../workspace/validator.js", () => ({
+ validateReadPath: vi.fn(),
+ validatePath: vi.fn(),
+ validateWritePath: vi.fn(),
+ validateDirectory: vi.fn(),
+ WorkspaceSecurityError: class WorkspaceSecurityError extends Error {
+ constructor(
+ message: string,
+ public readonly attemptedPath: string
+ ) {
+ super(message);
+ this.name = "WorkspaceSecurityError";
+ }
+ },
+}));
+
+vi.mock("../../workspace/paths.js", () => ({
+ WORKSPACE_ROOT: "/tmp/test-workspace",
+}));
+
+vi.mock("../../utils/errors.js", () => ({
+ getErrorMessage: vi.fn((e: unknown) => (e instanceof Error ? e.message : String(e))),
+}));
+
+vi.mock("../../utils/logger.js", () => ({
+ createLogger: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+}));
+
+import { readFileSync, statSync } from "node:fs";
+import { validateReadPath, WorkspaceSecurityError } from "../../workspace/validator.js";
+import { createWorkspaceRoutes } from "../routes/workspace.js";
+import type { WebUIServerDeps } from "../types.js";
+
+describe("GET /workspace/raw", () => {
+ let app: Hono;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ app = new Hono();
+ app.route("/workspace", createWorkspaceRoutes({} as WebUIServerDeps));
+ });
+
+ it("serves .png files with correct Content-Type", async () => {
+ const buf = Buffer.from("fake-png-data");
+ vi.mocked(validateReadPath).mockReturnValue({
+ absolutePath: "/tmp/test-workspace/test.png",
+ relativePath: "test.png",
+ exists: true,
+ isDirectory: false,
+ extension: ".png",
+ filename: "test.png",
+ });
+ vi.mocked(statSync).mockReturnValue({ size: 1024 } as any);
+ vi.mocked(readFileSync).mockReturnValue(buf as any);
+
+ const res = await app.request("/workspace/raw?path=test.png");
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("image/png");
+ const body = Buffer.from(await res.arrayBuffer());
+ expect(body).toEqual(buf);
+ });
+
+ it("serves .jpg files with correct Content-Type", async () => {
+ const buf = Buffer.from("fake-jpg-data");
+ vi.mocked(validateReadPath).mockReturnValue({
+ absolutePath: "/tmp/test-workspace/photo.jpg",
+ relativePath: "photo.jpg",
+ exists: true,
+ isDirectory: false,
+ extension: ".jpg",
+ filename: "photo.jpg",
+ });
+ vi.mocked(statSync).mockReturnValue({ size: 1024 } as any);
+ vi.mocked(readFileSync).mockReturnValue(buf as any);
+
+ const res = await app.request("/workspace/raw?path=photo.jpg");
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("image/jpeg");
+ });
+
+ it("serves .svg files with sandbox CSP header", async () => {
+ const buf = Buffer.from("");
+ vi.mocked(validateReadPath).mockReturnValue({
+ absolutePath: "/tmp/test-workspace/icon.svg",
+ relativePath: "icon.svg",
+ exists: true,
+ isDirectory: false,
+ extension: ".svg",
+ filename: "icon.svg",
+ });
+ vi.mocked(statSync).mockReturnValue({ size: 256 } as any);
+ vi.mocked(readFileSync).mockReturnValue(buf as any);
+
+ const res = await app.request("/workspace/raw?path=icon.svg");
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("image/svg+xml");
+ expect(res.headers.get("Content-Security-Policy")).toBe("sandbox");
+ });
+
+ it("returns 415 for unsupported file types", async () => {
+ vi.mocked(validateReadPath).mockReturnValue({
+ absolutePath: "/tmp/test-workspace/readme.txt",
+ relativePath: "readme.txt",
+ exists: true,
+ isDirectory: false,
+ extension: ".txt",
+ filename: "readme.txt",
+ });
+
+ const res = await app.request("/workspace/raw?path=readme.txt");
+
+ expect(res.status).toBe(415);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.error).toContain("Unsupported");
+ });
+
+ it("returns 400 when path query param is missing", async () => {
+ const res = await app.request("/workspace/raw");
+
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.error).toContain("path");
+ });
+
+ it("returns 403 on path traversal attempt", async () => {
+ vi.mocked(validateReadPath).mockImplementation(() => {
+ throw new WorkspaceSecurityError("Path traversal detected", "../../etc/passwd");
+ });
+
+ const res = await app.request("/workspace/raw?path=../../etc/passwd");
+
+ expect(res.status).toBe(403);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.error).toContain("Path traversal");
+ });
+
+ it("returns 413 when file exceeds 5MB limit", async () => {
+ vi.mocked(validateReadPath).mockReturnValue({
+ absolutePath: "/tmp/test-workspace/huge.png",
+ relativePath: "huge.png",
+ exists: true,
+ isDirectory: false,
+ extension: ".png",
+ filename: "huge.png",
+ });
+ vi.mocked(statSync).mockReturnValue({
+ size: 6 * 1024 * 1024,
+ } as any);
+
+ const res = await app.request("/workspace/raw?path=huge.png");
+
+ expect(res.status).toBe(413);
+ const data = await res.json();
+ expect(data.success).toBe(false);
+ expect(data.error).toContain("5MB");
+ });
+});
diff --git a/src/webui/routes/config.ts b/src/webui/routes/config.ts
index da4845f..e9cff94 100644
--- a/src/webui/routes/config.ts
+++ b/src/webui/routes/config.ts
@@ -9,16 +9,39 @@ import {
writeRawConfig,
} from "../../config/configurable-keys.js";
import type { ConfigKeyType, ConfigCategory } from "../../config/configurable-keys.js";
+import { getModelsForProvider } from "../../config/model-catalog.js";
+import {
+ getProviderMetadata,
+ validateApiKeyFormat,
+ type SupportedProvider,
+} from "../../config/providers.js";
+import { setTonapiKey } from "../../constants/api-endpoints.js";
+import { setToncenterApiKey, invalidateEndpointCache } from "../../ton/endpoint.js";
+import { invalidateTonClientCache } from "../../ton/wallet-service.js";
+
+/** Side-effects to run when specific config keys change at runtime. */
+const CONFIG_SIDE_EFFECTS: Record void> = {
+ tonapi_key: (v) => setTonapiKey(v),
+ toncenter_api_key: (v) => {
+ setToncenterApiKey(v);
+ invalidateEndpointCache();
+ invalidateTonClientCache();
+ },
+};
interface ConfigKeyData {
key: string;
+ label: string;
set: boolean;
value: string | null;
sensitive: boolean;
type: ConfigKeyType;
category: ConfigCategory;
description: string;
+ hotReload: "instant" | "restart";
options?: string[];
+ optionLabels?: Record;
+ itemType?: "string" | "number";
}
export function createConfigRoutes(deps: WebUIServerDeps) {
@@ -30,17 +53,29 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
const raw = readRawConfig(deps.configPath);
const data: ConfigKeyData[] = Object.entries(CONFIGURABLE_KEYS).map(([key, meta]) => {
- const value = getNestedValue(raw, key);
- const isSet = value != null && value !== "";
+ const rawValue = getNestedValue(raw, key);
+ const isSet =
+ rawValue != null &&
+ rawValue !== "" &&
+ !(Array.isArray(rawValue) && rawValue.length === 0);
+ const displayValue = isSet
+ ? meta.type === "array"
+ ? JSON.stringify(rawValue)
+ : meta.mask(String(rawValue))
+ : null;
return {
key,
+ label: meta.label,
set: isSet,
- value: isSet ? meta.mask(String(value)) : null,
+ value: displayValue,
sensitive: meta.sensitive,
type: meta.type,
category: meta.category,
description: meta.description,
+ hotReload: meta.hotReload,
...(meta.options ? { options: meta.options } : {}),
+ ...(meta.optionLabels ? { optionLabels: meta.optionLabels } : {}),
+ ...(meta.itemType ? { itemType: meta.itemType } : {}),
};
});
@@ -69,7 +104,7 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
);
}
- let body: { value?: string };
+ let body: { value?: unknown };
try {
body = await c.req.json();
} catch {
@@ -77,6 +112,65 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
}
const value = body.value;
+
+ // โโ Array keys โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ if (meta.type === "array") {
+ if (!Array.isArray(value)) {
+ return c.json(
+ { success: false, error: "Value must be an array for array keys" } as APIResponse,
+ 400
+ );
+ }
+
+ // Validate each item
+ for (let i = 0; i < value.length; i++) {
+ const itemStr = String(value[i]);
+ const itemErr = meta.validate(itemStr);
+ if (itemErr) {
+ return c.json(
+ {
+ success: false,
+ error: `Invalid item at index ${i} for ${key}: ${itemErr}`,
+ } as APIResponse,
+ 400
+ );
+ }
+ }
+
+ try {
+ const parsed = value.map((item) => meta.parse(String(item)));
+ const raw = readRawConfig(deps.configPath);
+ setNestedValue(raw, key, parsed);
+ writeRawConfig(raw, deps.configPath);
+
+ const runtimeConfig = deps.agent.getConfig() as Record;
+ setNestedValue(runtimeConfig, key, parsed);
+
+ const result: ConfigKeyData = {
+ key,
+ label: meta.label,
+ set: parsed.length > 0,
+ value: JSON.stringify(parsed),
+ sensitive: meta.sensitive,
+ type: meta.type,
+ category: meta.category,
+ description: meta.description,
+ hotReload: meta.hotReload,
+ ...(meta.itemType ? { itemType: meta.itemType } : {}),
+ };
+ return c.json({ success: true, data: result } as APIResponse);
+ } catch (err) {
+ return c.json(
+ {
+ success: false,
+ error: err instanceof Error ? err.message : String(err),
+ } as APIResponse,
+ 500
+ );
+ }
+ }
+
+ // โโ Scalar keys โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if (value == null || typeof value !== "string") {
return c.json(
{ success: false, error: "Missing or invalid 'value' field" } as APIResponse,
@@ -96,20 +190,41 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
const parsed = meta.parse(value);
const raw = readRawConfig(deps.configPath);
setNestedValue(raw, key, parsed);
+
+ // Auto-sync: setting owner_id also adds it to admin_ids
+ if (key === "telegram.owner_id" && typeof parsed === "number") {
+ const adminIds: number[] = (getNestedValue(raw, "telegram.admin_ids") as number[]) ?? [];
+ if (!adminIds.includes(parsed)) {
+ setNestedValue(raw, "telegram.admin_ids", [...adminIds, parsed]);
+ }
+ }
+
writeRawConfig(raw, deps.configPath);
// Update runtime config for immediate effect
const runtimeConfig = deps.agent.getConfig() as Record;
setNestedValue(runtimeConfig, key, parsed);
+ CONFIG_SIDE_EFFECTS[key]?.(parsed as string);
+
+ // Sync runtime admin_ids too
+ if (key === "telegram.owner_id" && typeof parsed === "number") {
+ const rtAdminIds: number[] =
+ (getNestedValue(runtimeConfig, "telegram.admin_ids") as number[]) ?? [];
+ if (!rtAdminIds.includes(parsed)) {
+ setNestedValue(runtimeConfig, "telegram.admin_ids", [...rtAdminIds, parsed]);
+ }
+ }
const result: ConfigKeyData = {
key,
+ label: meta.label,
set: true,
value: meta.mask(value),
sensitive: meta.sensitive,
type: meta.type,
category: meta.category,
description: meta.description,
+ hotReload: meta.hotReload,
...(meta.options ? { options: meta.options } : {}),
};
return c.json({ success: true, data: result } as APIResponse);
@@ -144,16 +259,20 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
// Clear from runtime config
const runtimeConfig = deps.agent.getConfig() as Record;
deleteNestedValue(runtimeConfig, key);
+ CONFIG_SIDE_EFFECTS[key]?.(undefined);
const result: ConfigKeyData = {
key,
+ label: meta.label,
set: false,
value: null,
sensitive: meta.sensitive,
type: meta.type,
category: meta.category,
description: meta.description,
+ hotReload: meta.hotReload,
...(meta.options ? { options: meta.options } : {}),
+ ...(meta.itemType ? { itemType: meta.itemType } : {}),
};
return c.json({ success: true, data: result } as APIResponse);
} catch (err) {
@@ -164,5 +283,56 @@ export function createConfigRoutes(deps: WebUIServerDeps) {
}
});
+ // Get model options for a provider
+ app.get("/models/:provider", (c) => {
+ const provider = c.req.param("provider");
+ const models = getModelsForProvider(provider);
+ return c.json({ success: true, data: models } as APIResponse);
+ });
+
+ // Get provider metadata (for API key UX)
+ app.get("/provider-meta/:provider", (c) => {
+ const provider = c.req.param("provider");
+ try {
+ const meta = getProviderMetadata(provider as SupportedProvider);
+ const needsKey = provider !== "claude-code" && provider !== "cocoon" && provider !== "local";
+ return c.json({
+ success: true,
+ data: {
+ needsKey,
+ keyHint: meta.keyHint,
+ keyPrefix: meta.keyPrefix,
+ consoleUrl: meta.consoleUrl,
+ displayName: meta.displayName,
+ },
+ } as APIResponse);
+ } catch (err) {
+ return c.json(
+ { success: false, error: err instanceof Error ? err.message : String(err) } as APIResponse,
+ 400
+ );
+ }
+ });
+
+ // Validate an API key format for a provider
+ app.post("/validate-api-key", async (c) => {
+ try {
+ const body = await c.req.json<{ provider: string; apiKey: string }>();
+ if (!body.provider || !body.apiKey) {
+ return c.json({ success: false, error: "Missing provider or apiKey" } as APIResponse, 400);
+ }
+ const error = validateApiKeyFormat(body.provider as SupportedProvider, body.apiKey);
+ return c.json({
+ success: true,
+ data: { valid: !error, error: error ?? null },
+ } as APIResponse);
+ } catch (err) {
+ return c.json(
+ { success: false, error: err instanceof Error ? err.message : String(err) } as APIResponse,
+ 400
+ );
+ }
+ });
+
return app;
}
diff --git a/src/webui/routes/mcp.ts b/src/webui/routes/mcp.ts
index 32884da..0d83bea 100644
--- a/src/webui/routes/mcp.ts
+++ b/src/webui/routes/mcp.ts
@@ -12,9 +12,10 @@ export function createMcpRoutes(deps: WebUIServerDeps) {
// List all MCP servers with their connection status and tools
app.get("/", (c) => {
+ const servers = typeof deps.mcpServers === "function" ? deps.mcpServers() : deps.mcpServers;
const response: APIResponse = {
success: true,
- data: deps.mcpServers,
+ data: servers,
};
return c.json(response);
});
diff --git a/src/webui/routes/memory.ts b/src/webui/routes/memory.ts
index d61c73e..c265fe1 100644
--- a/src/webui/routes/memory.ts
+++ b/src/webui/routes/memory.ts
@@ -1,5 +1,11 @@
import { Hono } from "hono";
-import type { WebUIServerDeps, MemorySearchResult, SessionInfo, APIResponse } from "../types.js";
+import type {
+ WebUIServerDeps,
+ MemorySearchResult,
+ MemorySourceFile,
+ SessionInfo,
+ APIResponse,
+} from "../types.js";
import { getErrorMessage } from "../../utils/errors.js";
export function createMemoryRoutes(deps: WebUIServerDeps) {
@@ -157,5 +163,95 @@ export function createMemoryRoutes(deps: WebUIServerDeps) {
}
});
+ // Get chunks for a specific source
+ app.get("/sources/:sourceKey", (c) => {
+ try {
+ const sourceKey = decodeURIComponent(c.req.param("sourceKey"));
+
+ const rows = deps.memory.db
+ .prepare(
+ `
+ SELECT id, text, source, path, start_line, end_line, updated_at
+ FROM knowledge
+ WHERE COALESCE(path, source) = ?
+ ORDER BY start_line ASC, updated_at DESC
+ `
+ )
+ .all(sourceKey) as Array<{
+ id: string;
+ text: string;
+ source: string;
+ path: string | null;
+ start_line: number | null;
+ end_line: number | null;
+ updated_at: number;
+ }>;
+
+ const chunks = rows.map((row) => ({
+ id: row.id,
+ text: row.text,
+ source: row.path || row.source,
+ startLine: row.start_line,
+ endLine: row.end_line,
+ updatedAt: row.updated_at,
+ }));
+
+ const response: APIResponse = {
+ success: true,
+ data: chunks,
+ };
+
+ return c.json(response);
+ } catch (error) {
+ const response: APIResponse = {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ return c.json(response, 500);
+ }
+ });
+
+ // List indexed sources (grouped by file/source category)
+ app.get("/sources", (c) => {
+ try {
+ const rows = deps.memory.db
+ .prepare(
+ `
+ SELECT
+ COALESCE(path, source) AS source_key,
+ COUNT(*) AS entry_count,
+ MAX(updated_at) AS last_updated
+ FROM knowledge
+ GROUP BY source_key
+ ORDER BY last_updated DESC
+ `
+ )
+ .all() as Array<{
+ source_key: string;
+ entry_count: number;
+ last_updated: number;
+ }>;
+
+ const sources: MemorySourceFile[] = rows.map((row) => ({
+ source: row.source_key,
+ entryCount: row.entry_count,
+ lastUpdated: row.last_updated,
+ }));
+
+ const response: APIResponse = {
+ success: true,
+ data: sources,
+ };
+
+ return c.json(response);
+ } catch (error) {
+ const response: APIResponse = {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ return c.json(response, 500);
+ }
+ });
+
return app;
}
diff --git a/src/webui/routes/plugins.ts b/src/webui/routes/plugins.ts
index 6915620..fdcc602 100644
--- a/src/webui/routes/plugins.ts
+++ b/src/webui/routes/plugins.ts
@@ -4,13 +4,15 @@ import type { WebUIServerDeps, APIResponse, LoadedPlugin } from "../types.js";
export function createPluginsRoutes(deps: WebUIServerDeps) {
const app = new Hono();
- // List all loaded plugins
+ // List all loaded plugins โ computed dynamically so plugins loaded after
+ // WebUI startup (via startAgent) are always reflected in the response.
app.get("/", (c) => {
- const response: APIResponse = {
- success: true,
- data: deps.plugins,
- };
- return c.json(response);
+ const data = deps.marketplace
+ ? deps.marketplace.modules
+ .filter((m) => deps.toolRegistry.isPluginModule(m.name))
+ .map((m) => ({ name: m.name, version: m.version ?? "0.0.0" }))
+ : deps.plugins;
+ return c.json>({ success: true, data });
});
return app;
diff --git a/src/webui/routes/setup.ts b/src/webui/routes/setup.ts
index c8a3b0f..fcafe94 100644
--- a/src/webui/routes/setup.ts
+++ b/src/webui/routes/setup.ts
@@ -16,6 +16,10 @@ 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";
@@ -34,108 +38,7 @@ import { createLogger } from "../../utils/logger.js";
const log = createLogger("Setup");
-// โโ Model catalog (same as CLI onboard.ts) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-
-const MODEL_OPTIONS: Record> = {
- anthropic: [
- {
- value: "claude-opus-4-5-20251101",
- name: "Claude Opus 4.5",
- description: "Most capable, $5/M",
- },
- { value: "claude-sonnet-4-0", name: "Claude Sonnet 4", description: "Balanced, $3/M" },
- {
- value: "claude-haiku-4-5-20251001",
- name: "Claude Haiku 4.5",
- description: "Fast & cheap, $1/M",
- },
- {
- value: "claude-3-5-haiku-20241022",
- name: "Claude 3.5 Haiku",
- description: "Cheapest, $0.80/M",
- },
- ],
- openai: [
- { value: "gpt-5", name: "GPT-5", description: "Most capable, 400K ctx, $1.25/M" },
- { 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: "o3", name: "o3", description: "Reasoning, 200K ctx, $2/M" },
- ],
- google: [
- { value: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "Fast, 1M ctx, $0.30/M" },
- {
- value: "gemini-2.5-pro",
- name: "Gemini 2.5 Pro",
- description: "Most capable, 1M ctx, $1.25/M",
- },
- { value: "gemini-2.0-flash", name: "Gemini 2.0 Flash", description: "Cheap, 1M ctx, $0.10/M" },
- ],
- xai: [
- { value: "grok-4-fast", name: "Grok 4 Fast", description: "Vision, 2M ctx, $0.20/M" },
- { value: "grok-4", name: "Grok 4", description: "Reasoning, 256K ctx, $3/M" },
- { value: "grok-3", name: "Grok 3", description: "Stable, 131K ctx, $3/M" },
- ],
- groq: [
- {
- value: "meta-llama/llama-4-maverick-17b-128e-instruct",
- name: "Llama 4 Maverick",
- description: "Vision, 131K ctx, $0.20/M",
- },
- { value: "qwen/qwen3-32b", name: "Qwen3 32B", description: "Reasoning, 131K ctx, $0.29/M" },
- {
- value: "deepseek-r1-distill-llama-70b",
- name: "DeepSeek R1 70B",
- description: "Reasoning, 131K ctx, $0.75/M",
- },
- {
- value: "llama-3.3-70b-versatile",
- name: "Llama 3.3 70B",
- description: "General purpose, 131K ctx, $0.59/M",
- },
- ],
- openrouter: [
- { value: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", description: "200K ctx, $5/M" },
- { value: "openai/gpt-5", name: "GPT-5", description: "400K ctx, $1.25/M" },
- { value: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "1M ctx, $0.30/M" },
- {
- value: "deepseek/deepseek-r1",
- name: "DeepSeek R1",
- description: "Reasoning, 64K ctx, $0.70/M",
- },
- { value: "x-ai/grok-4", name: "Grok 4", description: "256K ctx, $3/M" },
- ],
- moonshot: [
- { value: "kimi-k2.5", name: "Kimi K2.5", description: "Free, 256K ctx, multimodal" },
- {
- value: "kimi-k2-thinking",
- name: "Kimi K2 Thinking",
- description: "Free, 256K ctx, reasoning",
- },
- ],
- mistral: [
- {
- value: "devstral-small-2507",
- name: "Devstral Small",
- description: "Coding, 128K ctx, $0.10/M",
- },
- {
- value: "devstral-medium-latest",
- name: "Devstral Medium",
- description: "Coding, 262K ctx, $0.40/M",
- },
- {
- value: "mistral-large-latest",
- name: "Mistral Large",
- description: "General, 128K ctx, $2/M",
- },
- {
- value: "magistral-small",
- name: "Magistral Small",
- description: "Reasoning, 128K ctx, $0.50/M",
- },
- ],
-};
+import { getModelsForProvider } from "../../config/model-catalog.js";
// โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -196,7 +99,8 @@ export function createSetupRoutes(): Hono {
toolLimit: p.toolLimit,
keyPrefix: p.keyPrefix,
consoleUrl: p.consoleUrl,
- requiresApiKey: p.id !== "cocoon" && p.id !== "local",
+ requiresApiKey: p.id !== "cocoon" && p.id !== "local" && p.id !== "claude-code",
+ autoDetectsKey: p.id === "claude-code",
requiresBaseUrl: p.id === "local",
}));
return c.json({ success: true, data: providers });
@@ -205,7 +109,7 @@ export function createSetupRoutes(): Hono {
// โโ GET /models/:provider โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
app.get("/models/:provider", (c) => {
const provider = c.req.param("provider");
- const models = MODEL_OPTIONS[provider] || [];
+ const models = getModelsForProvider(provider);
const result = [
...models,
{
@@ -218,6 +122,29 @@ export function createSetupRoutes(): Hono {
return c.json({ success: true, data: result });
});
+ // โโ GET /detect-claude-code-key โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ app.get("/detect-claude-code-key", (c) => {
+ try {
+ const key = getClaudeCodeApiKey();
+ // TODO: revert to masked key after testing
+ // const masked = key.slice(0, 12) + "****" + key.slice(-4);
+ const masked = key; // TEMP: show full key for testing
+ 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 {
@@ -544,7 +471,6 @@ export function createSetupRoutes(): Hono {
},
storage: {
sessions_file: `${workspace.root}/sessions.json`,
- pairing_file: `${workspace.root}/pairing.json`,
memory_file: `${workspace.root}/memory.json`,
history_limit: 100,
},
@@ -563,7 +489,7 @@ export function createSetupRoutes(): Hono {
logging: { level: "info" as const, pretty: true },
dev: { hot_reload: false },
tool_rag: {
- enabled: true,
+ enabled: false,
top_k: 25,
always_include: [
"telegram_send_message",
@@ -580,6 +506,7 @@ export function createSetupRoutes(): Hono {
plugins: {},
...(input.cocoon ? { cocoon: input.cocoon } : {}),
...(input.tonapi_key ? { tonapi_key: input.tonapi_key } : {}),
+ ...(input.toncenter_api_key ? { toncenter_api_key: input.toncenter_api_key } : {}),
...(input.tavily_api_key ? { tavily_api_key: input.tavily_api_key } : {}),
};
diff --git a/src/webui/routes/tasks.ts b/src/webui/routes/tasks.ts
index 0ae346c..4669a50 100644
--- a/src/webui/routes/tasks.ts
+++ b/src/webui/routes/tasks.ts
@@ -4,6 +4,7 @@ import { getTaskStore, type TaskStatus } from "../../memory/agent/tasks.js";
import { getErrorMessage } from "../../utils/errors.js";
const VALID_STATUSES: TaskStatus[] = ["pending", "in_progress", "done", "failed", "cancelled"];
+const TERMINAL_STATUSES: TaskStatus[] = ["done", "failed", "cancelled"];
export function createTasksRoutes(deps: WebUIServerDeps) {
const app = new Hono();
@@ -92,7 +93,38 @@ export function createTasksRoutes(deps: WebUIServerDeps) {
}
});
- // Clean done tasks (bulk delete)
+ // Clean tasks by terminal status (bulk delete)
+ app.post("/clean", async (c) => {
+ try {
+ const body = await c.req.json<{ status?: string }>().catch(() => ({ status: undefined }));
+ const status = body.status as TaskStatus | undefined;
+
+ if (!status || !TERMINAL_STATUSES.includes(status as TaskStatus)) {
+ const response: APIResponse = {
+ success: false,
+ error: `Invalid status. Must be one of: ${TERMINAL_STATUSES.join(", ")}`,
+ };
+ return c.json(response, 400);
+ }
+
+ const tasks = store().listTasks({ status });
+ let deleted = 0;
+ for (const t of tasks) {
+ if (store().deleteTask(t.id)) deleted++;
+ }
+
+ const response: APIResponse = { success: true, data: { deleted } };
+ return c.json(response);
+ } catch (error) {
+ const response: APIResponse = {
+ success: false,
+ error: getErrorMessage(error),
+ };
+ return c.json(response, 500);
+ }
+ });
+
+ // Backward-compatible alias
app.post("/clean-done", (c) => {
try {
const doneTasks = store().listTasks({ status: "done" });
diff --git a/src/webui/routes/tools.ts b/src/webui/routes/tools.ts
index 4c1c129..898f9e2 100644
--- a/src/webui/routes/tools.ts
+++ b/src/webui/routes/tools.ts
@@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { WebUIServerDeps, ToolInfo, ModuleInfo, APIResponse } from "../types.js";
import { getErrorMessage } from "../../utils/errors.js";
+import { readRawConfig, setNestedValue, writeRawConfig } from "../../config/configurable-keys.js";
export function createToolsRoutes(deps: WebUIServerDeps) {
const app = new Hono();
@@ -86,7 +87,12 @@ export function createToolsRoutes(deps: WebUIServerDeps) {
try {
const config = deps.agent.getConfig();
const body = await c.req.json();
- const { enabled, topK } = body as { enabled?: boolean; topK?: number };
+ const { enabled, topK, alwaysInclude, skipUnlimitedProviders } = body as {
+ enabled?: boolean;
+ topK?: number;
+ alwaysInclude?: string[];
+ skipUnlimitedProviders?: boolean;
+ };
if (enabled !== undefined) {
config.tool_rag.enabled = enabled;
@@ -97,6 +103,33 @@ export function createToolsRoutes(deps: WebUIServerDeps) {
}
config.tool_rag.top_k = topK;
}
+ if (alwaysInclude !== undefined) {
+ if (
+ !Array.isArray(alwaysInclude) ||
+ alwaysInclude.some((s) => typeof s !== "string" || s.length === 0)
+ ) {
+ return c.json(
+ { success: false, error: "alwaysInclude must be an array of non-empty strings" },
+ 400
+ );
+ }
+ config.tool_rag.always_include = alwaysInclude;
+ }
+ if (skipUnlimitedProviders !== undefined) {
+ config.tool_rag.skip_unlimited_providers = skipUnlimitedProviders;
+ }
+
+ // Persist to YAML
+ const raw = readRawConfig(deps.configPath);
+ setNestedValue(raw, "tool_rag.enabled", config.tool_rag.enabled);
+ setNestedValue(raw, "tool_rag.top_k", config.tool_rag.top_k);
+ setNestedValue(raw, "tool_rag.always_include", config.tool_rag.always_include);
+ setNestedValue(
+ raw,
+ "tool_rag.skip_unlimited_providers",
+ config.tool_rag.skip_unlimited_providers
+ );
+ writeRawConfig(raw, deps.configPath);
const toolIndex = deps.toolRegistry.getToolIndex();
const response: APIResponse = {
@@ -106,6 +139,8 @@ export function createToolsRoutes(deps: WebUIServerDeps) {
indexed: toolIndex?.isIndexed ?? false,
topK: config.tool_rag.top_k,
totalTools: deps.toolRegistry.count,
+ alwaysInclude: config.tool_rag.always_include,
+ skipUnlimitedProviders: config.tool_rag.skip_unlimited_providers,
},
};
return c.json(response);
diff --git a/src/webui/routes/workspace.ts b/src/webui/routes/workspace.ts
index 84d2c24..aa66193 100644
--- a/src/webui/routes/workspace.ts
+++ b/src/webui/routes/workspace.ts
@@ -42,6 +42,17 @@ function errorResponse(c: any, error: unknown, status: number = 500) {
return c.json(response, code);
}
+const IMAGE_MIME_TYPES: Record = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+ ".bmp": "image/bmp",
+ ".ico": "image/x-icon",
+};
+
/** Recursively count files and total size */
function getWorkspaceStats(dir: string): { files: number; size: number } {
let files = 0;
@@ -140,6 +151,57 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) {
}
});
+ // Serve raw image file with correct MIME type
+ app.get("/raw", (c) => {
+ try {
+ const path = c.req.query("path");
+ if (!path) {
+ const response: APIResponse = { success: false, error: "Missing 'path' query parameter" };
+ return c.json(response, 400);
+ }
+
+ const validated = validateReadPath(path);
+ const mime = IMAGE_MIME_TYPES[validated.extension];
+
+ if (!mime) {
+ const response: APIResponse = {
+ success: false,
+ error: "Unsupported file type for raw preview",
+ };
+ return c.json(response, 415);
+ }
+
+ const stats = statSync(validated.absolutePath);
+
+ // 5MB limit for image preview
+ if (stats.size > 5 * 1024 * 1024) {
+ const response: APIResponse = {
+ success: false,
+ error: "Image too large for preview (max 5MB)",
+ };
+ return c.json(response, 413);
+ }
+
+ const buffer = readFileSync(validated.absolutePath);
+
+ const headers: Record = {
+ "Content-Type": mime,
+ "Content-Length": String(buffer.byteLength),
+ "Content-Disposition": "inline",
+ "Cache-Control": "private, max-age=60",
+ };
+
+ // SVG security: sandbox to prevent script execution if opened directly
+ if (validated.extension === ".svg") {
+ headers["Content-Security-Policy"] = "sandbox";
+ }
+
+ return c.body(buffer, 200, headers);
+ } catch (error) {
+ return errorResponse(c, error);
+ }
+ });
+
// Read file content
app.get("/read", (c) => {
try {
diff --git a/src/webui/server.ts b/src/webui/server.ts
index f76e84a..2b36185 100644
--- a/src/webui/server.ts
+++ b/src/webui/server.ts
@@ -1,12 +1,14 @@
import { Hono } from "hono";
import { serve } 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 { existsSync, readFileSync } from "node:fs";
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 { createLogger } from "../utils/logger.js";
const log = createLogger("WebUI");
@@ -205,6 +207,113 @@ export class WebUIServer {
this.app.route("/api/config", createConfigRoutes(this.deps));
this.app.route("/api/marketplace", createMarketplaceRoutes(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,
+ });
+ });
+
+ this.app.get("/api/agent/events", (c) => {
+ const lifecycle = this.deps.lifecycle;
+ if (!lifecycle) {
+ 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;
+ 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);
+ });
+ });
+
// Serve static files in production (if built)
const webDist = findWebDist();
if (webDist) {
diff --git a/src/webui/setup-auth.ts b/src/webui/setup-auth.ts
index b32333d..6e58d60 100644
--- a/src/webui/setup-auth.ts
+++ b/src/webui/setup-auth.ts
@@ -28,6 +28,8 @@ interface AuthSession {
phoneCodeHash: string; // NEVER sent to frontend
state: "code_sent" | "2fa_required" | "authenticated" | "failed";
passwordHint?: string;
+ fragmentUrl?: string;
+ codeLength?: number;
codeAttempts: number;
passwordAttempts: number;
createdAt: number;
@@ -46,7 +48,13 @@ export class TelegramAuthManager {
apiId: number,
apiHash: string,
phone: string
- ): Promise<{ authSessionId: string; codeViaApp: boolean; expiresAt: number }> {
+ ): Promise<{
+ authSessionId: string;
+ codeDelivery: "app" | "sms" | "fragment";
+ fragmentUrl?: string;
+ codeLength?: number;
+ expiresAt: number;
+ }> {
// Clean up any existing session
await this.cleanup();
@@ -59,7 +67,35 @@ export class TelegramAuthManager {
await client.connect();
- const result = await client.sendCode({ apiId, apiHash }, phone);
+ const result = await client.invoke(
+ new Api.auth.SendCode({
+ phoneNumber: phone,
+ apiId,
+ apiHash,
+ settings: new Api.CodeSettings({}),
+ })
+ );
+
+ if (result instanceof Api.auth.SentCodeSuccess) {
+ await client.disconnect();
+ throw new Error("Account already authenticated (SentCodeSuccess)");
+ }
+
+ // Detect code delivery method
+ let codeDelivery: "app" | "sms" | "fragment" = "sms";
+ let fragmentUrl: string | undefined;
+ let codeLength: number | undefined;
+
+ if (result.type instanceof Api.auth.SentCodeTypeApp) {
+ codeDelivery = "app";
+ codeLength = result.type.length;
+ } else if (result.type instanceof Api.auth.SentCodeTypeFragmentSms) {
+ codeDelivery = "fragment";
+ fragmentUrl = result.type.url;
+ codeLength = result.type.length;
+ } else if ("length" in result.type) {
+ codeLength = result.type.length as number;
+ }
const id = randomBytes(16).toString("hex");
const expiresAt = Date.now() + SESSION_TTL_MS;
@@ -70,6 +106,8 @@ export class TelegramAuthManager {
phone,
phoneCodeHash: result.phoneCodeHash,
state: "code_sent",
+ fragmentUrl,
+ codeLength,
codeAttempts: 0,
passwordAttempts: 0,
createdAt: Date.now(),
@@ -79,7 +117,7 @@ export class TelegramAuthManager {
};
log.info("Telegram verification code sent");
- return { authSessionId: id, codeViaApp: result.isCodeViaApp, expiresAt };
+ return { authSessionId: id, codeDelivery, fragmentUrl, codeLength, expiresAt };
}
/**
@@ -191,7 +229,11 @@ export class TelegramAuthManager {
/**
* Resend verification code
*/
- async resendCode(authSessionId: string): Promise<{ codeViaApp: boolean } | null> {
+ async resendCode(authSessionId: string): Promise<{
+ codeDelivery: "app" | "sms" | "fragment";
+ fragmentUrl?: string;
+ codeLength?: number;
+ } | null> {
const session = this.getSession(authSessionId);
if (!session || session.state !== "code_sent") return null;
@@ -206,12 +248,30 @@ export class TelegramAuthManager {
if (result instanceof Api.auth.SentCode) {
session.phoneCodeHash = result.phoneCodeHash;
session.codeAttempts = 0;
- const codeViaApp = result.type instanceof Api.auth.SentCodeTypeApp;
- return { codeViaApp };
+
+ let codeDelivery: "app" | "sms" | "fragment" = "sms";
+ let fragmentUrl: string | undefined;
+ let codeLength: number | undefined;
+
+ if (result.type instanceof Api.auth.SentCodeTypeApp) {
+ codeDelivery = "app";
+ codeLength = result.type.length;
+ } else if (result.type instanceof Api.auth.SentCodeTypeFragmentSms) {
+ codeDelivery = "fragment";
+ fragmentUrl = result.type.url;
+ codeLength = result.type.length;
+ } else if ("length" in result.type) {
+ codeLength = result.type.length as number;
+ }
+
+ session.fragmentUrl = fragmentUrl;
+ session.codeLength = codeLength;
+
+ return { codeDelivery, fragmentUrl, codeLength };
}
// SentCodeSuccess means already authenticated
- return { codeViaApp: false };
+ return { codeDelivery: "sms" };
}
/**
diff --git a/src/webui/types.ts b/src/webui/types.ts
index d7d50e0..0055fa4 100644
--- a/src/webui/types.ts
+++ b/src/webui/types.ts
@@ -6,6 +6,7 @@ import type { WebUIConfig, Config } from "../config/schema.js";
import type { Database } from "better-sqlite3";
import type { PluginModule, PluginContext } from "../agent/tools/types.js";
import type { SDKDependencies } from "../sdk/index.js";
+import type { AgentLifecycle } from "../agent/lifecycle.js";
export interface LoadedPlugin {
name: string;
@@ -14,7 +15,7 @@ export interface LoadedPlugin {
export interface McpServerInfo {
name: string;
- type: "stdio" | "sse";
+ type: "stdio" | "sse" | "streamable-http";
target: string;
scope: string;
enabled: boolean;
@@ -34,9 +35,10 @@ export interface WebUIServerDeps {
};
toolRegistry: ToolRegistry;
plugins: LoadedPlugin[];
- mcpServers: McpServerInfo[];
+ mcpServers: McpServerInfo[] | (() => McpServerInfo[]);
config: WebUIConfig;
configPath: string;
+ lifecycle?: AgentLifecycle;
marketplace?: MarketplaceDeps;
}
@@ -135,3 +137,9 @@ export interface SessionInfo {
contextTokens: number;
lastActivity: number;
}
+
+export interface MemorySourceFile {
+ source: string;
+ entryCount: number;
+ lastUpdated: number;
+}
diff --git a/src/webui/validate-step.ts b/src/webui/validate-step.ts
index 47a6587..c35db56 100644
--- a/src/webui/validate-step.ts
+++ b/src/webui/validate-step.ts
@@ -52,23 +52,26 @@ export function validateStep(step: number, data: WizardData): boolean {
return false;
}
}
+ if (data.provider === "claude-code") {
+ return true; // credentials auto-detected at runtime
+ }
return data.apiKey.length > 0;
- case 2:
- return (
- data.apiId > 0 && data.apiHash.length >= 10 && data.phone.startsWith("+") && data.userId > 0
- );
- case 3: {
+ 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.maxIterations >= 1 && data.maxIterations <= 50;
+ return data.userId > 0 && data.maxIterations >= 1 && data.maxIterations <= 50;
}
- case 4:
+ 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:
diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts
index 1400a97..53f2d22 100644
--- a/src/workspace/manager.ts
+++ b/src/workspace/manager.ts
@@ -22,6 +22,8 @@ const TEMPLATES_DIR = join(findPackageRoot(), "src", "templates");
export interface WorkspaceConfig {
workspaceDir?: string;
ensureTemplates?: boolean;
+ /** Suppress log.info() output (useful when CLI spinners are active) */
+ silent?: boolean;
}
export interface Workspace {
@@ -50,16 +52,18 @@ export interface Workspace {
* Ensure workspace directory structure exists and is initialized
*/
export async function ensureWorkspace(config?: WorkspaceConfig): Promise {
+ const silent = config?.silent ?? false;
+
// Create base teleton directory
if (!existsSync(TELETON_ROOT)) {
mkdirSync(TELETON_ROOT, { recursive: true });
- log.info(`Created Teleton root at ${TELETON_ROOT}`);
+ if (!silent) log.info(`Created Teleton root at ${TELETON_ROOT}`);
}
// Create workspace directory
if (!existsSync(WORKSPACE_ROOT)) {
mkdirSync(WORKSPACE_ROOT, { recursive: true });
- log.info(`Created workspace at ${WORKSPACE_ROOT}`);
+ if (!silent) log.info(`Created workspace at ${WORKSPACE_ROOT}`);
}
// Create workspace subdirectories
@@ -102,7 +106,7 @@ export async function ensureWorkspace(config?: WorkspaceConfig): Promise {
+async function bootstrapTemplates(workspace: Workspace, silent = false): Promise {
const templates = [
{ name: "SOUL.md", path: workspace.soulPath },
{ name: "MEMORY.md", path: workspace.memoryPath },
@@ -126,7 +130,7 @@ async function bootstrapTemplates(workspace: Workspace): Promise {
const templateSource = join(TEMPLATES_DIR, template.name);
if (existsSync(templateSource)) {
copyFileSync(templateSource, template.path);
- log.info(`Created ${template.name}`);
+ if (!silent) log.info(`Created ${template.name}`);
}
}
}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ac0f311..52cbcce 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -7,7 +7,6 @@ import { Tools } from './pages/Tools';
import { Plugins } from './pages/Plugins';
import { Soul } from './pages/Soul';
import { Memory } from './pages/Memory';
-import { Logs } from './pages/Logs';
import { Workspace } from './pages/Workspace';
import { Tasks } from './pages/Tasks';
import { Mcp } from './pages/Mcp';
@@ -134,7 +133,6 @@ function AuthenticatedApp() {
} />
} />
} />
- } />
} />
} />
} />
diff --git a/web/src/components/AgentControl.tsx b/web/src/components/AgentControl.tsx
new file mode 100644
index 0000000..ec49bce
--- /dev/null
+++ b/web/src/components/AgentControl.tsx
@@ -0,0 +1,224 @@
+import { useState, useRef, useCallback } from 'react';
+import { createPortal } from 'react-dom';
+import { useAgentStatus, AgentState } from '../hooks/useAgentStatus';
+
+const API_BASE = '/api';
+const MAX_START_RETRIES = 3;
+const RETRY_DELAYS = [1000, 2000, 4000];
+
+function jitter(ms: number): number {
+ return ms + ms * 0.3 * Math.random();
+}
+
+const STATE_CONFIG: Record = {
+ stopped: { dot: 'var(--text-tertiary)', label: 'Stopped', pulse: false },
+ starting: { dot: '#FFD60A', label: 'Starting...', pulse: true },
+ running: { dot: 'var(--green)', label: 'Running', pulse: true },
+ stopping: { dot: '#FF9F0A', label: 'Stopping...', pulse: true },
+ error: { dot: 'var(--red)', label: 'Error', pulse: false },
+};
+
+export function AgentControl() {
+ const { state, error } = useAgentStatus();
+ const [inflight, setInflight] = useState(false);
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [actionError, setActionError] = useState(null);
+ const [retrying, setRetrying] = useState(false);
+ const retryTimerRef = useRef | null>(null);
+ 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);
+ retryTimerRef.current = null;
+ }
+ setRetrying(false);
+ }, []);
+
+ const doStart = useCallback(async (attempt = 0): Promise => {
+ setInflight(true);
+ setActionError(null);
+ abortRef.current = new AbortController();
+
+ try {
+ const res = await fetch(`${API_BASE}/agent/start`, {
+ method: 'POST',
+ credentials: 'include',
+ signal: AbortSignal.timeout(10_000),
+ });
+ const json = await res.json();
+
+ if (!res.ok && json.error) {
+ throw new Error(json.error);
+ }
+ } catch (err) {
+ if (!retryTimerRef.current && attempt < MAX_START_RETRIES) {
+ setRetrying(true);
+ const delay = jitter(RETRY_DELAYS[attempt] ?? 4000);
+ retryTimerRef.current = setTimeout(() => {
+ retryTimerRef.current = null;
+ doStart(attempt + 1);
+ }, delay);
+ setActionError(err instanceof Error ? err.message : String(err));
+ setInflight(false);
+ return;
+ }
+ setActionError(err instanceof Error ? err.message : String(err));
+ setRetrying(false);
+ } finally {
+ setInflight(false);
+ abortRef.current = null;
+ }
+ }, []);
+
+ const doStop = useCallback(async () => {
+ setShowConfirm(false);
+ setInflight(true);
+ setActionError(null);
+ clearRetry();
+
+ try {
+ const res = await fetch(`${API_BASE}/agent/stop`, {
+ method: 'POST',
+ credentials: 'include',
+ signal: AbortSignal.timeout(10_000),
+ });
+ const json = await res.json();
+
+ if (!res.ok && json.error) {
+ throw new Error(json.error);
+ }
+ } catch (err) {
+ setActionError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setInflight(false);
+ }
+ }, [clearRetry]);
+
+ const handleStart = () => {
+ clearRetry();
+ doStart(0);
+ };
+
+ const handleStopClick = () => {
+ setShowConfirm(true);
+ };
+
+ const showPlay = displayState === 'stopped' || displayState === 'error';
+ const showStop = displayState === 'running';
+
+ return (
+
- Configure your agent's model and behavior. Defaults are pre-filled โ adjust what you need.
+ Configure your agent's behavior policies. Defaults are pre-filled โ adjust what you need.