feat: add group duty pin notification feature
All checks were successful
CI / lint-and-test (push) Successful in 23s

- 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.
This commit is contained in:
2026-02-23 10:51:47 +03:00
parent 77a94fa91b
commit 8091c608e8
14 changed files with 270 additions and 24 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -22,6 +22,9 @@ ADMIN_USERNAMES=admin1,admin2
# Timezone for the pinned duty message in groups (e.g. Europe/Moscow). # Timezone for the pinned duty message in groups (e.g. Europe/Moscow).
# DUTY_DISPLAY_TZ=Europe/Moscow # DUTY_DISPLAY_TZ=Europe/Moscow
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
# DUTY_PIN_NOTIFY=1
# Default UI language when user language is unknown: en or ru (default: en). # Default UI language when user language is unknown: en or ru (default: en).
# DEFAULT_LANGUAGE=en # DEFAULT_LANGUAGE=en

View File

@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning.
## [0.1.0] - 2025-02-20 ## [0.1.0] - 2025-02-20
### Added ### Added

View File

@@ -8,7 +8,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot. - **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens. - **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones. - **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. - **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot edits the pinned message and, if `DUTY_PIN_NOTIFY` is enabled (default), re-pins it so that members get a Telegram notification; the first pin (bot added to group or `/pin_duty`) is always silent.
## Data flow ## Data flow

View File

@@ -17,6 +17,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. | | **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. |
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. | | **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. | | **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot re-pins it so that group members get a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to only edit the message without re-pinning (no notification). The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. | | **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. |
## Roles and access ## Roles and access

View File

@@ -56,10 +56,12 @@ def build_team_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:
"""Build a VCALENDAR (ICS) with one VEVENT per duty for team calendar. """Build a VCALENDAR (ICS) with one VEVENT per duty for team calendar.
Same structure as personal calendar; PRODID is Team Calendar; each VEVENT 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: 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: Returns:
ICS file content as bytes (UTF-8). 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) end_dt = end_dt.replace(tzinfo=timezone.utc)
event.add("dtstart", start_dt) event.add("dtstart", start_dt)
event.add("dtend", end_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("description", full_name)
event.add("uid", f"duty-{duty.id}@duty-teller") event.add("uid", f"duty-{duty.id}@duty-teller")
event.add("dtstamp", datetime.now(timezone.utc)) event.add("dtstamp", datetime.now(timezone.utc))

View File

@@ -64,6 +64,7 @@ class Settings:
external_calendar_ics_url: str external_calendar_ics_url: str
duty_display_tz: str duty_display_tz: str
default_language: str default_language: str
duty_pin_notify: bool
@classmethod @classmethod
def from_env(cls) -> "Settings": def from_env(cls) -> "Settings":
@@ -108,6 +109,8 @@ class Settings:
duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() duty_display_tz=os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip()
or "Europe/Moscow", or "Europe/Moscow",
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")), 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 EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url
DUTY_DISPLAY_TZ = _settings.duty_display_tz DUTY_DISPLAY_TZ = _settings.duty_display_tz
DEFAULT_LANGUAGE = _settings.default_language DEFAULT_LANGUAGE = _settings.default_language
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
def is_admin(username: str) -> bool: def is_admin(username: str) -> bool:

View File

@@ -21,4 +21,5 @@ def register_handlers(app: Application) -> None:
app.add_handler(import_duty_schedule.duty_schedule_document_handler) 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.group_duty_pin_handler)
app.add_handler(group_duty_pin.pin_duty_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) app.add_error_handler(errors.error_handler)

View File

@@ -163,6 +163,7 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
t(lang, "help.set_phone"), t(lang, "help.set_phone"),
t(lang, "help.calendar_link"), t(lang, "help.calendar_link"),
t(lang, "help.pin_duty"), t(lang, "help.pin_duty"),
t(lang, "help.refresh_pin"),
] ]
if await is_admin_async(update.effective_user.id): if await is_admin_async(update.effective_user.id):
lines.append(t(lang, "help.import_schedule")) lines.append(t(lang, "help.import_schedule"))

View File

@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal
import duty_teller.config as config import duty_teller.config as config
from telegram import Update from telegram import Update
@@ -90,16 +91,21 @@ async def _schedule_next_update(
) )
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None: async def _refresh_pin_for_chat(
"""Job callback: refresh pinned duty message and schedule next update at shift end.""" context: ContextTypes.DEFAULT_TYPE, chat_id: int
chat_id = context.job.data.get("chat_id") ) -> Literal["updated", "no_message", "failed"]:
if chat_id is None: """Refresh pinned duty message for a chat: edit text, optionally re-pin, schedule next.
return
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() loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id) message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None: if message_id is None:
logger.info("No pin record for chat_id=%s, skipping update", chat_id) logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return return "no_message"
text = await loop.run_in_executor( text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE) 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: except (BadRequest, Forbidden) as e:
logger.warning("Failed to edit pinned message chat_id=%s: %s", chat_id, 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) next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end) 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( 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")) 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( group_duty_pin_handler = ChatMemberHandler(
my_chat_member_handler, my_chat_member_handler,
ChatMemberHandler.MY_CHAT_MEMBER, ChatMemberHandler.MY_CHAT_MEMBER,
) )
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd) pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)

View File

@@ -13,6 +13,11 @@ MESSAGES: dict[str, dict[str, str]] = {
"help.set_phone": "/set_phone — Set or clear phone for duty display", "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.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.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.private_only": "The /calendar_link command is only available in private chat.",
"calendar_link.access_denied": "Access denied.", "calendar_link.access_denied": "Access denied.",
"calendar_link.success": ( "calendar_link.success": (
@@ -82,6 +87,11 @@ MESSAGES: dict[str, dict[str, str]] = {
"help.set_phone": "/set_phone — Указать или очистить телефон для отображения в дежурстве", "help.set_phone": "/set_phone — Указать или очистить телефон для отображения в дежурстве",
"help.calendar_link": "/calendar_link — Получить ссылку на персональную подписку календаря (только в личке)", "help.calendar_link": "/calendar_link — Получить ссылку на персональную подписку календаря (только в личке)",
"help.pin_duty": "/pin_duty — В группе: закрепить сообщение о дежурстве (нужны права админа у бота)", "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.private_only": "Команда /calendar_link доступна только в личке.",
"calendar_link.access_denied": "Доступ запрещён.", "calendar_link.access_denied": "Доступ запрещён.",
"calendar_link.success": ( "calendar_link.success": (

View File

@@ -4,9 +4,10 @@ from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from telegram.error import BadRequest from telegram.error import BadRequest, Forbidden
from telegram.constants import ChatMemberStatus from telegram.constants import ChatMemberStatus
import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod 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 @pytest.mark.asyncio
async def test_update_group_pin_edits_message_and_schedules_next(): 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 = MagicMock()
context.job = MagicMock() context.job = MagicMock()
context.job.data = {"chat_id": 123} context.job.data = {"chat_id": 123}
context.bot = MagicMock() context.bot = MagicMock()
context.bot.edit_message_text = AsyncMock() context.bot.edit_message_text = AsyncMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock() context.application = MagicMock()
context.application.job_queue = MagicMock() context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock() context.application.job_queue.run_once = MagicMock()
with patch.object(mod, "_sync_get_message_id", return_value=1): with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object( with patch.object(mod, "_sync_get_message_id", return_value=1):
mod, "_get_duty_message_text_sync", return_value="Current duty" 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()): with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
await mod.update_group_pin(context) with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.update_group_pin(context)
context.bot.edit_message_text.assert_called_once_with( context.bot.edit_message_text.assert_called_once_with(
chat_id=123, message_id=1, text="Current duty" 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 @pytest.mark.asyncio
@@ -165,7 +173,7 @@ async def test_update_group_pin_no_message_id_skips():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_group_pin_edit_raises_bad_request_still_schedules_next(): 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 = MagicMock()
context.job = MagicMock() context.job = MagicMock()
context.job.data = {"chat_id": 111} 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( context.bot.edit_message_text = AsyncMock(
side_effect=BadRequest("Message not modified") side_effect=BadRequest("Message not modified")
) )
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock() context.application = MagicMock()
with patch.object(mod, "_sync_get_message_id", return_value=2): 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() mod, "_schedule_next_update", AsyncMock()
) as mock_schedule: ) as mock_schedule:
await mod.update_group_pin(context) 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) 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 @pytest.mark.asyncio
async def test_pin_duty_cmd_group_only_reply(): async def test_pin_duty_cmd_group_only_reply():
"""pin_duty_cmd in private chat -> reply group_only.""" """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") 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 --- # --- my_chat_member_handler ---

View File

@@ -46,7 +46,7 @@ def test_build_personal_ics_one_duty():
def test_build_team_ics_vevent_has_description_with_full_name(): 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 = [ duties = [
_duty(1, "2025-02-20T09:00:00Z", "2025-02-20T18:00:00Z", "duty"), _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"] events = [c for c in cal.walk() if c.name == "VEVENT"]
assert len(events) == 1 assert len(events) == 1
ev = events[0] ev = events[0]
assert ev.get("summary") == "Duty" assert ev.get("summary") == "Test User"
assert ev.get("description") == "Test User" assert ev.get("description") == "Test User"
assert b"Team Calendar" in ics or "Team Calendar" in ics.decode("utf-8") 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(): def test_build_personal_ics_event_types():
"""Unavailable and vacation get correct SUMMARY.""" """Unavailable and vacation get correct SUMMARY."""
duties = [ duties = [

View File

@@ -5,5 +5,3 @@
export const FETCH_TIMEOUT_MS = 15000; export const FETCH_TIMEOUT_MS = 15000;
export const RETRY_DELAY_MS = 800; export const RETRY_DELAY_MS = 800;
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200; export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
export const THEME_BG = { dark: "#17212b", light: "#d5d6db" };