From 891fbdc1016566fedb0cb498cf8d1d776c11ed04 Mon Sep 17 00:00:00 2001 From: Oleksandr Kozachuk Date: Tue, 14 Jul 2026 11:09:33 +0200 Subject: [PATCH] operator help: complete + context-aware (ops-17): !bot help full grouped list, !help for everyone per-channel --- fjerkroa_bot/discord_bot.py | 27 +++++++++++- specs/SPEC-006-operator-controls.md | 12 ++++++ tests/test_spec_ops.py | 66 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/fjerkroa_bot/discord_bot.py b/fjerkroa_bot/discord_bot.py index 42a4def..a659533 100644 --- a/fjerkroa_bot/discord_bot.py +++ b/fjerkroa_bot/discord_bot.py @@ -182,6 +182,9 @@ class FjerkroaBot(commands.Bot): if content.startswith("!privacy"): await message.channel.send(self.config.get("privacy-notice", DEFAULT_PRIVACY_NOTICE), suppress_embeds=True) return + if content.startswith("!help"): # OPS-17: context-aware, works even while paused + await message.channel.send(self._help_text(staff=self.is_staff_channel(message.channel)), suppress_embeds=True) + return if not self.replies_allowed(): return if str(message.content).startswith("!wichtel"): @@ -263,15 +266,35 @@ class FjerkroaBot(commands.Bot): return f"Cancelled {store.task_set_state(int(args[1]), 'cancelled')} task(s)." return None + def _help_text(self, staff: bool) -> str: + """Context-aware command help (OPS-17): every channel lists the user commands; the staff channel also lists operator commands.""" + everywhere = ( + "Available to everyone, in any channel:\n" + "• `!help` — this help\n" + "• `!forgetme` — delete your messages and memory traces (works even while I'm paused)\n" + "• `!privacy` — how your data is handled (works even while I'm paused)\n" + "• `!wichtel @a @b @c …` — draw Secret Santa pairings (needs ≥2 mentions; only while I'm active)" + ) + if not staff: + return everywhere + operator = ( + "Staff commands — this channel only, prefixed `!bot`:\n" + "• Control: `pause`, `resume`, `quiet `, `status`\n" + "• Cost: `spend`, `images on|off`\n" + "• Memory: `memory `, `forget-fact `, `pin `, `unpin `, `pins`\n" + "• Tasks: `tasks` (list), `tasks on|off`, `task-approve `, `task-cancel `" + ) + return operator + "\n\n" + everywhere + async def handle_staff_command(self, message: Message) -> None: - """Operator kill-switches, staff channel only (OPS-01..05, OPS-09, MEM-07).""" + """Operator kill-switches, staff channel only (OPS-01..05, OPS-09, OPS-17, MEM-07).""" args = str(message.content).split()[1:] for handler in (self._memory_command, self._task_command): reply = handler(args) if reply is not None: await message.channel.send(reply, suppress_embeds=True) return - reply = "Commands: pause, resume, images on|off, tasks on|off, quiet , status, spend, memory , forget-fact , pin , unpin " + reply = self._help_text(staff=True) # OPS-17: unknown/`help` -> full grouped help if args[:1] == ["pause"]: self.replies_enabled = False reply = "Replies paused." diff --git a/specs/SPEC-006-operator-controls.md b/specs/SPEC-006-operator-controls.md index 18bfaf2..77cb20b 100644 --- a/specs/SPEC-006-operator-controls.md +++ b/specs/SPEC-006-operator-controls.md @@ -73,3 +73,15 @@ per-channel) — without it, `!bot unpin ` required guessing ids. `!bot spend` answers in the staff channel with today's estimated spend in USD, token and image counts, and the configured budget. Management sees the cost, not just the cap. + +### OPS-17 — Help is complete and context-aware (coverage: test) + +Help reflects where each command actually works, because not every +command is allowed everywhere. `!help` answers in any channel and +lists only the commands usable there: in a normal channel the +everyone-commands (`!help`, `!forgetme`, `!privacy`, `!wichtel`); in +the staff channel it additionally lists the operator commands grouped +by purpose (control, cost, memory, tasks). `!bot help` — and any +unrecognised `!bot` command — answers with that same full staff help, +so the listing is exhaustive rather than the old hand-maintained +partial line. Help works even while the bot is paused. diff --git a/tests/test_spec_ops.py b/tests/test_spec_ops.py index 196467c..5b9f2c5 100644 --- a/tests/test_spec_ops.py +++ b/tests/test_spec_ops.py @@ -133,3 +133,69 @@ class TestTasksKillSwitch(OpsBase): """OPS-09: bot-initiated posts respect pause/quiet.""" await self.bot.on_message(self.staff_msg("!bot pause")) self.assertFalse(self.bot.bot_initiated_allowed()) + + +class TestHelp(OpsBase): + STAFF_CMDS = ( + "pause", + "resume", + "quiet ", + "status", + "spend", + "images on|off", + "memory ", + "forget-fact ", + "pin ", + "unpin ", + "pins", + "task-approve ", + "task-cancel ", + "(list)", + ) + + def test_staff_help_is_complete_and_grouped(self): + """OPS-17: staff help lists every operator command, grouped by purpose.""" + text = self.bot._help_text(staff=True) + for cmd in self.STAFF_CMDS: + self.assertIn(cmd, text, f"missing {cmd!r} in staff help") + for group in ("Control:", "Cost:", "Memory:", "Tasks:"): + self.assertIn(group, text) + for cmd in ("!help", "!forgetme", "!privacy", "!wichtel"): + self.assertIn(cmd, text) # everywhere-commands shown too + + def test_user_help_hides_operator_commands(self): + """OPS-17: non-staff help shows only the everyone-commands.""" + text = self.bot._help_text(staff=False) + for cmd in ("!help", "!forgetme", "!privacy", "!wichtel"): + self.assertIn(cmd, text) + for op in ("task-approve", "images on|off", "spend", "Staff commands", "Control:"): + self.assertNotIn(op, text) + + async def test_bot_help_in_staff_channel_returns_full_help(self): + """OPS-17: `!bot help` answers with the complete staff help.""" + await self.bot.on_message(self.staff_msg("!bot help")) + text = self.bot.staff_channel.send.await_args.args[0] + self.assertIn("Staff commands", text) + self.assertIn("task-cancel ", text) + + async def test_unknown_bot_command_falls_back_to_help(self): + """OPS-17: an unrecognised `!bot` command shows the full help, not a partial line.""" + await self.bot.on_message(self.staff_msg("!bot wat")) + text = self.bot.staff_channel.send.await_args.args[0] + self.assertIn("Control:", text) + + async def test_help_in_public_channel_is_user_scoped(self): + """OPS-17: `!help` in a normal channel lists only everyone-commands.""" + msg = self.public_msg("!help") + await self.bot.on_message(msg) + text = msg.channel.send.await_args.args[0] + self.assertIn("!forgetme", text) + self.assertNotIn("Staff commands", text) + self.assertNotIn("task-approve", text) + + async def test_help_works_while_paused(self): + """OPS-17: help answers even when replies are paused.""" + await self.bot.on_message(self.staff_msg("!bot pause")) + msg = self.public_msg("!help") + await self.bot.on_message(msg) + msg.channel.send.assert_awaited()