linkgravity 1.4.0 → 1.5.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/README.md +54 -19
- package/bin/cli.js +27 -3
- package/bin/platforms.js +18 -1
- package/bin/setup.js +43 -0
- package/package.json +4 -2
- package/requirements.txt +1 -0
- package/src/cogs/general_cog.py +0 -21
- package/src/cogs/voice_cog.py +48 -4
- package/src/config.py +15 -2
- package/src/core/logger.py +6 -0
- package/src/core/platform_health.py +28 -0
- package/src/handlers/thread_reply.py +1 -1
- package/src/main.py +33 -7
- package/src/main_discord.py +3 -0
- package/src/main_slack.py +252 -0
- package/src/main_telegram.py +74 -23
- package/src/messengers/base.py +5 -0
- package/src/messengers/discord_adapter.py +19 -1
- package/src/messengers/slack_adapter.py +492 -0
- package/src/messengers/telegram_adapter.py +4 -4
- package/src/services/audio_service.py +6 -1
- package/src/services/streaming.py +6 -5
- package/voice-service/index.js +43 -193
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Slack implementation of MessengerAdapter. Slack has real threads, so this
|
|
2
|
+
matches Discord's model (channel message starts a thread) rather than
|
|
3
|
+
Telegram's. conversation_id is "channel:thread_ts"."""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from slack_bolt.app.async_app import AsyncApp
|
|
12
|
+
from slack_sdk.errors import SlackApiError
|
|
13
|
+
from slack_sdk.web.async_client import AsyncWebClient
|
|
14
|
+
|
|
15
|
+
from config import logger, session_manager
|
|
16
|
+
from messengers.base import (
|
|
17
|
+
IncomingAttachment,
|
|
18
|
+
IncomingMessage,
|
|
19
|
+
MessengerAdapter,
|
|
20
|
+
PromptHandle,
|
|
21
|
+
ScopeOption,
|
|
22
|
+
ToolApprovalOutcome,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def markdown_to_slack_mrkdwn(text: str) -> str:
|
|
29
|
+
"""Converts the Discord-flavored markdown this codebase generates
|
|
30
|
+
(**bold**) into Slack's mrkdwn (*bold*). Code fences are already
|
|
31
|
+
compatible between the two, so left as-is."""
|
|
32
|
+
return _BOLD_RE.sub(r"*\1*", text)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def encode_conversation_id(channel: str, thread_ts: str) -> str:
|
|
36
|
+
return f"{channel}:{thread_ts}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_conversation_id(conversation_id: str) -> tuple[str, str] | None:
|
|
40
|
+
if ":" not in conversation_id:
|
|
41
|
+
return None
|
|
42
|
+
channel, _, thread_ts = conversation_id.partition(":")
|
|
43
|
+
return channel, thread_ts
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def latest_channel_session(channel: str) -> tuple[str, dict] | None:
|
|
47
|
+
"""Most recently created Slack session in a channel - used as a fallback for un-threaded
|
|
48
|
+
messages (users rarely bother clicking "Reply in thread") and for /model, /credit, which
|
|
49
|
+
can't target a specific thread since Slack slash commands can't be invoked inside one."""
|
|
50
|
+
candidates = [
|
|
51
|
+
(cid, s)
|
|
52
|
+
for cid, s in session_manager.get_all_sessions().items()
|
|
53
|
+
if s.get("platform") == "slack" and cid.startswith(f"{channel}:")
|
|
54
|
+
]
|
|
55
|
+
if not candidates:
|
|
56
|
+
return None
|
|
57
|
+
return max(candidates, key=lambda kv: kv[1].get("created_at", ""))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SlackConversationRef:
|
|
61
|
+
"""conversation_ref for Slack - a channel + the thread_ts all replies go under."""
|
|
62
|
+
|
|
63
|
+
__slots__ = ("channel", "thread_ts")
|
|
64
|
+
|
|
65
|
+
def __init__(self, channel: str, thread_ts: str):
|
|
66
|
+
self.channel = channel
|
|
67
|
+
self.thread_ts = thread_ts
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return f"SlackConversationRef({self.channel}, {self.thread_ts})"
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def api_thread_ts(self) -> str | None:
|
|
74
|
+
"""None for DMs (thread_ts is a "channel:channel" sentinel there, not a real ts)."""
|
|
75
|
+
return None if self.thread_ts == self.channel else self.thread_ts
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SlackMessageRef:
|
|
79
|
+
"""message_ref for edit_message - a specific message within a channel."""
|
|
80
|
+
|
|
81
|
+
__slots__ = ("channel", "ts")
|
|
82
|
+
|
|
83
|
+
def __init__(self, channel: str, ts: str):
|
|
84
|
+
self.channel = channel
|
|
85
|
+
self.ts = ts
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _SlackPromptHandle(PromptHandle):
|
|
89
|
+
def __init__(
|
|
90
|
+
self, client: AsyncWebClient, text: str, blocks: list[dict], cleanup: Callable[[], None] | None = None
|
|
91
|
+
):
|
|
92
|
+
self.client = client
|
|
93
|
+
self.text = text
|
|
94
|
+
self.blocks = blocks
|
|
95
|
+
self._cleanup = cleanup
|
|
96
|
+
self.channel: str | None = None
|
|
97
|
+
self.ts: str | None = None
|
|
98
|
+
self.outcome: ToolApprovalOutcome | None = None
|
|
99
|
+
|
|
100
|
+
async def send(self, conversation_ref: SlackConversationRef) -> dict:
|
|
101
|
+
try:
|
|
102
|
+
resp = await self.client.chat_postMessage(
|
|
103
|
+
channel=conversation_ref.channel,
|
|
104
|
+
thread_ts=conversation_ref.api_thread_ts,
|
|
105
|
+
text=self.text,
|
|
106
|
+
blocks=self.blocks,
|
|
107
|
+
)
|
|
108
|
+
except SlackApiError as e:
|
|
109
|
+
logger.error(f"Failed to send Slack prompt message: {e}")
|
|
110
|
+
raise
|
|
111
|
+
self.channel = resp["channel"]
|
|
112
|
+
self.ts = resp["ts"]
|
|
113
|
+
return resp
|
|
114
|
+
|
|
115
|
+
async def finalize(self) -> None:
|
|
116
|
+
if self._cleanup:
|
|
117
|
+
self._cleanup()
|
|
118
|
+
if self.ts is None:
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
await self.client.chat_update(channel=self.channel, ts=self.ts, text=self.text, blocks=self.blocks)
|
|
122
|
+
except SlackApiError as e:
|
|
123
|
+
logger.warning(f"Failed to finalize Slack prompt message: {e}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class SlackAdapter(MessengerAdapter):
|
|
127
|
+
platform_name = "slack"
|
|
128
|
+
supports_renaming = True # No thread "title" field in Slack, but we edit the parent message text instead.
|
|
129
|
+
|
|
130
|
+
def __init__(self, app: AsyncApp):
|
|
131
|
+
self.app = app
|
|
132
|
+
self.client: AsyncWebClient = app.client
|
|
133
|
+
self._bot_user_id: str | None = None
|
|
134
|
+
# action_id -> async handler(body, client) ; prompts add/remove their own keys here.
|
|
135
|
+
self._callbacks: dict[str, Callable[[dict, AsyncWebClient], Awaitable[None]]] = {}
|
|
136
|
+
# view callback_id -> async handler(body, client) for modal (write-in) submissions.
|
|
137
|
+
self._view_callbacks: dict[str, Callable[[dict, AsyncWebClient], Awaitable[None]]] = {}
|
|
138
|
+
|
|
139
|
+
async def resolve_bot_user_id(self) -> str:
|
|
140
|
+
if self._bot_user_id is None:
|
|
141
|
+
auth = await self.client.auth_test()
|
|
142
|
+
self._bot_user_id = auth["user_id"]
|
|
143
|
+
return self._bot_user_id
|
|
144
|
+
|
|
145
|
+
def register_callback(self, action_id: str, handler: Callable[[dict, AsyncWebClient], Awaitable[None]]) -> None:
|
|
146
|
+
self._callbacks[action_id] = handler
|
|
147
|
+
|
|
148
|
+
def register_view_callback(
|
|
149
|
+
self, callback_id: str, handler: Callable[[dict, AsyncWebClient], Awaitable[None]]
|
|
150
|
+
) -> None:
|
|
151
|
+
self._view_callbacks[callback_id] = handler
|
|
152
|
+
|
|
153
|
+
async def handle_block_action(self, body: dict) -> None:
|
|
154
|
+
actions = body.get("actions") or []
|
|
155
|
+
if not actions:
|
|
156
|
+
return
|
|
157
|
+
action_id = actions[0].get("action_id")
|
|
158
|
+
handler = self._callbacks.pop(action_id, None)
|
|
159
|
+
if handler is None:
|
|
160
|
+
return # expired/unknown action - nothing to do, Bolt already acked
|
|
161
|
+
await handler(body, self.client)
|
|
162
|
+
|
|
163
|
+
async def handle_view_submission(self, body: dict) -> None:
|
|
164
|
+
callback_id = (body.get("view") or {}).get("callback_id")
|
|
165
|
+
handler = self._view_callbacks.pop(callback_id, None)
|
|
166
|
+
if handler is None:
|
|
167
|
+
return
|
|
168
|
+
await handler(body, self.client)
|
|
169
|
+
|
|
170
|
+
def _make_reader(self, url: str) -> Callable[[], Awaitable[bytes]]:
|
|
171
|
+
async def _download() -> bytes:
|
|
172
|
+
# Private file URLs require bot-token auth (unlike public asset URLs).
|
|
173
|
+
import aiohttp
|
|
174
|
+
|
|
175
|
+
headers = {"Authorization": f"Bearer {self.client.token}"}
|
|
176
|
+
async with aiohttp.ClientSession() as session, session.get(url, headers=headers) as resp:
|
|
177
|
+
return await resp.read()
|
|
178
|
+
|
|
179
|
+
return _download
|
|
180
|
+
|
|
181
|
+
def to_incoming_message(self, raw_event: dict) -> IncomingMessage | None:
|
|
182
|
+
event = raw_event
|
|
183
|
+
if event.get("bot_id") or event.get("subtype") in ("bot_message", "message_changed", "message_deleted"):
|
|
184
|
+
return None
|
|
185
|
+
if event.get("user") == self._bot_user_id:
|
|
186
|
+
return None
|
|
187
|
+
channel = event.get("channel")
|
|
188
|
+
user = event.get("user")
|
|
189
|
+
text = event.get("text", "")
|
|
190
|
+
if channel is None or user is None:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# DMs use a flat model like Telegram (no threading expected).
|
|
194
|
+
if event.get("channel_type") == "im":
|
|
195
|
+
thread_ts = channel
|
|
196
|
+
elif event.get("thread_ts"):
|
|
197
|
+
thread_ts = event["thread_ts"] # explicit "Reply in thread" - honor it exactly
|
|
198
|
+
else:
|
|
199
|
+
# Most users don't bother threading replies, so a plain channel message falls back
|
|
200
|
+
# to that channel's most recent session instead of starting a disconnected new one.
|
|
201
|
+
found = latest_channel_session(channel)
|
|
202
|
+
thread_ts = decode_conversation_id(found[0])[1] if found else event["ts"]
|
|
203
|
+
conversation_id = encode_conversation_id(channel, thread_ts)
|
|
204
|
+
ref = SlackConversationRef(channel, thread_ts)
|
|
205
|
+
|
|
206
|
+
attachments = []
|
|
207
|
+
for f in event.get("files") or []:
|
|
208
|
+
url = f.get("url_private_download") or f.get("url_private")
|
|
209
|
+
if not url:
|
|
210
|
+
continue
|
|
211
|
+
attachments.append(
|
|
212
|
+
IncomingAttachment(
|
|
213
|
+
filename=f.get("name") or "file", content_type=f.get("mimetype"), reader=self._make_reader(url)
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def add_reaction(emoji: str) -> None:
|
|
218
|
+
try:
|
|
219
|
+
await self.client.reactions_add(channel=channel, timestamp=event["ts"], name=emoji.strip(":"))
|
|
220
|
+
except SlackApiError as e:
|
|
221
|
+
logger.warning(f"Failed to set Slack reaction: {e}")
|
|
222
|
+
|
|
223
|
+
return IncomingMessage(
|
|
224
|
+
author_id=user,
|
|
225
|
+
platform=self.platform_name,
|
|
226
|
+
content=text,
|
|
227
|
+
conversation_id=conversation_id,
|
|
228
|
+
conversation_ref=ref,
|
|
229
|
+
attachments=attachments,
|
|
230
|
+
add_reaction=add_reaction,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
async def send_message(self, conversation_ref: SlackConversationRef, text: str) -> dict:
|
|
234
|
+
try:
|
|
235
|
+
return await self.client.chat_postMessage(
|
|
236
|
+
channel=conversation_ref.channel,
|
|
237
|
+
thread_ts=conversation_ref.api_thread_ts,
|
|
238
|
+
text=markdown_to_slack_mrkdwn(text),
|
|
239
|
+
)
|
|
240
|
+
except SlackApiError as e:
|
|
241
|
+
logger.error(f"Failed to send Slack message: {e}")
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
async def edit_message(self, message_ref: dict, text: str) -> bool:
|
|
245
|
+
try:
|
|
246
|
+
await self.client.chat_update(
|
|
247
|
+
channel=message_ref["channel"], ts=message_ref["ts"], text=markdown_to_slack_mrkdwn(text)
|
|
248
|
+
)
|
|
249
|
+
return True
|
|
250
|
+
except SlackApiError as e:
|
|
251
|
+
if "message_not_found" in str(e):
|
|
252
|
+
return False
|
|
253
|
+
logger.warning(f"Failed to edit Slack message: {e}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
async def send_files(self, conversation_ref: SlackConversationRef, file_paths: list[str]) -> None:
|
|
257
|
+
for path in file_paths:
|
|
258
|
+
try:
|
|
259
|
+
await self.client.files_upload_v2(
|
|
260
|
+
channel=conversation_ref.channel, thread_ts=conversation_ref.api_thread_ts, file=path
|
|
261
|
+
)
|
|
262
|
+
except SlackApiError as e:
|
|
263
|
+
logger.error(f"Failed to send Slack file {path}: {e}")
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
def resolve_conversation(self, conversation_id: str) -> Any:
|
|
267
|
+
decoded = decode_conversation_id(conversation_id)
|
|
268
|
+
if decoded is None:
|
|
269
|
+
return None
|
|
270
|
+
channel, thread_ts = decoded
|
|
271
|
+
return SlackConversationRef(channel, thread_ts)
|
|
272
|
+
|
|
273
|
+
async def start_conversation(self, origin_ref: dict, title: str) -> SlackConversationRef:
|
|
274
|
+
# No explicit "create thread" call in Slack - the origin message's own ts becomes thread_ts.
|
|
275
|
+
return SlackConversationRef(origin_ref["channel"], origin_ref["ts"])
|
|
276
|
+
|
|
277
|
+
async def rename_conversation(self, conversation_ref: SlackConversationRef, title: str) -> None:
|
|
278
|
+
if conversation_ref.api_thread_ts is None:
|
|
279
|
+
return # DM has no announcement message to update
|
|
280
|
+
try:
|
|
281
|
+
await self.client.chat_update(
|
|
282
|
+
channel=conversation_ref.channel,
|
|
283
|
+
ts=conversation_ref.thread_ts,
|
|
284
|
+
text=f"🧵 *{markdown_to_slack_mrkdwn(title)}*",
|
|
285
|
+
)
|
|
286
|
+
except SlackApiError as e:
|
|
287
|
+
logger.warning(f"Failed to update Slack thread summary: {e}")
|
|
288
|
+
|
|
289
|
+
def create_tool_approval_prompt(
|
|
290
|
+
self,
|
|
291
|
+
decision_future: asyncio.Future,
|
|
292
|
+
title: str,
|
|
293
|
+
body: str,
|
|
294
|
+
scope_options: list[ScopeOption],
|
|
295
|
+
) -> PromptHandle:
|
|
296
|
+
prompt_id = uuid.uuid4().hex[:12]
|
|
297
|
+
text = f"*{title}*\n\n{markdown_to_slack_mrkdwn(body)}"
|
|
298
|
+
blocks = [
|
|
299
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": text[:2990]}},
|
|
300
|
+
]
|
|
301
|
+
keys: list[str] = []
|
|
302
|
+
elements = []
|
|
303
|
+
|
|
304
|
+
async def resolve(decision: str, scope: ScopeOption | None, resp_body: dict, client: AsyncWebClient):
|
|
305
|
+
handle.outcome = ToolApprovalOutcome(decision=decision, scope=scope)
|
|
306
|
+
if not decision_future.done():
|
|
307
|
+
decision_future.set_result(decision)
|
|
308
|
+
if decision == "allow" and scope:
|
|
309
|
+
new_text = f"✅ *Approved & auto-allowed ({scope.scope})*"
|
|
310
|
+
elif decision == "allow":
|
|
311
|
+
new_text = "✅ *Approved*"
|
|
312
|
+
else:
|
|
313
|
+
new_text = "❌ *Rejected*"
|
|
314
|
+
handle.text = new_text
|
|
315
|
+
handle.blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": new_text}}]
|
|
316
|
+
await handle.finalize()
|
|
317
|
+
|
|
318
|
+
allow_key = f"{prompt_id}:allow"
|
|
319
|
+
self._callbacks[allow_key] = lambda b, c: resolve("allow", None, b, c)
|
|
320
|
+
keys.append(allow_key)
|
|
321
|
+
elements.append(
|
|
322
|
+
{
|
|
323
|
+
"type": "button",
|
|
324
|
+
"text": {"type": "plain_text", "text": "✅ Approve once"},
|
|
325
|
+
"action_id": allow_key,
|
|
326
|
+
"style": "primary",
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
for i, opt in enumerate(scope_options):
|
|
331
|
+
suffix = " tool" if opt.kind == "tools" else ""
|
|
332
|
+
label = f"♾️ Allow [{opt.scope}]{suffix}"
|
|
333
|
+
if len(label) > 75:
|
|
334
|
+
label = label[:72] + "…"
|
|
335
|
+
key = f"{prompt_id}:scope:{i}"
|
|
336
|
+
self._callbacks[key] = lambda b, c, opt=opt: resolve("allow", opt, b, c)
|
|
337
|
+
keys.append(key)
|
|
338
|
+
elements.append({"type": "button", "text": {"type": "plain_text", "text": label}, "action_id": key})
|
|
339
|
+
|
|
340
|
+
reject_key = f"{prompt_id}:reject"
|
|
341
|
+
self._callbacks[reject_key] = lambda b, c: resolve("reject", None, b, c)
|
|
342
|
+
keys.append(reject_key)
|
|
343
|
+
elements.append(
|
|
344
|
+
{
|
|
345
|
+
"type": "button",
|
|
346
|
+
"text": {"type": "plain_text", "text": "❌ Reject"},
|
|
347
|
+
"action_id": reject_key,
|
|
348
|
+
"style": "danger",
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# 25 is Slack's hard per-block limit on action elements.
|
|
353
|
+
for chunk_start in range(0, len(elements), 25):
|
|
354
|
+
blocks.append({"type": "actions", "elements": elements[chunk_start : chunk_start + 25]})
|
|
355
|
+
|
|
356
|
+
handle = _SlackPromptHandle(
|
|
357
|
+
self.client, text, blocks, cleanup=lambda: [self._callbacks.pop(k, None) for k in keys]
|
|
358
|
+
)
|
|
359
|
+
return handle
|
|
360
|
+
|
|
361
|
+
def create_question_prompt(
|
|
362
|
+
self,
|
|
363
|
+
answer_future: asyncio.Future,
|
|
364
|
+
question: str,
|
|
365
|
+
options: list[str],
|
|
366
|
+
multi_select: bool = False,
|
|
367
|
+
allow_write_in: bool = True,
|
|
368
|
+
) -> PromptHandle:
|
|
369
|
+
prompt_id = uuid.uuid4().hex[:12]
|
|
370
|
+
text = f"❓ *Question from AI*\n\n*{question}*\n\nPlease choose an answer below."
|
|
371
|
+
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": text[:2990]}}]
|
|
372
|
+
keys: list[str] = []
|
|
373
|
+
|
|
374
|
+
async def resolve(chosen_text: str, note: str, body: dict, client: AsyncWebClient):
|
|
375
|
+
if not answer_future.done():
|
|
376
|
+
answer_future.set_result(chosen_text)
|
|
377
|
+
new_text = f"✅ *{note}: {chosen_text}*"
|
|
378
|
+
handle.text = new_text
|
|
379
|
+
handle.blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": new_text}}]
|
|
380
|
+
await handle.finalize()
|
|
381
|
+
|
|
382
|
+
async def open_write_in_modal(body: dict, client: AsyncWebClient):
|
|
383
|
+
view_callback_id = f"{prompt_id}:write_in_view"
|
|
384
|
+
|
|
385
|
+
async def on_submit(submit_body: dict, submit_client: AsyncWebClient):
|
|
386
|
+
value = submit_body["view"]["state"]["values"]["answer_block"]["answer_input"]["value"]
|
|
387
|
+
await resolve(value, "Selected (Write in)", submit_body, submit_client)
|
|
388
|
+
|
|
389
|
+
self.register_view_callback(view_callback_id, on_submit)
|
|
390
|
+
await client.views_open(
|
|
391
|
+
trigger_id=body["trigger_id"],
|
|
392
|
+
view={
|
|
393
|
+
"type": "modal",
|
|
394
|
+
"callback_id": view_callback_id,
|
|
395
|
+
"title": {"type": "plain_text", "text": "Write in"},
|
|
396
|
+
"submit": {"type": "plain_text", "text": "Submit"},
|
|
397
|
+
"close": {"type": "plain_text", "text": "Cancel"},
|
|
398
|
+
"blocks": [
|
|
399
|
+
{
|
|
400
|
+
"type": "input",
|
|
401
|
+
"block_id": "answer_block",
|
|
402
|
+
"label": {"type": "plain_text", "text": "Enter your response"},
|
|
403
|
+
"element": {
|
|
404
|
+
"type": "plain_text_input",
|
|
405
|
+
"action_id": "answer_input",
|
|
406
|
+
"multiline": True,
|
|
407
|
+
"max_length": 2000,
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
],
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if allow_write_in:
|
|
415
|
+
write_in_key = f"{prompt_id}:write_in"
|
|
416
|
+
self._callbacks[write_in_key] = open_write_in_modal
|
|
417
|
+
keys.append(write_in_key)
|
|
418
|
+
|
|
419
|
+
if multi_select and options:
|
|
420
|
+
selected: set[int] = set()
|
|
421
|
+
shown = options[:20]
|
|
422
|
+
toggle_prefix = f"{prompt_id}:toggle:"
|
|
423
|
+
submit_key = f"{prompt_id}:submit"
|
|
424
|
+
|
|
425
|
+
def render_elements() -> list[dict]:
|
|
426
|
+
elements = [
|
|
427
|
+
{
|
|
428
|
+
"type": "button",
|
|
429
|
+
"text": {"type": "plain_text", "text": ("☑️ " if i in selected else "⬜ ") + opt[:60]},
|
|
430
|
+
"action_id": f"{toggle_prefix}{i}",
|
|
431
|
+
}
|
|
432
|
+
for i, opt in enumerate(shown)
|
|
433
|
+
]
|
|
434
|
+
elements.append(
|
|
435
|
+
{
|
|
436
|
+
"type": "button",
|
|
437
|
+
"text": {"type": "plain_text", "text": "Submit"},
|
|
438
|
+
"action_id": submit_key,
|
|
439
|
+
"style": "primary",
|
|
440
|
+
}
|
|
441
|
+
)
|
|
442
|
+
if allow_write_in:
|
|
443
|
+
elements.append(
|
|
444
|
+
{
|
|
445
|
+
"type": "button",
|
|
446
|
+
"text": {"type": "plain_text", "text": "✍️ Write in"},
|
|
447
|
+
"action_id": write_in_key,
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
return elements
|
|
451
|
+
|
|
452
|
+
async def toggle(i: int, body: dict, client: AsyncWebClient):
|
|
453
|
+
selected.symmetric_difference_update({i})
|
|
454
|
+
handle.blocks = [blocks[0], {"type": "actions", "elements": render_elements()}]
|
|
455
|
+
self._callbacks[f"{toggle_prefix}{i}"] = lambda b, c, i=i: toggle(
|
|
456
|
+
i, b, c
|
|
457
|
+
) # re-register for next toggle
|
|
458
|
+
await self.client.chat_update(
|
|
459
|
+
channel=handle.channel, ts=handle.ts, text=handle.text, blocks=handle.blocks
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
async def submit(body: dict, client: AsyncWebClient):
|
|
463
|
+
if not selected:
|
|
464
|
+
return
|
|
465
|
+
chosen = ", ".join(shown[i] for i in sorted(selected))
|
|
466
|
+
await resolve(chosen, "Selected", body, client)
|
|
467
|
+
|
|
468
|
+
for i in range(len(shown)):
|
|
469
|
+
key = f"{toggle_prefix}{i}"
|
|
470
|
+
self._callbacks[key] = lambda b, c, i=i: toggle(i, b, c)
|
|
471
|
+
keys.append(key)
|
|
472
|
+
self._callbacks[submit_key] = submit
|
|
473
|
+
keys.append(submit_key)
|
|
474
|
+
blocks.append({"type": "actions", "elements": render_elements()})
|
|
475
|
+
else:
|
|
476
|
+
elements = []
|
|
477
|
+
for i, opt in enumerate(options[:23]):
|
|
478
|
+
key = f"{prompt_id}:opt:{i}"
|
|
479
|
+
self._callbacks[key] = lambda b, c, opt=opt: resolve(opt, "Selected", b, c)
|
|
480
|
+
keys.append(key)
|
|
481
|
+
elements.append({"type": "button", "text": {"type": "plain_text", "text": opt[:75]}, "action_id": key})
|
|
482
|
+
if allow_write_in:
|
|
483
|
+
elements.append(
|
|
484
|
+
{"type": "button", "text": {"type": "plain_text", "text": "✍️ Write in"}, "action_id": write_in_key}
|
|
485
|
+
)
|
|
486
|
+
for chunk_start in range(0, len(elements), 25):
|
|
487
|
+
blocks.append({"type": "actions", "elements": elements[chunk_start : chunk_start + 25]})
|
|
488
|
+
|
|
489
|
+
handle = _SlackPromptHandle(
|
|
490
|
+
self.client, text, blocks, cleanup=lambda: [self._callbacks.pop(k, None) for k in keys]
|
|
491
|
+
)
|
|
492
|
+
return handle
|
|
@@ -42,7 +42,7 @@ def markdown_to_telegram_html(text: str) -> str:
|
|
|
42
42
|
return "".join(out)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
async def
|
|
45
|
+
async def safe_query_edit(query, **kwargs) -> None:
|
|
46
46
|
try:
|
|
47
47
|
await query.edit_message_text(**kwargs)
|
|
48
48
|
except TelegramError as e:
|
|
@@ -258,7 +258,7 @@ class TelegramAdapter(MessengerAdapter):
|
|
|
258
258
|
new_text = "❌ <b>Rejected</b>"
|
|
259
259
|
handle.text = new_text
|
|
260
260
|
await query.answer()
|
|
261
|
-
await
|
|
261
|
+
await safe_query_edit(query, text=new_text, parse_mode="HTML")
|
|
262
262
|
|
|
263
263
|
allow_key = f"{prompt_id}:allow"
|
|
264
264
|
self._callbacks[allow_key] = lambda query: resolve("allow", None, query)
|
|
@@ -303,11 +303,11 @@ class TelegramAdapter(MessengerAdapter):
|
|
|
303
303
|
new_text = f"✅ <b>{note}: {html.escape(chosen_text)}</b>"
|
|
304
304
|
handle.text = new_text
|
|
305
305
|
await query.answer()
|
|
306
|
-
await
|
|
306
|
+
await safe_query_edit(query, text=new_text, parse_mode="HTML")
|
|
307
307
|
|
|
308
308
|
async def write_in(query):
|
|
309
309
|
await query.answer()
|
|
310
|
-
await
|
|
310
|
+
await safe_query_edit(
|
|
311
311
|
query,
|
|
312
312
|
text=f"❓ <b>{html.escape(question)}</b>\n\n💬 Reply with your answer as a message.",
|
|
313
313
|
parse_mode="HTML",
|
|
@@ -29,7 +29,12 @@ async def tts(text: str, voice: str = None) -> bytes | None:
|
|
|
29
29
|
active_voice = ko_voice
|
|
30
30
|
else:
|
|
31
31
|
active_voice = en_voice
|
|
32
|
-
|
|
32
|
+
|
|
33
|
+
speed = bot_settings.get("tts_speed", 1.0)
|
|
34
|
+
pct = round((speed - 1.0) * 100)
|
|
35
|
+
rate = f"{'+' if pct >= 0 else ''}{pct}%"
|
|
36
|
+
|
|
37
|
+
communicate = edge_tts.Communicate(clean, active_voice, rate=rate)
|
|
33
38
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
34
39
|
tmp = f.name
|
|
35
40
|
await communicate.save(tmp)
|
|
@@ -145,15 +145,16 @@ class TTSStreamManager:
|
|
|
145
145
|
|
|
146
146
|
async def stream_thinking_latest(bot, thread: Any, thread_id: str, context_dict: dict, queue: asyncio.Queue):
|
|
147
147
|
cog = bot.get_cog("VoiceCog") if bot else None
|
|
148
|
+
# getattr(..., None) is not None, not hasattr: discord.py's DMChannel has a `guild`
|
|
149
|
+
# property too (for duck-typing), but it always returns None - hasattr alone can't
|
|
150
|
+
# tell a real guild-backed channel/thread apart from a DM.
|
|
151
|
+
guild = getattr(thread, "guild", None)
|
|
148
152
|
is_voice = bool(
|
|
149
|
-
cog
|
|
150
|
-
and hasattr(thread, "guild")
|
|
151
|
-
and str(thread.guild.id) in cog._voice_state
|
|
152
|
-
and cog._voice_state[str(thread.guild.id)] == thread.id
|
|
153
|
+
cog and guild is not None and str(guild.id) in cog._voice_state and cog._voice_state[str(guild.id)] == thread.id
|
|
153
154
|
)
|
|
154
155
|
|
|
155
156
|
ui_mgr = StreamUpdater(thread, thread_id, context_dict)
|
|
156
|
-
tts_mgr = TTSStreamManager(thread_id,
|
|
157
|
+
tts_mgr = TTSStreamManager(thread_id, guild.id if guild is not None else None, cog, is_voice)
|
|
157
158
|
|
|
158
159
|
try:
|
|
159
160
|
while True:
|