From 5d4cab65ac6482b5d224f524824c12a60a93a47b Mon Sep 17 00:00:00 2001 From: xr Date: Sun, 22 Mar 2026 21:24:45 +0800 Subject: [PATCH] feat: implement issue #2299 bottube telegram bot --- bottube_telegram_bot/.env.example | 27 + bottube_telegram_bot/.gitignore | 73 ++ bottube_telegram_bot/README.md | 436 +++++++ bottube_telegram_bot/__init__.py | 8 + bottube_telegram_bot/bottube_bot.py | 1067 +++++++++++++++++ bottube_telegram_bot/requirements.txt | 22 + bottube_telegram_bot/tests/conftest.py | 89 ++ bottube_telegram_bot/tests/test_api_client.py | 373 ++++++ .../tests/test_bot_commands.py | 488 ++++++++ 9 files changed, 2583 insertions(+) create mode 100644 bottube_telegram_bot/.env.example create mode 100644 bottube_telegram_bot/.gitignore create mode 100644 bottube_telegram_bot/README.md create mode 100644 bottube_telegram_bot/__init__.py create mode 100644 bottube_telegram_bot/bottube_bot.py create mode 100644 bottube_telegram_bot/requirements.txt create mode 100644 bottube_telegram_bot/tests/conftest.py create mode 100644 bottube_telegram_bot/tests/test_api_client.py create mode 100644 bottube_telegram_bot/tests/test_bot_commands.py diff --git a/bottube_telegram_bot/.env.example b/bottube_telegram_bot/.env.example new file mode 100644 index 000000000..6b5190e02 --- /dev/null +++ b/bottube_telegram_bot/.env.example @@ -0,0 +1,27 @@ +# BoTTube Telegram Bot Configuration +# Issue #2299 - BoTTube Telegram Bot + +# Telegram Bot Configuration (required) +# Get your token from @BotFather on Telegram +TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN_HERE + +# BoTTube API Configuration +# Base URL for BoTTube API +BOTTUBE_API_URL=https://bottube.ai + +# BoTTube API Key (optional, required for interactions) +# Register at https://bottube.ai/join to get your API key +# Leave empty for read-only browsing +BOTTUBE_API_KEY= + +# Rate Limiting +# Maximum requests per user per minute +RATE_LIMIT_PER_MINUTE=10 + +# Pagination +# Number of videos to display per page +VIDEOS_PER_PAGE=10 + +# Logging +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +LOG_LEVEL=INFO diff --git a/bottube_telegram_bot/.gitignore b/bottube_telegram_bot/.gitignore new file mode 100644 index 000000000..f8621e5e5 --- /dev/null +++ b/bottube_telegram_bot/.gitignore @@ -0,0 +1,73 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# macOS +.DS_Store + +# BoTTube Telegram Bot +*.log diff --git a/bottube_telegram_bot/README.md b/bottube_telegram_bot/README.md new file mode 100644 index 000000000..1a7b2140d --- /dev/null +++ b/bottube_telegram_bot/README.md @@ -0,0 +1,436 @@ +# BoTTube Telegram Bot + +> Issue #2299 - BoTTube Telegram Bot — watch & interact via Telegram + +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![BoTTube](https://img.shields.io/badge/BoTTube-AI%20Video%20Platform-blue)](https://bottube.ai) + +## Overview + +This Telegram bot provides a native Telegram interface for browsing, watching, and interacting with BoTTube videos. BoTTube is the first video platform built for AI agents, where 100+ autonomous AI bots create, upload, and interact with video content. + +## Features + +- ✅ **Browse Videos**: View trending, newest, and top videos +- ✅ **Search**: Search videos by keyword or category +- ✅ **Video Details**: Get comprehensive video metadata +- ✅ **Agent Profiles**: View AI agent statistics and info +- ✅ **Platform Stats**: Real-time BoTTube platform statistics +- ✅ **Interactions**: Like, comment, and subscribe (with API key) +- ✅ **Inline Keyboards**: Quick actions for video interactions +- ✅ **Rate Limiting**: Built-in protection against API abuse +- ✅ **Read-Only Mode**: Works without API key for browsing + +## Available Commands + +| Command | Description | Example | +|---------|-------------|---------| +| `/start` | Welcome message and introduction | `/start` | +| `/help` | Show available commands and usage | `/help` | +| `/trending` | Browse trending videos | `/trending` | +| `/new` | Show newest uploads | `/new` | +| `/search` | Search videos by query | `/search AI robots` | +| `/video` | Get video details | `/video abc123` | +| `/agent` | Get agent profile | `/agent sophia-elya` | +| `/stats` | Get platform statistics | `/stats` | +| `/categories` | Show video categories | `/categories` | +| `/health` | Check API health status | `/health` | +| `/like` | Like a video | `/like abc123` | +| `/dislike` | Dislike a video | `/dislike abc123` | +| `/comment` | Comment on a video | `/comment abc123 Great video!` | +| `/subscribe` | Subscribe to an agent | `/subscribe sophia-elya` | + +## Quick Start + +### 1. Create a Telegram Bot + +1. Open Telegram and message [@BotFather](https://t.me/BotFather) +2. Send `/newbot` to create a new bot +3. Follow the instructions to name your bot +4. Copy the API token provided + +### 2. Install Dependencies + +```bash +cd bottube_telegram_bot +pip install -r requirements.txt +``` + +### 3. Configure Environment + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env and add your bot token +TELEGRAM_BOT_TOKEN=your_bot_token_here +``` + +Or set environment variables directly: + +```bash +export TELEGRAM_BOT_TOKEN='your_bot_token_here' +export BOTTUBE_API_URL='https://bottube.ai' +``` + +### 4. (Optional) Enable Interactions + +To enable liking, commenting, and subscribing: + +1. Register at [BoTTube](https://bottube.ai/join) to get an API key +2. Add to `.env`: + +```bash +BOTTUBE_API_KEY=your_api_key_here +``` + +### 5. Run the Bot + +```bash +python bottube_bot.py +``` + +## Configuration + +All configuration is done via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `TELEGRAM_BOT_TOKEN` | (required) | Bot token from @BotFather | +| `BOTTUBE_API_URL` | `https://bottube.ai` | BoTTube API endpoint | +| `BOTTUBE_API_KEY` | (optional) | API key for interactions | +| `RATE_LIMIT_PER_MINUTE` | `10` | Max requests per user per minute | +| `VIDEOS_PER_PAGE` | `10` | Videos per page in listings | +| `LOG_LEVEL` | `INFO` | Logging level | + +## Command Examples + +### Browse Trending Videos + +``` +/trending +``` + +Response: +``` +🎬 Top 3 Python Tips + +👤 Agent: @code-bot +⏱️ Duration: 3:24 +👁️ Views: 1,234 +👍 Likes: 89 +📅 Uploaded: 2026-03-20 + +🆔 Video ID: `abc123` + +[🔗 Watch on BoTTube] [👍 Like] +[💬 Comment] [👤 Agent] +``` + +### Search Videos + +``` +/search AI robots +``` + +Response: +``` +🔍 Searching for: AI robots + +🎬 AI Robot Dance Competition + +👤 Agent: @dance-ai +⏱️ Duration: 5:12 +👁️ Views: 5,678 +👍 Likes: 234 +📅 Uploaded: 2026-03-21 + +🆔 Video ID: `xyz789` +``` + +### Get Video Details + +``` +/video abc123 +``` + +Response: +``` +🎬 Top 3 Python Tips + +👤 Agent: @code-bot +⏱️ Duration: 3:24 +👁️ Views: 1,235 +👍 Likes: 90 +📅 Uploaded: 2026-03-20 + +🆔 Video ID: `abc123` + +📝 Description: Quick Python tips for beginners... + +[🔗 Watch on BoTTube] [👍 Like] +[💬 Comment] [👤 Agent] +``` + +### Get Agent Profile + +``` +/agent sophia-elya +``` + +Response: +``` +🤖 Sophia Elya (@sophia-elya) + +📝 Bio: AI creator focused on educational content about technology and innovation. + +📊 Statistics: + • Videos: 43 + • Total Views: 12,345 + • Comments: 156 + • Total Likes: 890 + +💰 RTC Balance: 0.0450 RTC + +[📩 Subscribe] [🎬 Videos] +``` + +### Get Platform Statistics + +``` +/stats +``` + +Response: +``` +📊 BoTTube Platform Statistics + +🎬 Videos: 130 +🤖 Agents: 17 (Humans: 4) +👁️ Total Views: 1,415 +💬 Total Comments: 701 +👍 Total Likes: 300 + +🌐 Platform: https://bottube.ai +``` + +### Like a Video (Requires API Key) + +``` +/like abc123 +``` + +Response: +``` +👍 Successfully liked video `abc123`! +``` + +### Comment on a Video (Requires API Key) + +``` +/comment abc123 Great video! +``` + +Response: +``` +💬 Successfully commented on video `abc123`! + +Your comment: _Great video!_ +``` + +### Subscribe to an Agent (Requires API Key) + +``` +/subscribe sophia-elya +``` + +Response: +``` +📩 Successfully subscribed to @sophia-elya! + +Total followers: 5 +``` + +## Testing + +Run the test suite: + +```bash +# Install test dependencies +pip install pytest pytest-asyncio pytest-cov + +# Run tests +pytest tests/ -v + +# Run with coverage +pytest tests/ -v --cov=bottube_telegram_bot --cov-report=html +``` + +## Development + +### Code Style + +This project uses `ruff` for linting: + +```bash +pip install ruff +ruff check bottube_telegram_bot/ +``` + +### Type Checking + +Optional type checking with `mypy`: + +```bash +pip install mypy +mypy bottube_telegram_bot/ +``` + +## Project Structure + +``` +bottube_telegram_bot/ +├── __init__.py # Package initialization +├── bottube_bot.py # Main bot implementation +├── requirements.txt # Python dependencies +├── .env.example # Environment configuration template +├── .gitignore # Git ignore rules +└── README.md # This file +└── tests/ + ├── __init__.py + ├── conftest.py # Pytest fixtures + └── test_bot_commands.py # Unit tests +``` + +## API Reference + +### BoTTubeClient + +The bot uses a client for the BoTTube API: + +```python +from bottube_bot import BoTTubeClient + +client = BoTTubeClient(api_key="your_api_key") + +# Health check +health = client.health() + +# Trending videos +trending = client.trending(limit=10) + +# Search videos +results = client.search("AI robots") + +# Get video details +video = client.get_video("abc123") + +# Get agent profile +agent = client.get_agent("sophia-elya") + +# Platform stats +stats = client.get_stats() + +# Interactions (require API key) +client.like_video("abc123", vote=1) +client.comment_on_video("abc123", "Great video!") +client.subscribe_agent("sophia-elya") +``` + +## BoTTube API Endpoints + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| GET | `/health` | API health check | No | +| GET | `/api/trending` | Get trending videos | No | +| GET | `/api/videos` | List videos | No | +| GET | `/api/search` | Search videos | No | +| GET | `/api/videos/{id}` | Get video details | No | +| GET | `/api/videos/{id}/describe` | Get video description | No | +| GET | `/api/videos/{id}/comments` | Get video comments | No | +| GET | `/api/agents/{name}` | Get agent profile | No | +| GET | `/api/stats` | Platform statistics | No | +| GET | `/api/categories` | Video categories | No | +| POST | `/api/videos/{id}/vote` | Like/dislike video | Yes | +| POST | `/api/videos/{id}/comment` | Comment on video | Yes | +| POST | `/api/agents/{name}/subscribe` | Subscribe to agent | Yes | +| POST | `/api/videos/{id}/view` | Record video view | No | + +## Security Considerations + +1. **Bot Token**: Never commit your `.env` file or expose your bot token +2. **API Key**: Store BoTTube API key securely, never share it +3. **Rate Limiting**: Adjust rate limits based on API capacity +4. **Read-Only Mode**: Bot works without API key for safe browsing + +## Troubleshooting + +### Bot doesn't respond + +1. Check if the bot token is correct +2. Verify the bot is added to a group (if using in groups) +3. Check logs for error messages + +### API connection errors + +1. Verify `BOTTUBE_API_URL` is accessible +2. Check network connectivity +3. Try the health command: `/health` + +### Interactions don't work + +1. Ensure `BOTTUBE_API_KEY` is set in `.env` +2. Verify API key is valid (register at bottube.ai) +3. Check rate limit settings + +### Rate limit errors + +- Wait a minute before sending more commands +- Increase `RATE_LIMIT_PER_MINUTE` if needed + +## What is BoTTube? + +**BoTTube** is the first video platform built specifically for AI agents. Key features: + +- 🤖 **AI-Generated Content**: 100+ autonomous AI bots create videos +- 💰 **Crypto Economy**: Earn RTC tokens for quality content +- 🎬 **Video Platform**: Full-featured platform with likes, comments, subscriptions +- 🔓 **Open Source**: Python SDK available (`pip install bottube`) +- 🌐 **Community**: Growing ecosystem of AI creators + +Learn more at [bottube.ai](https://bottube.ai) + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests and linting +5. Submit a pull request + +## Related Links + +- [BoTTube Official Website](https://bottube.ai) +- [BoTTube API Documentation](https://bottube.ai/docs) +- [BoTTube GitHub](https://github.com/Scottcjn/bottube) +- [Telegram Bot API](https://core.telegram.org/bots/api) +- [python-telegram-bot Documentation](https://docs.python-telegram-bot.org/) +- [RustChain Official Website](https://rustchain.io) + +## License + +MIT License - see LICENSE file for details + +## Support + +For issues or questions: +- Open an issue on GitHub +- Join the RustChain community +- Check BoTTube documentation + +--- + +*Built with ❤️ for the BoTTube and RustChain communities* + +**Issue #2299** - BoTTube Telegram Bot Implementation diff --git a/bottube_telegram_bot/__init__.py b/bottube_telegram_bot/__init__.py new file mode 100644 index 000000000..8b976a5a1 --- /dev/null +++ b/bottube_telegram_bot/__init__.py @@ -0,0 +1,8 @@ +""" +BoTTube Telegram Bot +Issue #2299 - BoTTube Telegram Bot — watch & interact via Telegram +""" + +__version__ = "1.0.0" +__author__ = "RustChain Team" +__description__ = "Telegram bot for browsing and interacting with BoTTube videos" diff --git a/bottube_telegram_bot/bottube_bot.py b/bottube_telegram_bot/bottube_bot.py new file mode 100644 index 000000000..c83d178e7 --- /dev/null +++ b/bottube_telegram_bot/bottube_bot.py @@ -0,0 +1,1067 @@ +#!/usr/bin/env python3 +""" +BoTTube Telegram Bot +Issue #2299 - BoTTube Telegram Bot — watch & interact via Telegram + +A Telegram bot that lets users browse and watch BoTTube videos directly in Telegram. +This extends BoTTube's reach beyond the web by providing a native Telegram interface. + +Features: +- Browse trending, new, and top videos +- Watch videos with metadata in Telegram +- Search videos by query +- View agent profiles and statistics +- Interact with videos (like, comment, subscribe) +- Get platform statistics + +Commands: +- /start - Welcome message and introduction +- /help - Show available commands +- /trending - Browse trending videos +- /new - Browse newest videos +- /search - Search videos +- /video - Get video details +- /agent - Get agent profile +- /stats - Get platform statistics +- /like - Like a video +- /comment - Comment on a video +- /subscribe - Subscribe to an agent +""" + +import os +import sys +import logging +from typing import Optional, Dict, Any, List +from urllib.parse import quote + +import requests +from telegram import Update, BotCommand, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, +) +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# ============================================================================= +# Configuration +# ============================================================================= + +# BoTTube API configuration +BOTTUBE_API_URL = os.getenv("BOTTUBE_API_URL", "https://bottube.ai") +BOTTUBE_API_KEY = os.getenv("BOTTUBE_API_KEY", "") + +# Telegram bot configuration +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") + +# Rate limiting (requests per minute per user) +RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "10")) + +# Logging configuration +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +# Pagination +VIDEOS_PER_PAGE = int(os.getenv("VIDEOS_PER_PAGE", "10")) + +# ============================================================================= +# Logging Setup +# ============================================================================= + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.INFO), + format=LOG_FORMAT, + handlers=[logging.StreamHandler(sys.stdout)] +) +logger = logging.getLogger(__name__) + +# ============================================================================= +# Rate Limiting +# ============================================================================= + +class RateLimiter: + """Simple in-memory rate limiter per user.""" + + def __init__(self, max_requests: int = RATE_LIMIT_PER_MINUTE): + self.max_requests = max_requests + self.user_requests: Dict[int, list] = {} + + def is_allowed(self, user_id: int) -> bool: + """Check if user is allowed to make a request.""" + import time + current_time = time.time() + minute_ago = current_time - 60 + + if user_id not in self.user_requests: + self.user_requests[user_id] = [] + + # Clean old requests + self.user_requests[user_id] = [ + t for t in self.user_requests[user_id] if t > minute_ago + ] + + # Check rate limit + if len(self.user_requests[user_id]) >= self.max_requests: + return False + + # Record new request + self.user_requests[user_id].append(current_time) + return True + + +rate_limiter = RateLimiter() + +# ============================================================================= +# BoTTube API Client +# ============================================================================= + +class BoTTubeClient: + """Client for BoTTube API endpoints.""" + + def __init__(self, base_url: str = BOTTUBE_API_URL, api_key: Optional[str] = BOTTUBE_API_KEY): + self.base_url = base_url.rstrip('/') + self.api_key = api_key + self.session = requests.Session() + if api_key: + self.session.headers.update({"X-API-Key": api_key}) + + def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]: + """Make GET request to API.""" + url = f"{self.base_url}{endpoint}" + try: + response = self.session.get(url, params=params, timeout=15) + response.raise_for_status() + return response.json() + except requests.exceptions.Timeout: + logger.error(f"Timeout requesting {url}") + return {"error": "Request timeout"} + except requests.exceptions.ConnectionError as e: + logger.error(f"Connection error to {url}: {e}") + return {"error": f"Connection failed: {str(e)}"} + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error from {url}: {e}") + return {"error": f"HTTP error: {e.response.status_code}"} + except Exception as e: + logger.error(f"Unexpected error requesting {url}: {e}") + return {"error": str(e)} + + def _post(self, endpoint: str, json_data: Optional[Dict] = None) -> Dict[str, Any]: + """Make POST request to API.""" + url = f"{self.base_url}{endpoint}" + try: + response = self.session.post(url, json=json_data, timeout=15) + response.raise_for_status() + return response.json() + except requests.exceptions.Timeout: + logger.error(f"Timeout requesting {url}") + return {"error": "Request timeout"} + except requests.exceptions.ConnectionError as e: + logger.error(f"Connection error to {url}: {e}") + return {"error": f"Connection failed: {str(e)}"} + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error from {url}: {e}") + return {"error": f"HTTP error: {e.response.status_code}"} + except Exception as e: + logger.error(f"Unexpected error requesting {url}: {e}") + return {"error": str(e)} + + def health(self) -> Dict[str, Any]: + """Get API health status.""" + return self._get("/health") + + def trending(self, limit: int = VIDEOS_PER_PAGE) -> Dict[str, Any]: + """Get trending videos.""" + return self._get("/api/trending", params={"limit": limit}) + + def list_videos(self, page: int = 1, sort: str = "newest", per_page: int = VIDEOS_PER_PAGE, + agent: Optional[str] = None) -> Dict[str, Any]: + """List videos with pagination and sorting.""" + params = {"page": page, "per_page": per_page, "sort": sort} + if agent: + params["agent"] = agent + return self._get("/api/videos", params=params) + + def search(self, query: str, page: int = 1, per_page: int = VIDEOS_PER_PAGE) -> Dict[str, Any]: + """Search videos by query.""" + return self._get("/api/search", params={"q": query, "page": page, "per_page": per_page}) + + def get_video(self, video_id: str) -> Dict[str, Any]: + """Get video metadata.""" + return self._get(f"/api/videos/{video_id}") + + def describe_video(self, video_id: str) -> Dict[str, Any]: + """Get video description with scene details.""" + return self._get(f"/api/videos/{video_id}/describe") + + def get_comments(self, video_id: str) -> Dict[str, Any]: + """Get video comments.""" + return self._get(f"/api/videos/{video_id}/comments") + + def get_agent(self, agent_name: str) -> Dict[str, Any]: + """Get agent profile.""" + return self._get(f"/api/agents/{agent_name}") + + def get_stats(self) -> Dict[str, Any]: + """Get platform statistics.""" + return self._get("/api/stats") + + def get_categories(self) -> Dict[str, Any]: + """Get video categories.""" + return self._get("/api/categories") + + # Auth-required endpoints + def like_video(self, video_id: str, vote: int = 1) -> Dict[str, Any]: + """Like/dislike a video (vote: 1=like, -1=dislike, 0=remove).""" + if not self.api_key: + return {"error": "API key required"} + return self._post(f"/api/videos/{video_id}/vote", json_data={"vote": vote}) + + def comment_on_video(self, video_id: str, content: str, parent_id: Optional[int] = None) -> Dict[str, Any]: + """Post a comment on a video.""" + if not self.api_key: + return {"error": "API key required"} + json_data = {"content": content} + if parent_id: + json_data["parent_id"] = parent_id + return self._post(f"/api/videos/{video_id}/comment", json_data=json_data) + + def subscribe_agent(self, agent_name: str) -> Dict[str, Any]: + """Subscribe to an agent.""" + if not self.api_key: + return {"error": "API key required"} + return self._post(f"/api/agents/{agent_name}/subscribe") + + def record_view(self, video_id: str) -> Dict[str, Any]: + """Record a video view.""" + return self._post(f"/api/videos/{video_id}/view") + + +# Global API client instance +api_client = BoTTubeClient() + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def format_video_card(video: Dict[str, Any]) -> str: + """Format video data for Telegram message.""" + title = video.get("title", "Untitled") + agent_name = video.get("agent_name", "Unknown") + views = video.get("views", 0) + likes = video.get("likes", 0) + duration = video.get("duration", 0) + video_id = video.get("video_id", video.get("id", "N/A")) + created_at = video.get("created_at", 0) + + # Format duration + if duration > 0: + mins = int(duration // 60) + secs = int(duration % 60) + duration_str = f"{mins}:{secs:02d}" + else: + duration_str = "N/A" + + # Format views/likes + views_str = f"{views:,}" if views > 0 else "0" + likes_str = f"{likes:,}" if likes > 0 else "0" + + # Format date + if created_at > 0: + from datetime import datetime + date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d") + else: + date_str = "N/A" + + # Truncate title if too long + if len(title) > 50: + title = title[:47] + "..." + + message = f""" +🎬 **{title}** + +👤 **Agent:** @{agent_name} +⏱️ **Duration:** {duration_str} +👁️ **Views:** {views_str} +👍 **Likes:** {likes_str} +📅 **Uploaded:** {date_str} + +🆔 Video ID: `{video_id}` +""" + return message + + +def create_video_keyboard(video_id: str) -> InlineKeyboardMarkup: + """Create inline keyboard for video actions.""" + keyboard = [ + [ + InlineKeyboardButton("🔗 Watch on BoTTube", url=f"{BOTTUBE_API_URL}/watch/{video_id}"), + InlineKeyboardButton("👍 Like", callback_data=f"like_{video_id}") + ], + [ + InlineKeyboardButton("💬 Comment", callback_data=f"comment_{video_id}"), + InlineKeyboardButton("👤 Agent", callback_data=f"agent_{video_id}") + ] + ] + return InlineKeyboardMarkup(keyboard) + + +def format_agent_card(agent: Dict[str, Any]) -> str: + """Format agent profile for Telegram message.""" + agent_name = agent.get("agent_name", "Unknown") + display_name = agent.get("display_name", agent_name) + bio = agent.get("bio", "No bio") + video_count = agent.get("video_count", 0) + total_views = agent.get("total_views", 0) + comment_count = agent.get("comment_count", 0) + total_likes = agent.get("total_likes", 0) + rtc_balance = agent.get("rtc_balance", 0) + + # Truncate bio if too long + if len(bio) > 150: + bio = bio[:147] + "..." + + message = f""" +🤖 **{display_name}** (@{agent_name}) + +📝 **Bio:** {bio} + +📊 **Statistics:** + • Videos: {video_count} + • Total Views: {total_views:,} + • Comments: {comment_count} + • Total Likes: {total_likes} + +💰 **RTC Balance:** {rtc_balance:.4f} RTC +""" + return message + + +def format_stats_card(stats: Dict[str, Any]) -> str: + """Format platform statistics for Telegram message.""" + videos = stats.get("videos", 0) + agents = stats.get("agents", 0) + humans = stats.get("humans", 0) + total_views = stats.get("total_views", 0) + total_comments = stats.get("total_comments", 0) + total_likes = stats.get("total_likes", 0) + + message = f""" +📊 **BoTTube Platform Statistics** + +🎬 **Videos:** {videos:,} +🤖 **Agents:** {agents} (Humans: {humans}) +👁️ **Total Views:** {total_views:,} +💬 **Total Comments:** {total_comments:,} +👍 **Total Likes:** {total_likes:,} + +🌐 Platform: {BOTTUBE_API_URL} +""" + return message + + +# ============================================================================= +# Bot Commands +# ============================================================================= + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /start command - welcome message.""" + user = update.effective_user + logger.info(f"User {user.id} ({user.username}) started the bot") + + welcome_text = f""" +🎬 **Welcome to BoTTube Telegram Bot!** + +I'm your gateway to the AI-generated video platform. Browse, watch, and interact with BoTTube videos directly from Telegram. + +**What is BoTTube?** +BoTTube is the first video platform built for AI agents. Over 100+ AI bots create, upload, and interact with video content autonomously! + +**Quick Start:** +/trending - Browse trending videos +/new - See newest uploads +/search - Search videos +/video - Get video details +/stats - Platform statistics + +**Interact:** +/like - Like a video +/comment - Comment +/subscribe - Follow an agent + +Start exploring at: {BOTTUBE_API_URL} +""" + await update.message.reply_text(welcome_text, parse_mode="Markdown") + + +async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /help command - show available commands.""" + help_text = f""" +🎬 **BoTTube Telegram Bot - Help** + +**Browse Videos:** +/trending - Show trending videos +/new - Show newest uploads +/top - Show most viewed videos +/search - Search by keyword + Example: /search AI robots + +**Video Details:** +/video - Get video metadata + Example: /video abc123 + +**Agent Profiles:** +/agent - Get agent profile + Example: /agent sophia-elya + +**Platform Info:** +/stats - Platform statistics +/categories - Video categories +/health - API health check + +**Interact (requires API key):** +/like - Like a video +/dislike - Dislike a video +/comment - Comment on video +/subscribe - Subscribe to agent + +**Configuration:** +Set BOTTUBE_API_KEY environment variable to enable interactions. + +**Rate Limit:** {RATE_LIMIT_PER_MINUTE} requests/minute +**API:** `{BOTTUBE_API_URL}` +""" + await update.message.reply_text(help_text, parse_mode="Markdown") + + +async def cmd_trending(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /trending command - show trending videos.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested trending videos") + + await update.message.reply_text("🔍 Fetching trending videos...") + + result = api_client.trending(limit=VIDEOS_PER_PAGE) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + videos = result.get("videos", []) + if not videos: + await update.message.reply_text("📭 No trending videos found.") + return + + # Display first 5 videos + for video in videos[:5]: + message = format_video_card(video) + video_id = video.get("video_id", video.get("id")) + keyboard = create_video_keyboard(video_id) + await update.message.reply_text(message, parse_mode="Markdown", reply_markup=keyboard) + + if len(videos) > 5: + await update.message.reply_text(f"📊 Showing 5 of {len(videos)} trending videos. Use /new for more.") + + +async def cmd_new(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /new command - show newest videos.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested newest videos") + + await update.message.reply_text("📅 Fetching newest videos...") + + result = api_client.list_videos(page=1, sort="newest", per_page=VIDEOS_PER_PAGE) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + videos = result.get("videos", []) + if not videos: + await update.message.reply_text("📭 No videos found.") + return + + # Display first 5 videos + for video in videos[:5]: + message = format_video_card(video) + video_id = video.get("video_id", video.get("id")) + keyboard = create_video_keyboard(video_id) + await update.message.reply_text(message, parse_mode="Markdown", reply_markup=keyboard) + + if len(videos) > 5: + await update.message.reply_text(f"📊 Showing 5 of {len(videos)} newest videos.") + + +async def cmd_search(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /search command - search videos.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for query argument + if not context.args: + await update.message.reply_text( + "❌ Usage: /search \n\n" + "Example: /search AI robots" + ) + return + + query = " ".join(context.args) + logger.info(f"User {user.id} searched for: {query}") + + await update.message.reply_text(f"🔍 Searching for: *{query}*", parse_mode="Markdown") + + result = api_client.search(query, page=1, per_page=VIDEOS_PER_PAGE) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + videos = result.get("videos", []) + total = result.get("total", 0) + + if not videos: + await update.message.reply_text(f"📭 No results found for '{query}'.") + return + + # Display first 5 results + for video in videos[:5]: + message = format_video_card(video) + video_id = video.get("video_id", video.get("id")) + keyboard = create_video_keyboard(video_id) + await update.message.reply_text(message, parse_mode="Markdown", reply_markup=keyboard) + + if len(videos) > 5: + await update.message.reply_text(f"📊 Showing 5 of {total} results.") + + +async def cmd_video(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /video command - get video details.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for video_id argument + if not context.args: + await update.message.reply_text( + "❌ Usage: /video \n\n" + "Example: /video abc123" + ) + return + + video_id = context.args[0] + logger.info(f"User {user.id} requested video: {video_id}") + + await update.message.reply_text(f"🎬 Fetching video details...") + + result = api_client.get_video(video_id) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + # Record view + api_client.record_view(video_id) + + message = format_video_card(result) + + # Get description if available + desc_result = api_client.describe_video(video_id) + if "error" not in desc_result: + description = desc_result.get("description", "") + if description and len(description) > 200: + description = description[:197] + "..." + if description: + message += f"\n📝 **Description:** {description}\n" + + keyboard = create_video_keyboard(video_id) + await update.message.reply_text(message, parse_mode="Markdown", reply_markup=keyboard) + + +async def cmd_agent(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /agent command - get agent profile.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for agent_name argument + if not context.args: + await update.message.reply_text( + "❌ Usage: /agent \n\n" + "Example: /agent sophia-elya" + ) + return + + agent_name = context.args[0] + logger.info(f"User {user.id} requested agent: {agent_name}") + + await update.message.reply_text(f"🤖 Fetching agent profile...") + + result = api_client.get_agent(agent_name) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + message = format_agent_card(result) + + # Add subscribe button + keyboard = InlineKeyboardMarkup([[ + InlineKeyboardButton("📩 Subscribe", callback_data=f"subscribe_{agent_name}"), + InlineKeyboardButton("🎬 Videos", callback_data=f"videos_{agent_name}") + ]]) + + await update.message.reply_text(message, parse_mode="Markdown", reply_markup=keyboard) + + +async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /stats command - get platform statistics.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested platform stats") + + await update.message.reply_text("📊 Fetching platform statistics...") + + result = api_client.get_stats() + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + message = format_stats_card(result) + await update.message.reply_text(message, parse_mode="Markdown") + + +async def cmd_categories(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /categories command - show video categories.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested categories") + + result = api_client.get_categories() + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + categories = result.get("categories", []) + if not categories: + await update.message.reply_text("📭 No categories found.") + return + + message = "📂 **Video Categories**\n\n" + for cat in categories: + message += f"• `{cat}`\n" + + message += f"\nUse /search to browse videos in a category." + + await update.message.reply_text(message, parse_mode="Markdown") + + +async def cmd_health(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /health command - check API health.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + logger.info(f"User {user.id} requested health check") + + result = api_client.health() + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + status = result.get("ok", False) + version = result.get("version", "N/A") + + status_icon = "✅" if status else "❌" + health_text = f""" +{status_icon} **BoTTube API Health** + +Status: *{'Online' if status else 'Offline'}* +Version: `{version}` + +API: `{BOTTUBE_API_URL}` +""" + await update.message.reply_text(health_text, parse_mode="Markdown") + + +async def cmd_like(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /like command - like a video.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for video_id argument + if not context.args: + await update.message.reply_text( + "❌ Usage: /like \n\n" + "Example: /like abc123" + ) + return + + video_id = context.args[0] + logger.info(f"User {user.id} liked video: {video_id}") + + if not BOTTUBE_API_KEY: + await update.message.reply_text( + "⚠️ API key required. Set BOTTUBE_API_KEY environment variable to enable interactions." + ) + return + + result = api_client.like_video(video_id, vote=1) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + await update.message.reply_text(f"👍 Successfully liked video `{video_id}`!", parse_mode="Markdown") + + +async def cmd_dislike(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /dislike command - dislike a video.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + if not context.args: + await update.message.reply_text( + "❌ Usage: /dislike \n\n" + "Example: /dislike abc123" + ) + return + + video_id = context.args[0] + logger.info(f"User {user.id} disliked video: {video_id}") + + if not BOTTUBE_API_KEY: + await update.message.reply_text( + "⚠️ API key required. Set BOTTUBE_API_KEY environment variable to enable interactions." + ) + return + + result = api_client.like_video(video_id, vote=-1) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + await update.message.reply_text(f"👎 Successfully disliked video `{video_id}`!", parse_mode="Markdown") + + +async def cmd_comment(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /comment command - comment on a video.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for arguments + if len(context.args) < 2: + await update.message.reply_text( + "❌ Usage: /comment \n\n" + "Example: /comment abc123 Great video!" + ) + return + + video_id = context.args[0] + comment_text = " ".join(context.args[1:]) + logger.info(f"User {user.id} commented on video: {video_id}") + + if not BOTTUBE_API_KEY: + await update.message.reply_text( + "⚠️ API key required. Set BOTTUBE_API_KEY environment variable to enable interactions." + ) + return + + result = api_client.comment_on_video(video_id, comment_text) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + await update.message.reply_text( + f"💬 Successfully commented on video `{video_id}`!\n\n" + f"Your comment: _{comment_text}_", + parse_mode="Markdown" + ) + + +async def cmd_subscribe(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /subscribe command - subscribe to an agent.""" + user = update.effective_user + + if not rate_limiter.is_allowed(user.id): + await update.message.reply_text( + f"⚠️ Rate limit exceeded. Please wait before making more requests." + ) + return + + # Check for agent_name argument + if not context.args: + await update.message.reply_text( + "❌ Usage: /subscribe \n\n" + "Example: /subscribe sophia-elya" + ) + return + + agent_name = context.args[0] + logger.info(f"User {user.id} subscribed to agent: {agent_name}") + + if not BOTTUBE_API_KEY: + await update.message.reply_text( + "⚠️ API key required. Set BOTTUBE_API_KEY environment variable to enable interactions." + ) + return + + result = api_client.subscribe_agent(agent_name) + + if "error" in result: + await update.message.reply_text(f"❌ Error: {result['error']}") + return + + follower_count = result.get("follower_count", 0) + await update.message.reply_text( + f"📩 Successfully subscribed to @{agent_name}!\n\n" + f"Total followers: {follower_count}", + parse_mode="Markdown" + ) + + +async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle callback queries from inline keyboards.""" + query = update.callback_query + await query.answer() + + data = query.data + logger.info(f"Callback received: {data}") + + if data.startswith("like_"): + video_id = data.split("_", 1)[1] + if BOTTUBE_API_KEY: + result = api_client.like_video(video_id, vote=1) + if "error" not in result: + await query.edit_message_text(f"👍 Liked video `{video_id}`!", parse_mode="Markdown") + else: + await query.edit_message_text("⚠️ API key required for interactions.") + + elif data.startswith("comment_"): + video_id = data.split("_", 1)[1] + await query.edit_message_text( + f"💬 To comment on this video, use:\n\n" + f"`/comment {video_id} `", + parse_mode="Markdown" + ) + + elif data.startswith("agent_"): + video_id = data.split("_", 1)[1] + video_data = api_client.get_video(video_id) + if "error" not in video_data: + agent_name = video_data.get("agent_name", "unknown") + await query.edit_message_text( + f"🤖 View agent profile:\n\n" + f"`/agent {agent_name}`", + parse_mode="Markdown" + ) + + elif data.startswith("subscribe_"): + agent_name = data.split("_", 1)[1] + if BOTTUBE_API_KEY: + result = api_client.subscribe_agent(agent_name) + if "error" not in result: + await query.edit_message_text( + f"📩 Subscribed to @{agent_name}!", + parse_mode="Markdown" + ) + else: + await query.edit_message_text("⚠️ API key required for interactions.") + + elif data.startswith("videos_"): + agent_name = data.split("_", 1)[1] + await query.edit_message_text( + f"🎬 View agent's videos:\n\n" + f"`/new` and look for @{agent_name}'s videos", + parse_mode="Markdown" + ) + + +async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle errors caused by updates.""" + logger.error(f"Update {update} caused error: {context.error}") + + if update and update.effective_message: + await update.effective_message.reply_text( + "❌ An error occurred while processing your request." + ) + + +# ============================================================================= +# Bot Initialization +# ============================================================================= + +def set_bot_commands(application: Application): + """Set up bot command list for Telegram menu.""" + commands = [ + BotCommand("start", "Start the bot"), + BotCommand("help", "Show available commands"), + BotCommand("trending", "Browse trending videos"), + BotCommand("new", "Show newest uploads"), + BotCommand("search", "Search videos"), + BotCommand("video", "Get video details"), + BotCommand("agent", "Get agent profile"), + BotCommand("stats", "Platform statistics"), + BotCommand("categories", "Video categories"), + BotCommand("health", "API health check"), + BotCommand("like", "Like a video"), + BotCommand("comment", "Comment on a video"), + BotCommand("subscribe", "Subscribe to an agent"), + ] + return commands + + +async def post_init(application: Application): + """Post-initialization setup.""" + commands = set_bot_commands(application) + await application.bot.set_my_commands(commands) + logger.info(f"Bot commands set: {[c.command for c in commands]}") + + +def validate_config() -> bool: + """Validate required configuration.""" + if not TELEGRAM_BOT_TOKEN: + logger.error("TELEGRAM_BOT_TOKEN environment variable is not set") + print("\n❌ Error: TELEGRAM_BOT_TOKEN environment variable is required") + print("\nTo get a bot token:") + print("1. Open Telegram and message @BotFather") + print("2. Send /newbot to create a new bot") + print("3. Follow the instructions to name your bot") + print("4. Copy the API token") + print("5. Set it: export TELEGRAM_BOT_TOKEN='your-token-here'") + print("\nOr create a .env file with:") + print(" TELEGRAM_BOT_TOKEN=your-token-here\n") + return False + + if TELEGRAM_BOT_TOKEN == "YOUR_BOT_TOKEN_HERE": + logger.error("Please replace 'YOUR_BOT_TOKEN_HERE' with your actual bot token") + print("\n❌ Error: Please replace 'YOUR_BOT_TOKEN_HERE' with your actual bot token") + return False + + logger.info(f"Configuration validated. BoTTube API: {BOTTUBE_API_URL}") + if BOTTUBE_API_KEY: + logger.info("BoTTube API key configured - interactions enabled") + else: + logger.info("BoTTube API key not set - interactions disabled") + return True + + +def main(): + """Main entry point - start the bot.""" + logger.info("Starting BoTTube Telegram Bot...") + logger.info(f"Python version: {sys.version}") + + # Validate configuration + if not validate_config(): + sys.exit(1) + + # Build application + application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() + + # Register command handlers + application.add_handler(CommandHandler("start", cmd_start)) + application.add_handler(CommandHandler("help", cmd_help)) + application.add_handler(CommandHandler("trending", cmd_trending)) + application.add_handler(CommandHandler("new", cmd_new)) + application.add_handler(CommandHandler("search", cmd_search)) + application.add_handler(CommandHandler("video", cmd_video)) + application.add_handler(CommandHandler("agent", cmd_agent)) + application.add_handler(CommandHandler("stats", cmd_stats)) + application.add_handler(CommandHandler("categories", cmd_categories)) + application.add_handler(CommandHandler("health", cmd_health)) + application.add_handler(CommandHandler("like", cmd_like)) + application.add_handler(CommandHandler("dislike", cmd_dislike)) + application.add_handler(CommandHandler("comment", cmd_comment)) + application.add_handler(CommandHandler("subscribe", cmd_subscribe)) + + # Register callback query handler + application.add_handler(MessageHandler(filters.StatusUpdate.ALL, callback_handler)) + + # Register error handler + application.add_error_handler(error_handler) + + # Set post-init callback + application.post_init = post_init + + # Start the bot + print("\n🎬 BoTTube Telegram Bot starting...") + print(f" API: {BOTTUBE_API_URL}") + print(f" Interactions: {'Enabled' if BOTTUBE_API_KEY else 'Disabled'}") + print(f" Rate limit: {RATE_LIMIT_PER_MINUTE} req/min") + print("\nPress Ctrl+C to stop\n") + + # Run polling + application.run_polling( + allowed_updates=Update.ALL_TYPES, + drop_pending_updates=True + ) + + +if __name__ == "__main__": + main() diff --git a/bottube_telegram_bot/requirements.txt b/bottube_telegram_bot/requirements.txt new file mode 100644 index 000000000..a5aec3ac0 --- /dev/null +++ b/bottube_telegram_bot/requirements.txt @@ -0,0 +1,22 @@ +# BoTTube Telegram Bot +# Issue #2299 - Dependencies + +# Telegram Bot API +python-telegram-bot>=20.0 + +# Environment variable management +python-dotenv>=1.0.0 + +# HTTP client +requests>=2.28.0 + +# Testing +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pytest-cov>=4.0.0 + +# Type checking (optional) +mypy>=1.0.0 + +# Linting (optional) +ruff>=0.1.0 diff --git a/bottube_telegram_bot/tests/conftest.py b/bottube_telegram_bot/tests/conftest.py new file mode 100644 index 000000000..c2f0057c7 --- /dev/null +++ b/bottube_telegram_bot/tests/conftest.py @@ -0,0 +1,89 @@ +""" +Pytest configuration and fixtures for BoTTube Telegram Bot tests +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch + + +@pytest.fixture +def mock_update(): + """Create a mock update object.""" + update = Mock() + update.message = AsyncMock() + update.message.reply_text = AsyncMock() + update.effective_user = Mock() + update.effective_user.id = 12345 + update.effective_user.username = "testuser" + update.effective_message = update.message + return update + + +@pytest.fixture +def mock_context(): + """Create a mock context object.""" + context = Mock() + context.args = [] + return context + + +@pytest.fixture +def mock_callback_query(): + """Create a mock callback query object.""" + query = Mock() + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + query.data = "test_data" + return query + + +@pytest.fixture +def mock_video_data(): + """Sample video data for testing.""" + return { + "video_id": "abc123", + "title": "Test Video", + "agent_name": "test-agent", + "views": 100, + "likes": 10, + "duration": 180, + "created_at": 1711111111, + "description": "A test video description" + } + + +@pytest.fixture +def mock_agent_data(): + """Sample agent data for testing.""" + return { + "agent_name": "test-agent", + "display_name": "Test Agent", + "bio": "A test agent bio", + "video_count": 5, + "total_views": 500, + "comment_count": 20, + "total_likes": 50, + "rtc_balance": 0.045 + } + + +@pytest.fixture +def mock_stats_data(): + """Sample platform statistics for testing.""" + return { + "videos": 130, + "agents": 17, + "humans": 4, + "total_views": 1415, + "total_comments": 701, + "total_likes": 300 + } + + +@pytest.fixture +def mock_health_data(): + """Sample health check data.""" + return { + "ok": True, + "version": "1.3.1" + } diff --git a/bottube_telegram_bot/tests/test_api_client.py b/bottube_telegram_bot/tests/test_api_client.py new file mode 100644 index 000000000..cb7c513cb --- /dev/null +++ b/bottube_telegram_bot/tests/test_api_client.py @@ -0,0 +1,373 @@ +""" +Unit tests for BoTTube API client +Issue #2299 - BoTTube Telegram Bot +""" + +import pytest +from unittest.mock import Mock, patch, MagicMock +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestBoTTubeClientEndpoints: + """Tests for BoTTubeClient API endpoints.""" + + @patch('bottube_bot.requests.Session') + def test_health_endpoint(self, mock_session_class): + """Test health check endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True, "version": "1.3.1"} + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.health() + + assert result == {"ok": True, "version": "1.3.1"} + mock_session.get.assert_called_once() + + @patch('bottube_bot.requests.Session') + def test_trending_endpoint(self, mock_session_class): + """Test trending videos endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "videos": [ + {"video_id": "abc123", "title": "Trending Video"} + ] + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.trending(limit=10) + + assert "videos" in result + mock_session.get.assert_called_once() + + @patch('bottube_bot.requests.Session') + def test_list_videos_endpoint(self, mock_session_class): + """Test list videos endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "videos": [ + {"video_id": "abc123", "title": "New Video"} + ] + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.list_videos(page=1, sort="newest", per_page=10) + + assert "videos" in result + + @patch('bottube_bot.requests.Session') + def test_search_endpoint(self, mock_session_class): + """Test search endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "videos": [ + {"video_id": "abc123", "title": "AI Video"} + ], + "total": 1 + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.search(query="AI robots", page=1, per_page=10) + + assert "videos" in result + assert result["total"] == 1 + + @patch('bottube_bot.requests.Session') + def test_get_video_endpoint(self, mock_session_class): + """Test get video endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "video_id": "abc123", + "title": "Test Video", + "agent_name": "test-agent" + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.get_video("abc123") + + assert result["video_id"] == "abc123" + + @patch('bottube_bot.requests.Session') + def test_describe_video_endpoint(self, mock_session_class): + """Test describe video endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "title": "Test Video", + "description": "Test description", + "scene_description": "Scene details" + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.describe_video("abc123") + + assert "description" in result + + @patch('bottube_bot.requests.Session') + def test_get_comments_endpoint(self, mock_session_class): + """Test get comments endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "comments": [ + {"id": 1, "content": "Great video!", "agent_name": "user1"} + ] + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.get_comments("abc123") + + assert "comments" in result + + @patch('bottube_bot.requests.Session') + def test_get_agent_endpoint(self, mock_session_class): + """Test get agent endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "agent_name": "test-agent", + "display_name": "Test Agent", + "video_count": 5 + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.get_agent("test-agent") + + assert result["agent_name"] == "test-agent" + + @patch('bottube_bot.requests.Session') + def test_get_stats_endpoint(self, mock_session_class): + """Test get stats endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "videos": 130, + "agents": 17, + "total_views": 1415 + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.get_stats() + + assert result["videos"] == 130 + + @patch('bottube_bot.requests.Session') + def test_get_categories_endpoint(self, mock_session_class): + """Test get categories endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = { + "categories": ["ai-art", "education", "tech"] + } + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client.get_categories() + + assert "categories" in result + assert len(result["categories"]) == 3 + + @patch('bottube_bot.requests.Session') + def test_like_video_endpoint(self, mock_session_class): + """Test like video endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = Mock() + mock_session.post.return_value = mock_response + + client = BoTTubeClient(api_key="test-key") + result = client.like_video("abc123", vote=1) + + assert result["ok"] is True + mock_session.post.assert_called_once() + + @patch('bottube_bot.requests.Session') + def test_comment_on_video_endpoint(self, mock_session_class): + """Test comment on video endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = Mock() + mock_session.post.return_value = mock_response + + client = BoTTubeClient(api_key="test-key") + result = client.comment_on_video("abc123", "Great video!") + + assert result["ok"] is True + + @patch('bottube_bot.requests.Session') + def test_subscribe_agent_endpoint(self, mock_session_class): + """Test subscribe agent endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True, "follower_count": 5} + mock_response.raise_for_status = Mock() + mock_session.post.return_value = mock_response + + client = BoTTubeClient(api_key="test-key") + result = client.subscribe_agent("test-agent") + + assert result["ok"] is True + assert result["follower_count"] == 5 + + @patch('bottube_bot.requests.Session') + def test_record_view_endpoint(self, mock_session_class): + """Test record view endpoint.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = Mock() + mock_session.post.return_value = mock_response + + client = BoTTubeClient() + result = client.record_view("abc123") + + assert result["ok"] is True + + +class TestBoTTubeClientErrors: + """Tests for error handling in BoTTubeClient.""" + + @patch('bottube_bot.requests.Session') + def test_timeout_error(self, mock_session_class): + """Test timeout error handling.""" + from bottube_bot import BoTTubeClient + import requests + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_session.get.side_effect = requests.exceptions.Timeout() + + client = BoTTubeClient() + result = client.health() + + assert "error" in result + assert "timeout" in result["error"].lower() + + @patch('bottube_bot.requests.Session') + def test_connection_error(self, mock_session_class): + """Test connection error handling.""" + from bottube_bot import BoTTubeClient + import requests + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_session.get.side_effect = requests.exceptions.ConnectionError("Connection failed") + + client = BoTTubeClient() + result = client.health() + + assert "error" in result + + @patch('bottube_bot.requests.Session') + def test_http_error(self, mock_session_class): + """Test HTTP error handling.""" + from bottube_bot import BoTTubeClient + import requests + + mock_session = Mock() + mock_session_class.return_value = mock_session + + mock_response = Mock() + mock_response.status_code = 404 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(response=mock_response) + mock_session.get.side_effect = mock_response.raise_for_status + + client = BoTTubeClient() + result = client.health() + + assert "error" in result + + def test_interactions_require_api_key(self): + """Test that interactions require API key.""" + from bottube_bot import BoTTubeClient + + client = BoTTubeClient() # No API key + result = client.like_video("abc123", vote=1) + + assert "error" in result + assert "API key" in result["error"] + + @patch('bottube_bot.requests.Session') + def test_general_exception_handling(self, mock_session_class): + """Test general exception handling.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_session.get.side_effect = Exception("Unexpected error") + + client = BoTTubeClient() + result = client.health() + + assert "error" in result diff --git a/bottube_telegram_bot/tests/test_bot_commands.py b/bottube_telegram_bot/tests/test_bot_commands.py new file mode 100644 index 000000000..92c4764fa --- /dev/null +++ b/bottube_telegram_bot/tests/test_bot_commands.py @@ -0,0 +1,488 @@ +""" +Unit tests for BoTTube Telegram Bot commands +Issue #2299 - BoTTube Telegram Bot +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch, MagicMock +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestBotCommands: + """Tests for bot command handlers.""" + + @pytest.fixture + def mock_update(self): + """Create a mock update object.""" + update = Mock() + update.message = AsyncMock() + update.message.reply_text = AsyncMock() + update.effective_user = Mock() + update.effective_user.id = 12345 + update.effective_user.username = "testuser" + return update + + @pytest.fixture + def mock_context(self): + """Create a mock context object.""" + context = Mock() + context.args = [] + return context + + @pytest.mark.asyncio + async def test_cmd_start(self, mock_update, mock_context): + """Test /start command.""" + from bottube_bot import cmd_start + + await cmd_start(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Welcome to BoTTube" in call_args[0][0] or "BoTTube Telegram Bot" in call_args[0][0] + + @pytest.mark.asyncio + async def test_cmd_help(self, mock_update, mock_context): + """Test /help command.""" + from bottube_bot import cmd_help + + await cmd_help(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Help" in call_args[0][0] or "Commands" in call_args[0][0] + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_trending_success(self, mock_client, mock_update, mock_context): + """Test /trending command success.""" + from bottube_bot import cmd_trending + + mock_client.trending.return_value = { + "videos": [ + {"video_id": "abc123", "title": "Test Video", "agent_name": "test", + "views": 100, "likes": 10, "duration": 180, "created_at": 1711111111} + ] + } + + await cmd_trending(mock_update, mock_context) + + assert mock_update.message.reply_text.called + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_trending_error(self, mock_client, mock_update, mock_context): + """Test /trending command with API error.""" + from bottube_bot import cmd_trending + + mock_client.trending.return_value = {"error": "API error"} + + await cmd_trending(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Error" in call_args[0][0] or "error" in call_args[0][0] + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_new_success(self, mock_client, mock_update, mock_context): + """Test /new command success.""" + from bottube_bot import cmd_new + + mock_client.list_videos.return_value = { + "videos": [ + {"video_id": "abc123", "title": "New Video", "agent_name": "test", + "views": 50, "likes": 5, "duration": 120, "created_at": 1711111111} + ] + } + + await cmd_new(mock_update, mock_context) + + assert mock_update.message.reply_text.called + + @pytest.mark.asyncio + async def test_cmd_search_no_args(self, mock_update, mock_context): + """Test /search command without arguments.""" + from bottube_bot import cmd_search + + mock_context.args = [] + await cmd_search(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_search_success(self, mock_client, mock_update, mock_context): + """Test /search command with query.""" + from bottube_bot import cmd_search + + mock_context.args = ["AI", "robots"] + mock_client.search.return_value = { + "videos": [ + {"video_id": "abc123", "title": "AI Robots", "agent_name": "test", + "views": 200, "likes": 20, "duration": 240, "created_at": 1711111111} + ], + "total": 1 + } + + await cmd_search(mock_update, mock_context) + + assert mock_update.message.reply_text.called + + @pytest.mark.asyncio + async def test_cmd_video_no_args(self, mock_update, mock_context): + """Test /video command without arguments.""" + from bottube_bot import cmd_video + + mock_context.args = [] + await cmd_video(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_video_success(self, mock_client, mock_update, mock_context): + """Test /video command with video ID.""" + from bottube_bot import cmd_video + + mock_context.args = ["abc123"] + mock_client.get_video.return_value = { + "video_id": "abc123", "title": "Test Video", "agent_name": "test", + "views": 100, "likes": 10, "duration": 180, "created_at": 1711111111 + } + mock_client.describe_video.return_value = {"description": "Test description"} + + await cmd_video(mock_update, mock_context) + + assert mock_update.message.reply_text.called + + @pytest.mark.asyncio + async def test_cmd_agent_no_args(self, mock_update, mock_context): + """Test /agent command without arguments.""" + from bottube_bot import cmd_agent + + mock_context.args = [] + await cmd_agent(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_agent_success(self, mock_client, mock_update, mock_context): + """Test /agent command with agent name.""" + from bottube_bot import cmd_agent + + mock_context.args = ["test-agent"] + mock_client.get_agent.return_value = { + "agent_name": "test-agent", "display_name": "Test Agent", + "bio": "Test bio", "video_count": 5, "total_views": 500, + "comment_count": 20, "total_likes": 50, "rtc_balance": 0.045 + } + + await cmd_agent(mock_update, mock_context) + + assert mock_update.message.reply_text.called + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + async def test_cmd_stats_success(self, mock_client, mock_update, mock_context): + """Test /stats command success.""" + from bottube_bot import cmd_stats + + mock_client.get_stats.return_value = { + "videos": 130, "agents": 17, "humans": 4, + "total_views": 1415, "total_comments": 701, "total_likes": 300 + } + + await cmd_stats(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "130" in call_args[0][0] or "Statistics" in call_args[0][0] + + @pytest.mark.asyncio + @patch('bottube_bot.api_client') + @patch('bottube_bot.rate_limiter') + async def test_cmd_health_success(self, mock_limiter, mock_client, mock_update, mock_context): + """Test /health command success.""" + from bottube_bot import cmd_health + + mock_limiter.is_allowed.return_value = True + mock_client.health.return_value = {"ok": True, "version": "1.3.1"} + + await cmd_health(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Online" in call_args[0][0] or "ok" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.rate_limiter') + async def test_cmd_like_no_args(self, mock_limiter, mock_update, mock_context): + """Test /like command without arguments.""" + from bottube_bot import cmd_like + + mock_limiter.is_allowed.return_value = True + mock_context.args = [] + await cmd_like(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.rate_limiter') + async def test_cmd_like_no_api_key(self, mock_limiter, mock_update, mock_context): + """Test /like command without API key.""" + from bottube_bot import cmd_like, BOTTUBE_API_KEY + + mock_limiter.is_allowed.return_value = True + mock_context.args = ["abc123"] + + with patch('bottube_bot.BOTTUBE_API_KEY', ''): + await cmd_like(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "API key" in call_args[0][0] or "api key" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.rate_limiter') + async def test_cmd_comment_no_args(self, mock_limiter, mock_update, mock_context): + """Test /comment command without arguments.""" + from bottube_bot import cmd_comment + + mock_limiter.is_allowed.return_value = True + mock_context.args = [] + await cmd_comment(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + @pytest.mark.asyncio + @patch('bottube_bot.rate_limiter') + async def test_cmd_subscribe_no_args(self, mock_limiter, mock_update, mock_context): + """Test /subscribe command without arguments.""" + from bottube_bot import cmd_subscribe + + mock_limiter.is_allowed.return_value = True + mock_context.args = [] + await cmd_subscribe(mock_update, mock_context) + + assert mock_update.message.reply_text.called + call_args = mock_update.message.reply_text.call_args + assert "Usage:" in call_args[0][0] or "usage" in call_args[0][0].lower() + + +class TestConfiguration: + """Tests for configuration validation.""" + + @patch('bottube_bot.TELEGRAM_BOT_TOKEN', '') + def test_validate_config_missing_token(self): + """Test validation fails without bot token.""" + from bottube_bot import validate_config + + result = validate_config() + assert result is False + + @patch('bottube_bot.TELEGRAM_BOT_TOKEN', 'YOUR_BOT_TOKEN_HERE') + def test_validate_config_default_token(self): + """Test validation fails with default token.""" + from bottube_bot import validate_config + + result = validate_config() + assert result is False + + @patch('bottube_bot.TELEGRAM_BOT_TOKEN', 'test-token-123') + def test_validate_config_valid_token(self): + """Test validation passes with valid token.""" + from bottube_bot import validate_config + + result = validate_config() + assert result is True + + +class TestBotCommandsSetup: + """Tests for bot command setup.""" + + def test_set_bot_commands(self): + """Test bot commands are set correctly.""" + from bottube_bot import set_bot_commands + from telegram import BotCommand + + commands = set_bot_commands(None) + + assert len(commands) >= 10 + command_names = [c.command for c in commands] + assert "start" in command_names + assert "help" in command_names + assert "trending" in command_names + assert "search" in command_names + assert "video" in command_names + assert "agent" in command_names + assert "stats" in command_names + + +class TestHelperFunctions: + """Tests for helper formatting functions.""" + + def test_format_video_card(self): + """Test video card formatting.""" + from bottube_bot import format_video_card + + video = { + "video_id": "abc123", + "title": "Test Video", + "agent_name": "test-agent", + "views": 1234, + "likes": 56, + "duration": 180, + "created_at": 1711111111 + } + + message = format_video_card(video) + + assert "Test Video" in message + assert "test-agent" in message + assert "1,234" in message + assert "56" in message + + def test_format_agent_card(self): + """Test agent card formatting.""" + from bottube_bot import format_agent_card + + agent = { + "agent_name": "test-agent", + "display_name": "Test Agent", + "bio": "Test bio", + "video_count": 5, + "total_views": 500, + "comment_count": 20, + "total_likes": 50, + "rtc_balance": 0.045 + } + + message = format_agent_card(agent) + + assert "Test Agent" in message + assert "test-agent" in message + assert "5" in message # video count + assert "500" in message # total views + + def test_format_stats_card(self): + """Test stats card formatting.""" + from bottube_bot import format_stats_card + + stats = { + "videos": 130, + "agents": 17, + "humans": 4, + "total_views": 1415, + "total_comments": 701, + "total_likes": 300 + } + + message = format_stats_card(stats) + + assert "130" in message + assert "17" in message + assert "Statistics" in message or "Platform" in message + + +class TestRateLimiter: + """Tests for rate limiting functionality.""" + + def test_rate_limiter_allows_first_request(self): + """Test that rate limiter allows first request.""" + from bottube_bot import RateLimiter + + limiter = RateLimiter(max_requests=10) + assert limiter.is_allowed(12345) is True + + def test_rate_limiter_blocks_after_limit(self): + """Test that rate limiter blocks after reaching limit.""" + from bottube_bot import RateLimiter + + limiter = RateLimiter(max_requests=2) + + # First two requests should be allowed + assert limiter.is_allowed(12345) is True + assert limiter.is_allowed(12345) is True + + # Third request should be blocked + assert limiter.is_allowed(12345) is False + + def test_rate_limiter_per_user(self): + """Test that rate limiting is per-user.""" + from bottube_bot import RateLimiter + + limiter = RateLimiter(max_requests=1) + + # User 1 reaches limit + assert limiter.is_allowed(11111) is True + assert limiter.is_allowed(11111) is False + + # User 2 should still be allowed + assert limiter.is_allowed(22222) is True + + +class TestBoTTubeClient: + """Tests for BoTTube API client.""" + + def test_client_initialization(self): + """Test client initialization.""" + from bottube_bot import BoTTubeClient + + client = BoTTubeClient() + assert client.base_url.endswith("/ai") or "bottube.ai" in client.base_url + + def test_client_with_api_key(self): + """Test client initialization with API key.""" + from bottube_bot import BoTTubeClient + + client = BoTTubeClient(api_key="test-key") + assert client.api_key == "test-key" + + @patch('bottube_bot.requests.Session') + def test_client_get_request(self, mock_session_class): + """Test GET request handling.""" + from bottube_bot import BoTTubeClient + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_response = Mock() + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = Mock() + mock_session.get.return_value = mock_response + + client = BoTTubeClient() + result = client._get("/health") + + assert result == {"ok": True} + mock_session.get.assert_called_once() + + @patch('bottube_bot.requests.Session') + def test_client_timeout(self, mock_session_class): + """Test timeout handling.""" + from bottube_bot import BoTTubeClient + import requests + + mock_session = Mock() + mock_session_class.return_value = mock_session + mock_session.get.side_effect = requests.exceptions.Timeout() + + client = BoTTubeClient() + result = client._get("/health") + + assert "error" in result + assert "timeout" in result["error"].lower()