#!/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