192 lines
5.9 KiB
Bash
Executable File
192 lines
5.9 KiB
Bash
Executable File
#!/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 <query>"
|
|
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 <text to store>"
|
|
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
|