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:
2026-02-27 10:35:19 -05:00
parent ad1234ec99
commit 33d56f8737
6 changed files with 124 additions and 28 deletions

View File

@@ -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.",

View File

@@ -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 = (

View File

@@ -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))

View File

@@ -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"

View File

@@ -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
]

View File

@@ -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