Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion beacon_skill/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def _maybe_udp_emit(cfg: Dict[str, Any], event: Dict[str, Any]) -> None:
_ALL_KINDS = ["like", "want", "bounty", "ad", "hello", "link", "event", "pay",
"pulse", "offer", "accept", "deliver", "confirm", "subscribe",
"mayday", "heartbeat", "accord"]
_ALL_TRANSPORTS = ["udp", "webhook", "discord", "bottube", "moltbook", "clawcities", "clawsta", "fourclaw", "pinchedin", "clawtasks", "clawnews", "rustchain"]
_ALL_TRANSPORTS = ["udp", "webhook", "telegram", "discord", "bottube", "moltbook", "clawcities", "clawsta", "fourclaw", "pinchedin", "clawtasks", "clawnews", "rustchain"]
_TOPIC_SUGGESTIONS = [
"ai", "blockchain", "gaming", "vintage-hardware", "music",
"art", "science", "finance", "devtools", "security",
Expand Down Expand Up @@ -1139,6 +1139,71 @@ def cmd_clawnews_search(args: argparse.Namespace) -> int:

# ── Discord ──


def _telegram_client(cfg: Dict[str, Any], bot_token: Optional[str] = None) -> Any:
from .transports.telegram import TelegramClient
token = bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing telegram.bot_token in config or --bot-token.")
sys.exit(1)
timeout_s = int(_cfg_get(cfg, "telegram", "timeout_s", default=20) or 20)
return TelegramClient(bot_token=token, timeout_s=timeout_s)

Comment on lines +1142 to +1151
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_telegram_client() is currently unused (only defined, never called) and also calls sys.exit(1) on missing config, which is inconsistent with most other client helpers in this file (they return an error code via the command functions). Consider removing it until needed, or refactoring cmd_telegram_send/listen to use it and return error codes instead of exiting.

Suggested change
def _telegram_client(cfg: Dict[str, Any], bot_token: Optional[str] = None) -> Any:
from .transports.telegram import TelegramClient
token = bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing telegram.bot_token in config or --bot-token.")
sys.exit(1)
timeout_s = int(_cfg_get(cfg, "telegram", "timeout_s", default=20) or 20)
return TelegramClient(bot_token=token, timeout_s=timeout_s)

Copilot uses AI. Check for mistakes.
def cmd_telegram_send(args: argparse.Namespace) -> int:
cfg = _load_config(args.config)
setup_logging(args.verbose)
from .transports.telegram import TelegramClient

extra, links = _parse_extra_and_links(args)
kind = getattr(args, "kind", "hello")
identity, _ = _ensure_identity(cfg, args)

env = _build_envelope(cfg, kind, "telegram:webhook", links, extra, identity=identity)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The envelope to field is currently hard-coded as "telegram:webhook", but Telegram sends are addressed to a specific chat_id. Using a destination that matches the actual target (similar to udp:{host}:{port}) will make envelopes/logs easier to reason about and avoid implying webhook delivery. Consider encoding the chat destination in to (e.g. telegram:chat:{chat_id}) and keeping the transport mechanism (polling vs webhook) separate.

Suggested change
env = _build_envelope(cfg, kind, "telegram:webhook", links, extra, identity=identity)
env = _build_envelope(cfg, kind, f"telegram:chat:{args.chat_id}", links, extra, identity=identity)

Copilot uses AI. Check for mistakes.

token = args.bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing bot_token.")
return 1

Comment on lines +1153 to +1167
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd_telegram_send/cmd_telegram_listen currently call helpers that don't exist in this file (_load_config, _parse_extra_and_links, _ensure_identity). This will raise NameError at runtime. Align this code with existing CLI patterns (e.g. use load_config() and _load_identity() and build extra/links from args directly) or add the missing helper implementations.

Suggested change
cfg = _load_config(args.config)
setup_logging(args.verbose)
from .transports.telegram import TelegramClient
extra, links = _parse_extra_and_links(args)
kind = getattr(args, "kind", "hello")
identity, _ = _ensure_identity(cfg, args)
env = _build_envelope(cfg, kind, "telegram:webhook", links, extra, identity=identity)
token = args.bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing bot_token.")
return 1
cfg = load_config(args.config)
setup_logging(args.verbose)
from .transports.telegram import TelegramClient
# Build extra and links directly from args, following existing CLI patterns.
extra = getattr(args, "extra", None) or {}
links = getattr(args, "links", None) or []
kind = getattr(args, "kind", "hello")
identity = _load_identity(args)
env = _build_envelope(cfg, kind, "telegram:webhook", links, extra, identity=identity)
token = args.bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing bot_token.")
return 1

Copilot uses AI. Check for mistakes.
client = TelegramClient(bot_token=token)
try:
res = client.send_message(args.chat_id, args.text, envelope=env)
if args.json:
Comment on lines +1161 to +1171
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

env = _build_envelope(...) returns an encoded envelope string, but it's passed as envelope=env into TelegramClient.send_message(), which expects a dict and uses .get(...). Either pass the envelope as part of the message text (e.g. payload_text = f"{text}\n\n{env}") or change both sides to use a consistent envelope representation (string vs decoded dict).

Copilot uses AI. Check for mistakes.
print(json.dumps(res))
else:
print("Telegram message sent successfully.")
return 0
except Exception as e:
logger.error(f"Failed to send: {e}")
return 1
return 0

def cmd_telegram_listen(args: argparse.Namespace) -> int:
cfg = _load_config(args.config)
setup_logging(args.verbose)
from .transports.telegram import TelegramListener

token = args.bot_token or _cfg_get(cfg, "telegram", "bot_token", default="")
if not token:
logger.error("Missing bot_token.")
return 1

listener = TelegramListener(bot_token=token)
Comment on lines +1152 to +1191
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New Telegram transport and CLI entrypoints aren't covered by tests, while similar transports (e.g. tests/test_discord_transport.py) and CLI behaviors (e.g. tests/test_cli_json_flag.py) are tested. Add unit tests for (1) TelegramClient request/response handling (mocking requests.Session) and (2) CLI parsing/dispatch for beacon telegram send/listen to prevent regressions like missing set_defaults(func=...).

Copilot uses AI. Check for mistakes.

def on_env(env_obj):
if args.json:
print(json.dumps(env_obj))
else:
chat = env_obj.get("chat_id")
txt = env_obj.get("text")
print(f"[Telegram] {chat}: {txt}")

try:
listener.run_sync(on_env)
except KeyboardInterrupt:
print("\nExiting Telegram listener.")
return 0

def _discord_client(cfg=None, webhook_url: Optional[str] = None) -> DiscordClient:
cfg = cfg or load_config()
timeout_s = int(_cfg_get(cfg, "discord", "timeout_s", default=20) or 20)
Expand Down Expand Up @@ -4934,6 +4999,19 @@ def add_ping_opts(pp: argparse.ArgumentParser) -> None:
sp.set_defaults(func=cmd_webhook_send)

# Discord
tg = sub.add_parser("telegram", help="Telegram bot transport")
tg_sub = tg.add_subparsers(dest="tcmd", required=True)

sp_send = tg_sub.add_parser("send", help="Send message to Telegram")
sp_send.add_argument("--bot-token", default=None, help="Telegram bot token")
sp_send.add_argument("--chat-id", required=True, help="Telegram chat ID")
sp_send.add_argument("--text", required=True, help="Message text")
sp_send.add_argument("--kind", default="hello", help="Envelope kind")
sp_send.add_argument("--link", action="append", default=[], help="Attach a link")

sp_listen = tg_sub.add_parser("listen", help="Listen for Telegram webhook updates")
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text says "Listen for Telegram webhook updates", but the implementation uses long-polling via /getUpdates (not webhooks). Update the CLI help/command naming to reflect polling, or implement webhook-based receiving if that's the intent.

Suggested change
sp_listen = tg_sub.add_parser("listen", help="Listen for Telegram webhook updates")
sp_listen = tg_sub.add_parser("listen", help="Listen for Telegram updates via long polling")

Copilot uses AI. Check for mistakes.
sp_listen.add_argument("--bot-token", default=None, help="Telegram bot token")

Comment on lines +5005 to +5014
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The telegram send / telegram listen subparsers don't call set_defaults(func=...). Since main() unconditionally executes args.func(args), running beacon telegram ... will fail with AttributeError: 'Namespace' object has no attribute 'func'. Add sp_send.set_defaults(func=cmd_telegram_send) and sp_listen.set_defaults(func=cmd_telegram_listen) (and any other needed defaults).

Copilot uses AI. Check for mistakes.
dc = sub.add_parser("discord", help="Discord webhook transport")
dc_sub = dc.add_subparsers(dest="dcmd", required=True)

Expand Down
3 changes: 3 additions & 0 deletions beacon_skill/transports/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
__all__ = [
"TelegramClient",
"TelegramListener",
"AgentMatrixTransport",
"BoTTubeClient",
"ClawCitiesClient",
Expand Down Expand Up @@ -34,3 +36,4 @@
from .udp import udp_listen, udp_send
from .conway import ConwayClient
from .webhook import WebhookServer, webhook_send
from .telegram import TelegramClient, TelegramListener
75 changes: 75 additions & 0 deletions beacon_skill/transports/telegram.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Telegram transport for Beacon."""

import logging
import requests
import time
from typing import Dict, Any, List
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List is imported but not used in this module.

Suggested change
from typing import Dict, Any, List
from typing import Dict, Any

Copilot uses AI. Check for mistakes.

logger = logging.getLogger(__name__)

class TelegramError(RuntimeError):
pass

class TelegramClient:
def __init__(self, bot_token: str, timeout_s: int = 20):
self.bot_token = bot_token
self.timeout_s = timeout_s
self.session = requests.Session()
self.base_url = f"https://api.telegram.org/bot{bot_token}"

def send_message(self, chat_id: str, text: str, envelope: Dict[str, Any] = None) -> Dict[str, Any]:
url = f"{self.base_url}/sendMessage"

message_text = text
if envelope:
message_text += f"\n\n[Beacon]: {envelope.get('id', 'N/A')} ({envelope.get('kind', 'N/A')})"
Comment on lines +20 to +25
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TelegramClient.send_message() treats envelope as a dict and calls .get('id'/'kind'), but callers (e.g. cmd_telegram_send) pass the encoded envelope string from _build_envelope(). This will raise at runtime, and even a decoded envelope dict does not contain an id field (the codec uses fields like nonce, agent_id, kind). Consider changing send_message() to accept an encoded envelope str (and append it to the message text like the Discord CLI does), or change the CLI to pass a decoded envelope dict and read the correct fields.

Suggested change
def send_message(self, chat_id: str, text: str, envelope: Dict[str, Any] = None) -> Dict[str, Any]:
url = f"{self.base_url}/sendMessage"
message_text = text
if envelope:
message_text += f"\n\n[Beacon]: {envelope.get('id', 'N/A')} ({envelope.get('kind', 'N/A')})"
def send_message(self, chat_id: str, text: str, envelope: Any = None) -> Dict[str, Any]:
url = f"{self.base_url}/sendMessage"
message_text = text
if envelope:
# Handle both encoded envelope strings and decoded envelope dicts.
if isinstance(envelope, str):
# Encoded envelope string, e.g. from _build_envelope().
message_text += f"\n\n[Beacon]: {envelope}"
elif isinstance(envelope, dict):
# Decoded envelope dict; use known fields when available.
envelope_id = envelope.get("nonce") or envelope.get("agent_id") or "N/A"
envelope_kind = envelope.get("kind", "N/A")
message_text += f"\n\n[Beacon]: {envelope_id} ({envelope_kind})"
else:
# Fallback for unexpected envelope types.
message_text += f"\n\n[Beacon]: {str(envelope)}"

Copilot uses AI. Check for mistakes.

payload = {
"chat_id": chat_id,
"text": message_text
}

resp = self.session.post(url, json=payload, timeout=self.timeout_s)
if resp.status_code != 200:
raise TelegramError(f"Failed to send to Telegram: {resp.text}")
return resp.json()
Comment on lines +13 to +35
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All other HTTP transports in this repo wrap requests with the shared with_retry helper (e.g. beacon_skill/transports/bottube.py:28-55) and set a User-Agent on the session. This transport currently does a single post()/get() with minimal error parsing, which makes it less resilient to transient network failures and rate limits. Consider adding a small _request() wrapper using with_retry, parsing Telegram error JSON (description), and setting a consistent User-Agent header on the session.

Copilot uses AI. Check for mistakes.

class TelegramListener:
def __init__(self, bot_token: str, timeout_s: int = 20):
self.bot_token = bot_token
self.timeout_s = timeout_s
self.base_url = f"https://api.telegram.org/bot{bot_token}"
self.session = requests.Session()

def run_sync(self, callback):
offset = 0
logger.info("Starting Telegram listener...")
while True:
try:
url = f"{self.base_url}/getUpdates"
payload = {"offset": offset, "timeout": self.timeout_s}
resp = self.session.get(url, params=payload, timeout=self.timeout_s + 5)

if resp.status_code == 200:
data = resp.json()

if not data.get("ok"):
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Telegram returns HTTP 200 but {"ok": false} (e.g., invalid token), the listener does continue without any backoff/logging. That can create a tight loop hammering the API. Add logging of the error payload and sleep/backoff (or raise) when ok is false.

Suggested change
if not data.get("ok"):
if not data.get("ok"):
logger.error("Telegram getUpdates returned ok=false: %s", data)
time.sleep(5)

Copilot uses AI. Check for mistakes.
continue

for update in data.get("result", []):
offset = update["update_id"] + 1
msg = update.get("message")
if msg and "text" in msg:
env = {
"platform": "telegram",
"chat_id": str(msg["chat"]["id"]),
"text": msg["text"],
"raw": update
}
callback(env)
else:
logger.error(f"Telegram offset {offset} failed: {resp.status_code}")
time.sleep(2)
except Exception as e:
logger.error(f"Telegram polling error: {e}")
time.sleep(5)
Loading