Compare commits

..

1 Commits

Author SHA1 Message Date
shokollm
f7503ecd14 feat: add /admin command to list room admins
Implement /admin command as specified in issue #50:
- Lists all admin user IDs for the current room
- Output format: 'Room Admins:\n- @user1\n- @user2'
- Shows 'No admins configured for this room.' if none exist
- Available to everyone (no permission check needed)

Changes:
- Add cmd_admin function to commands.py
- Register CommandHandler('admin', cmd_admin) in bot.py
- Add /admin to command menu in post_init
- Update /help to include /admin command

Closes #50
2026-04-04 06:52:23 +00:00
4 changed files with 35 additions and 90 deletions

View File

@@ -8,6 +8,7 @@ from telegram.ext import Application, CommandHandler, MessageHandler, filters
from commands import ( from commands import (
cmd_add, cmd_add,
cmd_admin,
cmd_bounty, cmd_bounty,
cmd_delete, cmd_delete,
cmd_edit, cmd_edit,
@@ -41,6 +42,7 @@ def build_app() -> Application:
app.add_handler(CommandHandler("delete", cmd_delete)) app.add_handler(CommandHandler("delete", cmd_delete))
app.add_handler(CommandHandler("track", cmd_track)) app.add_handler(CommandHandler("track", cmd_track))
app.add_handler(CommandHandler("untrack", cmd_untrack)) app.add_handler(CommandHandler("untrack", cmd_untrack))
app.add_handler(CommandHandler("admin", cmd_admin))
app.add_handler(MessageHandler(filters.COMMAND, cmd_help)) app.add_handler(MessageHandler(filters.COMMAND, cmd_help))
@@ -56,6 +58,7 @@ async def post_init(app: Application) -> None:
("edit", "Edit a bounty"), ("edit", "Edit a bounty"),
("track", "Track a bounty"), ("track", "Track a bounty"),
("untrack", "Stop tracking"), ("untrack", "Stop tracking"),
("admin", "List room admins"),
("help", "Show help"), ("help", "Show help"),
] ]
) )

View File

@@ -210,34 +210,32 @@ cmd_edit = cmd_update
async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
args = extract_args(update.message.text) args = extract_args(update.message.text)
if not args: if not args:
await update.message.reply_text("Usage: /delete <bounty_id> [bounty_id ...]") await update.message.reply_text("Usage: /delete <bounty_id>")
return return
try: try:
bounty_ids = [int(arg) for arg in args] bounty_id = int(args[0])
except ValueError: except ValueError:
await update.message.reply_text("Invalid bounty ID(s).") await update.message.reply_text("Invalid bounty ID.")
return return
user_id = get_user_id(update) user_id = get_user_id(update)
room_id = get_room_id(update) room_id = get_room_id(update)
results = BOUNTY_SERVICE.delete_bounties( try:
room_id=room_id, success = BOUNTY_SERVICE.delete_bounty(
bounty_ids=bounty_ids, room_id=room_id,
user_id=user_id, bounty_id=bounty_id,
) user_id=user_id,
)
except PermissionError as e:
await update.message.reply_text(f"{e}")
return
lines = [] if success:
for bounty_id, result in results.items(): await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.")
if result == "deleted": else:
lines.append(f"Bounty #{bounty_id} deleted.") await update.message.reply_text("Bounty not found.")
elif result == "not_found":
lines.append(f"⛔ Bounty #{bounty_id} not found.")
elif result == "permission_denied":
lines.append(f"⛔ Bounty #{bounty_id} - only admins can delete.")
await update.message.reply_text("\n".join(lines))
async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
@@ -323,7 +321,23 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
"/delete <id> — delete bounty\n" "/delete <id> — delete bounty\n"
"/track <id> — track a bounty (groups only)\n" "/track <id> — track a bounty (groups only)\n"
"/untrack <id> — stop tracking (groups only)\n" "/untrack <id> — stop tracking (groups only)\n"
"/admin — list room admins\n"
"/start — re-initialize\n" "/start — re-initialize\n"
"/help — this message", "/help — this message",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
room_id = get_room_id(update)
admin_ids = BOUNTY_SERVICE.list_admins(room_id)
if not admin_ids:
await update.message.reply_text("No admins configured for this room.")
return
lines = [f"Room Admins:"]
for admin_id in admin_ids:
lines.append(f"- @{admin_id}")
await update.message.reply_text("\n".join(lines))

View File

@@ -210,31 +210,6 @@ class BountyService:
self._storage.update_bounty(room_id, bounty) self._storage.update_bounty(room_id, bounty)
return True return True
def delete_bounties(
self, room_id: int, bounty_ids: list[int], user_id: int
) -> dict[int, str]:
"""Soft delete multiple bounties. Only admins can delete.
Returns a dict mapping bounty_id to result:
- "deleted": Successfully soft-deleted
- "not_found": Bounty does not exist
- "permission_denied": User is not admin
"""
results = {}
for bounty_id in bounty_ids:
bounty = self._storage.get_bounty(room_id, bounty_id)
if not bounty:
results[bounty_id] = "not_found"
continue
if not self.is_admin(room_id, user_id):
results[bounty_id] = "permission_denied"
continue
bounty.deleted_at = int(time.time())
self._storage.update_bounty(room_id, bounty)
results[bounty_id] = "deleted"
return results
class TrackingService: class TrackingService:
"""Service for tracking bounty operations.""" """Service for tracking bounty operations."""

View File

@@ -210,53 +210,6 @@ class TestBountyService:
result = self.service.delete_bounty(-1001, 999, self.admin_user_id) result = self.service.delete_bounty(-1001, 999, self.admin_user_id)
assert result is False assert result is False
def test_delete_bounties_multiple_success(self):
"""Test delete_bounties soft deletes multiple bounties."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First"
)
b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second"
)
b3 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Third"
)
results = self.service.delete_bounties(
-1001, [b1.id, b2.id, b3.id], self.admin_user_id
)
assert results == {b1.id: "deleted", b2.id: "deleted", b3.id: "deleted"}
assert self.service.get_bounty(-1001, b1.id) is None
assert self.service.get_bounty(-1001, b2.id) is None
assert self.service.get_bounty(-1001, b3.id) is None
def test_delete_bounties_mixed_results(self):
"""Test delete_bounties returns individual results per ID."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Exists"
)
results = self.service.delete_bounties(
-1001, [b1.id, 999, 888], self.admin_user_id
)
assert results == {b1.id: "deleted", 999: "not_found", 888: "not_found"}
def test_delete_bounties_permission_denied(self):
"""Test delete_bounties returns permission_denied for non-admin."""
b1 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="First"
)
b2 = self.service.add_bounty(
room_id=-1001, user_id=self.admin_user_id, text="Second"
)
results = self.service.delete_bounties(
-1001,
[b1.id, b2.id],
999, # non-admin user
)
assert results == {b1.id: "permission_denied", b2.id: "permission_denied"}
# Bounties should not be deleted
assert self.service.get_bounty(-1001, b1.id) is not None
assert self.service.get_bounty(-1001, b2.id) is not None
class TestTrackingService: class TestTrackingService:
"""Unit tests for TrackingService.""" """Unit tests for TrackingService."""