feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments
Core agent improvements: - RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector - Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection - Rich conversation storage for notable turns; compact_conversation truncates long user messages - Task-type classifier (query/action/analysis/creative) for observation tagging - Nested sub-agent visibility: deep delegations now register against the main agent's manager Child safety (Gabriel profile): - child_safety.py: filtering, audit logging, prompt constants for restricted sessions - .kiro/specs/child-safety-profile: requirements, design, tasks specs - GABRIEL_BOT_PROPOSAL.md: initial proposal doc - Reduced context window (10 msgs) and tutor-mode identity for restricted users Telegram adapter: - Polling watchdog: auto-restarts updater if polling drops unexpectedly - get_me() with exponential-backoff retry on NetworkError at startup - Correct stop() ordering: signal watchdog before cancelling tasks Email / Gmail: - send_email: supports file attachments (attachments list param) - get_email: surfaces attachment metadata in response Scheduled tasks / weather: - Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively - New scheduled tasks and scheduler state persistence Discord: - adapters/discord/__init__.py scaffold - discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config) Infrastructure: - n8n workflow exports (garvis_webhook, content_pipeline variants) - memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs - UCS C240 migration plan doc - requirements.txt: new deps - .claude/settings.json, fix_hooks.py: hook/permission tuning
This commit is contained in:
5
adapters/discord/__init__.py
Normal file
5
adapters/discord/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Discord adapter package."""
|
||||
|
||||
from .adapter import DiscordAdapter
|
||||
|
||||
__all__ = ["DiscordAdapter"]
|
||||
@@ -218,14 +218,19 @@ class AdapterRuntime:
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Failed to send progress update: {e}")
|
||||
|
||||
# Get response from agent (synchronous call in thread)
|
||||
response = await asyncio.to_thread(
|
||||
self.agent.chat,
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
progress_callback=progress_callback,
|
||||
inbound_message=processed_message,
|
||||
)
|
||||
# Check if a preprocessor signaled a block (e.g., child safety filter)
|
||||
_block_reply = processed_message.metadata.get("_cs_blocked")
|
||||
if _block_reply:
|
||||
response = _block_reply
|
||||
else:
|
||||
# Get response from agent (synchronous call in thread)
|
||||
response = await asyncio.to_thread(
|
||||
self.agent.chat,
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
progress_callback=progress_callback,
|
||||
inbound_message=processed_message,
|
||||
)
|
||||
|
||||
# Apply postprocessors
|
||||
for postprocessor in self._postprocessors:
|
||||
|
||||
@@ -112,6 +112,13 @@ class SlackAdapter(BaseAdapter):
|
||||
self.is_running = False
|
||||
print("[Slack] Disconnected")
|
||||
|
||||
def _is_user_allowed(self, user_id: str) -> bool:
|
||||
"""Return False if an allow-list is configured and this user is not on it."""
|
||||
allowed = self.config.settings.get("allowed_users", [])
|
||||
if not allowed:
|
||||
return True # open if unconfigured — preserves existing behaviour
|
||||
return str(user_id) in [str(u) for u in allowed]
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
"""Register Slack event handlers."""
|
||||
|
||||
@@ -121,8 +128,19 @@ class SlackAdapter(BaseAdapter):
|
||||
if event.get("subtype") in ["bot_message", "message_changed"]:
|
||||
return
|
||||
|
||||
# Suppress Slack system notifications (channel privacy changes, etc.)
|
||||
raw_text = event.get("text", "")
|
||||
_SUPPRESSED_PATTERNS = [
|
||||
"made this channel *private*",
|
||||
"has joined the channel",
|
||||
]
|
||||
if any(p in raw_text for p in _SUPPRESSED_PATTERNS):
|
||||
return
|
||||
|
||||
user_id = event.get("user")
|
||||
text = event.get("text", "")
|
||||
if not self._is_user_allowed(user_id):
|
||||
return
|
||||
text = raw_text
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
ts = event.get("ts")
|
||||
@@ -184,6 +202,8 @@ class SlackAdapter(BaseAdapter):
|
||||
async def handle_app_mentions(event, say):
|
||||
"""Handle @mentions of the bot."""
|
||||
user_id = event.get("user")
|
||||
if not self._is_user_allowed(user_id):
|
||||
return
|
||||
text = self._strip_mention(event.get("text", ""))
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
|
||||
@@ -5,10 +5,11 @@ Uses python-telegram-bot library for async Telegram Bot API integration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from telegram import Bot, Update
|
||||
from telegram.error import TelegramError
|
||||
from telegram.error import NetworkError, TelegramError
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
@@ -39,11 +40,15 @@ class TelegramAdapter(BaseAdapter):
|
||||
- parse_mode: "HTML" or "Markdown" (default: "Markdown")
|
||||
"""
|
||||
|
||||
_RECONNECT_DELAYS = [5, 15, 30, 60, 120] # seconds between startup retries
|
||||
|
||||
def __init__(self, config: AdapterConfig) -> None:
|
||||
super().__init__(config)
|
||||
self.application: Optional[Application] = None
|
||||
self.bot: Optional[Bot] = None
|
||||
self._polling_task: Optional[asyncio.Task] = None
|
||||
self._watchdog_task: Optional[asyncio.Task] = None
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
@@ -70,7 +75,7 @@ class TelegramAdapter(BaseAdapter):
|
||||
return bool(bot_token and len(bot_token) > 20)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Telegram bot."""
|
||||
"""Start the Telegram bot with retry on network failures."""
|
||||
if not self.validate_config():
|
||||
raise ValueError(
|
||||
"Invalid Telegram configuration. Need bot_token"
|
||||
@@ -89,36 +94,84 @@ class TelegramAdapter(BaseAdapter):
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
|
||||
# Run polling in a background task instead of blocking
|
||||
self._polling_task = asyncio.create_task(
|
||||
self.application.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
# start_polling() starts internal Updater machinery and returns quickly.
|
||||
# Use updater.running (not task state) to detect if polling is alive.
|
||||
await self.application.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
|
||||
self.is_running = True
|
||||
|
||||
me = await self.bot.get_me()
|
||||
print(
|
||||
f"[Telegram] Bot started: @{me.username} ({me.first_name})"
|
||||
)
|
||||
# Verify connectivity with retries — get_me() fails on DNS errors at boot.
|
||||
me = await self._get_me_with_retry()
|
||||
if me:
|
||||
print(f"[Telegram] Bot started: @{me.username} ({me.first_name})")
|
||||
else:
|
||||
print("[Telegram] Bot started (could not verify identity — network may be degraded)")
|
||||
|
||||
# Watchdog restarts polling if the task dies unexpectedly.
|
||||
self._watchdog_task = asyncio.create_task(self._polling_watchdog())
|
||||
|
||||
async def _get_me_with_retry(self):
|
||||
"""Call get_me() with exponential backoff. Returns None if all attempts fail."""
|
||||
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
|
||||
try:
|
||||
return await self.bot.get_me()
|
||||
except NetworkError as e:
|
||||
if attempt <= len(self._RECONNECT_DELAYS):
|
||||
print(f"[Telegram] Network error on startup (attempt {attempt}): {e} — retrying in {delay}s")
|
||||
await asyncio.sleep(delay)
|
||||
# Last attempt with no retry after
|
||||
try:
|
||||
return await self.bot.get_me()
|
||||
except NetworkError as e:
|
||||
print(f"[Telegram] Could not reach Telegram API after {len(self._RECONNECT_DELAYS)+1} attempts: {e}")
|
||||
return None
|
||||
|
||||
async def _polling_watchdog(self) -> None:
|
||||
"""Monitor the Updater and restart polling if it stops unexpectedly."""
|
||||
# Give polling a moment to fully initialise before we start watching.
|
||||
await asyncio.sleep(15)
|
||||
while self.is_running:
|
||||
await asyncio.sleep(30)
|
||||
if not self.is_running:
|
||||
break
|
||||
if not self.application or self.application.updater.running:
|
||||
continue # All good
|
||||
|
||||
self._logger.warning("[Telegram] Updater is no longer running — attempting restart")
|
||||
print("[Telegram] Polling dropped — restarting...")
|
||||
for attempt, delay in enumerate(self._RECONNECT_DELAYS, 1):
|
||||
await asyncio.sleep(delay)
|
||||
if not self.is_running:
|
||||
return
|
||||
try:
|
||||
await self.application.updater.start_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=False,
|
||||
)
|
||||
print(f"[Telegram] Polling restarted (attempt {attempt})")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[Telegram] Restart attempt {attempt} failed: {e}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the Telegram bot."""
|
||||
self.is_running = False # Signal watchdog to exit before cancelling tasks
|
||||
|
||||
if self._watchdog_task and not self._watchdog_task.done():
|
||||
self._watchdog_task.cancel()
|
||||
try:
|
||||
await self._watchdog_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
if self.application:
|
||||
print("[Telegram] Stopping bot...")
|
||||
await self.application.updater.stop()
|
||||
await self.application.stop()
|
||||
await self.application.shutdown()
|
||||
self.is_running = False
|
||||
|
||||
if self._polling_task and not self._polling_task.done():
|
||||
self._polling_task.cancel()
|
||||
try:
|
||||
await self._polling_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
print("[Telegram] Bot stopped")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user