diff --git a/cogs/commands.py b/cogs/commands.py index 12f9527..46b9046 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -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.", diff --git a/cogs/sentiment/__init__.py b/cogs/sentiment/__init__.py index c655e09..6119b0e 100644 --- a/cogs/sentiment/__init__.py +++ b/cogs/sentiment/__init__.py @@ -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 = ( diff --git a/cogs/sentiment/state.py b/cogs/sentiment/state.py index 81443a9..4a442ee 100644 --- a/cogs/sentiment/state.py +++ b/cogs/sentiment/state.py @@ -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)) diff --git a/config.yaml b/config.yaml index ef1febc..8252da8 100644 --- a/config.yaml +++ b/config.yaml @@ -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" diff --git a/utils/database.py b/utils/database.py index fb64fb5..82f769b 100644 --- a/utils/database.py +++ b/utils/database.py @@ -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 ] diff --git a/utils/drama_tracker.py b/utils/drama_tracker.py index d144639..0d42530 100644 --- a/utils/drama_tracker.py +++ b/utils/drama_tracker.py @@ -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