Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Telegram bot integration for VoIdea."""
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Telegram ↔ VoIdea account linking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
# In-memory store for link tokens (in production: Redis with TTL)
|
||||
_link_tokens: dict[str, int] = {} # token → user_id
|
||||
|
||||
|
||||
def create_link_token(user_id: int) -> str:
|
||||
"""Generate a one-time token for linking Telegram to VoIdea account."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
_link_tokens[token] = user_id
|
||||
return token
|
||||
|
||||
|
||||
def consume_link_token(token: str) -> int | None:
|
||||
"""Validate and consume a link token. Returns user_id or None."""
|
||||
user_id = _link_tokens.pop(token, None)
|
||||
return user_id
|
||||
|
||||
|
||||
def get_bot_link_url(base_url: str, token: str) -> str:
|
||||
"""Return the URL for the user to confirm account linking."""
|
||||
return f"{base_url}/bot/link?token={token}"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Telegram bot client — stub implementation.
|
||||
|
||||
Replace with python-telegram-bot or aiogram when TELEGRAM_BOT_TOKEN is set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("voidea.telegram.bot")
|
||||
|
||||
|
||||
class TelegramBotClient:
|
||||
"""Stub Telegram bot client.
|
||||
|
||||
All methods log instead of calling Telegram API.
|
||||
Replace with real implementation when TELEGRAM_BOT_TOKEN is configured.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str | None = None):
|
||||
self.token = token
|
||||
self.available = bool(token)
|
||||
if self.available:
|
||||
logger.info("Telegram bot client initialized with token")
|
||||
else:
|
||||
logger.warning("TELEGRAM_BOT_TOKEN not set — bot is in stub mode")
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: int | str,
|
||||
text: str,
|
||||
parse_mode: str = "HTML",
|
||||
reply_markup: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Send a message to a Telegram chat."""
|
||||
if not self.available:
|
||||
logger.info("[STUB] send_message to %s: %s", chat_id, text[:80])
|
||||
return None
|
||||
# TODO: real Telegram API call
|
||||
logger.info("send_message to %s: %s", chat_id, text[:80])
|
||||
return {"ok": True}
|
||||
|
||||
async def send_voice(
|
||||
self,
|
||||
chat_id: int | str,
|
||||
voice_url: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Send a voice message."""
|
||||
if not self.available:
|
||||
logger.info("[STUB] send_voice to %s: %s", chat_id, voice_url[:80])
|
||||
return None
|
||||
logger.info("send_voice to %s: %s", chat_id, voice_url[:80])
|
||||
return {"ok": True}
|
||||
|
||||
async def set_webhook(self, url: str) -> bool:
|
||||
"""Set the Telegram bot webhook URL."""
|
||||
if not self.available:
|
||||
logger.info("[STUB] set_webhook: %s", url)
|
||||
return False
|
||||
logger.info("set_webhook: %s", url)
|
||||
return True
|
||||
|
||||
async def delete_webhook(self) -> bool:
|
||||
"""Remove the webhook."""
|
||||
if not self.available:
|
||||
logger.info("[STUB] delete_webhook")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def set_commands(self, commands: list[dict[str, str]]) -> bool:
|
||||
"""Register bot commands with Telegram."""
|
||||
if not self.available:
|
||||
logger.info("[STUB] set_commands: %s", [c["command"] for c in commands])
|
||||
return False
|
||||
logger.info("set_commands: %d commands", len(commands))
|
||||
return True
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Command decorator for auto-registration of bot commands.
|
||||
|
||||
Usage:
|
||||
@bot_command(name="/idea", description="Create a new idea", requires_auth=True)
|
||||
async def cmd_idea(update, context): ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotCommandDef:
|
||||
name: str
|
||||
description: str
|
||||
requires_auth: bool = False
|
||||
handler: Callable | None = None
|
||||
|
||||
|
||||
_registry: list[BotCommandDef] = []
|
||||
|
||||
|
||||
def bot_command(
|
||||
name: str,
|
||||
description: str,
|
||||
requires_auth: bool = False,
|
||||
) -> Callable:
|
||||
"""Decorator that registers a function as a bot command handler."""
|
||||
|
||||
def wrapper(func: Callable) -> Callable:
|
||||
_registry.append(BotCommandDef(
|
||||
name=name,
|
||||
description=description,
|
||||
requires_auth=requires_auth,
|
||||
handler=func,
|
||||
))
|
||||
return func
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_registered_commands() -> list[BotCommandDef]:
|
||||
"""Return all registered command definitions."""
|
||||
return list(_registry)
|
||||
|
||||
|
||||
def get_enabled_commands(enabled_names: set[str]) -> list[BotCommandDef]:
|
||||
"""Return only commands that are in the enabled set."""
|
||||
return [cmd for cmd in _registry if cmd.name in enabled_names]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Telegram bot command handlers.
|
||||
|
||||
Registered via @bot_command decorator for auto-discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.integrations.telegram.auth import create_link_token, get_bot_link_url
|
||||
from app.integrations.telegram.decorators import bot_command
|
||||
from app.integrations.telegram.client import TelegramBotClient
|
||||
|
||||
logger = logging.getLogger("voidea.telegram.handlers")
|
||||
|
||||
_bot_client: TelegramBotClient | None = None
|
||||
|
||||
|
||||
def set_bot_client(client: TelegramBotClient) -> None:
|
||||
global _bot_client
|
||||
_bot_client = client
|
||||
|
||||
|
||||
@bot_command(name="/start", description="Welcome message and getting started")
|
||||
async def cmd_start(update: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
user = update.get("message", {}).get("from", {})
|
||||
first_name = user.get("first_name", "User")
|
||||
return (
|
||||
f"Hello, {first_name}! 👋\n\n"
|
||||
"I'm VoIdeaAI bot. Here's what I can do:\n"
|
||||
"/link — Link your Telegram to VoIdea account\n"
|
||||
"/idea — Create a new idea (requires linking)\n"
|
||||
"/help — Show available commands"
|
||||
)
|
||||
|
||||
|
||||
@bot_command(name="/help", description="Show available commands")
|
||||
async def cmd_help(update: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
return (
|
||||
"Available commands:\n"
|
||||
"/start — Welcome message\n"
|
||||
"/link — Link Telegram to your VoIdea account\n"
|
||||
"/idea — Create a new idea from text\n"
|
||||
"/help — This message\n\n"
|
||||
"To get started, use /link to connect your account."
|
||||
)
|
||||
|
||||
|
||||
@bot_command(name="/link", description="Link Telegram to your VoIdea account")
|
||||
async def cmd_link(update: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
user_id = update.get("message", {}).get("from", {}).get("id")
|
||||
if not user_id:
|
||||
return "Error: could not identify you."
|
||||
|
||||
token = create_link_token(int(user_id))
|
||||
base_url = context.get("base_url", "https://voidea.ai")
|
||||
link_url = get_bot_link_url(base_url, token)
|
||||
return (
|
||||
"To link your Telegram account to VoIdea:\n\n"
|
||||
f"1. Open this link: {link_url}\n"
|
||||
"2. Confirm the pairing\n"
|
||||
"3. Come back and use /idea to create ideas\n\n"
|
||||
"The link expires in 30 minutes."
|
||||
)
|
||||
|
||||
|
||||
@bot_command(name="/idea", description="Create a new idea (requires linking)")
|
||||
async def cmd_idea(update: dict[str, Any], context: dict[str, Any]) -> str:
|
||||
text = update.get("message", {}).get("text", "")
|
||||
args = text.replace("/idea", "", 1).strip()
|
||||
|
||||
if not args:
|
||||
return (
|
||||
"Usage: /idea <your idea text>\n\n"
|
||||
"Example: /idea A mobile app for tracking daily water intake\n\n"
|
||||
"You need to link your account first with /link."
|
||||
)
|
||||
|
||||
return (
|
||||
f"Idea received: \"{args[:200]}\"\n\n"
|
||||
"To save it permanently, link your account with /link first.\n"
|
||||
"After linking, your ideas will appear in your VoIdea dashboard."
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Telegram bot command sync service.
|
||||
|
||||
Synchronises @bot_command-decorated handlers with BotCommand DB table
|
||||
and Telegram Bot API (set_my_commands) on boot or manual trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.integrations.telegram.client import TelegramBotClient
|
||||
from app.integrations.telegram.decorators import get_registered_commands
|
||||
from app.models.bot_command import BotCommand
|
||||
|
||||
logger = logging.getLogger("voidea.telegram.sync")
|
||||
|
||||
|
||||
class TelegramBotSyncService:
|
||||
"""Syncs registered bot commands to DB and Telegram API."""
|
||||
|
||||
def __init__(self, db: AsyncSession, bot_client: TelegramBotClient | None = None):
|
||||
self.db = db
|
||||
self.bot_client = bot_client
|
||||
|
||||
async def sync_to_db(self) -> list[BotCommand]:
|
||||
"""Ensure all @bot_command handlers have matching BotCommand records."""
|
||||
registered = get_registered_commands()
|
||||
result = await self.db.execute(select(BotCommand))
|
||||
existing = {cmd.name: cmd for cmd in result.scalars().all()}
|
||||
|
||||
synced: list[BotCommand] = []
|
||||
|
||||
for reg in registered:
|
||||
if reg.name in existing:
|
||||
cmd = existing[reg.name]
|
||||
if cmd.description != reg.description or cmd.requires_auth != reg.requires_auth:
|
||||
cmd.description = reg.description
|
||||
cmd.requires_auth = reg.requires_auth
|
||||
synced.append(cmd)
|
||||
else:
|
||||
cmd = BotCommand(
|
||||
name=reg.name,
|
||||
description=reg.description,
|
||||
enabled=True,
|
||||
requires_auth=reg.requires_auth,
|
||||
)
|
||||
self.db.add(cmd)
|
||||
synced.append(cmd)
|
||||
|
||||
await self.db.commit()
|
||||
for cmd in synced:
|
||||
await self.db.refresh(cmd)
|
||||
|
||||
logger.info("Synced %d bot commands to DB", len(synced))
|
||||
return synced
|
||||
|
||||
async def sync_to_telegram(self) -> bool:
|
||||
"""Push enabled commands to Telegram Bot API via set_my_commands."""
|
||||
if not self.bot_client or not self.bot_client.available:
|
||||
logger.info("Telegram client not available — skipping set_my_commands")
|
||||
return False
|
||||
|
||||
result = await self.db.execute(
|
||||
select(BotCommand).where(BotCommand.enabled == True)
|
||||
)
|
||||
enabled = result.scalars().all()
|
||||
|
||||
commands = [
|
||||
{"command": cmd.name.lstrip("/"), "description": cmd.description}
|
||||
for cmd in enabled
|
||||
]
|
||||
|
||||
ok = await self.bot_client.set_commands(commands)
|
||||
if ok:
|
||||
logger.info("Pushed %d commands to Telegram", len(commands))
|
||||
else:
|
||||
logger.warning("Failed to push commands to Telegram")
|
||||
return ok
|
||||
|
||||
async def full_sync(self) -> dict[str, Any]:
|
||||
"""Run full sync: DB → Telegram."""
|
||||
synced = await self.sync_to_db()
|
||||
telegram_ok = await self.sync_to_telegram()
|
||||
return {
|
||||
"synced_count": len(synced),
|
||||
"telegram_sync": telegram_ok,
|
||||
}
|
||||
Reference in New Issue
Block a user