From 8091c608e880155af814c66344ffc8254b6dbda6 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Mon, 23 Feb 2026 10:51:47 +0300 Subject: [PATCH] feat: add group duty pin notification feature - Introduced a new configuration option `DUTY_PIN_NOTIFY` to control whether the bot re-pins the duty message when updated, providing notifications to group members. - Updated the architecture documentation to reflect the new functionality of re-pinning duty messages. - Enhanced the `.env.example` file to include the new configuration option with a description. - Added tests to verify the behavior of the new refresh pin command and its integration with the existing group duty pin functionality. - Updated internationalization messages to include help text for the new `/refresh_pin` command. --- .coverage | Bin 53248 -> 53248 bytes .env.example | 3 + CHANGELOG.md | 6 + docs/architecture.md | 2 +- docs/configuration.md | 1 + duty_teller/api/personal_calendar_ics.py | 9 +- duty_teller/config.py | 4 + duty_teller/handlers/__init__.py | 1 + duty_teller/handlers/commands.py | 1 + duty_teller/handlers/group_duty_pin.py | 59 +++++++- duty_teller/i18n/messages.py | 10 ++ tests/test_handlers_group_duty_pin.py | 175 +++++++++++++++++++++-- tests/test_personal_calendar_ics.py | 21 ++- webapp/js/constants.js | 2 - 14 files changed, 270 insertions(+), 24 deletions(-) diff --git a/.coverage b/.coverage index b3391c75bf44447a6515f7356ff540b6c054cb5d..5d4fa23f168ae7acc0cf477dd4e9c354c357a981 100644 GIT binary patch delta 239 zcmVbIv*cy|2E<0e|2ByVG*L`n~b{ zkGs$5r~dyv*FQItcaAX@e@}nk4FCZ6y(b?D4g>)S6dNkR{{xf0j*}rD2m}EMKm>wU pNdlaH?*DJ_?(P4-L;rsQB)|lC0!)AdlW~tF1mJ!B3$vP!W{N9rf2@V7S2^1SD!T$l1y^fP09tZ>h2|fgZS4je#e(wJ_ hc=z`IU+DiQKmtsFC%^ bytes: """Build a VCALENDAR (ICS) with one VEVENT per duty for team calendar. Same structure as personal calendar; PRODID is Team Calendar; each VEVENT - has SUMMARY='Duty' and DESCRIPTION set to the duty holder's full_name. + has SUMMARY set to the duty holder's full_name (or "Duty" if full_name is + empty or missing), and DESCRIPTION set to full_name. Args: - duties_with_name: List of (Duty, full_name). full_name is used for DESCRIPTION. + duties_with_name: List of (Duty, full_name). full_name is used for + SUMMARY and DESCRIPTION. Returns: ICS file content as bytes (UTF-8). @@ -79,7 +81,8 @@ def build_team_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes: end_dt = end_dt.replace(tzinfo=timezone.utc) event.add("dtstart", start_dt) event.add("dtend", end_dt) - event.add("summary", "Duty") + summary = (full_name or "").strip() or "Duty" + event.add("summary", summary) event.add("description", full_name) event.add("uid", f"duty-{duty.id}@duty-teller") event.add("dtstamp", datetime.now(timezone.utc)) diff --git a/duty_teller/config.py b/duty_teller/config.py index 144ce82..5281a3a 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -64,6 +64,7 @@ class Settings: external_calendar_ics_url: str duty_display_tz: str default_language: str + duty_pin_notify: bool @classmethod def from_env(cls) -> "Settings": @@ -108,6 +109,8 @@ class Settings: duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow", default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")), + duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower() + not in ("0", "false", "no"), ) @@ -128,6 +131,7 @@ CORS_ORIGINS = _settings.cors_origins EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url DUTY_DISPLAY_TZ = _settings.duty_display_tz DEFAULT_LANGUAGE = _settings.default_language +DUTY_PIN_NOTIFY = _settings.duty_pin_notify def is_admin(username: str) -> bool: diff --git a/duty_teller/handlers/__init__.py b/duty_teller/handlers/__init__.py index 6f416de..7a12728 100644 --- a/duty_teller/handlers/__init__.py +++ b/duty_teller/handlers/__init__.py @@ -21,4 +21,5 @@ def register_handlers(app: Application) -> None: app.add_handler(import_duty_schedule.duty_schedule_document_handler) app.add_handler(group_duty_pin.group_duty_pin_handler) app.add_handler(group_duty_pin.pin_duty_handler) + app.add_handler(group_duty_pin.refresh_pin_handler) app.add_error_handler(errors.error_handler) diff --git a/duty_teller/handlers/commands.py b/duty_teller/handlers/commands.py index 8cf2e58..335d8fa 100644 --- a/duty_teller/handlers/commands.py +++ b/duty_teller/handlers/commands.py @@ -163,6 +163,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: t(lang, "help.set_phone"), t(lang, "help.calendar_link"), t(lang, "help.pin_duty"), + t(lang, "help.refresh_pin"), ] if await is_admin_async(update.effective_user.id): lines.append(t(lang, "help.import_schedule")) diff --git a/duty_teller/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py index 275cfff..29cca17 100644 --- a/duty_teller/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -3,6 +3,7 @@ import asyncio import logging from datetime import datetime, timezone +from typing import Literal import duty_teller.config as config from telegram import Update @@ -90,16 +91,21 @@ async def _schedule_next_update( ) -async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: - """Job callback: refresh pinned duty message and schedule next update at shift end.""" - chat_id = context.job.data.get("chat_id") - if chat_id is None: - return +async def _refresh_pin_for_chat( + context: ContextTypes.DEFAULT_TYPE, chat_id: int +) -> Literal["updated", "no_message", "failed"]: + """Refresh pinned duty message for a chat: edit text, optionally re-pin, schedule next. + + Returns: + "updated" if the message was updated successfully; + "no_message" if there is no pin record for this chat; + "failed" if edit or permissions failed. + """ loop = asyncio.get_running_loop() message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) if message_id is None: logger.info("No pin record for chat_id=%s, skipping update", chat_id) - return + return "no_message" text = await loop.run_in_executor( None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE) ) @@ -111,8 +117,32 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: ) except (BadRequest, Forbidden) as e: logger.warning("Failed to edit pinned message chat_id=%s: %s", chat_id, e) + next_end = await loop.run_in_executor(None, _get_next_shift_end_sync) + await _schedule_next_update(context.application, chat_id, next_end) + return "failed" + if config.DUTY_PIN_NOTIFY: + try: + await context.bot.unpin_chat_message(chat_id=chat_id) + await context.bot.pin_chat_message( + chat_id=chat_id, + message_id=message_id, + disable_notification=False, + ) + except (BadRequest, Forbidden) as e: + logger.warning( + "Re-pin after update failed chat_id=%s: %s", chat_id, e + ) next_end = await loop.run_in_executor(None, _get_next_shift_end_sync) await _schedule_next_update(context.application, chat_id, next_end) + return "updated" + + +async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: + """Job callback: refresh pinned duty message and schedule next update at shift end.""" + chat_id = context.job.data.get("chat_id") + if chat_id is None: + return + await _refresh_pin_for_chat(context, chat_id) async def my_chat_member_handler( @@ -223,8 +253,25 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No await update.message.reply_text(t(lang, "pin_duty.failed")) +async def refresh_pin_cmd( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle /refresh_pin: immediately refresh pinned duty message in the group.""" + if not update.message or not update.effective_chat or not update.effective_user: + return + chat = update.effective_chat + lang = get_lang(update.effective_user) + if chat.type not in ("group", "supergroup"): + await update.message.reply_text(t(lang, "refresh_pin.group_only")) + return + chat_id = chat.id + result = await _refresh_pin_for_chat(context, chat_id) + await update.message.reply_text(t(lang, f"refresh_pin.{result}")) + + group_duty_pin_handler = ChatMemberHandler( my_chat_member_handler, ChatMemberHandler.MY_CHAT_MEMBER, ) pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd) +refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd) diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index bfab980..bee5409 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -13,6 +13,11 @@ MESSAGES: dict[str, dict[str, str]] = { "help.set_phone": "/set_phone — Set or clear phone for duty display", "help.calendar_link": "/calendar_link — Get personal calendar subscription link (private chat)", "help.pin_duty": "/pin_duty — In a group: pin the duty message (bot needs admin with Pin messages)", + "help.refresh_pin": "/refresh_pin — In a group: refresh pinned duty message text immediately", + "refresh_pin.group_only": "The /refresh_pin command works only in groups.", + "refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.", + "refresh_pin.updated": "Pinned duty message updated.", + "refresh_pin.failed": "Could not update the pinned message (permissions or edit error).", "calendar_link.private_only": "The /calendar_link command is only available in private chat.", "calendar_link.access_denied": "Access denied.", "calendar_link.success": ( @@ -82,6 +87,11 @@ MESSAGES: dict[str, dict[str, str]] = { "help.set_phone": "/set_phone — Указать или очистить телефон для отображения в дежурстве", "help.calendar_link": "/calendar_link — Получить ссылку на персональную подписку календаря (только в личке)", "help.pin_duty": "/pin_duty — В группе: закрепить сообщение о дежурстве (нужны права админа у бота)", + "help.refresh_pin": "/refresh_pin — В группе: немедленно обновить текст закреплённого сообщения о дежурстве", + "refresh_pin.group_only": "Команда /refresh_pin работает только в группах.", + "refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.", + "refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.", + "refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).", "calendar_link.private_only": "Команда /calendar_link доступна только в личке.", "calendar_link.access_denied": "Доступ запрещён.", "calendar_link.success": ( diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py index 466135e..ccd1041 100644 --- a/tests/test_handlers_group_duty_pin.py +++ b/tests/test_handlers_group_duty_pin.py @@ -4,9 +4,10 @@ from datetime import datetime, timedelta, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest -from telegram.error import BadRequest +from telegram.error import BadRequest, Forbidden from telegram.constants import ChatMemberStatus +import duty_teller.config as config from duty_teller.handlers import group_duty_pin as mod @@ -126,27 +127,34 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay(): @pytest.mark.asyncio async def test_update_group_pin_edits_message_and_schedules_next(): - """update_group_pin: with message_id and text, edits message and schedules next update.""" + """update_group_pin: with message_id and text, edits message, re-pins with notification, schedules next.""" context = MagicMock() context.job = MagicMock() context.job.data = {"chat_id": 123} context.bot = MagicMock() context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() context.application = MagicMock() context.application.job_queue = MagicMock() context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) context.application.job_queue.run_once = MagicMock() - with patch.object(mod, "_sync_get_message_id", return_value=1): - with patch.object( - mod, "_get_duty_message_text_sync", return_value="Current duty" - ): - with patch.object(mod, "_get_next_shift_end_sync", return_value=None): - with patch.object(mod, "_schedule_next_update", AsyncMock()): - await mod.update_group_pin(context) + with patch.object(config, "DUTY_PIN_NOTIFY", True): + with patch.object(mod, "_sync_get_message_id", return_value=1): + with patch.object( + mod, "_get_duty_message_text_sync", return_value="Current duty" + ): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()): + await mod.update_group_pin(context) context.bot.edit_message_text.assert_called_once_with( chat_id=123, message_id=1, text="Current duty" ) + context.bot.unpin_chat_message.assert_called_once_with(chat_id=123) + context.bot.pin_chat_message.assert_called_once_with( + chat_id=123, message_id=1, disable_notification=False + ) @pytest.mark.asyncio @@ -165,7 +173,7 @@ async def test_update_group_pin_no_message_id_skips(): @pytest.mark.asyncio async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): - """update_group_pin: edit_message_text BadRequest -> log, _schedule_next_update still called.""" + """update_group_pin: edit_message_text BadRequest -> no unpin/pin, _schedule_next_update still called.""" context = MagicMock() context.job = MagicMock() context.job.data = {"chat_id": 111} @@ -173,6 +181,8 @@ async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): context.bot.edit_message_text = AsyncMock( side_effect=BadRequest("Message not modified") ) + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() context.application = MagicMock() with patch.object(mod, "_sync_get_message_id", return_value=2): @@ -182,9 +192,62 @@ async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): mod, "_schedule_next_update", AsyncMock() ) as mock_schedule: await mod.update_group_pin(context) + context.bot.unpin_chat_message.assert_not_called() + context.bot.pin_chat_message.assert_not_called() mock_schedule.assert_called_once_with(context.application, 111, None) +@pytest.mark.asyncio +async def test_update_group_pin_repin_raises_still_schedules_next(): + """update_group_pin: edit succeeds, unpin or pin raises -> log, _schedule_next_update still called.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 222} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights")) + context.bot.pin_chat_message = AsyncMock() + context.application = MagicMock() + + with patch.object(config, "DUTY_PIN_NOTIFY", True): + with patch.object(mod, "_sync_get_message_id", return_value=3): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object( + mod, "_schedule_next_update", AsyncMock() + ) as mock_schedule: + with patch.object(mod, "logger") as mock_logger: + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_called_once() + mock_logger.warning.assert_called_once() + assert "Re-pin" in mock_logger.warning.call_args[0][0] + mock_schedule.assert_called_once_with(context.application, 222, None) + + +@pytest.mark.asyncio +async def test_update_group_pin_duty_pin_notify_false_skips_repin(): + """update_group_pin: DUTY_PIN_NOTIFY False -> edit only, no unpin/pin, schedule next.""" + context = MagicMock() + context.job = MagicMock() + context.job.data = {"chat_id": 333} + context.bot = MagicMock() + context.bot.edit_message_text = AsyncMock() + context.bot.unpin_chat_message = AsyncMock() + context.bot.pin_chat_message = AsyncMock() + context.application = MagicMock() + + with patch.object(config, "DUTY_PIN_NOTIFY", False): + with patch.object(mod, "_sync_get_message_id", return_value=4): + with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"): + with patch.object(mod, "_get_next_shift_end_sync", return_value=None): + with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule: + await mod.update_group_pin(context) + context.bot.edit_message_text.assert_called_once() + context.bot.unpin_chat_message.assert_not_called() + context.bot.pin_chat_message.assert_not_called() + mock_schedule.assert_called_once_with(context.application, 333, None) + + @pytest.mark.asyncio async def test_pin_duty_cmd_group_only_reply(): """pin_duty_cmd in private chat -> reply group_only.""" @@ -276,6 +339,98 @@ async def test_pin_duty_cmd_pin_raises_replies_failed(): mock_t.assert_called_with("en", "pin_duty.failed") +# --- refresh_pin_cmd --- + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_private_replies_group_only(): + """refresh_pin_cmd in private chat -> reply refresh_pin.group_only.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "private" + update.effective_chat.id = 1 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Group only" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Group only") + mock_t.assert_called_with("en", "refresh_pin.group_only") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_updated_replies_updated(): + """refresh_pin_cmd in group with pin record -> _refresh_pin_for_chat returns updated -> reply refresh_pin.updated.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Updated" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Updated") + mock_t.assert_called_with("en", "refresh_pin.updated") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_no_message_replies_no_message(): + """refresh_pin_cmd: no pin record -> _refresh_pin_for_chat returns no_message -> reply refresh_pin.no_message.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "No message" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("No message") + mock_t.assert_called_with("en", "refresh_pin.no_message") + + +@pytest.mark.asyncio +async def test_refresh_pin_cmd_group_edit_raises_replies_failed(): + """refresh_pin_cmd: _refresh_pin_for_chat returns failed (e.g. BadRequest) -> reply refresh_pin.failed.""" + update = MagicMock() + update.message = MagicMock() + update.message.reply_text = AsyncMock() + update.effective_chat = MagicMock() + update.effective_chat.type = "group" + update.effective_chat.id = 100 + update.effective_user = MagicMock() + context = MagicMock() + + with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): + with patch.object( + mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed") + ): + with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: + mock_t.return_value = "Failed" + await mod.refresh_pin_cmd(update, context) + update.message.reply_text.assert_called_once_with("Failed") + mock_t.assert_called_with("en", "refresh_pin.failed") + + # --- my_chat_member_handler --- diff --git a/tests/test_personal_calendar_ics.py b/tests/test_personal_calendar_ics.py index 1ad717b..5a00366 100644 --- a/tests/test_personal_calendar_ics.py +++ b/tests/test_personal_calendar_ics.py @@ -46,7 +46,7 @@ def test_build_personal_ics_one_duty(): def test_build_team_ics_vevent_has_description_with_full_name(): - """Team ICS VEVENT includes DESCRIPTION with the duty holder's full_name.""" + """Team ICS VEVENT has SUMMARY=full_name and DESCRIPTION=full_name.""" duties = [ _duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"), ] @@ -56,11 +56,28 @@ def test_build_team_ics_vevent_has_description_with_full_name(): events = [c for c in cal.walk() if c.name == "VEVENT"] assert len(events) == 1 ev = events[0] - assert ev.get("summary") == "Duty" + assert ev.get("summary") == "Test User" assert ev.get("description") == "Test User" assert b"Team Calendar" in ics or "Team Calendar" in ics.decode("utf-8") +def test_build_team_ics_empty_full_name_summary_fallback(): + """When full_name is empty or missing, SUMMARY is 'Duty'.""" + duty_ns = SimpleNamespace( + id=1, + start_at="2025-02-20T09:00:00Z", + end_at="2025-02-20T18:00:00Z", + event_type="duty", + ) + for empty_name in ("", " ", None): + duties = [(duty_ns, empty_name)] + ics = build_team_ics(duties) + cal = ICalendar.from_ical(ics) + events = [c for c in cal.walk() if c.name == "VEVENT"] + assert len(events) == 1 + assert events[0].get("summary") == "Duty" + + def test_build_personal_ics_event_types(): """Unavailable and vacation get correct SUMMARY.""" duties = [ diff --git a/webapp/js/constants.js b/webapp/js/constants.js index f6102b5..aebb484 100644 --- a/webapp/js/constants.js +++ b/webapp/js/constants.js @@ -5,5 +5,3 @@ export const FETCH_TIMEOUT_MS = 15000; export const RETRY_DELAY_MS = 800; export const RETRY_AFTER_ACCESS_DENIED_MS = 1200; - -export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };