Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions telegram-bot/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Telegram Bot
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
TELEGRAM_CHANNEL_ID=-1001234567890

# GitHub
GITHUB_TOKEN=your_github_token_here
GITHUB_REPO=SolFoundry/solfoundry
GITHUB_WEBHOOK_SECRET=your_webhook_secret_here

# SolFoundry API (optional - for leaderboard)
SOLFOUNDRY_API_URL=https://api.solfoundry.org

# Redis
REDIS_URL=redis://localhost:6379/0

# Bot mode: polling (dev) or webhook (prod)
BOT_MODE=polling
WEBHOOK_HOST=https://your-domain.com
WEBHOOK_PATH=/telegram/webhook

LOG_LEVEL=INFO
1 change: 1 addition & 0 deletions telegram-bot/bot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# SolFoundry Telegram Bot
184 changes: 184 additions & 0 deletions telegram-bot/bot/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
SolFoundry Telegram Bot β€” polling + FastAPI webhook server.

Usage:
python -m bot.app # polling mode
uvicorn bot.app:app --port 8080 # webhook mode
"""
import asyncio
import logging
import sys
from datetime import datetime, timezone
from typing import Optional

from fastapi import FastAPI, Request, Response
from telegram import Update
from telegram.ext import (
Application, CommandHandler, CallbackQueryHandler,
MessageHandler, filters,
)

from bot.config import config
from bot.formatters import format_bounty_notification
from bot.github_client import GitHubClient
from bot.handlers import (
cmd_start, cmd_help, cmd_leaderboard, cmd_filters,
cmd_filter, cmd_subscribe, cmd_unsubscribe, cmd_bounty,
handle_callback,
)
from bot.subscription_store import SubscriptionStore

logging.basicConfig(
level=getattr(logging, config.log_level, logging.INFO),
format="%(asctime)s %(levelname)s %(name)s β€” %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)

# ─────────────────────────────────────────────────────────────────────────────
# FastAPI webhook app
# ─────────────────────────────────────────────────────────────────────────────

app_fastapi = FastAPI(title="SolFoundry Telegram Bot Webhook")
telegram_app: Optional[Application] = None


@app_fastapi.post(config.webhook_path)
async def telegram_webhook(request: Request) -> Response:
global telegram_app
if telegram_app is None:
return Response(status_code=503, content="Bot not initialized")
try:
data = await request.json()
update = Update.de_json(data, telegram_app.bot)
await telegram_app.process_update(update)
except Exception:
logger.exception("Webhook update failed")
return Response(status_code=200)


@app_fastapi.get("/health")
async def health() -> dict:
return {"status": "ok", "mode": config.bot_mode}


# ─────────────────────────────────────────────────────────────────────────────
# Bounty polling loop
# ─────────────────────────────────────────────────────────────────────────────

async def poll_bounties(application: Application) -> None:
"""Background task: poll GitHub for new bounties and post to channel."""
store = SubscriptionStore()
github = GitHubClient()
channel_id = config.telegram_channel_id
logger.info("Bounty polling loop started (interval: 60s)")

while True:
try:
bounties = github.fetch_open_bounties()
logger.debug("Fetched %d open bounties", len(bounties))

for bounty in bounties:
if not bounty.is_open:
continue
if store.is_notified(bounty.number):
continue

text, keyboard = format_bounty_notification(bounty)
try:
await application.bot.send_message(
chat_id=channel_id,
text=text,
parse_mode="HTML",
reply_markup=keyboard,
disable_web_page_preview=True,
)
store.mark_notified(bounty.number)
logger.info("Notified channel about bounty #%d: %s", bounty.number, bounty.title)
except Exception as e:
logger.error("Failed to send bounty #%d: %s", bounty.number, e)

store.set_last_check(datetime.now(timezone.utc).isoformat())

except Exception:
logger.exception("Polling loop error")

await asyncio.sleep(60)


# ─────────────────────────────────────────────────────────────────────────────
# Bot setup
# ─────────────────────────────────────────────────────────────────────────────

def build_app() -> Application:
application = (
Application.builder()
.token(config.telegram_bot_token)
.read_timeout(30)
.write_timeout(30)
.build()
)
application.add_handler(CommandHandler("start", cmd_start))
application.add_handler(CommandHandler("help", cmd_help))
application.add_handler(CommandHandler("leaderboard", cmd_leaderboard))
application.add_handler(CommandHandler("filters", cmd_filters))
application.add_handler(CommandHandler("filter", cmd_filter))
application.add_handler(CommandHandler("subscribe", cmd_subscribe))
application.add_handler(CommandHandler("unsubscribe", cmd_unsubscribe))
application.add_handler(CommandHandler("bounty", cmd_bounty))
application.add_handler(CallbackQueryHandler(handle_callback))
return application


# ─────────────────────────────────────────────────────────────────────────────
# Entrypoints
# ─────────────────────────────────────────────────────────────────────────────

async def run_polling() -> None:
"""Polling mode β€” development."""
global telegram_app
telegram_app = build_app()
await telegram_app.initialize()
await telegram_app.start()
asyncio.create_task(poll_bounties(telegram_app))
await telegram_app.updater.start_polling(allowed_updates=Update.ALL_TYPES)
await telegram_app.updater.stop()
await telegram_app.stop()
await telegram_app.shutdown()


async def run_webhook() -> None:
"""Webhook mode β€” production."""
global telegram_app
telegram_app = build_app()
await telegram_app.initialize()
await telegram_app.start()
webhook_url = config.full_webhook_url
await telegram_app.bot.set_webhook(webhook_url)
logger.info("Webhook set to: %s", webhook_url)
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
pass
finally:
await telegram_app.stop()
await telegram_app.shutdown()


if __name__ == "__main__":
errors = config.validate()
if errors:
for e in errors:
print(f"CONFIG ERROR: {e}", file=sys.stderr)
sys.exit(1)

mode = config.bot_mode.lower()
if mode == "polling":
asyncio.run(run_polling())
elif mode == "webhook":
import uvicorn
uvicorn.run("bot.app:app", host="0.0.0.0", port=8080, reload=False)
else:
print(f"Unknown BOT_MODE: {mode}", file=sys.stderr)
sys.exit(1)
39 changes: 39 additions & 0 deletions telegram-bot/bot/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Bot configuration loaded from environment variables."""
from dataclasses import dataclass
from os import getenv
from typing import Optional


@dataclass
class BotConfig:
telegram_bot_token: str = getenv("TELEGRAM_BOT_TOKEN", "")
telegram_channel_id: str = getenv("TELEGRAM_CHANNEL_ID", "")

github_token: str = getenv("GITHUB_TOKEN", "")
github_repo: str = getenv("GITHUB_REPO", "SolFoundry/solfoundry")
github_webhook_secret: str = getenv("GITHUB_WEBHOOK_SECRET", "")

solfoundry_api_url: Optional[str] = getenv("SOLFOUNDRY_API_URL")

redis_url: str = getenv("REDIS_URL", "redis://localhost:6379/0")

bot_mode: str = getenv("BOT_MODE", "polling")
webhook_host: str = getenv("WEBHOOK_HOST", "")
webhook_path: str = getenv("WEBHOOK_PATH", "/telegram/webhook")

log_level: str = getenv("LOG_LEVEL", "INFO")

@property
def full_webhook_url(self) -> str:
return f"{self.webhook_host.rstrip('/')}{self.webhook_path}"

def validate(self) -> list[str]:
errors = []
if not self.telegram_bot_token:
errors.append("TELEGRAM_BOT_TOKEN is required")
if not self.telegram_channel_id:
errors.append("TELEGRAM_CHANNEL_ID is required")
return errors


config = BotConfig()
135 changes: 135 additions & 0 deletions telegram-bot/bot/formatters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Rich embed formatters for Telegram messages."""
import textwrap
from typing import Optional, Tuple

from telegram import InlineKeyboardButton, InlineKeyboardMarkup

from bot.models import Bounty, LeaderboardEntry

TIER_EMOJI = {"1": "πŸ”§", "2": "⚑", "3": "πŸš€", None: "πŸ“‹"}
TIER_LABEL = {
"1": "Tier 1 β€” Bug Fixes & Docs",
"2": "Tier 2 β€” Module & Integration",
"3": "Tier 3 β€” Major Feature",
None: "Bounty",
}


def format_bounty_notification(bounty: Bounty) -> Tuple[str, InlineKeyboardMarkup]:
tier = bounty.tier
tier_emoji = TIER_EMOJI.get(tier, "πŸ“‹")
tier_text = TIER_LABEL.get(tier, "Bounty")
body_preview = (bounty.body[:300].strip() + "...") if len(bounty.body) > 300 else bounty.body[:300].strip()
reward_text = f"πŸ’° {bounty.reward}" if bounty.reward else ""

meta_parts = []
if reward_text:
meta_parts.append(reward_text)
if bounty.bounty_type:
meta_parts.append(f"🏷 {bounty.bounty_type.capitalize()}")
meta_parts.append(f"πŸ• {bounty.created_at.strftime('%Y-%m-%d')}")

lines = [
f"{tier_emoji} <b>New {tier_text}</b>",
f"#{bounty.number} {bounty.title}",
"",
]
if body_preview:
lines.append(textwrap.fill(body_preview, width=80))
lines.append("")
lines.append(" | ".join(meta_parts))

keyboard = [
[
InlineKeyboardButton("πŸ” Details", url=bounty.html_url),
],
[
InlineKeyboardButton("βœ… I want this", callback_data=f"claim:{bounty.number}"),
],
]
return "\n".join(lines), InlineKeyboardMarkup(keyboard)


def format_bounty_detail(bounty: Bounty) -> Tuple[str, InlineKeyboardMarkup]:
tier = bounty.tier
tier_emoji = TIER_EMOJI.get(tier, "πŸ“‹")
tier_text = TIER_LABEL.get(tier, "Bounty")
reward_text = f"πŸ’° <b>{bounty.reward}</b>" if bounty.reward else "πŸ’° Reward TBD"

lines = [
f"{tier_emoji} <b>{tier_text}</b>",
f"#{bounty.number} β€” {bounty.title}",
"",
reward_text,
"",
]
if bounty.body:
wrapped = textwrap.fill(bounty.body[:1000], width=80)
lines.append(wrapped)
lines.append("")

meta = [
f"πŸ“Œ State: <b>{bounty.state.upper()}</b>",
f"πŸ• Posted: {bounty.created_at.strftime('%Y-%m-%d %H:%M UTC')}",
f"πŸ”— {bounty.html_url}",
]
if bounty.assignee:
meta.append(f"πŸ‘€ Assignee: @{bounty.assignee}")
lines.extend(meta)

keyboard = [
[InlineKeyboardButton("πŸ™ Open on GitHub", url=bounty.html_url)],
[InlineKeyboardButton("βœ… I want this bounty", callback_data=f"claim:{bounty.number}")],
]
return "\n".join(lines), InlineKeyboardMarkup(keyboard)


def format_leaderboard(entries: list[LeaderboardEntry]) -> str:
if not entries:
return "πŸ“Š No leaderboard data available yet."

header = "πŸ† <b>Top Contributors</b>\n\n```\n{'Rank':<6}{'Contributor':<25}{'Merged':<10}{'Est. Reward':<12}"
lines = [header, "─" * 55]

medal = {1: "πŸ₯‡", 2: "πŸ₯ˆ", 3: "πŸ₯‰"}
for entry in entries:
medal_str = medal.get(entry.rank, f"#{entry.rank}")
row = f"{medal_str:<6}{'@' + entry.username:<25}{entry.merged_count:<10}{entry.total_reward} FNDRY"
lines.append(row)
lines.append("```")
return "\n".join(lines)


def format_filter_status(user_filter: "UserFilter") -> str:
tier_str = ", ".join(user_filter.tiers) if user_filter.tiers else "All"
type_str = ", ".join(user_filter.types) if user_filter.types else "All types"
reward_str = f"β‰₯ {user_filter.min_reward} FNDRY" if user_filter.min_reward else "No minimum"

lines = [
"πŸ”” <b>Your Notification Filters</b>",
"",
f"<b>Tiers:</b> {tier_str}",
f"<b>Types:</b> {type_str}",
f"<b>Min reward:</b> {reward_str}",
"",
"Use /filter to update your settings.",
]
return "\n".join(lines)


FILTER_HELP = """\
πŸ”” <b>Set Bounty Notification Filters</b>

<code>/filter tier:1,2 type:feature,bug min:500 keyword:api</code>

<b>Options:</b>
β€’ <code>tier:N,N</code> β€” Filter by tier (1, 2, 3)
β€’ <code>type:t1,t2</code> β€” Type: feature, bug, docs, integration, security
β€’ <code>min:N</code> β€” Minimum FNDRY reward
β€’ <code>keyword:word</code> β€” Title must contain keyword

<b>Examples:</b>
<code>/filter tier:2,3 min:500</code> β†’ T2/T3 β‰₯500 FNDRY
<code>/filter type:bug</code> β†’ Bug bounties only
<code>/filter clear</code> β†’ Reset to defaults
"""
Loading