Files
claudetools/projects/discord-bot/bot/main.py
Mike Swanson 2efd4a4fb3 discord-bot: fix "no response", serialize turns, attribution, mentions, post-at-bottom
client.py: send() falls back to ResultMessage.result when no TextBlock streams
(the "(no response)" bug) and reconnects+retries once on a closed SDK session.

message_handler.py: per-thread turn lock so messages arriving mid-turn or from a
second user queue in order (nothing dropped); per-session requester-attribution
env (discord_id -> users.json key), pinned to the thread opener; _USER_MAP caches
only on a successful load; final answer posts as a fresh message at the BOTTOM
(no edit-in-place); a <@id> tag goes out as a fresh send so it actually pings.

main.py: allowed_mentions permits user pings, blocks @everyone/@here/roles.

DISCORD_CLAUDE.md: no thread auto-delete; tiered close-out (Q&A -> one-line rolling
log, substantive -> /save); @mention guidance; opener-pinned attribution note.

whoami-block.sh / sync.sh: bot-context attribution (Executed by ClaudeTools Bot /
Requested by <person>; git author = mapped requester, committer = bot). Strict
no-op for interactive sessions.

users.json: discord_id for Mike/Howard; added Winter Williams (bot-only, full trust).

Reviewed by Code Review Agent + Grok + Gemini (Gemini's "malformed email" finding
verified as a false positive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:00:34 -07:00

165 lines
5.3 KiB
Python

"""ClaudeTools Discord Bot - Main Entry Point."""
import asyncio
import logging
import sys
import discord
from discord.ext import commands
from dotenv import load_dotenv
from bot.config import settings
from bot.claude.client import ClaudeAgentManager
from bot.handlers.message_handler import MessageHandler
load_dotenv()
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
if settings.log_file:
settings.log_file.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(settings.log_file)
file_handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
logging.getLogger().addHandler(file_handler)
logger = logging.getLogger(__name__)
intents = discord.Intents.default()
intents.message_content = True
intents.guilds = True
intents.members = True
# allowed_mentions: permit pinging specific users (so the bot can @tag a person
# from users.json by their discord_id) but never @everyone/@here or whole roles.
bot = commands.Bot(
command_prefix="!",
intents=intents,
allowed_mentions=discord.AllowedMentions(
everyone=False, users=True, roles=False, replied_user=True
),
)
agent_manager = ClaudeAgentManager()
message_handler: MessageHandler | None = None
@bot.event
async def on_ready():
global message_handler
logger.info("[OK] Bot connected as %s (ID: %d)", bot.user.name, bot.user.id)
logger.info("[INFO] Connected to %d guild(s)", len(bot.guilds))
try:
settings.validate_paths()
logger.info("[OK] ClaudeTools workspace: %s", settings.claudetools_root)
except FileNotFoundError as e:
logger.error("[ERROR] Path validation failed: %s", e)
message_handler = MessageHandler(bot, agent_manager)
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching,
name="for @mentions | ClaudeTools Agent",
)
)
logger.info("[OK] Bot is ready and listening for mentions")
for guild in bot.guilds:
logger.info("[DEBUG] Guild: %s (id=%d) channels=%d", guild.name, guild.id, len(guild.text_channels))
for ch in guild.text_channels[:10]:
perms = ch.permissions_for(guild.me)
logger.info("[DEBUG] #%s view=%s read=%s send=%s", ch.name, perms.view_channel, perms.read_message_history, perms.send_messages)
@bot.event
async def on_message(message: discord.Message):
logger.info("[DEBUG] on_message fired: author=%s bot=%s channel=%s content_len=%d mentions=%d role_mentions=%d",
message.author.name, message.author.bot, getattr(message.channel, 'name', 'DM'),
len(message.content), len(message.mentions), len(message.role_mentions))
if message.author.bot:
return
# discord.py's message.mentions can fail to resolve in some channel permission
# configurations — fall back to checking the raw message content for the bot's
# snowflake ID so @mentions always trigger regardless of cache state.
bot_mention_in_content = (
bot.user is not None
and (
f"<@{bot.user.id}>" in message.content
or f"<@!{bot.user.id}>" in message.content
)
)
# Discord creates an integration role for the bot with the same display name.
# Some users (e.g. from autocomplete) ping that role instead of the bot user —
# both look identical in chat. Detect it by checking if any of the bot's guild
# roles appear in the message's role_mentions list.
bot_role_mentioned = (
bot.user is not None
and message.guild is not None
and bool(message.role_mentions)
and (
bot_member := message.guild.get_member(bot.user.id)
) is not None
and any(role in message.role_mentions for role in bot_member.roles)
)
is_mention = bot.user in message.mentions or bot_mention_in_content or bot_role_mentioned
is_in_bot_thread = (
isinstance(message.channel, discord.Thread)
and message.channel.owner_id == bot.user.id
)
if is_mention or is_in_bot_thread:
trigger = "mention" if is_mention else "thread-followup"
logger.info(
"[INFO] %s by %s in #%s: %s",
trigger,
message.author.name,
message.channel.name,
message.content[:100],
)
await message_handler.handle_mention(message)
return
await bot.process_commands(message)
@bot.event
async def on_error(event: str, *args, **kwargs):
logger.error("[ERROR] Error in %s", event, exc_info=sys.exc_info())
async def main():
try:
logger.info("[INFO] Starting ClaudeTools Discord Bot")
logger.info("[INFO] Claude model: %s", settings.claude_model)
logger.info("[INFO] Workspace: %s", settings.claudetools_root)
async with bot:
await bot.start(settings.discord_token)
except KeyboardInterrupt:
logger.info("[INFO] Keyboard interrupt, shutting down")
except Exception as e:
logger.error("[ERROR] Fatal error: %s", e, exc_info=True)
raise
finally:
await agent_manager.shutdown()
logger.info("[OK] Bot shut down complete")
if __name__ == "__main__":
asyncio.run(main())