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.
@@ -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 _safe_query_edit(query, **kwargs) -> None:
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 _safe_query_edit(query, text=new_text, parse_mode="HTML")
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 _safe_query_edit(query, text=new_text, parse_mode="HTML")
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 _safe_query_edit(
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
- communicate = edge_tts.Communicate(clean, active_voice)
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, thread.guild.id if hasattr(thread, "guild") else None, cog, is_voice)
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: