first commit
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Telegram search service for crypto and geopolitical channel monitoring."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
try:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default public channels: crypto + geopolitics/politics
|
||||
# Verified 2026-03-31: all channels return text messages with meaningful content
|
||||
DEFAULT_CHANNELS = [
|
||||
# Crypto / macro
|
||||
"CryptoVIPSignalTA", # crypto signals + macro news
|
||||
"whale_alert_io", # on-chain whale transfers
|
||||
"WatcherGuru", # crypto/macro breaking news
|
||||
# Geopolitics & politics
|
||||
"DDGeopolitics", # geopolitical analysis (views ~20k)
|
||||
"disclosetv", # US politics, breaking news (views ~50-70k)
|
||||
"realDonaldTrump", # Trump's own posts
|
||||
"intelslava", # Russia/Ukraine, geopolitics (views ~50k)
|
||||
"TheScrollOfBenjamin", # Middle East geopolitics
|
||||
"WarMonitors", # conflict breaking news (views ~14k, active)
|
||||
]
|
||||
|
||||
|
||||
class TelegramSearchService:
|
||||
"""
|
||||
Searches crypto-relevant public Telegram channels for messages.
|
||||
|
||||
Uses Telethon (MTProto API) to search public channels by keyword.
|
||||
Requires a one-time auth to generate a session string.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_id: str = "",
|
||||
api_hash: str = "",
|
||||
session_string: str = "",
|
||||
channels: Optional[List[str]] = None,
|
||||
):
|
||||
self.api_id = api_id
|
||||
self.api_hash = api_hash
|
||||
self.session_string = session_string
|
||||
self.channels = channels or DEFAULT_CHANNELS
|
||||
self._client = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Telegram search is available."""
|
||||
if not (self.api_id and self.api_hash and self.session_string):
|
||||
return False
|
||||
try:
|
||||
import telethon # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("telethon not installed. Telegram search disabled.")
|
||||
return False
|
||||
|
||||
def _get_client(self):
|
||||
"""Get or create the Telethon client."""
|
||||
if self._client is None:
|
||||
from telethon import TelegramClient
|
||||
from telethon.sessions import StringSession
|
||||
self._client = TelegramClient(
|
||||
StringSession(self.session_string),
|
||||
int(self.api_id),
|
||||
self.api_hash,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def _search_channel(
|
||||
self,
|
||||
channel: str,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
) -> List[dict]:
|
||||
"""Search a single channel for messages matching a query."""
|
||||
from telethon.errors import (
|
||||
FloodWaitError,
|
||||
ChannelPrivateError,
|
||||
UsernameNotOccupiedError,
|
||||
UsernameInvalidError,
|
||||
)
|
||||
|
||||
results = []
|
||||
try:
|
||||
client = self._get_client()
|
||||
async for message in client.iter_messages(
|
||||
channel,
|
||||
search=query,
|
||||
limit=limit,
|
||||
):
|
||||
if not message.text:
|
||||
continue
|
||||
results.append({
|
||||
"channel": channel,
|
||||
"text": message.text,
|
||||
"date": message.date.strftime("%Y-%m-%d %H:%M UTC") if message.date else "",
|
||||
"views": message.views or 0,
|
||||
"forwards": message.forwards or 0,
|
||||
})
|
||||
except FloodWaitError as e:
|
||||
logger.warning(f"Telegram flood wait: {e.seconds}s for channel {channel}")
|
||||
except (ChannelPrivateError, UsernameNotOccupiedError, UsernameInvalidError):
|
||||
logger.debug(f"Channel {channel} not accessible, skipping")
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Telegram channel {channel}: {e}")
|
||||
return results
|
||||
|
||||
async def _search_all_channels(self, query: str, limit_per_channel: int = 5) -> List[dict]:
|
||||
"""Search all configured channels."""
|
||||
client = self._get_client()
|
||||
async with client:
|
||||
all_results = []
|
||||
for channel in self.channels:
|
||||
results = await self._search_channel(channel, query, limit_per_channel)
|
||||
all_results.extend(results)
|
||||
# Sort by views descending
|
||||
all_results.sort(key=lambda x: x["views"], reverse=True)
|
||||
return all_results
|
||||
|
||||
def _format_report(self, query: str, messages: List[dict]) -> str:
|
||||
"""Format search results as a report string."""
|
||||
if not messages:
|
||||
return f"No Telegram messages found for '{query}'."
|
||||
|
||||
total_views = sum(m["views"] for m in messages)
|
||||
lines = [
|
||||
f"--- Telegram Search Results for '{query}' ---",
|
||||
f"Total Views in Sample: {total_views:,}",
|
||||
f"Channels Searched: {', '.join(self.channels)}",
|
||||
"Messages:",
|
||||
]
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
text_preview = msg["text"][:200]
|
||||
if len(msg["text"]) > 200:
|
||||
text_preview += "..."
|
||||
lines.append(
|
||||
f'{idx + 1}. [{msg["channel"]}] ({msg["date"]}, '
|
||||
f'👁 {msg["views"]:,}): "{text_preview}"'
|
||||
)
|
||||
|
||||
lines.append("-------------------------------------------")
|
||||
return "\n".join(lines)
|
||||
|
||||
def search_for_market(self, query: str, limit: int = 10) -> str:
|
||||
"""
|
||||
Search Telegram channels for messages relevant to a market.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Max total messages to return
|
||||
|
||||
Returns:
|
||||
Formatted report string
|
||||
"""
|
||||
if not self.is_available():
|
||||
return "Telegram search unavailable: credentials not configured or telethon not installed."
|
||||
|
||||
try:
|
||||
# Calculate per-channel limit
|
||||
limit_per_channel = max(3, limit // len(self.channels))
|
||||
|
||||
# Run async search — handle both sync and async calling contexts
|
||||
coro = self._search_all_channels(query, limit_per_channel)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# Already inside an async event loop — use a new thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
messages = pool.submit(
|
||||
asyncio.run, coro
|
||||
).result(timeout=30)
|
||||
except RuntimeError:
|
||||
# No running loop — safe to use run_until_complete
|
||||
loop = asyncio.get_event_loop()
|
||||
messages = loop.run_until_complete(coro)
|
||||
|
||||
# Trim to total limit
|
||||
messages = messages[:limit]
|
||||
return self._format_report(query, messages)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram search failed: {e}")
|
||||
return f"Telegram search failed: {str(e)}"
|
||||
Reference in New Issue
Block a user