Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 58s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m38s
Docker Public / Build + push Public (push) Successful in 49s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Public / Deploy Public on stage (push) Successful in 9s
Docker Web / Deploy Web on stage (push) Successful in 11s
- Заменил все хардкоды URL в src/** и deploy/: food-market.zat.kz → food-market.kz (публичный сайт) app.food-market.zat.kz → admin.food-market.kz (админ-API + SPA) - public/SignupForm и Header: дефолт PUBLIC_APP_URL теперь https://admin.food-market.kz (раньше указывал на сам публичный домен, что было багом — фронт стучался не туда после переезда зон). - public/Dockerfile ARG PUBLIC_APP_URL → admin.food-market.kz. - API appsettings.json CORS — оставил только два прода-origin (localhost для dev живёт там же). - Program.cs: добавил opts.SetIssuer(uri) если задан OpenIddict:Issuer в конфиге — иначе iss вычислялся из текущего HTTP-запроса и ломался при nginx-прокси без X-Forwarded-Proto. - docker-compose стейджа: env OpenIddict__Issuer=https://admin.food-market.kz/ + Cors__AllowedOrigins[0,1]. Nginx (на сервере, не в репе): - /etc/nginx/conf.d/food-market.kz.conf, admin.food-market.kz.conf — новые конфиги с certbot-выданными сертификатами на оба домена (LetsEncrypt --webroot, действителен до 2026-07-29). - Старые food-market.zat.kz / app.food-market.zat.kz переведены в 301-редирект на новые домены (HTTP+HTTPS), серты zat.kz пока оставлены чтобы handshake шёл нормально.
181 lines
6.7 KiB
Python
181 lines
6.7 KiB
Python
"""Telegram bridge: webhook receiver, paste-to-tmux only.
|
|
|
|
Refactored from the original 2-second polling loop to a fully event-driven
|
|
design: outgoing assistant messages are now pushed by the Claude Code Stop
|
|
hook (/usr/local/bin/cc-tg-notify-stop). This bridge only handles the
|
|
inbound side — Telegram → tmux paste.
|
|
|
|
Config (/etc/food-market/telegram.env or env vars):
|
|
TELEGRAM_BOT_TOKEN — bot token (required)
|
|
TELEGRAM_CHAT_ID — single whitelisted chat id (required)
|
|
TELEGRAM_WEBHOOK_URL — public URL Telegram should POST to
|
|
(default: https://food-market.kz/tg-webhook)
|
|
TELEGRAM_WEBHOOK_SECRET — random secret; bridge validates the
|
|
X-Telegram-Bot-Api-Secret-Token header on every
|
|
incoming request and Telegram sends it back so
|
|
third parties can't forge updates
|
|
TMUX_SESSION — tmux session to paste into (default: claude)
|
|
WEBHOOK_LISTEN_HOST — local bind host (default: 127.0.0.1)
|
|
WEBHOOK_LISTEN_PORT — local bind port (default: 8765)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from telegram import Update
|
|
from telegram.ext import (
|
|
ApplicationBuilder,
|
|
CommandHandler,
|
|
ContextTypes,
|
|
MessageHandler,
|
|
filters,
|
|
)
|
|
|
|
ENV_FILE = Path("/etc/food-market/telegram.env")
|
|
TMUX_SESSION = os.environ.get("TMUX_SESSION", "claude")
|
|
LISTEN_HOST = os.environ.get("WEBHOOK_LISTEN_HOST", "127.0.0.1")
|
|
LISTEN_PORT = int(os.environ.get("WEBHOOK_LISTEN_PORT", "8765"))
|
|
WEBHOOK_PATH = "/tg-webhook"
|
|
|
|
logger = logging.getLogger("bridge")
|
|
|
|
|
|
def load_env(path: Path) -> dict[str, str]:
|
|
out: dict[str, str] = {}
|
|
if not path.exists():
|
|
return out
|
|
for raw in path.read_text().splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
out[key.strip()] = value.strip().strip('"').strip("'")
|
|
return out
|
|
|
|
|
|
async def tmux_send_text(session: str, text: str) -> None:
|
|
"""Pastes one Telegram message verbatim into the tmux session, then Enter.
|
|
|
|
Uses `send-keys -l` for literal paste — no key-binding interpretation,
|
|
works for arbitrary text including unicode and special chars.
|
|
"""
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"tmux", "send-keys", "-t", session, "-l", text,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"tmux send-keys -l failed: {stderr.decode().strip()}")
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"tmux", "send-keys", "-t", session, "Enter",
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
|
)
|
|
_, stderr = await proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise RuntimeError(f"tmux send-keys Enter failed: {stderr.decode().strip()}")
|
|
|
|
|
|
def _allowed(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
|
|
chat_id = context.application.bot_data["chat_id"]
|
|
return update.effective_chat is not None and update.effective_chat.id == chat_id
|
|
|
|
|
|
async def cmd_ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
if not _allowed(update, context):
|
|
return
|
|
await update.message.reply_text(f"pong — webhook mode, tmux session «{TMUX_SESSION}»")
|
|
|
|
|
|
QUIET_FLAG = "/tmp/cc-tg-quiet"
|
|
|
|
|
|
async def cmd_quiet(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Заткнуть PreToolUse прогресс-ленту (Stop hook продолжает работать)."""
|
|
if not _allowed(update, context):
|
|
return
|
|
try:
|
|
open(QUIET_FLAG, "w").close()
|
|
await update.message.reply_text("🔕 Прогресс-лента отключена. Включить — /loud")
|
|
except Exception as exc: # noqa: BLE001
|
|
await update.message.reply_text(f"⚠️ {exc}")
|
|
|
|
|
|
async def cmd_loud(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
"""Включить обратно PreToolUse прогресс-ленту."""
|
|
if not _allowed(update, context):
|
|
return
|
|
try:
|
|
os.unlink(QUIET_FLAG)
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as exc: # noqa: BLE001
|
|
await update.message.reply_text(f"⚠️ {exc}")
|
|
return
|
|
await update.message.reply_text("🔔 Прогресс-лента включена.")
|
|
|
|
|
|
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
if not _allowed(update, context):
|
|
return
|
|
text = (update.message.text or "").strip() if update.message else ""
|
|
if not text:
|
|
return
|
|
logger.info("inbound message: %d chars", len(text))
|
|
try:
|
|
await tmux_send_text(TMUX_SESSION, text)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("paste to tmux failed: %s", exc)
|
|
await update.message.reply_text(f"⚠️ tmux error: {exc}")
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
env = {**os.environ, **load_env(ENV_FILE)}
|
|
token = env.get("TELEGRAM_BOT_TOKEN", "").strip()
|
|
chat_id_raw = env.get("TELEGRAM_CHAT_ID", "").strip()
|
|
secret = env.get("TELEGRAM_WEBHOOK_SECRET", "").strip()
|
|
webhook_url = env.get("TELEGRAM_WEBHOOK_URL", "https://food-market.kz/tg-webhook").strip()
|
|
if not token or not chat_id_raw:
|
|
print("ERROR: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr)
|
|
return 78
|
|
try:
|
|
chat_id = int(chat_id_raw)
|
|
except ValueError:
|
|
print(f"ERROR: TELEGRAM_CHAT_ID must be int, got {chat_id_raw!r}", file=sys.stderr)
|
|
return 78
|
|
if not secret:
|
|
logger.warning("TELEGRAM_WEBHOOK_SECRET is empty — webhook is unauthenticated")
|
|
|
|
application = ApplicationBuilder().token(token).build()
|
|
application.bot_data["chat_id"] = chat_id
|
|
application.add_handler(CommandHandler("ping", cmd_ping))
|
|
application.add_handler(CommandHandler("quiet", cmd_quiet))
|
|
application.add_handler(CommandHandler("loud", cmd_loud))
|
|
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
|
|
|
logger.info("starting webhook listener on %s:%d → %s", LISTEN_HOST, LISTEN_PORT, webhook_url)
|
|
application.run_webhook(
|
|
listen=LISTEN_HOST,
|
|
port=LISTEN_PORT,
|
|
url_path=WEBHOOK_PATH.lstrip("/"),
|
|
webhook_url=webhook_url,
|
|
secret_token=secret or None,
|
|
allowed_updates=Update.ALL_TYPES,
|
|
drop_pending_updates=False,
|
|
stop_signals=None,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|