Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,3 +277,8 @@ async def m012_chat_schedules_seen_and_notification_jobs(db):
);
"""
)


async def m013_persistent_notifications(db):
await db.execute("ALTER TABLE chat.categories ADD COLUMN persistent_notifications BOOLEAN NOT NULL DEFAULT FALSE;")
await db.execute("ALTER TABLE chat.chats ADD COLUMN last_admin_notification_at TIMESTAMP;")
3 changes: 3 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class CreateCategories(BaseModel):
claim_split: float | None = 0
guest_notifications: bool | None = False
public_note: str | None = DEFAULT_PUBLIC_NOTE
persistent_notifications: bool = False
notify_telegram: str | None = None
notify_nostr: str | None = None
notify_email: str | None = None
Expand Down Expand Up @@ -46,6 +47,7 @@ class Categories(BaseModel):
claim_split: float | None = 0
guest_notifications: bool | None = False
public_note: str | None = DEFAULT_PUBLIC_NOTE
persistent_notifications: bool = False
notify_telegram: str | None = None
notify_nostr: str | None = None
notify_email: str | None = None
Expand Down Expand Up @@ -152,6 +154,7 @@ class ChatSession(BaseModel):
participants: list[dict] = Field(default_factory=list)
messages: list[dict] = Field(default_factory=list)
last_message_at: datetime | None = None
last_admin_notification_at: datetime | None = None

created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
Expand Down
41 changes: 38 additions & 3 deletions services.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ async def _notify_new_chat(
message,
"chat.new",
)
chat.last_admin_notification_at = datetime.now(timezone.utc)


async def _notify_chat_reply(
Expand Down Expand Up @@ -566,7 +567,41 @@ def _sanitize_public_chat(chat: ChatSession) -> ChatSession:
return sanitized


async def _append_message(chat: ChatSession, message: ChatMessage, unread: bool) -> ChatSession:
async def _notify_persistent_message(chat: ChatSession, message: ChatMessage) -> None:
if message.sender_role != "public" or message.message_type != "message" or chat.resolved:
return
previous = [m for m in chat.messages if m.get("message_type", "message") == "message"]
if not previous or previous[0].get("sender_id") != message.sender_id:
return
if any(m.get("sender_role") == "admin" for m in previous):
return
category = await get_categories_by_id(chat.categories_id)
if not category or not category.persistent_notifications:
return
if not (category.notify_telegram or category.notify_nostr or category.notify_email):
return
last_notification = chat.last_admin_notification_at
if last_notification is None:
last_notification = datetime.fromisoformat(previous[0]["created_at"])
if last_notification.tzinfo is None:
last_notification = last_notification.replace(tzinfo=timezone.utc)
if message.created_at - last_notification < timedelta(minutes=15):
return
await send_notification(
category.notify_telegram,
[category.notify_nostr] if category.notify_nostr else [],
_parse_notify_emails(category.notify_email),
f'Unanswered chat: "{message.message}" {_build_chat_link(None, chat)}',
"chat.reminder",
)
chat.last_admin_notification_at = message.created_at


async def _append_message(
chat: ChatSession, message: ChatMessage, unread: bool, notify_persistent: bool = True
) -> ChatSession:
if notify_persistent:
await _notify_persistent_message(chat, message)
payload = _serialize_message(message)
chat.messages.append(payload)
chat.last_message_at = message.created_at
Expand Down Expand Up @@ -614,7 +649,7 @@ async def _handle_lnurlp_drawdown(
)
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)
await _append_message(chat, message, unread=True, notify_persistent=not after_hours)
if after_hours:
await _notify_after_hours_admin(category, chat, message, base_url)
else:
Expand Down Expand Up @@ -689,7 +724,7 @@ async def _send_free_message(
)
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)
await _append_message(chat, message, unread=True, notify_persistent=not after_hours)
if after_hours:
await _notify_after_hours_admin(category, chat, message, base_url)
elif data.sender_role == "public" and not user_id:
Expand Down
2 changes: 2 additions & 0 deletions static/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ window.PageChat = {
denomination: 'sat',
claim_split: 0,
guest_notifications: false,
persistent_notifications: false,
public_note:
'we aim to reply as soon as possible but it may take up to 24hrs for a reply',
notify_telegram: null,
Expand Down Expand Up @@ -262,6 +263,7 @@ window.PageChat = {
denomination: 'sat',
claim_split: 0,
guest_notifications: false,
persistent_notifications: false,
public_note:
'we aim to reply as soon as possible but it may take up to 24hrs for a reply',
notify_telegram: null,
Expand Down
17 changes: 17 additions & 0 deletions static/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,23 @@
label="Email address (comma separated)"
hint="Optional notification target"
></q-input>
<q-toggle
v-if="
categoriesFormDialog.data.notify_telegram ||
categoriesFormDialog.data.notify_nostr ||
categoriesFormDialog.data.notify_email
"
v-model="categoriesFormDialog.data.persistent_notifications"
label="Persistent notifications"
color="primary"
></q-toggle>
<div
v-if="categoriesFormDialog.data.persistent_notifications"
class="text-caption text-grey"
>
Notify again when the chat starter sends another message at least 15
minutes after the last notification, until support replies.
</div>
</q-expansion-item>

<q-expansion-item
Expand Down
90 changes: 90 additions & 0 deletions tests/test_persistent_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock
from uuid import uuid4

import pytest

from chat.crud import ( # type: ignore[import]
create_categories,
create_chat,
get_categories_by_id,
get_chat,
)
from chat.models import ChatMessage, ChatSession, CreateCategories # type: ignore[import]
from chat.services import _append_message # type: ignore[import]


@pytest.mark.asyncio
@pytest.mark.parametrize(
"enabled,elapsed,replied,sender,message_type,resolved,expected",
[
(True, 899, False, "guest", "message", False, 0),
(True, 900, False, "guest", "message", False, 1),
(True, 1800, False, "guest", "message", False, 1),
(False, 1800, False, "guest", "message", False, 0),
(True, 1800, True, "guest", "message", False, 0),
(True, 1800, False, "other", "message", False, 0),
(True, 1800, False, "guest", "tip", False, 0),
(True, 1800, False, "guest", "message", True, 0),
],
)
async def test_persistent_notification(
monkeypatch, enabled, elapsed, replied, sender, message_type, resolved, expected
):
notify = AsyncMock()
monkeypatch.setattr("chat.services.send_notification", notify)
monkeypatch.setattr("chat.services._broadcast_chat", AsyncMock())
category = await create_categories(
uuid4().hex,
CreateCategories(name="Support", persistent_notifications=enabled, notify_email="support@example.com"),
)
saved_category = await get_categories_by_id(category.id)
assert saved_category.persistent_notifications == enabled
now = datetime.now(timezone.utc)
initial = now - timedelta(seconds=elapsed)
messages = [
{
"id": "first",
"sender_id": "guest",
"sender_role": "public",
"message": "Hello",
"created_at": initial.isoformat(),
}
]
if replied:
messages.append({"id": "reply", "sender_id": "support", "sender_role": "admin", "message": "Hi"})
chat = ChatSession(
id=uuid4().hex,
categories_id=category.id,
messages=messages,
last_admin_notification_at=initial,
resolved=resolved,
)
await create_chat(category.id, chat)
message = ChatMessage(
id=uuid4().hex,
sender_id=sender,
sender_name=sender,
sender_role="public",
message="Anyone there?",
message_type=message_type,
created_at=now,
)
await _append_message(chat, message, unread=True)
assert notify.await_count == expected
saved = await get_chat(chat.id)
assert saved is not None
if expected:
assert abs((saved.last_admin_notification_at.replace(tzinfo=timezone.utc) - now).total_seconds()) < 1
assert notify.call_args.args[2] == ["support@example.com"]
assert "Anyone there?" in notify.call_args.args[3]
# A further message inside the cooldown must not send another reminder,
# including after loading the chat back from the database.
message.id = uuid4().hex
message.created_at = now + timedelta(minutes=1)
await _append_message(saved, message, unread=True)
assert notify.await_count == 1
message.id = uuid4().hex
message.created_at = now + timedelta(minutes=15)
await _append_message(saved, message, unread=True)
assert notify.await_count == 2
Loading