feat: capture message_thread_id for Telegram topic routing

This commit is contained in:
daniel
2026-05-12 14:05:00 -05:00
parent 3a49dadb69
commit 9d3a93a998
24 changed files with 1442 additions and 73 deletions

View File

@@ -71,7 +71,7 @@ def update_user_status(actor_id: str, name: str, status: str) -> None:
_sent_hashes: set = set()
def send_telegram_direct(chat_id: str, token: str, text: str) -> None:
def send_telegram_direct(chat_id: str, token: str, text: str, thread_id: int | None = None) -> None:
import hashlib
h = hashlib.md5(f'{chat_id}:{text}'.encode()).hexdigest()[:12]
if h in _sent_hashes:
@@ -79,7 +79,10 @@ def send_telegram_direct(chat_id: str, token: str, text: str) -> None:
return
_sent_hashes.add(h)
url = f'https://api.telegram.org/bot{token}/sendMessage'
data = json.dumps({'chat_id': chat_id, 'text': text}).encode()
payload: dict = {'chat_id': chat_id, 'text': text}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
try:
resp = urllib.request.urlopen(req, timeout=10)
@@ -138,6 +141,7 @@ def handler(event, context):
first = records[0]
channel = first.get('channel', 'telegram')
chat_id = first.get('chat_id', '')
message_thread_id = first.get('message_thread_id') # int or None
actor_id = f"{channel}:{chat_id}"
# ── User registry ─────────────────────────────────────────────────────
@@ -161,7 +165,7 @@ def handler(event, context):
if bot_token_secret_arn:
sm = boto3.client('secretsmanager', region_name='us-east-1')
bot_token = sm.get_secret_value(SecretId=bot_token_secret_arn)['SecretString']
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?")
send_telegram_direct(chat_id, bot_token, "Hi! I don't recognize you yet. What's your name?", thread_id=message_thread_id)
return
# ── Get or create AgentCore session ──────────────────────────────────
session_id = get_or_create_session(actor_id)
@@ -192,6 +196,7 @@ def handler(event, context):
'channel_adapter': {
'type': channel,
'target_id': str(chat_id),
'message_thread_id': message_thread_id,
'bot_token_secret_arn': os.environ.get('TELEGRAM_BOT_TOKEN_SECRET_ARN', ''),
},
}
@@ -256,7 +261,7 @@ def handler(event, context):
# Only flush if buffer is very large — prevents splitting multi-turn responses
if len(text_buffer) > 1200:
print(f'[agent-runner] send chunk {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip())
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
text_buffer = ''
# Flush any remaining text
@@ -267,6 +272,6 @@ def handler(event, context):
print(f'[agent-runner] heartbeat suppressed for {actor_id}')
return
print(f'[agent-runner] flushing {len(text_buffer)}c to {chat_id}')
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip())
send_telegram_direct(str(chat_id), bot_token, text_buffer.strip(), thread_id=message_thread_id)
print(f"[agent-runner] Completed session={session_id} actor={actor_id}")

View File

@@ -22,11 +22,14 @@ def get_bot_token() -> str:
return _bot_token
def send_typing(chat_id: str) -> None:
def send_typing(chat_id: str, thread_id: int | None = None) -> None:
"""Fire-and-forget typing action (does not raise on failure)."""
try:
token = get_bot_token()
data = json.dumps({'chat_id': chat_id, 'action': 'typing'}).encode()
payload: dict = {'chat_id': chat_id, 'action': 'typing'}
if thread_id is not None:
payload['message_thread_id'] = thread_id
data = json.dumps(payload).encode()
req = urllib.request.Request(
f'https://api.telegram.org/bot{token}/sendChatAction',
data=data,
@@ -37,6 +40,7 @@ def send_typing(chat_id: str) -> None:
pass # typing is best-effort
def handler(event, context):
# ── Validate Telegram webhook secret ──────────────────────────────────
expected_secret = os.environ.get('TELEGRAM_WEBHOOK_SECRET', '')
@@ -63,6 +67,7 @@ def handler(event, context):
return {'statusCode': 200, 'body': 'ok'}
chat_id = str(message.get('chat', {}).get('id', ''))
message_thread_id = message.get('message_thread_id') # present for supergroup topics
text = message.get('text', '')
from_user = message.get('from', {})
timestamp = message.get('date', 0)
@@ -74,7 +79,7 @@ def handler(event, context):
return {'statusCode': 200, 'body': 'ok'}
# ── Send typing action (non-blocking, background thread) ──────────────
t = threading.Thread(target=send_typing, args=(chat_id,))
t = threading.Thread(target=send_typing, args=(chat_id, message_thread_id))
t.daemon = True
t.start()
@@ -87,6 +92,7 @@ def handler(event, context):
MessageBody=json.dumps({
'channel': 'telegram',
'chat_id': chat_id,
'message_thread_id': message_thread_id, # None for regular chats, int for topics
'messages': [{
'text': text,
'from_id': str(from_user.get('id', '')),

View File

@@ -7,8 +7,9 @@ import boto3
class TelegramAdapter:
"""Channel adapter for Telegram Bot API."""
def __init__(self, chat_id: str, bot_token_secret_arn: str = ''):
def __init__(self, chat_id: str, bot_token_secret_arn: str = '', message_thread_id: int | None = None):
self.chat_id = str(chat_id)
self.thread_id = message_thread_id # None for regular chats, int for supergroup topics
self._secret_arn = bot_token_secret_arn
self._token: str | None = None
self._lock = threading.Lock()
@@ -36,31 +37,37 @@ class TelegramAdapter:
def send(self, text: str) -> str:
"""Send message, return message_id."""
resp = self._api('sendMessage', {
payload: dict = {
'chat_id': self.chat_id,
'text': text,
'parse_mode': 'Markdown',
})
}
if self.thread_id is not None:
payload['message_thread_id'] = self.thread_id
resp = self._api('sendMessage', payload)
return str(resp.get('result', {}).get('message_id', ''))
def send_typing(self) -> None:
"""Send typing action (best-effort)."""
try:
self._api('sendChatAction', {
'chat_id': self.chat_id,
'action': 'typing',
})
payload: dict = {'chat_id': self.chat_id, 'action': 'typing'}
if self.thread_id is not None:
payload['message_thread_id'] = self.thread_id
self._api('sendChatAction', payload)
except Exception:
pass
def edit(self, message_id: str, text: str) -> None:
"""Edit an existing message in-place."""
try:
self._api('editMessageText', {
payload: dict = {
'chat_id': self.chat_id,
'message_id': int(message_id),
'text': text,
'parse_mode': 'Markdown',
})
}
if self.thread_id is not None:
payload['message_thread_id'] = self.thread_id
self._api('editMessageText', payload)
except Exception:
pass

View File

@@ -64,6 +64,7 @@ def main(payload: dict, context) -> dict:
adapter = TelegramAdapter(
chat_id=adapter_config.get('target_id', ''),
bot_token_secret_arn=adapter_config.get('bot_token_secret_arn', ''),
message_thread_id=adapter_config.get('message_thread_id'),
)
else:
# Future channels: instantiate appropriate adapter