Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Telegram bot integration for VoIdea."""
+27
View File
@@ -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}"
+77
View File
@@ -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
+52
View File
@@ -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]
+84
View File
@@ -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."
)
+91
View File
@@ -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,
}