diff --git a/dr_manhattan/utils/__init__.py b/dr_manhattan/utils/__init__.py
index 2c1df6b..b1ac463 100644
--- a/dr_manhattan/utils/__init__.py
+++ b/dr_manhattan/utils/__init__.py
@@ -1,6 +1,7 @@
"""Utility functions and helpers for Dr. Manhattan."""
from .logger import ColoredFormatter, default_logger, setup_logger
+from .telegram import TelegramBot
from .tui import prompt_confirm, prompt_market_selection, prompt_selection
__all__ = [
@@ -10,4 +11,5 @@
"prompt_selection",
"prompt_market_selection",
"prompt_confirm",
+ "TelegramBot",
]
diff --git a/dr_manhattan/utils/telegram/__init__.py b/dr_manhattan/utils/telegram/__init__.py
new file mode 100644
index 0000000..3ec0f26
--- /dev/null
+++ b/dr_manhattan/utils/telegram/__init__.py
@@ -0,0 +1,97 @@
+"""
+Telegram Bot Integration Module
+
+A scalable, type-safe Telegram bot client for sending messages and notifications.
+
+Basic Usage:
+ from dr_manhattan.utils.telegram import TelegramBot
+
+ bot = TelegramBot(token="...", chat_id="...")
+ bot.send("Hello, World!")
+
+With Message Builder:
+ from dr_manhattan.utils.telegram import TelegramBot, MessageBuilder
+
+ msg = (MessageBuilder()
+ .title("Status Update")
+ .field("CPU", "45%")
+ .field("Memory", "2.1GB")
+ .build())
+
+ bot.send(msg)
+
+With Formatters:
+ from dr_manhattan.utils.telegram import TelegramBot
+ from dr_manhattan.utils.telegram.formatters import bold, code, key_value
+
+ bot.send(f"{bold('Alert')}: Server is {code('online')}")
+"""
+
+from .bot import TelegramBot
+from .formatters import (
+ MessageBuilder,
+ blockquote,
+ bold,
+ bullet_list,
+ code,
+ escape_html,
+ italic,
+ key_value,
+ link,
+ mention,
+ numbered_list,
+ pre,
+ progress_bar,
+ spoiler,
+ strikethrough,
+ table,
+ underline,
+)
+from .types import (
+ Chat,
+ ChatType,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ Message,
+ MessageOptions,
+ ParseMode,
+ ReplyMarkup,
+ SendResult,
+ TelegramConfig,
+ User,
+)
+
+__all__ = [
+ # Core
+ "TelegramBot",
+ # Types
+ "TelegramConfig",
+ "MessageOptions",
+ "SendResult",
+ "ParseMode",
+ "ChatType",
+ "User",
+ "Chat",
+ "Message",
+ "InlineKeyboardButton",
+ "InlineKeyboardMarkup",
+ "ReplyMarkup",
+ # Formatters
+ "MessageBuilder",
+ "escape_html",
+ "bold",
+ "italic",
+ "code",
+ "pre",
+ "link",
+ "mention",
+ "strikethrough",
+ "underline",
+ "spoiler",
+ "blockquote",
+ "table",
+ "key_value",
+ "bullet_list",
+ "numbered_list",
+ "progress_bar",
+]
diff --git a/dr_manhattan/utils/telegram/bot.py b/dr_manhattan/utils/telegram/bot.py
new file mode 100644
index 0000000..ab6285b
--- /dev/null
+++ b/dr_manhattan/utils/telegram/bot.py
@@ -0,0 +1,351 @@
+"""
+Core Telegram Bot implementation.
+
+A generic, type-safe Telegram bot client for sending messages and notifications.
+"""
+
+import json
+import logging
+from typing import Any, Dict, List, Optional
+
+import requests
+
+from .types import (
+ InlineKeyboardMarkup,
+ MessageOptions,
+ ParseMode,
+ ReplyMarkup,
+ SendResult,
+ TelegramConfig,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class TelegramBot:
+ """
+ Generic Telegram bot client for sending messages.
+
+ This is a low-level, type-safe client that can be used for any purpose.
+ For domain-specific formatting, use the formatters module.
+
+ Example:
+ bot = TelegramBot(token="...", chat_id="...")
+ result = bot.send("Hello, World!")
+ if result.success:
+ print(f"Message sent: {result.message_id}")
+ """
+
+ BASE_URL = "https://api.telegram.org/bot{token}/{method}"
+
+ def __init__(
+ self,
+ token: str,
+ chat_id: str,
+ parse_mode: ParseMode = ParseMode.HTML,
+ disable_notification: bool = False,
+ disable_web_page_preview: bool = True,
+ timeout: int = 10,
+ ) -> None:
+ """
+ Initialize Telegram bot.
+
+ Args:
+ token: Bot token from @BotFather
+ chat_id: Default chat ID to send messages to
+ parse_mode: Default parse mode for messages
+ disable_notification: Send messages silently by default
+ disable_web_page_preview: Disable link previews by default
+ timeout: Request timeout in seconds
+ """
+ self._config = TelegramConfig(
+ bot_token=token,
+ chat_id=chat_id,
+ parse_mode=parse_mode,
+ disable_notification=disable_notification,
+ disable_web_page_preview=disable_web_page_preview,
+ timeout=timeout,
+ )
+
+ @classmethod
+ def from_config(cls, config: TelegramConfig) -> "TelegramBot":
+ """Create bot from config object"""
+ return cls(
+ token=config.bot_token,
+ chat_id=config.chat_id,
+ parse_mode=config.parse_mode,
+ disable_notification=config.disable_notification,
+ disable_web_page_preview=config.disable_web_page_preview,
+ timeout=config.timeout,
+ )
+
+ @property
+ def enabled(self) -> bool:
+ """Check if bot is configured and enabled"""
+ return bool(self._config.bot_token and self._config.chat_id)
+
+ @property
+ def config(self) -> TelegramConfig:
+ """Get current configuration"""
+ return self._config
+
+ def _build_url(self, method: str) -> str:
+ """Build API URL for method"""
+ return self.BASE_URL.format(token=self._config.bot_token, method=method)
+
+ def _request(
+ self,
+ method: str,
+ data: Dict[str, Any],
+ ) -> SendResult:
+ """Make API request to Telegram"""
+ if not self.enabled:
+ return SendResult(success=False, error="Bot not configured")
+
+ url = self._build_url(method)
+
+ try:
+ response = requests.post(url, json=data, timeout=self._config.timeout)
+ result = response.json()
+
+ if not result.get("ok"):
+ error_msg = result.get("description", "Unknown error")
+ logger.warning(f"Telegram API error: {error_msg}")
+ return SendResult(success=False, error=error_msg, raw=result)
+
+ return SendResult(
+ success=True,
+ message_id=result.get("result", {}).get("message_id"),
+ raw=result.get("result"),
+ )
+
+ except requests.Timeout:
+ logger.warning("Telegram request timed out")
+ return SendResult(success=False, error="Request timed out")
+ except requests.RequestException as e:
+ logger.warning(f"Telegram request failed: {e}")
+ return SendResult(success=False, error=str(e))
+ except json.JSONDecodeError as e:
+ logger.warning(f"Failed to parse Telegram response: {e}")
+ return SendResult(success=False, error=f"Invalid response: {e}")
+ except Exception as e:
+ logger.warning(f"Telegram error: {e}")
+ return SendResult(success=False, error=str(e))
+
+ def send(
+ self,
+ text: str,
+ chat_id: Optional[str] = None,
+ options: Optional[MessageOptions] = None,
+ reply_markup: Optional[ReplyMarkup] = None,
+ ) -> SendResult:
+ """
+ Send a text message.
+
+ Args:
+ text: Message text (supports HTML/Markdown based on parse_mode)
+ chat_id: Override default chat ID
+ options: Message options (parse_mode, notifications, etc.)
+ reply_markup: Optional inline keyboard
+
+ Returns:
+ SendResult with success status and message ID
+ """
+ if not text:
+ return SendResult(success=False, error="Empty message")
+
+ opts = options or MessageOptions()
+
+ data: Dict[str, Any] = {
+ "chat_id": chat_id or self._config.chat_id,
+ "text": text,
+ "parse_mode": (opts.parse_mode or self._config.parse_mode).value,
+ "disable_notification": (
+ opts.disable_notification
+ if opts.disable_notification is not None
+ else self._config.disable_notification
+ ),
+ "disable_web_page_preview": (
+ opts.disable_web_page_preview
+ if opts.disable_web_page_preview is not None
+ else self._config.disable_web_page_preview
+ ),
+ }
+
+ if opts.reply_to_message_id:
+ data["reply_to_message_id"] = opts.reply_to_message_id
+
+ if opts.protect_content:
+ data["protect_content"] = True
+
+ if reply_markup:
+ if isinstance(reply_markup, InlineKeyboardMarkup):
+ data["reply_markup"] = reply_markup.to_dict()
+
+ return self._request("sendMessage", data)
+
+ def send_photo(
+ self,
+ photo: str,
+ caption: Optional[str] = None,
+ chat_id: Optional[str] = None,
+ options: Optional[MessageOptions] = None,
+ ) -> SendResult:
+ """
+ Send a photo.
+
+ Args:
+ photo: Photo URL or file_id
+ caption: Optional caption
+ chat_id: Override default chat ID
+ options: Message options
+ """
+ opts = options or MessageOptions()
+
+ data: Dict[str, Any] = {
+ "chat_id": chat_id or self._config.chat_id,
+ "photo": photo,
+ }
+
+ if caption:
+ data["caption"] = caption
+ data["parse_mode"] = (opts.parse_mode or self._config.parse_mode).value
+
+ return self._request("sendPhoto", data)
+
+ def send_document(
+ self,
+ document: str,
+ caption: Optional[str] = None,
+ chat_id: Optional[str] = None,
+ options: Optional[MessageOptions] = None,
+ ) -> SendResult:
+ """
+ Send a document.
+
+ Args:
+ document: Document URL or file_id
+ caption: Optional caption
+ chat_id: Override default chat ID
+ options: Message options
+ """
+ opts = options or MessageOptions()
+
+ data: Dict[str, Any] = {
+ "chat_id": chat_id or self._config.chat_id,
+ "document": document,
+ }
+
+ if caption:
+ data["caption"] = caption
+ data["parse_mode"] = (opts.parse_mode or self._config.parse_mode).value
+
+ return self._request("sendDocument", data)
+
+ def edit_message(
+ self,
+ message_id: int,
+ text: str,
+ chat_id: Optional[str] = None,
+ options: Optional[MessageOptions] = None,
+ reply_markup: Optional[ReplyMarkup] = None,
+ ) -> SendResult:
+ """
+ Edit an existing message.
+
+ Args:
+ message_id: ID of message to edit
+ text: New message text
+ chat_id: Override default chat ID
+ options: Message options
+ reply_markup: Optional inline keyboard
+ """
+ opts = options or MessageOptions()
+
+ data: Dict[str, Any] = {
+ "chat_id": chat_id or self._config.chat_id,
+ "message_id": message_id,
+ "text": text,
+ "parse_mode": (opts.parse_mode or self._config.parse_mode).value,
+ }
+
+ if reply_markup:
+ if isinstance(reply_markup, InlineKeyboardMarkup):
+ data["reply_markup"] = reply_markup.to_dict()
+
+ return self._request("editMessageText", data)
+
+ def delete_message(
+ self,
+ message_id: int,
+ chat_id: Optional[str] = None,
+ ) -> SendResult:
+ """
+ Delete a message.
+
+ Args:
+ message_id: ID of message to delete
+ chat_id: Override default chat ID
+ """
+ data = {
+ "chat_id": chat_id or self._config.chat_id,
+ "message_id": message_id,
+ }
+
+ return self._request("deleteMessage", data)
+
+ def get_me(self) -> Optional[Dict[str, Any]]:
+ """Get bot information"""
+ result = self._request("getMe", {})
+ return result.raw if result.success else None
+
+ def send_chat_action(
+ self,
+ action: str = "typing",
+ chat_id: Optional[str] = None,
+ ) -> SendResult:
+ """
+ Send chat action (typing indicator, etc.)
+
+ Args:
+ action: Action type (typing, upload_photo, upload_document, etc.)
+ chat_id: Override default chat ID
+ """
+ data = {
+ "chat_id": chat_id or self._config.chat_id,
+ "action": action,
+ }
+
+ return self._request("sendChatAction", data)
+
+ def send_batch(
+ self,
+ messages: List[str],
+ chat_id: Optional[str] = None,
+ options: Optional[MessageOptions] = None,
+ delay_ms: int = 0,
+ ) -> List[SendResult]:
+ """
+ Send multiple messages.
+
+ Args:
+ messages: List of message texts
+ chat_id: Override default chat ID
+ options: Message options
+ delay_ms: Delay between messages in milliseconds (0 = no delay)
+
+ Returns:
+ List of SendResult for each message
+ """
+ import time
+
+ results: List[SendResult] = []
+
+ for i, text in enumerate(messages):
+ result = self.send(text, chat_id=chat_id, options=options)
+ results.append(result)
+
+ if delay_ms > 0 and i < len(messages) - 1:
+ time.sleep(delay_ms / 1000)
+
+ return results
diff --git a/dr_manhattan/utils/telegram/formatters.py b/dr_manhattan/utils/telegram/formatters.py
new file mode 100644
index 0000000..696dbbd
--- /dev/null
+++ b/dr_manhattan/utils/telegram/formatters.py
@@ -0,0 +1,306 @@
+"""
+Message formatting utilities for Telegram.
+
+Provides HTML and Markdown formatting helpers for building messages.
+"""
+
+import html
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
+
+
+def escape_html(text: str) -> str:
+ """Escape HTML special characters"""
+ return html.escape(str(text))
+
+
+def bold(text: str) -> str:
+ """Format text as bold (HTML)"""
+ return f"{escape_html(text)}"
+
+
+def italic(text: str) -> str:
+ """Format text as italic (HTML)"""
+ return f"{escape_html(text)}"
+
+
+def code(text: str) -> str:
+ """Format text as inline code (HTML)"""
+ return f"{escape_html(text)}"
+
+
+def pre(text: str, language: Optional[str] = None) -> str:
+ """Format text as code block (HTML)"""
+ if language:
+ return (
+ f'
{escape_html(text)}'
+ )
+ return f"{escape_html(text)}"
+
+
+def link(text: str, url: str) -> str:
+ """Format text as hyperlink (HTML)"""
+ return f'{escape_html(text)}'
+
+
+def mention(text: str, user_id: int) -> str:
+ """Format text as user mention (HTML)"""
+ return f'{escape_html(text)}'
+
+
+def strikethrough(text: str) -> str:
+ """Format text as strikethrough (HTML)"""
+ return f"{escape_html(text)}" + + +@dataclass +class TableRow: + """Table row data""" + + cells: List[str] + bold_first: bool = False + + +def table( + rows: Sequence[Union[Tuple[str, ...], List[str], TableRow]], + header: Optional[Sequence[str]] = None, + separator: str = " | ", +) -> str: + """ + Format data as a simple text table. + + Args: + rows: List of rows (tuples/lists of cell values) + header: Optional header row + separator: Column separator + + Returns: + Formatted table as monospace text + """ + lines: List[str] = [] + + if header: + lines.append(separator.join(bold(h) for h in header)) + lines.append("-" * 20) + + for row in rows: + if isinstance(row, TableRow): + cells = row.cells + if row.bold_first and cells: + cells = [bold(cells[0])] + [code(c) for c in cells[1:]] + else: + cells = [code(c) for c in cells] + else: + cells = [code(str(c)) for c in row] + lines.append(separator.join(cells)) + + return "\n".join(lines) + + +def key_value( + data: Dict[str, Any], + separator: str = ": ", + bold_keys: bool = True, +) -> str: + """ + Format key-value pairs. + + Args: + data: Dictionary of key-value pairs + separator: Separator between key and value + bold_keys: Whether to bold the keys + + Returns: + Formatted key-value pairs + """ + lines: List[str] = [] + + for key, value in data.items(): + key_str = bold(key) if bold_keys else escape_html(key) + value_str = code(str(value)) + lines.append(f"{key_str}{separator}{value_str}") + + return "\n".join(lines) + + +def bullet_list(items: Sequence[str], bullet: str = "-") -> str: + """ + Format items as a bullet list. + + Args: + items: List of items + bullet: Bullet character + + Returns: + Formatted bullet list + """ + return "\n".join(f"{bullet} {escape_html(item)}" for item in items) + + +def numbered_list(items: Sequence[str], start: int = 1) -> str: + """ + Format items as a numbered list. + + Args: + items: List of items + start: Starting number + + Returns: + Formatted numbered list + """ + return "\n".join(f"{i}. {escape_html(item)}" for i, item in enumerate(items, start)) + + +def progress_bar( + current: float, + total: float, + width: int = 10, + filled: str = "█", + empty: str = "░", +) -> str: + """ + Create a text progress bar. + + Args: + current: Current value + total: Total value + width: Bar width in characters + filled: Character for filled portion + empty: Character for empty portion + + Returns: + Progress bar string + """ + if total <= 0: + ratio = 0.0 + else: + ratio = min(1.0, max(0.0, current / total)) + + filled_width = int(ratio * width) + empty_width = width - filled_width + + bar = filled * filled_width + empty * empty_width + percentage = ratio * 100 + + return f"{bar} {percentage:.1f}%" + + +class MessageBuilder: + """ + Fluent message builder for constructing formatted messages. + + Example: + msg = (MessageBuilder() + .title("Alert") + .field("Status", "OK") + .field("Count", 42) + .newline() + .text("Details here") + .build()) + """ + + def __init__(self) -> None: + self._parts: List[str] = [] + + def text(self, text: str, escape: bool = True) -> "MessageBuilder": + """Add plain text""" + self._parts.append(escape_html(text) if escape else text) + return self + + def raw(self, text: str) -> "MessageBuilder": + """Add raw HTML (no escaping)""" + self._parts.append(text) + return self + + def title(self, text: str) -> "MessageBuilder": + """Add a bold title""" + self._parts.append(bold(text)) + return self + + def subtitle(self, text: str) -> "MessageBuilder": + """Add an italic subtitle""" + self._parts.append(italic(text)) + return self + + def field(self, key: str, value: Any) -> "MessageBuilder": + """Add a key-value field""" + self._parts.append(f"{bold(key)}: {code(str(value))}") + return self + + def fields(self, data: Dict[str, Any]) -> "MessageBuilder": + """Add multiple key-value fields""" + for key, value in data.items(): + self.field(key, value) + self.newline() + return self + + def code_block(self, text: str, language: Optional[str] = None) -> "MessageBuilder": + """Add a code block""" + self._parts.append(pre(text, language)) + return self + + def inline_code(self, text: str) -> "MessageBuilder": + """Add inline code""" + self._parts.append(code(text)) + return self + + def link_text(self, text: str, url: str) -> "MessageBuilder": + """Add a hyperlink""" + self._parts.append(link(text, url)) + return self + + def newline(self, count: int = 1) -> "MessageBuilder": + """Add newlines""" + self._parts.append("\n" * count) + return self + + def separator(self, char: str = "-", width: int = 20) -> "MessageBuilder": + """Add a separator line""" + self._parts.append(char * width) + return self + + def bullet(self, items: Sequence[str]) -> "MessageBuilder": + """Add a bullet list""" + self._parts.append(bullet_list(items)) + return self + + def numbered(self, items: Sequence[str], start: int = 1) -> "MessageBuilder": + """Add a numbered list""" + self._parts.append(numbered_list(items, start)) + return self + + def progress( + self, + current: float, + total: float, + label: Optional[str] = None, + ) -> "MessageBuilder": + """Add a progress bar""" + bar = progress_bar(current, total) + if label: + self._parts.append(f"{escape_html(label)}: {bar}") + else: + self._parts.append(bar) + return self + + def build(self) -> str: + """Build the final message""" + return "".join(self._parts) + + def __str__(self) -> str: + return self.build() diff --git a/dr_manhattan/utils/telegram/types.py b/dr_manhattan/utils/telegram/types.py new file mode 100644 index 0000000..519d69f --- /dev/null +++ b/dr_manhattan/utils/telegram/types.py @@ -0,0 +1,176 @@ +""" +Type definitions for Telegram bot integration. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, TypeVar, Union + +T = TypeVar("T") + +Callback = Callable[[Dict[str, Any]], None] + + +class ParseMode(str, Enum): + """Telegram message parse modes""" + + HTML = "HTML" + MARKDOWN = "Markdown" + MARKDOWN_V2 = "MarkdownV2" + + +class ChatType(str, Enum): + """Telegram chat types""" + + PRIVATE = "private" + GROUP = "group" + SUPERGROUP = "supergroup" + CHANNEL = "channel" + + +@dataclass(frozen=True) +class TelegramConfig: + """Configuration for Telegram bot""" + + bot_token: str + chat_id: str + parse_mode: ParseMode = ParseMode.HTML + disable_notification: bool = False + disable_web_page_preview: bool = True + timeout: int = 10 + + def __post_init__(self) -> None: + if not self.bot_token: + raise ValueError("bot_token is required") + if not self.chat_id: + raise ValueError("chat_id is required") + + +@dataclass(frozen=True) +class MessageOptions: + """Options for sending a message""" + + parse_mode: Optional[ParseMode] = None + disable_notification: Optional[bool] = None + disable_web_page_preview: Optional[bool] = None + reply_to_message_id: Optional[int] = None + protect_content: bool = False + + +@dataclass(frozen=True) +class SendResult: + """Result of sending a message""" + + success: bool + message_id: Optional[int] = None + error: Optional[str] = None + raw: Optional[Dict[str, Any]] = None + + +@dataclass(frozen=True) +class User: + """Telegram user""" + + id: int + is_bot: bool + first_name: str + last_name: Optional[str] = None + username: Optional[str] = None + language_code: Optional[str] = None + + +@dataclass(frozen=True) +class Chat: + """Telegram chat""" + + id: int + type: ChatType + title: Optional[str] = None + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + + +@dataclass(frozen=True) +class Message: + """Telegram message""" + + message_id: int + date: int + chat: Chat + from_user: Optional[User] = None + text: Optional[str] = None + raw: Dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Message": + """Parse message from API response""" + chat_data = data.get("chat", {}) + chat = Chat( + id=chat_data.get("id", 0), + type=ChatType(chat_data.get("type", "private")), + title=chat_data.get("title"), + username=chat_data.get("username"), + first_name=chat_data.get("first_name"), + last_name=chat_data.get("last_name"), + ) + + from_data = data.get("from") + from_user = None + if from_data: + from_user = User( + id=from_data.get("id", 0), + is_bot=from_data.get("is_bot", False), + first_name=from_data.get("first_name", ""), + last_name=from_data.get("last_name"), + username=from_data.get("username"), + language_code=from_data.get("language_code"), + ) + + return cls( + message_id=data.get("message_id", 0), + date=data.get("date", 0), + chat=chat, + from_user=from_user, + text=data.get("text"), + raw=data, + ) + + +@dataclass +class InlineKeyboardButton: + """Inline keyboard button""" + + text: str + url: Optional[str] = None + callback_data: Optional[str] = None + + +@dataclass +class InlineKeyboardMarkup: + """Inline keyboard markup""" + + inline_keyboard: List[List[InlineKeyboardButton]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Convert to API format""" + return { + "inline_keyboard": [ + [ + { + k: v + for k, v in { + "text": btn.text, + "url": btn.url, + "callback_data": btn.callback_data, + }.items() + if v is not None + } + for btn in row + ] + for row in self.inline_keyboard + ] + } + + +ReplyMarkup = Union[InlineKeyboardMarkup, None] diff --git a/examples/copytrading/__init__.py b/examples/copytrading/__init__.py new file mode 100644 index 0000000..998cd34 --- /dev/null +++ b/examples/copytrading/__init__.py @@ -0,0 +1,44 @@ +""" +Polymarket Copytrading Bot + +Monitors a target wallet's trades and mirrors them on your account. + +Usage: + uv run python -m examples.copytrading --target