diff --git a/.gitignore b/.gitignore index 7ce4bb0..39a29ae 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ pytestcache/ .coverage htmlcov/ +data/hooks.json diff --git a/app/main.py b/app/main.py index a96e979..2206dd9 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,7 @@ from .config import get_settings from .models import ( HookCreate, HookResponse, - HookUpdateId, + HookUpdate, LoginStartRequest, LoginVerifyRequest, MessageTriggerResponse, @@ -21,7 +21,8 @@ from .storage import ( delete_hook_async, get_hook_async, list_hooks_async, - update_hook_id_async, + record_hook_trigger_async, + update_hook_async, ) from .telegram_service import telegram_service @@ -128,6 +129,7 @@ async def list_hooks() -> List[HookResponse]: chat_id=hook.chat_id, message=hook.message, created_at=hook.created_at, + last_triggered_at=hook.last_triggered_at, action_url=f"{settings.base_url}{hook.action_path}", ) for hook in hooks @@ -142,6 +144,7 @@ async def create_hook(payload: HookCreate) -> HookResponse: chat_id=hook.chat_id, message=hook.message, created_at=hook.created_at, + last_triggered_at=hook.last_triggered_at, action_url=f"{settings.base_url}{hook.action_path}", ) @@ -154,9 +157,14 @@ async def delete_hook(hook_id: str) -> None: @app.patch("/api/hooks/{hook_id}", response_model=HookResponse) -async def update_hook(hook_id: str, payload: HookUpdateId) -> HookResponse: +async def update_hook(hook_id: str, payload: HookUpdate) -> HookResponse: try: - updated = await update_hook_id_async(hook_id, payload.hook_id) + updated = await update_hook_async( + hook_id, + new_hook_id=payload.hook_id, + chat_id=payload.chat_id, + message=payload.message, + ) except KeyError as exc: raise HTTPException(status_code=404, detail="Hook not found") from exc except ValueError as exc: @@ -166,6 +174,7 @@ async def update_hook(hook_id: str, payload: HookUpdateId) -> HookResponse: chat_id=updated.chat_id, message=updated.message, created_at=updated.created_at, + last_triggered_at=updated.last_triggered_at, action_url=f"{settings.base_url}{updated.action_path}", ) @@ -181,4 +190,5 @@ async def trigger_hook(hook_id: str) -> MessageTriggerResponse: raise HTTPException(status_code=401, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=500, detail=str(exc)) from exc + await record_hook_trigger_async(hook.hook_id) return MessageTriggerResponse(status="sent", hook_id=hook.hook_id, chat_id=hook.chat_id) diff --git a/app/models.py b/app/models.py index 9b517f6..6d187ce 100644 --- a/app/models.py +++ b/app/models.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator, model_validator class HookCreate(BaseModel): @@ -14,6 +14,7 @@ class HookRead(BaseModel): message: str chat_id: str created_at: datetime + last_triggered_at: Optional[datetime] = None @property def action_path(self) -> str: @@ -24,14 +25,60 @@ class HookResponse(HookRead): action_url: str -class HookUpdateId(BaseModel): - hook_id: str = Field( - ..., +class HookUpdate(BaseModel): + hook_id: Optional[str] = Field( + default=None, min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_-]+$", description="New hook identifier", ) + chat_id: Optional[str] = Field( + default=None, + min_length=1, + description="Updated target chat ID or username", + ) + message: Optional[str] = Field( + default=None, + min_length=1, + description="Updated message body supporting Markdown", + ) + + @model_validator(mode="after") + def ensure_any_field(cls, values: "HookUpdate") -> "HookUpdate": + if values.hook_id is None and values.chat_id is None and values.message is None: + raise ValueError("Provide at least one field to update") + return values + + @field_validator("hook_id", mode="before") + @classmethod + def normalize_hook_id(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + trimmed = value.strip() + if not trimmed: + raise ValueError("Hook ID cannot be empty") + return trimmed + + @field_validator("chat_id", mode="before") + @classmethod + def normalize_chat_id(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + trimmed = value.strip() + if not trimmed: + raise ValueError("Chat ID cannot be empty") + return trimmed + + @field_validator("message", mode="before") + @classmethod + def normalize_message(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + stripped = value.strip() + if not stripped: + raise ValueError("Message cannot be empty") + return stripped class LoginStartRequest(BaseModel): diff --git a/app/static/app.js b/app/static/app.js index f7b2828..b4c0db6 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,6 +1,9 @@ const sessionStatusEl = document.querySelector("#session-status"); const sessionUserEl = document.querySelector("#session-user"); const loginFeedbackEl = document.querySelector("#login-feedback"); +const sessionSummaryTextEl = document.querySelector("#session-summary-text"); +const sessionDetailsEl = document.querySelector("#session-details"); +const toggleSessionBtn = document.querySelector("#toggle-session"); const startLoginForm = document.querySelector("#start-login-form"); const verifyLoginForm = document.querySelector("#verify-login-form"); const logoutButton = document.querySelector("#logout-button"); @@ -10,9 +13,22 @@ const hookTemplate = document.querySelector("#hook-template"); const createHookForm = document.querySelector("#create-hook-form"); const toggleCreateBtn = document.querySelector("#toggle-create"); +let sessionDetailsVisible = false; +let sessionDetailsTouched = false; let createFormVisible = false; let createFormTouched = false; +function setSessionDetailsVisibility(show, { fromUser = false } = {}) { + if (!toggleSessionBtn || !sessionDetailsEl) return; + sessionDetailsVisible = show; + if (fromUser) { + sessionDetailsTouched = true; + } + sessionDetailsEl.classList.toggle("hidden", !show); + toggleSessionBtn.setAttribute("aria-expanded", String(show)); + toggleSessionBtn.textContent = show ? "Hide session controls" : "Manage session"; +} + function setCreateFormVisibility(show, { fromUser = false } = {}) { if (!toggleCreateBtn) return; createFormVisible = show; @@ -37,6 +53,13 @@ if (toggleCreateBtn) { setCreateFormVisibility(false); } +if (toggleSessionBtn) { + toggleSessionBtn.addEventListener("click", () => { + setSessionDetailsVisibility(!sessionDetailsVisible, { fromUser: true }); + }); + setSessionDetailsVisibility(false); +} + async function fetchJSON(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json" }, @@ -54,6 +77,9 @@ function updateSessionUI(status) { sessionStatusEl.textContent = "Authorized"; sessionStatusEl.style.background = "rgba(100, 221, 155, 0.12)"; sessionStatusEl.style.color = "#64dd9b"; + if (sessionSummaryTextEl) { + sessionSummaryTextEl.textContent = status.user ? `Logged in as ${status.user}` : "Session ready"; + } sessionUserEl.textContent = `Logged in as ${status.user}`; loginFeedbackEl.textContent = "Session ready. You can trigger hooks."; startLoginForm.classList.add("hidden"); @@ -63,6 +89,11 @@ function updateSessionUI(status) { sessionStatusEl.textContent = status.code_sent ? "Awaiting code" : "Not authorized"; sessionStatusEl.style.background = "rgba(79, 140, 255, 0.15)"; sessionStatusEl.style.color = "#4f8cff"; + if (sessionSummaryTextEl) { + sessionSummaryTextEl.textContent = status.code_sent + ? "Waiting for verification" + : "Login required"; + } sessionUserEl.textContent = status.phone_number ? `Phone number: ${status.phone_number}` : "Set a phone number to begin"; @@ -78,6 +109,13 @@ function updateSessionUI(status) { startLoginForm.classList.remove("hidden"); } + + const shouldShowDetails = !status.authorized || status.code_sent; + if (!sessionDetailsTouched) { + setSessionDetailsVisibility(shouldShowDetails); + } else if (shouldShowDetails && !sessionDetailsVisible) { + setSessionDetailsVisibility(true); + } } async function refreshAll() { @@ -110,82 +148,110 @@ async function loadHooks() { const node = hookTemplate.content.cloneNode(true); node.querySelector("h3").textContent = hook.chat_id; node.querySelector(".hook-date").textContent = new Date(hook.created_at).toLocaleString(); + const lastRunEl = node.querySelector(".hook-last-run"); + if (lastRunEl) { + lastRunEl.textContent = hook.last_triggered_at + ? `Last triggered ${new Date(hook.last_triggered_at).toLocaleString()}` + : "Never triggered yet"; + } node.querySelector(".hook-message").textContent = hook.message; node.querySelector(".hook-url").textContent = hook.action_url; node.querySelector(".hook-id").textContent = hook.hook_id; const feedbackEl = node.querySelector(".hook-feedback"); + const editForm = node.querySelector(".edit-hook-form"); + const editIdInput = editForm.querySelector(".edit-id"); + const editChatInput = editForm.querySelector(".edit-chat"); + const editMessageInput = editForm.querySelector(".edit-message"); + const saveEditBtn = editForm.querySelector(".save-edit"); + const cancelEditBtn = editForm.querySelector(".cancel-edit"); + const editDetailsBtn = node.querySelector(".edit-details"); + + const setFeedback = (text = "", color = "") => { + feedbackEl.textContent = text; + feedbackEl.style.color = color; + }; + + const toggleEditForm = (show) => { + editForm.classList.toggle("hidden", !show); + editDetailsBtn.disabled = show; + if (show) { + editIdInput.value = hook.hook_id; + editChatInput.value = hook.chat_id; + editMessageInput.value = hook.message; + requestAnimationFrame(() => { + editIdInput.focus(); + }); + } + }; + + editDetailsBtn.addEventListener("click", () => { + toggleEditForm(true); + }); + + cancelEditBtn.addEventListener("click", () => { + toggleEditForm(false); + setFeedback(); + }); const copyBtn = node.querySelector(".copy"); copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(hook.action_url); - copyBtn.textContent = "Copied!"; - setTimeout(() => { - copyBtn.textContent = "Copy URL"; - }, 2000); + setFeedback("Hook URL copied to clipboard.", "#64dd9b"); + setTimeout(() => setFeedback(), 2000); } catch (err) { - copyBtn.textContent = "Copy failed"; - setTimeout(() => { - copyBtn.textContent = "Copy URL"; - }, 2000); + setFeedback(`Copy failed: ${err.message}`, "#ffbac7"); + setTimeout(() => setFeedback(), 2500); } }); const triggerBtn = node.querySelector(".trigger"); triggerBtn.addEventListener("click", async () => { - const originalText = triggerBtn.textContent; triggerBtn.disabled = true; - triggerBtn.textContent = "Sending…"; - feedbackEl.textContent = ""; + setFeedback("Sending message…"); try { const result = await fetchJSON(`/action/${hook.hook_id}`); - triggerBtn.textContent = "Sent"; - feedbackEl.textContent = `Status: ${result.status}`; - feedbackEl.style.color = "#64dd9b"; + setFeedback(`Status: ${result.status}`, "#64dd9b"); } catch (err) { - triggerBtn.textContent = "Retry"; - feedbackEl.textContent = `Failed: ${err.message}`; - feedbackEl.style.color = "#ffbac7"; + setFeedback(`Failed: ${err.message}`, "#ffbac7"); } finally { setTimeout(() => { - triggerBtn.textContent = originalText; triggerBtn.disabled = false; - feedbackEl.style.color = ""; - }, 2000); + setFeedback(); + }, 2500); } }); - const editIdBtn = node.querySelector(".edit-id"); - const editIconMarkup = editIdBtn.innerHTML; - editIdBtn.addEventListener("click", async () => { - const newId = prompt("Enter new hook ID", hook.hook_id); - if (newId === null) { + editForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const updatedId = editIdInput.value.trim(); + const updatedChat = editChatInput.value.trim(); + const updatedMessage = editMessageInput.value.trim(); + if (!updatedId || !updatedChat || !updatedMessage) { + setFeedback("Hook ID, chat ID, and message are required.", "#ffbac7"); return; } - const sanitized = newId.trim(); - if (!sanitized || sanitized === hook.hook_id) { - return; - } - editIdBtn.disabled = true; - editIdBtn.textContent = "Saving…"; - feedbackEl.textContent = ""; + saveEditBtn.disabled = true; + cancelEditBtn.disabled = true; + const originalSaveText = saveEditBtn.textContent; + saveEditBtn.textContent = "Saving…"; + setFeedback(); try { await fetchJSON(`/api/hooks/${hook.hook_id}`, { method: "PATCH", - body: JSON.stringify({ hook_id: sanitized }), + body: JSON.stringify({ hook_id: updatedId, chat_id: updatedChat, message: updatedMessage }), }); - feedbackEl.textContent = "Hook ID updated."; - feedbackEl.style.color = "#64dd9b"; + setFeedback("Hook updated.", "#64dd9b"); + toggleEditForm(false); await loadHooks(); } catch (err) { - feedbackEl.textContent = `Update failed: ${err.message}`; - feedbackEl.style.color = "#ffbac7"; + setFeedback(`Update failed: ${err.message}`, "#ffbac7"); } finally { - editIdBtn.disabled = false; - editIdBtn.innerHTML = editIconMarkup; + saveEditBtn.disabled = false; + cancelEditBtn.disabled = false; + saveEditBtn.textContent = originalSaveText; setTimeout(() => { - feedbackEl.textContent = ""; - feedbackEl.style.color = ""; + setFeedback(); }, 2500); } }); diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..82ca81c --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,19 @@ + + Telegram Message Hook Icon + Circular badge with a paper plane and hook accent + + + + + + + + + + + + + + + + diff --git a/app/static/logo.svg b/app/static/logo.svg new file mode 100644 index 0000000..cd28e1d --- /dev/null +++ b/app/static/logo.svg @@ -0,0 +1,19 @@ + + Telegram Message Hook Logo + Circular badge with a paper plane and hook accent + + + + + + + + + + + + + + + + diff --git a/app/static/style.css b/app/static/style.css index 6e6a176..4810b70 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -32,6 +32,23 @@ footer { padding: 2rem clamp(1rem, 6vw, 4rem); } +.site-identity { + display: flex; + align-items: center; + gap: clamp(1rem, 4vw, 1.75rem); +} + +.site-logo { + width: clamp(3.25rem, 8vw, 4.75rem); + height: auto; + filter: drop-shadow(0 12px 24px rgba(24, 40, 92, 0.55)); +} + +.site-title { + display: grid; + gap: 0.5rem; +} + header h1 { margin: 0; font-size: clamp(2rem, 5vw, 3.5rem); @@ -45,10 +62,10 @@ header h1 { } main { - display: grid; + display: flex; + flex-direction: column; gap: 2rem; padding: 0 clamp(1rem, 6vw, 4rem) 4rem; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } .card { @@ -86,6 +103,52 @@ main { margin-bottom: 0.75rem; } +.session-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.session-summary-main { + display: grid; + gap: 0.5rem; + justify-items: start; +} + +.session-status-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.session-summary-text { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.session-toggle { + align-self: center; + white-space: nowrap; +} + +.session-details { + margin-top: 1.5rem; + display: grid; + gap: 1rem; +} + +#session-section { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 1.5rem; +} + .user-info { margin-bottom: 1.5rem; color: var(--muted); @@ -144,12 +207,15 @@ button:active { } .icon-button { - padding: 0.6rem; - min-width: 0; + padding: 0; + width: 2.75rem; + height: 2.75rem; + min-width: 2.75rem; display: inline-flex; align-items: center; justify-content: center; - gap: 0.25rem; + border-radius: 12px; + flex-shrink: 0; } .icon { @@ -206,7 +272,14 @@ button:active { .hook-list { display: grid; - gap: 1rem; + gap: 1.25rem; + grid-template-columns: 1fr; +} + +@media (min-width: 900px) { + .hook-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .hook-card { @@ -237,12 +310,26 @@ button:active { } .hook-meta { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.hook-heading { display: flex; justify-content: space-between; - align-items: baseline; + align-items: flex-start; gap: 1rem; } +.hook-timestamps { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + text-align: right; +} + .hook-meta h3 { margin: 0; font-size: 1rem; @@ -254,6 +341,11 @@ button:active { color: var(--muted); } +.hook-last-run { + font-size: 0.85rem; + color: var(--muted); +} + .hook-message { white-space: pre-wrap; font-family: "JetBrains Mono", "Fira Code", monospace; @@ -271,6 +363,33 @@ button:active { user-select: all; } +.edit-hook-form { + margin-top: 0.75rem; + display: grid; + gap: 0.75rem; + padding: 0.85rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.edit-hook-form label { + display: grid; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +.edit-hook-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.edit-hook-form .save-edit { + min-width: 8rem; +} + .hook-feedback { margin: 0.35rem 0 0; min-height: 1rem; @@ -297,7 +416,13 @@ button:active { text-align: center; } - .hook-actions { + .site-identity { flex-direction: column; + gap: 1rem; + } + + .hook-actions { + flex-wrap: wrap; + justify-content: center; } } diff --git a/app/storage.py b/app/storage.py index 6f41c8d..86f5cb0 100644 --- a/app/storage.py +++ b/app/storage.py @@ -22,6 +22,16 @@ class HookStore: self.hook_id_length = hook_id_length self._lock = threading.Lock() self._initialize() + + def _deserialize_hook(self, item: dict) -> HookRead: + last_triggered = item.get("last_triggered_at") + return HookRead( + hook_id=item["hook_id"], + chat_id=item["chat_id"], + message=item["message"], + created_at=datetime.fromisoformat(item["created_at"]), + last_triggered_at=datetime.fromisoformat(last_triggered) if last_triggered else None, + ) def _initialize(self) -> None: if not self.storage_path.exists(): @@ -58,15 +68,7 @@ class HookStore: def list_hooks(self) -> List[HookRead]: raw_hooks = self._load_raw() - hooks = [ - HookRead( - hook_id=item["hook_id"], - chat_id=item["chat_id"], - message=item["message"], - created_at=datetime.fromisoformat(item["created_at"]), - ) - for item in raw_hooks - ] + hooks = [self._deserialize_hook(item) for item in raw_hooks] hooks.sort(key=lambda h: h.created_at, reverse=True) return hooks @@ -82,6 +84,7 @@ class HookStore: "chat_id": payload.chat_id, "message": payload.message, "created_at": created_at, + "last_triggered_at": None, } ) self._save_raw(raw_hooks) @@ -90,18 +93,14 @@ class HookStore: chat_id=payload.chat_id, message=payload.message, created_at=datetime.fromisoformat(created_at), + last_triggered_at=None, ) def get_hook(self, hook_id: str) -> Optional[HookRead]: raw_hooks = self._load_raw() for item in raw_hooks: if item.get("hook_id") == hook_id: - return HookRead( - hook_id=item["hook_id"], - chat_id=item["chat_id"], - message=item["message"], - created_at=datetime.fromisoformat(item["created_at"]), - ) + return self._deserialize_hook(item) return None def delete_hook(self, hook_id: str) -> bool: @@ -113,34 +112,61 @@ class HookStore: self._save_raw(new_hooks) return True - def update_hook_id(self, current_id: str, new_id: str) -> HookRead: - normalized_new_id = new_id.strip() - if not normalized_new_id: - raise ValueError("Hook ID cannot be empty") - if not HOOK_ID_PATTERN.fullmatch(normalized_new_id): - raise ValueError("Hook ID must be 3-64 characters of letters, numbers, '_' or '-' only") + def update_hook( + self, + current_id: str, + *, + new_hook_id: Optional[str] = None, + chat_id: Optional[str] = None, + message: Optional[str] = None, + ) -> HookRead: + if new_hook_id is None and chat_id is None and message is None: + raise ValueError("No updates provided") + + normalized_id = new_hook_id.strip() if new_hook_id is not None else None + normalized_chat = chat_id.strip() if chat_id is not None else None + normalized_message = message.strip() if message is not None else None + + if normalized_id is not None: + if not normalized_id: + raise ValueError("Hook ID cannot be empty") + if not HOOK_ID_PATTERN.fullmatch(normalized_id): + raise ValueError("Hook ID must be 3-64 characters of letters, numbers, '_' or '-' only") + if normalized_chat is not None and not normalized_chat: + raise ValueError("Chat ID cannot be empty") + if normalized_message is not None and not normalized_message: + raise ValueError("Message cannot be empty") + with self._lock: raw_hooks = self._load_raw() exists = next((item for item in raw_hooks if item.get("hook_id") == current_id), None) if not exists: raise KeyError("Hook not found") - if normalized_new_id == current_id: - return HookRead( - hook_id=exists["hook_id"], - chat_id=exists["chat_id"], - message=exists["message"], - created_at=datetime.fromisoformat(exists["created_at"]), - ) - if any(item.get("hook_id") == normalized_new_id for item in raw_hooks): - raise ValueError("Hook ID already exists") - exists["hook_id"] = normalized_new_id + + if normalized_id is not None and normalized_id != current_id: + if any(item.get("hook_id") == normalized_id for item in raw_hooks): + raise ValueError("Hook ID already exists") + exists["hook_id"] = normalized_id + + if normalized_chat is not None: + exists["chat_id"] = normalized_chat + + if normalized_message is not None: + exists["message"] = normalized_message + self._save_raw(raw_hooks) - return HookRead( - hook_id=normalized_new_id, - chat_id=exists["chat_id"], - message=exists["message"], - created_at=datetime.fromisoformat(exists["created_at"]), - ) + return self._deserialize_hook(exists) + + def mark_hook_triggered(self, hook_id: str) -> HookRead: + timestamp = datetime.now(UTC).replace(microsecond=0).isoformat() + with self._lock: + raw_hooks = self._load_raw() + exists = next((item for item in raw_hooks if item.get("hook_id") == hook_id), None) + if not exists: + raise KeyError("Hook not found") + exists["last_triggered_at"] = timestamp + self._save_raw(raw_hooks) + return self._deserialize_hook(exists) settings = get_settings() @@ -163,5 +189,21 @@ async def delete_hook_async(hook_id: str) -> bool: return await run_in_threadpool(store.delete_hook, hook_id) -async def update_hook_id_async(current_hook_id: str, new_hook_id: str) -> HookRead: - return await run_in_threadpool(store.update_hook_id, current_hook_id, new_hook_id) +async def update_hook_async( + current_hook_id: str, + *, + new_hook_id: Optional[str] = None, + chat_id: Optional[str] = None, + message: Optional[str] = None, +) -> HookRead: + return await run_in_threadpool( + store.update_hook, + current_hook_id, + new_hook_id=new_hook_id, + chat_id=chat_id, + message=message, + ) + + +async def record_hook_trigger_async(hook_id: str) -> HookRead: + return await run_in_threadpool(store.mark_hook_triggered, hook_id) diff --git a/app/templates/index.html b/app/templates/index.html index b3e190c..a514a14 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -5,35 +5,52 @@ Telegram Message Hook +
-

Telegram Message Hook

-

Trigger curated messages in your chats via simple webhooks.

+
+ +
+

Telegram Message Hook

+

Trigger curated messages in your chats via simple webhooks.

+
+
-
-

Telegram Session

-
Checking status…
- -
-
- - - -
- - +
+
+
+
+
Checking status…
+

Preparing session details…

+
+
+ +
+ - -
@@ -63,28 +80,66 @@ diff --git a/data/hooks.json b/data/hooks.json index 0fbc970..b3e55b8 100644 --- a/data/hooks.json +++ b/data/hooks.json @@ -3,12 +3,21 @@ "hook_id": "anmich", "chat_id": "@troogs", "message": "Test an mich", - "created_at": "2025-10-07T09:47:50+00:00" + "created_at": "2025-10-07T09:47:50+00:00", + "last_triggered_at": "2025-10-07T11:31:26+00:00" }, { "hook_id": "yszramzb", - "chat_id": "@Veleda_B", - "message": ":*", - "created_at": "2025-10-07T09:48:11+00:00" + "chat_id": "@TroogS", + "message": ":*\n*FETT!* nichtfett **Kursiiiieeev**", + "created_at": "2025-10-07T09:48:11+00:00", + "last_triggered_at": "2025-10-07T11:34:41+00:00" + }, + { + "hook_id": "i10ofwya", + "chat_id": "asd", + "message": "asd", + "created_at": "2025-10-07T11:17:18+00:00", + "last_triggered_at": null } ] diff --git a/tests/test_app.py b/tests/test_app.py index 7297b40..4381713 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,5 @@ import importlib +from datetime import datetime from pathlib import Path from types import SimpleNamespace from typing import List, Tuple @@ -103,11 +104,13 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None: assert data["chat_id"] == payload["chat_id"] assert payload["message"] in data["message"] assert data["action_url"].endswith(f"/action/{data['hook_id']}") + assert data["last_triggered_at"] is None list_resp = client.get("/api/hooks") assert list_resp.status_code == 200 hooks = list_resp.json() assert len(hooks) == 1 + assert hooks[0]["last_triggered_at"] is None trigger_resp = client.get(f"/action/{data['hook_id']}") assert trigger_resp.status_code == 200 @@ -116,12 +119,18 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None: call_log = getattr(client, "call_log") assert call_log == [(payload["chat_id"], payload["message"])] + refreshed_hooks = client.get("/api/hooks").json() + last_triggered_value = refreshed_hooks[0]["last_triggered_at"] + assert last_triggered_value is not None + first_triggered_at = datetime.fromisoformat(last_triggered_value) + new_id = "customid123" patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id}) assert patch_resp.status_code == 200 patched = patch_resp.json() assert patched["hook_id"] == new_id assert patched["action_url"].endswith(f"/action/{new_id}") + assert patched["last_triggered_at"] == last_triggered_value # Old ID should now be gone old_trigger = client.get(f"/action/{data['hook_id']}") @@ -132,6 +141,29 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None: assert new_trigger.status_code == 200 assert getattr(client, "call_log") == [(payload["chat_id"], payload["message"])] + update_payload = { + "chat_id": "@updated", + "message": "Updated message!", + } + update_resp = client.patch(f"/api/hooks/{new_id}", json=update_payload) + assert update_resp.status_code == 200 + updated_hook = update_resp.json() + assert updated_hook["hook_id"] == new_id + assert updated_hook["chat_id"] == update_payload["chat_id"] + assert updated_hook["message"] == update_payload["message"] + assert updated_hook["last_triggered_at"] == last_triggered_value + + call_log.clear() + retrigger_resp = client.get(f"/action/{new_id}") + assert retrigger_resp.status_code == 200 + assert getattr(client, "call_log") == [(update_payload["chat_id"], update_payload["message"])] + + final_hooks = client.get("/api/hooks").json() + second_triggered_value = final_hooks[0]["last_triggered_at"] + assert second_triggered_value is not None + second_triggered_at = datetime.fromisoformat(second_triggered_value) + assert second_triggered_at >= first_triggered_at + def test_login_verify_without_phone_number(client: TestClient) -> None: response = client.post("/api/login/verify", json={"code": "123456"})