From 79497a13c1961263a77d9acd9a45a708b35783ae Mon Sep 17 00:00:00 2001 From: mrbeandev Date: Mon, 16 Feb 2026 00:10:54 +0530 Subject: [PATCH] feat: Coolify deployment support and Telegram UI/UX enhancements This PR introduces comprehensive support for Coolify deployment and several improvements to the Telegram channel: Coolify Deployment: - Added COOLIFY.md guide with 3 configuration methods. - Added entrypoint-coolify.sh to generate config.json from environment variables. - Added Dockerfile.coolify and docker-compose-coolify.yml optimized for Coolify. - Support for full JSON configuration via PICOCLAW_CONFIG_JSON env var. Telegram Enhancements: - Persistent 'typing' indicator that repeats every 4s while AI is thinking. - Automatic registration of bot commands (/model, /models) on startup. - Consolidated /model command that supports 'provider/model' syntax for atomic switching. - Dynamic /models command that shows actually configured providers and active model. Configuration: - Improved AgentLoop to support hot-switching models and providers without restart. These changes improve cloud deployability and user experience in chat channels. --- COOLIFY.md | 326 +++++++++++++++++++++++++++++++++++++ Dockerfile.coolify | 43 +++++ README.md | 26 +++ docker-compose-coolify.yml | 164 +++++++++++++++++++ entrypoint-coolify.sh | 153 +++++++++++++++++ pkg/agent/loop.go | 84 +++++++++- pkg/channels/telegram.go | 57 +++---- 7 files changed, 817 insertions(+), 36 deletions(-) create mode 100644 COOLIFY.md create mode 100644 Dockerfile.coolify create mode 100644 docker-compose-coolify.yml create mode 100644 entrypoint-coolify.sh diff --git a/COOLIFY.md b/COOLIFY.md new file mode 100644 index 000000000..4f8b78ddb --- /dev/null +++ b/COOLIFY.md @@ -0,0 +1,326 @@ +# ☁️ Deploying PicoClaw on Coolify + +Deploy PicoClaw as a self-hosted AI assistant on [Coolify](https://coolify.io) — the open-source Heroku/Vercel alternative. + +> **Looking for the quick 3-step version?** See [README.md → Deploy on Coolify](README.md#%EF%B8%8F-deploy-on-coolify-3-steps) + +--- + +## 📋 Prerequisites + +- A Coolify instance (v4+) +- A GitHub account (to fork the repo) +- At least one LLM API key (e.g., [Gemini](https://aistudio.google.com/apikey), [OpenRouter](https://openrouter.ai/keys)) + +## 🚀 Quick Deploy + +### Step 1: Fork the Repository + +Fork [mrbeandev/picoclaw](https://github.com/mrbeandev/picoclaw) to your GitHub account. + +### Step 2: Create a New Service in Coolify + +1. Go to your Coolify dashboard → **Projects** → select or create a project +2. Click **+ New** → **Docker Compose** +3. Connect your forked GitHub repo +4. Set the following: + - **Branch:** `deploy/coolify` + - **Docker Compose File:** `docker-compose-coolify.yml` + - **Base Directory:** `/` (root) + +### Step 3: Configure Environment Variables + +Go to **Environment Variables** tab and add your keys. See [Configuration](#-configuration) below. + +### Step 4: Deploy! + +Hit **Deploy** and wait for the build to complete (~30 seconds). + +--- + +## ⚙️ Configuration + +PicoClaw on Coolify supports **3 configuration methods**. The entrypoint script checks them in order — **first match wins**. + +### Which Method Should I Use? + +| | Method 1: JSON Env Var | Method 2: Mounted File | Method 3: Individual Env Vars | +|---|---|---|---| +| **Difficulty** | Medium | Easy | Easiest | +| **Flexibility** | ✅ Full control | ✅ Full control | ⚠️ Limited | +| **Custom providers (Ollama, vLLM)** | ✅ Yes | ✅ Yes | ❌ No | +| **Allow-lists** | ✅ Yes | ✅ Yes | ✅ Yes (comma-separated) | +| **Feishu, DingTalk, QQ, OneBot** | ✅ Yes | ✅ Yes | ❌ No | +| **Custom API base URLs** | ✅ Yes | ✅ Yes | ❌ No | +| **Requires JSON minification** | ⚠️ Yes | ❌ No | ❌ No | +| **Edit without rebuild** | ✅ Redeploy only | ✅ Restart only | ✅ Redeploy only | +| **Pretty-printed JSON** | ❌ Must minify | ✅ Yes | N/A | + +--- + +### Method 1: Full JSON Config (Most Flexible) ⭐ + +**Best for:** Full control, custom providers (Ollama, vLLM), complex setups. + +Paste your **entire** `config.json` as a single environment variable: + +| Key | Value | +|-----|-------| +| `PICOCLAW_CONFIG_JSON` | `{"agents":{"defaults":{"provider":"gemini",...}},...}` | + +#### Example: Gemini + Telegram + Ollama + +```json +{ + "agents": { + "defaults": { + "provider": "gemini", + "model": "gemini-2.5-flash-lite", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20, + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + }, + "providers": { + "gemini": { + "api_key": "AIzaSy..." + }, + "vllm": { + "api_key": "dummy", + "api_base": "http://your-ollama-server:11434/v1" + }, + "openrouter": { + "api_key": "sk-or-..." + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC-DEF...", + "allow_from": ["your_telegram_user_id"] + }, + "discord": { + "enabled": false, + "token": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "duckduckgo": { "enabled": true, "max_results": 5 }, + "brave": { "enabled": false, "api_key": "", "max_results": 5 } + }, + "firecrawl": { "enabled": false, "api_key": "", "api_base": "https://api.firecrawl.dev/v1" }, + "serpapi": { "enabled": false, "api_key": "", "max_results": 10 } + }, + "heartbeat": { "enabled": true, "interval": 30 }, + "gateway": { "host": "0.0.0.0", "port": 18790 }, + "devices": { "enabled": false, "monitor_usb": false } +} +``` + +#### ⚠️ Important: You MUST minify the JSON! + +Coolify environment variables are single-line. You need to compress the JSON into **one line** before pasting. + +**🔧 JSON Tools for Minifying:** + +| Tool | Type | URL | +|------|------|-----| +| **JSON Minifier** | Web | [jsonformatter.org/json-minify](https://jsonformatter.org/json-minify) | +| **JSON Formatter** | Web | [jsonformatter.curiousconcept.com](https://jsonformatter.curiousconcept.com/) | +| **JSON Editor Online** | Web | [jsoneditoronline.org](https://jsoneditoronline.org/) — edit visually, then copy minified | +| **jq** | CLI | `cat config.json \| jq -c .` — outputs minified JSON | +| **Python** | CLI | `python3 -c "import json,sys;print(json.dumps(json.load(sys.stdin)))" < config.json` | +| **Node.js** | CLI | `node -e "process.stdin.on('data',d=>console.log(JSON.stringify(JSON.parse(d))))"< config.json` | + +**Workflow:** +1. Write your config in a pretty-printed JSON editor +2. Validate it (the tools above show errors) +3. Minify / compress to one line +4. Paste the single line as the `PICOCLAW_CONFIG_JSON` value in Coolify + +**Example minified output:** +``` +{"agents":{"defaults":{"provider":"gemini","model":"gemini-2.5-flash-lite","max_tokens":8192,"temperature":0.7,"max_tool_iterations":20,"workspace":"~/.picoclaw/workspace","restrict_to_workspace":true}},"providers":{"gemini":{"api_key":"AIzaSy..."},"vllm":{"api_key":"dummy","api_base":"http://ollama:11434/v1"}},"channels":{"telegram":{"enabled":true,"token":"123456:ABC-DEF...","allow_from":["123456789"]}},"tools":{"web":{"duckduckgo":{"enabled":true,"max_results":5}}},"heartbeat":{"enabled":true,"interval":30},"gateway":{"host":"0.0.0.0","port":18790},"devices":{"enabled":false,"monitor_usb":false}} +``` + +--- + +### Method 2: Mounted Config File + +**Best for:** Users who prefer editing a normal file, and want pretty-printed JSON without minification. + +Use Coolify's **Storages** feature to mount a config file into the container: + +#### Step-by-step: + +1. Go to your PicoClaw service in Coolify +2. Click the **Storages** tab +3. Click **+ Add** and configure: + - **Source Path:** Leave empty (Coolify auto-creates it) or set to `/data/coolify/applications//config.json` + - **Destination Path:** `/config/config.json` +4. Save the storage mount +5. SSH into your Coolify server and create the config file: + ```bash + # Find your app's data directory + ls /data/coolify/applications/ + + # Create the config file (replace with your app's UUID) + nano /data/coolify/applications//config.json + ``` +6. Paste your full config JSON (pretty-printed is fine!) and save +7. **Restart** the service in Coolify (no rebuild needed) + +The entrypoint will automatically detect `/config/config.json` and use it. + +#### ✅ Advantages +- **Pretty-printed JSON** — no minification needed, easy to read and edit +- **Full control** — same flexibility as Method 1 +- **Edit without rebuild** — just edit the file on disk and restart the container +- **Custom providers** — Ollama, vLLM, and any other custom provider work fine + +#### ❌ Limitations +- **Requires SSH access** — you need SSH into the Coolify server to create/edit the file +- **No Coolify UI editing** — you can't edit the file content from Coolify's web UI (only set the mount path) +- **File must exist before starting** — if the file doesn't exist, this method is skipped and it falls through to Method 3 +- **Not portable** — the config lives on the server's filesystem, not in Coolify's database + +--- + +### Method 3: Individual Environment Variables + +**Best for:** Simple setups — just Gemini + one channel, defaults for everything else. + +Set these in Coolify's **Environment Variables** tab: + +#### Required + +| Variable | Description | Example | +|----------|-------------|---------| +| `PICOCLAW_PROVIDERS_GEMINI_API_KEY` | Gemini API key | `AIzaSy...` | + +#### Provider Keys (optional) + +| Variable | Description | +|----------|-------------| +| `PICOCLAW_PROVIDERS_OPENROUTER_API_KEY` | OpenRouter API key | +| `PICOCLAW_PROVIDERS_OPENAI_API_KEY` | OpenAI API key | +| `PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY` | Anthropic API key | +| `PICOCLAW_PROVIDERS_GROQ_API_KEY` | Groq API key | +| `PICOCLAW_PROVIDERS_MISTRAL_API_KEY` | Mistral API key | +| `PICOCLAW_PROVIDERS_DEEPSEEK_API_KEY` | DeepSeek API key | + +#### Channel Config (optional) + +| Variable | Description | +|----------|-------------| +| `PICOCLAW_CHANNELS_TELEGRAM_ENABLED` | `true` / `false` | +| `PICOCLAW_CHANNELS_TELEGRAM_TOKEN` | Telegram bot token | +| `PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM` | Comma-separated user IDs (e.g., `123,456`) | +| `PICOCLAW_CHANNELS_DISCORD_ENABLED` | `true` / `false` | +| `PICOCLAW_CHANNELS_DISCORD_TOKEN` | Discord bot token | +| `PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM` | Comma-separated user IDs | + +#### Model Config (optional) + +| Variable | Default | Description | +|----------|---------|-------------| +| `PICOCLAW_AGENTS_DEFAULTS_PROVIDER` | `gemini` | LLM provider name | +| `PICOCLAW_AGENTS_DEFAULTS_MODEL` | `gemini-2.5-flash-lite` | Model name | +| `PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS` | `8192` | Max output tokens | +| `PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE` | `0.7` | Temperature | + +#### Other (optional) + +| Variable | Default | Description | +|----------|---------|-------------| +| `TZ` | `UTC` | Timezone (e.g., `Asia/Kolkata` for IST) | +| `PICOCLAW_HEARTBEAT_ENABLED` | `true` | Enable heartbeat | +| `PICOCLAW_HEARTBEAT_INTERVAL` | `30` | Heartbeat interval (minutes) | +| `PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED` | `true` | DuckDuckGo search | +| `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED` | `false` | Brave search | +| `PICOCLAW_TOOLS_WEB_BRAVE_API_KEY` | | Brave API key | + +#### ✅ Advantages +- **Zero JSON knowledge needed** — just set key=value pairs +- **Easiest to set up** — add a few env vars and deploy +- **Good for quick testing** — get running in under a minute + +#### ❌ Limitations +- **No custom providers** — only the built-in providers are supported (Gemini, OpenRouter, OpenAI, Anthropic, Groq, Mistral, DeepSeek, Zhipu, Moonshot, Nvidia, vLLM). You **cannot** add Ollama or other custom OpenAI-compatible providers +- **No custom API base URLs** — you can't override `api_base` for providers (needed for self-hosted models) +- **Limited channel support** — only Telegram, Discord, Slack, and LINE are configurable. Feishu, DingTalk, QQ, WhatsApp, MaixCam, and OneBot are **not** configurable via env vars +- **No proxy settings** — provider proxy configuration is not available +- **Hardcoded defaults** — many settings like `max_results`, `webhook_port`, etc. use hardcoded defaults that can't be changed +- **Allow-lists are comma-separated strings** — works but less flexible than JSON arrays (no spaces in IDs) + +> **💡 Tip:** Start with Method 3 to get running quickly, then switch to Method 1 when you need more control. + +--- + +## 🔧 Running Agent & Doctor Commands + +The gateway service runs automatically. To run one-shot commands, SSH into your Coolify server and use: + +```bash +# Run agent mode (one-shot question) +docker compose -f docker-compose-coolify.yml --profile agent run --rm picoclaw-agent -m "Hello!" + +# Run agent mode (interactive) +docker compose -f docker-compose-coolify.yml --profile agent run --rm picoclaw-agent + +# Run doctor (diagnostics) +docker compose -f docker-compose-coolify.yml --profile doctor run --rm picoclaw-doctor +``` + +### 🎮 Chat Commands (Telegram/Discord) + +You can check status and swap models directly from your chat app: + +| Command | Action | +|---------|--------| +| `/models` | View active model, provider, and all configured endpoints. | +| `/model ` | Switch the **model name** (keeping current provider). | +| `/model /` | Switch **both** provider and model (e.g., `vllm/qwen3-coder-next:cloud`). | + +> [!NOTE] +> Changes made via `/model` are active in memory. If the container restarts, it will revert to the default model defined in your `PICOCLAW_CONFIG_JSON` or environment variables. + +--- + +## �️ Troubleshooting + +### "No API key configured" +Your config isn't being loaded. Check: +- For Method 1: Is `PICOCLAW_CONFIG_JSON` set? Is it valid JSON? +- For Method 3: Is `PICOCLAW_PROVIDERS_GEMINI_API_KEY` set? +- Check container logs: `docker logs picoclaw-gateway` — look for the `📝 Using config from...` line. + +### Container keeps restarting +Check logs: `docker logs picoclaw-gateway --tail 50` + +Common issues: +- Invalid JSON in `PICOCLAW_CONFIG_JSON` (use a validator!) +- Missing API key for the configured provider + +### Build fails +- Ensure you're using the `deploy/coolify` branch +- Check that both `Dockerfile.coolify` and `entrypoint-coolify.sh` exist in the repo + +--- + +## 📁 File Structure (Coolify-specific) + +``` +picoclaw/ +├── docker-compose-coolify.yml # Coolify-optimized compose file +├── Dockerfile.coolify # Coolify Dockerfile with entrypoint +├── entrypoint-coolify.sh # Config generator script +├── Dockerfile # Original Dockerfile (not used by Coolify) +├── docker-compose.yml # Original compose (not used by Coolify) +└── config/ + └── config.example.json # Reference config with all options +``` diff --git a/Dockerfile.coolify b/Dockerfile.coolify new file mode 100644 index 000000000..a61519516 --- /dev/null +++ b/Dockerfile.coolify @@ -0,0 +1,43 @@ +# ============================================================ +# PicoClaw Dockerfile — Coolify Edition +# Uses an entrypoint script that generates config.json from +# environment variables at container startup. +# ============================================================ + +# Stage 1: Build the picoclaw binary +FROM golang:1.26.0-alpine AS builder + +RUN apk add --no-cache git make + +WORKDIR /src + +# Cache dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build +COPY . . +RUN make build + +# Stage 2: Minimal runtime image +FROM alpine:3.23 + +RUN apk add --no-cache ca-certificates tzdata curl + +# Copy binary +COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw + +# Create picoclaw home directory +RUN /usr/local/bin/picoclaw onboard + +# Copy the entrypoint script that generates config from env vars +COPY entrypoint-coolify.sh /usr/local/bin/entrypoint-coolify.sh +RUN chmod +x /usr/local/bin/entrypoint-coolify.sh + +# Default env vars (overridden by Coolify) +ENV PICOCLAW_AGENTS_DEFAULTS_PROVIDER="gemini" +ENV PICOCLAW_AGENTS_DEFAULTS_MODEL="gemini-2.5-flash-lite" +ENV PICOCLAW_PROVIDERS_GEMINI_API_KEY="" + +ENTRYPOINT ["/usr/local/bin/entrypoint-coolify.sh"] +CMD ["gateway"] diff --git a/README.md b/README.md index f80c042f0..888873417 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,32 @@ docker compose --profile gateway build --no-cache docker compose --profile gateway up -d ``` +### ☁️ Deploy on Coolify (3 Steps) + +Deploy PicoClaw on [Coolify](https://coolify.io) in under 5 minutes: + +**1. Create service** — In Coolify: **+ New** → **Docker Compose** → connect your fork of this repo + - Branch: `main` + - Compose file: `docker-compose-coolify.yml` + +**2. Add env vars** — Go to **Environment Variables** tab and add: + +| Variable | Value | +|----------|-------| +| `PICOCLAW_PROVIDERS_GEMINI_API_KEY` | Your [Gemini API key](https://aistudio.google.com/apikey) | +| `PICOCLAW_CHANNELS_TELEGRAM_ENABLED` | `true` *(if using Telegram)* | +| `PICOCLAW_CHANNELS_TELEGRAM_TOKEN` | Your bot token from [@BotFather](https://t.me/BotFather) | +| `TZ` | `Asia/Kolkata` *(or your timezone)* | + +**3. Deploy!** 🚀 + +> [!TIP] +> **Need more control?** (custom providers like Ollama, allowlists, multiple channels) +> Set `PICOCLAW_CONFIG_JSON` with your full config as a single env var. +> See the complete guide: **[COOLIFY.md](COOLIFY.md)** + +--- + ### 🚀 Quick Start > [!TIP] diff --git a/docker-compose-coolify.yml b/docker-compose-coolify.yml new file mode 100644 index 000000000..1b6107080 --- /dev/null +++ b/docker-compose-coolify.yml @@ -0,0 +1,164 @@ +# ============================================================ +# PicoClaw — Coolify-Optimized Docker Compose +# ============================================================ +# +# DEPLOYMENT STEPS (Coolify): +# 1. Create a new service → Docker Compose +# 2. Point source to this repository +# 3. Set "Docker Compose file" path to: picoclaw/docker-compose-coolify.yml +# 4. Add environment variables in Coolify's UI (see below) +# 5. Deploy! +# +# REQUIRED ENVIRONMENT VARIABLES (set in Coolify UI): +# GEMINI_API_KEY — Your Gemini API key +# +# OPTIONAL ENVIRONMENT VARIABLES: +# LLM_PROVIDER — LLM provider (default: gemini) +# LLM_MODEL — Model name (default: gemini-2.5-flash-lite) +# TELEGRAM_BOT_TOKEN — Telegram bot token +# DISCORD_BOT_TOKEN — Discord bot token +# OPENROUTER_API_KEY — OpenRouter API key +# OPENAI_API_KEY — OpenAI API key +# ANTHROPIC_API_KEY — Anthropic API key +# GROQ_API_KEY — Groq API key (also enables voice transcription) +# BRAVE_SEARCH_API_KEY — Brave Search API key +# LINE_CHANNEL_SECRET — LINE channel secret +# LINE_CHANNEL_ACCESS_TOKEN — LINE channel access token +# TZ — Timezone (default: UTC) +# +# ============================================================ + +services: + # ───────────────────────────────────────────── + # PicoClaw Gateway (Long-running Bot) + # This is the main service that Coolify will auto-start + # ───────────────────────────────────────────── + picoclaw-gateway: + build: + context: . + dockerfile: Dockerfile.coolify + container_name: picoclaw-gateway + restart: unless-stopped + + # ── Environment Variables ──────────────────── + # If PICOCLAW_CONFIG_JSON is set, it takes full priority. + # Individual vars below are only used as fallback (Method 3). + environment: + # -- Full JSON config (Method 1 — overrides everything below) -- + - PICOCLAW_CONFIG_JSON=${PICOCLAW_CONFIG_JSON:-} + + # -- Core LLM Config (only used if PICOCLAW_CONFIG_JSON is empty) -- + - LLM_PROVIDER=${LLM_PROVIDER:-} + - LLM_MODEL=${LLM_MODEL:-} + + # -- Provider API Keys -- + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - GROQ_API_KEY=${GROQ_API_KEY:-} + + # -- Chat Channel Tokens -- + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-} + - LINE_CHANNEL_SECRET=${LINE_CHANNEL_SECRET:-} + - LINE_CHANNEL_ACCESS_TOKEN=${LINE_CHANNEL_ACCESS_TOKEN:-} + + # -- Web Search -- + - BRAVE_SEARCH_API_KEY=${BRAVE_SEARCH_API_KEY:-} + - BRAVE_SEARCH_ENABLED=${BRAVE_SEARCH_ENABLED:-} + - DUCKDUCKGO_ENABLED=${DUCKDUCKGO_ENABLED:-} + + # -- Heartbeat -- + - HEARTBEAT_ENABLED=${HEARTBEAT_ENABLED:-} + - HEARTBEAT_INTERVAL=${HEARTBEAT_INTERVAL:-} + + # -- Timezone -- + - TZ=${TZ:-UTC} + + # ── Volumes ────────────────────────────────── + volumes: + # Persistent workspace — sessions, memory, logs survive redeploys + # Note: config.json is created by 'picoclaw onboard' during Docker build + # and all settings are overridden via environment variables above + - picoclaw-workspace:/root/.picoclaw/workspace + + # ── Ports ──────────────────────────────────── + # Expose ports if using LINE webhook or MaixCAM channel + # Coolify will auto-detect and configure these + ports: + - "18790:18790" # MaixCAM / Gateway + - "18791:18791" # LINE Webhook + + # ── Health Check ───────────────────────────── + # Coolify uses this to determine if the container is healthy + healthcheck: + test: ["CMD", "picoclaw", "status"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + # ── Command ────────────────────────────────── + command: ["gateway"] + + # ── Logging ────────────────────────────────── + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # ───────────────────────────────────────────── + # PicoClaw Agent (One-shot / Interactive) + # Not auto-started by Coolify (has profile). + # Run manually via SSH: + # docker compose -f docker-compose-coolify.yml run --rm picoclaw-agent -m "Hello" + # docker compose -f docker-compose-coolify.yml run --rm picoclaw-agent + # ───────────────────────────────────────────── + picoclaw-agent: + build: + context: . + dockerfile: Dockerfile + container_name: picoclaw-agent + profiles: + - agent + environment: + - PICOCLAW_AGENTS_DEFAULTS_PROVIDER=${LLM_PROVIDER:-gemini} + - PICOCLAW_AGENTS_DEFAULTS_MODEL=${LLM_MODEL:-gemini-2.5-flash-lite} + - PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY:-} + - PICOCLAW_PROVIDERS_OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} + - PICOCLAW_PROVIDERS_OPENAI_API_KEY=${OPENAI_API_KEY:-} + - PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - TZ=${TZ:-UTC} + volumes: + - picoclaw-workspace:/root/.picoclaw/workspace + entrypoint: ["picoclaw", "agent"] + stdin_open: true + tty: true + + # ───────────────────────────────────────────── + # PicoClaw Doctor (Diagnostic Tool) + # Not auto-started by Coolify (has profile). + # Run manually via SSH: + # docker compose -f docker-compose-coolify.yml run --rm picoclaw-doctor + # ───────────────────────────────────────────── + picoclaw-doctor: + build: + context: . + dockerfile: Dockerfile + container_name: picoclaw-doctor + profiles: + - doctor + environment: + - PICOCLAW_AGENTS_DEFAULTS_PROVIDER=${LLM_PROVIDER:-gemini} + - PICOCLAW_AGENTS_DEFAULTS_MODEL=${LLM_MODEL:-gemini-2.5-flash-lite} + - PICOCLAW_PROVIDERS_GEMINI_API_KEY=${GEMINI_API_KEY:-} + - TZ=${TZ:-UTC} + volumes: + - picoclaw-workspace:/root/.picoclaw/workspace + entrypoint: ["picoclaw", "doctor"] + +volumes: + picoclaw-workspace: + driver: local diff --git a/entrypoint-coolify.sh b/entrypoint-coolify.sh new file mode 100644 index 000000000..16f8e2505 --- /dev/null +++ b/entrypoint-coolify.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# ============================================================ +# PicoClaw Coolify Entrypoint +# Generates config.json before starting PicoClaw +# +# CONFIG PRIORITY (first match wins): +# 1. PICOCLAW_CONFIG_JSON env var — paste your entire JSON config +# 2. Mounted file at /config/config.json — use Coolify Storages +# 3. Auto-generated from individual env vars (basic setup) +# ============================================================ +set -e + +CONFIG_DIR="/root/.picoclaw" +CONFIG_FILE="${CONFIG_DIR}/config.json" + +mkdir -p "${CONFIG_DIR}" + +# ───────────────────────────────────────────────── +# METHOD 1: Full JSON config via env var +# Set PICOCLAW_CONFIG_JSON in Coolify env vars +# with your entire config.json content +# ───────────────────────────────────────────────── +if [ -n "${PICOCLAW_CONFIG_JSON}" ]; then + echo "📝 Using config from PICOCLAW_CONFIG_JSON env var" + echo "${PICOCLAW_CONFIG_JSON}" > "${CONFIG_FILE}" + exec picoclaw "$@" +fi + +# ───────────────────────────────────────────────── +# METHOD 2: Mounted config file +# In Coolify → Storages → Add: +# Source: /data/coolify/applications//config.json +# Destination: /config/config.json +# Then paste your JSON in the file content +# ───────────────────────────────────────────────── +if [ -f "/config/config.json" ]; then + echo "📝 Using mounted config from /config/config.json" + cp /config/config.json "${CONFIG_FILE}" + exec picoclaw "$@" +fi + +# ───────────────────────────────────────────────── +# METHOD 3: Auto-generate from individual env vars +# Good for simple setups (Gemini + Telegram, etc.) +# ───────────────────────────────────────────────── +echo "📝 Generating config from individual env vars" + +# Helper: comma-separated string → JSON array +csv_to_json_array() { + input="$1" + if [ -z "$input" ]; then echo "[]"; return; fi + result="[" + first=true + OLD_IFS="$IFS"; IFS="," + for item in $input; do + item=$(echo "$item" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -n "$item" ]; then + if [ "$first" = true ]; then first=false; else result="${result},"; fi + result="${result}\"${item}\"" + fi + done + IFS="$OLD_IFS" + echo "${result}]" +} + +# ── Resolve env vars ───────────────────────────── +# Accept both Coolify-friendly short names and PICOCLAW_* internal names +R_PROVIDER="${PICOCLAW_AGENTS_DEFAULTS_PROVIDER:-${LLM_PROVIDER:-gemini}}" +R_MODEL="${PICOCLAW_AGENTS_DEFAULTS_MODEL:-${LLM_MODEL:-gemini-2.5-flash-lite}}" + +R_GEMINI_KEY="${PICOCLAW_PROVIDERS_GEMINI_API_KEY:-${GEMINI_API_KEY:-}}" +R_OPENROUTER_KEY="${PICOCLAW_PROVIDERS_OPENROUTER_API_KEY:-${OPENROUTER_API_KEY:-}}" +R_OPENAI_KEY="${PICOCLAW_PROVIDERS_OPENAI_API_KEY:-${OPENAI_API_KEY:-}}" +R_ANTHROPIC_KEY="${PICOCLAW_PROVIDERS_ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY:-}}" +R_GROQ_KEY="${PICOCLAW_PROVIDERS_GROQ_API_KEY:-${GROQ_API_KEY:-}}" +R_MISTRAL_KEY="${PICOCLAW_PROVIDERS_MISTRAL_API_KEY:-${MISTRAL_API_KEY:-}}" +R_DEEPSEEK_KEY="${PICOCLAW_PROVIDERS_DEEPSEEK_API_KEY:-${DEEPSEEK_API_KEY:-}}" +R_VLLM_KEY="${PICOCLAW_PROVIDERS_VLLM_API_KEY:-${VLLM_API_KEY:-}}" +R_VLLM_BASE="${PICOCLAW_PROVIDERS_VLLM_API_BASE:-${VLLM_API_BASE:-}}" + +R_TELEGRAM="${PICOCLAW_CHANNELS_TELEGRAM_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}" +R_DISCORD="${PICOCLAW_CHANNELS_DISCORD_TOKEN:-${DISCORD_BOT_TOKEN:-}}" +R_LINE_SECRET="${PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET:-${LINE_CHANNEL_SECRET:-}}" +R_LINE_ACCESS="${PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN:-${LINE_CHANNEL_ACCESS_TOKEN:-}}" + +R_BRAVE_KEY="${PICOCLAW_TOOLS_WEB_BRAVE_API_KEY:-${BRAVE_SEARCH_API_KEY:-}}" +R_BRAVE_ON="${PICOCLAW_TOOLS_WEB_BRAVE_ENABLED:-${BRAVE_SEARCH_ENABLED:-false}}" +R_DDG_ON="${PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED:-${DUCKDUCKGO_ENABLED:-true}}" +R_HB_ON="${PICOCLAW_HEARTBEAT_ENABLED:-${HEARTBEAT_ENABLED:-true}}" +R_HB_INT="${PICOCLAW_HEARTBEAT_INTERVAL:-${HEARTBEAT_INTERVAL:-30}}" + +# ── Auto-enable channels when token is provided ── +if [ -n "$R_TELEGRAM" ]; then TG_ON="true"; else TG_ON="${PICOCLAW_CHANNELS_TELEGRAM_ENABLED:-false}"; fi +if [ -n "$R_DISCORD" ]; then DC_ON="true"; else DC_ON="${PICOCLAW_CHANNELS_DISCORD_ENABLED:-false}"; fi +if [ -n "$R_LINE_SECRET" ] && [ -n "$R_LINE_ACCESS" ]; then LN_ON="true"; else LN_ON="${PICOCLAW_CHANNELS_LINE_ENABLED:-false}"; fi + +TELEGRAM_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM:-}") +DISCORD_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM:-}") +SLACK_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_SLACK_ALLOW_FROM:-}") +LINE_ALLOW=$(csv_to_json_array "${PICOCLAW_CHANNELS_LINE_ALLOW_FROM:-}") + +cat > "${CONFIG_FILE}" <` or `/model /`_") + return sb.String() +} + func (al *AgentLoop) RegisterTool(tool tools.Tool) { al.tools.Register(tool) } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 5601d508c..69e37b14c 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -31,8 +31,7 @@ type TelegramChannel struct { config *config.Config chatIDs map[string]int64 transcriber *voice.GroqTranscriber - placeholders sync.Map // chatID -> messageID - stopThinking sync.Map // chatID -> thinkingCancel + stopThinking sync.Map // chatID -> typingCancel } type thinkingCancel struct { @@ -75,7 +74,6 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann config: cfg, chatIDs: make(map[string]int64), transcriber: nil, - placeholders: sync.Map{}, stopThinking: sync.Map{}, }, nil } @@ -133,6 +131,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return nil } + func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.setRunning(false) @@ -149,7 +148,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid chat ID: %w", err) } - // Stop thinking animation + // Stop typing indicator goroutine if stop, ok := c.stopThinking.Load(msg.ChatID); ok { if cf, ok := stop.(*thinkingCancel); ok && cf != nil { cf.Cancel() @@ -159,18 +158,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err htmlContent := markdownToTelegramHTML(msg.Content) - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil - } - // Fallback to new message if edit fails - } - tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML @@ -321,31 +308,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ - "error": err.Error(), - }) - } - - // Stop any previous thinking animation + // Start repeating typing indicator (expires after 5s per Telegram API, + // so we re-send every 4s until the response arrives) chatIDStr := fmt.Sprintf("%d", chatID) + // Cancel any previous typing goroutine for this chat if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { cf.Cancel() } } - - // Create cancel function for thinking state - _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) - if err == nil { - pID := pMsg.MessageID - c.placeholders.Store(chatIDStr, pID) - } + typingCtx, typingCancel := context.WithCancel(ctx) + c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: typingCancel}) + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + // Send immediately first + _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + } + } + }() metadata := map[string]string{ "message_id": fmt.Sprintf("%d", message.MessageID),