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.
This commit is contained in:
mrbeandev 2026-02-16 00:10:54 +05:30
parent 1d748fb742
commit 86bb45610c
7 changed files with 895 additions and 36 deletions

326
COOLIFY.md Normal file
View file

@ -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/<your-app-uuid>/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 <uuid> with your app's UUID)
nano /data/coolify/applications/<uuid>/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 <name>` | Switch the **model name** (keeping current provider). |
| `/model <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.
---
## <20> 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
```

43
Dockerfile.coolify Normal file
View file

@ -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"]

View file

@ -172,6 +172,32 @@ docker compose --profile gateway build --no-cache
docker compose --profile gateway up -d 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 ### 🚀 Quick Start
> [!TIP] > [!TIP]

164
docker-compose-coolify.yml Normal file
View file

@ -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

153
entrypoint-coolify.sh Normal file
View file

@ -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/<uuid>/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}" <<ENDOFCONFIG
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"provider": "${R_PROVIDER}",
"model": "${R_MODEL}",
"max_tokens": ${PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS:-8192},
"temperature": ${PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE:-0.7},
"max_tool_iterations": ${PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS:-20}
}
},
"channels": {
"telegram": { "enabled": ${TG_ON}, "token": "${R_TELEGRAM}", "allow_from": ${TELEGRAM_ALLOW} },
"discord": { "enabled": ${DC_ON}, "token": "${R_DISCORD}", "allow_from": ${DISCORD_ALLOW} },
"slack": { "enabled": ${PICOCLAW_CHANNELS_SLACK_ENABLED:-false}, "bot_token": "${PICOCLAW_CHANNELS_SLACK_BOT_TOKEN:-}", "app_token": "${PICOCLAW_CHANNELS_SLACK_APP_TOKEN:-}", "allow_from": ${SLACK_ALLOW} },
"line": { "enabled": ${LN_ON}, "channel_secret": "${R_LINE_SECRET}", "channel_access_token": "${R_LINE_ACCESS}", "webhook_host": "0.0.0.0", "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": ${LINE_ALLOW} },
"maixcam": { "enabled": false, "host": "0.0.0.0", "port": 18790, "allow_from": [] },
"whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", "allow_from": [] },
"feishu": { "enabled": false, "app_id": "", "app_secret": "", "encrypt_key": "", "verification_token": "", "allow_from": [] },
"dingtalk": { "enabled": false, "client_id": "", "client_secret": "", "allow_from": [] },
"onebot": { "enabled": false, "ws_url": "ws://127.0.0.1:3001", "access_token": "", "reconnect_interval": 5, "group_trigger_prefix": [], "allow_from": [] }
},
"providers": {
"gemini": { "api_key": "${R_GEMINI_KEY}", "api_base": "${PICOCLAW_PROVIDERS_GEMINI_API_BASE:-}" },
"openrouter": { "api_key": "${R_OPENROUTER_KEY}", "api_base": "${PICOCLAW_PROVIDERS_OPENROUTER_API_BASE:-}" },
"openai": { "api_key": "${R_OPENAI_KEY}", "api_base": "${PICOCLAW_PROVIDERS_OPENAI_API_BASE:-}" },
"anthropic": { "api_key": "${R_ANTHROPIC_KEY}", "api_base": "${PICOCLAW_PROVIDERS_ANTHROPIC_API_BASE:-}" },
"groq": { "api_key": "${R_GROQ_KEY}", "api_base": "${PICOCLAW_PROVIDERS_GROQ_API_BASE:-}" },
"mistral": { "api_key": "${R_MISTRAL_KEY}", "api_base": "${PICOCLAW_PROVIDERS_MISTRAL_API_BASE:-}" },
"zhipu": { "api_key": "${PICOCLAW_PROVIDERS_ZHIPU_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_ZHIPU_API_BASE:-}" },
"moonshot": { "api_key": "${PICOCLAW_PROVIDERS_MOONSHOT_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_MOONSHOT_API_BASE:-}" },
"deepseek": { "api_key": "${R_DEEPSEEK_KEY}", "api_base": "${PICOCLAW_PROVIDERS_DEEPSEEK_API_BASE:-}" },
"nvidia": { "api_key": "${PICOCLAW_PROVIDERS_NVIDIA_API_KEY:-}", "api_base": "${PICOCLAW_PROVIDERS_NVIDIA_API_BASE:-}" },
"vllm": { "api_key": "${R_VLLM_KEY}", "api_base": "${R_VLLM_BASE}" }
},
"tools": {
"web": {
"brave": { "enabled": ${R_BRAVE_ON}, "api_key": "${R_BRAVE_KEY}", "max_results": 5 },
"duckduckgo": { "enabled": ${R_DDG_ON}, "max_results": 5 }
},
"firecrawl": { "enabled": ${PICOCLAW_TOOLS_FIRECRAWL_ENABLED:-false}, "api_key": "${PICOCLAW_TOOLS_FIRECRAWL_API_KEY:-}", "api_base": "https://api.firecrawl.dev/v1" },
"serpapi": { "enabled": ${PICOCLAW_TOOLS_SERPAPI_ENABLED:-false}, "api_key": "${PICOCLAW_TOOLS_SERPAPI_API_KEY:-}", "max_results": 10 }
},
"heartbeat": { "enabled": ${R_HB_ON}, "interval": ${R_HB_INT} },
"devices": { "enabled": false, "monitor_usb": false },
"gateway": { "host": "0.0.0.0", "port": 18790 }
}
ENDOFCONFIG
exec picoclaw "$@"

View file

@ -32,6 +32,7 @@ import (
type AgentLoop struct { type AgentLoop struct {
bus *bus.MessageBus bus *bus.MessageBus
provider providers.LLMProvider provider providers.LLMProvider
cfg *config.Config
workspace string workspace string
model string model string
contextWindow int // Maximum context window size in tokens contextWindow int // Maximum context window size in tokens
@ -137,9 +138,10 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
return &AgentLoop{ return &AgentLoop{
bus: msgBus, bus: msgBus,
provider: provider, provider: provider,
cfg: cfg,
workspace: workspace, workspace: workspace,
model: cfg.Agents.Defaults.Model, model: cfg.Agents.Defaults.Model,
contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization contextWindow: cfg.Agents.Defaults.MaxTokens,
maxIterations: cfg.Agents.Defaults.MaxToolIterations, maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager, sessions: sessionsManager,
state: stateManager, state: stateManager,
@ -195,6 +197,89 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) al.running.Store(false)
} }
func (al *AgentLoop) GetModel() string {
return al.model
}
func (al *AgentLoop) SetModel(model string) {
al.model = model
}
// listModelsResponse builds a dynamic /models response from the current config.
func (al *AgentLoop) listModelsResponse() string {
var sb strings.Builder
sb.WriteString("📋 **Model Info**\n\n")
sb.WriteString(fmt.Sprintf("**Active model:** `%s`\n", al.model))
sb.WriteString(fmt.Sprintf("**Active provider:** `%s`\n\n", al.cfg.Agents.Defaults.Provider))
// List all configured providers
type providerEntry struct {
Name string
APIBase string
}
providersList := []providerEntry{
{"gemini", al.cfg.Providers.Gemini.APIBase},
{"openrouter", al.cfg.Providers.OpenRouter.APIBase},
{"openai", al.cfg.Providers.OpenAI.APIBase},
{"anthropic", al.cfg.Providers.Anthropic.APIBase},
{"vllm", al.cfg.Providers.VLLM.APIBase},
{"groq", al.cfg.Providers.Groq.APIBase},
{"deepseek", al.cfg.Providers.DeepSeek.APIBase},
{"mistral", al.cfg.Providers.Mistral.APIBase},
{"nvidia", al.cfg.Providers.Nvidia.APIBase},
{"moonshot", al.cfg.Providers.Moonshot.APIBase},
{"zhipu", al.cfg.Providers.Zhipu.APIBase},
}
hasConfigured := false
for _, p := range providersList {
// Show providers that have either an API key or API base configured
hasKey := false
switch p.Name {
case "gemini":
hasKey = al.cfg.Providers.Gemini.APIKey != ""
case "openrouter":
hasKey = al.cfg.Providers.OpenRouter.APIKey != ""
case "openai":
hasKey = al.cfg.Providers.OpenAI.APIKey != ""
case "anthropic":
hasKey = al.cfg.Providers.Anthropic.APIKey != ""
case "vllm":
hasKey = al.cfg.Providers.VLLM.APIKey != "" || al.cfg.Providers.VLLM.APIBase != ""
case "groq":
hasKey = al.cfg.Providers.Groq.APIKey != ""
case "deepseek":
hasKey = al.cfg.Providers.DeepSeek.APIKey != ""
case "mistral":
hasKey = al.cfg.Providers.Mistral.APIKey != ""
case "nvidia":
hasKey = al.cfg.Providers.Nvidia.APIKey != ""
case "moonshot":
hasKey = al.cfg.Providers.Moonshot.APIKey != ""
case "zhipu":
hasKey = al.cfg.Providers.Zhipu.APIKey != ""
}
if hasKey {
if !hasConfigured {
sb.WriteString("**Configured providers:**\n")
hasConfigured = true
}
active := ""
if p.Name == al.cfg.Agents.Defaults.Provider {
active = " ✅"
}
if p.APIBase != "" {
sb.WriteString(fmt.Sprintf("- `%s` → %s%s\n", p.Name, p.APIBase, active))
} else {
sb.WriteString(fmt.Sprintf("- `%s`%s\n", p.Name, active))
}
}
}
sb.WriteString("\n_Usage: `/model <name>` or `/model <provider>/<model>`_")
return sb.String()
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) RegisterTool(tool tools.Tool) {
al.tools.Register(tool) al.tools.Register(tool)
} }
@ -263,6 +348,45 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg) return al.processSystemMessage(ctx, msg)
} }
// Handle Slash Commands
if strings.HasPrefix(msg.Content, "/") {
cmd := strings.Split(msg.Content, " ")[0]
switch cmd {
case "/model":
parts := strings.SplitN(msg.Content, " ", 2)
if len(parts) == 1 {
return fmt.Sprintf("Current model: `%s`\nCurrent provider: `%s`\n\n_Usage: `/model <name>` or `/model <provider>/<model>`_", al.model, al.cfg.Agents.Defaults.Provider), nil
}
input := strings.TrimSpace(parts[1])
// Check for provider/model format (e.g. "vllm/qwen3-coder-next:cloud")
if idx := strings.Index(input, "/"); idx > 0 {
newProvider := input[:idx]
newModel := input[idx+1:]
oldProvider := al.cfg.Agents.Defaults.Provider
oldModel := al.model
// Switch provider
al.cfg.Agents.Defaults.Provider = newProvider
newLLM, err := providers.CreateProvider(al.cfg)
if err != nil {
al.cfg.Agents.Defaults.Provider = oldProvider
return fmt.Sprintf("❌ Failed to switch to `%s`: %v", newProvider, err), nil
}
al.provider = newLLM
al.SetModel(newModel)
return fmt.Sprintf("✅ Switched: `%s/%s` → `%s/%s`", oldProvider, oldModel, newProvider, newModel), nil
}
// Model-only switch (keep current provider)
oldModel := al.model
al.SetModel(input)
return fmt.Sprintf("🔄 Switched model: `%s` → `%s` (provider: `%s`)", oldModel, input, al.cfg.Agents.Defaults.Provider), nil
case "/models":
return al.listModelsResponse(), nil
}
}
// Process as user message // Process as user message
return al.runAgentLoop(ctx, processOptions{ return al.runAgentLoop(ctx, processOptions{
SessionKey: msg.SessionKey, SessionKey: msg.SessionKey,

View file

@ -27,8 +27,7 @@ type TelegramChannel struct {
config config.TelegramConfig config config.TelegramConfig
chatIDs map[string]int64 chatIDs map[string]int64
transcriber *voice.GroqTranscriber transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> typingCancel
stopThinking sync.Map // chatID -> thinkingCancel
} }
type thinkingCancel struct { type thinkingCancel struct {
@ -69,7 +68,6 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
config: cfg, config: cfg,
chatIDs: make(map[string]int64), chatIDs: make(map[string]int64),
transcriber: nil, transcriber: nil,
placeholders: sync.Map{},
stopThinking: sync.Map{}, stopThinking: sync.Map{},
}, nil }, nil
} }
@ -88,6 +86,8 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err) return fmt.Errorf("failed to start long polling: %w", err)
} }
c.registerCommands(ctx)
c.setRunning(true) c.setRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
"username": c.bot.Username(), "username": c.bot.Username(),
@ -113,6 +113,41 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return nil return nil
} }
func (c *TelegramChannel) registerCommands(ctx context.Context) {
commands := []telego.BotCommand{
{
Command: "model",
Description: "View current model or switch using 'model <name>' or 'model <provider>/<model>'",
},
{
Command: "models",
Description: "List all available models and configured providers",
},
}
err := c.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
Commands: commands,
})
if err != nil {
logger.ErrorCF("telegram", "Failed to set bot commands", map[string]interface{}{
"error": err.Error(),
})
return
}
// Verify commands
registered, err := c.bot.GetMyCommands(ctx, &telego.GetMyCommandsParams{})
if err == nil {
var cmdNames []string
for _, cmd := range registered {
cmdNames = append(cmdNames, "/"+cmd.Command)
}
logger.InfoCF("telegram", "Successfully registered bot commands", map[string]interface{}{
"commands": strings.Join(cmdNames, ", "),
})
}
}
func (c *TelegramChannel) Stop(ctx context.Context) error { func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...") logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false) c.setRunning(false)
@ -129,7 +164,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid chat ID: %w", 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 stop, ok := c.stopThinking.Load(msg.ChatID); ok {
if cf, ok := stop.(*thinkingCancel); ok && cf != nil { if cf, ok := stop.(*thinkingCancel); ok && cf != nil {
cf.Cancel() cf.Cancel()
@ -139,18 +174,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
htmlContent := markdownToTelegramHTML(msg.Content) 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 := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML tgMsg.ParseMode = telego.ModeHTML
@ -304,31 +327,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
"preview": utils.Truncate(content, 50), "preview": utils.Truncate(content, 50),
}) })
// Thinking indicator // Start repeating typing indicator (expires after 5s per Telegram API,
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) // so we re-send every 4s until the response arrives)
if err != nil {
logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
"error": err.Error(),
})
}
// Stop any previous thinking animation
chatIDStr := fmt.Sprintf("%d", chatID) chatIDStr := fmt.Sprintf("%d", chatID)
// Cancel any previous typing goroutine for this chat
if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if prevStop, ok := c.stopThinking.Load(chatIDStr); ok {
if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil {
cf.Cancel() cf.Cancel()
} }
} }
typingCtx, typingCancel := context.WithCancel(ctx)
// Create cancel function for thinking state c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: typingCancel})
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) go func() {
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) ticker := time.NewTicker(4 * time.Second)
defer ticker.Stop()
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) // Send immediately first
if err == nil { _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
pID := pMsg.MessageID for {
c.placeholders.Store(chatIDStr, pID) select {
case <-typingCtx.Done():
return
case <-ticker.C:
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
} }
}
}()
metadata := map[string]string{ metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID), "message_id": fmt.Sprintf("%d", message.MessageID),