chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
# Discord Bot Frontend for GenericAgent
|
||||
# ⚠️ 需要在 Discord Developer Portal 开启 "Message Content Intent"
|
||||
# Bot → Privileged Gateway Intents → MESSAGE CONTENT INTENT → 打开
|
||||
# pip install discord.py
|
||||
|
||||
import asyncio, json, os, queue as Q, re, sys, threading, time
|
||||
from collections import OrderedDict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import (
|
||||
AgentChatMixin, build_done_text, ensure_single_instance, extract_files,
|
||||
public_access, redirect_log, require_runtime, split_text, strip_files, clean_reply,
|
||||
HELP_TEXT, FILE_HINT, format_restore,
|
||||
_handle_continue_frontend, _reset_conversation,
|
||||
)
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
import discord
|
||||
except Exception:
|
||||
print("Please install discord.py to use Discord: pip install discord.py")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
BOT_TOKEN = str(mykeys.get("discord_bot_token", "") or "").strip()
|
||||
ALLOWED = {str(x).strip() for x in mykeys.get("discord_allowed_users", []) if str(x).strip()}
|
||||
USER_TASKS = {}
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
MEDIA_DIR = os.path.join(TEMP_DIR, "discord_media")
|
||||
ACTIVE_FILE = os.path.join(TEMP_DIR, "discord_active_channels.json")
|
||||
ACTIVE_TTL_SECONDS = 30 * 24 * 3600
|
||||
EXIT_CHANNEL_TEXTS = {"退出该频道", "退出此频道", "退出频道"}
|
||||
EXIT_THREAD_TEXTS = {"退出该子区", "退出此子区", "退出子区"}
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _extract_discord_progress(text):
|
||||
"""Return the newest concise <summary> from a streaming transcript."""
|
||||
matches = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
|
||||
if not matches:
|
||||
return ""
|
||||
summary = re.sub(r"\s+", " ", matches[-1]).strip()
|
||||
return summary[:120]
|
||||
|
||||
|
||||
def _strip_discord_transcript(text):
|
||||
"""Hide LLM/tool transcript noise while preserving the final natural reply."""
|
||||
text = text or ""
|
||||
text = re.sub(r"^\s*\*?\*?LLM Running \(Turn \d+\) \.\.\.\*?\*?\s*$", "", text, flags=re.M)
|
||||
text = re.sub(r"^\s*🛠️\s+.*?(?=^\s*(?:\*?\*?LLM Running|<summary>|$))", "", text, flags=re.M | re.DOTALL)
|
||||
text = re.sub(r"^\s*(?:✅|❌|ERR|STDOUT|PAT\b|RC\b).*?$", "", text, flags=re.M)
|
||||
text = re.sub(r"<tool_use>.*?</tool_use>", "", text, flags=re.DOTALL)
|
||||
text = clean_reply(text)
|
||||
return strip_files(text).strip()
|
||||
|
||||
|
||||
def _display_done_text(text):
|
||||
body = _strip_discord_transcript(text)
|
||||
if body and body != "...":
|
||||
return body
|
||||
summaries = re.findall(r"<summary>\s*(.*?)\s*</summary>", text or "", flags=re.DOTALL)
|
||||
if summaries:
|
||||
return re.sub(r"\s+", " ", summaries[-1]).strip() or "..."
|
||||
return "..."
|
||||
|
||||
|
||||
class DiscordApp(AgentChatMixin):
|
||||
label, source, split_limit = "Discord", "discord", 1900
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.guilds = True
|
||||
intents.dm_messages = True
|
||||
proxy = str(mykeys.get("proxy", "") or "").strip() or None
|
||||
self.client = discord.Client(intents=intents, proxy=proxy)
|
||||
self.background_tasks = set()
|
||||
self._channel_cache = OrderedDict() # chat_id -> channel/user object (LRU, max 500)
|
||||
self._active_channels = self._load_active_channels() # guild chat_id -> {last_seen: float}
|
||||
self._active_lock = threading.Lock()
|
||||
self._agents = OrderedDict() # chat_id -> GeneraticAgent, each chat has isolated history
|
||||
self._agent_lock = threading.Lock()
|
||||
|
||||
@self.client.event
|
||||
async def on_ready():
|
||||
print(f"[Discord] bot ready: {self.client.user} ({self.client.user.id})")
|
||||
|
||||
@self.client.event
|
||||
async def on_message(message):
|
||||
await self._handle_message(message)
|
||||
|
||||
def _chat_id(self, message):
|
||||
"""Return a string chat_id: 'dm:<user_id>' or 'ch:<channel_id>'."""
|
||||
if isinstance(message.channel, discord.DMChannel):
|
||||
return f"dm:{message.author.id}"
|
||||
return f"ch:{message.channel.id}"
|
||||
|
||||
def _load_active_channels(self):
|
||||
try:
|
||||
with open(ACTIVE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
now = time.time()
|
||||
active = {}
|
||||
for chat_id, item in data.items():
|
||||
if not str(chat_id).startswith("ch:") or not isinstance(item, dict):
|
||||
continue
|
||||
last_seen = float(item.get("last_seen") or 0)
|
||||
if now - last_seen <= ACTIVE_TTL_SECONDS:
|
||||
active[str(chat_id)] = {"last_seen": last_seen}
|
||||
return active
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to load active channels: {e}")
|
||||
return {}
|
||||
|
||||
def _save_active_channels(self):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(ACTIVE_FILE), exist_ok=True)
|
||||
tmp = ACTIVE_FILE + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(self._active_channels, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
os.replace(tmp, ACTIVE_FILE)
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to save active channels: {e}")
|
||||
|
||||
def _is_active_channel(self, chat_id, now=None):
|
||||
now = now or time.time()
|
||||
with self._active_lock:
|
||||
item = self._active_channels.get(chat_id)
|
||||
if not item:
|
||||
return False
|
||||
if now - float(item.get("last_seen") or 0) > ACTIVE_TTL_SECONDS:
|
||||
self._active_channels.pop(chat_id, None)
|
||||
self._save_active_channels()
|
||||
print(f"[Discord] channel expired: {chat_id}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _touch_active_channel(self, chat_id, now=None):
|
||||
if not chat_id.startswith("ch:"):
|
||||
return
|
||||
with self._active_lock:
|
||||
self._active_channels[chat_id] = {"last_seen": float(now or time.time())}
|
||||
self._save_active_channels()
|
||||
|
||||
def _deactivate_channel(self, chat_id):
|
||||
with self._active_lock:
|
||||
changed = self._active_channels.pop(chat_id, None) is not None
|
||||
self._save_active_channels()
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
try:
|
||||
self._get_agent(chat_id).abort()
|
||||
except Exception as e:
|
||||
print(f"[Discord] deactivate abort failed for {chat_id}: {e}")
|
||||
return changed
|
||||
|
||||
def _get_agent(self, chat_id):
|
||||
with self._agent_lock:
|
||||
ga = self._agents.get(chat_id)
|
||||
if ga is None:
|
||||
ga = GeneraticAgent()
|
||||
ga.verbose = False
|
||||
self._agents[chat_id] = ga
|
||||
threading.Thread(target=ga.run, daemon=True, name=f"discord-agent-{chat_id}").start()
|
||||
if len(self._agents) > 200:
|
||||
old_chat_id, _old_agent = self._agents.popitem(last=False)
|
||||
print(f"[Discord] dropped agent cache entry: {old_chat_id}")
|
||||
else:
|
||||
self._agents.move_to_end(chat_id)
|
||||
return ga
|
||||
|
||||
async def _download_attachments(self, message):
|
||||
"""Download attachments/images to MEDIA_DIR, return list of local paths."""
|
||||
paths = []
|
||||
for att in message.attachments:
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', att.filename or f"file_{att.id}")
|
||||
local_path = os.path.join(MEDIA_DIR, f"{att.id}_{safe_name}")
|
||||
try:
|
||||
await att.save(local_path)
|
||||
paths.append(local_path)
|
||||
print(f"[Discord] saved attachment: {local_path}")
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to save attachment {att.filename}: {e}")
|
||||
return paths
|
||||
|
||||
async def send_text(self, chat_id, content, **ctx):
|
||||
"""Send text (and optionally files) to a chat_id."""
|
||||
channel = self._channel_cache.get(chat_id)
|
||||
if channel is None:
|
||||
try:
|
||||
if chat_id.startswith("dm:"):
|
||||
user = await self.client.fetch_user(int(chat_id[3:]))
|
||||
channel = await user.create_dm()
|
||||
else:
|
||||
channel = await self.client.fetch_channel(int(chat_id[3:]))
|
||||
self._channel_cache[chat_id] = channel
|
||||
if len(self._channel_cache) > 500:
|
||||
self._channel_cache.popitem(last=False)
|
||||
except Exception as e:
|
||||
print(f"[Discord] cannot resolve channel for {chat_id}: {e}")
|
||||
return
|
||||
for part in split_text(content, self.split_limit):
|
||||
try:
|
||||
await channel.send(part)
|
||||
except Exception as e:
|
||||
print(f"[Discord] send error: {e}")
|
||||
|
||||
async def send_done(self, chat_id, raw_text, **ctx):
|
||||
"""Send final reply: text parts + file attachments."""
|
||||
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
|
||||
body = _display_done_text(raw_text)
|
||||
|
||||
# Send text (send_text handles splitting internally)
|
||||
if body and body != "...":
|
||||
await self.send_text(chat_id, body, **ctx)
|
||||
|
||||
# Send files as Discord attachments
|
||||
if files:
|
||||
channel = self._channel_cache.get(chat_id)
|
||||
if channel:
|
||||
for fpath in files:
|
||||
try:
|
||||
await channel.send(file=discord.File(fpath))
|
||||
except Exception as e:
|
||||
print(f"[Discord] failed to send file {fpath}: {e}")
|
||||
await self.send_text(chat_id, f"⚠️ 文件发送失败: {os.path.basename(fpath)}", **ctx)
|
||||
|
||||
if not body and not files:
|
||||
await self.send_text(chat_id, "...", **ctx)
|
||||
|
||||
async def handle_command(self, chat_id, cmd, **ctx):
|
||||
"""Handle slash commands against the per-chat agent, keeping Discord chats isolated."""
|
||||
ga = self._get_agent(chat_id)
|
||||
parts = (cmd or "").split()
|
||||
op = (parts[0] if parts else "").lower()
|
||||
if op == "/help":
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
if op == "/stop":
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
ga.abort()
|
||||
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
|
||||
if op == "/status":
|
||||
llm = ga.get_llm_name() if ga.llmclient else "未配置"
|
||||
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if ga.is_running else '🟢 空闲'}\nLLM: [{ga.llm_no}] {llm}", **ctx)
|
||||
if op == "/llm":
|
||||
if not ga.llmclient:
|
||||
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
|
||||
if len(parts) > 1:
|
||||
try:
|
||||
ga.next_llm(int(parts[1]))
|
||||
return await self.send_text(chat_id, f"✅ 已切换到 [{ga.llm_no}] {ga.get_llm_name()}", **ctx)
|
||||
except Exception:
|
||||
return await self.send_text(chat_id, f"用法: /llm <0-{len(ga.list_llms()) - 1}>", **ctx)
|
||||
lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in ga.list_llms()]
|
||||
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
|
||||
if op == "/restore":
|
||||
try:
|
||||
restored_info, err = format_restore()
|
||||
if err:
|
||||
return await self.send_text(chat_id, err, **ctx)
|
||||
restored, fname, count = restored_info
|
||||
ga.abort()
|
||||
ga.history.extend(restored)
|
||||
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
|
||||
except Exception as e:
|
||||
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
|
||||
if op == "/continue":
|
||||
return await self.send_text(chat_id, _handle_continue_frontend(ga, cmd), **ctx)
|
||||
if op == "/new":
|
||||
return await self.send_text(chat_id, _reset_conversation(ga), **ctx)
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
|
||||
async def run_agent(self, chat_id, text, **ctx):
|
||||
"""Run the isolated per-chat Discord agent."""
|
||||
ga = self._get_agent(chat_id)
|
||||
state = {"running": True}
|
||||
self.user_tasks[chat_id] = state
|
||||
try:
|
||||
await self.send_text(chat_id, "思考中...", **ctx)
|
||||
dq = ga.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
|
||||
last_ping = time.time()
|
||||
last_step = ""
|
||||
step_no = 0
|
||||
while state["running"]:
|
||||
try:
|
||||
item = await asyncio.to_thread(dq.get, True, 3)
|
||||
except Q.Empty:
|
||||
if ga.is_running and time.time() - last_ping > self.ping_interval:
|
||||
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
|
||||
last_ping = time.time()
|
||||
continue
|
||||
if "next" in item:
|
||||
step = _extract_discord_progress(item.get("next", ""))
|
||||
if step and step != last_step:
|
||||
step_no += 1
|
||||
await self.send_text(chat_id, f"步骤{step_no}:{step}", **ctx)
|
||||
last_step = step
|
||||
last_ping = time.time()
|
||||
continue
|
||||
if "done" in item:
|
||||
await self.send_done(chat_id, item.get("done", ""), **ctx)
|
||||
break
|
||||
if not state["running"]:
|
||||
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[{self.label}] run_agent error: {e}")
|
||||
traceback.print_exc()
|
||||
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
|
||||
finally:
|
||||
self.user_tasks.pop(chat_id, None)
|
||||
|
||||
async def _handle_message(self, message):
|
||||
# Ignore self
|
||||
if message.author == self.client.user or message.author.bot:
|
||||
return
|
||||
|
||||
is_dm = isinstance(message.channel, discord.DMChannel)
|
||||
is_guild = message.guild is not None
|
||||
chat_id = self._chat_id(message)
|
||||
now = time.time()
|
||||
mentioned = bool(is_guild and self.client.user and self.client.user.mentioned_in(message))
|
||||
|
||||
self._channel_cache[chat_id] = message.channel
|
||||
if len(self._channel_cache) > 500:
|
||||
self._channel_cache.popitem(last=False)
|
||||
|
||||
user_id = str(message.author.id)
|
||||
user_name = str(message.author)
|
||||
|
||||
if not public_access(ALLOWED) and user_id not in ALLOWED:
|
||||
print(f"[Discord] unauthorized user: {user_name} ({user_id})")
|
||||
return
|
||||
|
||||
if is_guild:
|
||||
active = self._is_active_channel(chat_id, now)
|
||||
if not mentioned and not active:
|
||||
return
|
||||
if mentioned or active:
|
||||
self._touch_active_channel(chat_id, now)
|
||||
|
||||
# Strip bot mention from content
|
||||
content = message.content or ""
|
||||
if is_guild and self.client.user:
|
||||
content = re.sub(rf"<@!?{self.client.user.id}>", "", content).strip()
|
||||
else:
|
||||
content = content.strip()
|
||||
|
||||
normalized = re.sub(r"\s+", "", content)
|
||||
if is_guild and normalized in EXIT_CHANNEL_TEXTS | EXIT_THREAD_TEXTS:
|
||||
self._deactivate_channel(chat_id)
|
||||
label = "子区" if normalized in EXIT_THREAD_TEXTS else "频道"
|
||||
await self.send_text(chat_id, f"✅ 已退出该{label},之后除非重新 @ 我,否则不会主动响应。")
|
||||
print(f"[Discord] manually deactivated {chat_id} by {user_name} ({user_id})")
|
||||
return
|
||||
|
||||
# Download attachments
|
||||
attachment_paths = await self._download_attachments(message)
|
||||
|
||||
# Build message text with attachment paths
|
||||
if attachment_paths:
|
||||
paths_text = "\n".join(f"[附件: {p}]" for p in attachment_paths)
|
||||
content = f"{content}\n{paths_text}" if content else paths_text
|
||||
|
||||
if not content:
|
||||
return
|
||||
|
||||
print(f"[Discord] message from {user_name} ({user_id}, {'dm' if is_dm else 'guild'}): {content[:200]}")
|
||||
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content)
|
||||
|
||||
task = asyncio.create_task(self.run_agent(chat_id, content))
|
||||
self.background_tasks.add(task)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
|
||||
async def start(self):
|
||||
print("[Discord] bot starting...")
|
||||
delay, max_delay = 5, 300
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
await self.client.start(BOT_TOKEN)
|
||||
except Exception as e:
|
||||
print(f"[Discord] error: {e}")
|
||||
if time.monotonic() - started_at >= 60:
|
||||
delay = 5
|
||||
print(f"[Discord] reconnect in {delay}s...")
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19532, "Discord")
|
||||
require_runtime(agent, "Discord", discord_bot_token=BOT_TOKEN)
|
||||
redirect_log(__file__, "dcapp.log", "Discord", ALLOWED)
|
||||
asyncio.run(DiscordApp().start())
|
||||
Reference in New Issue
Block a user