fix: channel duplication root fix - ready gate + conv_id-first + Discord API search + hash pre-init
This commit is contained in:
37
bot.py
37
bot.py
@@ -130,6 +130,7 @@ class GravityBot(commands.Bot):
|
|||||||
self.session_names: dict[str, str] = {}
|
self.session_names: dict[str, str] = {}
|
||||||
self._channel_create_lock = asyncio.Lock() # SINGLE global lock
|
self._channel_create_lock = asyncio.Lock() # SINGLE global lock
|
||||||
self._sent_approval_ids: set[str] = set() # Track sent approvals
|
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.bridge = BridgeProtocol()
|
||||||
self.session_category: discord.CategoryChannel | None = None
|
self.session_category: discord.CategoryChannel | None = None
|
||||||
self.guild: discord.Guild | 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)
|
# ONLY reconnect to existing Discord channels (NO new creation)
|
||||||
await self._reconnect_existing_channels()
|
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):
|
async def _reconnect_existing_channels(self):
|
||||||
"""Scan existing Discord channels and map them — MERGE same-name channels."""
|
"""Scan existing Discord channels and map them — MERGE same-name channels."""
|
||||||
if not self.session_category:
|
if not self.session_category:
|
||||||
@@ -219,32 +224,38 @@ class GravityBot(commands.Bot):
|
|||||||
async def _ensure_channel(
|
async def _ensure_channel(
|
||||||
self, conversation_id: str, project_name: str
|
self, conversation_id: str, project_name: str
|
||||||
) -> discord.TextChannel:
|
) -> discord.TextChannel:
|
||||||
"""Get or create a channel. SINGLE channel per project name, guaranteed."""
|
"""Get or create a channel. ONE channel per conv_id, guaranteed."""
|
||||||
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
|
|
||||||
target_name = channel_name.lower().replace(" ", "-")
|
|
||||||
|
|
||||||
# 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:
|
if conversation_id in self.session_channels:
|
||||||
ch = self.session_channels[conversation_id]
|
return self.session_channels[conversation_id]
|
||||||
# Verify the channel name matches (project name might have changed)
|
|
||||||
if ch.name == target_name:
|
|
||||||
return ch
|
|
||||||
|
|
||||||
async with self._channel_create_lock:
|
async with self._channel_create_lock:
|
||||||
# Double-check after lock
|
# Double-check after lock
|
||||||
if conversation_id in self.session_channels:
|
if conversation_id in self.session_channels:
|
||||||
ch = self.session_channels[conversation_id]
|
return self.session_channels[conversation_id]
|
||||||
if ch.name == target_name:
|
|
||||||
return ch
|
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}"
|
||||||
|
target_name = channel_name.lower().replace(" ", "-")
|
||||||
|
|
||||||
# Check ALL mapped channels for same name
|
# Check ALL mapped channels for same name
|
||||||
for cid, ch in self.session_channels.items():
|
for cid, ch in self.session_channels.items():
|
||||||
if ch.name == target_name:
|
if ch.name == target_name:
|
||||||
self.session_channels[conversation_id] = ch
|
self.session_channels[conversation_id] = ch
|
||||||
self.session_names[conversation_id] = project_name
|
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
|
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)
|
# Create new channel (truly no match anywhere)
|
||||||
try:
|
try:
|
||||||
channel = await self.guild.create_text_channel(
|
channel = await self.guild.create_text_channel(
|
||||||
@@ -274,6 +285,8 @@ class GravityBot(commands.Bot):
|
|||||||
async def _process_events(self):
|
async def _process_events(self):
|
||||||
"""Main event loop — ALL events go through here sequentially."""
|
"""Main event loop — ALL events go through here sequentially."""
|
||||||
await self.wait_until_ready()
|
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():
|
while not self.is_closed():
|
||||||
try:
|
try:
|
||||||
|
|||||||
21
watcher.py
21
watcher.py
@@ -52,13 +52,30 @@ class BrainEventHandler(FileSystemEventHandler):
|
|||||||
self._initialize_known_sessions()
|
self._initialize_known_sessions()
|
||||||
|
|
||||||
def _initialize_known_sessions(self):
|
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
|
brain_path = Config.BRAIN_PATH
|
||||||
|
hash_count = 0
|
||||||
if brain_path.exists():
|
if brain_path.exists():
|
||||||
for entry in brain_path.iterdir():
|
for entry in brain_path.iterdir():
|
||||||
if entry.is_dir() and self._is_conversation_id(entry.name):
|
if entry.is_dir() and self._is_conversation_id(entry.name):
|
||||||
self._known_sessions.add(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:
|
def _is_conversation_id(self, name: str) -> bool:
|
||||||
parts = name.split("-")
|
parts = name.split("-")
|
||||||
|
|||||||
Reference in New Issue
Block a user