vibe-remote 2.1.6__py3-none-any.whl

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 (52) hide show
  1. config/__init__.py +37 -0
  2. config/paths.py +56 -0
  3. config/v2_compat.py +74 -0
  4. config/v2_config.py +206 -0
  5. config/v2_sessions.py +73 -0
  6. config/v2_settings.py +115 -0
  7. core/__init__.py +0 -0
  8. core/controller.py +736 -0
  9. core/handlers/__init__.py +13 -0
  10. core/handlers/command_handlers.py +342 -0
  11. core/handlers/message_handler.py +365 -0
  12. core/handlers/session_handler.py +233 -0
  13. core/handlers/settings_handler.py +362 -0
  14. modules/__init__.py +0 -0
  15. modules/agent_router.py +58 -0
  16. modules/agents/__init__.py +38 -0
  17. modules/agents/base.py +91 -0
  18. modules/agents/claude_agent.py +344 -0
  19. modules/agents/codex_agent.py +368 -0
  20. modules/agents/opencode_agent.py +2155 -0
  21. modules/agents/service.py +41 -0
  22. modules/agents/subagent_router.py +136 -0
  23. modules/claude_client.py +154 -0
  24. modules/im/__init__.py +63 -0
  25. modules/im/base.py +323 -0
  26. modules/im/factory.py +60 -0
  27. modules/im/formatters/__init__.py +4 -0
  28. modules/im/formatters/base_formatter.py +639 -0
  29. modules/im/formatters/slack_formatter.py +127 -0
  30. modules/im/slack.py +2091 -0
  31. modules/session_manager.py +138 -0
  32. modules/settings_manager.py +587 -0
  33. vibe/__init__.py +6 -0
  34. vibe/__main__.py +12 -0
  35. vibe/_version.py +34 -0
  36. vibe/api.py +412 -0
  37. vibe/cli.py +637 -0
  38. vibe/runtime.py +213 -0
  39. vibe/service_main.py +101 -0
  40. vibe/templates/slack_manifest.json +65 -0
  41. vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
  42. vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
  43. vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
  44. vibe/ui/dist/index.html +17 -0
  45. vibe/ui/dist/logo.png +0 -0
  46. vibe/ui/dist/vite.svg +1 -0
  47. vibe/ui_server.py +346 -0
  48. vibe_remote-2.1.6.dist-info/METADATA +295 -0
  49. vibe_remote-2.1.6.dist-info/RECORD +52 -0
  50. vibe_remote-2.1.6.dist-info/WHEEL +4 -0
  51. vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
  52. vibe_remote-2.1.6.dist-info/licenses/LICENSE +21 -0
modules/im/slack.py ADDED
@@ -0,0 +1,2091 @@
1
+ import asyncio
2
+ import asyncio
3
+ import hashlib
4
+ import logging
5
+ import time
6
+ from typing import Dict, Any, Optional, Callable
7
+ from slack_sdk.web.async_client import AsyncWebClient
8
+ from slack_sdk.socket_mode.aiohttp import SocketModeClient
9
+ from slack_sdk.socket_mode.request import SocketModeRequest
10
+ from slack_sdk.socket_mode.response import SocketModeResponse
11
+ from slack_sdk.errors import SlackApiError
12
+ from markdown_to_mrkdwn import SlackMarkdownConverter
13
+
14
+ from .base import BaseIMClient, MessageContext, InlineKeyboard, InlineButton
15
+ from config.v2_config import SlackConfig
16
+ from .formatters import SlackFormatter
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _UNSET = object()
21
+
22
+
23
+ class SlackBot(BaseIMClient):
24
+ """Slack implementation of the IM client"""
25
+
26
+ def __init__(self, config: SlackConfig):
27
+ super().__init__(config)
28
+ self.config = config
29
+ self.web_client: Optional[AsyncWebClient] = None
30
+ self.socket_client: Optional[SocketModeClient] = None
31
+
32
+ # Initialize Slack formatter
33
+ self.formatter = SlackFormatter()
34
+
35
+ # Initialize markdown to mrkdwn converter
36
+ self.markdown_converter = SlackMarkdownConverter()
37
+
38
+ # Note: Thread handling now uses user's message timestamp directly
39
+
40
+ # Store callback handlers
41
+ self.command_handlers: Dict[str, Callable] = {}
42
+ self.slash_command_handlers: Dict[str, Callable] = {}
43
+
44
+ # Store trigger IDs for modal interactions
45
+ self.trigger_ids: Dict[str, str] = {}
46
+
47
+ # Settings manager for thread tracking (will be injected later)
48
+ self.settings_manager = None
49
+ self._recent_event_ids: Dict[str, float] = {}
50
+ self._stop_event: Optional[asyncio.Event] = None
51
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
52
+
53
+ def set_settings_manager(self, settings_manager):
54
+ """Set the settings manager for thread tracking"""
55
+ self.settings_manager = settings_manager
56
+
57
+ def _is_duplicate_event(self, event_id: Optional[str]) -> bool:
58
+ """Deduplicate Slack events using event_id with a short TTL."""
59
+ if not event_id:
60
+ return False
61
+ now = time.time()
62
+ expiry = now - 30 # retain for 30s
63
+ for key in list(self._recent_event_ids.keys()):
64
+ if self._recent_event_ids[key] < expiry:
65
+ del self._recent_event_ids[key]
66
+ if event_id in self._recent_event_ids:
67
+ logger.debug(f"Ignoring duplicate Slack event_id {event_id}")
68
+ return True
69
+ self._recent_event_ids[event_id] = now
70
+ return False
71
+
72
+ def get_default_parse_mode(self) -> str:
73
+ """Get the default parse mode for Slack"""
74
+ return "markdown"
75
+
76
+ def should_use_thread_for_reply(self) -> bool:
77
+ """Slack uses threads for replies"""
78
+ return True
79
+
80
+ def _ensure_clients(self):
81
+ """Ensure web and socket clients are initialized"""
82
+ if self.web_client is None:
83
+ self.web_client = AsyncWebClient(token=self.config.bot_token)
84
+
85
+ if self.socket_client is None and self.config.app_token:
86
+ self.socket_client = SocketModeClient(
87
+ app_token=self.config.app_token, web_client=self.web_client
88
+ )
89
+
90
+ def _convert_markdown_to_slack_mrkdwn(self, text: str) -> str:
91
+ """Convert standard markdown to Slack mrkdwn format using third-party library
92
+
93
+ Uses markdown-to-mrkdwn library for comprehensive conversion including:
94
+ - Bold: ** to *
95
+ - Italic: * to _
96
+ - Strikethrough: ~~ to ~
97
+ - Code blocks: ``` preserved
98
+ - Inline code: ` preserved
99
+ - Links: [text](url) to <url|text>
100
+ - Headers, lists, quotes, and more
101
+ """
102
+ try:
103
+ # Use the third-party converter for comprehensive markdown to mrkdwn conversion
104
+ converted_text = self.markdown_converter.convert(text)
105
+ return converted_text
106
+ except Exception as e:
107
+ logger.warning(
108
+ f"Error converting markdown to mrkdwn: {e}, using original text"
109
+ )
110
+ # Fallback to original text if conversion fails
111
+ return text
112
+
113
+ async def send_message(
114
+ self,
115
+ context: MessageContext,
116
+ text: str,
117
+ parse_mode: Optional[str] = None,
118
+ reply_to: Optional[str] = None,
119
+ ) -> str:
120
+ """Send a message to Slack"""
121
+ self._ensure_clients()
122
+ try:
123
+ if not text:
124
+ raise ValueError("Slack send_message requires non-empty text")
125
+ # Convert markdown to Slack mrkdwn if needed
126
+ if parse_mode == "markdown":
127
+ text = self._convert_markdown_to_slack_mrkdwn(text)
128
+
129
+ # Prepare message kwargs
130
+ kwargs = {"channel": context.channel_id, "text": text}
131
+
132
+ # Handle thread replies
133
+ if context.thread_id:
134
+ kwargs["thread_ts"] = context.thread_id
135
+ # Optionally broadcast to channel
136
+ if context.platform_specific and context.platform_specific.get(
137
+ "reply_broadcast"
138
+ ):
139
+ kwargs["reply_broadcast"] = True
140
+ elif reply_to:
141
+ # If reply_to is specified, use it as thread timestamp
142
+ kwargs["thread_ts"] = reply_to
143
+
144
+ # Handle formatting
145
+ if parse_mode == "markdown":
146
+ kwargs["mrkdwn"] = True
147
+
148
+ # Workaround: ensure multi-line content is preserved. Slack sometimes collapses
149
+ # rich_text rendering for bot messages; sending with blocks+mrkdwn forces line breaks.
150
+ if "\n" in text and "blocks" not in kwargs and len(text) <= 3000:
151
+ kwargs["blocks"] = [
152
+ {
153
+ "type": "section",
154
+ "text": {
155
+ "type": "mrkdwn" if parse_mode == "markdown" else "plain_text",
156
+ "text": text,
157
+ },
158
+ }
159
+ ]
160
+
161
+ # Send message
162
+ response = await self.web_client.chat_postMessage(**kwargs)
163
+
164
+ # Mark thread as active if we sent a message to a thread
165
+ if self.settings_manager and (context.thread_id or reply_to):
166
+ thread_ts = context.thread_id or reply_to
167
+ self.settings_manager.mark_thread_active(
168
+ context.user_id, context.channel_id, thread_ts
169
+ )
170
+ logger.debug(f"Marked thread {thread_ts} as active after bot message")
171
+
172
+ return response["ts"]
173
+
174
+ except SlackApiError as e:
175
+ logger.error(f"Error sending Slack message: {e}")
176
+ raise
177
+
178
+ async def upload_markdown(
179
+ self,
180
+ context: MessageContext,
181
+ title: str,
182
+ content: str,
183
+ filetype: str = "markdown",
184
+ ) -> str:
185
+ self._ensure_clients()
186
+ data = content or ""
187
+ result = await self.web_client.files_upload_v2(
188
+ channel=context.channel_id,
189
+ thread_ts=context.thread_id,
190
+ filename=title,
191
+ title=title,
192
+ content=data,
193
+ )
194
+ file_id = result.get("file", {}).get("id")
195
+ if not file_id:
196
+ file_id = result.get("files", [{}])[0].get("id")
197
+ return file_id or ""
198
+
199
+ async def add_reaction(self, context: MessageContext, message_id: str, emoji: str) -> bool:
200
+ """Add a reaction emoji to a Slack message."""
201
+ self._ensure_clients()
202
+
203
+ name = (emoji or "").strip()
204
+ if name.startswith(":") and name.endswith(":") and len(name) > 2:
205
+ name = name[1:-1]
206
+ if name in ["👀", "eyes", "eye"]:
207
+ name = "eyes"
208
+
209
+ if not name:
210
+ return False
211
+
212
+ try:
213
+ await self.web_client.reactions_add(
214
+ channel=context.channel_id,
215
+ timestamp=message_id,
216
+ name=name,
217
+ )
218
+ return True
219
+ except SlackApiError as err:
220
+ try:
221
+ if getattr(err, "response", None) and err.response.get("error") == "already_reacted":
222
+ return True
223
+ except Exception:
224
+ pass
225
+
226
+ error_code = None
227
+ needed = None
228
+ try:
229
+ if getattr(err, "response", None):
230
+ error_code = err.response.get("error")
231
+ needed = err.response.get("needed")
232
+ except Exception:
233
+ pass
234
+
235
+ # NOTE: reaction failures were previously DEBUG-only; surface at INFO/WARN for operability.
236
+ if error_code in ["missing_scope", "not_in_channel", "channel_not_found"]:
237
+ logger.warning(
238
+ f"Slack reaction add failed: error={error_code}, needed={needed}"
239
+ )
240
+ else:
241
+ logger.info(f"Slack reaction add failed: {err}")
242
+ return False
243
+ except Exception as err:
244
+ logger.debug(f"Failed to add Slack reaction: {err}")
245
+ return False
246
+
247
+ async def remove_reaction(self, context: MessageContext, message_id: str, emoji: str) -> bool:
248
+ """Remove a reaction emoji from a Slack message."""
249
+ self._ensure_clients()
250
+
251
+ name = (emoji or "").strip()
252
+ if name.startswith(":") and name.endswith(":") and len(name) > 2:
253
+ name = name[1:-1]
254
+ if name in ["👀", "eyes", "eye"]:
255
+ name = "eyes"
256
+
257
+ if not name:
258
+ return False
259
+
260
+ try:
261
+ await self.web_client.reactions_remove(
262
+ channel=context.channel_id,
263
+ timestamp=message_id,
264
+ name=name,
265
+ )
266
+ return True
267
+ except SlackApiError as err:
268
+ logger.debug(f"Failed to remove Slack reaction: {err}")
269
+ return False
270
+ except Exception as err:
271
+ logger.debug(f"Failed to remove Slack reaction: {err}")
272
+ return False
273
+
274
+ async def send_message_with_buttons(
275
+ self,
276
+ context: MessageContext,
277
+ text: str,
278
+ keyboard: InlineKeyboard,
279
+ parse_mode: Optional[str] = None,
280
+ ) -> str:
281
+ """Send a message with interactive buttons"""
282
+ self._ensure_clients()
283
+ try:
284
+ # Default to markdown for Slack if not specified
285
+ if not parse_mode:
286
+ parse_mode = "markdown"
287
+
288
+ # Convert markdown to Slack mrkdwn if needed
289
+ if parse_mode == "markdown":
290
+ text = self._convert_markdown_to_slack_mrkdwn(text)
291
+
292
+ # Convert our generic keyboard to Slack blocks
293
+ blocks = [
294
+ {
295
+ "type": "section",
296
+ "text": {
297
+ "type": "mrkdwn" if parse_mode == "markdown" else "plain_text",
298
+ "text": text,
299
+ "verbatim": True,
300
+ },
301
+ }
302
+ ]
303
+
304
+ # Add action blocks for buttons
305
+ for row_idx, row in enumerate(keyboard.buttons):
306
+ elements = []
307
+ for button in row:
308
+ elements.append(
309
+ {
310
+ "type": "button",
311
+ "text": {"type": "plain_text", "text": button.text},
312
+ "action_id": button.callback_data,
313
+ "value": button.callback_data,
314
+ }
315
+ )
316
+
317
+ blocks.append(
318
+ {
319
+ "type": "actions",
320
+ "block_id": f"actions_{row_idx}",
321
+ "elements": elements,
322
+ }
323
+ )
324
+
325
+ # Prepare message kwargs
326
+ kwargs = {
327
+ "channel": context.channel_id,
328
+ "blocks": blocks,
329
+ "text": text, # Fallback text
330
+ }
331
+
332
+ # Handle thread replies
333
+ if context.thread_id:
334
+ kwargs["thread_ts"] = context.thread_id
335
+
336
+ response = await self.web_client.chat_postMessage(**kwargs)
337
+
338
+ # Mark thread as active if we sent a message to a thread
339
+ if self.settings_manager and context.thread_id:
340
+ self.settings_manager.mark_thread_active(
341
+ context.user_id, context.channel_id, context.thread_id
342
+ )
343
+ logger.debug(f"Marked thread {context.thread_id} as active after bot message with buttons")
344
+
345
+ return response["ts"]
346
+
347
+ except SlackApiError as e:
348
+ logger.error(f"Error sending Slack message with buttons: {e}")
349
+ raise
350
+
351
+ async def edit_message(
352
+ self,
353
+ context: MessageContext,
354
+ message_id: str,
355
+ text: Optional[str] = None,
356
+ keyboard: Optional[InlineKeyboard] = None,
357
+ parse_mode: Optional[str] = None,
358
+ ) -> bool:
359
+ """Edit an existing Slack message"""
360
+ self._ensure_clients()
361
+ try:
362
+ if text and parse_mode == "markdown":
363
+ text = self._convert_markdown_to_slack_mrkdwn(text)
364
+
365
+ kwargs = {"channel": context.channel_id, "ts": message_id}
366
+
367
+ if text is not None:
368
+ kwargs["text"] = text
369
+
370
+ if keyboard:
371
+ # Convert keyboard to blocks (similar to send_message_with_buttons)
372
+ blocks = []
373
+ if text:
374
+ blocks.append(
375
+ {
376
+ "type": "section",
377
+ "text": {
378
+ "type": "mrkdwn" if parse_mode == "markdown" else "plain_text",
379
+ "text": text,
380
+ },
381
+ }
382
+ )
383
+
384
+ for row_idx, row in enumerate(keyboard.buttons):
385
+ elements = []
386
+ for button in row:
387
+ elements.append(
388
+ {
389
+ "type": "button",
390
+ "text": {"type": "plain_text", "text": button.text},
391
+ "action_id": button.callback_data,
392
+ "value": button.callback_data,
393
+ }
394
+ )
395
+
396
+ blocks.append(
397
+ {
398
+ "type": "actions",
399
+ "block_id": f"actions_{row_idx}",
400
+ "elements": elements,
401
+ }
402
+ )
403
+
404
+ kwargs["blocks"] = blocks
405
+
406
+ await self.web_client.chat_update(**kwargs)
407
+ return True
408
+
409
+ except SlackApiError as e:
410
+ logger.error(f"Error editing Slack message: {e}")
411
+ return False
412
+
413
+ async def remove_inline_keyboard(
414
+ self,
415
+ context: MessageContext,
416
+ message_id: str,
417
+ text: Optional[str] = None,
418
+ parse_mode: Optional[str] = None,
419
+ ) -> bool:
420
+ """Remove interactive buttons from a Slack message."""
421
+ self._ensure_clients()
422
+ try:
423
+ blocks = []
424
+ fallback_text = text
425
+ if fallback_text is not None and parse_mode == "markdown":
426
+ fallback_text = self._convert_markdown_to_slack_mrkdwn(fallback_text)
427
+
428
+ if fallback_text:
429
+ blocks = [
430
+ {
431
+ "type": "section",
432
+ "text": {
433
+ "type": "mrkdwn" if parse_mode == "markdown" else "plain_text",
434
+ "text": fallback_text,
435
+ },
436
+ }
437
+ ]
438
+
439
+ kwargs = {"channel": context.channel_id, "ts": message_id, "blocks": blocks}
440
+ if fallback_text is not None:
441
+ kwargs["text"] = fallback_text
442
+ await self.web_client.chat_update(**kwargs)
443
+ return True
444
+ except SlackApiError as e:
445
+ logger.error(f"Error removing Slack buttons: {e}")
446
+ return False
447
+
448
+ async def answer_callback(
449
+ self, callback_id: str, text: Optional[str] = None, show_alert: bool = False
450
+ ) -> bool:
451
+ """Answer a Slack interactive callback"""
452
+ # Slack does not have a direct equivalent to answer_callback_query
453
+ # Instead, we typically update the message or send an ephemeral message
454
+ # This will be handled in the event processing
455
+ return True
456
+
457
+ def register_handlers(self):
458
+ """Register Slack event handlers"""
459
+ if not self.socket_client:
460
+ logger.warning(
461
+ "Socket mode client not configured, skipping handler registration"
462
+ )
463
+ return
464
+
465
+ # Register socket mode request handler
466
+ self.socket_client.socket_mode_request_listeners.append(
467
+ self._handle_socket_mode_request
468
+ )
469
+
470
+ async def _handle_socket_mode_request(
471
+ self, client: SocketModeClient, req: SocketModeRequest
472
+ ):
473
+ """Handle incoming Socket Mode requests"""
474
+ try:
475
+ if req.type == "events_api":
476
+ # Handle Events API events
477
+ await self._handle_event(req.payload)
478
+ elif req.type == "slash_commands":
479
+ # Handle slash commands
480
+ await self._handle_slash_command(req.payload)
481
+ elif req.type == "interactive":
482
+ # Handle interactive components (buttons, etc.)
483
+ await self._handle_interactive(req.payload)
484
+
485
+ # Acknowledge the request
486
+ response = SocketModeResponse(envelope_id=req.envelope_id)
487
+ await client.send_socket_mode_response(response)
488
+
489
+ except Exception as e:
490
+ logger.error(f"Error handling socket mode request: {e}")
491
+ # Still acknowledge even on error
492
+ response = SocketModeResponse(envelope_id=req.envelope_id)
493
+ await client.send_socket_mode_response(response)
494
+
495
+ async def _handle_event(self, payload: Dict[str, Any]):
496
+ """Handle Events API events"""
497
+ event = payload.get("event", {})
498
+ event_type = event.get("type")
499
+ event_id = payload.get("event_id")
500
+ if self._is_duplicate_event(event_id):
501
+ return
502
+
503
+ if event_type == "message":
504
+ # Ignore bot messages
505
+ if event.get("bot_id"):
506
+ return
507
+
508
+ # Ignore message subtypes (edited, deleted, joins, etc.)
509
+ # We only process plain user messages without subtype
510
+ event_subtype = event.get("subtype")
511
+ if event_subtype:
512
+ logger.debug(f"Ignoring Slack message with subtype: {event_subtype}")
513
+ return
514
+
515
+ channel_id = event.get("channel")
516
+
517
+ # Check if this message contains a bot mention
518
+ # If it does, skip processing as it will be handled by app_mention event
519
+ text = (event.get("text") or "").strip()
520
+ import re
521
+
522
+ if re.search(r"<@[\w]+>", text):
523
+ logger.info(f"Skipping message event with bot mention: '{text}'")
524
+ return
525
+
526
+ # Ignore messages without user or without actual text
527
+ user_id = event.get("user")
528
+ if not user_id:
529
+ logger.debug("Ignoring Slack message without user id")
530
+ return
531
+ if not text:
532
+ logger.debug("Ignoring Slack message with empty text")
533
+ return
534
+
535
+ # Check if we require mention in channels (not DMs)
536
+ # For threads: only respond if the bot is active in that thread
537
+ is_thread_reply = event.get("thread_ts") is not None
538
+
539
+ # Resolve effective require_mention: per-channel override or global default
540
+ effective_require_mention = self.config.require_mention
541
+ if self.settings_manager:
542
+ effective_require_mention = self.settings_manager.get_require_mention(
543
+ channel_id, global_default=self.config.require_mention
544
+ )
545
+
546
+ if effective_require_mention and not channel_id.startswith("D"):
547
+ # In channel main thread: require mention (silently ignore)
548
+ if not is_thread_reply:
549
+ logger.debug(f"Ignoring non-mention message in channel: '{text}'")
550
+ return
551
+
552
+ # In thread: check if bot is active in this thread
553
+ if is_thread_reply:
554
+ thread_ts = event.get("thread_ts")
555
+ # If we have settings_manager, check if thread is active
556
+ if self.settings_manager:
557
+ if not self.settings_manager.is_thread_active(user_id, channel_id, thread_ts):
558
+ logger.debug(f"Ignoring message in inactive thread {thread_ts}: '{text}'")
559
+ return
560
+ else:
561
+ # Without settings_manager, fall back to ignoring non-mention in threads
562
+ logger.debug(f"No settings_manager, ignoring thread message: '{text}'")
563
+ return
564
+
565
+ # Only check channel authorization for messages we're actually going to process
566
+ if not await self._is_authorized_channel(channel_id):
567
+ logger.info(f"Unauthorized message from channel: {channel_id}")
568
+ await self._send_unauthorized_message(channel_id)
569
+ return
570
+
571
+ # Extract context
572
+ # For Slack: if no thread_ts, use the message's own ts as thread_id (start of thread)
573
+ thread_id = event.get("thread_ts") or event.get("ts")
574
+
575
+ context = MessageContext(
576
+ user_id=user_id,
577
+ channel_id=channel_id,
578
+ thread_id=thread_id, # Always have a thread_id
579
+ message_id=event.get("ts"),
580
+ platform_specific={"team_id": payload.get("team_id"), "event": event},
581
+ )
582
+
583
+ # Handle slash commands in regular messages
584
+ if text.startswith("/"):
585
+ parts = text.split(maxsplit=1)
586
+ command = parts[0][1:] # Remove the /
587
+ args = parts[1] if len(parts) > 1 else ""
588
+
589
+ if command in self.on_command_callbacks:
590
+ handler = self.on_command_callbacks[command]
591
+ await handler(context, args)
592
+ return
593
+
594
+ # Handle as regular message
595
+ if self.on_message_callback:
596
+ await self.on_message_callback(context, text)
597
+
598
+ elif event_type == "app_mention":
599
+ # Handle @mentions
600
+ channel_id = event.get("channel")
601
+
602
+ # Check if channel is authorized based on whitelist
603
+ if not await self._is_authorized_channel(channel_id):
604
+ logger.info(f"Unauthorized mention from channel: {channel_id}")
605
+ await self._send_unauthorized_message(channel_id)
606
+ return
607
+
608
+ # For Slack: if no thread_ts, use the message's own ts as thread_id (start of thread)
609
+ thread_id = event.get("thread_ts") or event.get("ts")
610
+
611
+ context = MessageContext(
612
+ user_id=event.get("user"),
613
+ channel_id=channel_id,
614
+ thread_id=thread_id, # Always have a thread_id
615
+ message_id=event.get("ts"),
616
+ platform_specific={"team_id": payload.get("team_id"), "event": event},
617
+ )
618
+
619
+ # Mark thread as active when bot is @mentioned
620
+ if self.settings_manager and thread_id:
621
+ self.settings_manager.mark_thread_active(
622
+ event.get("user"), channel_id, thread_id
623
+ )
624
+ logger.info(f"Marked thread {thread_id} as active due to @mention")
625
+
626
+ # Remove the mention from the text
627
+ text = event.get("text", "")
628
+ import re
629
+
630
+ text = re.sub(r"<@[\w]+>", "", text).strip()
631
+
632
+ logger.info(
633
+ f"App mention processed: original='{event.get('text')}', cleaned='{text}'"
634
+ )
635
+
636
+ # Check if this is a command after mention
637
+ if text.startswith("/"):
638
+ parts = text.split(maxsplit=1)
639
+ command = parts[0][1:] # Remove the /
640
+ args = parts[1] if len(parts) > 1 else ""
641
+
642
+ logger.info(
643
+ f"Command detected: '{command}', available: {list(self.on_command_callbacks.keys())}"
644
+ )
645
+
646
+ if command in self.on_command_callbacks:
647
+ logger.info(f"Executing command handler for: {command}")
648
+ handler = self.on_command_callbacks[command]
649
+ await handler(context, args)
650
+ return
651
+ else:
652
+ logger.warning(f"Command '{command}' not found in callbacks")
653
+
654
+ # Handle as regular message
655
+ logger.info(f"Handling as regular message: '{text}'")
656
+ if self.on_message_callback:
657
+ await self.on_message_callback(context, text)
658
+
659
+ async def _handle_slash_command(self, payload: Dict[str, Any]):
660
+ """Handle native Slack slash commands"""
661
+ command = payload.get("command", "").lstrip("/")
662
+ channel_id = payload.get("channel_id")
663
+
664
+ # Check if channel is authorized based on whitelist
665
+ if not await self._is_authorized_channel(channel_id):
666
+ logger.info(f"Unauthorized slash command from channel: {channel_id}")
667
+ # Send a response to user about unauthorized channel
668
+ response_url = payload.get("response_url")
669
+ if response_url:
670
+ await self.send_slash_response(
671
+ response_url,
672
+ "❌ This channel is not enabled. Please go to the control panel to enable it.",
673
+ )
674
+ return
675
+
676
+ # Map Slack slash commands to internal commands
677
+ # Only /start and /stop commands are exposed to users
678
+ command_mapping = {"start": "start", "stop": "stop"}
679
+
680
+ # Get the actual command name
681
+ actual_command = command_mapping.get(command, command)
682
+
683
+ # Create context for slash command
684
+ context = MessageContext(
685
+ user_id=payload.get("user_id"),
686
+ channel_id=payload.get("channel_id"),
687
+ platform_specific={
688
+ "trigger_id": payload.get("trigger_id"),
689
+ "response_url": payload.get("response_url"),
690
+ "command": command,
691
+ "text": payload.get("text"),
692
+ "payload": payload,
693
+ },
694
+ )
695
+
696
+ # Send immediate acknowledgment to Slack
697
+ response_url = payload.get("response_url")
698
+
699
+ # Try to handle as registered command
700
+ if actual_command in self.on_command_callbacks:
701
+ handler = self.on_command_callbacks[actual_command]
702
+
703
+ # Send immediate "processing" response for long-running commands
704
+ if response_url and actual_command not in [
705
+ "start",
706
+ "status",
707
+ "clear",
708
+ "cwd",
709
+ "queue",
710
+ ]:
711
+ await self.send_slash_response(
712
+ response_url, f"⏳ Processing `/{command}`..."
713
+ )
714
+
715
+ await handler(context, payload.get("text", ""))
716
+ elif actual_command in self.slash_command_handlers:
717
+ handler = self.slash_command_handlers[actual_command]
718
+ await handler(context, payload.get("text", ""))
719
+ else:
720
+ # Send response back to Slack for unknown command
721
+ if response_url:
722
+ await self.send_slash_response(
723
+ response_url,
724
+ f"❌ Unknown command: `/{command}`\n\nPlease use `@Vibe Remote /start` to access all bot features.",
725
+ )
726
+
727
+ async def _handle_interactive(self, payload: Dict[str, Any]):
728
+ """Handle interactive components (buttons, modal submissions, etc.)"""
729
+ if payload.get("type") == "block_actions":
730
+ # Handle button clicks / select changes
731
+ user = payload.get("user", {})
732
+ actions = payload.get("actions", [])
733
+ view = payload.get("view", {})
734
+
735
+ # In Slack modals, `channel` is often missing. We store the originating
736
+ # channel_id in `view.private_metadata` when opening the modal.
737
+ channel_id = (
738
+ payload.get("channel", {}).get("id")
739
+ or payload.get("container", {}).get("channel_id")
740
+ or (view.get("private_metadata") if isinstance(view, dict) else None)
741
+ )
742
+
743
+ # Check if channel is authorized for interactive components
744
+ if not await self._is_authorized_channel(channel_id):
745
+ logger.info(
746
+ f"Unauthorized interactive action from channel: {channel_id}"
747
+ )
748
+ try:
749
+ await self._send_unauthorized_message(channel_id)
750
+ except Exception:
751
+ pass
752
+ return
753
+
754
+ view = payload.get("view", {})
755
+ for action in actions:
756
+ action_type = action.get("type")
757
+ if action_type == "button":
758
+ callback_data = action.get("action_id")
759
+
760
+ if self.on_callback_query_callback:
761
+ thread_id = (
762
+ payload.get("container", {}).get("thread_ts")
763
+ or payload.get("message", {}).get("thread_ts")
764
+ or payload.get("message", {}).get("ts")
765
+ )
766
+ # Create a context for the callback
767
+ context = MessageContext(
768
+ user_id=user.get("id"),
769
+ channel_id=channel_id,
770
+ thread_id=thread_id,
771
+ message_id=payload.get("message", {}).get("ts"),
772
+ platform_specific={
773
+ "trigger_id": payload.get("trigger_id"),
774
+ "response_url": payload.get("response_url"),
775
+ "action": action,
776
+ "payload": payload,
777
+ },
778
+ )
779
+
780
+ await self.on_callback_query_callback(context, callback_data)
781
+ elif action_type in {"static_select", "external_select"}:
782
+ action_id = action.get("action_id")
783
+ if action_id in {"opencode_agent_select", "opencode_model_select"}:
784
+ if hasattr(self, "_on_routing_modal_update"):
785
+ channel_from_view = view.get("private_metadata")
786
+ await self._on_routing_modal_update(
787
+ user.get("id"),
788
+ channel_from_view or channel_id,
789
+ view,
790
+ action,
791
+ )
792
+
793
+ elif payload.get("type") == "view_submission":
794
+ # Handle modal submissions asynchronously to avoid Slack timeouts
795
+ asyncio.create_task(self._handle_view_submission(payload))
796
+ return
797
+
798
+ async def _handle_view_submission(self, payload: Dict[str, Any]):
799
+ """Handle modal dialog submissions"""
800
+ view = payload.get("view", {})
801
+ callback_id = view.get("callback_id")
802
+
803
+ if callback_id == "settings_modal":
804
+ # Handle settings modal submission
805
+ user_id = payload.get("user", {}).get("id")
806
+ values = view.get("state", {}).get("values", {})
807
+
808
+ # Extract selected show message types
809
+ show_types_data = values.get("show_message_types", {}).get(
810
+ "show_types_select", {}
811
+ )
812
+ selected_options = show_types_data.get("selected_options", [])
813
+
814
+ # Get the values from selected options
815
+ show_types = [opt.get("value") for opt in selected_options]
816
+
817
+ # Extract require_mention setting
818
+ require_mention_data = values.get("require_mention_block", {}).get(
819
+ "require_mention_select", {}
820
+ )
821
+ require_mention_value = require_mention_data.get("selected_option", {}).get("value")
822
+ # Convert to Optional[bool]: "__default__" -> None, "true" -> True, "false" -> False
823
+ if require_mention_value == "__default__":
824
+ require_mention = None
825
+ elif require_mention_value == "true":
826
+ require_mention = True
827
+ elif require_mention_value == "false":
828
+ require_mention = False
829
+ else:
830
+ require_mention = None
831
+
832
+ # Get channel_id from the view's private_metadata if available
833
+ channel_id = view.get("private_metadata")
834
+
835
+ # Update settings - need access to settings manager
836
+ if hasattr(self, "_on_settings_update"):
837
+ await self._on_settings_update(user_id, show_types, channel_id, require_mention)
838
+
839
+ elif callback_id == "change_cwd_modal":
840
+ # Handle change CWD modal submission
841
+ user_id = payload.get("user", {}).get("id")
842
+ values = view.get("state", {}).get("values", {})
843
+
844
+ # Extract new CWD path
845
+ new_cwd_data = values.get("new_cwd_block", {}).get("new_cwd_input", {})
846
+ new_cwd = new_cwd_data.get("value", "")
847
+
848
+ # Get channel_id from private_metadata
849
+ channel_id = view.get("private_metadata")
850
+
851
+ # Update CWD - need access to controller or settings manager
852
+ if hasattr(self, "_on_change_cwd"):
853
+ await self._on_change_cwd(user_id, new_cwd, channel_id)
854
+
855
+ # Send success message to the user (via DM or channel)
856
+ # We need to find the right channel to send the message
857
+ # For now, we'll rely on the controller to handle this
858
+
859
+ elif callback_id == "opencode_question_modal":
860
+ user_id = payload.get("user", {}).get("id")
861
+ values = view.get("state", {}).get("values", {})
862
+ metadata_raw = view.get("private_metadata")
863
+
864
+ try:
865
+ import json
866
+
867
+ metadata = json.loads(metadata_raw) if metadata_raw else {}
868
+ except Exception:
869
+ metadata = {}
870
+
871
+ channel_id = metadata.get("channel_id")
872
+ thread_id = metadata.get("thread_id")
873
+
874
+ answers = []
875
+ q_count = int(metadata.get("question_count") or 1)
876
+ for idx in range(q_count):
877
+ block_id = f"q{idx}"
878
+ action_id = "select"
879
+ data = values.get(block_id, {}).get(action_id, {})
880
+ selected_options = data.get("selected_options")
881
+ if isinstance(selected_options, list):
882
+ answers.append([opt.get("value") for opt in selected_options if opt.get("value")])
883
+ else:
884
+ selected = data.get("selected_option")
885
+ if selected and selected.get("value") is not None:
886
+ answers.append([str(selected.get("value"))])
887
+ else:
888
+ answers.append([])
889
+
890
+ if self.on_callback_query_callback:
891
+ context = MessageContext(
892
+ user_id=user_id,
893
+ channel_id=str(channel_id) if channel_id else "",
894
+ thread_id=str(thread_id) if thread_id else None,
895
+ platform_specific={"payload": payload},
896
+ )
897
+ await self.on_callback_query_callback(
898
+ context,
899
+ "opencode_question:modal:" + json.dumps({"answers": answers}),
900
+ )
901
+
902
+ elif callback_id == "routing_modal":
903
+ # Handle routing modal submission
904
+ user_id = payload.get("user", {}).get("id")
905
+ values = view.get("state", {}).get("values", {})
906
+ channel_id = view.get("private_metadata")
907
+
908
+ # Extract backend
909
+ backend_data = values.get("backend_block", {}).get("backend_select", {})
910
+ backend = backend_data.get("selected_option", {}).get("value")
911
+
912
+ # Extract OpenCode agent (optional)
913
+ oc_agent_data = values.get("opencode_agent_block", {}).get(
914
+ "opencode_agent_select", {}
915
+ )
916
+ oc_agent = oc_agent_data.get("selected_option", {}).get("value")
917
+ if oc_agent == "__default__":
918
+ oc_agent = None
919
+
920
+ # Extract OpenCode model (optional)
921
+ oc_model_data = values.get("opencode_model_block", {}).get(
922
+ "opencode_model_select", {}
923
+ )
924
+ oc_model = oc_model_data.get("selected_option", {}).get("value")
925
+ if oc_model == "__default__":
926
+ oc_model = None
927
+
928
+ # Extract OpenCode reasoning effort (optional)
929
+ oc_reasoning = None
930
+ reasoning_block = values.get("opencode_reasoning_block", {})
931
+ if isinstance(reasoning_block, dict):
932
+ for action_id, action_data in reasoning_block.items():
933
+ if (
934
+ isinstance(action_id, str)
935
+ and action_id.startswith("opencode_reasoning_select")
936
+ and isinstance(action_data, dict)
937
+ ):
938
+ oc_reasoning = action_data.get("selected_option", {}).get("value")
939
+ break
940
+ if oc_reasoning == "__default__":
941
+ oc_reasoning = None
942
+
943
+ # Extract require_mention (optional)
944
+ require_mention_data = values.get("require_mention_block", {}).get(
945
+ "require_mention_select", {}
946
+ )
947
+ require_mention_value = require_mention_data.get("selected_option", {}).get("value")
948
+ # Convert to Optional[bool]: "__default__" -> None, "true" -> True, "false" -> False
949
+ if require_mention_value == "__default__":
950
+ require_mention = None
951
+ elif require_mention_value == "true":
952
+ require_mention = True
953
+ elif require_mention_value == "false":
954
+ require_mention = False
955
+ else:
956
+ require_mention = None
957
+
958
+ # Update routing via callback
959
+ if hasattr(self, "_on_routing_update"):
960
+ await self._on_routing_update(
961
+ user_id, channel_id, backend, oc_agent, oc_model, oc_reasoning, require_mention
962
+ )
963
+
964
+ def run(self):
965
+ """Run the Slack bot"""
966
+ if self.config.app_token:
967
+ # Socket Mode
968
+ logger.info("Starting Slack bot in Socket Mode...")
969
+
970
+ async def start():
971
+ self._ensure_clients()
972
+ self.register_handlers()
973
+ self._loop = asyncio.get_running_loop()
974
+ self._stop_event = asyncio.Event()
975
+ await self.socket_client.connect()
976
+ await self._stop_event.wait()
977
+ await self._async_close()
978
+
979
+ asyncio.run(start())
980
+ else:
981
+ # Web API only mode (for development/testing)
982
+ logger.warning("No app token provided, running in Web API only mode")
983
+
984
+ async def start():
985
+ self._ensure_clients()
986
+ self._loop = asyncio.get_running_loop()
987
+ self._stop_event = asyncio.Event()
988
+ await self._stop_event.wait()
989
+ await self._async_close()
990
+
991
+ try:
992
+ asyncio.run(start())
993
+ except KeyboardInterrupt:
994
+ logger.info("Shutting down...")
995
+
996
+ def stop(self) -> None:
997
+ if self._stop_event is None:
998
+ return
999
+ if self._loop and self._loop.is_running():
1000
+ self._loop.call_soon_threadsafe(self._stop_event.set)
1001
+ else:
1002
+ self._stop_event.set()
1003
+
1004
+ async def shutdown(self) -> None:
1005
+ """Best-effort async shutdown for Slack clients."""
1006
+ if self._stop_event is not None:
1007
+ self._stop_event.set()
1008
+
1009
+ try:
1010
+ current_loop = asyncio.get_running_loop()
1011
+ except RuntimeError:
1012
+ current_loop = None
1013
+
1014
+ if self._loop and self._loop.is_running() and self._loop is not current_loop:
1015
+ try:
1016
+ future = asyncio.run_coroutine_threadsafe(self._async_close(), self._loop)
1017
+ future.result(timeout=5)
1018
+ except Exception as exc:
1019
+ logger.debug(f"Slack shutdown dispatch failed: {exc}")
1020
+ return
1021
+
1022
+ await self._async_close()
1023
+
1024
+ async def _async_close(self) -> None:
1025
+ if self.socket_client is not None:
1026
+ try:
1027
+ disconnect = getattr(self.socket_client, "disconnect", None)
1028
+ if callable(disconnect):
1029
+ result = disconnect()
1030
+ if asyncio.iscoroutine(result):
1031
+ await result
1032
+ except Exception as exc:
1033
+ logger.debug(f"Socket mode disconnect failed: {exc}")
1034
+ try:
1035
+ close = getattr(self.socket_client, "close", None)
1036
+ if callable(close):
1037
+ result = close()
1038
+ if asyncio.iscoroutine(result):
1039
+ await result
1040
+ except Exception as exc:
1041
+ logger.debug(f"Socket mode close failed: {exc}")
1042
+
1043
+ if self.web_client is not None:
1044
+ try:
1045
+ await self.web_client.close()
1046
+ except Exception as exc:
1047
+ logger.debug(f"Slack web client close failed: {exc}")
1048
+
1049
+ async def get_user_info(self, user_id: str) -> Dict[str, Any]:
1050
+ """Get information about a Slack user"""
1051
+ self._ensure_clients()
1052
+ try:
1053
+ response = await self.web_client.users_info(user=user_id)
1054
+ user = response["user"]
1055
+ return {
1056
+ "id": user["id"],
1057
+ "name": user.get("name"),
1058
+ "real_name": user.get("real_name"),
1059
+ "display_name": user.get("profile", {}).get("display_name"),
1060
+ "email": user.get("profile", {}).get("email"),
1061
+ "is_bot": user.get("is_bot", False),
1062
+ }
1063
+ except SlackApiError as e:
1064
+ logger.error(f"Error getting user info: {e}")
1065
+ raise
1066
+
1067
+ async def get_channel_info(self, channel_id: str) -> Dict[str, Any]:
1068
+ """Get information about a Slack channel"""
1069
+ self._ensure_clients()
1070
+ try:
1071
+ response = await self.web_client.conversations_info(channel=channel_id)
1072
+ channel = response["channel"]
1073
+ return {
1074
+ "id": channel["id"],
1075
+ "name": channel.get("name"),
1076
+ "is_private": channel.get("is_private", False),
1077
+ "is_im": channel.get("is_im", False),
1078
+ "is_channel": channel.get("is_channel", False),
1079
+ "topic": channel.get("topic", {}).get("value"),
1080
+ "purpose": channel.get("purpose", {}).get("value"),
1081
+ }
1082
+ except SlackApiError as e:
1083
+ logger.error(f"Error getting channel info: {e}")
1084
+ raise
1085
+
1086
+ def format_markdown(self, text: str) -> str:
1087
+ """Format markdown text for Slack mrkdwn format
1088
+
1089
+ Slack uses single asterisks for bold and different formatting rules
1090
+ """
1091
+ # Convert double asterisks to single for bold
1092
+ formatted = text.replace("**", "*")
1093
+
1094
+ # Convert inline code blocks (backticks work the same)
1095
+ # Lists work similarly
1096
+ # Links work similarly [text](url) -> <url|text>
1097
+ # But we'll keep simple for now - just handle bold
1098
+
1099
+ return formatted
1100
+
1101
+ async def open_settings_modal(
1102
+ self,
1103
+ trigger_id: str,
1104
+ user_settings: Any,
1105
+ message_types: list,
1106
+ display_names: dict,
1107
+ channel_id: str = None,
1108
+ current_require_mention: object = None, # None=default, True, False
1109
+ global_require_mention: bool = False,
1110
+ ):
1111
+ """Open a modal dialog for settings"""
1112
+ self._ensure_clients()
1113
+
1114
+ # Create options for the multi-select menu
1115
+ options = []
1116
+ selected_options = []
1117
+
1118
+ for msg_type in message_types:
1119
+ display_name = display_names.get(msg_type, msg_type)
1120
+ option = {
1121
+ "text": {"type": "plain_text", "text": display_name, "emoji": True},
1122
+ "value": msg_type,
1123
+ "description": {
1124
+ "type": "plain_text",
1125
+ "text": self._get_message_type_description(msg_type),
1126
+ "emoji": True,
1127
+ },
1128
+ }
1129
+ options.append(option)
1130
+
1131
+ # If this type is shown, add THE SAME option object to selected options
1132
+ if msg_type in user_settings.show_message_types:
1133
+ selected_options.append(option) # Same object reference!
1134
+
1135
+ logger.info(
1136
+ f"Creating modal with {len(options)} options, {len(selected_options)} selected"
1137
+ )
1138
+ logger.info(f"Show types: {user_settings.show_message_types}")
1139
+
1140
+ # Debug: Log the actual data being sent
1141
+ import json
1142
+
1143
+ logger.info(f"Options: {json.dumps(options, indent=2)}")
1144
+ logger.info(f"Selected options: {json.dumps(selected_options, indent=2)}")
1145
+
1146
+ # Create the multi-select element
1147
+ multi_select_element = {
1148
+ "type": "multi_static_select",
1149
+ "placeholder": {
1150
+ "type": "plain_text",
1151
+ "text": "Select message types to show",
1152
+ "emoji": True,
1153
+ },
1154
+ "options": options,
1155
+ "action_id": "show_types_select",
1156
+ }
1157
+
1158
+ # Only add initial_options if there are selected options
1159
+ if selected_options:
1160
+ multi_select_element["initial_options"] = selected_options
1161
+
1162
+ # Build require_mention selector
1163
+ global_mention_label = "On" if global_require_mention else "Off"
1164
+ require_mention_options = [
1165
+ {
1166
+ "text": {"type": "plain_text", "text": f"(Default) - {global_mention_label}"},
1167
+ "value": "__default__",
1168
+ },
1169
+ {
1170
+ "text": {"type": "plain_text", "text": "Require @mention"},
1171
+ "value": "true",
1172
+ },
1173
+ {
1174
+ "text": {"type": "plain_text", "text": "Don't require @mention"},
1175
+ "value": "false",
1176
+ },
1177
+ ]
1178
+
1179
+ # Determine initial option for require_mention
1180
+ initial_require_mention = require_mention_options[0] # Default
1181
+ if current_require_mention is not None:
1182
+ target_value = "true" if current_require_mention else "false"
1183
+ for opt in require_mention_options:
1184
+ if opt["value"] == target_value:
1185
+ initial_require_mention = opt
1186
+ break
1187
+
1188
+ require_mention_select = {
1189
+ "type": "static_select",
1190
+ "action_id": "require_mention_select",
1191
+ "placeholder": {"type": "plain_text", "text": "Select @mention behavior"},
1192
+ "options": require_mention_options,
1193
+ "initial_option": initial_require_mention,
1194
+ }
1195
+
1196
+ # Create the modal view
1197
+ view = {
1198
+ "type": "modal",
1199
+ "callback_id": "settings_modal",
1200
+ "private_metadata": channel_id or "", # Store channel_id for later use
1201
+ "title": {"type": "plain_text", "text": "Settings", "emoji": True},
1202
+ "submit": {"type": "plain_text", "text": "Save", "emoji": True},
1203
+ "close": {"type": "plain_text", "text": "Cancel", "emoji": True},
1204
+ "blocks": [
1205
+ {
1206
+ "type": "header",
1207
+ "text": {
1208
+ "type": "plain_text",
1209
+ "text": "Channel Behavior",
1210
+ "emoji": True,
1211
+ },
1212
+ },
1213
+ {
1214
+ "type": "input",
1215
+ "block_id": "require_mention_block",
1216
+ "element": require_mention_select,
1217
+ "label": {
1218
+ "type": "plain_text",
1219
+ "text": "Require @mention to respond",
1220
+ "emoji": True,
1221
+ },
1222
+ },
1223
+ {
1224
+ "type": "context",
1225
+ "elements": [
1226
+ {
1227
+ "type": "mrkdwn",
1228
+ "text": "_When enabled, the bot only responds when @mentioned in channels (DMs always work)._",
1229
+ }
1230
+ ],
1231
+ },
1232
+ {"type": "divider"},
1233
+ {
1234
+ "type": "header",
1235
+ "text": {
1236
+ "type": "plain_text",
1237
+ "text": "Message Visibility",
1238
+ "emoji": True,
1239
+ },
1240
+ },
1241
+ {
1242
+ "type": "section",
1243
+ "text": {
1244
+ "type": "mrkdwn",
1245
+ "text": "Choose which message types to *show* from agent output. Unselected types won't appear in your Slack workspace.",
1246
+ },
1247
+ },
1248
+ {
1249
+ "type": "input",
1250
+ "block_id": "show_message_types",
1251
+ "element": multi_select_element,
1252
+ "label": {
1253
+ "type": "plain_text",
1254
+ "text": "Show these message types:",
1255
+ "emoji": True,
1256
+ },
1257
+ "optional": True,
1258
+ },
1259
+ {
1260
+ "type": "context",
1261
+ "elements": [
1262
+ {
1263
+ "type": "mrkdwn",
1264
+ "text": "_💡 Tip: You can show/hide message types at any time. Changes apply immediately to new messages._",
1265
+ }
1266
+ ],
1267
+ },
1268
+ ],
1269
+ }
1270
+
1271
+ try:
1272
+ await self.web_client.views_open(trigger_id=trigger_id, view=view)
1273
+ except SlackApiError as e:
1274
+ logger.error(f"Error opening modal: {e}")
1275
+ raise
1276
+
1277
+ def _get_message_type_description(self, msg_type: str) -> str:
1278
+ """Get description for a message type"""
1279
+ descriptions = {
1280
+ "system": "System initialization and status messages",
1281
+ "toolcall": "Agent tool name + params (one line)",
1282
+ "assistant": "Agent responses and explanations",
1283
+ }
1284
+ return descriptions.get(msg_type, f"{msg_type} messages")
1285
+
1286
+ async def open_change_cwd_modal(
1287
+ self, trigger_id: str, current_cwd: str, channel_id: str = None
1288
+ ):
1289
+ """Open a modal dialog for changing working directory"""
1290
+ self._ensure_clients()
1291
+
1292
+ # Create the modal view
1293
+ view = {
1294
+ "type": "modal",
1295
+ "callback_id": "change_cwd_modal",
1296
+ "private_metadata": channel_id or "", # Store channel_id for later use
1297
+ "title": {
1298
+ "type": "plain_text",
1299
+ "text": "Change Working Directory",
1300
+ "emoji": True,
1301
+ },
1302
+ "submit": {"type": "plain_text", "text": "Change", "emoji": True},
1303
+ "close": {"type": "plain_text", "text": "Cancel", "emoji": True},
1304
+ "blocks": [
1305
+ {
1306
+ "type": "section",
1307
+ "text": {
1308
+ "type": "mrkdwn",
1309
+ "text": f"Current working directory:\n`{current_cwd}`",
1310
+ },
1311
+ },
1312
+ {"type": "divider"},
1313
+ {
1314
+ "type": "input",
1315
+ "block_id": "new_cwd_block",
1316
+ "element": {
1317
+ "type": "plain_text_input",
1318
+ "action_id": "new_cwd_input",
1319
+ "placeholder": {
1320
+ "type": "plain_text",
1321
+ "text": "Enter new directory path",
1322
+ "emoji": True,
1323
+ },
1324
+ "initial_value": current_cwd,
1325
+ },
1326
+ "label": {
1327
+ "type": "plain_text",
1328
+ "text": "New Working Directory:",
1329
+ "emoji": True,
1330
+ },
1331
+ "hint": {
1332
+ "type": "plain_text",
1333
+ "text": "Use absolute path (e.g., /home/user/project) or ~ for home directory",
1334
+ "emoji": True,
1335
+ },
1336
+ },
1337
+ {
1338
+ "type": "context",
1339
+ "elements": [
1340
+ {
1341
+ "type": "mrkdwn",
1342
+ "text": "💡 _Tip: The directory will be created if it doesn't exist._",
1343
+ }
1344
+ ],
1345
+ },
1346
+ ],
1347
+ }
1348
+
1349
+ try:
1350
+ await self.web_client.views_open(trigger_id=trigger_id, view=view)
1351
+ except SlackApiError as e:
1352
+ logger.error(f"Error opening change CWD modal: {e}")
1353
+ raise
1354
+
1355
+ def _get_default_opencode_agent_name(self, opencode_agents: list) -> Optional[str]:
1356
+ """Resolve the default OpenCode agent name."""
1357
+ for agent in opencode_agents:
1358
+ name = agent.get("name")
1359
+ if name == "build":
1360
+ return name
1361
+ for agent in opencode_agents:
1362
+ name = agent.get("name")
1363
+ if name:
1364
+ return name
1365
+ return None
1366
+
1367
+ def _resolve_opencode_default_model(
1368
+ self,
1369
+ opencode_default_config: dict,
1370
+ opencode_agents: list,
1371
+ selected_agent: Optional[str],
1372
+ ) -> Optional[str]:
1373
+ """Resolve the default model for a selected OpenCode agent."""
1374
+ agent_name = selected_agent or self._get_default_opencode_agent_name(opencode_agents)
1375
+ if isinstance(opencode_default_config, dict):
1376
+ agents_config = opencode_default_config.get("agent", {})
1377
+ if isinstance(agents_config, dict) and agent_name:
1378
+ agent_config = agents_config.get(agent_name, {})
1379
+ if isinstance(agent_config, dict):
1380
+ model = agent_config.get("model")
1381
+ if isinstance(model, str) and model:
1382
+ return model
1383
+ model = opencode_default_config.get("model")
1384
+ if isinstance(model, str) and model:
1385
+ return model
1386
+ return None
1387
+
1388
+ def _build_routing_modal_view(
1389
+ self,
1390
+ channel_id: str,
1391
+ registered_backends: list,
1392
+ current_backend: str,
1393
+ current_routing,
1394
+ opencode_agents: list,
1395
+ opencode_models: dict,
1396
+ opencode_default_config: dict,
1397
+ selected_backend: object = _UNSET,
1398
+ selected_opencode_agent: object = _UNSET,
1399
+ selected_opencode_model: object = _UNSET,
1400
+ selected_opencode_reasoning: object = _UNSET,
1401
+ current_require_mention: object = _UNSET, # None=default, True, False
1402
+ global_require_mention: bool = False,
1403
+ ) -> dict:
1404
+ """Build modal view for agent/model routing settings."""
1405
+ # Build backend options
1406
+ backend_display_names = {
1407
+ "claude": "Claude Code",
1408
+ "codex": "Codex",
1409
+ "opencode": "OpenCode",
1410
+ }
1411
+ backend_options = []
1412
+ for backend in registered_backends:
1413
+ display_name = backend_display_names.get(backend, backend.capitalize())
1414
+ backend_options.append({
1415
+ "text": {"type": "plain_text", "text": display_name},
1416
+ "value": backend,
1417
+ })
1418
+
1419
+ # Find initial backend option
1420
+ selected_backend_value = (
1421
+ current_backend if selected_backend is _UNSET else selected_backend
1422
+ )
1423
+ initial_backend = None
1424
+ for option in backend_options:
1425
+ if option["value"] == selected_backend_value:
1426
+ initial_backend = option
1427
+ break
1428
+ if initial_backend is None and backend_options:
1429
+ initial_backend = backend_options[0]
1430
+
1431
+ backend_select = {
1432
+ "type": "static_select",
1433
+ "action_id": "backend_select",
1434
+ "placeholder": {"type": "plain_text", "text": "Select backend"},
1435
+ "options": backend_options,
1436
+ "initial_option": initial_backend,
1437
+ }
1438
+
1439
+ # Build modal blocks
1440
+ blocks = [
1441
+ {
1442
+ "type": "section",
1443
+ "text": {
1444
+ "type": "mrkdwn",
1445
+ "text": f"*Current Backend:* {backend_display_names.get(current_backend, current_backend)}",
1446
+ },
1447
+ },
1448
+ {"type": "divider"},
1449
+ {
1450
+ "type": "input",
1451
+ "block_id": "backend_block",
1452
+ "element": backend_select,
1453
+ "label": {"type": "plain_text", "text": "Backend"},
1454
+ },
1455
+ ]
1456
+
1457
+ # Add require_mention selector
1458
+ # Build options: Default (uses global), Require @mention, Don't require @mention
1459
+ global_mention_label = "On" if global_require_mention else "Off"
1460
+ require_mention_options = [
1461
+ {
1462
+ "text": {"type": "plain_text", "text": f"(Default) - {global_mention_label}"},
1463
+ "value": "__default__",
1464
+ },
1465
+ {
1466
+ "text": {"type": "plain_text", "text": "Require @mention"},
1467
+ "value": "true",
1468
+ },
1469
+ {
1470
+ "text": {"type": "plain_text", "text": "Don't require @mention"},
1471
+ "value": "false",
1472
+ },
1473
+ ]
1474
+
1475
+ # Determine initial option
1476
+ initial_require_mention = require_mention_options[0] # Default
1477
+ if current_require_mention is not _UNSET and current_require_mention is not None:
1478
+ target_value = "true" if current_require_mention else "false"
1479
+ for opt in require_mention_options:
1480
+ if opt["value"] == target_value:
1481
+ initial_require_mention = opt
1482
+ break
1483
+
1484
+ require_mention_select = {
1485
+ "type": "static_select",
1486
+ "action_id": "require_mention_select",
1487
+ "placeholder": {"type": "plain_text", "text": "Select @mention behavior"},
1488
+ "options": require_mention_options,
1489
+ "initial_option": initial_require_mention,
1490
+ }
1491
+
1492
+ blocks.append({
1493
+ "type": "input",
1494
+ "block_id": "require_mention_block",
1495
+ "element": require_mention_select,
1496
+ "label": {"type": "plain_text", "text": "Require @mention to respond"},
1497
+ })
1498
+
1499
+ # OpenCode-specific options (only if opencode is registered)
1500
+ if "opencode" in registered_backends:
1501
+ # Get current opencode settings
1502
+ if selected_opencode_agent is _UNSET:
1503
+ current_oc_agent = (
1504
+ current_routing.opencode_agent if current_routing else None
1505
+ )
1506
+ else:
1507
+ current_oc_agent = selected_opencode_agent
1508
+
1509
+ if selected_opencode_model is _UNSET:
1510
+ current_oc_model = (
1511
+ current_routing.opencode_model if current_routing else None
1512
+ )
1513
+ else:
1514
+ current_oc_model = selected_opencode_model
1515
+
1516
+ if selected_opencode_reasoning is _UNSET:
1517
+ current_oc_reasoning = (
1518
+ current_routing.opencode_reasoning_effort if current_routing else None
1519
+ )
1520
+ else:
1521
+ current_oc_reasoning = selected_opencode_reasoning
1522
+
1523
+ # Determine default agent/model from OpenCode config
1524
+ default_model_str = self._resolve_opencode_default_model(
1525
+ opencode_default_config, opencode_agents, current_oc_agent
1526
+ )
1527
+
1528
+ # Build agent options
1529
+ agent_options = [
1530
+ {"text": {"type": "plain_text", "text": "(Default)"}, "value": "__default__"}
1531
+ ]
1532
+ for agent in opencode_agents:
1533
+ agent_name = agent.get("name", "")
1534
+ if agent_name:
1535
+ agent_options.append({
1536
+ "text": {"type": "plain_text", "text": agent_name},
1537
+ "value": agent_name,
1538
+ })
1539
+
1540
+ # Find initial agent
1541
+ initial_agent = agent_options[0] # Default
1542
+ if current_oc_agent:
1543
+ for opt in agent_options:
1544
+ if opt["value"] == current_oc_agent:
1545
+ initial_agent = opt
1546
+ break
1547
+
1548
+ agent_select = {
1549
+ "type": "static_select",
1550
+ "action_id": "opencode_agent_select",
1551
+ "placeholder": {"type": "plain_text", "text": "Select OpenCode agent"},
1552
+ "options": agent_options,
1553
+ "initial_option": initial_agent,
1554
+ }
1555
+
1556
+ # Build model options
1557
+ default_label = "(Default)"
1558
+ if default_model_str:
1559
+ default_label = f"(Default) - {default_model_str}"
1560
+ model_options = [
1561
+ {"text": {"type": "plain_text", "text": default_label}, "value": "__default__"}
1562
+ ]
1563
+
1564
+ # Add models from providers
1565
+ providers_data = opencode_models.get("providers", [])
1566
+ defaults = opencode_models.get("default", {})
1567
+
1568
+ # Calculate max models per provider to fit within Slack's 100 option limit
1569
+ # Reserve 1 for "(Default)" option
1570
+ num_providers = len(providers_data)
1571
+ max_per_provider = max(5, (99 // num_providers)) if num_providers > 0 else 99
1572
+
1573
+ def model_sort_key(model_item):
1574
+ """Sort models by release_date (newest first), deprioritize utility models."""
1575
+ model_id, model_info = model_item
1576
+ mid_lower = model_id.lower()
1577
+
1578
+ # Deprioritize embedding and utility models (put them at the end)
1579
+ is_utility = any(
1580
+ kw in mid_lower
1581
+ for kw in ["embedding", "tts", "whisper", "ada", "davinci", "turbo-instruct"]
1582
+ )
1583
+ utility_penalty = 1 if is_utility else 0
1584
+
1585
+ # Get release_date for sorting (newest first)
1586
+ # Default to old date if not available, convert to negative int for DESC sort
1587
+ release_date = "1970-01-01"
1588
+ if isinstance(model_info, dict):
1589
+ release_date = model_info.get("release_date", "1970-01-01") or "1970-01-01"
1590
+ # Convert YYYY-MM-DD to int (e.g., 20250414) and negate for descending order
1591
+ try:
1592
+ date_int = -int(release_date.replace("-", ""))
1593
+ except (ValueError, AttributeError):
1594
+ date_int = 0
1595
+
1596
+ # Sort by: utility_penalty ASC, release_date DESC (via negative int), model_id ASC
1597
+ return (utility_penalty, date_int, model_id)
1598
+
1599
+ for provider in providers_data:
1600
+ provider_id = provider.get("id", "")
1601
+ provider_name = provider.get("name", provider_id)
1602
+ models = provider.get("models", {})
1603
+
1604
+ # Handle both dict and list formats for models
1605
+ if isinstance(models, dict):
1606
+ model_items = list(models.items())
1607
+ elif isinstance(models, list):
1608
+ model_items = [(m, m) if isinstance(m, str) else (m.get("id", ""), m) for m in models]
1609
+ else:
1610
+ model_items = []
1611
+
1612
+ # Sort models by priority
1613
+ model_items.sort(key=model_sort_key)
1614
+
1615
+ # Limit models per provider
1616
+ provider_model_count = 0
1617
+ for model_id, model_info in model_items:
1618
+ if provider_model_count >= max_per_provider:
1619
+ break
1620
+
1621
+ # Get model name
1622
+ if isinstance(model_info, dict):
1623
+ model_name = model_info.get("name", model_id)
1624
+ else:
1625
+ model_name = model_id
1626
+
1627
+ if model_id:
1628
+ full_model = f"{provider_id}/{model_id}"
1629
+ # Mark if this is the provider's default
1630
+ is_default = defaults.get(provider_id) == model_id
1631
+ display = f"{provider_name}: {model_name}"
1632
+ if is_default:
1633
+ display += " (default)"
1634
+
1635
+ model_options.append({
1636
+ "text": {"type": "plain_text", "text": display[:75]}, # Slack limit
1637
+ "value": full_model,
1638
+ })
1639
+ provider_model_count += 1
1640
+
1641
+ # Final safety check for Slack's 100 option limit
1642
+ if len(model_options) > 100:
1643
+ model_options = model_options[:100]
1644
+ logger.warning("Truncated model options to 100 for Slack modal")
1645
+
1646
+ # Find initial model
1647
+ initial_model = model_options[0] # Default
1648
+ if current_oc_model:
1649
+ for opt in model_options:
1650
+ if opt["value"] == current_oc_model:
1651
+ initial_model = opt
1652
+ break
1653
+
1654
+ model_select = {
1655
+ "type": "static_select",
1656
+ "action_id": "opencode_model_select",
1657
+ "placeholder": {"type": "plain_text", "text": "Select model"},
1658
+ "options": model_options,
1659
+ "initial_option": initial_model,
1660
+ }
1661
+
1662
+ # Build reasoning effort options dynamically based on model variants
1663
+ target_model = current_oc_model or default_model_str
1664
+ model_variants: Dict[str, Any] = {}
1665
+
1666
+ reasoning_model_key = target_model or "__default__"
1667
+ reasoning_action_id = (
1668
+ "opencode_reasoning_select__"
1669
+ + hashlib.sha1(reasoning_model_key.encode("utf-8")).hexdigest()[:8]
1670
+ )
1671
+
1672
+ if target_model:
1673
+ # Parse provider/model format
1674
+ parts = target_model.split("/", 1)
1675
+ if len(parts) == 2:
1676
+ target_provider, target_model_id = parts
1677
+ # Search for this model in providers data
1678
+ for provider in providers_data:
1679
+ if provider.get("id") != target_provider:
1680
+ continue
1681
+
1682
+ models = provider.get("models", {})
1683
+ model_info: Optional[dict] = None
1684
+
1685
+ if isinstance(models, dict):
1686
+ candidate = models.get(target_model_id)
1687
+ if isinstance(candidate, dict):
1688
+ model_info = candidate
1689
+ elif isinstance(models, list):
1690
+ for entry in models:
1691
+ if (
1692
+ isinstance(entry, dict)
1693
+ and entry.get("id") == target_model_id
1694
+ ):
1695
+ model_info = entry
1696
+ break
1697
+
1698
+ if isinstance(model_info, dict):
1699
+ variants = model_info.get("variants", {})
1700
+ if isinstance(variants, dict):
1701
+ model_variants = variants
1702
+
1703
+ break
1704
+
1705
+ # Build options from variants or use fallback
1706
+ reasoning_effort_options = [
1707
+ {"text": {"type": "plain_text", "text": "(Default)"}, "value": "__default__"}
1708
+ ]
1709
+
1710
+ if model_variants:
1711
+ # Use model-specific variants with stable ordering
1712
+ variant_order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
1713
+ variant_display_names = {
1714
+ "none": "None",
1715
+ "minimal": "Minimal",
1716
+ "low": "Low",
1717
+ "medium": "Medium",
1718
+ "high": "High",
1719
+ "xhigh": "Extra High",
1720
+ "max": "Max",
1721
+ }
1722
+ # Sort variants by predefined order, unknown variants go to end alphabetically
1723
+ sorted_variants = sorted(
1724
+ model_variants.keys(),
1725
+ key=lambda x: (
1726
+ variant_order.index(x) if x in variant_order else len(variant_order),
1727
+ x,
1728
+ ),
1729
+ )
1730
+ for variant_key in sorted_variants:
1731
+ display_name = variant_display_names.get(variant_key, variant_key.capitalize())
1732
+ reasoning_effort_options.append({
1733
+ "text": {"type": "plain_text", "text": display_name},
1734
+ "value": variant_key,
1735
+ })
1736
+ else:
1737
+ # Fallback to common options
1738
+ reasoning_effort_options.extend([
1739
+ {"text": {"type": "plain_text", "text": "Low"}, "value": "low"},
1740
+ {"text": {"type": "plain_text", "text": "Medium"}, "value": "medium"},
1741
+ {"text": {"type": "plain_text", "text": "High"}, "value": "high"},
1742
+ ])
1743
+
1744
+ # Find initial reasoning effort
1745
+ initial_reasoning = reasoning_effort_options[0] # Default
1746
+ if current_oc_reasoning:
1747
+ for opt in reasoning_effort_options:
1748
+ if opt["value"] == current_oc_reasoning:
1749
+ initial_reasoning = opt
1750
+ break
1751
+
1752
+ reasoning_select = {
1753
+ "type": "static_select",
1754
+ "action_id": reasoning_action_id,
1755
+ "placeholder": {"type": "plain_text", "text": "Select reasoning effort"},
1756
+ "options": reasoning_effort_options,
1757
+ "initial_option": initial_reasoning,
1758
+ }
1759
+
1760
+ # Add OpenCode section
1761
+ blocks.extend([
1762
+ {"type": "divider"},
1763
+ {
1764
+ "type": "section",
1765
+ "text": {
1766
+ "type": "mrkdwn",
1767
+ "text": "*OpenCode Options* (only applies when backend is OpenCode)",
1768
+ },
1769
+ },
1770
+ {
1771
+ "type": "input",
1772
+ "block_id": "opencode_agent_block",
1773
+ "optional": True,
1774
+ "dispatch_action": True,
1775
+ "element": agent_select,
1776
+ "label": {"type": "plain_text", "text": "OpenCode Agent"},
1777
+ },
1778
+ {
1779
+ "type": "input",
1780
+ "block_id": "opencode_model_block",
1781
+ "optional": True,
1782
+ "dispatch_action": True,
1783
+ "element": model_select,
1784
+ "label": {"type": "plain_text", "text": "Model"},
1785
+ },
1786
+ {
1787
+ "type": "input",
1788
+ "block_id": "opencode_reasoning_block",
1789
+ "optional": True,
1790
+ "element": reasoning_select,
1791
+ "label": {"type": "plain_text", "text": "Reasoning Effort (Thinking Mode)"},
1792
+ },
1793
+ ])
1794
+
1795
+ # Add tip
1796
+ blocks.append({
1797
+ "type": "context",
1798
+ "elements": [
1799
+ {
1800
+ "type": "mrkdwn",
1801
+ "text": "_💡 Select (Default) to use OpenCode's configured defaults._",
1802
+ }
1803
+ ],
1804
+ })
1805
+
1806
+ return {
1807
+ "type": "modal",
1808
+ "callback_id": "routing_modal",
1809
+ "private_metadata": channel_id,
1810
+ "title": {"type": "plain_text", "text": "Agent Settings"},
1811
+ "submit": {"type": "plain_text", "text": "Save"},
1812
+ "close": {"type": "plain_text", "text": "Cancel"},
1813
+ "blocks": blocks,
1814
+ }
1815
+
1816
+ async def open_opencode_question_modal(
1817
+ self,
1818
+ trigger_id: str,
1819
+ context: MessageContext,
1820
+ pending: Dict[str, Any],
1821
+ ):
1822
+ self._ensure_clients()
1823
+
1824
+ questions = pending.get("questions")
1825
+ questions = questions if isinstance(questions, list) else []
1826
+ if not questions:
1827
+ raise ValueError("No questions available")
1828
+
1829
+ import json
1830
+
1831
+ private_metadata = json.dumps(
1832
+ {
1833
+ "channel_id": context.channel_id,
1834
+ "thread_id": context.thread_id,
1835
+ "question_count": len(questions),
1836
+ }
1837
+ )
1838
+
1839
+ blocks: list[Dict[str, Any]] = []
1840
+ for idx, q in enumerate(questions):
1841
+ if not isinstance(q, dict):
1842
+ continue
1843
+ header = (q.get("header") or f"Question {idx + 1}").strip()
1844
+ prompt = (q.get("question") or "").strip()
1845
+ multiple = bool(q.get("multiple"))
1846
+ options = q.get("options") if isinstance(q.get("options"), list) else []
1847
+
1848
+ option_items = []
1849
+ for opt in options:
1850
+ if not isinstance(opt, dict):
1851
+ continue
1852
+ label = opt.get("label")
1853
+ if label is None:
1854
+ continue
1855
+ desc = opt.get("description")
1856
+ item: Dict[str, Any] = {
1857
+ "text": {
1858
+ "type": "plain_text",
1859
+ "text": str(label)[:75],
1860
+ "emoji": True,
1861
+ },
1862
+ "value": str(label),
1863
+ }
1864
+ if desc:
1865
+ item["description"] = {
1866
+ "type": "plain_text",
1867
+ "text": str(desc)[:75],
1868
+ "emoji": True,
1869
+ }
1870
+ option_items.append(item)
1871
+
1872
+ element: Dict[str, Any]
1873
+ if multiple:
1874
+ element = {
1875
+ "type": "multi_static_select",
1876
+ "action_id": "select",
1877
+ "options": option_items,
1878
+ "placeholder": {
1879
+ "type": "plain_text",
1880
+ "text": "Select one or more",
1881
+ "emoji": True,
1882
+ },
1883
+ }
1884
+ else:
1885
+ element = {
1886
+ "type": "static_select",
1887
+ "action_id": "select",
1888
+ "options": option_items,
1889
+ "placeholder": {
1890
+ "type": "plain_text",
1891
+ "text": "Select one",
1892
+ "emoji": True,
1893
+ },
1894
+ }
1895
+
1896
+ label_text = header
1897
+ if prompt:
1898
+ label_text = f"{header}: {prompt}"[:150]
1899
+
1900
+ blocks.append(
1901
+ {
1902
+ "type": "input",
1903
+ "block_id": f"q{idx}",
1904
+ "label": {
1905
+ "type": "plain_text",
1906
+ "text": label_text,
1907
+ "emoji": True,
1908
+ },
1909
+ "element": element,
1910
+ }
1911
+ )
1912
+
1913
+ view = {
1914
+ "type": "modal",
1915
+ "callback_id": "opencode_question_modal",
1916
+ "private_metadata": private_metadata,
1917
+ "title": {"type": "plain_text", "text": "OpenCode", "emoji": True},
1918
+ "submit": {"type": "plain_text", "text": "Submit", "emoji": True},
1919
+ "close": {"type": "plain_text", "text": "Cancel", "emoji": True},
1920
+ "blocks": blocks,
1921
+ }
1922
+
1923
+ await self.web_client.views_open(trigger_id=trigger_id, view=view)
1924
+
1925
+ async def open_routing_modal(
1926
+ self,
1927
+ trigger_id: str,
1928
+ channel_id: str,
1929
+ registered_backends: list,
1930
+ current_backend: str,
1931
+ current_routing, # Optional[ChannelRouting]
1932
+ opencode_agents: list,
1933
+ opencode_models: dict,
1934
+ opencode_default_config: dict,
1935
+ current_require_mention: object = None, # None=default, True, False
1936
+ global_require_mention: bool = False,
1937
+ ):
1938
+ """Open a modal dialog for agent/model routing settings"""
1939
+ self._ensure_clients()
1940
+
1941
+ view = self._build_routing_modal_view(
1942
+ channel_id=channel_id,
1943
+ registered_backends=registered_backends,
1944
+ current_backend=current_backend,
1945
+ current_routing=current_routing,
1946
+ opencode_agents=opencode_agents,
1947
+ opencode_models=opencode_models,
1948
+ opencode_default_config=opencode_default_config,
1949
+ current_require_mention=current_require_mention,
1950
+ global_require_mention=global_require_mention,
1951
+ )
1952
+
1953
+ try:
1954
+ await self.web_client.views_open(trigger_id=trigger_id, view=view)
1955
+ except SlackApiError as e:
1956
+ logger.error(f"Error opening routing modal: {e}")
1957
+ raise
1958
+
1959
+ async def update_routing_modal(
1960
+ self,
1961
+ view_id: str,
1962
+ view_hash: str,
1963
+ channel_id: str,
1964
+ registered_backends: list,
1965
+ current_backend: str,
1966
+ current_routing,
1967
+ opencode_agents: list,
1968
+ opencode_models: dict,
1969
+ opencode_default_config: dict,
1970
+ selected_backend: Optional[str] = None,
1971
+ selected_opencode_agent: Optional[str] = None,
1972
+ selected_opencode_model: Optional[str] = None,
1973
+ selected_opencode_reasoning: Optional[str] = None,
1974
+ current_require_mention: object = None,
1975
+ global_require_mention: bool = False,
1976
+ ) -> None:
1977
+ """Update routing modal when selections change."""
1978
+ self._ensure_clients()
1979
+
1980
+ view = self._build_routing_modal_view(
1981
+ channel_id=channel_id,
1982
+ registered_backends=registered_backends,
1983
+ current_backend=current_backend,
1984
+ current_routing=current_routing,
1985
+ opencode_agents=opencode_agents,
1986
+ opencode_models=opencode_models,
1987
+ opencode_default_config=opencode_default_config,
1988
+ selected_backend=selected_backend,
1989
+ selected_opencode_agent=selected_opencode_agent,
1990
+ selected_opencode_model=selected_opencode_model,
1991
+ selected_opencode_reasoning=selected_opencode_reasoning,
1992
+ current_require_mention=current_require_mention,
1993
+ global_require_mention=global_require_mention,
1994
+ )
1995
+
1996
+ try:
1997
+ await self.web_client.views_update(view_id=view_id, hash=view_hash, view=view)
1998
+ except SlackApiError as e:
1999
+ logger.error(f"Error updating routing modal: {e}")
2000
+ raise
2001
+
2002
+ def register_callbacks(
2003
+ self,
2004
+ on_message: Optional[Callable] = None,
2005
+ on_command: Optional[Dict[str, Callable]] = None,
2006
+ on_callback_query: Optional[Callable] = None,
2007
+ **kwargs,
2008
+ ):
2009
+ """Register callback functions for different events"""
2010
+ super().register_callbacks(on_message, on_command, on_callback_query, **kwargs)
2011
+
2012
+ # Register command handlers
2013
+ if on_command:
2014
+ self.command_handlers.update(on_command)
2015
+
2016
+ # Register any slash command handlers passed in kwargs
2017
+ if "on_slash_command" in kwargs:
2018
+ slash_commands = kwargs["on_slash_command"]
2019
+ if isinstance(slash_commands, dict):
2020
+ self.slash_command_handlers.update(slash_commands)
2021
+
2022
+ # Register settings update handler
2023
+ if "on_settings_update" in kwargs:
2024
+ self._on_settings_update = kwargs["on_settings_update"]
2025
+
2026
+ # Register change CWD handler
2027
+ if "on_change_cwd" in kwargs:
2028
+ self._on_change_cwd = kwargs["on_change_cwd"]
2029
+
2030
+ # Register routing update handler
2031
+ if "on_routing_update" in kwargs:
2032
+ self._on_routing_update = kwargs["on_routing_update"]
2033
+
2034
+ # Register routing modal update handler
2035
+ if "on_routing_modal_update" in kwargs:
2036
+ self._on_routing_modal_update = kwargs["on_routing_modal_update"]
2037
+
2038
+ async def get_or_create_thread(
2039
+ self, channel_id: str, user_id: str
2040
+ ) -> Optional[str]:
2041
+ """Get existing thread timestamp or return None for new thread"""
2042
+ # Deprecated: Thread handling now uses user's message timestamp directly
2043
+ return None
2044
+
2045
+ async def send_slash_response(
2046
+ self, response_url: str, text: str, ephemeral: bool = True
2047
+ ) -> bool:
2048
+ """Send response to a slash command via response_url"""
2049
+ try:
2050
+ import aiohttp
2051
+
2052
+ async with aiohttp.ClientSession() as session:
2053
+ await session.post(
2054
+ response_url,
2055
+ json={
2056
+ "text": text,
2057
+ "response_type": "ephemeral" if ephemeral else "in_channel",
2058
+ },
2059
+ )
2060
+ return True
2061
+ except Exception as e:
2062
+ logger.error(f"Error sending slash command response: {e}")
2063
+ return False
2064
+
2065
+ async def _is_authorized_channel(self, channel_id: str) -> bool:
2066
+ """Check if a channel is authorized based on whitelist configuration"""
2067
+ if not self.settings_manager:
2068
+ logger.warning("No settings_manager configured; rejecting by default")
2069
+ return False
2070
+
2071
+ settings = self.settings_manager.get_channel_settings(channel_id)
2072
+ if settings is None:
2073
+ logger.warning("No channel settings found; rejecting by default")
2074
+ return False
2075
+
2076
+ if settings.enabled:
2077
+ return True
2078
+
2079
+ logger.info("Channel not enabled in settings.json: %s", channel_id)
2080
+ return False
2081
+
2082
+ async def _send_unauthorized_message(self, channel_id: str):
2083
+ """Send unauthorized access message to channel"""
2084
+ try:
2085
+ self._ensure_clients()
2086
+ await self.web_client.chat_postMessage(
2087
+ channel=channel_id,
2088
+ text="❌ This channel is not enabled. Please go to the control panel to enable it.",
2089
+ )
2090
+ except Exception as e:
2091
+ logger.error(f"Failed to send unauthorized message to {channel_id}: {e}")