Add Telegram notifications
This commit is contained in:
@@ -73,6 +73,15 @@ class AdminUser(Base):
|
|||||||
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
role: Mapped[str] = mapped_column(String, nullable=False, default="admin") # 'admin' | 'manager'
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfig(Base):
|
||||||
|
__tablename__ = "telegram_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
bot_token_enc: Mapped[str] = mapped_column(String, nullable=False) # Fernet-encrypted
|
||||||
|
chat_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from routers.gates import router as gates_router
|
|||||||
from routers.credentials import router as credentials_router
|
from routers.credentials import router as credentials_router
|
||||||
from routers.admins import router as admins_router
|
from routers.admins import router as admins_router
|
||||||
from routers.stats import router as stats_router
|
from routers.stats import router as stats_router
|
||||||
|
from routers.telegram import router as telegram_router
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -98,6 +99,7 @@ app.include_router(gates_router)
|
|||||||
app.include_router(credentials_router)
|
app.include_router(credentials_router)
|
||||||
app.include_router(admins_router)
|
app.include_router(admins_router)
|
||||||
app.include_router(stats_router)
|
app.include_router(stats_router)
|
||||||
|
app.include_router(telegram_router)
|
||||||
|
|
||||||
# ── Static / frontend ─────────────────────────────────────────────────────────
|
# ── Static / frontend ─────────────────────────────────────────────────────────
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -7,15 +8,33 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from core.auth import decrypt_secret
|
from core.auth import decrypt_secret
|
||||||
from core.config import utcnow
|
from core.config import utcnow
|
||||||
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, get_db
|
from core.database import ApiCredential, GateAccessLog, GateDB, Keypass, TelegramConfig, get_db
|
||||||
from core.dependencies import require_admin, require_manager, require_keypass
|
from core.dependencies import require_admin, require_manager, require_keypass
|
||||||
from core.schemas import GateCreate, GatePublicResponse, GateResponse
|
from core.schemas import GateCreate, GatePublicResponse, GateResponse
|
||||||
from services.gates import call_open_gate
|
from services.gates import call_open_gate
|
||||||
|
from services.telegram import send_gate_notification
|
||||||
|
|
||||||
router = APIRouter(tags=["gates"])
|
router = APIRouter(tags=["gates"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify(db: Session, gate_name: str, opened_by: str, ip: str | None) -> None:
|
||||||
|
"""Fire a Telegram notification in a background thread if configured and enabled."""
|
||||||
|
try:
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg or not cfg.enabled:
|
||||||
|
return
|
||||||
|
token = decrypt_secret(cfg.bot_token_enc)
|
||||||
|
chat_id = cfg.chat_id
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
threading.Thread(
|
||||||
|
target=send_gate_notification,
|
||||||
|
args=(token, chat_id, gate_name, opened_by, ip),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
# ── Admin: gate CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/admin/gates", response_model=list[GateResponse])
|
@router.get("/api/admin/gates", response_model=list[GateResponse])
|
||||||
@@ -120,6 +139,7 @@ async def admin_open_gate(
|
|||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
|
logger.info("Gate opened by admin: gate_id=%d gate=%r caller=%r ip=%r", gate_db.id, gate_db.name, caller["sub"], ip)
|
||||||
|
_notify(db, gate_db.name, f"[{caller['sub']}]", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|
||||||
|
|
||||||
@@ -185,5 +205,6 @@ async def open_gate(
|
|||||||
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
|
logger.error("Gate open failed: gate_id=%d keypass_id=%d error=%r", gate_db.id, _kp.id, error_msg)
|
||||||
raise HTTPException(502, error_msg or "Gate operation failed")
|
raise HTTPException(502, error_msg or "Gate operation failed")
|
||||||
|
|
||||||
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass_id=%d ip=%r", gate_db.id, gate_db.name, _kp.id, ip)
|
logger.info("Gate opened by keypass: gate_id=%d gate=%r keypass=%r ip=%r", gate_db.id, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
|
||||||
|
_notify(db, gate_db.name, f"{_kp.description} ({_kp.code})", ip)
|
||||||
return {"success": True, "gate": gate_db.name}
|
return {"success": True, "gate": gate_db.name}
|
||||||
|
|||||||
75
src/routers/telegram.py
Normal file
75
src/routers/telegram.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.auth import decrypt_secret, encrypt_secret
|
||||||
|
from core.database import TelegramConfig, get_db
|
||||||
|
from core.dependencies import require_admin
|
||||||
|
from services.telegram import send_test_message
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/telegram", tags=["admin-telegram"])
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigRead(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
chat_id: str
|
||||||
|
configured: bool # True when a bot token has been saved
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramConfigUpsert(BaseModel):
|
||||||
|
bot_token: Optional[str] = None # None = keep existing token
|
||||||
|
chat_id: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TelegramConfigRead)
|
||||||
|
async def get_telegram_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
return TelegramConfigRead(enabled=False, chat_id="", configured=False)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=TelegramConfigRead)
|
||||||
|
async def upsert_telegram_config(
|
||||||
|
req: TelegramConfigUpsert,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if cfg:
|
||||||
|
if req.bot_token:
|
||||||
|
cfg.bot_token_enc = encrypt_secret(req.bot_token)
|
||||||
|
cfg.chat_id = req.chat_id
|
||||||
|
cfg.enabled = req.enabled
|
||||||
|
else:
|
||||||
|
if not req.bot_token:
|
||||||
|
raise HTTPException(422, "bot_token is required for initial setup")
|
||||||
|
cfg = TelegramConfig(
|
||||||
|
bot_token_enc=encrypt_secret(req.bot_token),
|
||||||
|
chat_id=req.chat_id,
|
||||||
|
enabled=req.enabled,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return TelegramConfigRead(enabled=cfg.enabled, chat_id=cfg.chat_id, configured=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_telegram(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
cfg: Optional[TelegramConfig] = db.query(TelegramConfig).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(422, "Telegram is not configured yet")
|
||||||
|
ok, err = send_test_message(decrypt_secret(cfg.bot_token_enc), cfg.chat_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(502, err or "Telegram API error")
|
||||||
|
return {"success": True}
|
||||||
52
src/services/telegram.py
Normal file
52
src/services/telegram.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TELEGRAM_API = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
def send_gate_notification(
|
||||||
|
bot_token: str,
|
||||||
|
chat_id: str,
|
||||||
|
gate_name: str,
|
||||||
|
opened_by: str,
|
||||||
|
ip: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Send a Telegram message. Runs synchronously; call from a background thread or task."""
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%d/%m/%Y %H:%M UTC")
|
||||||
|
text = (
|
||||||
|
f"🔓 *Gate opened*\n"
|
||||||
|
f"*Gate:* {gate_name}\n"
|
||||||
|
f"*By:* `{opened_by}`\n"
|
||||||
|
f"*Time:* {ts}"
|
||||||
|
)
|
||||||
|
if ip:
|
||||||
|
text += f"\n*IP:* `{ip}`"
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
logger.warning("Telegram notification failed: %s", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Telegram notification error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_message(bot_token: str, chat_id: str) -> tuple[bool, str]:
|
||||||
|
"""Send a test message. Returns (success, error_or_empty)."""
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
_TELEGRAM_API.format(token=bot_token),
|
||||||
|
json={"chat_id": chat_id, "text": "✅ Lagomare Gates — Telegram notifications are working!"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if resp.ok:
|
||||||
|
return True, ""
|
||||||
|
return False, resp.json().get("description", resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
return False, str(exc)
|
||||||
@@ -119,6 +119,7 @@
|
|||||||
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button>
|
<button class="tab-btn admin-only" data-tab="credentials">AVConnect Credentials</button>
|
||||||
<button class="tab-btn" data-tab="stats">Statistics</button>
|
<button class="tab-btn" data-tab="stats">Statistics</button>
|
||||||
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
<button class="tab-btn admin-only" data-tab="admins">Admins</button>
|
||||||
|
<button class="tab-btn admin-only" data-tab="telegram">Notifications</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -286,6 +287,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Telegram / Notifications pane ────────────────────────────────── -->
|
||||||
|
<div id="tab-telegram" class="tab-pane">
|
||||||
|
<h3 style="margin-bottom:1rem">Telegram Notifications</h3>
|
||||||
|
<div class="card" style="max-width:480px">
|
||||||
|
<p style="color:var(--text-muted);font-size:.9rem;margin-bottom:1.25rem">
|
||||||
|
Send a message to a Telegram group or chat every time a gate is opened.
|
||||||
|
Create a bot via <a href="https://t.me/BotFather" target="_blank" rel="noopener" style="color:var(--primary)">@BotFather</a>,
|
||||||
|
add it to your group, and paste its token and the chat ID below.
|
||||||
|
</p>
|
||||||
|
<form id="telegram-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-token">Bot Token</label>
|
||||||
|
<input id="tg-token" type="password" autocomplete="off"
|
||||||
|
placeholder="Leave empty to keep current" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tg-chat-id">Chat / Group ID</label>
|
||||||
|
<input id="tg-chat-id" type="text" autocomplete="off"
|
||||||
|
placeholder="e.g. -1001234567890" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;margin:0">
|
||||||
|
<input type="checkbox" id="tg-enabled" checked style="width:1.1rem;height:1.1rem;flex-shrink:0;cursor:pointer" />
|
||||||
|
<span style="font-weight:600">Enable notifications</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="tg-status" style="font-size:.85rem;color:var(--text-muted);margin-bottom:.75rem"></p>
|
||||||
|
<p id="tg-error" class="error-msg hidden"></p>
|
||||||
|
<div style="display:flex;gap:.75rem;flex-wrap:wrap">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" id="btn-tg-test" class="btn btn-ghost">Send test message</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.tab-content -->
|
</div><!-- /.tab-content -->
|
||||||
</div><!-- /#admin-view -->
|
</div><!-- /#admin-view -->
|
||||||
|
|
||||||
|
|||||||
@@ -725,6 +725,52 @@ document.getElementById("chpw-form").addEventListener("submit", async e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Telegram / Notifications ──────────────────────────────────────────────────
|
||||||
|
async function loadTelegram() {
|
||||||
|
try {
|
||||||
|
const cfg = await api("GET", "/api/admin/telegram");
|
||||||
|
document.getElementById("tg-chat-id").value = cfg.chat_id || "";
|
||||||
|
document.getElementById("tg-enabled").checked = cfg.enabled;
|
||||||
|
document.getElementById("tg-status").textContent = cfg.configured
|
||||||
|
? "Bot token is saved. Leave the token field empty to keep it."
|
||||||
|
: "Not configured yet. Enter a bot token to get started.";
|
||||||
|
} catch { /* non-admin: tab hidden anyway */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("telegram-form").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const token = document.getElementById("tg-token").value.trim() || null;
|
||||||
|
const chat_id = document.getElementById("tg-chat-id").value.trim();
|
||||||
|
const enabled = document.getElementById("tg-enabled").checked;
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/admin/telegram", { bot_token: token, chat_id, enabled });
|
||||||
|
document.getElementById("tg-token").value = "";
|
||||||
|
showToast("Telegram settings saved");
|
||||||
|
loadTelegram();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("btn-tg-test").addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("btn-tg-test");
|
||||||
|
const errEl = document.getElementById("tg-error");
|
||||||
|
btn.disabled = true;
|
||||||
|
errEl.classList.add("hidden");
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/admin/telegram/test");
|
||||||
|
showToast("Test message sent ✓");
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message;
|
||||||
|
errEl.classList.remove("hidden");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Load all data ─────────────────────────────────────────────────────────────
|
// ── Load all data ─────────────────────────────────────────────────────────────
|
||||||
function loadAllData() {
|
function loadAllData() {
|
||||||
const isAdmin = _tokenPayload().scope === "admin";
|
const isAdmin = _tokenPayload().scope === "admin";
|
||||||
@@ -734,6 +780,7 @@ function loadAllData() {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadCredentials();
|
loadCredentials();
|
||||||
loadAdmins();
|
loadAdmins();
|
||||||
|
loadTelegram();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user