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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## [clankwarden] -- 2026-06-04

### Fixes
- **Scammer-report buttons no longer fail.** The "Clank", "False report (30m)",
and dehoist-alert "Clank" buttons ran the full containment path before
acknowledging the click, so when containment took longer than Discord's 3s
interaction window the button showed "This interaction failed". They now defer
the interaction first and reply via a follow-up, and surface the real reason
(e.g. immunity / role hierarchy) instead of a generic failure.
- **Releasing a clanker reliably restores their roles.** Two gaps are fixed:
re-clanking someone already in the tank no longer overwrites their saved
roles with an empty set (the original roles are preserved on the saved
record), and role restoration on release no longer fails as an all-or-nothing
batch -- managed roles (booster/bot/integration) and roles above the bot are
skipped, and the remaining roles are handed back one-by-one so a single
un-assignable role can't block the rest.

### Clanker hunters -- multiple report channels
- **Several hunter channels.** The scam-report channel is no longer a single
channel. Set as many as you like with `.clank hunter channel #a #b #c`, and
Expand Down
39 changes: 34 additions & 5 deletions cogs/clank.py
Original file line number Diff line number Diff line change
Expand Up @@ -4419,7 +4419,10 @@ async def _do_clank(
VALUES ($1, $2, $8, $3, $4, $4, $5, $6, $7, $9, $9, 0, now())
ON CONFLICT (user_id, guild_id) DO UPDATE
SET case_num = $8,
stored_roles = $3,
stored_roles = CASE
WHEN COALESCE(array_length(clanker_records.stored_roles, 1), 0) > 0
THEN clanker_records.stored_roles
ELSE $3 END,
reason = $4,
clank_context = $4,
clanked_at = now(),
Expand Down Expand Up @@ -4789,13 +4792,39 @@ async def _do_release(
# Strip the Clankermax tier role too (best-effort, idempotent).
await self._set_clankermax(member, False)
stored_ids: set[int] = set(rec.get("stored_roles") or []) if rec else set()
to_restore = [r for r in guild.roles if r.id in stored_ids]
if to_restore:
me = guild.me
# Only roles the bot can actually grant: skip managed roles (booster /
# bot / integration roles -- Discord re-adds those itself and rejects
# manual assignment) and anything at or above the bot's top role.
assignable = [
r for r in guild.roles
if r.id in stored_ids and not r.managed and not r.is_default()
and (me is not None and r < me.top_role)
]
if assignable:
try:
await member.add_roles(*to_restore, reason="Clanktank: restoring roles")
restored = to_restore
await member.add_roles(*assignable, reason="Clanktank: restoring roles")
restored = assignable
except discord.Forbidden:
# add_roles is atomic -- one bad role fails the whole batch.
# Fall back to per-role so the rest are still handed back.
for r in assignable:
try:
await member.add_roles(r, reason="Clanktank: restoring roles")
restored.append(r)
except Exception:
log.warning(
"clanktank: could not restore role %s uid=%s gid=%s",
r.id, user_id, guild_id)
except Exception:
log.warning("clanktank: role restore failed uid=%s", user_id)
skipped = [r for r in guild.roles
if r.id in stored_ids and r not in restored and not r.is_default()]
if skipped:
log.info(
"clanktank: %d stored role(s) not restored uid=%s gid=%s "
"(managed or above the bot's top role): %s",
len(skipped), user_id, guild_id, [r.id for r in skipped])

asyncio.ensure_future(self._er_cleanup(user_id, guild_id))
await self._save_to_history(user_id, guild_id, rec)
Expand Down
31 changes: 25 additions & 6 deletions cogs/dehoist.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,13 +605,17 @@ async def _clank(self, interaction: discord.Interaction) -> None:
if clank is None or not hasattr(clank, "warden_contain"):
await interaction.response.send_message("Clank cog unavailable.", ephemeral=True)
return
# Defer the component update first: containment can take longer than the
# 3s interaction window, which would otherwise fail the click outright.
await interaction.response.defer()
try:
await clank.warden_contain(self.member, reason="Mod clank from dehoist alert")
self.clanked = True
self._build(True, "alert")
await interaction.response.edit_message(view=self)
await interaction.edit_original_response(view=self)
except Exception:
await interaction.response.send_message("Clank failed.", ephemeral=True)
log.exception("dehoist: alert-clank failed target=%s", self.member.id)
await interaction.followup.send("Clank failed.", ephemeral=True)


class _ReportAlert(discord.ui.LayoutView):
Expand Down Expand Up @@ -658,11 +662,18 @@ async def _clank(self, interaction: discord.Interaction) -> None:
if clank is None or not hasattr(clank, "warden_contain"):
await interaction.response.send_message("Clank cog unavailable.", ephemeral=True)
return
# Containment hits the gateway several times (role edits, DB, purge), which
# can exceed Discord's 3s interaction window -- defer first so the token
# stays valid and the click never shows "This interaction failed".
await interaction.response.defer(ephemeral=True)
try:
await clank.warden_contain(self.target, reason=f"Clank from report by {interaction.user}")
await interaction.response.send_message(f"Clanked {self.target.mention}.", ephemeral=True)
await interaction.followup.send(f"Clanked {self.target.mention}.", ephemeral=True)
except ValueError as exc:
await interaction.followup.send(f"Couldn't clank: {exc}", ephemeral=True)
except Exception:
await interaction.response.send_message("Clank failed.", ephemeral=True)
log.exception("dehoist: report-clank failed target=%s", self.target.id)
await interaction.followup.send("Clank failed (check the bot's role hierarchy).", ephemeral=True)

async def _false(self, interaction: discord.Interaction) -> None:
"""False report: clank the *reporter* for 30 minutes."""
Expand All @@ -672,14 +683,22 @@ async def _false(self, interaction: discord.Interaction) -> None:
await interaction.response.send_message(
"Can't action that (reporter left, or clank cog unavailable).", ephemeral=True)
return
# Defer before the (slow) containment path so the interaction does not
# expire mid-clank -- otherwise the follow-up reply 404s and Discord shows
# "This interaction failed".
await interaction.response.defer(ephemeral=True)
try:
await clank.warden_contain(
member, reason=f"False scam report (marked by {interaction.user})",
duration_s=1800)
await interaction.response.send_message(
await interaction.followup.send(
f"Marked false -- clanked {member.mention} for 30 minutes.", ephemeral=True)
except ValueError as exc:
await interaction.followup.send(f"Couldn't clank the reporter: {exc}", ephemeral=True)
except Exception:
await interaction.response.send_message("Couldn't clank the reporter.", ephemeral=True)
log.exception("dehoist: false-report clank failed reporter=%s", member.id)
await interaction.followup.send(
"Couldn't clank the reporter (check the bot's role hierarchy).", ephemeral=True)


class _DehoistConfig(discord.ui.LayoutView):
Expand Down
Loading