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.
- config/__init__.py +37 -0
- config/paths.py +56 -0
- config/v2_compat.py +74 -0
- config/v2_config.py +206 -0
- config/v2_sessions.py +73 -0
- config/v2_settings.py +115 -0
- core/__init__.py +0 -0
- core/controller.py +736 -0
- core/handlers/__init__.py +13 -0
- core/handlers/command_handlers.py +342 -0
- core/handlers/message_handler.py +365 -0
- core/handlers/session_handler.py +233 -0
- core/handlers/settings_handler.py +362 -0
- modules/__init__.py +0 -0
- modules/agent_router.py +58 -0
- modules/agents/__init__.py +38 -0
- modules/agents/base.py +91 -0
- modules/agents/claude_agent.py +344 -0
- modules/agents/codex_agent.py +368 -0
- modules/agents/opencode_agent.py +2155 -0
- modules/agents/service.py +41 -0
- modules/agents/subagent_router.py +136 -0
- modules/claude_client.py +154 -0
- modules/im/__init__.py +63 -0
- modules/im/base.py +323 -0
- modules/im/factory.py +60 -0
- modules/im/formatters/__init__.py +4 -0
- modules/im/formatters/base_formatter.py +639 -0
- modules/im/formatters/slack_formatter.py +127 -0
- modules/im/slack.py +2091 -0
- modules/session_manager.py +138 -0
- modules/settings_manager.py +587 -0
- vibe/__init__.py +6 -0
- vibe/__main__.py +12 -0
- vibe/_version.py +34 -0
- vibe/api.py +412 -0
- vibe/cli.py +637 -0
- vibe/runtime.py +213 -0
- vibe/service_main.py +101 -0
- vibe/templates/slack_manifest.json +65 -0
- vibe/ui/dist/assets/index-8g3mNwMK.js +35 -0
- vibe/ui/dist/assets/index-M55aMB5R.css +1 -0
- vibe/ui/dist/assets/logo-BzryTZ7u.png +0 -0
- vibe/ui/dist/index.html +17 -0
- vibe/ui/dist/logo.png +0 -0
- vibe/ui/dist/vite.svg +1 -0
- vibe/ui_server.py +346 -0
- vibe_remote-2.1.6.dist-info/METADATA +295 -0
- vibe_remote-2.1.6.dist-info/RECORD +52 -0
- vibe_remote-2.1.6.dist-info/WHEEL +4 -0
- vibe_remote-2.1.6.dist-info/entry_points.txt +2 -0
- 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}")
|