feat: Enhance hook management and session handling

- Update hook model to include last_triggered_at field.
- Modify API endpoints to support updating hooks with new fields.
- Implement session management UI improvements with toggle functionality.
- Add new JavaScript functions for better session detail visibility.
- Refactor hook storage logic to handle last triggered timestamps.
- Introduce new favicon and logo for branding.
- Update styles for improved layout and user experience.
- Enhance tests to cover new functionality and ensure reliability.
This commit is contained in:
Andre Beging
2025-10-07 13:39:07 +02:00
parent c7f694d820
commit 1204f5dcde
11 changed files with 559 additions and 134 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ __pycache__/
pytestcache/ pytestcache/
.coverage .coverage
htmlcov/ htmlcov/
data/hooks.json

View File

@@ -10,7 +10,7 @@ from .config import get_settings
from .models import ( from .models import (
HookCreate, HookCreate,
HookResponse, HookResponse,
HookUpdateId, HookUpdate,
LoginStartRequest, LoginStartRequest,
LoginVerifyRequest, LoginVerifyRequest,
MessageTriggerResponse, MessageTriggerResponse,
@@ -21,7 +21,8 @@ from .storage import (
delete_hook_async, delete_hook_async,
get_hook_async, get_hook_async,
list_hooks_async, list_hooks_async,
update_hook_id_async, record_hook_trigger_async,
update_hook_async,
) )
from .telegram_service import telegram_service from .telegram_service import telegram_service
@@ -128,6 +129,7 @@ async def list_hooks() -> List[HookResponse]:
chat_id=hook.chat_id, chat_id=hook.chat_id,
message=hook.message, message=hook.message,
created_at=hook.created_at, created_at=hook.created_at,
last_triggered_at=hook.last_triggered_at,
action_url=f"{settings.base_url}{hook.action_path}", action_url=f"{settings.base_url}{hook.action_path}",
) )
for hook in hooks for hook in hooks
@@ -142,6 +144,7 @@ async def create_hook(payload: HookCreate) -> HookResponse:
chat_id=hook.chat_id, chat_id=hook.chat_id,
message=hook.message, message=hook.message,
created_at=hook.created_at, created_at=hook.created_at,
last_triggered_at=hook.last_triggered_at,
action_url=f"{settings.base_url}{hook.action_path}", 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) @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: 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: except KeyError as exc:
raise HTTPException(status_code=404, detail="Hook not found") from exc raise HTTPException(status_code=404, detail="Hook not found") from exc
except ValueError as exc: except ValueError as exc:
@@ -166,6 +174,7 @@ async def update_hook(hook_id: str, payload: HookUpdateId) -> HookResponse:
chat_id=updated.chat_id, chat_id=updated.chat_id,
message=updated.message, message=updated.message,
created_at=updated.created_at, created_at=updated.created_at,
last_triggered_at=updated.last_triggered_at,
action_url=f"{settings.base_url}{updated.action_path}", 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 raise HTTPException(status_code=401, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=500, detail=str(exc)) from exc 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) return MessageTriggerResponse(status="sent", hook_id=hook.hook_id, chat_id=hook.chat_id)

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator, model_validator
class HookCreate(BaseModel): class HookCreate(BaseModel):
@@ -14,6 +14,7 @@ class HookRead(BaseModel):
message: str message: str
chat_id: str chat_id: str
created_at: datetime created_at: datetime
last_triggered_at: Optional[datetime] = None
@property @property
def action_path(self) -> str: def action_path(self) -> str:
@@ -24,14 +25,60 @@ class HookResponse(HookRead):
action_url: str action_url: str
class HookUpdateId(BaseModel): class HookUpdate(BaseModel):
hook_id: str = Field( hook_id: Optional[str] = Field(
..., default=None,
min_length=3, min_length=3,
max_length=64, max_length=64,
pattern=r"^[A-Za-z0-9_-]+$", pattern=r"^[A-Za-z0-9_-]+$",
description="New hook identifier", 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): class LoginStartRequest(BaseModel):

View File

@@ -1,6 +1,9 @@
const sessionStatusEl = document.querySelector("#session-status"); const sessionStatusEl = document.querySelector("#session-status");
const sessionUserEl = document.querySelector("#session-user"); const sessionUserEl = document.querySelector("#session-user");
const loginFeedbackEl = document.querySelector("#login-feedback"); 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 startLoginForm = document.querySelector("#start-login-form");
const verifyLoginForm = document.querySelector("#verify-login-form"); const verifyLoginForm = document.querySelector("#verify-login-form");
const logoutButton = document.querySelector("#logout-button"); const logoutButton = document.querySelector("#logout-button");
@@ -10,9 +13,22 @@ const hookTemplate = document.querySelector("#hook-template");
const createHookForm = document.querySelector("#create-hook-form"); const createHookForm = document.querySelector("#create-hook-form");
const toggleCreateBtn = document.querySelector("#toggle-create"); const toggleCreateBtn = document.querySelector("#toggle-create");
let sessionDetailsVisible = false;
let sessionDetailsTouched = false;
let createFormVisible = false; let createFormVisible = false;
let createFormTouched = 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 } = {}) { function setCreateFormVisibility(show, { fromUser = false } = {}) {
if (!toggleCreateBtn) return; if (!toggleCreateBtn) return;
createFormVisible = show; createFormVisible = show;
@@ -37,6 +53,13 @@ if (toggleCreateBtn) {
setCreateFormVisibility(false); setCreateFormVisibility(false);
} }
if (toggleSessionBtn) {
toggleSessionBtn.addEventListener("click", () => {
setSessionDetailsVisibility(!sessionDetailsVisible, { fromUser: true });
});
setSessionDetailsVisibility(false);
}
async function fetchJSON(url, options = {}) { async function fetchJSON(url, options = {}) {
const response = await fetch(url, { const response = await fetch(url, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -54,6 +77,9 @@ function updateSessionUI(status) {
sessionStatusEl.textContent = "Authorized"; sessionStatusEl.textContent = "Authorized";
sessionStatusEl.style.background = "rgba(100, 221, 155, 0.12)"; sessionStatusEl.style.background = "rgba(100, 221, 155, 0.12)";
sessionStatusEl.style.color = "#64dd9b"; sessionStatusEl.style.color = "#64dd9b";
if (sessionSummaryTextEl) {
sessionSummaryTextEl.textContent = status.user ? `Logged in as ${status.user}` : "Session ready";
}
sessionUserEl.textContent = `Logged in as ${status.user}`; sessionUserEl.textContent = `Logged in as ${status.user}`;
loginFeedbackEl.textContent = "Session ready. You can trigger hooks."; loginFeedbackEl.textContent = "Session ready. You can trigger hooks.";
startLoginForm.classList.add("hidden"); startLoginForm.classList.add("hidden");
@@ -63,6 +89,11 @@ function updateSessionUI(status) {
sessionStatusEl.textContent = status.code_sent ? "Awaiting code" : "Not authorized"; sessionStatusEl.textContent = status.code_sent ? "Awaiting code" : "Not authorized";
sessionStatusEl.style.background = "rgba(79, 140, 255, 0.15)"; sessionStatusEl.style.background = "rgba(79, 140, 255, 0.15)";
sessionStatusEl.style.color = "#4f8cff"; sessionStatusEl.style.color = "#4f8cff";
if (sessionSummaryTextEl) {
sessionSummaryTextEl.textContent = status.code_sent
? "Waiting for verification"
: "Login required";
}
sessionUserEl.textContent = status.phone_number sessionUserEl.textContent = status.phone_number
? `Phone number: ${status.phone_number}` ? `Phone number: ${status.phone_number}`
: "Set a phone number to begin"; : "Set a phone number to begin";
@@ -78,6 +109,13 @@ function updateSessionUI(status) {
startLoginForm.classList.remove("hidden"); startLoginForm.classList.remove("hidden");
} }
const shouldShowDetails = !status.authorized || status.code_sent;
if (!sessionDetailsTouched) {
setSessionDetailsVisibility(shouldShowDetails);
} else if (shouldShowDetails && !sessionDetailsVisible) {
setSessionDetailsVisibility(true);
}
} }
async function refreshAll() { async function refreshAll() {
@@ -110,82 +148,110 @@ async function loadHooks() {
const node = hookTemplate.content.cloneNode(true); const node = hookTemplate.content.cloneNode(true);
node.querySelector("h3").textContent = hook.chat_id; node.querySelector("h3").textContent = hook.chat_id;
node.querySelector(".hook-date").textContent = new Date(hook.created_at).toLocaleString(); 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-message").textContent = hook.message;
node.querySelector(".hook-url").textContent = hook.action_url; node.querySelector(".hook-url").textContent = hook.action_url;
node.querySelector(".hook-id").textContent = hook.hook_id; node.querySelector(".hook-id").textContent = hook.hook_id;
const feedbackEl = node.querySelector(".hook-feedback"); 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"); const copyBtn = node.querySelector(".copy");
copyBtn.addEventListener("click", async () => { copyBtn.addEventListener("click", async () => {
try { try {
await navigator.clipboard.writeText(hook.action_url); await navigator.clipboard.writeText(hook.action_url);
copyBtn.textContent = "Copied!"; setFeedback("Hook URL copied to clipboard.", "#64dd9b");
setTimeout(() => { setTimeout(() => setFeedback(), 2000);
copyBtn.textContent = "Copy URL";
}, 2000);
} catch (err) { } catch (err) {
copyBtn.textContent = "Copy failed"; setFeedback(`Copy failed: ${err.message}`, "#ffbac7");
setTimeout(() => { setTimeout(() => setFeedback(), 2500);
copyBtn.textContent = "Copy URL";
}, 2000);
} }
}); });
const triggerBtn = node.querySelector(".trigger"); const triggerBtn = node.querySelector(".trigger");
triggerBtn.addEventListener("click", async () => { triggerBtn.addEventListener("click", async () => {
const originalText = triggerBtn.textContent;
triggerBtn.disabled = true; triggerBtn.disabled = true;
triggerBtn.textContent = "Sending…"; setFeedback("Sending message…");
feedbackEl.textContent = "";
try { try {
const result = await fetchJSON(`/action/${hook.hook_id}`); const result = await fetchJSON(`/action/${hook.hook_id}`);
triggerBtn.textContent = "Sent"; setFeedback(`Status: ${result.status}`, "#64dd9b");
feedbackEl.textContent = `Status: ${result.status}`;
feedbackEl.style.color = "#64dd9b";
} catch (err) { } catch (err) {
triggerBtn.textContent = "Retry"; setFeedback(`Failed: ${err.message}`, "#ffbac7");
feedbackEl.textContent = `Failed: ${err.message}`;
feedbackEl.style.color = "#ffbac7";
} finally { } finally {
setTimeout(() => { setTimeout(() => {
triggerBtn.textContent = originalText;
triggerBtn.disabled = false; triggerBtn.disabled = false;
feedbackEl.style.color = ""; setFeedback();
}, 2000); }, 2500);
} }
}); });
const editIdBtn = node.querySelector(".edit-id"); editForm.addEventListener("submit", async (event) => {
const editIconMarkup = editIdBtn.innerHTML; event.preventDefault();
editIdBtn.addEventListener("click", async () => { const updatedId = editIdInput.value.trim();
const newId = prompt("Enter new hook ID", hook.hook_id); const updatedChat = editChatInput.value.trim();
if (newId === null) { const updatedMessage = editMessageInput.value.trim();
if (!updatedId || !updatedChat || !updatedMessage) {
setFeedback("Hook ID, chat ID, and message are required.", "#ffbac7");
return; return;
} }
const sanitized = newId.trim(); saveEditBtn.disabled = true;
if (!sanitized || sanitized === hook.hook_id) { cancelEditBtn.disabled = true;
return; const originalSaveText = saveEditBtn.textContent;
} saveEditBtn.textContent = "Saving…";
editIdBtn.disabled = true; setFeedback();
editIdBtn.textContent = "Saving…";
feedbackEl.textContent = "";
try { try {
await fetchJSON(`/api/hooks/${hook.hook_id}`, { await fetchJSON(`/api/hooks/${hook.hook_id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ hook_id: sanitized }), body: JSON.stringify({ hook_id: updatedId, chat_id: updatedChat, message: updatedMessage }),
}); });
feedbackEl.textContent = "Hook ID updated."; setFeedback("Hook updated.", "#64dd9b");
feedbackEl.style.color = "#64dd9b"; toggleEditForm(false);
await loadHooks(); await loadHooks();
} catch (err) { } catch (err) {
feedbackEl.textContent = `Update failed: ${err.message}`; setFeedback(`Update failed: ${err.message}`, "#ffbac7");
feedbackEl.style.color = "#ffbac7";
} finally { } finally {
editIdBtn.disabled = false; saveEditBtn.disabled = false;
editIdBtn.innerHTML = editIconMarkup; cancelEditBtn.disabled = false;
saveEditBtn.textContent = originalSaveText;
setTimeout(() => { setTimeout(() => {
feedbackEl.textContent = ""; setFeedback();
feedbackEl.style.color = "";
}, 2500); }, 2500);
} }
}); });

19
app/static/favicon.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
<title id="title">Telegram Message Hook Icon</title>
<desc id="desc">Circular badge with a paper plane and hook accent</desc>
<defs>
<radialGradient id="bg" cx="50%" cy="28%" r="70%">
<stop offset="0%" stop-color="#45C9FF" />
<stop offset="60%" stop-color="#2AA3F6" />
<stop offset="100%" stop-color="#1A5DD8" />
</radialGradient>
<linearGradient id="hook" x1="0" x2="1" y1="1" y2="0">
<stop offset="0%" stop-color="#62FFD6" />
<stop offset="100%" stop-color="#42C79B" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="30" fill="url(#bg)" />
<path d="M50.2 15.6L13.7 30.6c-1.7.7-1.6 3.1.2 3.7l10.8 3.4c.9.3 1.8-.1 2.4-.8l14-14.2c.4-.4 1 .1.6.7l-9.4 15.1c-.4.6-.5 1.3-.4 2l1.8 11.2c.3 1.8 2.7 2.2 3.4.6l4.7-9.8c.2-.5.7-.9 1.2-1l11.4-2.7c1.8-.4 2-3 .3-3.7l-10.7-4.4 9.1-9.1c1.5-1.6-.2-4-2.4-2.9z" fill="#fff" />
<path d="M46 46.5c0 7.7-6.3 14-14 14s-14-6.3-14-14" fill="none" stroke="url(#hook)" stroke-width="4" stroke-linecap="round" />
<circle cx="18" cy="46.5" r="3" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

19
app/static/logo.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-labelledby="title desc">
<title id="title">Telegram Message Hook Logo</title>
<desc id="desc">Circular badge with a paper plane and hook accent</desc>
<defs>
<radialGradient id="bg" cx="50%" cy="30%" r="75%">
<stop offset="0%" stop-color="#45C9FF" />
<stop offset="55%" stop-color="#2AA3F6" />
<stop offset="100%" stop-color="#1A5DD8" />
</radialGradient>
<linearGradient id="hook" x1="0" x2="1" y1="1" y2="0">
<stop offset="0%" stop-color="#62FFD6" />
<stop offset="100%" stop-color="#42C79B" />
</linearGradient>
</defs>
<circle cx="80" cy="80" r="74" fill="url(#bg)" />
<path d="M120.6 37.8L34.7 72.3c-3.5 1.4-3.4 6.2.2 7.4l22.1 7.1c1.7.5 3.5-.1 4.6-1.4l32-32.6c.8-.8 2 .3 1.3 1.2L75.4 96.9c-.9 1.2-1.2 2.7-.9 4.1l4 24.1c.7 4.1 6.1 4.9 7.7 1l9.8-21.1c.4-.9 1.3-1.6 2.3-1.8l24-5.7c3.5-.8 3.8-5.6.5-7.1l-23.1-9.5 19.7-19.7c3.4-3.4-.7-9-5.1-6.4z" fill="#fff" />
<path d="M115 108c0 19-15.4 34.4-34.4 34.4S46.2 127 46.2 108" fill="none" stroke="url(#hook)" stroke-width="10" stroke-linecap="round" />
<circle cx="46.2" cy="108" r="6.5" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -32,6 +32,23 @@ footer {
padding: 2rem clamp(1rem, 6vw, 4rem); 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 { header h1 {
margin: 0; margin: 0;
font-size: clamp(2rem, 5vw, 3.5rem); font-size: clamp(2rem, 5vw, 3.5rem);
@@ -45,10 +62,10 @@ header h1 {
} }
main { main {
display: grid; display: flex;
flex-direction: column;
gap: 2rem; gap: 2rem;
padding: 0 clamp(1rem, 6vw, 4rem) 4rem; padding: 0 clamp(1rem, 6vw, 4rem) 4rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
} }
.card { .card {
@@ -86,6 +103,52 @@ main {
margin-bottom: 0.75rem; 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 { .user-info {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: var(--muted); color: var(--muted);
@@ -144,12 +207,15 @@ button:active {
} }
.icon-button { .icon-button {
padding: 0.6rem; padding: 0;
min-width: 0; width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25rem; border-radius: 12px;
flex-shrink: 0;
} }
.icon { .icon {
@@ -206,7 +272,14 @@ button:active {
.hook-list { .hook-list {
display: grid; 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 { .hook-card {
@@ -237,12 +310,26 @@ button:active {
} }
.hook-meta { .hook-meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.hook-heading {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: flex-start;
gap: 1rem; gap: 1rem;
} }
.hook-timestamps {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
text-align: right;
}
.hook-meta h3 { .hook-meta h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
@@ -254,6 +341,11 @@ button:active {
color: var(--muted); color: var(--muted);
} }
.hook-last-run {
font-size: 0.85rem;
color: var(--muted);
}
.hook-message { .hook-message {
white-space: pre-wrap; white-space: pre-wrap;
font-family: "JetBrains Mono", "Fira Code", monospace; font-family: "JetBrains Mono", "Fira Code", monospace;
@@ -271,6 +363,33 @@ button:active {
user-select: all; 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 { .hook-feedback {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
min-height: 1rem; min-height: 1rem;
@@ -297,7 +416,13 @@ button:active {
text-align: center; text-align: center;
} }
.hook-actions { .site-identity {
flex-direction: column; flex-direction: column;
gap: 1rem;
}
.hook-actions {
flex-wrap: wrap;
justify-content: center;
} }
} }

View File

@@ -23,6 +23,16 @@ class HookStore:
self._lock = threading.Lock() self._lock = threading.Lock()
self._initialize() 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: def _initialize(self) -> None:
if not self.storage_path.exists(): if not self.storage_path.exists():
self.storage_path.parent.mkdir(parents=True, exist_ok=True) self.storage_path.parent.mkdir(parents=True, exist_ok=True)
@@ -58,15 +68,7 @@ class HookStore:
def list_hooks(self) -> List[HookRead]: def list_hooks(self) -> List[HookRead]:
raw_hooks = self._load_raw() raw_hooks = self._load_raw()
hooks = [ hooks = [self._deserialize_hook(item) for item in 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.sort(key=lambda h: h.created_at, reverse=True) hooks.sort(key=lambda h: h.created_at, reverse=True)
return hooks return hooks
@@ -82,6 +84,7 @@ class HookStore:
"chat_id": payload.chat_id, "chat_id": payload.chat_id,
"message": payload.message, "message": payload.message,
"created_at": created_at, "created_at": created_at,
"last_triggered_at": None,
} }
) )
self._save_raw(raw_hooks) self._save_raw(raw_hooks)
@@ -90,18 +93,14 @@ class HookStore:
chat_id=payload.chat_id, chat_id=payload.chat_id,
message=payload.message, message=payload.message,
created_at=datetime.fromisoformat(created_at), created_at=datetime.fromisoformat(created_at),
last_triggered_at=None,
) )
def get_hook(self, hook_id: str) -> Optional[HookRead]: def get_hook(self, hook_id: str) -> Optional[HookRead]:
raw_hooks = self._load_raw() raw_hooks = self._load_raw()
for item in raw_hooks: for item in raw_hooks:
if item.get("hook_id") == hook_id: if item.get("hook_id") == hook_id:
return HookRead( return self._deserialize_hook(item)
hook_id=item["hook_id"],
chat_id=item["chat_id"],
message=item["message"],
created_at=datetime.fromisoformat(item["created_at"]),
)
return None return None
def delete_hook(self, hook_id: str) -> bool: def delete_hook(self, hook_id: str) -> bool:
@@ -113,34 +112,61 @@ class HookStore:
self._save_raw(new_hooks) self._save_raw(new_hooks)
return True return True
def update_hook_id(self, current_id: str, new_id: str) -> HookRead: def update_hook(
normalized_new_id = new_id.strip() self,
if not normalized_new_id: current_id: str,
raise ValueError("Hook ID cannot be empty") *,
if not HOOK_ID_PATTERN.fullmatch(normalized_new_id): new_hook_id: Optional[str] = None,
raise ValueError("Hook ID must be 3-64 characters of letters, numbers, '_' or '-' only") 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: with self._lock:
raw_hooks = self._load_raw() raw_hooks = self._load_raw()
exists = next((item for item in raw_hooks if item.get("hook_id") == current_id), None) exists = next((item for item in raw_hooks if item.get("hook_id") == current_id), None)
if not exists: if not exists:
raise KeyError("Hook not found") raise KeyError("Hook not found")
if normalized_new_id == current_id:
return HookRead( if normalized_id is not None and normalized_id != current_id:
hook_id=exists["hook_id"], if any(item.get("hook_id") == normalized_id for item in raw_hooks):
chat_id=exists["chat_id"], raise ValueError("Hook ID already exists")
message=exists["message"], exists["hook_id"] = normalized_id
created_at=datetime.fromisoformat(exists["created_at"]),
) if normalized_chat is not None:
if any(item.get("hook_id") == normalized_new_id for item in raw_hooks): exists["chat_id"] = normalized_chat
raise ValueError("Hook ID already exists")
exists["hook_id"] = normalized_new_id if normalized_message is not None:
exists["message"] = normalized_message
self._save_raw(raw_hooks) self._save_raw(raw_hooks)
return HookRead( return self._deserialize_hook(exists)
hook_id=normalized_new_id,
chat_id=exists["chat_id"], def mark_hook_triggered(self, hook_id: str) -> HookRead:
message=exists["message"], timestamp = datetime.now(UTC).replace(microsecond=0).isoformat()
created_at=datetime.fromisoformat(exists["created_at"]), 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() 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) 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: async def update_hook_async(
return await run_in_threadpool(store.update_hook_id, current_hook_id, new_hook_id) 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)

View File

@@ -5,35 +5,52 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Message Hook</title> <title>Telegram Message Hook</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml" />
</head> </head>
<body> <body>
<header> <header>
<h1>Telegram Message Hook</h1> <div class="site-identity">
<p class="subtitle">Trigger curated messages in your chats via simple webhooks.</p> <img src="/static/logo.svg" alt="Telegram Message Hook logo" class="site-logo" />
<div class="site-title">
<h1>Telegram Message Hook</h1>
<p class="subtitle">Trigger curated messages in your chats via simple webhooks.</p>
</div>
</div>
</header> </header>
<main> <main>
<section id="session-section" class="card"> <section id="session-section" class="card" aria-label="Telegram session">
<h2>Telegram Session</h2> <div class="session-summary">
<div id="session-status" class="status-pill">Checking status…</div> <div class="session-summary-main">
<div id="session-user" class="user-info"></div> <div class="session-status-row">
<div id="login-forms"> <div id="session-status" class="status-pill">Checking status…</div>
<form id="start-login-form" class="form-grid"> <p id="session-summary-text" class="session-summary-text">Preparing session details…</p>
<label for="phone-number">Phone Number</label> </div>
<input type="tel" id="phone-number" name="phone" placeholder="+123456789" /> </div>
<button type="submit" class="primary">Send Verification Code</button> <button type="button" id="toggle-session" class="secondary session-toggle" aria-expanded="false">
</form> Manage session
</button>
<form id="verify-login-form" class="form-grid hidden"> </div>
<label for="verification-code">Verification Code</label> <div id="session-details" class="session-details hidden">
<input type="text" id="verification-code" name="code" placeholder="12345" /> <div id="session-user" class="user-info"></div>
<label for="twofactor-password">Password (if 2FA enabled)</label> <div id="login-forms">
<input type="password" id="twofactor-password" name="password" placeholder="Your password" /> <form id="start-login-form" class="form-grid">
<button type="submit" class="primary">Complete Login</button> <label for="phone-number">Phone Number</label>
</form> <input type="tel" id="phone-number" name="phone" placeholder="+123456789" />
<button type="submit" class="primary">Send Verification Code</button>
</form>
<form id="verify-login-form" class="form-grid hidden">
<label for="verification-code">Verification Code</label>
<input type="text" id="verification-code" name="code" placeholder="12345" />
<label for="twofactor-password">Password (if 2FA enabled)</label>
<input type="password" id="twofactor-password" name="password" placeholder="Your password" />
<button type="submit" class="primary">Complete Login</button>
</form>
</div>
<button id="logout-button" class="secondary hidden">Log out</button>
<p id="login-feedback" class="feedback"></p>
</div> </div>
<button id="logout-button" class="secondary hidden">Log out</button>
<p id="login-feedback" class="feedback"></p>
</section> </section>
<section id="hooks-section" class="card"> <section id="hooks-section" class="card">
@@ -63,28 +80,66 @@
<template id="hook-template"> <template id="hook-template">
<article class="hook-card"> <article class="hook-card">
<div class="hook-meta"> <div class="hook-meta">
<h3></h3> <div class="hook-heading">
<span class="hook-date"></span> <h3></h3>
<div class="hook-timestamps">
<span class="hook-date"></span>
<span class="hook-last-run"></span>
</div>
</div>
</div> </div>
<div class="hook-id-row"> <div class="hook-id-row">
<span class="hook-id-label">ID:</span> <span class="hook-id-label">ID:</span>
<code class="hook-id mono"></code> <code class="hook-id mono"></code>
<button class="secondary edit-id icon-button" aria-label="Edit hook ID" title="Edit hook ID"> </div>
<p class="hook-message"></p>
<code class="hook-url"></code>
<form class="edit-hook-form hidden">
<label>
Hook ID
<input type="text" class="edit-id" required />
</label>
<label>
Chat ID or Username
<input type="text" class="edit-chat" required />
</label>
<label>
Message (Markdown supported)
<textarea class="edit-message" rows="5" required></textarea>
</label>
<div class="edit-hook-actions">
<button type="submit" class="primary save-edit">Save changes</button>
<button type="button" class="secondary cancel-edit">Cancel</button>
</div>
</form>
<p class="hook-feedback"></p>
<div class="hook-actions">
<button class="primary trigger icon-button" aria-label="Trigger hook" title="Trigger hook">
<svg class="icon icon-play" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
<span class="sr-only">Trigger hook</span>
</button>
<button class="secondary copy icon-button" aria-label="Copy URL" title="Copy URL">
<svg class="icon icon-copy" viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v16h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 18H8V7h11v16z" />
</svg>
<span class="sr-only">Copy URL</span>
</button>
<button class="secondary edit-details icon-button" aria-label="Edit hook" title="Edit hook">
<svg class="icon icon-pen" viewBox="0 0 24 24" aria-hidden="true"> <svg class="icon icon-pen" viewBox="0 0 24 24" aria-hidden="true">
<path <path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm18.71-10.04a1 1 0 0 0 0-1.41l-2.51-2.51a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 2-1.66z" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zm18.71-10.04a1 1 0 0 0 0-1.41l-2.51-2.51a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 2-1.66z"
/> />
</svg> </svg>
<span class="sr-only">Edit hook ID</span> <span class="sr-only">Edit hook</span>
</button>
<button class="danger delete icon-button" aria-label="Delete hook" title="Delete hook">
<svg class="icon icon-trash" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3V4H4v2h1v15a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3h-6zm2 0h2v1h-2V3zm7 3v15H6V6h12zM9 8h2v9H9V8zm4 0h2v9h-2V8z" />
</svg>
<span class="sr-only">Delete hook</span>
</button> </button>
</div>
<p class="hook-message"></p>
<code class="hook-url"></code>
<p class="hook-feedback"></p>
<div class="hook-actions">
<button class="primary trigger">Play now</button>
<button class="secondary copy">Copy URL</button>
<button class="danger delete">Delete</button>
</div> </div>
</article> </article>
</template> </template>

View File

@@ -3,12 +3,21 @@
"hook_id": "anmich", "hook_id": "anmich",
"chat_id": "@troogs", "chat_id": "@troogs",
"message": "Test an mich", "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", "hook_id": "yszramzb",
"chat_id": "@Veleda_B", "chat_id": "@TroogS",
"message": ":*", "message": ":*\n*FETT!* nichtfett **Kursiiiieeev**",
"created_at": "2025-10-07T09:48:11+00:00" "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
} }
] ]

View File

@@ -1,4 +1,5 @@
import importlib import importlib
from datetime import datetime
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import List, Tuple 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 data["chat_id"] == payload["chat_id"]
assert payload["message"] in data["message"] assert payload["message"] in data["message"]
assert data["action_url"].endswith(f"/action/{data['hook_id']}") assert data["action_url"].endswith(f"/action/{data['hook_id']}")
assert data["last_triggered_at"] is None
list_resp = client.get("/api/hooks") list_resp = client.get("/api/hooks")
assert list_resp.status_code == 200 assert list_resp.status_code == 200
hooks = list_resp.json() hooks = list_resp.json()
assert len(hooks) == 1 assert len(hooks) == 1
assert hooks[0]["last_triggered_at"] is None
trigger_resp = client.get(f"/action/{data['hook_id']}") trigger_resp = client.get(f"/action/{data['hook_id']}")
assert trigger_resp.status_code == 200 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") call_log = getattr(client, "call_log")
assert call_log == [(payload["chat_id"], payload["message"])] 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" new_id = "customid123"
patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id}) patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id})
assert patch_resp.status_code == 200 assert patch_resp.status_code == 200
patched = patch_resp.json() patched = patch_resp.json()
assert patched["hook_id"] == new_id assert patched["hook_id"] == new_id
assert patched["action_url"].endswith(f"/action/{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 ID should now be gone
old_trigger = client.get(f"/action/{data['hook_id']}") 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 new_trigger.status_code == 200
assert getattr(client, "call_log") == [(payload["chat_id"], payload["message"])] 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: def test_login_verify_without_phone_number(client: TestClient) -> None:
response = client.post("/api/login/verify", json={"code": "123456"}) response = client.post("/api/login/verify", json={"code": "123456"})