linkgravity 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/cli.js +278 -0
- package/bin/setup.js +260 -0
- package/hooks/hook.py +60 -0
- package/hooks/stop_hook.py +64 -0
- package/npm-scripts/postinstall.js +62 -0
- package/npm-scripts/prepare.js +45 -0
- package/npm-scripts/register-hook.js +182 -0
- package/npm-scripts/run-dev.js +9 -0
- package/npm-scripts/venv-paths.js +45 -0
- package/package.json +59 -0
- package/requirements.txt +13 -0
- package/src/api/server.py +48 -0
- package/src/api/ui_routes.py +340 -0
- package/src/api/voice_routes.py +94 -0
- package/src/approval/command_parser.py +62 -0
- package/src/approval/tool_formatter.py +68 -0
- package/src/cogs/general_cog.py +287 -0
- package/src/cogs/voice/__init__.py +0 -0
- package/src/cogs/voice/enrollment.py +436 -0
- package/src/cogs/voice/stt_session.py +121 -0
- package/src/cogs/voice_cog.py +573 -0
- package/src/config.py +123 -0
- package/src/core/agy_runner.py +380 -0
- package/src/core/atomic_io.py +31 -0
- package/src/core/logger.py +28 -0
- package/src/core/session_manager.py +126 -0
- package/src/handlers/message_router.py +16 -0
- package/src/handlers/thread_reply.py +165 -0
- package/src/main.py +313 -0
- package/src/messengers/base.py +105 -0
- package/src/messengers/discord_adapter.py +240 -0
- package/src/messengers/registry.py +19 -0
- package/src/services/audio_service.py +67 -0
- package/src/services/discord_helpers.py +95 -0
- package/src/services/discord_mcp.py +50 -0
- package/src/services/response.py +51 -0
- package/src/services/streaming.py +199 -0
- package/src/utils/utils.py +40 -0
- package/voice-service/index.js +1048 -0
- package/voice-service/package-lock.json +1880 -0
- package/voice-service/package.json +24 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import discord
|
|
2
|
+
|
|
3
|
+
from config import allowed
|
|
4
|
+
from handlers.thread_reply import handle_thread_reply
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def handle_message(bot, message: discord.Message):
|
|
8
|
+
if message.type not in (discord.MessageType.default, discord.MessageType.reply):
|
|
9
|
+
return
|
|
10
|
+
if message.author.bot:
|
|
11
|
+
return
|
|
12
|
+
if not allowed(message.author.id):
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
if isinstance(message.channel, discord.Thread):
|
|
16
|
+
await handle_thread_reply(bot, message)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import discord
|
|
6
|
+
|
|
7
|
+
from config import session_manager
|
|
8
|
+
from messengers.registry import get_adapter
|
|
9
|
+
from services.response import render_thought_process, send_agy_response
|
|
10
|
+
from services.streaming import stream_thinking_latest
|
|
11
|
+
from utils.utils import (
|
|
12
|
+
agy_new_conversation,
|
|
13
|
+
agy_send_message,
|
|
14
|
+
build_content_with_images,
|
|
15
|
+
cleanup_images,
|
|
16
|
+
generate_thread_title,
|
|
17
|
+
handle_image_attachments,
|
|
18
|
+
stt,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def handle_approval_reply(
|
|
23
|
+
message: discord.Message, thread: discord.Thread, session: dict, content: str, pa
|
|
24
|
+
) -> bool:
|
|
25
|
+
adapter = get_adapter()
|
|
26
|
+
if content.lower() in ("yes", "y", "allow", "승인"):
|
|
27
|
+
pa.set_result("allow")
|
|
28
|
+
await adapter.send_message(thread, f'✅ *Answer Received (Write in): "{content}"*')
|
|
29
|
+
return True
|
|
30
|
+
elif content.lower() in ("c", "cancel"):
|
|
31
|
+
pa.set_result("allow")
|
|
32
|
+
await adapter.send_message(thread, "✅ *Text Approval Received*")
|
|
33
|
+
return True
|
|
34
|
+
elif content.lower() in ("no", "n", "reject", "거절"):
|
|
35
|
+
pa.set_result("reject")
|
|
36
|
+
await adapter.send_message(thread, "❌ *Text Rejection Received*")
|
|
37
|
+
return True
|
|
38
|
+
elif content.lower() in ("clear", "reset"):
|
|
39
|
+
await adapter.send_message(thread, "🧹 Conversation context cleared.")
|
|
40
|
+
old_sess = session_manager.remove_session(str(thread.id))
|
|
41
|
+
if old_sess:
|
|
42
|
+
session_manager.set_session(str(thread.id), old_sess)
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def handle_pending_session(
|
|
48
|
+
bot, thread: discord.Thread, session: dict, agy_content: str, content: str, image_paths: list
|
|
49
|
+
):
|
|
50
|
+
adapter = get_adapter()
|
|
51
|
+
try:
|
|
52
|
+
async with adapter.typing(thread):
|
|
53
|
+
ctx = {"status_msg": None}
|
|
54
|
+
start_time = time.time()
|
|
55
|
+
queue = asyncio.Queue()
|
|
56
|
+
session_manager.register_queue(str(thread.id), queue)
|
|
57
|
+
stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
|
|
58
|
+
|
|
59
|
+
cwd = session.get("cwd")
|
|
60
|
+
model = session.get("model")
|
|
61
|
+
result_text, new_conv_id = await agy_new_conversation(
|
|
62
|
+
agy_content, model=model, stream_queue=queue, thread_id=str(thread.id), cwd=cwd
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await queue.put(("__END__", True))
|
|
66
|
+
await stream_task
|
|
67
|
+
|
|
68
|
+
response_text = result_text
|
|
69
|
+
new_title = await generate_thread_title(content, response_text)
|
|
70
|
+
await adapter.rename_conversation(thread, new_title)
|
|
71
|
+
|
|
72
|
+
response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
|
|
73
|
+
|
|
74
|
+
session["conversation_id"] = new_conv_id
|
|
75
|
+
session["created_at"] = datetime.now().isoformat()
|
|
76
|
+
session["status"] = "active"
|
|
77
|
+
session_manager.save_sessions()
|
|
78
|
+
|
|
79
|
+
await send_agy_response(thread, response_text, session, ctx, start_time, new_conv_id)
|
|
80
|
+
finally:
|
|
81
|
+
cleanup_images(image_paths)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def handle_existing_session(
|
|
85
|
+
bot, thread: discord.Thread, session: dict, conv_id: str, agy_content: str, image_paths: list
|
|
86
|
+
):
|
|
87
|
+
adapter = get_adapter()
|
|
88
|
+
try:
|
|
89
|
+
async with adapter.typing(thread):
|
|
90
|
+
ctx = {"status_msg": None}
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
queue = asyncio.Queue()
|
|
93
|
+
session_manager.register_queue(str(thread.id), queue)
|
|
94
|
+
stream_task = asyncio.create_task(stream_thinking_latest(bot, thread, context_dict=ctx, queue=queue))
|
|
95
|
+
result_text = await agy_send_message(
|
|
96
|
+
conv_id,
|
|
97
|
+
agy_content,
|
|
98
|
+
model=session.get("model"),
|
|
99
|
+
stream_queue=queue,
|
|
100
|
+
thread_id=str(thread.id),
|
|
101
|
+
cwd=session.get("cwd"),
|
|
102
|
+
)
|
|
103
|
+
await queue.put(("__END__", True))
|
|
104
|
+
await stream_task
|
|
105
|
+
|
|
106
|
+
response_text = result_text
|
|
107
|
+
response_text = await render_thought_process(conv_id, ctx, response_text, thread)
|
|
108
|
+
await send_agy_response(thread, response_text, session, ctx, start_time, conv_id)
|
|
109
|
+
finally:
|
|
110
|
+
cleanup_images(image_paths)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def handle_thread_reply(bot, message: discord.Message):
|
|
114
|
+
thread = message.channel
|
|
115
|
+
session = session_manager.get_session(str(thread.id))
|
|
116
|
+
if not session:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
adapter = get_adapter()
|
|
120
|
+
content = message.content.strip()
|
|
121
|
+
if content.startswith("/new"):
|
|
122
|
+
await adapter.send_message(thread,
|
|
123
|
+
"To start a new session, please use the `/new` slash command (not typed as text) - it works from inside a thread too and will open the new one in the right place."
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
for att in message.attachments:
|
|
128
|
+
ct = att.content_type or ""
|
|
129
|
+
if "audio" in ct or att.filename.endswith((".ogg", ".mp3", ".m4a", ".wav")):
|
|
130
|
+
await message.add_reaction("🎤")
|
|
131
|
+
audio_bytes = await att.read()
|
|
132
|
+
text = await stt(audio_bytes)
|
|
133
|
+
if text:
|
|
134
|
+
content = text
|
|
135
|
+
await adapter.send_message(thread, f'🎤 *Speech Recognized: "{text}"*')
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
image_paths = await handle_image_attachments(message)
|
|
139
|
+
if image_paths:
|
|
140
|
+
await message.add_reaction("📎")
|
|
141
|
+
await adapter.send_message(thread, f"📎 *{len(image_paths)} file(s) attached*")
|
|
142
|
+
|
|
143
|
+
if not content and not image_paths:
|
|
144
|
+
return
|
|
145
|
+
if not content:
|
|
146
|
+
content = "Analyze this attachment"
|
|
147
|
+
|
|
148
|
+
agy_content = build_content_with_images(content, image_paths)
|
|
149
|
+
conv_id = session.get("conversation_id")
|
|
150
|
+
pa = session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
|
|
151
|
+
|
|
152
|
+
if conv_id and pa and not pa.done():
|
|
153
|
+
handled = await handle_approval_reply(message, thread, session, content, pa)
|
|
154
|
+
if handled:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if not conv_id:
|
|
158
|
+
if session.get("status") == "pending":
|
|
159
|
+
await handle_pending_session(bot, thread, session, agy_content, content, image_paths)
|
|
160
|
+
return
|
|
161
|
+
else:
|
|
162
|
+
await adapter.send_message(thread, "⚠️ Session ID not found. Start a new session with `/new`.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
await handle_existing_session(bot, thread, session, conv_id, agy_content, image_paths)
|
package/src/main.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import atexit
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from functools import partial
|
|
7
|
+
|
|
8
|
+
import discord
|
|
9
|
+
from discord.ext import commands
|
|
10
|
+
|
|
11
|
+
from api import server
|
|
12
|
+
from config import (
|
|
13
|
+
DISCORD_TOKEN,
|
|
14
|
+
SESSION_SCOPES,
|
|
15
|
+
logger,
|
|
16
|
+
session_manager,
|
|
17
|
+
)
|
|
18
|
+
from handlers.message_router import handle_message
|
|
19
|
+
from services.response import send_agy_response
|
|
20
|
+
from services.streaming import stream_thinking_latest
|
|
21
|
+
from utils.utils import (
|
|
22
|
+
agy_new_conversation,
|
|
23
|
+
agy_send_message,
|
|
24
|
+
stt,
|
|
25
|
+
tts,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
voice_process = None
|
|
29
|
+
_voice_shutting_down = False
|
|
30
|
+
_voice_restart_timestamps = [] # epoch seconds of recent auto-restarts, for the backoff/giveup check below
|
|
31
|
+
|
|
32
|
+
VOICE_SERVICE_HEALTH_URL = "http://localhost:18081/health"
|
|
33
|
+
VOICE_SERVICE_READY_TIMEOUT_SEC = 20
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _wait_for_voice_service_ready():
|
|
37
|
+
import aiohttp
|
|
38
|
+
|
|
39
|
+
deadline = asyncio.get_event_loop().time() + VOICE_SERVICE_READY_TIMEOUT_SEC
|
|
40
|
+
async with aiohttp.ClientSession() as session:
|
|
41
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
42
|
+
try:
|
|
43
|
+
async with session.get(VOICE_SERVICE_HEALTH_URL, timeout=aiohttp.ClientTimeout(total=2)) as resp:
|
|
44
|
+
if resp.status == 200:
|
|
45
|
+
data = await resp.json()
|
|
46
|
+
if data.get("ready"):
|
|
47
|
+
logger.info("🎤 Voice service is online and ready.")
|
|
48
|
+
return
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
await asyncio.sleep(0.5)
|
|
52
|
+
logger.warning(
|
|
53
|
+
f"Voice service did not report ready within {VOICE_SERVICE_READY_TIMEOUT_SEC}s - "
|
|
54
|
+
"/join may fail until it finishes starting."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
STATUS_TEXT_BY_KEYWORDS = [
|
|
59
|
+
(("run_command",), "🖥️ Running command..."),
|
|
60
|
+
(("file", "list"), "🔍 Analyzing files..."),
|
|
61
|
+
(("search", "web"), "🌐 Searching web..."),
|
|
62
|
+
(("replace", "write"), "✍️ Writing code..."),
|
|
63
|
+
(("ask_question",), "❓ Waiting for input..."),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _status_text_for_tool(tool_name: str) -> str:
|
|
68
|
+
for keywords, text in STATUS_TEXT_BY_KEYWORDS:
|
|
69
|
+
if any(keyword in tool_name for keyword in keywords):
|
|
70
|
+
return text
|
|
71
|
+
return f"⚙️ Running {tool_name}..."
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
intents = discord.Intents.default()
|
|
75
|
+
intents.message_content = True
|
|
76
|
+
intents.voice_states = True
|
|
77
|
+
bot = commands.Bot(command_prefix="!", intents=intents)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def status_updater_task():
|
|
81
|
+
await bot.wait_until_ready()
|
|
82
|
+
logger.debug("status_updater_task started")
|
|
83
|
+
try:
|
|
84
|
+
last_status = ""
|
|
85
|
+
while True:
|
|
86
|
+
await asyncio.sleep(2)
|
|
87
|
+
|
|
88
|
+
full_status = ""
|
|
89
|
+
if not session_manager.has_active_queues():
|
|
90
|
+
full_status = "🟢 Idle"
|
|
91
|
+
else:
|
|
92
|
+
first_t_id = session_manager.get_active_queue_keys()[0]
|
|
93
|
+
sess = session_manager.get_session(first_t_id) or {}
|
|
94
|
+
tool_name = sess.get("current_tool")
|
|
95
|
+
full_status = _status_text_for_tool(tool_name) if tool_name else "🧠 Thinking..."
|
|
96
|
+
|
|
97
|
+
if full_status != last_status:
|
|
98
|
+
logger.debug(f"Status updating to: {full_status}")
|
|
99
|
+
try:
|
|
100
|
+
await bot.change_presence(
|
|
101
|
+
status=discord.Status.online,
|
|
102
|
+
activity=discord.Activity(type=discord.ActivityType.playing, name=full_status),
|
|
103
|
+
)
|
|
104
|
+
last_status = full_status
|
|
105
|
+
except discord.HTTPException as e:
|
|
106
|
+
logger.warning(f"Presence update rate-limited or failed: {e}")
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning(f"Presence update error: {e}")
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
logger.debug("Status updater task was cancelled.")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.exception(f"FATAL ERROR IN STATUS UPDATER: {e}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
|
|
116
|
+
return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _supervise_voice_process(voice_dir: str):
|
|
120
|
+
"""voice_process (voice-service/index.js) can die on its own - most
|
|
121
|
+
notably from the known upstream @discordjs/voice DAVE decrypt bug
|
|
122
|
+
(discordjs/discord.js#11419), which can throw synchronously out of
|
|
123
|
+
an event handler with nothing upstream to catch it. Without this,
|
|
124
|
+
that single crash would leave every voice feature (wake word, STT,
|
|
125
|
+
TTS) dead until someone manually restarts the whole bot.
|
|
126
|
+
|
|
127
|
+
Polls every 1s so a crash gets noticed and a restart kicked off
|
|
128
|
+
almost immediately - the "5 crashes in 5 minutes" check below is
|
|
129
|
+
NOT a claim that 5 minutes of intermittent breakage is acceptable;
|
|
130
|
+
it only exists to stop a genuine crash-loop (missing node_modules,
|
|
131
|
+
a port conflict, etc.) from burning CPU forever. Anyone mid-
|
|
132
|
+
recording when Node dies gets told immediately via
|
|
133
|
+
VoiceCog.handle_voice_service_down(), rather than finding out
|
|
134
|
+
whenever a stale request to Node eventually times out.
|
|
135
|
+
"""
|
|
136
|
+
global voice_process
|
|
137
|
+
while True:
|
|
138
|
+
await asyncio.sleep(1)
|
|
139
|
+
if _voice_shutting_down:
|
|
140
|
+
return
|
|
141
|
+
if voice_process is None or voice_process.poll() is None:
|
|
142
|
+
continue # still running (or never started) - nothing to do
|
|
143
|
+
|
|
144
|
+
exit_code = voice_process.poll()
|
|
145
|
+
logger.warning(f"🎤 Voice service exited unexpectedly (code {exit_code}). Attempting to restart it...")
|
|
146
|
+
|
|
147
|
+
# Notify anyone mid-recording before attempting the restart, not after.
|
|
148
|
+
voice_cog = bot.get_cog("VoiceCog")
|
|
149
|
+
if voice_cog:
|
|
150
|
+
try:
|
|
151
|
+
await voice_cog.handle_voice_service_down()
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Error notifying users of voice service outage: {e}")
|
|
154
|
+
|
|
155
|
+
now = asyncio.get_event_loop().time()
|
|
156
|
+
_voice_restart_timestamps.append(now)
|
|
157
|
+
while _voice_restart_timestamps and now - _voice_restart_timestamps[0] > 300:
|
|
158
|
+
_voice_restart_timestamps.pop(0)
|
|
159
|
+
|
|
160
|
+
if len(_voice_restart_timestamps) > 5:
|
|
161
|
+
logger.error(
|
|
162
|
+
"🎤 Voice service has crashed 5+ times in the last 5 minutes - giving up on auto-restart "
|
|
163
|
+
"to avoid a crash loop. Check `lgy logs` for the underlying error, fix it, then run "
|
|
164
|
+
"`lgy restart`."
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
voice_process = _spawn_voice_process(voice_dir)
|
|
170
|
+
asyncio.create_task(_wait_for_voice_service_ready())
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.error(f"Failed to restart voice service: {e}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@bot.event
|
|
176
|
+
async def on_ready():
|
|
177
|
+
logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@bot.event
|
|
181
|
+
async def setup_hook():
|
|
182
|
+
await bot.tree.sync()
|
|
183
|
+
logger.info("Slash commands synced.")
|
|
184
|
+
|
|
185
|
+
asyncio.create_task(server.setup_webhook_server(bot))
|
|
186
|
+
|
|
187
|
+
global voice_process
|
|
188
|
+
try:
|
|
189
|
+
voice_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "voice-service")
|
|
190
|
+
logger.debug(f"Spawning Node.js process at: {voice_dir}")
|
|
191
|
+
if os.path.exists(os.path.join(voice_dir, "index.js")):
|
|
192
|
+
if not os.path.isdir(os.path.join(voice_dir, "node_modules")):
|
|
193
|
+
logger.error(
|
|
194
|
+
"voice-service/node_modules is missing - its dependencies were never installed "
|
|
195
|
+
"(or got wiped, e.g. by replacing project files without re-running `npm install`). "
|
|
196
|
+
"Voice features will not work until you run `npm install` in the project root "
|
|
197
|
+
"and restart the bot."
|
|
198
|
+
)
|
|
199
|
+
voice_process = _spawn_voice_process(voice_dir)
|
|
200
|
+
logger.info("🎤 Voice service starting, waiting for it to come online...")
|
|
201
|
+
asyncio.create_task(_wait_for_voice_service_ready())
|
|
202
|
+
asyncio.create_task(_supervise_voice_process(voice_dir))
|
|
203
|
+
|
|
204
|
+
def cleanup_voice():
|
|
205
|
+
global _voice_shutting_down
|
|
206
|
+
_voice_shutting_down = True
|
|
207
|
+
if voice_process and voice_process.poll() is None:
|
|
208
|
+
logger.debug("Zombie prevention: Terminating child Node.js process as Python exits...")
|
|
209
|
+
voice_process.terminate()
|
|
210
|
+
try:
|
|
211
|
+
voice_process.wait(timeout=3)
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
voice_process.kill()
|
|
214
|
+
|
|
215
|
+
atexit.register(cleanup_voice)
|
|
216
|
+
else:
|
|
217
|
+
logger.warning("Voice service (index.js) not found. Skipping auto-start.")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.error(f"Failed to auto-start voice service: {e}")
|
|
220
|
+
|
|
221
|
+
def _handle_task_exception(loop, context):
|
|
222
|
+
exc = context.get("exception")
|
|
223
|
+
if exc:
|
|
224
|
+
logger.opt(exception=exc).error(f"Unhandled asyncio task exception: {context.get('message', '')}")
|
|
225
|
+
else:
|
|
226
|
+
logger.error(f"Unhandled asyncio context: {context}")
|
|
227
|
+
|
|
228
|
+
asyncio.get_event_loop().set_exception_handler(_handle_task_exception)
|
|
229
|
+
|
|
230
|
+
logger.debug("Launching status_updater_task...")
|
|
231
|
+
asyncio.create_task(status_updater_task())
|
|
232
|
+
logger.debug("status_updater_task dispatched")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@bot.event
|
|
236
|
+
async def on_error(event_method: str, *args, **kwargs):
|
|
237
|
+
logger.exception(f"Unhandled exception in Discord event: {event_method}")
|
|
238
|
+
exc_type, exc_value, _ = sys.exc_info()
|
|
239
|
+
if exc_value is None:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
error_msg = "⚠️ **An internal bot error has occurred.** Please contact the developer or check the server logs."
|
|
243
|
+
|
|
244
|
+
if event_method == "on_message":
|
|
245
|
+
if args and isinstance(args[0], discord.Message):
|
|
246
|
+
message = args[0]
|
|
247
|
+
try:
|
|
248
|
+
await message.reply(error_msg)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@bot.event
|
|
254
|
+
async def on_application_command_error(interaction, error):
|
|
255
|
+
logger.exception(f"Slash command error: {error}")
|
|
256
|
+
error_msg = "⚠️ **An error occurred while processing the command.** Please try again later or check the logs."
|
|
257
|
+
try:
|
|
258
|
+
if interaction.response.is_done():
|
|
259
|
+
await interaction.followup.send(error_msg, ephemeral=True)
|
|
260
|
+
else:
|
|
261
|
+
await interaction.response.send_message(error_msg, ephemeral=True)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@bot.event
|
|
267
|
+
async def on_message(message: discord.Message):
|
|
268
|
+
await handle_message(bot, message)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def main():
|
|
272
|
+
if not DISCORD_TOKEN or not SESSION_SCOPES:
|
|
273
|
+
logger.critical("Missing DISCORD_TOKEN, or no server/channel configured (session_scopes) - run `lgy setup`")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
discord.utils.setup_logging()
|
|
277
|
+
|
|
278
|
+
from messengers.discord_adapter import DiscordAdapter
|
|
279
|
+
from messengers.registry import set_adapter
|
|
280
|
+
|
|
281
|
+
set_adapter(DiscordAdapter(bot))
|
|
282
|
+
|
|
283
|
+
async with bot:
|
|
284
|
+
from cogs.voice_cog import VoiceCog
|
|
285
|
+
from config import bot_settings, save_bot_settings
|
|
286
|
+
|
|
287
|
+
await bot.add_cog(
|
|
288
|
+
VoiceCog(
|
|
289
|
+
bot=bot,
|
|
290
|
+
stt=stt,
|
|
291
|
+
tts=tts,
|
|
292
|
+
send_agy_response=send_agy_response,
|
|
293
|
+
agy_send=agy_send_message,
|
|
294
|
+
stream_thinking_latest=partial(stream_thinking_latest, bot),
|
|
295
|
+
agy_start_session=agy_new_conversation,
|
|
296
|
+
session_manager=session_manager,
|
|
297
|
+
bot_settings=bot_settings,
|
|
298
|
+
save_bot_settings=save_bot_settings,
|
|
299
|
+
logger=logger,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
from cogs.general_cog import GeneralCog
|
|
304
|
+
|
|
305
|
+
await bot.add_cog(GeneralCog(bot=bot))
|
|
306
|
+
await bot.start(DISCORD_TOKEN)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
if __name__ == "__main__":
|
|
310
|
+
try:
|
|
311
|
+
asyncio.run(main())
|
|
312
|
+
except KeyboardInterrupt:
|
|
313
|
+
pass
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Core messenger interface. Every backend (Discord now, Slack/Telegram
|
|
2
|
+
later) implements MessengerAdapter; business logic never touches
|
|
3
|
+
platform SDK types directly.
|
|
4
|
+
|
|
5
|
+
Futures for approval/question prompts are owned by business logic, not
|
|
6
|
+
the adapter - the same future can also be resolved by a typed reply,
|
|
7
|
+
voice, or /stop, not just a button. Voice is deliberately not part of
|
|
8
|
+
this interface; see VoiceCapable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ScopeOption:
|
|
19
|
+
"""kind: "commands" or "tools" - which persistent allowlist scope belongs to."""
|
|
20
|
+
|
|
21
|
+
kind: str
|
|
22
|
+
scope: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolApprovalOutcome:
|
|
27
|
+
decision: str # "allow" | "reject"
|
|
28
|
+
scope: ScopeOption | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PromptHandle(ABC):
|
|
32
|
+
outcome: ToolApprovalOutcome | None = None
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def send(self, conversation_ref: Any) -> Any:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def finalize(self) -> None:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MessengerAdapter(ABC):
|
|
44
|
+
platform_name: str = "unknown"
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def send_message(self, conversation_ref: Any, text: str) -> Any:
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def edit_message(self, message_ref: Any, text: str) -> bool:
|
|
52
|
+
"""Returns False if the message is gone - caller should send a new one instead."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def send_files(self, conversation_ref: Any, file_paths: list[str]) -> None:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def resolve_conversation(self, conversation_id: str) -> Any:
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def start_conversation(self, origin_ref: Any, title: str) -> Any:
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def rename_conversation(self, conversation_ref: Any, title: str) -> None:
|
|
69
|
+
raise NotImplementedError
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def typing(self, conversation_ref: Any):
|
|
73
|
+
yield
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def create_tool_approval_prompt(
|
|
77
|
+
self,
|
|
78
|
+
decision_future,
|
|
79
|
+
title: str,
|
|
80
|
+
body: str,
|
|
81
|
+
scope_options: list[ScopeOption],
|
|
82
|
+
) -> PromptHandle:
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
def create_question_prompt(
|
|
87
|
+
self,
|
|
88
|
+
answer_future,
|
|
89
|
+
question: str,
|
|
90
|
+
options: list[str],
|
|
91
|
+
multi_select: bool = False,
|
|
92
|
+
allow_write_in: bool = True,
|
|
93
|
+
) -> PromptHandle:
|
|
94
|
+
raise NotImplementedError
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def supports_voice(self) -> bool:
|
|
98
|
+
return isinstance(self, VoiceCapable)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@runtime_checkable
|
|
102
|
+
class VoiceCapable(Protocol):
|
|
103
|
+
async def join_voice(self, guild_ref: Any, channel_ref: Any) -> None: ...
|
|
104
|
+
async def leave_voice(self, guild_ref: Any) -> None: ...
|
|
105
|
+
async def play_tts(self, guild_ref: Any, audio_bytes: bytes) -> None: ...
|