From 7c081e70b5e2fa69d2f1c57edb108ad6daa10869 Mon Sep 17 00:00:00 2001 From: CD Date: Sat, 7 Mar 2026 13:09:05 +0900 Subject: [PATCH] fix: channel duplication root fix - ready gate + conv_id-first + Discord API search + hash pre-init --- bot.py | 37 +++++++++++++++++++++++++------------ watcher.py | 21 +++++++++++++++++++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/bot.py b/bot.py index 78abf22..98a4319 100644 --- a/bot.py +++ b/bot.py @@ -130,6 +130,7 @@ class GravityBot(commands.Bot): self.session_names: dict[str, str] = {} self._channel_create_lock = asyncio.Lock() # SINGLE global lock self._sent_approval_ids: set[str] = set() # Track sent approvals + self._ready_event = asyncio.Event() # Gate: wait until on_ready finishes self.bridge = BridgeProtocol() self.session_category: discord.CategoryChannel | None = None self.guild: discord.Guild | None = None @@ -163,6 +164,10 @@ class GravityBot(commands.Bot): # ONLY reconnect to existing Discord channels (NO new creation) await self._reconnect_existing_channels() + # NOW allow event processing to begin + self._ready_event.set() + logger.info("Ready gate opened — event processing enabled") + async def _reconnect_existing_channels(self): """Scan existing Discord channels and map them — MERGE same-name channels.""" if not self.session_category: @@ -219,32 +224,38 @@ class GravityBot(commands.Bot): async def _ensure_channel( self, conversation_id: str, project_name: str ) -> discord.TextChannel: - """Get or create a channel. SINGLE channel per project name, guaranteed.""" - channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" - target_name = channel_name.lower().replace(" ", "-") + """Get or create a channel. ONE channel per conv_id, guaranteed.""" - # Fast path: this conv_id already mapped + # Fast path: this conv_id already has a channel — ALWAYS return it + # (even if project name changed; name changes are cosmetic, not worth a new channel) if conversation_id in self.session_channels: - ch = self.session_channels[conversation_id] - # Verify the channel name matches (project name might have changed) - if ch.name == target_name: - return ch + return self.session_channels[conversation_id] async with self._channel_create_lock: # Double-check after lock if conversation_id in self.session_channels: - ch = self.session_channels[conversation_id] - if ch.name == target_name: - return ch + return self.session_channels[conversation_id] + + channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" + target_name = channel_name.lower().replace(" ", "-") # Check ALL mapped channels for same name for cid, ch in self.session_channels.items(): if ch.name == target_name: self.session_channels[conversation_id] = ch self.session_names[conversation_id] = project_name - logger.info(f"Reusing channel #{ch.name} for {conversation_id[:8]}") + logger.info(f"Reusing mapped channel #{ch.name} for {conversation_id[:8]}") return ch + # Check Discord API — maybe channel exists but isn't in our dict + if self.session_category: + for ch in self.session_category.text_channels: + if ch.name == target_name: + self.session_channels[conversation_id] = ch + self.session_names[conversation_id] = project_name + logger.info(f"Found existing Discord channel #{ch.name} for {conversation_id[:8]}") + return ch + # Create new channel (truly no match anywhere) try: channel = await self.guild.create_text_channel( @@ -274,6 +285,8 @@ class GravityBot(commands.Bot): async def _process_events(self): """Main event loop — ALL events go through here sequentially.""" await self.wait_until_ready() + await self._ready_event.wait() # Wait until on_ready + reconnect completes + logger.info("Event processor started (ready gate passed)") while not self.is_closed(): try: diff --git a/watcher.py b/watcher.py index 33bce29..7ef2704 100644 --- a/watcher.py +++ b/watcher.py @@ -52,13 +52,30 @@ class BrainEventHandler(FileSystemEventHandler): self._initialize_known_sessions() def _initialize_known_sessions(self): - """Scan existing brain directories to establish baseline (no events emitted).""" + """Scan existing brain directories to establish baseline (no events emitted). + Also pre-loads content hashes for watched files to prevent spurious events. + """ brain_path = Config.BRAIN_PATH + hash_count = 0 if brain_path.exists(): for entry in brain_path.iterdir(): if entry.is_dir() and self._is_conversation_id(entry.name): self._known_sessions.add(entry.name) - logger.info(f"Found {len(self._known_sessions)} existing sessions at startup") + # Pre-load content hashes for watched files + for watched in Config.WATCHED_FILES: + fpath = entry / watched + if fpath.exists(): + try: + content = fpath.read_text(encoding="utf-8") + h = hashlib.md5(content.encode()).hexdigest() + self._content_hashes[str(fpath)] = h + hash_count += 1 + except (OSError, UnicodeDecodeError): + pass + logger.info( + f"Found {len(self._known_sessions)} existing sessions, " + f"pre-loaded {hash_count} content hashes" + ) def _is_conversation_id(self, name: str) -> bool: parts = name.split("-")