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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. 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: ...