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,240 @@
|
|
|
1
|
+
"""Discord implementation of MessengerAdapter. All discord.py UI code
|
|
2
|
+
(buttons, modals, embeds) for the main messaging path lives here."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import discord
|
|
9
|
+
|
|
10
|
+
from config import logger
|
|
11
|
+
from messengers.base import (
|
|
12
|
+
MessengerAdapter,
|
|
13
|
+
PromptHandle,
|
|
14
|
+
ScopeOption,
|
|
15
|
+
ToolApprovalOutcome,
|
|
16
|
+
VoiceCapable,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _DiscordPromptHandle(PromptHandle):
|
|
21
|
+
def __init__(self, embed: discord.Embed, view: discord.ui.View):
|
|
22
|
+
self.embed = embed
|
|
23
|
+
self.view = view
|
|
24
|
+
self.message: discord.Message | None = None
|
|
25
|
+
self.outcome: ToolApprovalOutcome | None = None
|
|
26
|
+
|
|
27
|
+
async def send(self, conversation_ref: discord.abc.Messageable) -> discord.Message:
|
|
28
|
+
self.message = await conversation_ref.send(embed=self.embed, view=self.view)
|
|
29
|
+
return self.message
|
|
30
|
+
|
|
31
|
+
async def finalize(self) -> None:
|
|
32
|
+
for child in self.view.children:
|
|
33
|
+
child.disabled = True
|
|
34
|
+
if self.message is None:
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
await self.message.edit(embed=self.embed, view=self.view)
|
|
38
|
+
except discord.NotFound:
|
|
39
|
+
pass
|
|
40
|
+
except discord.HTTPException as e:
|
|
41
|
+
logger.warning(f"Failed to finalize prompt message: {e}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DiscordAdapter(MessengerAdapter):
|
|
45
|
+
platform_name = "discord"
|
|
46
|
+
|
|
47
|
+
def __init__(self, bot: discord.Client):
|
|
48
|
+
self.bot = bot
|
|
49
|
+
|
|
50
|
+
# -- plain messaging ---------------------------------------------------
|
|
51
|
+
|
|
52
|
+
async def send_message(self, conversation_ref: discord.abc.Messageable, text: str) -> discord.Message:
|
|
53
|
+
return await conversation_ref.send(text)
|
|
54
|
+
|
|
55
|
+
async def edit_message(self, message_ref: discord.Message, text: str) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
await message_ref.edit(content=text, embed=None)
|
|
58
|
+
return True
|
|
59
|
+
except discord.NotFound:
|
|
60
|
+
logger.warning("Discord message not found (likely deleted).")
|
|
61
|
+
return False
|
|
62
|
+
except discord.Forbidden:
|
|
63
|
+
logger.error("Forbidden to edit Discord message (missing permissions).")
|
|
64
|
+
return False
|
|
65
|
+
except discord.HTTPException as e:
|
|
66
|
+
logger.warning(f"HTTPException editing Discord message: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
async def send_files(self, conversation_ref: discord.abc.Messageable, file_paths: list[str]) -> None:
|
|
70
|
+
if not file_paths:
|
|
71
|
+
return
|
|
72
|
+
await conversation_ref.send(files=[discord.File(p) for p in file_paths])
|
|
73
|
+
|
|
74
|
+
def resolve_conversation(self, conversation_id: str) -> Any:
|
|
75
|
+
try:
|
|
76
|
+
return self.bot.get_channel(int(conversation_id))
|
|
77
|
+
except (TypeError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
|
|
81
|
+
return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
|
|
82
|
+
|
|
83
|
+
async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
|
|
84
|
+
await conversation_ref.edit(name=title[:100])
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def typing(self, conversation_ref: discord.abc.Messageable):
|
|
88
|
+
async with conversation_ref.typing():
|
|
89
|
+
yield
|
|
90
|
+
|
|
91
|
+
# -- interactive prompts ----------------------------------------------
|
|
92
|
+
|
|
93
|
+
def create_tool_approval_prompt(
|
|
94
|
+
self,
|
|
95
|
+
decision_future: asyncio.Future,
|
|
96
|
+
title: str,
|
|
97
|
+
body: str,
|
|
98
|
+
scope_options: list[ScopeOption],
|
|
99
|
+
) -> PromptHandle:
|
|
100
|
+
embed = discord.Embed(title=title, description=body, color=discord.Color.orange())
|
|
101
|
+
view = discord.ui.View(timeout=None)
|
|
102
|
+
handle = _DiscordPromptHandle(embed, view)
|
|
103
|
+
|
|
104
|
+
def make_callback(decision: str, scope: ScopeOption | None):
|
|
105
|
+
async def callback(interaction: discord.Interaction):
|
|
106
|
+
handle.outcome = ToolApprovalOutcome(decision=decision, scope=scope)
|
|
107
|
+
if not decision_future.done():
|
|
108
|
+
decision_future.set_result(decision)
|
|
109
|
+
|
|
110
|
+
for child in view.children:
|
|
111
|
+
child.disabled = True
|
|
112
|
+
if decision == "allow" and scope:
|
|
113
|
+
embed.color = discord.Color.green()
|
|
114
|
+
embed.title = f"✅ Approved & Auto-Allowed ({scope.scope})"
|
|
115
|
+
elif decision == "allow":
|
|
116
|
+
embed.color = discord.Color.green()
|
|
117
|
+
embed.title = "✅ Tool Execution Approved"
|
|
118
|
+
else:
|
|
119
|
+
embed.color = discord.Color.red()
|
|
120
|
+
embed.title = "❌ Tool Execution Rejected"
|
|
121
|
+
await interaction.response.edit_message(embed=embed, view=view)
|
|
122
|
+
|
|
123
|
+
return callback
|
|
124
|
+
|
|
125
|
+
btn_once = discord.ui.Button(label="✅ Approve once", style=discord.ButtonStyle.green)
|
|
126
|
+
btn_once.callback = make_callback("allow", None)
|
|
127
|
+
view.add_item(btn_once)
|
|
128
|
+
|
|
129
|
+
for opt in scope_options:
|
|
130
|
+
suffix = " tool" if opt.kind == "tools" else ""
|
|
131
|
+
label = f"♾️ Allow [{opt.scope}]{suffix}"
|
|
132
|
+
if len(label) > 80:
|
|
133
|
+
label = f"♾️ Allow […{opt.scope[-68:]}]{suffix}"[:80]
|
|
134
|
+
btn = discord.ui.Button(label=label, style=discord.ButtonStyle.gray)
|
|
135
|
+
btn.callback = make_callback("allow", opt)
|
|
136
|
+
view.add_item(btn)
|
|
137
|
+
|
|
138
|
+
btn_reject = discord.ui.Button(label="❌ Reject", style=discord.ButtonStyle.red)
|
|
139
|
+
btn_reject.callback = make_callback("reject", None)
|
|
140
|
+
view.add_item(btn_reject)
|
|
141
|
+
|
|
142
|
+
return handle
|
|
143
|
+
|
|
144
|
+
def create_question_prompt(
|
|
145
|
+
self,
|
|
146
|
+
answer_future: asyncio.Future,
|
|
147
|
+
question: str,
|
|
148
|
+
options: list[str],
|
|
149
|
+
multi_select: bool = False,
|
|
150
|
+
allow_write_in: bool = True,
|
|
151
|
+
) -> PromptHandle:
|
|
152
|
+
embed = discord.Embed(
|
|
153
|
+
title="❓ Question from AI",
|
|
154
|
+
description=f"**{question}**\n\nPlease select an answer below.",
|
|
155
|
+
color=discord.Color.blue(),
|
|
156
|
+
)
|
|
157
|
+
view = discord.ui.View(timeout=None)
|
|
158
|
+
handle = _DiscordPromptHandle(embed, view)
|
|
159
|
+
|
|
160
|
+
async def _resolve(interaction: discord.Interaction, text: str, note: str):
|
|
161
|
+
await interaction.response.send_message(f"✅ {note}: **{text}**")
|
|
162
|
+
if not answer_future.done():
|
|
163
|
+
answer_future.set_result(text)
|
|
164
|
+
for child in view.children:
|
|
165
|
+
child.disabled = True
|
|
166
|
+
if interaction.message:
|
|
167
|
+
try:
|
|
168
|
+
await interaction.message.edit(view=view)
|
|
169
|
+
except discord.HTTPException:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
if multi_select and options:
|
|
173
|
+
select = discord.ui.Select(
|
|
174
|
+
placeholder="Select multiple options...",
|
|
175
|
+
min_values=1,
|
|
176
|
+
max_values=min(len(options), 25),
|
|
177
|
+
options=[discord.SelectOption(label=opt[:100]) for opt in options[:25]],
|
|
178
|
+
)
|
|
179
|
+
view.add_item(select)
|
|
180
|
+
|
|
181
|
+
async def submit_callback(interaction: discord.Interaction):
|
|
182
|
+
if not select.values:
|
|
183
|
+
return
|
|
184
|
+
await _resolve(interaction, ", ".join(select.values), "Selected")
|
|
185
|
+
|
|
186
|
+
submit_btn = discord.ui.Button(label="Submit", style=discord.ButtonStyle.primary)
|
|
187
|
+
submit_btn.callback = submit_callback
|
|
188
|
+
view.add_item(submit_btn)
|
|
189
|
+
else:
|
|
190
|
+
def make_option_callback(opt_text: str):
|
|
191
|
+
async def callback(interaction: discord.Interaction):
|
|
192
|
+
await _resolve(interaction, opt_text, "Selected")
|
|
193
|
+
|
|
194
|
+
return callback
|
|
195
|
+
|
|
196
|
+
# 25 components per view; leave room for the write-in button.
|
|
197
|
+
for opt in options[:24]:
|
|
198
|
+
btn = discord.ui.Button(label=opt[:80], style=discord.ButtonStyle.primary)
|
|
199
|
+
btn.callback = make_option_callback(opt)
|
|
200
|
+
view.add_item(btn)
|
|
201
|
+
|
|
202
|
+
if allow_write_in:
|
|
203
|
+
class WriteInModal(discord.ui.Modal, title="Write in"):
|
|
204
|
+
answer = discord.ui.TextInput(
|
|
205
|
+
label="Enter your response",
|
|
206
|
+
style=discord.TextStyle.paragraph,
|
|
207
|
+
placeholder="Type your response here...",
|
|
208
|
+
required=True,
|
|
209
|
+
max_length=2000,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def on_submit(modal_self, interaction: discord.Interaction):
|
|
213
|
+
await _resolve(interaction, modal_self.answer.value, "Selected (Write in)")
|
|
214
|
+
|
|
215
|
+
async def write_in_callback(interaction: discord.Interaction):
|
|
216
|
+
await interaction.response.send_modal(WriteInModal())
|
|
217
|
+
|
|
218
|
+
write_in_btn = discord.ui.Button(label="✍️ Write in", style=discord.ButtonStyle.secondary)
|
|
219
|
+
write_in_btn.callback = write_in_callback
|
|
220
|
+
view.add_item(write_in_btn)
|
|
221
|
+
|
|
222
|
+
return handle
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class DiscordVoiceAdapter(DiscordAdapter, VoiceCapable):
|
|
226
|
+
"""Discord adapter with voice wired in. Separate class so it's
|
|
227
|
+
visible at the type level which code paths need voice."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, bot: discord.Client, voice_cog):
|
|
230
|
+
super().__init__(bot)
|
|
231
|
+
self.voice_cog = voice_cog
|
|
232
|
+
|
|
233
|
+
async def join_voice(self, guild_ref: Any, channel_ref: Any) -> None:
|
|
234
|
+
raise NotImplementedError("Wire to VoiceCog's /join when migrating voice_cog.py")
|
|
235
|
+
|
|
236
|
+
async def leave_voice(self, guild_ref: Any) -> None:
|
|
237
|
+
raise NotImplementedError("Wire to VoiceCog's /leave when migrating voice_cog.py")
|
|
238
|
+
|
|
239
|
+
async def play_tts(self, guild_ref: Any, audio_bytes: bytes) -> None:
|
|
240
|
+
await self.voice_cog._play_audio(str(guild_ref), audio_bytes)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Process-wide messenger adapter instance, set once at startup by
|
|
2
|
+
main.py. Matches the codebase's existing singleton pattern (see
|
|
3
|
+
config.session_manager) so deep call sites don't need the adapter
|
|
4
|
+
threaded through every signature."""
|
|
5
|
+
|
|
6
|
+
from messengers.base import MessengerAdapter
|
|
7
|
+
|
|
8
|
+
_adapter: MessengerAdapter | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def set_adapter(adapter: MessengerAdapter) -> None:
|
|
12
|
+
global _adapter
|
|
13
|
+
_adapter = adapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_adapter() -> MessengerAdapter:
|
|
17
|
+
if _adapter is None:
|
|
18
|
+
raise RuntimeError("Messenger adapter not initialized - main.py must call set_adapter() at startup.")
|
|
19
|
+
return _adapter
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from config import TTS_VOICE, bot_settings, logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def tts(text: str, voice: str = None) -> bytes | None:
|
|
11
|
+
voice = voice or bot_settings.get("tts_voice", TTS_VOICE)
|
|
12
|
+
try:
|
|
13
|
+
import edge_tts
|
|
14
|
+
|
|
15
|
+
clean = re.sub(r"[`*#_\[\]()]", "", text)
|
|
16
|
+
clean = re.sub(r"https?://\S+", "URL", clean)
|
|
17
|
+
clean = re.sub(r"\n+", ". ", clean).strip()[:800]
|
|
18
|
+
if not clean:
|
|
19
|
+
return None
|
|
20
|
+
hangul_chars = len(re.findall(r"[가-힣]", clean))
|
|
21
|
+
alpha_chars = len(re.findall(r"[a-zA-Z]", clean))
|
|
22
|
+
total_letters = hangul_chars + alpha_chars
|
|
23
|
+
ko_voice = os.getenv("TTS_VOICE_KO", "ko-KR-SunHiNeural")
|
|
24
|
+
en_voice = os.getenv("TTS_VOICE_EN", "en-US-AriaNeural")
|
|
25
|
+
|
|
26
|
+
if total_letters == 0:
|
|
27
|
+
active_voice = TTS_VOICE
|
|
28
|
+
elif (hangul_chars / total_letters) >= 0.3:
|
|
29
|
+
active_voice = ko_voice
|
|
30
|
+
else:
|
|
31
|
+
active_voice = en_voice
|
|
32
|
+
communicate = edge_tts.Communicate(clean, active_voice)
|
|
33
|
+
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
34
|
+
tmp = f.name
|
|
35
|
+
await communicate.save(tmp)
|
|
36
|
+
data = Path(tmp).read_bytes()
|
|
37
|
+
os.unlink(tmp)
|
|
38
|
+
return data
|
|
39
|
+
except Exception as e:
|
|
40
|
+
logger.error(f"TTS error: {e}")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def stt(audio_bytes: bytes) -> str | None:
|
|
45
|
+
try:
|
|
46
|
+
|
|
47
|
+
def _transcribe():
|
|
48
|
+
import io
|
|
49
|
+
|
|
50
|
+
import speech_recognition as sr
|
|
51
|
+
|
|
52
|
+
r = sr.Recognizer()
|
|
53
|
+
with sr.AudioFile(io.BytesIO(audio_bytes)) as source:
|
|
54
|
+
audio = r.record(source)
|
|
55
|
+
try:
|
|
56
|
+
return r.recognize_google(audio, language="ko-KR")
|
|
57
|
+
except sr.UnknownValueError:
|
|
58
|
+
return None
|
|
59
|
+
except sr.RequestError as e:
|
|
60
|
+
logger.error(f"STT API error: {e}")
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
result = await asyncio.to_thread(_transcribe)
|
|
64
|
+
return result.strip() if result else None
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.error(f"STT internal error: {e}")
|
|
67
|
+
return None
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import string
|
|
3
|
+
import uuid
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import discord
|
|
7
|
+
|
|
8
|
+
from config import TMP_FILE_DIR, logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def handle_image_attachments(message: discord.Message) -> list[str]:
|
|
12
|
+
saved_paths = []
|
|
13
|
+
for att in message.attachments:
|
|
14
|
+
ext = Path(att.filename).suffix.lower()
|
|
15
|
+
filename = f"{uuid.uuid4().hex}{ext or '.tmp'}"
|
|
16
|
+
dest = TMP_FILE_DIR / filename
|
|
17
|
+
try:
|
|
18
|
+
data = await att.read()
|
|
19
|
+
dest.write_bytes(data)
|
|
20
|
+
saved_paths.append(str(dest))
|
|
21
|
+
except Exception as e:
|
|
22
|
+
logger.error(f"Image save failed: {e}")
|
|
23
|
+
return saved_paths
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cleanup_images(image_paths: list[str]):
|
|
27
|
+
for path in image_paths:
|
|
28
|
+
try:
|
|
29
|
+
Path(path).unlink(missing_ok=True)
|
|
30
|
+
except Exception as e:
|
|
31
|
+
logger.warning(f"Failed to delete temp image {path}: {e}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_content_with_images(content: str, image_paths: list[str]) -> str:
|
|
35
|
+
parts = []
|
|
36
|
+
parts.append(content)
|
|
37
|
+
|
|
38
|
+
if not image_paths:
|
|
39
|
+
return "\n".join(parts)
|
|
40
|
+
|
|
41
|
+
parts.append("\n\n[Attached file path (use `view_file` tool to analyze)]")
|
|
42
|
+
for path in image_paths:
|
|
43
|
+
parts.append(f"- {path}")
|
|
44
|
+
return "\n".join(parts)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_ansi(text: str) -> str:
|
|
48
|
+
return re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])").sub("", text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_approval_intent(text: str) -> str:
|
|
52
|
+
lower_c = text.strip().lower()
|
|
53
|
+
neg_kr_substr = ["아니", "거절", "취소", "하지마", "안돼", "싫어", "멈춰", "그만", "안해", "노노"]
|
|
54
|
+
for neg in neg_kr_substr:
|
|
55
|
+
if neg in lower_c:
|
|
56
|
+
return "reject"
|
|
57
|
+
pos_kr_substr = ["진행", "승인", "하라고", "해봐", "해라", "실행", "그래", "알았어", "좋아", "수락", "오케이"]
|
|
58
|
+
for pos in pos_kr_substr:
|
|
59
|
+
if pos in lower_c:
|
|
60
|
+
return "allow"
|
|
61
|
+
|
|
62
|
+
clean_text = lower_c.translate(str.maketrans("", "", string.punctuation))
|
|
63
|
+
words = clean_text.split()
|
|
64
|
+
exact_reject = ["no", "cancel", "stop", "reject", "deny", "n", "ㄴㄴ", "nope", "abort", "quit", "never"]
|
|
65
|
+
exact_allow = [
|
|
66
|
+
"yes",
|
|
67
|
+
"ok",
|
|
68
|
+
"okay",
|
|
69
|
+
"go",
|
|
70
|
+
"approve",
|
|
71
|
+
"allow",
|
|
72
|
+
"y",
|
|
73
|
+
"응",
|
|
74
|
+
"어",
|
|
75
|
+
"ㅇㅇ",
|
|
76
|
+
"ㅇㅋ",
|
|
77
|
+
"해",
|
|
78
|
+
"콜",
|
|
79
|
+
"네",
|
|
80
|
+
"sure",
|
|
81
|
+
"yeah",
|
|
82
|
+
"yep",
|
|
83
|
+
"yup",
|
|
84
|
+
"proceed",
|
|
85
|
+
"fine",
|
|
86
|
+
"alright",
|
|
87
|
+
"고",
|
|
88
|
+
]
|
|
89
|
+
for word in words:
|
|
90
|
+
if word in exact_reject:
|
|
91
|
+
return "reject"
|
|
92
|
+
for word in words:
|
|
93
|
+
if word in exact_allow:
|
|
94
|
+
return "allow"
|
|
95
|
+
return None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("DiscordButtons")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@mcp.tool()
|
|
10
|
+
def ask_discord_user(question: str, options: list[str]) -> str:
|
|
11
|
+
"""
|
|
12
|
+
Ask a multiple-choice question to the user in Discord using interactive buttons.
|
|
13
|
+
You MUST use this tool instead of the default ask_question tool when running in Discord.
|
|
14
|
+
"""
|
|
15
|
+
thread_id = os.environ.get("DISCORD_THREAD_ID")
|
|
16
|
+
if not thread_id:
|
|
17
|
+
return "Error: DISCORD_THREAD_ID not set. Are you running in Discord?"
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
resp = requests.post(
|
|
21
|
+
"http://127.0.0.1:18080/mcp_ask",
|
|
22
|
+
json={"thread_id": thread_id, "question": question, "options": options},
|
|
23
|
+
timeout=300,
|
|
24
|
+
)
|
|
25
|
+
if resp.status_code == 200:
|
|
26
|
+
return resp.json().get("answer", "No answer")
|
|
27
|
+
return f"Error: HTTP {resp.status_code}"
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return f"Error: {e}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def send_discord_message(channel_id: str, message: str) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Sends a text message to a specific Discord channel by its ID.
|
|
36
|
+
You can use this tool when the user asks you to send a message to a different channel.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
resp = requests.post(
|
|
40
|
+
"http://127.0.0.1:18080/mcp_send_channel", json={"channel_id": channel_id, "message": message}, timeout=10
|
|
41
|
+
)
|
|
42
|
+
if resp.status_code == 200:
|
|
43
|
+
return "Message sent successfully"
|
|
44
|
+
return f"Error: HTTP {resp.status_code} - {resp.text}"
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return f"Error: {e}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
mcp.run()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
|
|
5
|
+
from messengers.registry import get_adapter
|
|
6
|
+
from utils.utils import get_current_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def send_agy_response(
|
|
10
|
+
thread: Any,
|
|
11
|
+
response_text: str,
|
|
12
|
+
session: dict,
|
|
13
|
+
ctx: dict = None,
|
|
14
|
+
start_time: float = 0,
|
|
15
|
+
conv_id: str = None,
|
|
16
|
+
):
|
|
17
|
+
adapter = get_adapter()
|
|
18
|
+
session_manager.save_sessions()
|
|
19
|
+
|
|
20
|
+
parts = [response_text[i : i + MAX_EMBED_LEN] for i in range(0, max(len(response_text), 1), MAX_EMBED_LEN)]
|
|
21
|
+
for idx, part in enumerate(parts):
|
|
22
|
+
is_last = idx == len(parts) - 1
|
|
23
|
+
|
|
24
|
+
if is_last:
|
|
25
|
+
if not part.strip():
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
session_model = session.get("model")
|
|
29
|
+
model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
|
|
30
|
+
text_to_send = f"{part}\n-# 🤖 {model_display}"
|
|
31
|
+
|
|
32
|
+
status_msg = ctx.get("status_msg") if ctx else None
|
|
33
|
+
if status_msg and await adapter.edit_message(status_msg, text_to_send):
|
|
34
|
+
continue
|
|
35
|
+
await adapter.send_message(thread, text_to_send)
|
|
36
|
+
else:
|
|
37
|
+
await adapter.send_message(thread, part)
|
|
38
|
+
|
|
39
|
+
files_to_send = []
|
|
40
|
+
if conv_id and start_time:
|
|
41
|
+
brain_dir = Path.home() / f".gemini/antigravity-cli/brain/{conv_id}"
|
|
42
|
+
if brain_dir.exists():
|
|
43
|
+
for md_file in brain_dir.glob("*.md"):
|
|
44
|
+
if md_file.stat().st_mtime >= start_time:
|
|
45
|
+
files_to_send.append(str(md_file))
|
|
46
|
+
if files_to_send:
|
|
47
|
+
await adapter.send_files(thread, files_to_send)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def render_thought_process(conv_id: str, ctx: dict, response_text: str, thread: Any) -> str:
|
|
51
|
+
return ctx.get("final_text", response_text)
|