From 91ebefd6d1ff152b1ecbe7f8c0b1efbe780af63e Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:58:06 +0100 Subject: [PATCH 1/6] Email notifications and working hours Add Email notifications and working hours Public users are routed through different notification flows depending on availability: during working hours messages notify Telegram, with a delayed no-response reminder; outside working hours guests must leave an email and admins receive email/Telegram notifications. Also add editable per-category email templates, delayed guest reply emails that skip when replies are seen, public seen tracking, logged-in support replies from public chat links, and embedded chat persistence across refresh. --- .gitignore | 1 + __init__.py | 7 +- crud.py | 38 +++ models.py | 41 ++- services.py | 457 ++++++++++++++++++++++++++- static/embed.js | 115 ++++++- static/embed.vue | 98 ++++-- static/i18n/en.js | 38 +++ static/index.js | 142 ++++++++- static/index.vue | 117 ++++++- static/public_page.js | 46 ++- static/public_page.vue | 49 ++- static/routes.json | 18 +- tasks.py | 11 +- tests/test_schedule_notifications.py | 161 ++++++++++ views_api.py | 29 +- 16 files changed, 1284 insertions(+), 84 deletions(-) create mode 100644 static/i18n/en.js create mode 100644 tests/test_schedule_notifications.py diff --git a/.gitignore b/.gitignore index e68ab2e..a3f76bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__ +data node_modules .venv .mypy_cache diff --git a/__init__.py b/__init__.py index 2914a53..f2fcf1a 100644 --- a/__init__.py +++ b/__init__.py @@ -5,7 +5,7 @@ from loguru import logger from .crud import db -from .tasks import cleanup_empty_chats, wait_for_paid_invoices +from .tasks import cleanup_empty_chats, dispatch_notification_jobs, wait_for_paid_invoices from .views import chat_generic_router, chat_public_router from .views_api import chat_api_router from .views_lnurl import chat_lnurl_router @@ -44,6 +44,11 @@ def chat_start(): scheduled_tasks.append(task) cleanup_task = create_permanent_unique_task("ext_chat_cleanup", cleanup_empty_chats) scheduled_tasks.append(cleanup_task) + notification_task = create_permanent_unique_task( + "ext_chat_notifications", + dispatch_notification_jobs, + ) + scheduled_tasks.append(notification_task) __all__ = [ diff --git a/crud.py b/crud.py index c96a636..62384ea 100644 --- a/crud.py +++ b/crud.py @@ -4,6 +4,7 @@ from .models import ( Categories, CategoriesFilters, + ChatNotificationJob, ChatPayment, ChatSession, ChatsFilters, @@ -211,3 +212,40 @@ async def delete_empty_chats_before(cutoff: int) -> None: AND created_at < cast(:cutoff AS timestamp) """ await db.execute(query, {"cutoff": cutoff}) + + +################################# Notification Jobs ########################### + + +async def create_notification_job(job: ChatNotificationJob) -> ChatNotificationJob: + await db.insert("chat.notification_jobs", job) + return job + + +async def get_notification_job(job_id: str) -> ChatNotificationJob | None: + return await db.fetchone( + "SELECT * FROM chat.notification_jobs WHERE id = :id", + {"id": job_id}, + ChatNotificationJob, + ) + + +async def update_notification_job(job: ChatNotificationJob) -> ChatNotificationJob: + await db.update("chat.notification_jobs", job) + return job + + +async def get_due_notification_jobs( + due_at, + limit: int = 25, +) -> list[ChatNotificationJob]: + return await db.fetchall( + """ + SELECT * FROM chat.notification_jobs + WHERE status = 'pending' AND due_at <= :due_at + ORDER BY due_at ASC + LIMIT :limit + """, + {"due_at": due_at, "limit": limit}, + model=ChatNotificationJob, + ) diff --git a/models.py b/models.py index 954cd5d..9ed63bd 100644 --- a/models.py +++ b/models.py @@ -3,7 +3,7 @@ from lnbits.db import FilterModel from pydantic import BaseModel, Field -DEFAULT_PUBLIC_NOTE = "we aim to reply instantly but it may take up to 24hrs for a reply" +DEFAULT_PUBLIC_NOTE = "we aim to reply as soon as possible but it may take up to 24hrs for a reply" class CreateCategories(BaseModel): @@ -21,6 +21,15 @@ class CreateCategories(BaseModel): notify_telegram: str | None = None notify_nostr: str | None = None notify_email: str | None = None + schedule_enabled: bool | None = False + schedule_timezone: str | None = "UTC" + schedule_days: str | list[int] | None = "0,1,2,3,4" + schedule_start: str | None = "09:00" + schedule_end: str | None = "17:00" + admin_after_hours_subject: str | None = None + admin_after_hours_body: str | None = None + user_new_message_subject: str | None = None + user_new_message_body: str | None = None class Categories(BaseModel): @@ -40,6 +49,15 @@ class Categories(BaseModel): notify_telegram: str | None = None notify_nostr: str | None = None notify_email: str | None = None + schedule_enabled: bool | None = False + schedule_timezone: str | None = "UTC" + schedule_days: str | None = "0,1,2,3,4" + schedule_start: str | None = "09:00" + schedule_end: str | None = "17:00" + admin_after_hours_subject: str | None = None + admin_after_hours_body: str | None = None + user_new_message_subject: str | None = None + user_new_message_body: str | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -59,6 +77,12 @@ class PublicCategories(BaseModel): public_note: str | None = DEFAULT_PUBLIC_NOTE notify_email_available: bool | None = False notify_nostr_available: bool | None = False + schedule_enabled: bool | None = False + schedule_available: bool | None = True + schedule_timezone: str | None = None + schedule_start: str | None = None + schedule_end: str | None = None + schedule_days: list[int] = Field(default_factory=list) class CategoriesFilters(FilterModel): @@ -123,6 +147,8 @@ class ChatSession(BaseModel): claimed_by_name: str | None = None notify_email: str | None = None notify_nostr: str | None = None + public_last_seen_message_id: str | None = None + public_last_seen_at: datetime | None = None participants: list[dict] = Field(default_factory=list) messages: list[dict] = Field(default_factory=list) last_message_at: datetime | None = None @@ -141,6 +167,7 @@ class CreateChatMessage(BaseModel): sender_name: str sender_role: str message: str + notify_email: str | None = None class ChatNotifications(BaseModel): @@ -171,6 +198,18 @@ class ChatPayment(BaseModel): created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +class ChatNotificationJob(BaseModel): + id: str + chat_id: str + categories_id: str + job_type: str + message_id: str + due_at: datetime + status: str = "pending" + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class TipRequest(BaseModel): amount: int sender_id: str diff --git a/services.py b/services.py index fb5e537..f0b6372 100644 --- a/services.py +++ b/services.py @@ -1,12 +1,17 @@ import json import math -from datetime import datetime, timezone +from datetime import datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from lnbits.core.crud.users import get_user from lnbits.core.crud.wallets import get_wallets from lnbits.core.models import Payment from lnbits.core.services import create_invoice, pay_invoice, websocket_manager -from lnbits.core.services.notifications import send_notification +from lnbits.core.services.notifications import ( + send_email_notification, + send_notification, + send_telegram_notification, +) from lnbits.helpers import urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import fiat_amount_as_satoshis @@ -15,17 +20,21 @@ from .crud import ( create_chat, create_chat_payment, + create_notification_job, get_categories_by_id, get_chat, get_chat_for_category, get_chat_payment, + get_due_notification_jobs, update_chat, update_chat_payment, + update_notification_job, ) from .helpers import is_valid_email_address from .models import ( Categories, ChatMessage, + ChatNotificationJob, ChatParticipant, ChatPayment, ChatPaymentRequest, @@ -35,6 +44,17 @@ ) MAX_PARTICIPANTS = 10 +USER_EMAIL_DELAY_MINUTES = 5 +TELEGRAM_REMINDER_DELAY_MINUTES = 2 + +CHAT_TEMPLATE_DEFAULTS = { + "admin_after_hours_subject": "New chat message", + "admin_after_hours_body": ( + "New chat message from {sender_name} ({guest_email})\n\n" "{message}\n\nOpen chat: {chat_url}" + ), + "user_new_message_subject": "You have a new chat message", + "user_new_message_body": ("You have a new reply from {category_name}.\n\n" "{message}\n\nOpen chat: {chat_url}"), +} def _clean_name(value: str | None, fallback: str) -> str: @@ -129,6 +149,113 @@ def _parse_notify_emails(raw: str | None) -> list[str]: return [email.strip() for email in raw.split(",") if email.strip()] +def _is_true(value: str | bool | None) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _parse_schedule_days(value: str | list[int] | None) -> list[int]: + if not value: + return [] + if isinstance(value, list): + days = [int(day) for day in value] + if any(day < 0 or day > 6 for day in days): + raise ValueError("Schedule days must be between 0 and 6.") + return sorted(set(days)) + days = [] + for item in value.split(","): + item = item.strip() + if not item: + continue + day = int(item) + if day < 0 or day > 6: + raise ValueError("Schedule days must be between 0 and 6.") + days.append(day) + return sorted(set(days)) + + +def _schedule_days_to_string(value: str | list[int] | None) -> str: + if isinstance(value, list): + return ",".join(str(day) for day in sorted(set(value))) + return value or "0,1,2,3,4" + + +def _parse_schedule_time(value: str | None) -> time: + if not value: + raise ValueError("Schedule time is required.") + try: + return time.fromisoformat(value) + except ValueError as exc: + raise ValueError("Schedule time must use HH:MM format.") from exc + + +def _validate_timezone(value: str | None) -> str: + timezone_name = (value or "UTC").strip() or "UTC" + try: + ZoneInfo(timezone_name) + except ZoneInfoNotFoundError as exc: + raise ValueError("Unknown schedule timezone.") from exc + return timezone_name + + +def normalize_category_schedule_payload(data: dict) -> dict: + payload = dict(data) + payload["schedule_enabled"] = _is_true(payload.get("schedule_enabled")) + payload["schedule_timezone"] = _validate_timezone(payload.get("schedule_timezone")) + days = _parse_schedule_days(_schedule_days_to_string(payload.get("schedule_days"))) + payload["schedule_days"] = ",".join(str(day) for day in days) + start = _parse_schedule_time(payload.get("schedule_start") or "09:00") + end = _parse_schedule_time(payload.get("schedule_end") or "17:00") + if start >= end: + raise ValueError("Schedule start must be before schedule end.") + payload["schedule_start"] = start.strftime("%H:%M") + payload["schedule_end"] = end.strftime("%H:%M") + return payload + + +def _category_template(category: Categories, key: str) -> str: + value = getattr(category, key, None) + if value: + return value + return CHAT_TEMPLATE_DEFAULTS[key] + + +def is_chat_available_from_config( + config: dict[str, str], + now: datetime | None = None, +) -> bool: + if not _is_true(config.get("schedule_enabled")): + return True + timezone_name = _validate_timezone(config.get("schedule_timezone")) + tz = ZoneInfo(timezone_name) + local_now = now.astimezone(tz) if now else datetime.now(tz) + days = _parse_schedule_days(config.get("schedule_days")) + if local_now.weekday() not in days: + return False + start = _parse_schedule_time(config.get("schedule_start")) + end = _parse_schedule_time(config.get("schedule_end")) + return start <= local_now.time() <= end + + +def get_category_schedule_metadata(category: Categories) -> dict: + config = { + "schedule_enabled": "true" if category.schedule_enabled else "false", + "schedule_timezone": category.schedule_timezone or "UTC", + "schedule_days": category.schedule_days or "0,1,2,3,4", + "schedule_start": category.schedule_start or "09:00", + "schedule_end": category.schedule_end or "17:00", + } + return { + "schedule_enabled": _is_true(config.get("schedule_enabled")), + "schedule_available": is_chat_available_from_config(config), + "schedule_timezone": config.get("schedule_timezone"), + "schedule_start": config.get("schedule_start"), + "schedule_end": config.get("schedule_end"), + "schedule_days": _parse_schedule_days(config.get("schedule_days")), + } + + async def _resolve_category_wallet(category: Categories) -> str | None: if category.wallet: return category.wallet @@ -172,21 +299,207 @@ async def _notify_chat_reply( ) -> None: if not category.guest_notifications: return - email = (chat.notify_email or "").strip() nostr = (chat.notify_nostr or "").strip() - if not email and not nostr: + if not nostr: return chat_link = _build_chat_link(base_url, chat) message = f'New reply: "{message_text}" {chat_link}' await send_notification( None, [nostr] if nostr else [], - [email] if email else [], + [], message, "chat.reply", ) +async def _notify_chat_resolved_public(category: Categories, chat: ChatSession) -> None: + message_text = "This chat has been marked as resolved." + message = { + "sender_name": category.name, + "message": message_text, + } + values = _template_values(category, chat, message) + email = (chat.notify_email or "").strip() + nostr = (chat.notify_nostr or "").strip() + if email and settings.lnbits_email_notifications_enabled: + subject = _render_template(_category_template(category, "user_new_message_subject"), values) + body = _render_template(_category_template(category, "user_new_message_body"), values) + await send_email_notification([email], body, subject) + if nostr and settings.is_nostr_notifications_configured(): + await send_notification( + None, + [nostr], + [], + f"{message_text} {_build_chat_link(None, chat)}", + "chat.resolved", + ) + + +def _render_template(template: str, values: dict[str, str]) -> str: + class SafeValues(dict): + def __missing__(self, key): + return "{" + key + "}" + + return template.format_map(SafeValues(values)) + + +def _message_seen_by_public(chat: ChatSession, message_id: str) -> bool: + if not chat.public_last_seen_message_id: + return False + seen_index = -1 + message_index = -1 + for index, message in enumerate(chat.messages): + if message.get("id") == chat.public_last_seen_message_id: + seen_index = index + if message.get("id") == message_id: + message_index = index + return seen_index >= 0 and message_index >= 0 and seen_index >= message_index + + +def _latest_unanswered_public_message(chat: ChatSession) -> dict | None: + last_public = None + last_admin_index = -1 + for index, message in enumerate(chat.messages): + if message.get("sender_role") == "admin": + last_admin_index = index + if message.get("sender_role") == "public": + last_public = (index, message) + if not last_public: + return None + public_index, public_message = last_public + if last_admin_index > public_index: + return None + return public_message + + +def _template_values( + category: Categories, + chat: ChatSession, + message: dict, + base_url: str | None = None, + guest_email: str | None = None, +) -> dict[str, str]: + return { + "category_name": category.name, + "chat_id": chat.id, + "chat_url": _build_chat_link(base_url, chat), + "sender_name": str(message.get("sender_name") or "anon"), + "guest_email": guest_email or chat.notify_email or "", + "message": str(message.get("message") or ""), + } + + +async def _notify_after_hours_admin( + category: Categories, + chat: ChatSession, + message: ChatMessage, + base_url: str | None, +) -> None: + message_dict = _serialize_message(message) + values = _template_values(category, chat, message_dict, base_url, chat.notify_email) + subject = _render_template(_category_template(category, "admin_after_hours_subject"), values) + body = _render_template(_category_template(category, "admin_after_hours_body"), values) + emails = _parse_notify_emails(category.notify_email) + if emails: + await send_email_notification(emails, body, subject) + if category.notify_telegram and settings.is_telegram_notifications_configured(): + await send_telegram_notification(category.notify_telegram, body) + + +async def _schedule_user_reply_email( + category: Categories, + chat: ChatSession, + message: ChatMessage, +) -> None: + if not (chat.notify_email or "").strip(): + return + await create_notification_job( + ChatNotificationJob( + id=urlsafe_short_hash(), + chat_id=chat.id, + categories_id=chat.categories_id, + job_type="user_email", + message_id=message.id, + due_at=datetime.now(timezone.utc) + timedelta(minutes=USER_EMAIL_DELAY_MINUTES), + ) + ) + + +async def _schedule_public_telegram_reminder( + category: Categories, + chat: ChatSession, + message: ChatMessage, +) -> None: + if not category.notify_telegram: + return + config = get_category_schedule_metadata(category) + if not is_chat_available_from_config(config): + return + await create_notification_job( + ChatNotificationJob( + id=urlsafe_short_hash(), + chat_id=chat.id, + categories_id=chat.categories_id, + job_type="telegram_reminder", + message_id=message.id, + due_at=datetime.now(timezone.utc) + timedelta(minutes=TELEGRAM_REMINDER_DELAY_MINUTES), + ) + ) + + +async def _prepare_after_hours_email( + category: Categories, + chat: ChatSession, + data: CreateChatMessage, + user_id: str | None = None, +) -> bool: + config = get_category_schedule_metadata(category) + after_hours = data.sender_role == "public" and not user_id and not is_chat_available_from_config(config) + if not after_hours: + return False + email_value = (data.notify_email or "").strip() + if not email_value: + raise ValueError("Email address is required outside working hours.") + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled.") + if not is_valid_email_address(email_value): + raise ValueError("Invalid email address.") + chat.notify_email = email_value + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + return True + + +async def _prepare_guest_notify_email(chat: ChatSession, data: CreateChatMessage, user_id: str | None = None) -> None: + if user_id or data.sender_role != "public": + return + email_value = (data.notify_email or "").strip() + if not email_value: + return + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled.") + if not is_valid_email_address(email_value): + raise ValueError("Invalid email address.") + if chat.notify_email == email_value: + return + chat.notify_email = email_value + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + + +async def _notify_first_paid_message( + category: Categories, + chat: ChatSession, + message: ChatMessage, +) -> None: + config = get_category_schedule_metadata(category) + if is_chat_available_from_config(config): + await _notify_new_chat(category, chat, None, message.message) + else: + await _notify_after_hours_admin(category, chat, message, None) + + async def create_public_chat( categories_id: str, data: CreateChat, @@ -283,6 +596,7 @@ async def _handle_lnurlp_drawdown( data: CreateChatMessage, sender_name: str, base_url: str | None, + after_hours: bool = False, ) -> ChatPaymentRequest: if chat.balance < amount: raise ValueError("Insufficient balance. Fund the chat to continue.") @@ -298,9 +612,13 @@ async def _handle_lnurlp_drawdown( amount=amount, message_type="message", ) - if not chat.messages: + if not chat.messages and not after_hours: await _notify_new_chat(category, chat, base_url, data.message) await _append_message(chat, message, unread=True) + if after_hours: + await _notify_after_hours_admin(category, chat, message, base_url) + else: + await _schedule_public_telegram_reminder(category, chat, message) await _broadcast_balance(chat.id, chat.balance) return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) @@ -359,6 +677,7 @@ async def _send_free_message( sender_name: str, base_url: str | None, user_id: str | None = None, + after_hours: bool = False, ) -> ChatPaymentRequest: message = ChatMessage( id=urlsafe_short_hash(), @@ -368,9 +687,13 @@ async def _send_free_message( message=data.message, created_at=datetime.now(timezone.utc), ) - if not chat.messages: + if not chat.messages and not after_hours: await _notify_new_chat(category, chat, base_url, data.message) await _append_message(chat, message, unread=True) + if after_hours: + await _notify_after_hours_admin(category, chat, message, base_url) + elif data.sender_role == "public" and not user_id: + await _schedule_public_telegram_reminder(category, chat, message) if user_id: await _notify_chat_reply(category, chat, data.message, base_url) return ChatPaymentRequest(chat_id=chat.id, pending=False, message_id=message.id) @@ -394,6 +717,9 @@ async def send_public_message( sender_name = _clean_name(data.sender_name, "anon") _ensure_participant(chat, data.sender_id, sender_name, data.sender_role) + after_hours = await _prepare_after_hours_email(category, chat, data, user_id=user_id) + if not after_hours: + await _prepare_guest_notify_email(chat, data, user_id=user_id) if user_id and chat.claimed_by_id and chat.claimed_by_id != user_id: claimed_name = chat.claimed_by_name or "another user" @@ -404,12 +730,28 @@ async def send_public_message( amount = await _calculate_amount(category, data.message) if category.paid and category.lnurlp and amount > 0 and not user_id: - return await _handle_lnurlp_drawdown(category, chat, amount, data, sender_name, base_url) + return await _handle_lnurlp_drawdown( + category, + chat, + amount, + data, + sender_name, + base_url, + after_hours=after_hours, + ) if category.paid and amount > 0 and not user_id: return await _create_payg_payment_request(category, chat, amount, data, sender_name) - return await _send_free_message(category, chat, data, sender_name, base_url, user_id=user_id) + return await _send_free_message( + category, + chat, + data, + sender_name, + base_url, + user_id=user_id, + after_hours=after_hours, + ) async def send_admin_message( @@ -432,6 +774,7 @@ async def send_admin_message( await _append_message(chat, message, unread=False) category = await get_categories_by_id(chat.categories_id) if category: + await _schedule_user_reply_email(category, chat, message) await _notify_chat_reply(category, chat, data.message) return message @@ -478,6 +821,10 @@ async def mark_chat_resolved(chat_id: str, resolved: bool) -> ChatSession: chat.resolved = resolved chat.updated_at = datetime.now(timezone.utc) await update_chat(chat) + if resolved: + category = await get_categories_by_id(chat.categories_id) + if category: + await _notify_chat_resolved_public(category, chat) await _broadcast_chat(chat.id, {"type": "resolved", "resolved": resolved}) return chat @@ -494,6 +841,26 @@ async def mark_chat_seen(chat_id: str) -> ChatSession: return chat +async def mark_public_chat_seen(categories_id: str, chat_id: str) -> ChatSession: + chat = await get_chat_for_category(categories_id, chat_id) + if not chat: + raise ValueError("Chat not found.") + if chat.messages: + chat.public_last_seen_message_id = chat.messages[-1].get("id") + chat.public_last_seen_at = datetime.now(timezone.utc) + chat.updated_at = datetime.now(timezone.utc) + await update_chat(chat) + await _broadcast_chat( + chat.id, + { + "type": "public_seen", + "message_id": chat.public_last_seen_message_id, + "seen_at": chat.public_last_seen_at.isoformat(), + }, + ) + return _sanitize_public_chat(chat) + + async def request_tip( categories_id: str, chat_id: str, @@ -600,8 +967,12 @@ async def _finalize_chat_payment(chat_payment: ChatPayment) -> bool: if not chat.messages: category = await get_categories_by_id(chat.categories_id) if category: - await _notify_new_chat(category, chat, None, chat_payment.message) + await _notify_first_paid_message(category, chat, message) await _append_message(chat, message, unread=True) + if message.sender_role == "public": + category = await get_categories_by_id(chat.categories_id) + if category: + await _schedule_public_telegram_reminder(category, chat, message) return True @@ -620,6 +991,72 @@ async def payment_received_for_client_data(payment: Payment) -> bool: return await _finalize_chat_payment(chat_payment) +async def _mark_job(job: ChatNotificationJob, status: str) -> None: + job.status = status + job.updated_at = datetime.now(timezone.utc) + await update_notification_job(job) + + +async def _process_user_email_job(job: ChatNotificationJob) -> None: + chat = await get_chat(job.chat_id) + email = (chat.notify_email or "").strip() if chat else "" + if not chat or not email: + await _mark_job(job, "skipped") + return + if _message_seen_by_public(chat, job.message_id): + await _mark_job(job, "skipped") + return + category = await get_categories_by_id(job.categories_id) + if not category: + await _mark_job(job, "skipped") + return + message = next((m for m in chat.messages if m.get("id") == job.message_id), None) + if not message: + await _mark_job(job, "skipped") + return + values = _template_values(category, chat, message) + subject = _render_template(_category_template(category, "user_new_message_subject"), values) + body = _render_template(_category_template(category, "user_new_message_body"), values) + await send_email_notification([email], body, subject) + await _mark_job(job, "sent") + + +async def _process_telegram_reminder_job(job: ChatNotificationJob) -> None: + chat = await get_chat(job.chat_id) + category = await get_categories_by_id(job.categories_id) + if not chat or not category or not category.notify_telegram: + await _mark_job(job, "skipped") + return + config = get_category_schedule_metadata(category) + if not is_chat_available_from_config(config): + await _mark_job(job, "skipped") + return + latest_public = _latest_unanswered_public_message(chat) + if not latest_public or latest_public.get("id") != job.message_id: + await _mark_job(job, "skipped") + return + message = ( + f"No response after {TELEGRAM_REMINDER_DELAY_MINUTES} minutes: " + f'"{latest_public.get("message", "")}" {_build_chat_link(None, chat)}' + ) + await send_telegram_notification(category.notify_telegram, message) + await _mark_job(job, "sent") + + +async def process_due_notification_jobs() -> None: + jobs = await get_due_notification_jobs(datetime.now(timezone.utc)) + for job in jobs: + try: + if job.job_type == "user_email": + await _process_user_email_job(job) + elif job.job_type == "telegram_reminder": + await _process_telegram_reminder_job(job) + else: + await _mark_job(job, "skipped") + except Exception as exc: + logger.warning(f"chat: notification job failed: {exc}") + + async def toggle_chat_claim(chat_id: str, user_id: str) -> ChatSession: chat = await get_chat(chat_id) if not chat: diff --git a/static/embed.js b/static/embed.js index ca99ac4..e6cecd3 100644 --- a/static/embed.js +++ b/static/embed.js @@ -72,12 +72,27 @@ window.PageChatEmbed = { this.notificationsEnabled && !!this.publicPageData?.notify_nostr_available ) + }, + isAfterHours() { + return ( + !!this.publicPageData?.schedule_enabled && + !this.publicPageData?.schedule_available + ) + }, + canSendMessage() { + if (!this.messageInput || this.sending) return false + if (this.isAfterHours && !this.authUser && !this.notificationForm.email) + return false + return true } }, methods: { toggleMinimize() { this.isMinimized = !this.isMinimized this.notifyParent() + if (!this.isMinimized) { + this.markPublicSeen() + } }, notifyParent() { @@ -140,29 +155,76 @@ window.PageChatEmbed = { }, async ensureChat() { - const chatId = this.$route.params.chat + const chatId = + this.$route.params.chat || + this.$q.localStorage.getItem(this.chatStorageKey()) if (chatId) { this.chatId = chatId - await this.fetchChat() - this.loadNotificationForm() - return + try { + await this.fetchChat() + this.storeChatId() + this.updateChatUrl() + this.loadNotificationForm() + return + } catch (error) { + this.clearStoredChat() + console.warn(error) + } } - const payload = { - participant_id: this.participantId, - participant_name: this.participantName + await this.createChat() + }, + + chatStorageKey() { + return `lnbits.chat.embed.${this.categoriesId}.chat` + }, + + storeChatId() { + if (this.chatId) { + this.$q.localStorage.set(this.chatStorageKey(), this.chatId) } + }, + + clearStoredChat() { + this.$q.localStorage.remove(this.chatStorageKey()) + }, + + async createChat() { const {data} = await LNbits.api.request( 'POST', `/chat/api/v1/chats/${this.categoriesId}/public`, null, - payload + { + participant_id: this.participantId, + participant_name: this.participantName + } ) this.chatId = data.id this.chatData = data + this.storeChatId() this.updateChatUrl() this.loadNotificationForm() }, + async startNewChat() { + this.clearStoredChat() + this.chatId = '' + this.messageInput = '' + this.pendingAmount = 0 + this.closePaymentDialog() + if (this.chatSocket) { + this.chatSocket.close() + this.chatSocket = null + } + if (this.balanceSocket) { + this.balanceSocket.close() + this.balanceSocket = null + } + await this.createChat() + await this.fetchLnurl() + this.connectChatWebsocket() + this.connectBalanceWebsocket() + }, + notificationStorageKey() { if (!this.chatId) return '' return `lnbits.chat.notifications.${this.chatId}` @@ -216,7 +278,7 @@ window.PageChatEmbed = { }, updateChatUrl() { - if (this.$route?.params?.chat) return + this.storeChatId() const query = window.location.search || '' const target = `/chat/embed/${this.categoriesId}/${this.chatId}${query}` window.history.replaceState({}, '', target) @@ -228,6 +290,24 @@ window.PageChatEmbed = { `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public` ) this.chatData = data + await this.markPublicSeen() + }, + + async markPublicSeen() { + if (!this.chatId || document.hidden || this.isMinimized) return + try { + const {data} = await LNbits.api.request( + 'POST', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public/seen`, + null + ) + if (data?.public_last_seen_message_id) { + this.chatData.public_last_seen_message_id = + data.public_last_seen_message_id + } + } catch (error) { + console.warn(error) + } }, async toggleClaim() { @@ -306,8 +386,12 @@ window.PageChatEmbed = { const payload = { sender_id: this.participantId, sender_name: this.participantName, - sender_role: 'public', - message: messageText + sender_role: this.authUser ? 'admin' : 'public', + message: messageText, + notify_email: + this.isAfterHours && !this.authUser + ? this.notificationForm.email || '' + : undefined } const {data} = await LNbits.api.request( 'POST', @@ -468,6 +552,9 @@ window.PageChatEmbed = { role: message.sender_role }) } + if (message.sender_role === 'admin') { + this.markPublicSeen() + } } } if (payload.type === 'resolved') { @@ -524,7 +611,13 @@ window.PageChatEmbed = { this.connectBalanceWebsocket() this.notifyParent() }, + mounted() { + window.addEventListener('focus', this.markPublicSeen) + document.addEventListener('visibilitychange', this.markPublicSeen) + }, beforeUnmount() { + window.removeEventListener('focus', this.markPublicSeen) + document.removeEventListener('visibilitychange', this.markPublicSeen) if (this.chatSocket) { this.chatSocket.close() } diff --git a/static/embed.vue b/static/embed.vue index fba1827..9a25bd9 100644 --- a/static/embed.vue +++ b/static/embed.vue @@ -17,6 +17,15 @@ Start the conversation.
+ + Start a new chat +
- + +
+
+
+ + - - - Send a tip - - - Fund balance - +
+ + + Send a tip + + + Fund balance + +
Payment required ( sats) @@ -137,7 +171,7 @@ diff --git a/static/i18n/en.js b/static/i18n/en.js new file mode 100644 index 0000000..ea30568 --- /dev/null +++ b/static/i18n/en.js @@ -0,0 +1,38 @@ +window.i18n.global.mergeLocaleMessage('en', { + chat: { + settings: 'Settings', + chat_settings: 'Chat settings', + working_hours: 'Working hours', + enable_working_hours: 'Enable working hours', + timezone: 'Timezone', + timezone_hint: 'Search by city or timezone name.', + working_days: 'Working days', + start_time: 'Start time', + end_time: 'End time', + email_templates: 'Email templates', + admin_after_hours_email: 'Admin after-hours email', + user_new_message_email: 'User new-message email', + subject: 'Subject', + message: 'Message', + placeholders: + 'Available placeholders: category_name, chat_id, chat_url, sender_name, guest_email, message', + save_settings: 'Save settings', + settings_saved: 'Settings saved', + monday: 'Monday', + tuesday: 'Tuesday', + wednesday: 'Wednesday', + thursday: 'Thursday', + friday: 'Friday', + saturday: 'Saturday', + sunday: 'Sunday', + currently_available: 'Currently available', + currently_unavailable: 'Currently unavailable', + outside_working_hours: 'Outside working hours', + outside_working_hours_hint: + 'Leave your email address and message. We will reply by email.', + email: 'Email', + email_required: 'Email is required outside working hours.', + type_message: 'Type a message...', + send_message: 'Send message' + } +}) diff --git a/static/index.js b/static/index.js index c407f73..592625b 100644 --- a/static/index.js +++ b/static/index.js @@ -18,10 +18,21 @@ window.PageChat = { claim_split: 0, guest_notifications: false, public_note: - 'we aim to reply instantly but it may take up to 24hrs for a reply', + 'we aim to reply as soon as possible but it may take up to 24hrs for a reply', notify_telegram: null, notify_nostr: null, - notify_email: null + notify_email: null, + schedule_enabled: false, + schedule_timezone: 'UTC', + schedule_days: [0, 1, 2, 3, 4], + schedule_start: '09:00', + schedule_end: '17:00', + admin_after_hours_subject: 'New chat message', + admin_after_hours_body: + 'New chat message from {sender_name} ({guest_email})\n\n{message}\n\nOpen chat: {chat_url}', + user_new_message_subject: 'You have a new chat message', + user_new_message_body: + 'You have a new reply from {category_name}.\n\n{message}\n\nOpen chat: {chat_url}' } }, categoriesList: [], @@ -91,6 +102,8 @@ window.PageChat = { }, chatList: [], selectedChat: null, + timezoneOptionList: [], + filteredTimezoneOptions: [], chatSocket: null, messageInput: '', sending: false, @@ -107,6 +120,18 @@ window.PageChat = { const rows = this.chatsTable.pagination.rowsNumber || 0 const perPage = this.chatsTable.pagination.rowsPerPage || 1 return Math.max(1, Math.ceil(rows / perPage)) + }, + + scheduleDayOptions() { + return [ + {label: this.$t('chat.monday'), value: 0}, + {label: this.$t('chat.tuesday'), value: 1}, + {label: this.$t('chat.wednesday'), value: 2}, + {label: this.$t('chat.thursday'), value: 3}, + {label: this.$t('chat.friday'), value: 4}, + {label: this.$t('chat.saturday'), value: 5}, + {label: this.$t('chat.sunday'), value: 6} + ] } }, watch: { @@ -144,6 +169,55 @@ window.PageChat = { } }, methods: { + buildTimezoneOptions() { + let zones = [] + if (Intl.supportedValuesOf) { + try { + zones = Intl.supportedValuesOf('timeZone') + } catch (_) { + zones = [] + } + } + if (!zones.length) { + zones = [ + 'UTC', + 'Africa/Johannesburg', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/New_York', + 'America/Sao_Paulo', + 'Asia/Dubai', + 'Asia/Hong_Kong', + 'Asia/Singapore', + 'Asia/Tokyo', + 'Australia/Sydney', + 'Europe/Amsterdam', + 'Europe/Berlin', + 'Europe/London', + 'Europe/Madrid', + 'Europe/Paris' + ] + } + if (!zones.includes('UTC')) { + zones = ['UTC', ...zones] + } + return zones.map(zone => ({label: zone, value: zone})) + }, + + filterTimezones(value, update) { + update(() => { + const needle = (value || '').toLowerCase() + if (!needle) { + this.filteredTimezoneOptions = this.timezoneOptionList + return + } + this.filteredTimezoneOptions = this.timezoneOptionList.filter(option => + option.label.toLowerCase().includes(needle) + ) + }) + }, + getChatScrollEl() { const ref = this.$refs.adminChatScroll if (!ref) return null @@ -189,15 +263,45 @@ window.PageChat = { claim_split: 0, guest_notifications: false, public_note: - 'we aim to reply instantly but it may take up to 24hrs for a reply', + 'we aim to reply as soon as possible but it may take up to 24hrs for a reply', notify_telegram: null, notify_nostr: null, - notify_email: null + notify_email: null, + schedule_enabled: false, + schedule_timezone: 'UTC', + schedule_days: [0, 1, 2, 3, 4], + schedule_start: '09:00', + schedule_end: '17:00', + admin_after_hours_subject: 'New chat message', + admin_after_hours_body: + 'New chat message from {sender_name} ({guest_email})\n\n{message}\n\nOpen chat: {chat_url}', + user_new_message_subject: 'You have a new chat message', + user_new_message_body: + 'You have a new reply from {category_name}.\n\n{message}\n\nOpen chat: {chat_url}' } this.categoriesFormDialog.show = true }, async showEditCategoriesForm(data) { - this.categoriesFormDialog.data = {...data} + this.categoriesFormDialog.data = { + ...data, + schedule_enabled: + data.schedule_enabled === true || data.schedule_enabled === 'true', + schedule_days: (data.schedule_days || '0,1,2,3,4') + .toString() + .split(',') + .filter(item => item !== '') + .map(item => Number(item)), + admin_after_hours_subject: + data.admin_after_hours_subject || 'New chat message', + admin_after_hours_body: + data.admin_after_hours_body || + 'New chat message from {sender_name} ({guest_email})\n\n{message}\n\nOpen chat: {chat_url}', + user_new_message_subject: + data.user_new_message_subject || 'You have a new chat message', + user_new_message_body: + data.user_new_message_body || + 'You have a new reply from {category_name}.\n\n{message}\n\nOpen chat: {chat_url}' + } this.categoriesFormDialog.show = true }, async saveCategories() { @@ -331,6 +435,19 @@ window.PageChat = { return message.sender_role === 'admin' }, + publicHasSeenMessage(message) { + if (!this.selectedChat || message.sender_role !== 'admin') return false + const seenId = this.selectedChat.public_last_seen_message_id + if (!seenId) return false + const messageIndex = this.selectedChat.messages.findIndex( + item => item.id === message.id + ) + const seenIndex = this.selectedChat.messages.findIndex( + item => item.id === seenId + ) + return messageIndex >= 0 && seenIndex >= messageIndex + }, + messageColor(message) { const palette = [ 'blue-1', @@ -419,6 +536,10 @@ window.PageChat = { this.selectedChat.resolved = payload.resolved this.updateChatListEntry(this.selectedChat) } + if (payload.type === 'public_seen' && this.selectedChat) { + this.selectedChat.public_last_seen_message_id = payload.message_id + this.selectedChat.public_last_seen_at = payload.seen_at + } } catch (err) { console.warn('Chat websocket message failed', err) } @@ -455,9 +576,10 @@ window.PageChat = { return `${window.location.origin}/chat/${chat.categories_id}/${chat.id}` }, - showEmbedDialog(category) { + embedCode(category) { + if (!category) return '' const src = `${window.location.origin}/chat/embed/${category.id}?min=1&label=${encodeURIComponent('Chat to us')}` - this.embedDialog.iframe = `
+ return `
` + }, + + showEmbedDialog(category) { + this.embedDialog.iframe = this.embedCode(category) this.embedDialog.show = true }, @@ -508,6 +634,8 @@ window.PageChat = { } }, async created() { + this.timezoneOptionList = this.buildTimezoneOptions() + this.filteredTimezoneOptions = this.timezoneOptionList await this.fetchCurrencies() await this.getCategories() await this.getChats() diff --git a/static/index.vue b/static/index.vue index 4e23315..0ee5ee2 100644 --- a/static/index.vue +++ b/static/index.vue @@ -289,7 +289,18 @@ Tip
-
+
+ + + Seen by public user + +
+ + + + +
+ + +
+
+ + +
+ + + + +
+
Update diff --git a/static/public_page.js b/static/public_page.js index dabc8e3..c3a4a78 100644 --- a/static/public_page.js +++ b/static/public_page.js @@ -64,6 +64,18 @@ window.PageChatPublic = { this.notificationsEnabled && !!this.publicPageData?.notify_nostr_available ) + }, + isAfterHours() { + return ( + !!this.publicPageData?.schedule_enabled && + !this.publicPageData?.schedule_available + ) + }, + canSendMessage() { + if (!this.messageInput || this.sending) return false + if (this.isAfterHours && !this.authUser && !this.notificationForm.email) + return false + return true } }, @@ -259,11 +271,30 @@ window.PageChatPublic = { `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public` ) this.chatData = data + await this.markPublicSeen() this.autoScroll = true await this.scrollToBottomSmooth() }, + async markPublicSeen() { + if (!this.chatId || document.hidden) return + if (!this.autoScroll) return + try { + const {data} = await LNbits.api.request( + 'POST', + `/chat/api/v1/chats/${this.categoriesId}/${this.chatId}/public/seen`, + null + ) + if (data?.public_last_seen_message_id) { + this.chatData.public_last_seen_message_id = + data.public_last_seen_message_id + } + } catch (error) { + console.warn(error) + } + }, + async toggleClaim() { if (!this.authUser) return try { @@ -332,8 +363,12 @@ window.PageChatPublic = { const payload = { sender_id: this.participantId, sender_name: this.participantName, - sender_role: 'public', - message: messageText + sender_role: this.authUser ? 'admin' : 'public', + message: messageText, + notify_email: + this.isAfterHours && !this.authUser + ? this.notificationForm.email || '' + : undefined } const {data} = await LNbits.api.request( 'POST', @@ -510,6 +545,9 @@ window.PageChatPublic = { }) } } + if (message.sender_role === 'admin') { + this.markPublicSeen() + } } if (payload.type === 'resolved') { this.chatData.resolved = payload.resolved @@ -567,9 +605,13 @@ window.PageChatPublic = { // One extra nudge once the DOM exists this.autoScroll = true this.scrollToBottomSmooth() + window.addEventListener('focus', this.markPublicSeen) + document.addEventListener('visibilitychange', this.markPublicSeen) }, beforeUnmount() { + window.removeEventListener('focus', this.markPublicSeen) + document.removeEventListener('visibilitychange', this.markPublicSeen) if (this.chatSocket) { this.chatSocket.close() } diff --git a/static/public_page.vue b/static/public_page.vue index b790c1e..3373276 100644 --- a/static/public_page.vue +++ b/static/public_page.vue @@ -6,7 +6,7 @@
- +
@@ -32,7 +32,7 @@ @@ -66,25 +66,54 @@
- +
- + +
+
+
+ + -
+ > +
diff --git a/static/routes.json b/static/routes.json index 1e84c46..03c13d3 100644 --- a/static/routes.json +++ b/static/routes.json @@ -3,36 +3,42 @@ "path": "/chat/", "name": "PageChat", "template": "/chat/static/index.vue", - "component": "/chat/static/index.js" + "component": "/chat/static/index.js", + "i18n": "/chat/static/i18n" }, { "path": "/chat/:id", "name": "PageChatPublic", "template": "/chat/static/public_page.vue", - "component": "/chat/static/public_page.js" + "component": "/chat/static/public_page.js", + "i18n": "/chat/static/i18n" }, { "path": "/chat/:id/:chat", "name": "PageChatPublicChat", "template": "/chat/static/public_page.vue", - "component": "/chat/static/public_page.js" + "component": "/chat/static/public_page.js", + "i18n": "/chat/static/i18n" }, { "path": "/chat/embed/:id", "name": "PageChatEmbed", "template": "/chat/static/embed.vue", - "component": "/chat/static/embed.js" + "component": "/chat/static/embed.js", + "i18n": "/chat/static/i18n" }, { "path": "/chat/embed/:id/:chat", "name": "PageChatEmbedChat", "template": "/chat/static/embed.vue", - "component": "/chat/static/embed.js" + "component": "/chat/static/embed.js", + "i18n": "/chat/static/i18n" }, { "path": "/chat/chats/:id", "name": "PageChatInstanceList", "template": "/chat/static/instance_list.vue", - "component": "/chat/static/instance_list.js" + "component": "/chat/static/instance_list.js", + "i18n": "/chat/static/i18n" } ] diff --git a/tasks.py b/tasks.py index 3c64530..3b84c50 100644 --- a/tasks.py +++ b/tasks.py @@ -7,7 +7,7 @@ from loguru import logger from .crud import delete_empty_chats_before -from .services import payment_received_for_client_data +from .services import payment_received_for_client_data, process_due_notification_jobs async def wait_for_paid_invoices(): @@ -38,3 +38,12 @@ async def cleanup_empty_chats() -> None: except Exception as e: logger.warning(f"Error cleaning empty chats: {e}") await asyncio.sleep(60) + + +async def dispatch_notification_jobs() -> None: + while settings.lnbits_running: + try: + await process_due_notification_jobs() + except Exception as e: + logger.warning(f"Error processing chat notification jobs: {e}") + await asyncio.sleep(30) diff --git a/tests/test_schedule_notifications.py b/tests/test_schedule_notifications.py new file mode 100644 index 0000000..1488c72 --- /dev/null +++ b/tests/test_schedule_notifications.py @@ -0,0 +1,161 @@ +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +import pytest +from lnbits.helpers import urlsafe_short_hash + +from chat.crud import ( # type: ignore[import] + create_categories, + create_chat, + create_notification_job, + get_notification_job, +) +from chat.models import ( # type: ignore[import] + ChatNotificationJob, + ChatSession, + CreateCategories, +) +from chat.services import ( # type: ignore[import] + CHAT_TEMPLATE_DEFAULTS, + _render_template, + get_category_schedule_metadata, + is_chat_available_from_config, + process_due_notification_jobs, +) + + +@pytest.mark.asyncio +async def test_template_defaults_are_available(): + assert CHAT_TEMPLATE_DEFAULTS["admin_after_hours_subject"] == "New chat message" + assert CHAT_TEMPLATE_DEFAULTS["user_new_message_subject"] == "You have a new chat message" + + +def test_schedule_evaluation_with_timezone_and_days(): + config = { + "schedule_enabled": "true", + "schedule_timezone": "UTC", + "schedule_days": "0,1,2,3,4", + "schedule_start": "09:00", + "schedule_end": "17:00", + } + + monday_midday = datetime(2026, 7, 6, 12, 0, tzinfo=timezone.utc) + monday_evening = datetime(2026, 7, 6, 18, 0, tzinfo=timezone.utc) + saturday_midday = datetime(2026, 7, 11, 12, 0, tzinfo=timezone.utc) + + assert is_chat_available_from_config(config, monday_midday) + assert not is_chat_available_from_config(config, monday_evening) + assert not is_chat_available_from_config(config, saturday_midday) + + +@pytest.mark.asyncio +async def test_category_schedule_metadata(): + category = await create_categories( + uuid4().hex, + CreateCategories( + name=f"category-{urlsafe_short_hash()}", + schedule_enabled=True, + schedule_timezone="UTC", + schedule_days="0,1,2", + schedule_start="08:00", + schedule_end="12:00", + ), + ) + + metadata = get_category_schedule_metadata(category) + + assert metadata["schedule_enabled"] is True + assert metadata["schedule_timezone"] == "UTC" + assert metadata["schedule_days"] == [0, 1, 2] + + +def test_template_rendering_keeps_unknown_placeholders(): + rendered = _render_template( + "Hello {sender_name}, keep {unknown}", + {"sender_name": "Alice"}, + ) + + assert rendered == "Hello Alice, keep {unknown}" + + +async def _create_chat_with_admin_message(seen: bool) -> tuple[str, str, str]: + category = await create_categories( + uuid4().hex, + CreateCategories(name=f"category-{urlsafe_short_hash()}"), + ) + message_id = urlsafe_short_hash() + chat = ChatSession( + id=urlsafe_short_hash(), + categories_id=category.id, + notify_email="guest@example.com", + public_last_seen_message_id=message_id if seen else None, + messages=[ + { + "id": message_id, + "sender_id": "admin-support", + "sender_name": "support", + "sender_role": "admin", + "message": "Reply", + "created_at": datetime.now(timezone.utc).isoformat(), + } + ], + ) + await create_chat(category.id, chat) + return category.id, chat.id, message_id + + +@pytest.mark.asyncio +async def test_user_email_job_skips_seen_message(monkeypatch): + category_id, chat_id, message_id = await _create_chat_with_admin_message(True) + sent = [] + + async def fake_send_email(to_emails, message, subject): + sent.append((to_emails, message, subject)) + + monkeypatch.setattr("chat.services.send_email_notification", fake_send_email) + job = await create_notification_job( + ChatNotificationJob( + id=urlsafe_short_hash(), + chat_id=chat_id, + categories_id=category_id, + job_type="user_email", + message_id=message_id, + due_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + ) + + await process_due_notification_jobs() + saved = await get_notification_job(job.id) + + assert sent == [] + assert saved + assert saved.status == "skipped" + + +@pytest.mark.asyncio +async def test_user_email_job_sends_unseen_message(monkeypatch): + category_id, chat_id, message_id = await _create_chat_with_admin_message(False) + sent = [] + + async def fake_send_email(to_emails, message, subject): + sent.append((to_emails, message, subject)) + + monkeypatch.setattr("chat.services.send_email_notification", fake_send_email) + job = await create_notification_job( + ChatNotificationJob( + id=urlsafe_short_hash(), + chat_id=chat_id, + categories_id=category_id, + job_type="user_email", + message_id=message_id, + due_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + ) + + await process_due_notification_jobs() + saved = await get_notification_job(job.id) + + assert sent + assert sent[0][0] == ["guest@example.com"] + assert saved + assert saved.status == "sent" diff --git a/views_api.py b/views_api.py index 04de53c..304ffd3 100644 --- a/views_api.py +++ b/views_api.py @@ -39,9 +39,12 @@ ) from .services import ( create_public_chat, + get_category_schedule_metadata, get_public_chat, mark_chat_resolved, mark_chat_seen, + mark_public_chat_seen, + normalize_category_schedule_payload, request_tip, send_admin_message, send_public_message, @@ -69,7 +72,11 @@ async def api_create_categories( payload["claim_split"] = max(0, min(float(payload["claim_split"]), 90)) if payload.get("public_note") is None: payload["public_note"] = DEFAULT_PUBLIC_NOTE - categories = await create_categories(account_id.id, CreateCategories(**payload)) + try: + payload = normalize_category_schedule_payload(payload) + categories = await create_categories(account_id.id, CreateCategories(**payload)) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc return categories @@ -92,7 +99,11 @@ async def api_update_categories( payload["claim_split"] = max(0, min(float(payload["claim_split"]), 90)) if payload.get("public_note") is None: payload["public_note"] = DEFAULT_PUBLIC_NOTE - categories = await update_categories(Categories(**{**categories.dict(), **payload})) + try: + payload = normalize_category_schedule_payload(payload) + categories = await update_categories(Categories(**{**categories.dict(), **payload})) + except ValueError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc return categories @@ -153,6 +164,7 @@ async def api_get_public_categories(categories_id: str) -> PublicCategories: payload["public_note"] = DEFAULT_PUBLIC_NOTE payload["notify_email_available"] = settings.lnbits_email_notifications_enabled payload["notify_nostr_available"] = settings.is_nostr_notifications_configured() + payload.update(get_category_schedule_metadata(categories)) return PublicCategories(**payload) @@ -266,6 +278,19 @@ async def api_update_chat_notifications( raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc +@chat_api_router.post( + "/api/v1/chats/{categories_id}/{chat_id}/public/seen", + name="Mark Chat Seen (Public)", + summary="Mark the public chat window as having seen the current messages.", + response_model=ChatSession, +) +async def api_mark_public_chat_seen(categories_id: str, chat_id: str) -> ChatSession: + try: + return await mark_public_chat_seen(categories_id, chat_id) + except ValueError as exc: + raise HTTPException(HTTPStatus.NOT_FOUND, str(exc)) from exc + + @chat_api_router.post( "/api/v1/chats/{categories_id}/{chat_id}/public/claim", name="Toggle Chat Claim", From 8ccaabc8bb2ea0abdddbf4dba358d55f239b5e3e Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:12:56 +0100 Subject: [PATCH 2/6] Remove .trim from message input as it causes awkward input issues on mobile --- static/embed.vue | 2 +- static/index.vue | 2 +- static/public_page.vue | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/static/embed.vue b/static/embed.vue index 9a25bd9..ffc5175 100644 --- a/static/embed.vue +++ b/static/embed.vue @@ -99,7 +99,7 @@ Date: Thu, 9 Jul 2026 09:14:29 +0100 Subject: [PATCH 3/6] Commented ensureChat --- static/embed.js | 1 + 1 file changed, 1 insertion(+) diff --git a/static/embed.js b/static/embed.js index e6cecd3..8c8a55e 100644 --- a/static/embed.js +++ b/static/embed.js @@ -154,6 +154,7 @@ window.PageChatEmbed = { } }, + // Restore the previous embedded chat for this category, or create one if none exists. async ensureChat() { const chatId = this.$route.params.chat || From aefdbe267297a6c804d9481c5e43ff60539bdeb5 Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:21:11 +0100 Subject: [PATCH 4/6] Set Europe/London as the default timezone for schedule --- models.py | 4 ++-- services.py | 4 ++-- static/index.js | 4 ++-- tests/test_schedule_notifications.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/models.py b/models.py index 9ed63bd..8646bcc 100644 --- a/models.py +++ b/models.py @@ -22,7 +22,7 @@ class CreateCategories(BaseModel): notify_nostr: str | None = None notify_email: str | None = None schedule_enabled: bool | None = False - schedule_timezone: str | None = "UTC" + schedule_timezone: str | None = "Europe/London" schedule_days: str | list[int] | None = "0,1,2,3,4" schedule_start: str | None = "09:00" schedule_end: str | None = "17:00" @@ -50,7 +50,7 @@ class Categories(BaseModel): notify_nostr: str | None = None notify_email: str | None = None schedule_enabled: bool | None = False - schedule_timezone: str | None = "UTC" + schedule_timezone: str | None = "Europe/London" schedule_days: str | None = "0,1,2,3,4" schedule_start: str | None = "09:00" schedule_end: str | None = "17:00" diff --git a/services.py b/services.py index f0b6372..f139c28 100644 --- a/services.py +++ b/services.py @@ -191,7 +191,7 @@ def _parse_schedule_time(value: str | None) -> time: def _validate_timezone(value: str | None) -> str: - timezone_name = (value or "UTC").strip() or "UTC" + timezone_name = (value or "Europe/London").strip() or "Europe/London" try: ZoneInfo(timezone_name) except ZoneInfoNotFoundError as exc: @@ -241,7 +241,7 @@ def is_chat_available_from_config( def get_category_schedule_metadata(category: Categories) -> dict: config = { "schedule_enabled": "true" if category.schedule_enabled else "false", - "schedule_timezone": category.schedule_timezone or "UTC", + "schedule_timezone": category.schedule_timezone or "Europe/London", "schedule_days": category.schedule_days or "0,1,2,3,4", "schedule_start": category.schedule_start or "09:00", "schedule_end": category.schedule_end or "17:00", diff --git a/static/index.js b/static/index.js index 592625b..1c003b4 100644 --- a/static/index.js +++ b/static/index.js @@ -23,7 +23,7 @@ window.PageChat = { notify_nostr: null, notify_email: null, schedule_enabled: false, - schedule_timezone: 'UTC', + schedule_timezone: 'Europe/London', schedule_days: [0, 1, 2, 3, 4], schedule_start: '09:00', schedule_end: '17:00', @@ -268,7 +268,7 @@ window.PageChat = { notify_nostr: null, notify_email: null, schedule_enabled: false, - schedule_timezone: 'UTC', + schedule_timezone: 'Europe/London', schedule_days: [0, 1, 2, 3, 4], schedule_start: '09:00', schedule_end: '17:00', diff --git a/tests/test_schedule_notifications.py b/tests/test_schedule_notifications.py index 1488c72..da2f3ba 100644 --- a/tests/test_schedule_notifications.py +++ b/tests/test_schedule_notifications.py @@ -33,7 +33,7 @@ async def test_template_defaults_are_available(): def test_schedule_evaluation_with_timezone_and_days(): config = { "schedule_enabled": "true", - "schedule_timezone": "UTC", + "schedule_timezone": "Europe/London", "schedule_days": "0,1,2,3,4", "schedule_start": "09:00", "schedule_end": "17:00", @@ -55,7 +55,7 @@ async def test_category_schedule_metadata(): CreateCategories( name=f"category-{urlsafe_short_hash()}", schedule_enabled=True, - schedule_timezone="UTC", + schedule_timezone="Europe/London", schedule_days="0,1,2", schedule_start="08:00", schedule_end="12:00", @@ -65,7 +65,7 @@ async def test_category_schedule_metadata(): metadata = get_category_schedule_metadata(category) assert metadata["schedule_enabled"] is True - assert metadata["schedule_timezone"] == "UTC" + assert metadata["schedule_timezone"] == "Europe/London" assert metadata["schedule_days"] == [0, 1, 2] From ec8f44440c3a883d45adb364d9d5a11dc61b0753 Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:21:26 +0100 Subject: [PATCH 5/6] Fix `make all` warnings --- Makefile | 20 ++++++++++---------- pyproject.toml | 6 ++++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 93f3542..333daea 100644 --- a/Makefile +++ b/Makefile @@ -5,27 +5,27 @@ format: prettier black ruff check: mypy pyright checkblack checkruff checkprettier prettier: - uv run ./node_modules/.bin/prettier --write . + uv --project . --no-config run ./node_modules/.bin/prettier --write . pyright: - uv run ./node_modules/.bin/pyright + uv --project . --no-config run ./node_modules/.bin/pyright mypy: - uv run mypy . + uv --project . --no-config run mypy . black: - uv run black . + uv --project . --no-config run black . ruff: - uv run ruff check . --fix + uv --project . --no-config run ruff check . --fix checkruff: - uv run ruff check . + uv --project . --no-config run ruff check . checkprettier: - uv run ./node_modules/.bin/prettier --check . + uv --project . --no-config run ./node_modules/.bin/prettier --check . checkblack: - uv run black --check . + uv --project . --no-config run black --check . checkeditorconfig: editorconfig-checker @@ -33,7 +33,7 @@ checkeditorconfig: test: PYTHONUNBUFFERED=1 \ DEBUG=true \ - uv run pytest + uv --project . --no-config run pytest install-pre-commit-hook: @echo "Installing pre-commit hook to git" @@ -41,7 +41,7 @@ install-pre-commit-hook: uv run pre-commit install pre-commit: - uv run pre-commit run --all-files + uv --project . --no-config run pre-commit run --all-files checkbundle: diff --git a/pyproject.toml b/pyproject.toml index b3f2bcd..dc35978 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ dependencies = [ "lnbits>=1.4.0" ] [tool.poetry] package-mode = false -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "black>=24.3.0", "pytest-asyncio>=0.21.0", "pytest>=7.3.2", @@ -23,6 +23,8 @@ dev-dependencies = [ [tool.mypy] plugins = ["pydantic.mypy"] +ignore_missing_imports = true +follow_imports = "silent" [tool.pydantic-mypy] init_forbid_extra = true From 1ca659541ab02905c9a571b2c36885e1ebd36d16 Mon Sep 17 00:00:00 2001 From: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:13:04 +0100 Subject: [PATCH 6/6] Added missing migration for scheduled jobs --- migrations.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/migrations.py b/migrations.py index d43670e..6461d57 100644 --- a/migrations.py +++ b/migrations.py @@ -200,3 +200,80 @@ async def m011_chat_guest_notifications(db): ALTER TABLE chat.chats ADD COLUMN notify_nostr TEXT; """ ) + + +async def m012_chat_schedules_seen_and_notification_jobs(db): + """ + Add schedule/template fields, public seen fields, and notification jobs table. + """ + + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN schedule_enabled BOOLEAN DEFAULT FALSE; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN schedule_timezone TEXT DEFAULT 'Europe/London'; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN schedule_days TEXT DEFAULT '0,1,2,3,4'; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN schedule_start TEXT DEFAULT '09:00'; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN schedule_end TEXT DEFAULT '17:00'; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN admin_after_hours_subject TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN admin_after_hours_body TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN user_new_message_subject TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.categories ADD COLUMN user_new_message_body TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.chats ADD COLUMN public_last_seen_message_id TEXT; + """ + ) + await db.execute( + """ + ALTER TABLE chat.chats ADD COLUMN public_last_seen_at TIMESTAMP; + """ + ) + await db.execute( + f""" + CREATE TABLE chat.notification_jobs ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + categories_id TEXT NOT NULL, + job_type TEXT NOT NULL, + message_id TEXT NOT NULL, + due_at TIMESTAMP NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, + updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """ + )