feat: move user aliases from config to DB with /bcs-alias command
Aliases now stored in UserState table instead of config.yaml. Adds Aliases column (NVARCHAR 500), loads on startup, persists via flush. New /bcs-alias slash command (view/set/clear) for managing nicknames. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -250,6 +250,7 @@ class CommandsCog(commands.Cog):
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
aliases=",".join(user_data.aliases) if user_data.aliases else None,
|
||||
))
|
||||
status = "now immune" if is_immune else "no longer immune"
|
||||
await interaction.response.send_message(
|
||||
@@ -501,6 +502,7 @@ class CommandsCog(commands.Cog):
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
aliases=",".join(user_data.aliases) if user_data.aliases else None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Note added for {user.display_name}.", ephemeral=True
|
||||
@@ -516,11 +518,86 @@ class CommandsCog(commands.Cog):
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=None,
|
||||
aliases=",".join(user_data.aliases) if user_data.aliases else None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Notes cleared for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-alias",
|
||||
description="Manage nicknames/aliases for a user. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(
|
||||
action="What to do with aliases",
|
||||
user="The user whose aliases to manage",
|
||||
text="Comma-separated aliases (only used with 'set')",
|
||||
)
|
||||
@app_commands.choices(action=[
|
||||
app_commands.Choice(name="view", value="view"),
|
||||
app_commands.Choice(name="set", value="set"),
|
||||
app_commands.Choice(name="clear", value="clear"),
|
||||
])
|
||||
async def bcs_alias(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
action: app_commands.Choice[str],
|
||||
user: discord.Member,
|
||||
text: str | None = None,
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message("Admin only.", ephemeral=True)
|
||||
return
|
||||
|
||||
if action.value == "view":
|
||||
aliases = self.bot.drama_tracker.get_user_aliases(user.id)
|
||||
desc = ", ".join(aliases) if aliases else "_No aliases set._"
|
||||
embed = discord.Embed(
|
||||
title=f"Aliases: {user.display_name}",
|
||||
description=desc,
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
elif action.value == "set":
|
||||
if not text:
|
||||
await interaction.response.send_message(
|
||||
"Provide `text` with comma-separated aliases (e.g. `Glam, G`).", ephemeral=True
|
||||
)
|
||||
return
|
||||
aliases = [a.strip() for a in text.split(",") if a.strip()]
|
||||
self.bot.drama_tracker.set_user_aliases(user.id, aliases)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
aliases=",".join(aliases),
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Aliases for {user.display_name} set to: {', '.join(aliases)}", ephemeral=True
|
||||
)
|
||||
|
||||
elif action.value == "clear":
|
||||
self.bot.drama_tracker.set_user_aliases(user.id, [])
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
aliases=None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Aliases cleared for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-mode",
|
||||
description="Switch the bot's personality mode.",
|
||||
|
||||
@@ -253,18 +253,18 @@ class SentimentCog(commands.Cog):
|
||||
"""Replace display name keys with anonymous keys in user notes map."""
|
||||
return {anon_map.get(name, name): notes for name, notes in user_notes_map.items()}
|
||||
|
||||
@staticmethod
|
||||
def _build_alias_context(
|
||||
self,
|
||||
messages: list[discord.Message],
|
||||
anon_map: dict[str, str],
|
||||
alias_config: dict,
|
||||
) -> str:
|
||||
"""Build anonymized alias context string for the LLM.
|
||||
|
||||
Maps user IDs from messages to their known nicknames using the
|
||||
config, then replaces display names with anonymous keys.
|
||||
Maps user IDs from messages to their known nicknames from
|
||||
DramaTracker, then replaces display names with anonymous keys.
|
||||
"""
|
||||
if not alias_config:
|
||||
all_aliases = self.bot.drama_tracker.get_all_aliases()
|
||||
if not all_aliases:
|
||||
return ""
|
||||
lines = []
|
||||
seen_ids: set[int] = set()
|
||||
@@ -273,14 +273,13 @@ class SentimentCog(commands.Cog):
|
||||
if uid in seen_ids:
|
||||
continue
|
||||
seen_ids.add(uid)
|
||||
aliases = alias_config.get(uid)
|
||||
aliases = all_aliases.get(uid)
|
||||
if aliases:
|
||||
anon_key = anon_map.get(msg.author.display_name, msg.author.display_name)
|
||||
lines.append(f" {anon_key} is also known as: {', '.join(aliases)}")
|
||||
# Also include aliases for members NOT in the conversation (so the LLM
|
||||
# can recognize name-drops of absent members)
|
||||
for uid, aliases in alias_config.items():
|
||||
uid = int(uid) if isinstance(uid, str) else uid
|
||||
for uid, aliases in all_aliases.items():
|
||||
if uid not in seen_ids:
|
||||
lines.append(f" (not in chat) also known as: {', '.join(aliases)}")
|
||||
return "\n".join(lines) if lines else ""
|
||||
@@ -498,8 +497,7 @@ class SentimentCog(commands.Cog):
|
||||
anon_conversation = self._anonymize_conversation(conversation, anon_map)
|
||||
anon_notes = self._anonymize_notes(user_notes_map, anon_map) if user_notes_map else user_notes_map
|
||||
|
||||
alias_config = config.get("user_aliases", {})
|
||||
alias_context = self._build_alias_context(all_messages, anon_map, alias_config)
|
||||
alias_context = self._build_alias_context(all_messages, anon_map)
|
||||
|
||||
channel_context = build_channel_context(ref_message, game_channels)
|
||||
|
||||
@@ -675,8 +673,7 @@ class SentimentCog(commands.Cog):
|
||||
anon_conversation = self._anonymize_conversation(conversation, anon_map)
|
||||
anon_notes = self._anonymize_notes(user_notes_map, anon_map) if user_notes_map else user_notes_map
|
||||
|
||||
alias_config = config.get("user_aliases", {})
|
||||
alias_context = self._build_alias_context(raw_messages, anon_map, alias_config)
|
||||
alias_context = self._build_alias_context(raw_messages, anon_map)
|
||||
|
||||
channel_context = build_channel_context(raw_messages[0], game_channels)
|
||||
mention_context = (
|
||||
|
||||
@@ -4,6 +4,11 @@ import logging
|
||||
logger = logging.getLogger("bcs.sentiment")
|
||||
|
||||
|
||||
def _aliases_csv(user_data) -> str | None:
|
||||
"""Convert aliases list to comma-separated string for DB storage."""
|
||||
return ",".join(user_data.aliases) if user_data.aliases else None
|
||||
|
||||
|
||||
def save_user_state(bot, dirty_users: set[int], user_id: int) -> None:
|
||||
"""Fire-and-forget save of a user's current state to DB."""
|
||||
user_data = bot.drama_tracker.get_user(user_id)
|
||||
@@ -16,6 +21,7 @@ def save_user_state(bot, dirty_users: set[int], user_id: int) -> None:
|
||||
user_notes=user_data.notes or None,
|
||||
warned=user_data.warned_since_reset,
|
||||
last_offense_at=user_data.last_offense_time or None,
|
||||
aliases=_aliases_csv(user_data),
|
||||
))
|
||||
dirty_users.discard(user_id)
|
||||
|
||||
@@ -37,5 +43,6 @@ async def flush_dirty_states(bot, dirty_users: set[int]) -> None:
|
||||
user_notes=user_data.notes or None,
|
||||
warned=user_data.warned_since_reset,
|
||||
last_offense_at=user_data.last_offense_time or None,
|
||||
aliases=_aliases_csv(user_data),
|
||||
)
|
||||
logger.info("Flushed %d dirty user states to DB.", len(dirty))
|
||||
|
||||
@@ -21,13 +21,6 @@ sentiment:
|
||||
escalation_threshold: 0.25 # Triage toxicity score that triggers re-analysis with heavy model
|
||||
escalation_boost: 0.04 # Per-message drama boost after warning (sustained toxicity ramps toward mute)
|
||||
|
||||
# Nicknames/aliases for server members. Used by the LLM to recognize
|
||||
# when someone references another member by name in chat.
|
||||
user_aliases:
|
||||
684222822272598058: ["Mark", "Limit"] # thelimitations
|
||||
1113144994790903908: ["Glam", "G"] # Glamgirlxx
|
||||
1195191381929508964: ["Bree"] # QueenBree10
|
||||
|
||||
game_channels:
|
||||
gta-online: "GTA Online"
|
||||
battlefield: "Battlefield"
|
||||
|
||||
@@ -138,6 +138,12 @@ class Database:
|
||||
ALTER TABLE UserState ADD LastOffenseAt FLOAT NULL
|
||||
""")
|
||||
|
||||
# --- Schema migration for user aliases/nicknames ---
|
||||
cursor.execute("""
|
||||
IF COL_LENGTH('UserState', 'Aliases') IS NULL
|
||||
ALTER TABLE UserState ADD Aliases NVARCHAR(500) NULL
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'BotSettings')
|
||||
CREATE TABLE BotSettings (
|
||||
@@ -314,19 +320,20 @@ class Database:
|
||||
user_notes: str | None = None,
|
||||
warned: bool = False,
|
||||
last_offense_at: float | None = None,
|
||||
aliases: str | None = None,
|
||||
) -> None:
|
||||
"""Upsert user state (offense count, immunity, off-topic count, coherence baseline, notes, warned, last offense time)."""
|
||||
"""Upsert user state (offense count, immunity, off-topic count, coherence baseline, notes, warned, last offense time, aliases)."""
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._save_user_state_sync,
|
||||
user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at,
|
||||
user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at, aliases,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to save user state")
|
||||
|
||||
def _save_user_state_sync(self, user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at):
|
||||
def _save_user_state_sync(self, user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes, warned, last_offense_at, aliases):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
@@ -337,14 +344,14 @@ class Database:
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET OffenseCount = ?, Immune = ?, OffTopicCount = ?,
|
||||
BaselineCoherence = ?, UserNotes = ?, Warned = ?,
|
||||
LastOffenseAt = ?,
|
||||
LastOffenseAt = ?, Aliases = ?,
|
||||
UpdatedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);""",
|
||||
INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt, Aliases)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);""",
|
||||
user_id,
|
||||
offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at,
|
||||
user_id, offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at,
|
||||
offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at, aliases,
|
||||
user_id, offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes, 1 if warned else 0, last_offense_at, aliases,
|
||||
)
|
||||
cursor.close()
|
||||
finally:
|
||||
@@ -387,7 +394,7 @@ class Database:
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt FROM UserState"
|
||||
"SELECT UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes, Warned, LastOffenseAt, Aliases FROM UserState"
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
@@ -401,6 +408,7 @@ class Database:
|
||||
"user_notes": row[5] or "",
|
||||
"warned": bool(row[6]),
|
||||
"last_offense_at": float(row[7]) if row[7] is not None else 0.0,
|
||||
"aliases": row[8] or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -30,6 +30,8 @@ class UserDrama:
|
||||
last_coherence_alert_time: float = 0.0
|
||||
# Per-user LLM notes
|
||||
notes: str = ""
|
||||
# Known aliases/nicknames
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class DramaTracker:
|
||||
@@ -217,6 +219,16 @@ class DramaTracker:
|
||||
def clear_user_notes(self, user_id: int) -> None:
|
||||
self.get_user(user_id).notes = ""
|
||||
|
||||
def get_user_aliases(self, user_id: int) -> list[str]:
|
||||
return self.get_user(user_id).aliases
|
||||
|
||||
def set_user_aliases(self, user_id: int, aliases: list[str]) -> None:
|
||||
self.get_user(user_id).aliases = aliases
|
||||
|
||||
def get_all_aliases(self) -> dict[int, list[str]]:
|
||||
"""Return {user_id: [aliases]} for all users that have aliases set."""
|
||||
return {uid: user.aliases for uid, user in self._users.items() if user.aliases}
|
||||
|
||||
def reset_off_topic(self, user_id: int) -> None:
|
||||
user = self.get_user(user_id)
|
||||
user.off_topic_count = 0
|
||||
@@ -298,6 +310,8 @@ class DramaTracker:
|
||||
user.offense_count = 0
|
||||
user.warned_since_reset = False
|
||||
user.last_offense_time = 0.0
|
||||
if state.get("aliases"):
|
||||
user.aliases = [a.strip() for a in state["aliases"].split(",") if a.strip()]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
Reference in New Issue
Block a user