Skip to content

Commit 72bdd26

Browse files
committed
feat(credits): add admin slash-commands to manage user limits and unlimited roles; enforce unlimited via roles/admin while respecting global cap
1 parent 7d11856 commit 72bdd26

4 files changed

Lines changed: 275 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ Memory commands
140140
- `/memory show [scope: channel|all] [user] [limit] [ephemeral]` – zeigt gespeichertes Gedächtnis (Zusammenfassung + letzte Schritte). Ohne `user` wird dein eigenes angezeigt. Andere Nutzer nur für Admins.
141141
- `/memory clear [scope: channel|all] [user] [confirm] [ephemeral]` – löscht Gedächtnis. Ohne `user` löscht du dein eigenes. Andere Nutzer nur für Admins. `confirm:true` erforderlich.
142142

143+
Credits (Admin)
144+
- `/credits stats` – zeigt globale Nutzung und Cap (aktueller Monat)
145+
- `/credits show [user]` – zeigt Nutzung eines Nutzers
146+
- `/credits set-user-limit user:<user> limit:<n>` – setzt benutzerbezogenes Limit (überschreibt Rangregeln)
147+
- `/credits clear-user-limit user:<user>` – entfernt benutzerbezogenes Limit
148+
- `/credits add-unlimited-role role:<role>` – Rolle erhält unendliche Credits (globaler Cap gilt weiterhin)
149+
- `/credits remove-unlimited-role role:<role>` – entfernt unendliche Rolle
150+
- `/credits list-unlimited-roles` – listet unendliche Rollen
151+
143152
License
144153
MIT — see `LICENSE`.
145154
- `run_queue.py` – queue worker (`rag-run-queue`) that processes jobs created from Discord
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Optional
5+
6+
import discord
7+
from discord import app_commands
8+
from discord.ext import commands
9+
10+
from ..infrastructure.permissions import require_admin
11+
from ..infrastructure.credits import (
12+
get_usage,
13+
set_user_limit,
14+
clear_user_limit,
15+
add_unlimited_role,
16+
remove_unlimited_role,
17+
list_unlimited_roles,
18+
)
19+
20+
21+
class CreditCommands(commands.Cog):
22+
def __init__(self, bot: commands.Bot):
23+
self.bot = bot
24+
25+
credits = app_commands.Group(name="credits", description="Admin: Credits Limits & Rollen verwalten")
26+
27+
@credits.command(name="stats", description="Zeigt globale Nutzung und Cap des aktuellen Zeitraums")
28+
@require_admin()
29+
async def stats(self, interaction: discord.Interaction) -> None:
30+
# Show global usage; we need a user id for get_usage, reuse caller
31+
_, global_used = await asyncio.to_thread(get_usage, int(interaction.user.id))
32+
from ..config import settings
33+
cap = int(getattr(settings, "credit_global_cap", 0))
34+
await interaction.response.send_message(
35+
f"🌐 Global genutzt: {global_used} / Cap: {cap}", ephemeral=True
36+
)
37+
38+
@credits.command(name="show", description="Zeigt die Nutzung und Limits eines Nutzers")
39+
@require_admin()
40+
async def show(self, interaction: discord.Interaction, user: Optional[discord.Member] = None) -> None:
41+
target = user or interaction.user
42+
user_used, global_used = await asyncio.to_thread(get_usage, int(target.id))
43+
from ..config import settings
44+
cap = int(getattr(settings, "credit_global_cap", 0))
45+
await interaction.response.send_message(
46+
f"👤 <@{target.id}> genutzt: {user_used}\n🌐 Global: {global_used}/{cap}", ephemeral=True
47+
)
48+
49+
@credits.command(name="set-user-limit", description="Setzt ein benutzerbezogenes monatliches Limit (überschreibt Ränge)")
50+
@require_admin()
51+
async def set_user_limit_cmd(self, interaction: discord.Interaction, user: discord.Member, limit: int) -> None:
52+
await asyncio.to_thread(set_user_limit, int(user.id), int(limit))
53+
await interaction.response.send_message(
54+
f"✅ Limit für <@{user.id}> gesetzt auf {limit}", ephemeral=True
55+
)
56+
57+
@credits.command(name="clear-user-limit", description="Entfernt das benutzerbezogene Limit (Rangregeln greifen wieder)")
58+
@require_admin()
59+
async def clear_user_limit_cmd(self, interaction: discord.Interaction, user: discord.Member) -> None:
60+
await asyncio.to_thread(clear_user_limit, int(user.id))
61+
await interaction.response.send_message(
62+
f"✅ Benutzerlimit für <@{user.id}> entfernt", ephemeral=True
63+
)
64+
65+
@credits.command(name="add-unlimited-role", description="Fügt eine Rolle als 'unendlich' hinzu (per-user Limit entfällt)")
66+
@require_admin()
67+
async def add_unlimited_role_cmd(self, interaction: discord.Interaction, role: discord.Role) -> None:
68+
guild_id = interaction.guild.id if interaction.guild else None
69+
await asyncio.to_thread(add_unlimited_role, int(role.id), str(role.name or ""), int(guild_id) if guild_id else None)
70+
await interaction.response.send_message(
71+
f"✅ Rolle '{role.name}' ({role.id}) als unendlich registriert", ephemeral=True
72+
)
73+
74+
@credits.command(name="remove-unlimited-role", description="Entfernt eine Rolle aus der 'unendlich' Liste")
75+
@require_admin()
76+
async def remove_unlimited_role_cmd(self, interaction: discord.Interaction, role: discord.Role) -> None:
77+
await asyncio.to_thread(remove_unlimited_role, int(role.id))
78+
await interaction.response.send_message(
79+
f"✅ Rolle '{role.name}' ({role.id}) entfernt", ephemeral=True
80+
)
81+
82+
@credits.command(name="list-unlimited-roles", description="Listet Rollen mit 'unendlich'-Status auf")
83+
@require_admin()
84+
async def list_unlimited_roles_cmd(self, interaction: discord.Interaction) -> None:
85+
rows = await asyncio.to_thread(list_unlimited_roles)
86+
if not rows:
87+
await interaction.response.send_message("(leer)", ephemeral=True)
88+
return
89+
lines = ["Unendliche Rollen:"]
90+
for rid, name, gid in rows:
91+
lines.append(f"- {name or '(ohne Namen)'} ({rid}) guild={gid or '-'}")
92+
await interaction.response.send_message("\n".join(lines), ephemeral=True)
93+
94+
95+
async def setup(bot: commands.Bot):
96+
await bot.add_cog(CreditCommands(bot))
97+

src/discord_rag_bot/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ class Settings(BaseSettings):
9898
credit_role_ranks_by_name: dict[str, str] = {}
9999
# Map role ID (as string) -> rank (JSON), e.g.: {"123456": "gold"}
100100
credit_role_ranks_by_id: dict[str, str] = {}
101+
# Roles with unlimited per-user credit (still respects global cap)
102+
credit_unlimited_role_names: list[str] = []
103+
credit_unlimited_role_ids: list[int] = []
101104
# Estimation: ~tokens per char and expected output tokens; 1 credit per 1k tokens by default
102105
credit_tokens_per_char: float = 0.25
103106
credit_est_output_tokens: int = 600

src/discord_rag_bot/infrastructure/credits.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ async def _ensure_async(conn: asyncpg.Connection) -> None:
3636
used_credits INTEGER NOT NULL DEFAULT 0,
3737
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3838
);
39+
CREATE TABLE IF NOT EXISTS bot_credit_user_limits (
40+
user_id BIGINT PRIMARY KEY,
41+
limit INTEGER NOT NULL,
42+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
43+
);
44+
CREATE TABLE IF NOT EXISTS bot_credit_unlimited_roles (
45+
role_id BIGINT PRIMARY KEY,
46+
role_name TEXT,
47+
guild_id BIGINT,
48+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
49+
);
3950
"""
4051
)
4152

@@ -185,3 +196,158 @@ def resolve_user_limit_from_roles(*, member_roles: list[tuple[int, str]]) -> int
185196
if isinstance(lim, int) and lim > max_limit:
186197
max_limit = lim
187198
return max_limit
199+
200+
201+
def has_unlimited_from_roles(*, member_roles: list[tuple[int, str]]) -> bool:
202+
# From settings
203+
names = set((settings.credit_unlimited_role_names or []))
204+
ids = set(int(x) for x in (settings.credit_unlimited_role_ids or []))
205+
for rid, name in member_roles:
206+
if rid in ids:
207+
return True
208+
if name and name in names:
209+
return True
210+
# From DB
211+
async def run() -> bool:
212+
conn = await asyncpg.connect(_dsn())
213+
try:
214+
await _ensure_async(conn)
215+
role_ids = [int(rid) for rid, _ in member_roles if rid]
216+
if not role_ids:
217+
return False
218+
rows = await conn.fetch(
219+
"SELECT role_id FROM bot_credit_unlimited_roles WHERE role_id = ANY($1::BIGINT[])",
220+
role_ids,
221+
)
222+
return bool(rows)
223+
finally:
224+
await conn.close()
225+
226+
return asyncio.run(run())
227+
228+
229+
def get_user_limit_override(user_id: int) -> Optional[int]:
230+
async def run() -> Optional[int]:
231+
conn = await asyncpg.connect(_dsn())
232+
try:
233+
await _ensure_async(conn)
234+
row = await conn.fetchrow("SELECT limit FROM bot_credit_user_limits WHERE user_id=$1", int(user_id))
235+
return int(row[0]) if row else None
236+
finally:
237+
await conn.close()
238+
239+
return asyncio.run(run())
240+
241+
242+
def set_user_limit(user_id: int, limit: int) -> None:
243+
async def run():
244+
conn = await asyncpg.connect(_dsn())
245+
try:
246+
await _ensure_async(conn)
247+
await conn.execute(
248+
"""
249+
INSERT INTO bot_credit_user_limits(user_id, limit)
250+
VALUES ($1, $2)
251+
ON CONFLICT (user_id) DO UPDATE SET limit=EXCLUDED.limit, updated_at=NOW()
252+
""",
253+
int(user_id), int(limit)
254+
)
255+
finally:
256+
await conn.close()
257+
258+
asyncio.run(run())
259+
260+
261+
def clear_user_limit(user_id: int) -> None:
262+
async def run():
263+
conn = await asyncpg.connect(_dsn())
264+
try:
265+
await _ensure_async(conn)
266+
await conn.execute("DELETE FROM bot_credit_user_limits WHERE user_id=$1", int(user_id))
267+
finally:
268+
await conn.close()
269+
270+
asyncio.run(run())
271+
272+
273+
def add_unlimited_role(role_id: int, role_name: Optional[str], guild_id: Optional[int]) -> None:
274+
async def run():
275+
conn = await asyncpg.connect(_dsn())
276+
try:
277+
await _ensure_async(conn)
278+
await conn.execute(
279+
"""
280+
INSERT INTO bot_credit_unlimited_roles(role_id, role_name, guild_id)
281+
VALUES ($1, $2, $3)
282+
ON CONFLICT (role_id) DO UPDATE SET role_name=EXCLUDED.role_name, guild_id=EXCLUDED.guild_id
283+
""",
284+
int(role_id), role_name, int(guild_id) if guild_id else None
285+
)
286+
finally:
287+
await conn.close()
288+
289+
asyncio.run(run())
290+
291+
292+
def remove_unlimited_role(role_id: int) -> None:
293+
async def run():
294+
conn = await asyncpg.connect(_dsn())
295+
try:
296+
await _ensure_async(conn)
297+
await conn.execute("DELETE FROM bot_credit_unlimited_roles WHERE role_id=$1", int(role_id))
298+
finally:
299+
await conn.close()
300+
301+
asyncio.run(run())
302+
303+
304+
def list_unlimited_roles() -> list[tuple[int, Optional[str], Optional[int]]]:
305+
async def run() -> list[tuple[int, Optional[str], Optional[int]]]:
306+
conn = await asyncpg.connect(_dsn())
307+
try:
308+
await _ensure_async(conn)
309+
rows = await conn.fetch("SELECT role_id, role_name, guild_id FROM bot_credit_unlimited_roles ORDER BY created_at")
310+
out: list[tuple[int, Optional[str], Optional[int]]] = []
311+
for r in rows:
312+
out.append((int(r["role_id"]), r["role_name"], int(r["guild_id"]) if r["guild_id"] else None))
313+
return out
314+
finally:
315+
await conn.close()
316+
317+
return asyncio.run(run())
318+
319+
320+
def get_usage(user_id: int) -> tuple[int, int]:
321+
"""Return (user_used, global_used) for current period."""
322+
period = _period_start()
323+
324+
async def run() -> tuple[int, int]:
325+
conn = await asyncpg.connect(_dsn())
326+
try:
327+
await _ensure_async(conn)
328+
row_u = await conn.fetchrow("SELECT used_credits FROM bot_credits_user WHERE user_id=$1 AND period_start=$2", int(user_id), period)
329+
row_g = await conn.fetchrow("SELECT used_credits FROM bot_credits_global WHERE period_start=$1", period)
330+
return (int(row_u[0]) if row_u else 0, int(row_g[0]) if row_g else 0)
331+
finally:
332+
await conn.close()
333+
334+
return asyncio.run(run())
335+
336+
337+
def compute_user_policy(*, user_id: int, member_roles: list[tuple[int, str]], is_admin: bool) -> tuple[bool, int]:
338+
"""Return (unlimited, per_user_limit).
339+
340+
unlimited ignores per-user limit but still respects global cap.
341+
"""
342+
# Admins unlimited by default
343+
if is_admin:
344+
return True, 10**9
345+
if has_unlimited_from_roles(member_roles=member_roles):
346+
return True, 10**9
347+
# Per-user override
348+
ul = get_user_limit_override(user_id)
349+
if isinstance(ul, int):
350+
return False, max(1, int(ul))
351+
# Rank-based
352+
limit = resolve_user_limit_from_roles(member_roles=member_roles)
353+
return False, max(1, int(limit))

0 commit comments

Comments
 (0)