Skip to content

Commit 93f9ecf

Browse files
committed
Respect media permissions and role hierarchy
1 parent 458e23a commit 93f9ecf

11 files changed

Lines changed: 528 additions & 81 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ MEMACT_GUILD_ID=
33
MEMACT_DATABASE=memact_automod.db
44
# MEMACT_STREAM_TITLE=Moderating this server
55
# MEMACT_STREAM_URL=https://twitch.tv/discord
6+
# Optional server-specific Discord IDs. Defaults match the current Memact server.
7+
# MEMACT_BOT_JOIN_ROLE_ID=1485762440980336810
8+
# MEMACT_MEMBER_JOIN_ROLE_ID=1485762960851996822
9+
# MEMACT_ACTION_LOG_CHANNEL_ID=1485774247698890903
10+
# MEMACT_WELCOME_CHANNEL_ID=1485763966515941618
11+
# MEMACT_INTRO_CHANNEL_ID=1485764265989246996
12+
# MEMACT_TICKET_CHANNEL_ID=1486012638507827210
13+
# MEMACT_BLUESKY_RELAY_CHANNEL_ID=1490277253949558975
614
# For JustRunMy.App, point this at persistent app storage if SQLite state
715
# should survive Git deploys and restarts:
816
# MEMACT_DATABASE=/data/memact_automod.db

README.md

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ This repository's source code is open source under the MIT license. See
4747
dictator references, protected-class slurs, untrusted external links,
4848
suspicious promos, unsolicited invites, URL shorteners, and repeated spam,
4949
including messages from installed bots/apps and webhooks
50+
- media-friendly moderation: normal image uploads, GIFs, stickers, embeds, and
51+
attachment-only messages are allowed, while suspicious text links are still
52+
evaluated
5053
- silent Sentinel detection for protected-class hate patterns, self-harm
5154
harassment, scam links, homoglyph domains, misleading markdown links,
5255
new-account bursts, and mention raids
@@ -59,7 +62,7 @@ This repository's source code is open source under the MIT license. See
5962
- rules management and rules embed posting
6063
- generic embed creation and reusable embed templates
6164
- member report, appeal, and ticket flows
62-
- optional Bluesky relay with automatic posting to a fixed Discord channel and
65+
- optional Bluesky relay with automatic posting to a configured Discord channel and
6366
moderator-picked reposts for older posts
6467

6568
## Setup
@@ -70,31 +73,35 @@ This repository's source code is open source under the MIT license. See
7073
including View Audit Log, Moderate Members, Manage Messages, Kick Members,
7174
Ban Members, Manage Roles, and Manage Channels.
7275
4. Copy `.env.example` to `.env` and fill in `MEMACT_TOKEN`.
73-
5. Install dependencies with `pip install -r requirements.txt`.
74-
6. Run the bot with `python main.py`.
75-
7. Run `/automod install` once in Discord to create or refresh native Memact
76+
5. If you are not running the current Memact server, set the optional
77+
channel/role ID environment variables listed below so welcome, intro,
78+
ticket, log, join-role, and Bluesky relay behavior points at your server.
79+
6. Install dependencies with `pip install -r requirements.txt`.
80+
7. Run the bot with `python main.py`.
81+
8. Run `/automod install` once in Discord to create or refresh native Memact
7682
Guard rules, including the hate-speech preset.
77-
8. Use `/security sentinel_recent` or `/security sentinel` to review silent
83+
9. Use `/security sentinel_recent` or `/security sentinel` to review silent
7884
risk intelligence after the bot has seen real traffic.
7985

8086
## Bluesky Relay
8187

8288
The Bluesky relay is optional. When enabled, the bot can mirror posts from one
83-
public Bluesky account into the fixed Discord channel `1490277253949558975`.
89+
public Bluesky account into the configured relay channel. Memact's default
90+
relay channel is `1490277253949558975`, and other servers can override it with
91+
`MEMACT_BLUESKY_RELAY_CHANNEL_ID`.
8492

8593
### Setup
8694

8795
1. Start the bot normally.
88-
2. Make sure the Discord server contains the text channel with ID
89-
`1490277253949558975`.
96+
2. Make sure the Discord server contains the configured relay text channel.
9097
3. In Discord, run `/bluesky setup handle:<account>`.
9198
4. The bot saves the current latest Bluesky post as its sync point and starts
9299
auto-posting only new posts from that moment onward. The relay checks for
93100
new posts every five minutes.
94101

95102
### Moderator commands
96103

97-
- `/bluesky view`: show the selected account, relay status, fixed relay
104+
- `/bluesky view`: show the selected account, relay status, configured relay
98105
channel, and last synced post
99106
- `/bluesky sync_now`: immediately catch up on posts that arrived while the
100107
bot was offline
@@ -118,6 +125,9 @@ In practice:
118125
- the bot does not warn members for ordinary profanity or casual keywords
119126
- GIFs, all-caps messages, memes, and normal chat are not judged by a custom
120127
keyword engine
128+
- image uploads, GIFs, stickers, embeds, and attachment-only messages are not
129+
violations by themselves; if suspicious or untrusted links appear beside
130+
that media, the links are still evaluated and can be deleted
121131
- native Discord AutoMod blocks spam, mention raids, hate-speech slur presets,
122132
and known scam-link patterns before they become moderation cases
123133
- Memact's local guard deletes public strong-profanity and offensive-reference
@@ -126,8 +136,9 @@ In practice:
126136
- protected-class slurs are hard-blocked locally with punctuation and leetspeak
127137
variants, so targeted racism does not depend only on Discord's native preset
128138
- public links use an allowlist-first posture: known safe hosts stay normal,
129-
while unknown external links, shorteners, suspicious TLDs, invite spam, and
130-
random-looking hosts are treated as spam-risk and logged
139+
`memact.com` links stay allowed, while unknown external links, shorteners,
140+
suspicious TLDs, invite spam, and random-looking hosts are treated as
141+
spam-risk and logged
131142
- installed bots/apps and webhooks are moderated too, so compromised or spammy
132143
integrations cannot freely post abusive text, invite links, or promo scams
133144
- staff-only channels are treated with lighter friction for human moderators,
@@ -194,6 +205,15 @@ Recommended settings:
194205
- `MEMACT_BACKUP_DIR` (optional, recommended on persistent storage)
195206
- `MEMACT_BACKUP_INTERVAL_HOURS` (optional, default `12`)
196207
- `MEMACT_BACKUP_RETENTION` (optional, default `14`)
208+
- `MEMACT_BOT_JOIN_ROLE_ID` (optional, defaults to Memact's bot role)
209+
- `MEMACT_MEMBER_JOIN_ROLE_ID` (optional, defaults to Memact's member role)
210+
- `MEMACT_ACTION_LOG_CHANNEL_ID` (optional, defaults to Memact's action log)
211+
- `MEMACT_WELCOME_CHANNEL_ID` (optional, defaults to Memact's welcome
212+
channel)
213+
- `MEMACT_INTRO_CHANNEL_ID` (optional, defaults to Memact's intro channel)
214+
- `MEMACT_TICKET_CHANNEL_ID` (optional, defaults to Memact's ticket channel)
215+
- `MEMACT_BLUESKY_RELAY_CHANNEL_ID` (optional, defaults to Memact's Bluesky
216+
relay channel)
197217
6. Start the app and watch the JustRunMy.App logs until the bot prints that it
198218
logged in and synced commands.
199219
7. Run `/automod install` after the bot is online if Discord native AutoMod
@@ -205,6 +225,15 @@ Important JustRunMy.App notes:
205225
the optional `/healthz` endpoint.
206226
- Keep secrets such as `MEMACT_TOKEN` in JustRunMy.App environment variables,
207227
not in `.env`.
228+
- Server-specific IDs are normal runtime environment variables. Defaults match
229+
the current Memact server, but other servers should configure them explicitly.
230+
- If GitHub Actions is used for deployment, store sensitive deployment values as
231+
GitHub Secrets and inject them into the deployment/runtime environment; the
232+
bot code reads only normal environment variables.
233+
- You can enable Discord permissions for everyone to send images, GIFs,
234+
stickers, and embeds. Memact Guard will leave media-only messages alone while
235+
still filtering scam, invite, shortener, suspicious-domain, promo-link, and
236+
spam links found in message text.
208237
- Use the dashboard logs, web shell, and auto-restart controls for debugging
209238
and recovery.
210239
- For durable SQLite data, set `MEMACT_DATABASE` to a path that lives on

bot.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from config import ACTION_LOG_CHANNEL_ID, EMBED_COLOR, Settings
1212
from db import Database
13+
from utils.checks import bot_can_act_on_member, log_bot_action_blocked
1314
from utils.time import format_timedelta, to_iso, utcnow
1415
from utils.ui import build_embed, safe_dm
1516

@@ -267,8 +268,30 @@ async def apply_warning(
267268
bot_user = self.user or moderator
268269
escalation: str | None = None
269270

270-
if total_points >= config["warn_ban_threshold"] and member.bannable:
271-
await member.ban(reason=f"Auto-ban after warnings: {reason}", delete_message_seconds=0)
271+
if total_points >= config["warn_ban_threshold"]:
272+
can_ban, block_reason = bot_can_act_on_member(member, action="automatic ban", required_permission="ban_members")
273+
if not can_ban or not member.bannable:
274+
await log_bot_action_blocked(
275+
self,
276+
guild,
277+
action="automatic ban",
278+
target=member,
279+
reason=block_reason or "Discord reports this member is not bannable.",
280+
fields=[("Warning Points", str(total_points), True)],
281+
)
282+
return case_id, total_points, None
283+
try:
284+
await member.ban(reason=f"Auto-ban after warnings: {reason}", delete_message_seconds=0)
285+
except (nextcord.Forbidden, nextcord.HTTPException) as error:
286+
await log_bot_action_blocked(
287+
self,
288+
guild,
289+
action="automatic ban",
290+
target=member,
291+
reason=f"Discord rejected the ban: {type(error).__name__}.",
292+
fields=[("Warning Points", str(total_points), True)],
293+
)
294+
return case_id, total_points, None
272295
escalation = "ban"
273296
case_id = await self.add_case(
274297
guild.id,
@@ -291,8 +314,30 @@ async def apply_warning(
291314
description=f"{member.mention} was banned automatically after warning escalation.",
292315
fields=[("Case", str(case_id), True), ("Warning Points", str(total_points), True)],
293316
)
294-
elif total_points >= config["warn_kick_threshold"] and member.kickable:
295-
await member.kick(reason=f"Auto-kick after warnings: {reason}")
317+
elif total_points >= config["warn_kick_threshold"]:
318+
can_kick, block_reason = bot_can_act_on_member(member, action="automatic kick", required_permission="kick_members")
319+
if not can_kick or not member.kickable:
320+
await log_bot_action_blocked(
321+
self,
322+
guild,
323+
action="automatic kick",
324+
target=member,
325+
reason=block_reason or "Discord reports this member is not kickable.",
326+
fields=[("Warning Points", str(total_points), True)],
327+
)
328+
return case_id, total_points, None
329+
try:
330+
await member.kick(reason=f"Auto-kick after warnings: {reason}")
331+
except (nextcord.Forbidden, nextcord.HTTPException) as error:
332+
await log_bot_action_blocked(
333+
self,
334+
guild,
335+
action="automatic kick",
336+
target=member,
337+
reason=f"Discord rejected the kick: {type(error).__name__}.",
338+
fields=[("Warning Points", str(total_points), True)],
339+
)
340+
return case_id, total_points, None
296341
escalation = "kick"
297342
case_id = await self.add_case(
298343
guild.id,
@@ -316,9 +361,31 @@ async def apply_warning(
316361
fields=[("Case", str(case_id), True), ("Warning Points", str(total_points), True)],
317362
)
318363
elif total_points >= config["warn_timeout_threshold"]:
364+
can_timeout, block_reason = bot_can_act_on_member(member, action="automatic timeout", required_permission="moderate_members")
365+
if not can_timeout:
366+
await log_bot_action_blocked(
367+
self,
368+
guild,
369+
action="automatic timeout",
370+
target=member,
371+
reason=block_reason or "Discord role hierarchy blocked the timeout.",
372+
fields=[("Warning Points", str(total_points), True)],
373+
)
374+
return case_id, total_points, None
319375
timeout_minutes = max(1, int(config["warn_timeout_minutes"]))
320376
duration = timedelta(minutes=timeout_minutes)
321-
await member.edit(timeout=duration, reason=f"Auto-timeout after warnings: {reason}")
377+
try:
378+
await member.edit(timeout=duration, reason=f"Auto-timeout after warnings: {reason}")
379+
except (nextcord.Forbidden, nextcord.HTTPException) as error:
380+
await log_bot_action_blocked(
381+
self,
382+
guild,
383+
action="automatic timeout",
384+
target=member,
385+
reason=f"Discord rejected the timeout: {type(error).__name__}.",
386+
fields=[("Warning Points", str(total_points), True)],
387+
)
388+
return case_id, total_points, None
322389
escalation = "timeout"
323390
case_id = await self.add_case(
324391
guild.id,
@@ -394,6 +461,21 @@ async def process_due_actions(self) -> None:
394461
except (nextcord.Forbidden, nextcord.HTTPException):
395462
continue
396463
if member is not None:
464+
can_untimeout, block_reason = bot_can_act_on_member(
465+
member,
466+
action="scheduled timeout removal",
467+
required_permission="moderate_members",
468+
)
469+
if not can_untimeout:
470+
await log_bot_action_blocked(
471+
self,
472+
guild,
473+
action="scheduled timeout removal",
474+
target=member,
475+
reason=block_reason or "Discord role hierarchy blocked the timeout removal.",
476+
)
477+
self.db.delete_scheduled_action(action["id"])
478+
continue
397479
try:
398480
await member.edit(timeout=None, reason=reason)
399481
except (nextcord.Forbidden, nextcord.HTTPException):

cogs/automod.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010

1111
from bot import MemactAutoModBot
1212
from config import ACTION_LOG_CHANNEL_ID, BOT_JOIN_ROLE_ID, COMMAND_GUILD_IDS, INTRO_CHANNEL_ID, MEMBER_JOIN_ROLE_ID, WELCOME_CHANNEL_ID
13-
from utils.checks import is_moderator_member, require_admin
13+
from utils.checks import (
14+
bot_can_act_on_member,
15+
bot_can_delete_message,
16+
bot_can_manage_role,
17+
is_moderator_member,
18+
log_bot_action_blocked,
19+
require_admin,
20+
)
1421
from utils.content_guard import GuardDecision, evaluate_guard_message
1522
from utils.sentinel import SentinelDecision, content_hash, evaluate_message
1623
from utils.ui import build_embed, send_interaction
@@ -248,6 +255,18 @@ async def _assign_join_role(self, member: nextcord.Member, role_id: int) -> bool
248255
return False
249256
if role in member.roles:
250257
return True
258+
can_assign_role, role_reason = bot_can_manage_role(member.guild, role)
259+
can_edit_member, member_reason = bot_can_act_on_member(member, action="assign join role", required_permission="manage_roles")
260+
if not can_assign_role or not can_edit_member:
261+
await log_bot_action_blocked(
262+
self.bot,
263+
member.guild,
264+
action="assign join role",
265+
target=member,
266+
reason=role_reason or member_reason or "Discord role hierarchy blocked join role assignment.",
267+
fields=[("Role", role.mention, True)],
268+
)
269+
return False
251270
try:
252271
await member.add_roles(role, reason="Automatic join role assignment.")
253272
except (nextcord.Forbidden, nextcord.HTTPException) as error:
@@ -450,11 +469,15 @@ async def _run_guard(self, message: nextcord.Message, config: dict) -> bool:
450469
deleted = False
451470
deletion_error: str | None = None
452471
if decision.should_delete:
453-
try:
454-
await message.delete()
455-
deleted = True
456-
except (nextcord.Forbidden, nextcord.HTTPException) as error:
457-
deletion_error = f"{type(error).__name__}: {error}"
472+
can_delete, block_reason = bot_can_delete_message(message)
473+
if not can_delete:
474+
deletion_error = block_reason or "Discord role hierarchy blocked message deletion."
475+
else:
476+
try:
477+
await message.delete()
478+
deleted = True
479+
except (nextcord.Forbidden, nextcord.HTTPException) as error:
480+
deletion_error = f"{type(error).__name__}: {error}"
458481

459482
event_id = self.bot.db.add_sentinel_event(
460483
message.guild.id,
@@ -596,6 +619,17 @@ async def on_member_join(self, member: nextcord.Member) -> None:
596619
return
597620

598621
reason = f"Account younger than required minimum of {required_hours} hours."
622+
can_kick, block_reason = bot_can_act_on_member(member, action="join screen kick", required_permission="kick_members")
623+
if not can_kick or not member.kickable:
624+
await log_bot_action_blocked(
625+
self.bot,
626+
member.guild,
627+
action="join screen kick",
628+
target=member,
629+
reason=block_reason or "Discord reports this member is not kickable.",
630+
fields=[("Age Hours", f"{age_hours:.2f}", True), ("Required Hours", str(required_hours), True)],
631+
)
632+
return
599633
try:
600634
await member.kick(reason=reason)
601635
except (nextcord.Forbidden, nextcord.HTTPException):

cogs/bluesky.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ async def post_manual_selection(
386386
if channel is None:
387387
await send_interaction(
388388
interaction,
389-
content=f"The fixed Bluesky relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found.",
389+
content=f"The configured Bluesky relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found.",
390390
ephemeral=True,
391391
)
392392
return
@@ -464,7 +464,7 @@ async def view(self, interaction: nextcord.Interaction) -> None:
464464
)
465465
await send_interaction(interaction, embed=embed)
466466

467-
@bluesky.subcommand(description="Set which Bluesky account should auto-post into the fixed relay channel")
467+
@bluesky.subcommand(description="Set which Bluesky account should auto-post into the configured relay channel")
468468
async def setup(
469469
self,
470470
interaction: nextcord.Interaction,
@@ -478,7 +478,7 @@ async def setup(
478478
if relay_channel is None:
479479
await send_interaction(
480480
interaction,
481-
content=f"The fixed relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found in this server.",
481+
content=f"The configured relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found in this server.",
482482
ephemeral=True,
483483
)
484484
return
@@ -625,7 +625,7 @@ async def history(self, interaction: nextcord.Interaction) -> None:
625625
if relay_channel is None:
626626
await send_interaction(
627627
interaction,
628-
content=f"The fixed relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found in this server.",
628+
content=f"The configured relay channel `{BLUESKY_RELAY_CHANNEL_ID}` was not found in this server.",
629629
ephemeral=True,
630630
)
631631
return

0 commit comments

Comments
 (0)