diff --git a/docs/telegram-bot-setup.md b/docs/telegram-bot-setup.md new file mode 100644 index 0000000..29db802 --- /dev/null +++ b/docs/telegram-bot-setup.md @@ -0,0 +1,77 @@ +# Neuron Telegram Gateway — Setup + +The Telegram gateway lets you chat with your Neuron soul via Telegram. Plain messages go to the soul; commands give access to memory and status. + +## 1. Create a bot via @BotFather + +1. Open Telegram and search for **@BotFather** +2. Send `/newbot` +3. Pick a name (e.g. "Neuron") +4. Pick a username (must end in `bot`, e.g. `myneuron_bot`) +5. BotFather replies with your **HTTP API token** — looks like `7123456789:ABCdef...` +6. Optionally set a description: `/setdescription` → select your bot → type a description + +## 2. Store the token in the macOS Keychain + +Never put the token in a plist, `.env`, or any file that might be committed. + +```bash +security add-generic-password \ + -s neuron-telegram-bot \ + -a neuron \ + -w '' +``` + +Verify: +```bash +security find-generic-password -s neuron-telegram-bot -a neuron -w +``` + +## 3. Load the LaunchAgent + +```bash +launchctl load ~/Library/LaunchAgents/ai.neuron.telegram-gateway.plist +``` + +Check it started: +```bash +launchctl list | grep telegram +tail -f ~/.neuron/logs/telegram-gateway.out.log +``` + +## 4. Test + +Send your bot a message in Telegram. It should reply using your soul's voice. + +## Commands + +| Command | What it does | +|---------|-------------| +| `` | Forwarded to the soul → responds in its voice | +| `/memory ` | Searches soul memories, returns top 3 | +| `/remember ` | Stores text as a memory node | +| `/status` | Reports whether the soul is reachable | + +## Unload / stop + +```bash +launchctl unload ~/Library/LaunchAgents/ai.neuron.telegram-gateway.plist +``` + +## Troubleshoot + +- **"token not found"** — re-run step 2 above +- **"Soul is resting"** — the soul daemon at `http://localhost:7770` is not running; start it with `launchctl load ~/Library/LaunchAgents/ai.neuron.engram.plist` (or whichever plist runs the soul) +- **Logs**: `~/.neuron/logs/telegram-gateway.out.log` and `telegram-gateway.err.log` +- **Test gateway script directly**: + ```bash + TELEGRAM_BOT_TOKEN= ~/Development/neuron-technologies/neuron/tools/telegram-gateway.sh + ``` + +## Soul API endpoints used + +| Endpoint | Purpose | +|----------|---------| +| `POST /api/chat` | Forward messages to the soul | +| `POST /api/neuron/recall` | Search memories | +| `POST /api/neuron/memory` | Store conversation as a memory node | diff --git a/tools/telegram-gateway.sh b/tools/telegram-gateway.sh new file mode 100755 index 0000000..cf1d03d --- /dev/null +++ b/tools/telegram-gateway.sh @@ -0,0 +1,191 @@ +#!/bin/bash +# Neuron Telegram Gateway +# Polls Telegram for new messages, forwards to the soul at localhost:7770, sends responses back. +# Supports plain text chat + commands: /memory, /remember, /status +# +# Token resolution order: +# 1. $TELEGRAM_BOT_TOKEN env var +# 2. macOS Keychain: security find-generic-password -s neuron-telegram-bot -a neuron -w + +set -euo pipefail + +TOKEN="${TELEGRAM_BOT_TOKEN:-$(security find-generic-password -s neuron-telegram-bot -a neuron -w 2>/dev/null || true)}" +SOUL_URL="http://localhost:7770" +OFFSET=0 +POLL_TIMEOUT=30 + +if [[ -z "$TOKEN" ]]; then + echo "ERROR: No Telegram bot token. Set TELEGRAM_BOT_TOKEN or store in keychain." >&2 + echo "See: ~/Development/neuron-technologies/neuron/docs/telegram-bot-setup.md" >&2 + exit 1 +fi + +TG="https://api.telegram.org/bot${TOKEN}" + +log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } + +# Send a Telegram message back to a chat +send_message() { + local chat_id="$1" + local text="$2" + curl -s -X POST "${TG}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --argjson cid "$chat_id" --arg t "$text" \ + '{chat_id: $cid, text: $t, parse_mode: "Markdown"}')" \ + > /dev/null +} + +# Store a memory in the soul +store_memory() { + local content="$1" + local label="${2:-telegram:conversation}" + curl -s -X POST "${SOUL_URL}/api/neuron/memory" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg c "$content" --arg l "$label" \ + '{content: $c, label: $l}')" \ + > /dev/null +} + +# Chat with the soul; echoes the response text +soul_chat() { + local message="$1" + local from="${2:-unknown}" + local response + response=$(curl -s -X POST "${SOUL_URL}/api/chat" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg m "$message" --arg f "$from" \ + '{message: $m, from: $f}')" 2>/dev/null) + # Extract .response — fall back to raw body on parse failure + jq -r '.response // empty' <<< "$response" 2>/dev/null || echo "$response" +} + +# Search soul memories; echoes formatted results +soul_recall() { + local query="$1" + local limit="${2:-3}" + local raw + raw=$(curl -s -X POST "${SOUL_URL}/api/neuron/recall" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg q "$query" --argjson l "$limit" \ + '{query: $q, limit: $l}')" 2>/dev/null) + # Format top results as a numbered list (truncate long nodes to 300 chars) + jq -r 'if type == "array" then + to_entries | .[:3] | map( + (.index + 1 | tostring) + ". " + (.value.content | .[0:300] | gsub("\n";" ")) + ) | join("\n\n") + else + "No results found." + end' <<< "$raw" 2>/dev/null || echo "No results found." +} + +# Check if soul is reachable +soul_health() { + curl -s --max-time 3 "${SOUL_URL}/" > /dev/null 2>&1 && echo "up" || echo "down" +} + +handle_update() { + local update="$1" + local chat_id msg_text from_name update_id + + update_id=$(jq -r '.update_id' <<< "$update") + chat_id=$(jq -r '.message.chat.id // empty' <<< "$update") + msg_text=$(jq -r '.message.text // empty' <<< "$update") + from_name=$(jq -r '.message.from.first_name // "stranger"' <<< "$update") + + # Skip non-message updates (inline queries, etc.) + if [[ -z "$chat_id" || -z "$msg_text" ]]; then + OFFSET=$((update_id + 1)) + return + fi + + log "[$update_id] from=$from_name chat=$chat_id text=${msg_text:0:60}" + + # Route by command prefix + if [[ "$msg_text" == /status* ]]; then + local health + health=$(soul_health) + if [[ "$health" == "up" ]]; then + send_message "$chat_id" "Soul is *online* at ${SOUL_URL} ✓" + else + send_message "$chat_id" "Soul appears to be *offline* (${SOUL_URL} unreachable)." + fi + + elif [[ "$msg_text" == /memory* ]]; then + local query="${msg_text#/memory}" + query="${query# }" + if [[ -z "$query" ]]; then + send_message "$chat_id" "Usage: /memory " + else + local results + results=$(soul_recall "$query" 3) + if [[ -n "$results" ]]; then + send_message "$chat_id" "*Memories matching \"${query}\":* + +${results}" + else + send_message "$chat_id" "No memories found for \"${query}\"." + fi + fi + + elif [[ "$msg_text" == /remember* ]]; then + local content="${msg_text#/remember}" + content="${content# }" + if [[ -z "$content" ]]; then + send_message "$chat_id" "Usage: /remember " + else + store_memory "Telegram (${from_name}): ${content}" "telegram:explicit" + send_message "$chat_id" "Stored: _${content}_" + fi + + else + # Plain text — forward to soul chat + local soul_response + soul_response=$(soul_chat "$msg_text" "$from_name" 2>/dev/null || true) + + if [[ -z "$soul_response" ]]; then + soul_response="Neuron is resting — try again in a moment." + fi + + send_message "$chat_id" "$soul_response" + + # Capture conversation as a memory (fire-and-forget) + store_memory "Telegram conversation with ${from_name}: [user] ${msg_text} [soul] ${soul_response}" \ + "telegram:conversation" & + fi + + OFFSET=$((update_id + 1)) +} + +log "Neuron Telegram gateway starting (soul=${SOUL_URL}, poll_timeout=${POLL_TIMEOUT}s)" + +while true; do + # Long-poll for updates + UPDATES=$(curl -s --max-time $((POLL_TIMEOUT + 5)) \ + "${TG}/getUpdates?offset=${OFFSET}&timeout=${POLL_TIMEOUT}" 2>/dev/null || true) + + if [[ -z "$UPDATES" ]]; then + log "WARN: Empty response from Telegram; retrying in 5s" + sleep 5 + continue + fi + + OK=$(jq -r '.ok // false' <<< "$UPDATES" 2>/dev/null) + if [[ "$OK" != "true" ]]; then + DESC=$(jq -r '.description // "unknown error"' <<< "$UPDATES" 2>/dev/null) + log "WARN: Telegram API error: ${DESC}; retrying in 10s" + sleep 10 + continue + fi + + # Iterate over each update + COUNT=$(jq '.result | length' <<< "$UPDATES" 2>/dev/null || echo 0) + if [[ "$COUNT" -gt 0 ]]; then + for i in $(seq 0 $((COUNT - 1))); do + update=$(jq ".result[$i]" <<< "$UPDATES") + handle_update "$update" + done + fi + + # Avoid hammering the API if something is very wrong + sleep 1 +done