Files
RIT-nexus/telegram-bot/telegram_bot.py

363 lines
14 KiB
Python

import os
import json
import sqlite3
import threading
import time
import logging
import requests
import asyncio
import discord
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import uvicorn
# Configure Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.StreamHandler()
]
)
# Load env variables from .env if present
env_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(env_path):
with open(env_path, "r", encoding="utf-8") as env_file:
for line in env_file:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, val = line.split("=", 1)
os.environ[key.strip()] = val.strip()
# Load Configuration
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
if not os.path.exists(CONFIG_PATH):
default_config = {
"telegram_bot_token": "",
"helper_chat_ids": [],
"discord_bot_token": "",
"discord_helper_user_ids": [],
"spring_backend_url": "http://localhost:8080"
}
with open(CONFIG_PATH, "w") as f:
json.dump(default_config, f, indent=2)
return default_config
with open(CONFIG_PATH, "r") as f:
return json.load(f)
config = load_config()
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN") or config.get("telegram_bot_token")
DISCORD_TOKEN = os.environ.get("DISCORD_BOT_TOKEN") or config.get("discord_bot_token")
BACKEND_URL = os.environ.get("SPRING_BACKEND_URL") or config.get("spring_backend_url", "http://localhost:8080")
# Database Setup
DB_PATH = os.path.join(os.path.dirname(__file__), "bot_mappings.db")
def init_db():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS question_mappings (
chat_id INTEGER,
message_id INTEGER,
question_id INTEGER,
PRIMARY KEY (chat_id, message_id)
)
""")
conn.commit()
conn.close()
init_db()
def save_mapping(chat_id: int, message_id: int, question_id: int):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"INSERT OR REPLACE INTO question_mappings (chat_id, message_id, question_id) VALUES (?, ?, ?)",
(chat_id, message_id, question_id)
)
conn.commit()
conn.close()
def get_question_id(chat_id: int, message_id: int) -> int:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT question_id FROM question_mappings WHERE chat_id = ? AND message_id = ?",
(chat_id, message_id)
)
row = cursor.fetchone()
conn.close()
return row[0] if row else None
# Telegram API Helpers
def send_telegram_message(chat_id: int, text: str, reply_to_message_id: int = None) -> dict:
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": "Markdown",
"reply_markup": {"force_reply": True, "selective": True}
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
try:
response = requests.post(url, json=payload, timeout=10)
return response.json()
except Exception as e:
logging.error(f"Error sending Telegram message to {chat_id}: {e}")
return {}
# Background Long Polling for Telegram Updates
def telegram_polling_thread():
logging.info("Starting Telegram long polling thread...")
offset = 0
while True:
# Re-load config dynamic updates
current_config = load_config()
helpers = current_config.get("helper_chat_ids", [])
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url")
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN") or current_config.get("telegram_bot_token")
url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
params = {"offset": offset, "timeout": 20}
try:
response = requests.get(url, params=params, timeout=25)
data = response.json()
if not data.get("ok"):
logging.error(f"Telegram API getUpdates error: {data}")
time.sleep(5)
continue
updates = data.get("result", [])
for update in updates:
offset = update["update_id"] + 1
message = update.get("message")
if not message:
continue
chat_id = message["chat"]["id"]
text = message.get("text", "").strip()
# Help helper find their Chat ID
if text == "/start":
welcome_text = (
f"👋 *Welcome to RIT Freshers Hub Intermediary Bot!*\n\n"
f"To configure this helper, register this Chat ID in the `config.json` file:\n"
f"`{chat_id}`\n\n"
f"Once registered, you will receive new student questions here and can reply directly to them."
)
send_telegram_message(chat_id, welcome_text)
continue
# Process reply messages
reply_to = message.get("reply_to_message")
if reply_to:
# Verify if helper is authorized
if chat_id not in helpers:
logging.warning(f"Unauthorized message from chat ID {chat_id}")
send_telegram_message(chat_id, "⚠️ You are not registered as an authorized helper in config.json.")
continue
original_message_id = reply_to["message_id"]
question_id = get_question_id(chat_id, original_message_id)
if question_id:
# Extract author name
first_name = message["from"].get("first_name", "")
last_name = message["from"].get("last_name", "")
author_name = f"{first_name} {last_name}".strip() or "Senior Helper"
logging.info(f"Submitting answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": text,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code == 200 or res.status_code == 201:
send_telegram_message(chat_id, "✅ *Answer posted successfully to the Q&A board!*", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, f"❌ *Failed to post answer to backend.* (Status: {res.status_code})\nResponse: {res.text[:100]}", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
send_telegram_message(chat_id, f"❌ *Connection error to backend.* ({e})", reply_to_message_id=message["message_id"])
else:
send_telegram_message(chat_id, "❓ This message does not correspond to any active question or the mapping has expired.", reply_to_message_id=message["message_id"])
except Exception as e:
logging.error(f"Error in long polling loop: {e}")
time.sleep(5)
# Start Polling Thread
polling_thread = threading.Thread(target=telegram_polling_thread, daemon=True)
polling_thread.start()
# Discord Bot Client Setup
intents = discord.Intents.default()
intents.messages = True
intents.message_content = True
discord_client = discord.Client(intents=intents)
discord_loop = None
@discord_client.event
async def on_ready():
logging.info(f"Discord Bot logged in as {discord_client.user}!")
@discord_client.event
async def on_message(message):
if message.author == discord_client.user:
return
# Process DMs
if isinstance(message.channel, discord.DMChannel):
current_config = load_config()
discord_helpers = current_config.get("discord_helper_user_ids", [])
author_id = message.author.id
# Verify helper is authorized
if author_id not in [int(x) for x in discord_helpers if str(x).isdigit()]:
logging.warning(f"Unauthorized Discord message from user ID {author_id}")
await message.channel.send("⚠️ You are not registered as an authorized helper in config.json.")
return
# Check if helper is replying to a specific question message
if message.reference and message.reference.message_id:
original_message_id = message.reference.message_id
question_id = get_question_id(author_id, original_message_id)
if question_id:
author_name = message.author.name
logging.info(f"Submitting Discord answer for question {question_id} by helper '{author_name}'")
# Post answer to Spring Boot backend
backend_url = os.environ.get("SPRING_BACKEND_URL") or current_config.get("spring_backend_url")
backend_endpoint = f"{backend_url}/api/questions/{question_id}/answers"
answer_payload = {
"body": message.content,
"author": author_name
}
try:
res = requests.post(backend_endpoint, json=answer_payload, timeout=10)
if res.status_code in [200, 201]:
await message.reply("✅ *Answer posted successfully to the Q&A board!*")
else:
await message.reply(f"❌ *Failed to post answer to backend.* (Status: {res.status_code})")
except Exception as e:
logging.error(f"Error calling backend endpoint {backend_endpoint}: {e}")
await message.reply(f"❌ *Connection error to backend.* ({e})")
else:
await message.reply("❓ This message does not correspond to any active question or the mapping has expired.")
else:
await message.reply("💬 Please use the **Reply** feature on the question message to answer it so I know which question you're answering!")
async def broadcast_discord_question(question_id: int, title: str, body: str, author: str, user_ids: list):
formatted_msg = (
f"❓ **New Student Question!**\n\n"
f"👤 **Author:** {author}\n"
f"📌 **Topic:** {title}\n"
f"📝 **Details:** {body}\n\n"
f"💬 **Reply directly to this message to submit your answer.**"
)
for user_id_val in user_ids:
try:
user_id = int(user_id_val)
user = await discord_client.fetch_user(user_id)
if user:
msg = await user.send(formatted_msg)
save_mapping(user_id, msg.id, question_id)
logging.info(f"Sent Discord DM to helper {user_id}")
except Exception as e:
logging.error(f"Failed to send Discord DM to helper {user_id_val}: {e}")
def run_discord_bot():
global discord_loop
logging.info("Starting Discord bot thread...")
discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(discord_loop)
try:
discord_loop.run_until_complete(discord_client.start(DISCORD_TOKEN))
except Exception as e:
logging.error(f"Discord Bot failed to run: {e}")
# Start Discord Bot Thread
discord_thread = threading.Thread(target=run_discord_bot, daemon=True)
discord_thread.start()
# FastAPI Web Server Setup
app = FastAPI(title="RIT Telegram & Discord Intermediary Bot HTTP Server")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class QuestionPayload(BaseModel):
question_id: int
title: str
body: str
author: str
@app.post("/send_question")
def send_question(payload: QuestionPayload):
current_config = load_config()
# 1. Telegram Broadcast
telegram_helpers = current_config.get("helper_chat_ids", [])
telegram_sent = 0
if telegram_helpers:
logging.info(f"Broadcasting question {payload.question_id} to {len(telegram_helpers)} Telegram helpers.")
formatted_msg = (
f"❓ *New Student Question!*\n\n"
f"👤 *Author:* {payload.author}\n"
f"📌 *Topic:* {payload.title}\n"
f"📝 *Details:* {payload.body}\n\n"
f"💬 *Reply to this message directly to submit your answer.*"
)
for chat_id in telegram_helpers:
res = send_telegram_message(chat_id, formatted_msg)
if res.get("ok"):
message_id = res["result"]["message_id"]
save_mapping(chat_id, message_id, payload.question_id)
telegram_sent += 1
# 2. Discord Broadcast
discord_helpers = current_config.get("discord_helper_user_ids", [])
discord_sent = 0
if discord_helpers and DISCORD_TOKEN:
logging.info(f"Broadcasting question {payload.question_id} to {len(discord_helpers)} Discord helpers.")
if discord_loop:
try:
asyncio.run_coroutine_threadsafe(
broadcast_discord_question(payload.question_id, payload.title, payload.body, payload.author, discord_helpers),
discord_loop
)
discord_sent = len(discord_helpers)
except Exception as e:
logging.error(f"Error scheduling Discord broadcast: {e}")
else:
logging.warning("Discord loop not running. Skipping Discord broadcast.")
return {
"status": "success",
"telegram_delivered_to": telegram_sent,
"discord_queued_for": discord_sent
}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8082)