Skip to content

Commit 640ec3a

Browse files
committed
feat: per-command Discord slash permissions, Docker defaults, repo allowlist
- Add discord.command_permissions and unrestricted_slash_commands (testing) - Legacy fallback to assignments.issue_assignees when a command is omitted - Apply checks to assign-issue, issue-requests, sync; clearer error messages - Config examples and docker-compose; optional github.repos allowlist in aussie.yaml - Extend .gitignore for local venvs and .pydeps - Add unit tests for permission helpers Single commit on current main; no committed virtualenv or dependency trees. Made-with: Cursor
1 parent 31fabb8 commit 640ec3a

10 files changed

Lines changed: 403 additions & 26 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
.env.*
33
!.env.example
44
.venv/
5+
.venv*/
6+
.pydeps/
7+
.ci-venv/
58
__pycache__/
69
*.sqlite
710
*.db

config/aussie.yaml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
runtime:
66
mode: "active"
77
log_level: "INFO"
8-
data_dir: "./data/aussie"
8+
data_dir: "/data"
99
github_adapter: "ghdcbot.adapters.github.rest:GitHubRestAdapter"
1010
discord_adapter: "ghdcbot.adapters.discord.api:DiscordApiAdapter"
1111
storage_adapter: "ghdcbot.adapters.storage.sqlite:SqliteStorage"
@@ -21,13 +21,34 @@ github:
2121
read: true
2222
write: true
2323
user_fallback: false
24+
# Repo allowlist: only these repositories are scanned (sync/run-once). Uses short repo names under the org (not "org/name").
25+
# Omit this block entirely to include all org repos (slower on large orgs). mode "deny" excludes listed names instead.
26+
repos:
27+
mode: allow
28+
names:
29+
- "Gitcord-GithubDiscordBot"
30+
# Add more repos as needed, e.g.:
31+
# - "another-repo"
2432

2533
discord:
2634
guild_id: "1022871757289422898"
2735
token: "${DISCORD_TOKEN}"
2836
permissions:
2937
read: true
3038
write: true
39+
# TESTING: set false in production — when true, any server member may use /assign-issue, /issue-requests, /sync.
40+
unrestricted_slash_commands: true
41+
# Per-command slash access (role_ids stable; role_names optional). Admins may use these when enabled below.
42+
command_permissions:
43+
assign-issue:
44+
role_names: ["Mentor"]
45+
allow_discord_administrators: true
46+
issue-requests:
47+
role_names: ["Mentor"]
48+
allow_discord_administrators: true
49+
sync:
50+
role_names: ["Mentor"]
51+
allow_discord_administrators: true
3152
activity_channel_id: null
3253
pr_preview_channels: []
3354
# Verified GitHub → Discord DMs (or channel if channel_id is set). Requires linked users (/link + /verify-link).

config/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
runtime:
33
mode: "active"
44
log_level: "INFO"
5-
data_dir: "./data"
5+
data_dir: "/data"
66
github_adapter: "ghdcbot.adapters.github.rest:GitHubRestAdapter"
77
discord_adapter: "ghdcbot.adapters.discord.api:DiscordApiAdapter"
88
storage_adapter: "ghdcbot.adapters.storage.sqlite:SqliteStorage"

config/docker-example.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ discord:
2424
permissions:
2525
read: true
2626
write: false
27+
# Who may run /assign-issue, /issue-requests, /sync (add role_ids from Discord for stable checks).
28+
# Omit this block to fall back to assignments.issue_assignees for those commands.
29+
command_permissions:
30+
assign-issue:
31+
role_names: ["Mentor"]
32+
allow_discord_administrators: true
33+
issue-requests:
34+
role_names: ["Mentor"]
35+
allow_discord_administrators: true
36+
sync:
37+
role_names: ["Mentor"]
38+
allow_discord_administrators: true
2739
activity_channel_id: null
2840
pr_preview_channels: []
2941

@@ -42,6 +54,7 @@ role_mappings:
4254
assignments:
4355
review_roles:
4456
- "Maintainer"
57+
# Fallback if discord.command_permissions is omitted; also used for org issue assignment planning.
4558
issue_assignees:
4659
- "Mentor"
4760
issue_request_eligible_roles: []

config/example.yaml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ discord:
3333
# Optional: channel names where PR URLs trigger passive preview (requires message content intent)
3434
# Example: ["mentor-chat", "review-queue"]
3535
pr_preview_channels: []
36+
# Who may run /assign-issue, /issue-requests, /sync (role_ids preferred; role_names optional).
37+
# If you omit this block entirely, the bot uses assignments.issue_assignees (role names) for those commands.
38+
command_permissions:
39+
assign-issue:
40+
role_names: ["Mentor"]
41+
allow_discord_administrators: true
42+
issue-requests:
43+
role_names: ["Mentor"]
44+
allow_discord_administrators: true
45+
sync:
46+
role_names: ["Mentor"]
47+
allow_discord_administrators: true
3648
# Optional: verified-only GitHub → Discord notifications (issue assigned, PR review, merged, etc.)
3749
# notifications:
3850
# enabled: true
@@ -60,7 +72,7 @@ role_mappings:
6072
assignments:
6173
review_roles:
6274
- "Maintainer"
63-
# Discord role(s) that can use /assign-issue, /sync, and /issue-requests (e.g. "Mentor")
75+
# Fallback for slash-command access if discord.command_permissions is omitted; also used for org issue assignment planning.
6476
issue_assignees:
6577
- "Mentor"
6678
# Optional: roles required for a contributor to be "eligible" for issue assignment (empty = any verified user)

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ services:
2323
- ./config:/app/config:ro
2424
# Persist SQLite state, reports, and audit logs between restarts.
2525
- gitcord_data:/data
26-
command: ["ghdcbot", "--config", "/app/config/config.yaml", "bot"]
26+
command: ["ghdcbot", "--config", "/app/config/aussie.yaml", "bot"]
2727
restart: unless-stopped
2828

2929
volumes:

src/ghdcbot/bot.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@
5050
)
5151
from ghdcbot.logging.setup import configure_logging
5252
from ghdcbot.plugins.registry import build_adapter
53+
from ghdcbot.discord_command_permissions import (
54+
format_slash_command_permission_denied,
55+
slash_command_allowed,
56+
)
57+
58+
# Slash command names used for permission checks (must match @tree.command name=...)
59+
SLASH_CMD_ASSIGN_ISSUE = "assign-issue"
60+
SLASH_CMD_ISSUE_REQUESTS = "issue-requests"
61+
SLASH_CMD_SYNC = "sync"
5362

5463

5564
def run_bot(config_path: str) -> None:
@@ -888,24 +897,20 @@ async def cancel_assignment(self, interaction: discord.Interaction, button: disc
888897
except Exception:
889898
pass
890899

891-
# Create a check function for mentor-only commands
892-
def mentor_check(interaction: discord.Interaction) -> bool:
893-
"""Check if user has mentor role."""
894-
mentor_roles = getattr(config, "assignments", None)
895-
if not mentor_roles:
896-
return False
897-
issue_assignee_roles = getattr(mentor_roles, "issue_assignees", [])
898-
if not issue_assignee_roles:
899-
return False
900-
user_roles = [role.name for role in interaction.user.roles]
901-
return any(role in issue_assignee_roles for role in user_roles)
900+
def command_permission_check(command_name: str):
901+
"""Restrict slash commands via discord.command_permissions or legacy issue_assignees."""
902+
903+
def check(interaction: discord.Interaction) -> bool:
904+
return slash_command_allowed(interaction, config, command_name)
905+
906+
return check
902907

903908
@tree.command(
904909
name="assign-issue",
905910
description="Assign a GitHub issue to a Discord user (mentor-only, requires confirmation)",
906911
guild=discord.Object(id=guild_id),
907912
)
908-
@app_commands.check(mentor_check)
913+
@app_commands.check(command_permission_check(SLASH_CMD_ASSIGN_ISSUE))
909914
@app_commands.describe(
910915
issue_url="GitHub issue URL (e.g., https://github.com/owner/repo/issues/123)",
911916
assignee="Discord user to assign the issue to"
@@ -1579,7 +1584,7 @@ async def request_issue_cmd(interaction: discord.Interaction, issue_url: str) ->
15791584
description="List pending issue assignment requests (mentor-only); pick a repo first.",
15801585
guild=discord.Object(id=guild_id),
15811586
)
1582-
@app_commands.check(mentor_check)
1587+
@app_commands.check(command_permission_check(SLASH_CMD_ISSUE_REQUESTS))
15831588
async def issue_requests_cmd(interaction: discord.Interaction) -> None:
15841589
await interaction.response.defer(ephemeral=True)
15851590
pending = getattr(storage, "list_pending_issue_requests", None)
@@ -1684,7 +1689,7 @@ async def on_message(message: discord.Message) -> None:
16841689
description="Sync GitHub events and send notifications (mentor-only)",
16851690
guild=discord.Object(id=guild_id),
16861691
)
1687-
@app_commands.check(mentor_check)
1692+
@app_commands.check(command_permission_check(SLASH_CMD_SYNC))
16881693
async def sync_cmd(interaction: discord.Interaction) -> None:
16891694
"""Manually trigger run-once to sync GitHub events and send notifications."""
16901695
await interaction.response.defer(ephemeral=True)
@@ -1751,17 +1756,13 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm
17511756
"""Handle app command errors, including check failures."""
17521757
if isinstance(error, app_commands.CheckFailure):
17531758
try:
1754-
mentor_roles = getattr(config, "assignments", None)
1755-
issue_assignee_roles = getattr(mentor_roles, "issue_assignees", []) if mentor_roles else []
1756-
role_list = ', '.join(issue_assignee_roles) if issue_assignee_roles else 'configure issue_assignees'
1757-
error_message = f"❌ Permission denied. Only mentors with roles **{role_list}** can use this command."
1758-
1759+
cmd_name = interaction.command.name if interaction.command else "unknown"
1760+
error_message = format_slash_command_permission_denied(config, cmd_name)
17591761
logger.info(
1760-
"Check failure for user %s (%s) on command %s. Required roles: %s",
1762+
"Check failure for user %s (%s) on command %s.",
17611763
interaction.user.name,
17621764
interaction.user.id,
1763-
interaction.command.name if interaction.command else "unknown",
1764-
role_list,
1765+
cmd_name,
17651766
)
17661767

17671768
# Check if response is already sent (shouldn't happen for check failures, but be safe)

src/ghdcbot/config/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ class GitHubConfig(BaseModel):
5858
user_fallback: bool = False
5959

6060

61+
class SlashCommandPermissionRule(BaseModel):
62+
"""Who may run a restricted slash command (e.g. assign-issue, issue-requests, sync).
63+
64+
If a command is omitted from ``discord.command_permissions``, the bot falls back to
65+
``assignments.issue_assignees`` (role name match), for backward compatibility.
66+
"""
67+
68+
role_ids: list[str] = Field(default_factory=list)
69+
role_names: list[str] = Field(default_factory=list)
70+
allow_discord_administrators: bool = False
71+
72+
6173
class NotificationConfig(BaseModel):
6274
"""Configuration for verified-only GitHub → Discord notifications."""
6375
enabled: bool = True
@@ -89,6 +101,10 @@ class DiscordConfig(BaseModel):
89101
pr_preview_channels: list[str] = Field(default_factory=list)
90102
# Optional: verified-only GitHub → Discord notifications
91103
notifications: NotificationConfig | None = None
104+
# Optional: per-command slash permission (keys: assign-issue, issue-requests, sync). See SlashCommandPermissionRule.
105+
command_permissions: dict[str, SlashCommandPermissionRule] | None = None
106+
# TESTING ONLY: if true, any guild member may run assign-issue / issue-requests / sync. Turn off for production.
107+
unrestricted_slash_commands: bool = False
92108

93109

94110
class QualityAdjustmentsConfig(BaseModel):
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Per-slash-command permission checks (Discord roles + optional Administrator bypass)."""
2+
3+
from __future__ import annotations
4+
5+
import discord
6+
7+
from ghdcbot.config.models import BotConfig, SlashCommandPermissionRule
8+
9+
10+
def _legacy_issue_assignee_allowed(member: discord.Member, config: BotConfig) -> bool:
11+
"""Backward compatible: assignments.issue_assignees matched by role name."""
12+
mentor_roles = getattr(config, "assignments", None)
13+
if not mentor_roles:
14+
return False
15+
issue_assignee_roles = getattr(mentor_roles, "issue_assignees", [])
16+
if not issue_assignee_roles:
17+
return False
18+
user_roles = [role.name for role in member.roles]
19+
return any(role in issue_assignee_roles for role in user_roles)
20+
21+
22+
def _is_guild_member_like(user: object) -> bool:
23+
"""True for Discord Member in a guild (has roles + guild_permissions). Duck-typed for tests."""
24+
return hasattr(user, "roles") and hasattr(user, "guild_permissions")
25+
26+
27+
def slash_command_allowed(
28+
interaction: discord.Interaction,
29+
config: BotConfig,
30+
command_name: str,
31+
) -> bool:
32+
"""Return True if the member may run this slash command."""
33+
member = interaction.user
34+
if not _is_guild_member_like(member):
35+
return False
36+
37+
if getattr(config.discord, "unrestricted_slash_commands", False):
38+
return True
39+
40+
perms = getattr(config.discord, "command_permissions", None)
41+
rule: SlashCommandPermissionRule | None = None
42+
if perms and command_name in perms:
43+
rule = perms[command_name]
44+
45+
if rule is None:
46+
return _legacy_issue_assignee_allowed(member, config)
47+
48+
if rule.allow_discord_administrators and member.guild_permissions.administrator:
49+
return True
50+
51+
id_allow = {str(rid).strip() for rid in rule.role_ids if str(rid).strip()}
52+
for role in member.roles:
53+
if str(role.id) in id_allow:
54+
return True
55+
56+
if rule.role_names:
57+
allowed_names = set(rule.role_names)
58+
user_role_names = {r.name for r in member.roles}
59+
if user_role_names & allowed_names:
60+
return True
61+
62+
return False
63+
64+
65+
def format_slash_command_permission_denied(config: BotConfig, command_name: str) -> str:
66+
"""User-facing message listing who may use the command."""
67+
perms = getattr(config.discord, "command_permissions", None)
68+
rule: SlashCommandPermissionRule | None = None
69+
if perms and command_name in perms:
70+
rule = perms[command_name]
71+
72+
if rule is None:
73+
mentor_roles = getattr(config, "assignments", None)
74+
issue_assignee_roles = getattr(mentor_roles, "issue_assignees", []) if mentor_roles else []
75+
role_list = ", ".join(issue_assignee_roles) if issue_assignee_roles else "configure assignments.issue_assignees"
76+
return (
77+
f"❌ Permission denied. Only members with roles **{role_list}** "
78+
f"can use `/{command_name}` (or set `discord.command_permissions`)."
79+
)
80+
81+
bits: list[str] = []
82+
if rule.role_ids:
83+
bits.append("role ID(s): " + ", ".join(str(r) for r in rule.role_ids))
84+
if rule.role_names:
85+
bits.append("role name(s): " + ", ".join(rule.role_names))
86+
if rule.allow_discord_administrators:
87+
bits.append("Discord Administrators")
88+
if not bits:
89+
return f"❌ Permission denied. Nobody is allowed to use `/{command_name}` with the current rule (fix `discord.command_permissions`)."
90+
return f"❌ Permission denied for `/{command_name}`. Allowed: {', '.join(bits)}."

0 commit comments

Comments
 (0)