diff --git a/container/bin/notify b/container/bin/notify index 2deb4705..8d1b7745 100755 --- a/container/bin/notify +++ b/container/bin/notify @@ -75,6 +75,15 @@ def _creature_emoji(session: str, session_reg: Path) -> str: }.get(result.stdout.strip(), "๐Ÿฆซ") +def _safe_transport_detail(stderr: str, secrets: tuple[str, ...]) -> str: + """Keep curl's diagnosis while withholding request credentials/content.""" + detail = " ".join(stderr.split()) + for secret in sorted((value for value in secrets if value), key=len, reverse=True): + detail = detail.replace(secret, "") + # Curl diagnostics are short. Bound untrusted output from wrappers/proxies. + return detail[:300] or "no curl diagnostic" + + def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) try: @@ -104,7 +113,7 @@ def main(argv: list[str] | None = None) -> int: payload.update(adapter="telegram", chat_id=route_id) api = os.environ.get("WOLTSPACE_API", "http://localhost:7777") - response = subprocess.run( + request = subprocess.run( [ "curl", "-s", @@ -119,11 +128,28 @@ def main(argv: list[str] | None = None) -> int: capture_output=True, text=True, check=False, - ).stdout + ) + if request.returncode != 0: + detail = _safe_transport_detail( + request.stderr, + ( + message, + full_message, + os.environ.get("SLACK_BOT_TOKEN", ""), + os.environ.get("SLACK_APP_TOKEN", ""), + os.environ.get("TELEGRAM_BOT_TOKEN", ""), + ), + ) + print( + f"notify: transport failed (curl exit {request.returncode}): {detail}", + file=sys.stderr, + ) + return 1 + response = request.stdout try: result = json.loads(response) except json.JSONDecodeError: - print(f"notify: failed: {response or 'unknown error'}", file=sys.stderr) + print("notify: failed: invalid or empty API response", file=sys.stderr) return 1 if result.get("ok"): print(f"[notify] โ†’ {result.get('adapter', '?')}: {message}") diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 5916edb2..5d416a21 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -1,14 +1,10 @@ -""" -Slack adapter โ€” thin layer over core. -Responds to @mentions, reads full thread context, follows up in threads. -Uses Socket Mode (no public HTTP endpoint needed). +"""Owner-only Slack DM adapter over Socket Mode. Thread ownership model: - - @bot in channel โ†’ new thread, dog (Haiku) responds + - owner starts a DM thread โ†’ dog responds - Dog spawns a session โ†’ thread becomes session-owned - Messages in session-owned thread โ†’ routed directly to Claude Code session - - @bot in session-owned thread โ†’ escape hatch back to dog - - Dead session โ†’ error message, @bot to recover + - Dead session โ†’ ownership clears so the dog can recover in that thread """ import os @@ -18,15 +14,22 @@ import logging import asyncio import random +import tempfile +import time +import fcntl import urllib.request +import urllib.parse +from collections import deque from datetime import datetime, timezone from pathlib import Path -from collections import defaultdict from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler import sys -from bot.core import get_response, message_session, list_sessions, kill_session, get_tunnel_url, switch_wolt, list_wolts, _bot_log, build_ack_text, _sanitize_history -from wolts import get_active_creature +from bot.core import ( + message_session, start_claude_session, _bot_log, build_ack_text, registry, + _sanitize_history, +) +from wolts import get_active_creature, list_wolts sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) from env_compat import get_env @@ -55,6 +58,12 @@ ACTIVE_THREADS_FILE = CHAT_DIR / "_active_threads.json" THREAD_SESSIONS_FILE = CHAT_DIR / "_thread_sessions.json" +OWNER_SELECTIONS_FILE = CHAT_DIR / "_owner_selections.json" +PENDING_MESSAGES_FILE = CHAT_DIR / "_pending_messages.json" +PENDING_LOCK_FILE = CHAT_DIR / "_pending_messages.lock" +PENDING_TTL_SECONDS = 600 +PENDING_MAX_PER_OWNER = 5 +PENDING_MAX_BYTES = 32 * 1024 def _dog_name() -> str: @@ -101,6 +110,200 @@ def _save_thread_sessions(): _active_threads: set[str] = _load_active_threads() _thread_sessions: dict[str, dict] = _load_thread_sessions() +RODENT_WOLT_TYPES = {"raccoon", "beaver", "otter", "rodent"} + + +def _load_owner_selections() -> dict[str, str]: + try: + data = json.loads(OWNER_SELECTIONS_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + return { + str(user): str(wolt) for user, wolt in data.items() + if isinstance(user, str) and isinstance(wolt, str) + } if isinstance(data, dict) else {} + + +def _save_owner_selections(selections: dict[str, str]) -> None: + CHAT_DIR.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=CHAT_DIR, prefix=".owner-selections-", suffix=".tmp", text=True + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write(json.dumps(selections, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, OWNER_SELECTIONS_FILE) + finally: + temporary.unlink(missing_ok=True) + + +_owner_selections: dict[str, str] = _load_owner_selections() + + +def _eligible_wolts() -> dict[str, dict]: + eligible = {} + for wolt in sorted(list_wolts(), key=lambda item: item.get("name", "").casefold()): + name = wolt.get("name", "") + if name and wolt.get("type") in RODENT_WOLT_TYPES: + eligible[name] = wolt + return eligible + + +def _selected_wolt(user: str) -> dict | None: + name = _owner_selections.get(user) + if not name: + return None + selected = _eligible_wolts().get(name) + if selected is not None: + return selected + _owner_selections.pop(user, None) + _save_owner_selections(_owner_selections) + return None + + +def _select_wolt(user: str, name: str) -> dict | None: + selected = _eligible_wolts().get(name) + if selected is None: + return None + _owner_selections[user] = name + _save_owner_selections(_owner_selections) + return selected + + +def _picker_text(wolts: dict[str, dict], current: str = "") -> str: + if not wolts: + return "No eligible wolts are available. Create a rodent wolt in the lodge first." + lines = ["Choose your wolt:"] + if current: + lines.append(f"Current selection: `{current}`") + lines.extend(f"{index}. {name}" for index, name in enumerate(wolts, 1)) + lines.append("\nIf the menu is unavailable, send `wolt `.") + return "\n".join(lines) + + +def _picker_blocks(wolts: dict[str, dict]) -> list[dict]: + # Slack static_select accepts at most 100 options. Text fallback above lists + # the complete roster, so every eligible wolt remains reachable. + options = [ + {"text": {"type": "plain_text", "text": name[:75]}, "value": name} + for name in list(wolts)[:100] + ] + if not options: + return [] + return [{ + "type": "actions", + "elements": [{ + "type": "static_select", + "action_id": "select_wolt", + "placeholder": {"type": "plain_text", "text": "Choose your wolt"}, + "options": options, + }], + }] + + +def _text_selection(text: str) -> str | None: + match = re.fullmatch(r"/?wolt\s+(\S+)\s*", text, flags=re.IGNORECASE) + return match.group(1) if match else None + + +def _picker_request(text: str) -> bool: + return re.fullmatch(r"/?wolt\s*", text, flags=re.IGNORECASE) is not None + + +def _write_private_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, raw = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-", suffix=".tmp") + temporary = Path(raw) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w") as handle: + json.dump(data, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _pending_mutate(mutator): + CHAT_DIR.mkdir(parents=True, exist_ok=True) + with PENDING_LOCK_FILE.open("a+") as lock: + os.chmod(PENDING_LOCK_FILE, 0o600) + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + data = json.loads(PENDING_MESSAGES_FILE.read_text()) + if not isinstance(data, dict): + data = {} + except (FileNotFoundError, json.JSONDecodeError, OSError): + data = {} + result = mutator(data) + _write_private_json(PENDING_MESSAGES_FILE, data) + return result + + +def _pending_create(user: str, channel: str, root_ts: str, event_id: str, text: str, + *, now: float | None = None) -> dict: + now = time.time() if now is None else now + if not text or len(text.encode("utf-8")) > PENDING_MAX_BYTES: + raise ValueError("message must be 1..32768 UTF-8 bytes") + key = _thread_key(channel, root_ts) + def mutate(data): + active = [r for r in data.values() if r.get("user") == user and + r.get("state") == "pending" and r.get("expires_at", 0) > now] + if len(active) >= PENDING_MAX_PER_OWNER: + raise ValueError("too many pending conversations") + record = {"user": user, "channel": channel, "root_ts": root_ts, + "event_id": event_id, "text": text, "picker_ts": "", + "state": "pending", "created_at": now, + "expires_at": now + PENDING_TTL_SECONDS} + data[key] = record + return dict(record) + return _pending_mutate(mutate) + + +def _pending_set_picker(channel: str, root_ts: str, picker_ts: str) -> None: + key = _thread_key(channel, root_ts) + def mutate(data): + if key in data and data[key].get("state") == "pending": + data[key]["picker_ts"] = picker_ts + _pending_mutate(mutate) + + +def _pending_claim(user: str, channel: str, picker_ts: str, wolt: str, + *, now: float | None = None) -> dict | None: + now = time.time() if now is None else now + def mutate(data): + for record in data.values(): + if (record.get("user") == user and record.get("channel") == channel and + picker_ts in {record.get("picker_ts"), record.get("root_ts")} and + record.get("state") == "pending"): + if record.get("expires_at", 0) <= now: + record["state"] = "expired" + record.pop("text", None) + return None + claimed = dict(record) + record["state"] = "claimed" + record["wolt"] = wolt + record.pop("text", None) # durable at-most-once boundary + return claimed + return None + return _pending_mutate(mutate) + + +def _pending_finish(channel: str, root_ts: str, state: str, session: str = "") -> None: + key = _thread_key(channel, root_ts) + def mutate(data): + if key in data: + data[key]["state"] = state + data[key]["session"] = session + data[key].pop("text", None) + _pending_mutate(mutate) + # --- Dog ack messages --- @@ -117,6 +320,11 @@ def _save_thread_sessions(): "๐Ÿถ *full body wiggle*", ] +SLACK_USER_ID_RE = re.compile(r"^[UW][A-Z0-9]{8,}$") +_SEEN_EVENT_LIMIT = 2048 +_seen_event_ids: set[str] = set() +_seen_event_order: deque[str] = deque() + # --- Helpers --- @@ -163,9 +371,43 @@ def _strip_mention(text: str, bot_user_id: str) -> str: return re.sub(rf"<@{bot_user_id}>", "", text).strip() -def _has_mention(text: str, bot_user_id: str) -> bool: - """Check if text contains an @mention of the bot.""" - return bool(bot_user_id) and f"<@{bot_user_id}>" in text +def _owner_user_id() -> str | None: + """Return the explicitly configured owner, never an inferred user.""" + value = os.environ.get("SLACK_OWNER_USER", "").strip() + return value if SLACK_USER_ID_RE.fullmatch(value) else None + + +def _event_identity(body: dict, event: dict) -> str: + return str( + body.get("event_id") + or event.get("client_msg_id") + or event.get("event_ts") + or event.get("ts") + or "" + ) + + +def _accept_owner_dm(event: dict, body: dict) -> tuple[bool, str]: + """Fail closed before history, downloads, model calls, or session routing.""" + owner = _owner_user_id() + if owner is None: + return False, "owner_not_configured" + if event.get("bot_id") or event.get("subtype"): + return False, "non_human_event" + if event.get("channel_type") != "im": + return False, "not_dm" + if event.get("user") != owner: + return False, "not_owner" + event_id = _event_identity(body, event) + if not event_id: + return False, "missing_event_id" + if event_id in _seen_event_ids: + return False, "duplicate" + _seen_event_ids.add(event_id) + _seen_event_order.append(event_id) + if len(_seen_event_order) > _SEEN_EVENT_LIMIT: + _seen_event_ids.discard(_seen_event_order.popleft()) + return True, "accepted" def _extract_image(event: dict) -> tuple[bytes, str] | None: @@ -264,24 +506,19 @@ def _get_session_owner(channel: str, thread_ts: str) -> dict | None: return _thread_sessions.get(key) -def _set_session_owner(channel: str, thread_ts: str, session_name: str, wolt_name: str, creature: str): +def _set_session_owner(channel: str, thread_ts: str, session_name: str, wolt_name: str, + creature: str, session_link: str = ""): """Mark a thread as owned by a session.""" key = _thread_key(channel, thread_ts) _thread_sessions[key] = { "session": session_name, "wolt": wolt_name, "creature": creature, + "session_link": session_link, } _save_thread_sessions() -def _clear_session_owner(channel: str, thread_ts: str): - """Release thread ownership (back to dog).""" - key = _thread_key(channel, thread_ts) - _thread_sessions.pop(key, None) - _save_thread_sessions() - - def _extract_session_info(result: dict) -> dict | None: """Extract session name, wolt, and creature from a get_response result.""" if result.get("type") != "session": @@ -297,7 +534,113 @@ def _extract_session_info(result: dict) -> dict | None: # --- Posting results --- -async def _post_result(client, channel: str, thread_ts: str, result: dict): +async def _post_text(client, channel: str, thread_ts: str, user: str, text: str): + """Prefer Slack's agent stream surface, with a plain-message fallback.""" + try: + started = await client.chat_startStream( + channel=channel, + thread_ts=thread_ts, + recipient_user_id=user, + markdown_text=text, + ) + stream_ts = started.get("ts") + if not stream_ts: + raise RuntimeError("chat.startStream returned no message timestamp") + await client.chat_stopStream(channel=channel, ts=stream_ts) + return + except Exception as exc: + logger.info("Slack streaming unavailable; using chat.postMessage: %s", exc) + await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text) + + +def _session_link(session: dict) -> str: + """Return only an exact platform-created HTTPS link for this session.""" + url = session.get("url") or "" + name = session.get("name") or "" + try: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except (TypeError, ValueError): + return "" + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or + parsed.password or parsed.path != "/tui" or parsed.fragment or + query != {"session": [name]}): + return "" + return url + + +async def _set_agent_status(client, channel: str, thread_ts: str, + status: str, *, title: str = "") -> bool: + """Set the supported native Slack Agent Session status.""" + try: + arguments = { + "channel_id": channel, + "thread_ts": thread_ts, + "status": status, + } + if title: + arguments["title"] = title + response = await client.agents_sessions_setStatus( + **arguments, + ) + return bool(response.get("ok", True)) + except Exception as exc: + logger.error("Slack native agent status unavailable: %s", exc) + return False + + +async def _spawn_pending(client, selected: dict, claimed: dict) -> None: + channel, root_ts = claimed["channel"], claimed["root_ts"] + picker_ts, user = claimed["picker_ts"], claimed["user"] + await client.chat_update( + channel=channel, ts=picker_ts, + text=f"โœ… Accepted. ๐ŸŒฑ Starting {selected['name']}โ€ฆ", blocks=[], + ) + routing = {"adapter": "slack", "chat_id": channel, "thread_ts": root_ts} + try: + session = await asyncio.to_thread( + start_claude_session, claimed["text"], wolt=selected["name"], + creature=selected.get("type", ""), routing=routing, + ) + except Exception: + _pending_finish(channel, root_ts, "failed") + await client.chat_delete(channel=channel, ts=picker_ts) + logger.exception("Error starting selected Slack wolt") + return + session_link = _session_link(session) + _set_session_owner( + channel, root_ts, session["name"], selected["name"], selected.get("type", ""), + session_link, + ) + _pending_finish(channel, root_ts, "consumed", session["name"]) + link_suffix = f" <{session_link}|Open session>" if session_link else "" + if await _set_agent_status( + client, channel, root_ts, "processing", title=session["name"] + ): + await asyncio.to_thread( + registry.update, session["name"], wolt=selected["name"], + slack_progress_mode="agent", slack_progress_ts="", + slack_session_link=session_link, + ) + await client.chat_update( + channel=channel, ts=picker_ts, + text=f"๐ŸŒฑ {selected['name']} session ready.{link_suffix}", blocks=[], + ) + return + await asyncio.to_thread( + registry.update, session["name"], wolt=selected["name"], + slack_progress_mode="", slack_progress_ts="", + slack_session_link=session_link, + ) + await client.chat_update( + channel=channel, ts=picker_ts, + text=(f"๐ŸŒฑ {selected['name']} session ready, but Slack Agent View is not " + f"authorized. Reinstall with `assistant:write`.{link_suffix}"), + blocks=[], + ) + + +async def _post_result(client, channel: str, thread_ts: str, user: str, result: dict): """Post a result to Slack โ€” handles text, session, and image types. Sends tool call logs before the final response.""" for tc in result.get("tool_calls_log", []): @@ -321,11 +664,7 @@ async def _post_result(client, channel: str, thread_ts: str, result: dict): initial_comment=caption or None, ) else: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=format_response(result), - ) + await _post_text(client, channel, thread_ts, user, format_response(result)) # If a session was spawned, take ownership of the thread session_info = _extract_session_info(result) @@ -349,7 +688,26 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t session_name = owner["session"] wolt = owner["wolt"] creature = owner["creature"] - emoji = CREATURE_EMOJIS.get(creature, "๐Ÿพ") + session_link = owner.get("session_link", "") + link_suffix = f" <{session_link}|Open session>" if session_link else "" + + native_agent_status = await _set_agent_status( + client, channel, thread_ts, "processing" + ) + if native_agent_status: + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="agent", slack_progress_ts="", + slack_session_link=session_link, + ) + else: + await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=("Slack Agent View is not authorized. Reinstall with " + f"`assistant:write` before retrying.{link_suffix}"), + ) + return session_msg = ( f"[slack message from human, channel={channel}, thread={thread_ts}]: {text}\n" @@ -363,18 +721,18 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t _append_history(channel, thread_ts, "user", text) if result.get("ok"): - session_link = result.get("url") or session_name - if result.get("status") == "revived": - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"{emoji} {wolt}: โ†’ session had exited โ€” revived and delivered\n{session_link}", + refreshed_link = _session_link({ + "name": session_name, + "url": result.get("url") or "", + }) + if refreshed_link and refreshed_link != session_link: + session_link = refreshed_link + _set_session_owner( + channel, thread_ts, session_name, wolt, creature, session_link ) - else: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"๐Ÿชต sent\n{session_link}", + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_session_link=session_link, ) _append_message(channel, thread_ts, { "role": "assistant", @@ -382,11 +740,18 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t }) else: error = result.get("error", "unknown error") - # Session is dead โ€” tell user how to recover + await _set_agent_status(client, channel, thread_ts, "active") + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="", slack_progress_ts="", + ) + # Return the thread to the dog so the next owner message can recover. + _thread_sessions.pop(_thread_key(channel, thread_ts), None) + _save_thread_sessions() await client.chat_postMessage( channel=channel, thread_ts=thread_ts, - text=f"session {session_name} is no longer active โ€” @mention me to start a new conversation", + text=f"session {session_name} is no longer active โ€” send again to start a new conversation", ) _append_message(channel, thread_ts, { "role": "assistant", @@ -397,121 +762,54 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t # --- Main app --- def create_app(): - """Create and configure the Slack Bolt app.""" + """Create the owner-only DM app; channel mentions are intentionally absent.""" app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) - @app.event("app_mention") - async def handle_mention(event, client, context): - """Handle @mentions โ€” always goes to dog (Haiku). - - This is the entry point for new conversations and the escape hatch - from session-owned threads. - """ - channel = event["channel"] - user = event.get("user", "unknown") - text = event.get("text", "") - thread_ts = event.get("thread_ts", event["ts"]) - - bot_user_id = context.get("bot_user_id", "") - user_message = _strip_mention(text, bot_user_id) - - # Check for image attachment - image_result = await asyncio.get_event_loop().run_in_executor(None, _extract_image, event) - user_content = None - if image_result: - image_bytes, mime_type = image_result - user_content = _image_content(image_bytes, mime_type, user_message) - user_message = f"[image] {user_message}" if user_message else "[image]" - - if not user_message and user_content is None: + @app.action("select_wolt") + async def handle_select_wolt(ack, body, client): + await ack() + owner = _owner_user_id() + user = (body.get("user") or {}).get("id") + channel = (body.get("channel") or {}).get("id", "") + actions = body.get("actions") + action = actions[0] if isinstance(actions, list) and len(actions) == 1 else {} + option = action.get("selected_option") if isinstance(action, dict) else None + name = option.get("value") if isinstance(option, dict) else None + picker_ts = (body.get("message") or {}).get("ts", "") + if user != owner or not channel.startswith("D") or not isinstance(name, str): + logger.info("Ignoring unauthorized or malformed Slack wolt selection") return - - logger.info(f"Mention from user={user} in channel={channel} thread={thread_ts}") - - # If this thread was session-owned, release it back to dog - owner = _get_session_owner(channel, thread_ts) - if owner: - _clear_session_owner(channel, thread_ts) + selected = _eligible_wolts().get(name) + if selected is None: + logger.info("Ignoring stale Slack wolt selection") await client.chat_postMessage( channel=channel, - thread_ts=thread_ts, - text=f"๐Ÿถ dog is back in this thread", + text="That wolt is no longer available. Send any DM to choose again.", ) - - # Mark thread as active - _active_threads.add(_thread_key(channel, thread_ts)) - _save_active_threads() - - # Immediate ack - try: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=random.choice(DOG_ACK_MESSAGES), - ) - except Exception: - pass - - # Build context from full thread - history = await _build_thread_context(client, channel, thread_ts, bot_user_id) - if history and history[-1]["role"] == "user": - history = history[:-1] - - routing = {"adapter": "slack", "chat_id": channel, "thread_ts": thread_ts} - try: - result = get_response(user_message, conversation_history=list(history), routing=routing, user_content=user_content) - except Exception as e: - logger.error(f"Error getting response: {e}") - await client.chat_postMessage(channel=channel, thread_ts=thread_ts, - text="Something broke on my end. Try again in a sec.") return - - _append_history(channel, thread_ts, "user", user_message) - for msg in result["history_messages"]: - _append_message(channel, thread_ts, msg) - - await _post_result(client, channel, thread_ts, result) + claimed = _pending_claim(user, channel, picker_ts, name) + if claimed is None: + logger.info("Ignoring expired, duplicate, or unbound Slack selection") + return + _owner_selections[user] = name + _save_owner_selections(_owner_selections) + await _spawn_pending(client, selected, claimed) @app.event("message") - async def handle_message(event, client, context): - """Handle follow-up messages in active threads. - - If thread is session-owned โ†’ route directly to Claude Code session. - If thread is dog-owned โ†’ route to dog (Haiku). - Skip if message contains @mention (handled by handle_mention). - """ - if event.get("bot_id") or event.get("subtype"): + async def handle_message(event, client, context, body): + """Start or continue one owner DM thread.""" + accepted, reason = _accept_owner_dm(event, body) + if not accepted: + logger.info("Ignoring Slack event: %s", reason) return channel = event["channel"] - thread_ts = event.get("thread_ts") - - if not thread_ts: - return - if _thread_key(channel, thread_ts) not in _active_threads: - return - - user = event.get("user", "unknown") + thread_ts = event.get("thread_ts") or event["ts"] + user = event["user"] text = event.get("text", "") bot_user_id = context.get("bot_user_id", "") - - # Skip if this is an @mention (handled by handle_mention) - if _has_mention(text, bot_user_id): - return - user_message = _strip_mention(text, bot_user_id) - # Check for image attachment - image_result = await asyncio.get_event_loop().run_in_executor(None, _extract_image, event) - user_content = None - if image_result: - image_bytes, mime_type = image_result - user_content = _image_content(image_bytes, mime_type, user_message) - user_message = f"[image] {user_message}" if user_message else "[image]" - - if not user_message and user_content is None: - return - # --- Session-owned thread: route directly to session --- owner = _get_session_owner(channel, thread_ts) if owner: @@ -519,37 +817,50 @@ async def handle_message(event, client, context): await _route_to_session(client, channel, thread_ts, owner, user_message) return - # --- Dog-owned thread: route to Haiku --- - logger.info(f"Thread follow-up from user={user} in channel={channel} thread={thread_ts}") + requested = _text_selection(user_message) + if event.get("thread_ts") and requested is not None: + selected = _eligible_wolts().get(requested) + if selected: + claimed = _pending_claim(user, channel, thread_ts, requested) + if claimed: + await _spawn_pending(client, selected, claimed) + return - # Immediate ack - try: + if event.get("thread_ts"): + return # unowned/stale thread never becomes a fresh conversation + + if event.get("files"): await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=random.choice(DOG_ACK_MESSAGES), + channel=channel, thread_ts=thread_ts, + text="Attachments are not supported by the wolt picker yet. Send a text-only message.", ) - except Exception: - pass - - history = await _build_thread_context(client, channel, thread_ts, bot_user_id) - if history and history[-1]["role"] == "user": - history = history[:-1] + return - routing = {"adapter": "slack", "chat_id": channel, "thread_ts": thread_ts} + wolts = _eligible_wolts() + current = _selected_wolt(user) + if _picker_request(user_message): + await client.chat_postMessage( + channel=channel, thread_ts=thread_ts, + text=_picker_text(wolts, current.get("name", "") if current else ""), + blocks=_picker_blocks(wolts), + ) + return try: - result = get_response(user_message, conversation_history=list(history), routing=routing, user_content=user_content) - except Exception as e: - logger.error(f"Error getting response: {e}") - await client.chat_postMessage(channel=channel, thread_ts=thread_ts, - text="Something broke on my end. Try again in a sec.") + _pending_create( + user, channel, thread_ts, + str(body.get("event_id") or event.get("event_ts") or event["ts"]), + user_message, + ) + except ValueError as exc: + await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=str(exc)) return - - _append_history(channel, thread_ts, "user", user_message) - for msg in result["history_messages"]: - _append_message(channel, thread_ts, msg) - - await _post_result(client, channel, thread_ts, result) + posted = await client.chat_postMessage( + channel=channel, thread_ts=thread_ts, + text=_picker_text(wolts, current.get("name", "") if current else ""), + blocks=_picker_blocks(wolts), + ) + if posted.get("ts"): + _pending_set_picker(channel, thread_ts, posted["ts"]) return app diff --git a/container/skills/setup-slack/SKILL.md b/container/skills/setup-slack/SKILL.md new file mode 100644 index 00000000..78c1011a --- /dev/null +++ b/container/skills/setup-slack/SKILL.md @@ -0,0 +1,131 @@ +--- +name: setup-slack +description: Connect a private Woltspace lodge to its owner's Slack with the supported owner-DM Agent View app and Socket Mode flow. +--- + +# Slack Setup + +Guide the owner through one supported setup: one owner-controlled Slack app per +lodge, Agent View, Socket Mode, and one immutable owner member ID. Work one +step at a time and stop for the owner at Slack UI boundaries. + +This skill is idempotent. Inspect existing configuration without printing +tokens; preserve unrelated app settings and lodge configuration. + +## Boundaries + +- This connector is for direct messages from one configured owner. Do not add + channel events, mentions, other users, or a first-sender fallback. +- Do not resurrect the legacy channel/mention bot or custom progress-message + setup. Native Slack Agent Sessions are the supported experience. +- Never ask the owner to paste `xoxb-` or `xapp-` secrets into Slack. Have them + enter secrets on their lodge machine, or use an already-approved private + configuration surface. +- Creating or changing the Slack app, reinstalling it, writing secrets, and + restarting the lodge are separate external/local mutations. Explain the + next mutation and obtain approval when the current request did not already + authorize it. +- Never expose token values in commands, logs, validation output, or summaries. + +## 1. Inspect without secrets + +Find the active Woltspace data root and its +`.space/platform/config.json`. Report only whether `channels.slack` exists, +whether it is enabled, and which required fields are present. Check +`woltspace status` for the Slack connector. Do not print the configuration +object or environment. + +If Slack is already healthy, ask the owner to send a DM and validate the +acceptance flow instead of rebuilding the app. + +## 2. Create or migrate the Slack app + +Read [references/manifest.json](references/manifest.json) and give that exact +manifest to the owner as a formatted code block. Existing apps should be +updated to this manifest rather than creating a second app. + +The intentional surface is: + +- Agent View with the description `gnaw. build. repeat` +- bot scopes `assistant:write`, `chat:write`, and `im:history` +- bot events `app_home_opened` and `message.im` +- Interactivity and Socket Mode enabled + +Do not add `app_mentions:read`, channel/group history, channel/group message +events, file scopes, `chat:write.customize`, or `agent_session_stopped`. +Those belong to separately implemented features. + +After the owner saves the manifest, have them reinstall the app to the +workspace so the new bot scopes take effect. + +## 3. Create the Socket Mode token + +In the Slack app's **Basic Information โ†’ App-Level Tokens**, create a token +with scope `connections:write`. This produces the private `xapp-` token. +The workspace installation produces the private `xoxb-` bot token. + +Have the owner copy their immutable Slack member ID from **Profile โ†’ More โ†’ +Copy member ID**. It must begin with `U` or `W`; never infer it from the first +message. + +## 4. Configure the lodge + +Merge this object into `.space/platform/config.json`, preserving every +unrelated key: + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "", + "app_token": "", + "owner_user": "" + } + } +} +``` + +Keep the file private. Validate only token prefixes, owner-ID shape, and field +presence; never echo the values. The legacy `ENABLE_SLACK_BOT` and +`SLACK_*` environment path is migration input, not the canonical setup. With +owner approval, move those values into `channels.slack`, verify the connector, +then remove the redundant legacy entries. + +## 5. Activate and prove + +With restart approval, record `woltspace status` and the current tmux session +count, restart through the normal Woltspace lifecycle, then verify: + +- lodge health and the Slack connector are `running`; +- all pre-existing tmux sessions survived; +- the owner can DM the app, choose a wolt, and have the original message + delivered exactly once; +- Slack shows native processing state and the Agent Session title becomes the + exact Woltspace session slug; +- the final reply retains **Open session** and status returns to active; +- a follow-up in the same thread stays pinned to the same Woltspace session. + +Do not probe with a non-owner account or post into channels unless the owner +explicitly supplies and authorizes that test. Report that those inputs fail +closed by design. + +## Troubleshooting + +- `not_authorized` from `agents.sessions.setStatus`: confirm Agent View, + `assistant:write`, Save, and workspace reinstall. +- connector disabled: check all four `channels.slack` fields and that the owner + ID begins with `U` or `W`. +- Socket Mode does not connect: confirm the app-level token has + `connections:write` and Socket Mode is enabled. +- replies work but no native processing state: the app is still on the legacy + surface; migrate it instead of enabling message-progress fallback. + +Slack images, native Stop, custom per-wolt identity, shared OAuth installation, +and channel participation are not part of setup. Treat each as a separate +product change with its own scopes and acceptance test. + +The exact session slug is the current stable title, not the final naming UX. +Woltspace should later own a human-friendly session name for browsing and sync +that name into Slack. Slack remains a presentation surface, not the source of +truth for session identity or naming. diff --git a/container/skills/setup-slack/references/manifest.json b/container/skills/setup-slack/references/manifest.json new file mode 100644 index 00000000..3116891b --- /dev/null +++ b/container/skills/setup-slack/references/manifest.json @@ -0,0 +1,41 @@ +{ + "display_information": { + "name": "Woltspace", + "description": "Woltspace", + "background_color": "#3A6644" + }, + "features": { + "agent_view": { + "agent_description": "gnaw. build. repeat" + }, + "bot_user": { + "display_name": "Woltspace", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "assistant:write", + "chat:write", + "im:history" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_home_opened", + "message.im" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "app_level_token_rotation_enabled": false, + "is_mcp_enabled": false + } +} diff --git a/docs/environment.md b/docs/environment.md index 9ad36c04..3cc9f945 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -172,11 +172,12 @@ These keep the names their own platform gave them, and are configured in **Chat platforms** โ€” `ENABLE_TELEGRAM_BOT`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS`, `TELEGRAM_CHAT_ID`, `ENABLE_SLACK_BOT`, -`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_NOTIFY_CHANNEL`. Read by +`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_OWNER_USER`, +`SLACK_NOTIFY_CHANNEL`. Read by `src/woltspace/channels.py` when planning connectors and by the adapters in -`container/bot/`. `TELEGRAM_BOT_DIR` / `TELEGRAM_BOT_MODULE` and the `SLACK_*` -equivalents point a connector at the adapter to run, and are derived by -`container_entrypoint.build_environment` rather than set by hand. +`container/bot/`. `TELEGRAM_BOT_DIR` / `TELEGRAM_BOT_MODULE` point its +connector at an owner-customized Telegram adapter. Slack always uses the +platform's owner-DM Agent View adapter; there is no custom Slack module path. `BOT_ADAPTER` names which adapter a bot process is. **Models** โ€” `LLM_MODEL` picks the bot brain's model; diff --git a/docs/native-and-container.md b/docs/native-and-container.md index 1ca5814b..5c0be063 100644 --- a/docs/native-and-container.md +++ b/docs/native-and-container.md @@ -366,7 +366,7 @@ live with it. | What | Why | Workaround for now | Intended fix | |---|---|---|---| -| Slack is silent | There is no Slack connector; only Telegram is behind the seam. | Nothing โ€” Slack stays on the container. | A `SlackConnector` beside `TelegramConnector`. | +| Slack is silent | Slack is disabled, missing one of its three credentials, or the owner member ID is malformed. | Run `woltspace status` and follow the Slack connector remedy. | Use the bundled `setup-slack` skill for the owner-DM Agent View setup. | | No public URL | Native defaults the tunnel off (deliberately). `.env`'s named-tunnel token is ignored unless `WOLTSPACE_PUBLIC_TUNNEL=true`. | `WOLTSPACE_PUBLIC_TUNNEL=true woltspace start` โ€” only with the container stopped, or two connectors load-balance the same hostname. | Document; possibly a `tunnel` block in `config.json`. | | The digest cron and the vulture reaper do not run | The wolf is a supervised connector in both runtimes now, so schedules fire natively. The vulture is gone from the container entrypoint too โ€” adoption on start does the reconciling it existed for. | None; adoption covers the reaping. | The digest is a wolf schedule, not a creature. | | A session whose workdir sits outside the data root shows its agent's workspace trust prompt | Preparing a session pre-accepts the trust dialog โ€” claude's in `~/.claude.json`, codex's in `$CODEX_HOME/config.toml` โ€” but only for directories inside the data root: pointing woltspace at a colony is the trust decision, and it does not extend past it. Everything else still asks, and a headless spawn there parks with nobody to answer. | Trust the directory once by hand (open the agent in it), or keep session workdirs inside the data root. | Nothing โ€” the data root is the boundary on purpose. | diff --git a/docs/sandboxed-adapter-callbacks.md b/docs/sandboxed-adapter-callbacks.md new file mode 100644 index 00000000..b2cbbadf --- /dev/null +++ b/docs/sandboxed-adapter-callbacks.md @@ -0,0 +1,36 @@ +# Sandboxed adapter callbacks + +The Slack DM acceptance test exposed a general delivery boundary: an agent can +produce a valid reply while its harness sandbox refuses the localhost HTTP call +used by `notify`. Per-message elevation is not a durable solution, and adapter +tokens or unrestricted channel authority must not move into the agent sandbox. + +## Proposed boundary + +Make `notify` a client of a lodge-owned local callback broker. The connector or +control-plane process runs the broker outside agent sandboxes and retains all +network credentials. A session submits only a bounded delivery intent through a +private Unix socket or atomic spool directory that its sandbox can access. + +The lodge creates a per-session endpoint or capability and binds it to the +session's registry record. The broker derives the adapter, owner, destination, +and thread from that record; a caller cannot select another session's route. +Before delivery it validates that the session is alive, the route is still +owned by that session, and the connector's owner policy permits the callback. +It also enforces message-size and rate limits. + +The durable form should use atomic enqueue, an idempotency key, explicit +accepted/delivered/failed state, bounded retry, a dead-letter state, and a +metadata-only audit trail. A Unix socket is the simplest primary transport; a +mode-0700 lodge-owned spool is a portable fallback where a harness can write +files but cannot open localhost sockets. The spool needs no-follow handling, +atomic rename, private per-session directories, and broker-created capabilities +so one same-lodge session cannot forge another session's callback. + +`notify` can retain HTTP as a compatibility path for trusted contexts, while +sandboxed sessions default to the broker. This keeps the security invariant: +the agent communicates reply data and intent, while all delivery authority, +credentials, routing, and retry policy remain local to the lodge. + +This is follow-up architecture only. The Slack DM MVP does not implement the +broker or change the current control-plane API. diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md new file mode 100644 index 00000000..39800726 --- /dev/null +++ b/docs/slack-dm-agent-mvp.md @@ -0,0 +1,145 @@ +# Slack owner-DM MVP handoff + +## What this slice ships + +Slack is a native supervised connector with the same start, stop, health, +bounded-restart and orphan-reaping lifecycle as Telegram. It accepts only +`message.im` events from one exact configured Slack member ID. Channel +mentions, multi-person DMs, bot/subtype events, malformed owner configuration +and duplicate events are rejected before history, attachment, model or session +work. Existing persisted Slack thread-to-session ownership remains the routing +model. + +Every fresh top-level text DM starts a new conversation by showing one Block Kit +`static_select` plus an exact-name text fallback. The original text is held in a +private, size-capped, ten-minute pending record. A valid owner selection claims +it atomically, removes the stored plaintext, and delivers it exactly once to a +new session for that wolt. Duplicate, stale, expired, forged or ambiguously +recovered claims never replay it. A prior selection is displayed only as a +convenience; it does not bypass the picker. Historical owned threads remain +pinned to their original session. Attachments receive an explicit deferred +response rather than being silently dropped. + +The picker is replaced by a ready acknowledgement after selection. Slack's +native Agent Session becomes `processing`, receives the exact Woltspace session +slug as its title, and returns to `active` after the final reply. Each final +reply includes a validated **Open session** link to the exact spawned session +when the platform supplies its HTTPS lodge URL. Follow-ups stay pinned to the +same Slack thread and Woltspace session. + +The earlier custom `Gnawing` progress-message surface is deprecated. Agent View +plus `assistant:write` is the only supported setup. Native Stop and customized +per-wolt sender identity remain future work. + +## Reusable private-lodge manifest + +Create one Slack app per lodge from this manifest, then create an app-level +token with `connections:write` and install the app to the workspace. Do not add +`app_mentions:read`, channel/group scopes, or channel/group message events. + +```json +{ + "display_information": { + "name": "Woltspace", + "description": "Woltspace", + "background_color": "#3A6644" + }, + "features": { + "agent_view": { + "agent_description": "gnaw. build. repeat" + }, + "bot_user": { + "display_name": "Woltspace", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": ["assistant:write", "chat:write", "im:history"] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": ["app_home_opened", "message.im"] + }, + "interactivity": {"is_enabled": true}, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "app_level_token_rotation_enabled": false, + "is_mcp_enabled": false + } +} +``` + +Configure the lodge only after copying the intended human's immutable Slack +member ID (starts with `U` or `W`): + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "", + "app_token": "", + "owner_user": "" + } + } +} +``` + +The first live acceptance is deliberately separate: confirm the member ID with +the owner, start the lodge, verify Slack is healthy, send one owner DM, choose a +wolt, and observe the original message start the exact chosen-wolt session once. +Verify a non-owner DM and a channel mention cause no work, restart, then confirm +the same owned thread recovers. Never infer the owner from the first sender. + +## Current and future Slack surfaces + +| Surface | Slack support | Additional app work | +|---|---|---| +| Slash commands | Yes | Declare each command and its request URL; Socket Mode can receive interactive payloads. | +| Global/message shortcuts | Yes | Enable Interactivity and declare callbacks. | +| Static select | Yes, via Block Kit | Implemented for owner-scoped wolt selection; Interactivity must be enabled. | +| Buttons, shortcuts and modals | Yes, via Block Kit | Future callbacks must preserve the same owner gate. | +| Suggested prompts | Yes, in Slack's Agent View | Future handler and product work. | +| Processing/active status | Implemented | Agent View plus `assistant:write`; final delivery returns status to `active`. | +| Agent Session title | Implemented | Exact Woltspace slug today; human-friendly Woltspace-owned names later. | +| Streaming responses | Partially | Uses Slack's stream API when available, with a plain final-message fallback. | +| Native Stop | Yes, through `agent_session_stopped` | Convert the app to Agent View, subscribe to the event, and map it to real session interruption. | +| Per-wolt name/icon | Yes | Add `chat:write.customize`; keep sender identity auditable and owner-controlled. | + +Switching an existing Slack app to Agent View and adding scopes/events are +external app mutations and require explicit owner approval plus reinstall. +Do not retain the old channel/mention manifest or custom progress-message setup +as an alternate supported mode. + +## From private setup to OAuth installation + +The private MVP stores one lodge's bot/app tokens and owner ID locally. A later +multi-lodge installer should use Slack OAuth v2 for the bot installation, +persist each installation by team/enterprise ID, verify the installing user, +ask them to confirm the owner member ID, and create/rotate the Socket Mode +app-level token through an owner-controlled setup step. The reusable manifest +remains the source of truth for scopes and subscriptions. OAuth must not turn +"installer" into ambient multi-user authority: each lodge still pins one owner +and rejects every other inbound identity by default. + +## Cross-channel follow-up + +Telegram must adopt the same identity-bound selection invariant after this +Slack slice: when the allowed Telegram identity has no valid selected wolt, +show **Choose your wolt**; never silently fall back to `active_dog`. Keep each +channel's selection scoped to its authenticated owner identity, revalidate it +against the live eligible-wolt roster, and reopen the picker if it disappears +or becomes ineligible. This is roadmap scope only and is not implemented here. + +Official references: Slack's `chat.startStream`, `assistant.threads.setStatus`, +`agent_session_stopped`, app manifests, Socket Mode and OAuth v2 documentation. + +## Session naming follow-up + +The exact Woltspace session slug is the stable Agent Session title for this +MVP. The intended browsing UX is a later Woltspace-owned human-friendly session +name that is synchronized into Slack. Slack must not become the source of truth +for session identity or naming. diff --git a/pyproject.toml b/pyproject.toml index a2f2382d..ec33749a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,8 @@ packages = ["src/woltspace"] "docs/shared-skills.md" = "woltspace/_bundle/docs/shared-skills.md" "docs/colony-seeds.md" = "woltspace/_bundle/docs/colony-seeds.md" "docs/digging-v0.md" = "woltspace/_bundle/docs/digging-v0.md" +"docs/slack-dm-agent-mvp.md" = "woltspace/_bundle/docs/slack-dm-agent-mvp.md" +"docs/sandboxed-adapter-callbacks.md" = "woltspace/_bundle/docs/sandboxed-adapter-callbacks.md" [tool.hatch.build.targets.sdist] include = [ @@ -73,5 +75,7 @@ include = [ "/docs/shared-skills.md", "/docs/colony-seeds.md", "/docs/digging-v0.md", + "/docs/slack-dm-agent-mvp.md", + "/docs/sandboxed-adapter-callbacks.md", "/LICENSE", ] diff --git a/server/notify.py b/server/notify.py index 319043a2..48eeb3af 100644 --- a/server/notify.py +++ b/server/notify.py @@ -5,9 +5,12 @@ """ import json +import logging +import urllib.parse from pathlib import Path import httpx +from sessions import SessionRegistry from .config import ( STATE_DIR, @@ -18,6 +21,8 @@ ) from .state import sanitize_session +logger = logging.getLogger(__name__) + class NoNotificationTarget(RuntimeError): """Nowhere to deliver to โ€” a configuration gap, not a platform failure. @@ -28,6 +33,21 @@ class NoNotificationTarget(RuntimeError): """ +def _slack_session_link(session: str, routing: dict | None) -> str: + """Return only the adapter-validated link bound to this exact session.""" + url = (routing or {}).get("slack_session_link", "") + try: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except (TypeError, ValueError): + return "" + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or + parsed.password or parsed.path != "/tui" or parsed.fragment or + query != {"session": [session]}): + return "" + return url + + def read_session_registry(session: str) -> dict | None: """Find a session file by scanning all per-wolt .state/sessions/ dirs.""" @@ -93,6 +113,25 @@ async def slack_send(token: str, channel: str, thread_ts: str | None, text: str) return data +async def slack_set_agent_status( + token: str, channel: str, thread_ts: str, status: str +) -> dict: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://slack.com/api/agents.sessions.setStatus", + json={ + "channel_id": channel, + "thread_ts": thread_ts, + "status": status, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + data = response.json() + if not data.get("ok"): + raise RuntimeError(data.get("error", "agents.sessions.setStatus error")) + return data + + async def _send_telegram(session: str, message: str, chat_id: str) -> dict: """Send a notification via Telegram with den-reply footer.""" token = dotenv_env("TELEGRAM_BOT_TOKEN") @@ -117,7 +156,14 @@ async def _send_telegram(session: str, message: str, chat_id: str) -> dict: return {"adapter": "telegram", "chat_id": chat_id} -async def _send_slack(message: str, channel: str, thread_ts: str | None = None) -> dict: +async def _send_slack( + message: str, + channel: str, + thread_ts: str | None = None, + *, + session: str = "", + routing: dict | None = None, +) -> dict: """Send a notification via Slack to a specific channel/thread.""" token = dotenv_env("SLACK_BOT_TOKEN") if not token: @@ -126,7 +172,30 @@ async def _send_slack(message: str, channel: str, thread_ts: str | None = None) channel = dotenv_env("SLACK_NOTIFY_CHANNEL") if not channel: raise RuntimeError("no slack channel provided and SLACK_NOTIFY_CHANNEL not set") - await slack_send(token, channel, thread_ts, message) + progress_mode = (routing or {}).get("slack_progress_mode", "") + session_link = _slack_session_link(session, routing) + final_message = message + if session_link: + final_message += f"\n\n<{session_link}|Open session>" + if progress_mode == "agent" and thread_ts: + try: + await slack_send(token, channel, thread_ts, final_message) + finally: + try: + await slack_set_agent_status( + token, channel, thread_ts, "active" + ) + except Exception as exc: + logger.warning("Could not clear Slack native agent status: %s", exc) + if session and routing: + SessionRegistry(WOLTS_DIR).update( + session, + wolt=routing.get("wolt", ""), + slack_progress_mode="", + slack_progress_ts="", + ) + else: + await slack_send(token, channel, thread_ts, final_message) append_chat_history("slack", channel, message) return {"adapter": "slack", "channel": channel} @@ -139,11 +208,27 @@ async def send_notification(session: str, message: str, explicit: dict | None = {"adapter": "telegram", "chat_id": "98765"} """ - # 1. Explicit routing โ€” caller knows exactly where to send + routing = read_session_registry(session) if session else None + + # 1. Explicit routing โ€” caller knows exactly where to send. A temporary + # progress surface is usable only when the explicit route exactly matches + # the authoritative session record. if explicit and explicit.get("adapter"): adapter = explicit["adapter"] if adapter == "slack": - return await _send_slack(message, explicit.get("channel", ""), explicit.get("thread_ts")) + channel = explicit.get("channel", "") + thread_ts = explicit.get("thread_ts") + exact = bool( + routing + and routing.get("adapter") == "slack" + and routing.get("chat_id") == channel + and routing.get("thread_ts") == thread_ts + ) + return await _send_slack( + message, channel, thread_ts, + session=session if exact else "", + routing=routing if exact else None, + ) if adapter == "telegram": chat_id = explicit.get("chat_id", "") if chat_id: @@ -151,7 +236,6 @@ async def send_notification(session: str, message: str, explicit: dict | None = # 2. Session registry lookup โ€” find routing from session metadata if session: - routing = read_session_registry(session) if routing: adapter = routing.get("adapter") if adapter == "slack": @@ -159,6 +243,8 @@ async def send_notification(session: str, message: str, explicit: dict | None = message, routing.get("chat_id", ""), routing.get("thread_ts"), + session=session, + routing=routing, ) if adapter == "telegram": chat_id = routing.get("chat_id") diff --git a/src/woltspace/channels.py b/src/woltspace/channels.py index cb2b7b34..afe2de09 100644 --- a/src/woltspace/channels.py +++ b/src/woltspace/channels.py @@ -17,6 +17,7 @@ import importlib.util import os +import re import shutil import subprocess import sys @@ -75,7 +76,7 @@ def _bot_project(bot_dir: str, install_root: Path) -> Path | None: def _interpreter( - bot_dir: str, isolation: str, install_root: Path + bot_dir: str, isolation: str, install_root: Path, dependency: str = "telegram" ) -> tuple[str, ...]: """The interpreter that owns the adapter's dependencies. @@ -88,7 +89,7 @@ def _interpreter( A container built some other way (a checkout with missing dependencies) still falls back to that uv project, which is what it has always used. """ - if isolation != "host" and not _module_available("telegram"): + if isolation != "host" and not _module_available(dependency): uv = shutil.which("uv") project = _bot_project(bot_dir, install_root) if uv and project is not None: @@ -268,6 +269,97 @@ def plan( ) +class SlackConnector: + """A fail-closed, owner-only Slack DM connector.""" + + name = "slack" + + def plan( + self, layout: RuntimeLayout, env: Mapping[str, str] | None = None + ) -> ConnectorPlan: + values = dict(os.environ if env is None else env) + settings = channel_config(layout, self.name, values) + path = config_path(layout, values) + configured = bool(settings.get("enabled", False)) + raw_enabled = values.get("ENABLE_SLACK_BOT") + from_env = _truthy(raw_enabled) if raw_enabled is not None else False + env_disables = raw_enabled is not None and not from_env + bot_token = (values.get("SLACK_BOT_TOKEN") or settings.get("bot_token") or "").strip() + app_token = (values.get("SLACK_APP_TOKEN") or settings.get("app_token") or "").strip() + owner_user = (values.get("SLACK_OWNER_USER") or settings.get("owner_user") or "").strip() + remedy = ( + f'Set channels.slack = {{"enabled": true, "bot_token": "", ' + f'"app_token": "", "owner_user": ""}} in {path}.' + ) + if env_disables or not (configured or from_env): + return ConnectorPlan(self.name, False, "disabled", remedy=remedy) + if not _truthy(values.get("WOLTSPACE_ENTRYPOINT", "")): + return ConnectorPlan( + self.name, False, + "not the platform entrypoint; a guest never opens Socket Mode", + remedy="Run the control plane through `woltspace start` to own Slack.", + ) + missing = [ + label for label, value in ( + ("bot token", bot_token), + ("app token", app_token), + ("owner user ID", owner_user), + ) if not value + ] + if missing: + return ConnectorPlan( + self.name, False, f"enabled without {', '.join(missing)}", remedy=remedy + ) + if re.fullmatch(r"[UW][A-Z0-9]{8,}", owner_user) is None: + return ConnectorPlan( + self.name, False, "enabled with a malformed owner user ID", remedy=remedy + ) + + bot_dir = str(layout.install_root / "container") + module = "bot.slack_adapter" + child_env = export_both({ + "SLACK_BOT_TOKEN": bot_token, + "SLACK_APP_TOKEN": app_token, + "SLACK_OWNER_USER": owner_user, + "WOLTSPACE_WOLTS_DIR": str(layout.wolts_dir), + "WOLTSPACE_DIR": str(layout.install_root), + "WOLTSPACE_ISOLATION": layout.isolation, + "WOLTSPACE_HOST": layout.host, + "WOLTSPACE_PORT": str(layout.port), + "WOLTSPACE_API": layout.endpoint, + "PYTHONPATH": os.pathsep.join( + part for part in ( + bot_dir, str(layout.runtime_lib), values.get("PYTHONPATH", "") + ) if part + ), + }) + interpreter = _interpreter( + bot_dir, layout.isolation, layout.install_root, "slack_bolt" + ) + if interpreter == (sys.executable,) and not _module_available("slack_bolt"): + return ConnectorPlan( + self.name, False, "enabled but slack-bolt is not installed", + remedy=( + "Reinstall woltspace to restore its dependencies: " + "`uv tool install --force woltspace` " + "(from a checkout: `uv tool install --force .`), then `woltspace start`." + ), + ) + detail = f"owner-only DMs ยท {module} from {bot_dir}" + if _truthy(values.get("DEV_MODE", "")) and _module_available("watchfiles"): + command = ( + *interpreter, "-m", "watchfiles", "--filter", "python", + f"{' '.join(interpreter)} -m {module}", "bot/", + ) + detail += " (dev reload)" + else: + command = (*interpreter, "-m", module) + return ConnectorPlan( + self.name, True, detail, command, bot_dir, child_env, remedy, + ("-m", module), + ) + + TOKEN_BUSY_DETAIL = "another process is already polling this bot token" TOKEN_BUSY_REMEDY = ( "One bot token can only be polled by one process. Stop the other poller, or " @@ -535,7 +627,7 @@ def plan( CONNECTORS: tuple[ChannelConnector, ...] = ( - TelegramConnector(), TuiBridgeConnector(), WolfConnector(), + TelegramConnector(), SlackConnector(), TuiBridgeConnector(), WolfConnector(), ) @@ -554,7 +646,10 @@ def connector_secrets(plans: list[ConnectorPlan]) -> dict[str, str]: for plan in plans: if not plan.enabled: continue - for key in ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS"): + for key in ( + "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", + "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_OWNER_USER", + ): value = plan.env.get(key) if value: secrets[key] = value diff --git a/src/woltspace/container_entrypoint.py b/src/woltspace/container_entrypoint.py index 6d2b2a90..8920755a 100644 --- a/src/woltspace/container_entrypoint.py +++ b/src/woltspace/container_entrypoint.py @@ -330,7 +330,6 @@ def build_environment( """ source = os.environ if env is None else env tg_dir, tg_mod = resolve_bot_module(wolt_dir, woltspace_dir, "telegram") - slack_dir, slack_mod = resolve_bot_module(wolt_dir, woltspace_dir, "slack") return export_both({ "WOLTSPACE_WOLT_NAME": wolt_name, "WOLTSPACE_WOLT_DIR": str(wolt_dir), @@ -338,8 +337,6 @@ def build_environment( "DEV_MODE": "true" if dev_mode else "false", "TELEGRAM_BOT_DIR": tg_dir, "TELEGRAM_BOT_MODULE": tg_mod, - "SLACK_BOT_DIR": slack_dir, - "SLACK_BOT_MODULE": slack_mod, "PYTHONPATH": f"{woltspace_dir}/container/lib:{source.get('PYTHONPATH', '')}", "PATH": f"{woltspace_dir}/container/bin:{source.get('PATH', '')}", "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", @@ -460,53 +457,6 @@ def preload_viewport(wolt_name: str, state_dir: Path) -> str: return url -def start_slack_bot(env: dict[str, str]) -> subprocess.Popen | None: - """Slack has no connector yet, so it is still launched by hand. - - Telegram, the wolf scheduler and the TUI pty bridge are all supervised - children of the control plane (ChannelConnector โ€” see - src/woltspace/channels.py). Starting any of them here would give the colony - two of it: two pollers on one bot token, two schedulers firing every cron - twice. Inspect them with: curl -s localhost:7777/health | jq .connectors - (NOT `woltspace status` โ€” inside the container that name resolves to - container/bin/woltspace, a different CLI which knows nothing about - connectors.) - - Slack runs on the installed interpreter, which owns slack-bolt via the - `connectors` extra. - """ - if not (env.get("ENABLE_SLACK_BOT") == "true" - and env.get("SLACK_BOT_TOKEN") and env.get("SLACK_APP_TOKEN")): - return None - - bot_dir = env["SLACK_BOT_DIR"] - module = env["SLACK_BOT_MODULE"] - dev_mode = env.get("DEV_MODE") == "true" - print(f"starting slack bot ({bot_dir}, dev={env.get('DEV_MODE')})...") - - if dev_mode: - command = ["woltspace-python", "-m", "watchfiles", "--filter", "python", - f"python -m {module}", "bot/"] - else: - command = ["woltspace-python", "-m", module] - - child_env = dict(env) - child_env["BOT_ADAPTER"] = "slack" - child_env["PYTHONPATH"] = f"{bot_dir}:{env.get('PYTHONPATH', '')}" - try: - # start_new_session is bash's `disown`: the bot outlives nothing here, - # but it must not take a terminal signal meant for the control plane. - return subprocess.Popen(command, cwd=bot_dir, env=child_env, - start_new_session=True) - except OSError as exc: - # A missing interpreter or an unusable cwd raises here, in the boot - # process. Bash launched this in a backgrounded subshell, where the same - # failure cost one line of stderr and nothing else โ€” a chat adapter that - # cannot start is not a reason to withhold the whole colony. - print(f"slack bot failed to start: {exc}") - return None - - def report_tunnel_url(wolts_dir: Path) -> None: """Report the tunnel URL once it lands, without standing in the way. @@ -592,10 +542,11 @@ def run_node_phase() -> int: parents=True, exist_ok=True) open_tmux_window(wolt_name, wolt_dir, wolts_dir) - start_slack_bot(dict(os.environ)) start_tunnel_report(wolts_dir, dict(os.environ)) # โ”€โ”€ The control plane โ”€โ”€ + # Every chat adapter is now a supervised ChannelConnector; boot must not + # launch a detached duplicate before handing ownership to `serve`. # The same supervisor a native user runs, from the same installed package, # in this very process: docker's SIGTERM reaches the owner of the connectors # instead of a shell that would leave them orphaned. diff --git a/test/test_channel_connectors.py b/test/test_channel_connectors.py index dd236f3a..4d29f118 100644 --- a/test/test_channel_connectors.py +++ b/test/test_channel_connectors.py @@ -21,6 +21,7 @@ ) from woltspace.channels import ( # noqa: E402 ConnectorPlan, + SlackConnector, TelegramConnector, connector_secrets, plan_connectors, @@ -73,7 +74,72 @@ def test_disabled_without_config(self, layout): plan = TelegramConnector().plan(layout, {}) assert plan.enabled is False assert plan.command == () - assert "config.json" in plan.remedy + + +class TestSlackPlan: + BASE = { + "ENABLE_SLACK_BOT": "true", + "SLACK_BOT_TOKEN": "xoxb-test", + "SLACK_APP_TOKEN": "xapp-test", + "SLACK_OWNER_USER": "U12345678", + "WOLTSPACE_ENTRYPOINT": "1", + } + + def test_disabled_without_an_explicit_owner(self, layout): + env = dict(self.BASE) + env.pop("SLACK_OWNER_USER") + plan = SlackConnector().plan(layout, env) + assert plan.enabled is False + assert "owner user ID" in plan.detail + + @pytest.mark.parametrize("owner", ["", "jerpint", "C12345678", "U12 345678"]) + def test_malformed_owner_configuration_fails_closed(self, layout, owner): + plan = SlackConnector().plan(layout, dict(self.BASE, SLACK_OWNER_USER=owner)) + assert plan.enabled is False + + def test_enabled_plan_carries_private_credentials_only_in_child_env(self, layout): + plan = SlackConnector().plan(layout, self.BASE) + assert plan.enabled is True + assert plan.command[-2:] == ("-m", "bot.slack_adapter") + assert plan.env["SLACK_OWNER_USER"] == "U12345678" + assert plan.env["SLACK_BOT_TOKEN"] == "xoxb-test" + public = json.dumps(plan.to_record()) + assert "xoxb-test" not in public + assert "xapp-test" not in public + assert "U12345678" not in public + + def test_custom_adapter_environment_cannot_replace_owner_dm_runtime(self, layout): + plan = SlackConnector().plan(layout, { + **self.BASE, + "SLACK_BOT_DIR": "/tmp/legacy-slack", + "SLACK_BOT_MODULE": "legacy.slack_adapter", + }) + + assert plan.cwd == str(layout.install_root / "container") + assert plan.command[-2:] == ("-m", "bot.slack_adapter") + assert "legacy" not in plan.detail + + def test_config_shape_and_all_three_secrets_are_resolved(self, layout): + write_config(layout, {"channels": {"slack": { + "enabled": True, + "bot_token": "xoxb-config", + "app_token": "xapp-config", + "owner_user": "U87654321", + }}}) + plan = SlackConnector().plan(layout, {"WOLTSPACE_ENTRYPOINT": "1"}) + assert plan.enabled is True + assert connector_secrets([plan]) == { + "SLACK_BOT_TOKEN": "xoxb-config", + "SLACK_APP_TOKEN": "xapp-config", + "SLACK_OWNER_USER": "U87654321", + } + + def test_ambient_tokens_never_start_a_guest(self, layout): + env = dict(self.BASE) + env.pop("WOLTSPACE_ENTRYPOINT") + plan = SlackConnector().plan(layout, env) + assert plan.enabled is False + assert "not the platform entrypoint" in plan.detail def test_enabled_from_data_root_config(self, layout): write_config(layout, { @@ -593,10 +659,10 @@ def test_boot_no_longer_launches_a_second_telegram_bot(self): dev_mode=False, env={}, ) assert env["TELEGRAM_BOT_MODULE"] == "bot.telegram_adapter" - # ...and the only process boot starts by hand is slack, which has no - # connector yet. A second telegram poller on one token is the bug. + # No chat adapter is launched by hand; the connector supervisor owns + # both Telegram and Slack. A second poller on one token is the bug. launchers = [name for name in vars(container_entrypoint) - if name.startswith("start_") and "slack" not in name] + if name.startswith("start_")] assert launchers == ["start_tunnel_report"] def test_boot_runs_the_installed_control_plane_in_its_own_process(self): diff --git a/test/test_container_entrypoint.py b/test/test_container_entrypoint.py index b405c98b..6954e90b 100644 --- a/test/test_container_entrypoint.py +++ b/test/test_container_entrypoint.py @@ -80,14 +80,12 @@ def test_dev_mode_is_declared_not_inferred(self, tmp_path): assert not boot.is_truthy("") and not boot.is_truthy(None) assert not boot.is_truthy("false") - def test_bot_modules_fall_back_to_the_platform_adapters(self, tmp_path): + def test_telegram_module_falls_back_to_the_platform_adapter(self, tmp_path): env = self._env(tmp_path) bundle = tmp_path / "bundle" assert env["TELEGRAM_BOT_MODULE"] == "bot.telegram_adapter" assert env["TELEGRAM_BOT_DIR"] == str(bundle / "container") - assert env["SLACK_BOT_MODULE"] == "bot.slack_adapter" - assert env["SLACK_BOT_DIR"] == str(bundle / "container") def test_a_wolt_owned_adapter_wins(self, tmp_path): wolt_dir = tmp_path / "wolts" / "mywolt" @@ -98,8 +96,8 @@ def test_a_wolt_owned_adapter_wins(self, tmp_path): assert env["TELEGRAM_BOT_DIR"] == str(wolt_dir) assert env["TELEGRAM_BOT_MODULE"] == "wolt.bot.telegram_adapter" - # Slack has no override, so it still points at the platform's copy - assert env["SLACK_BOT_MODULE"] == "bot.slack_adapter" + assert "SLACK_BOT_MODULE" not in env + assert "SLACK_BOT_DIR" not in env def test_nothing_is_written_to_the_real_environment(self, tmp_path): import os @@ -284,61 +282,6 @@ def test_preloads_the_viewport_with_the_wolt_site(self, tmp_path): assert isinstance(payload["updated"], int) -# --------------------------------------------------------------------------- -# Slack โ€” the one process boot still starts by hand -# --------------------------------------------------------------------------- - -class TestSlackBot: - BASE = { - "ENABLE_SLACK_BOT": "true", - "SLACK_BOT_TOKEN": "xoxb-token", - "SLACK_APP_TOKEN": "xapp-token", - "SLACK_BOT_DIR": "/bundle/container", - "SLACK_BOT_MODULE": "bot.slack_adapter", - "DEV_MODE": "false", - "PYTHONPATH": "/bundle/container/lib:", - } - - def _launch(self, env): - with patch.object(boot.subprocess, "Popen") as popen: - boot.start_slack_bot(env) - return popen - - def test_not_started_without_both_tokens(self): - for missing in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"): - env = dict(self.BASE, **{missing: ""}) - assert self._launch(env).call_count == 0 - - def test_not_started_unless_enabled(self): - assert self._launch(dict(self.BASE, ENABLE_SLACK_BOT="false")).call_count == 0 - - def test_runs_on_the_installed_interpreter_detached(self): - popen = self._launch(dict(self.BASE)) - - args, kwargs = popen.call_args - assert args[0] == ["woltspace-python", "-m", "bot.slack_adapter"] - assert kwargs["cwd"] == "/bundle/container" - assert kwargs["start_new_session"] is True - assert kwargs["env"]["BOT_ADAPTER"] == "slack" - assert kwargs["env"]["PYTHONPATH"] == "/bundle/container:/bundle/container/lib:" - - def test_a_bot_that_cannot_start_is_a_warning_not_a_dead_colony(self, capsys): - """Bash backgrounded this; the failure cost one line and nothing else.""" - with patch.object(boot.subprocess, "Popen", - side_effect=FileNotFoundError(2, "no woltspace-python")): - assert boot.start_slack_bot(dict(self.BASE)) is None - - assert "slack bot failed to start:" in capsys.readouterr().out - - def test_dev_mode_wraps_it_in_watchfiles(self): - popen = self._launch(dict(self.BASE, DEV_MODE="true")) - - assert popen.call_args.args[0] == [ - "woltspace-python", "-m", "watchfiles", "--filter", "python", - "python -m bot.slack_adapter", "bot/", - ] - - # --------------------------------------------------------------------------- # Tunnel reporting # --------------------------------------------------------------------------- diff --git a/test/test_native_resilience.py b/test/test_native_resilience.py index 2b701f75..e73cb786 100644 --- a/test/test_native_resilience.py +++ b/test/test_native_resilience.py @@ -871,7 +871,7 @@ def test_no_connector_is_planned_from_inherited_environment(self, tmp_path): layout = _layout(tmp_path, isolation="external") plans = {plan.name: plan for plan in plan_connectors(layout)} - assert [plan.enabled for plan in plans.values()] == [False, False, False] + assert [plan.enabled for plan in plans.values()] == [False, False, False, False] assert "ambient environment" in plans["telegram"].detail assert plans["telegram"].command == () # The pty bridge is a guest here too: the real instance owns that port. diff --git a/test/test_notify_cli.py b/test/test_notify_cli.py index 474fbb9a..db7def21 100644 --- a/test/test_notify_cli.py +++ b/test/test_notify_cli.py @@ -254,6 +254,53 @@ def test_rejects_empty_stdin_and_invalid_chat_id(tmp_path): assert not capture.exists() +def test_transport_failure_reports_curl_diagnosis_without_secrets(tmp_path): + env, capture = _environment(tmp_path) + curl = Path(env["PATH"].split(":", 1)[0]) / "curl" + message = "harmless but private delivery text" + token = "xoxb-test-secret-token" + curl.write_text( + f"""#!/bin/bash +printf '%s\\n' 'curl: (7) could not connect; {message}; {token}' >&2 +exit 7 +""" + ) + env["SLACK_BOT_TOKEN"] = token + + result = subprocess.run( + [NOTIFY, "--slack", "C0123ABC", "123.456"], + input=message, + text=True, + capture_output=True, + env=env, + ) + + assert result.returncode == 1 + assert "transport failed (curl exit 7)" in result.stderr + assert "could not connect" in result.stderr + assert "" in result.stderr + assert message not in result.stderr + assert token not in result.stderr + assert not capture.exists() + + +def test_empty_successful_transport_response_is_diagnosed(tmp_path): + env, _ = _environment(tmp_path) + curl = Path(env["PATH"].split(":", 1)[0]) / "curl" + curl.write_text("#!/bin/bash\nexit 0\n") + + result = subprocess.run( + [NOTIFY, "--telegram", "123"], + input="delivery text", + text=True, + capture_output=True, + env=env, + ) + + assert result.returncode == 1 + assert "invalid or empty API response" in result.stderr + + def test_session_context_inlines_complete_heredocs_for_explicit_routes(): from sessions import _adapter_context diff --git a/test/test_notify_progress.py b/test/test_notify_progress.py new file mode 100644 index 00000000..8fe573e1 --- /dev/null +++ b/test/test_notify_progress.py @@ -0,0 +1,93 @@ +"""Slack final delivery clears one exact native Agent Session status.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from server import notify + + +def route(**changes): + return { + "name": "n00b-session-1", + "wolt": "n00b", + "adapter": "slack", + "chat_id": "D123", + "thread_ts": "1000.1", + "slack_progress_mode": "agent", + "slack_progress_ts": "", + "slack_session_link": "https://lodge.test/tui?session=n00b-session-1", + **changes, + } + + +@pytest.mark.asyncio +async def test_invalid_or_cross_session_link_is_not_attached(monkeypatch): + routing = route( + slack_session_link="https://evil.test/tui?session=other-session", + ) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + send = AsyncMock(return_value={"ok": True}) + status = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "slack_set_agent_status", status) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + monkeypatch.setattr( + notify, "SessionRegistry", lambda root: Mock(update=Mock()) + ) + + await notify._send_slack( + "final", "D123", "1000.1", + session="n00b-session-1", routing=routing, + ) + + send.assert_awaited_once_with("xoxb-test", "D123", "1000.1", "final") + + +@pytest.mark.asyncio +async def test_native_agent_progress_posts_final_then_returns_active(monkeypatch): + routing = route( + slack_progress_mode="agent", + slack_progress_ts="", + ) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + send = AsyncMock(return_value={"ok": True}) + status = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "slack_set_agent_status", status) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + update = Mock() + monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) + + await notify._send_slack( + "final", "D123", "1000.1", + session="n00b-session-1", routing=routing, + ) + + send.assert_awaited_once_with( + "xoxb-test", "D123", "1000.1", + "final\n\n", + ) + status.assert_awaited_once_with( + "xoxb-test", "D123", "1000.1", "active" + ) + update.assert_called_once_with( + "n00b-session-1", wolt="n00b", + slack_progress_mode="", slack_progress_ts="", + ) + + +@pytest.mark.asyncio +async def test_route_mismatch_cannot_claim_progress(monkeypatch): + monkeypatch.setattr(notify, "read_session_registry", lambda session: route()) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + send = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + + await notify.send_notification( + "n00b-session-1", "final", + explicit={"adapter": "slack", "channel": "DOTHER", "thread_ts": "1000.1"}, + ) + + send.assert_awaited_once_with("xoxb-test", "DOTHER", "1000.1", "final") diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py new file mode 100644 index 00000000..75249699 --- /dev/null +++ b/test/test_slack_adapter.py @@ -0,0 +1,586 @@ +"""Fail-closed Slack DM admission and response transport.""" + +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "container")) + +from bot import slack_adapter as slack # noqa: E402 + + +@pytest.fixture(autouse=True) +def isolated_admission(monkeypatch, tmp_path): + monkeypatch.setenv("SLACK_OWNER_USER", "U12345678") + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + monkeypatch.setattr(slack, "OWNER_SELECTIONS_FILE", tmp_path / "owners.json") + monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "threads.json") + monkeypatch.setattr(slack, "PENDING_MESSAGES_FILE", tmp_path / "pending.json") + monkeypatch.setattr(slack, "PENDING_LOCK_FILE", tmp_path / "pending.lock") + slack._seen_event_ids.clear() + slack._seen_event_order.clear() + slack._owner_selections.clear() + slack._thread_sessions.clear() + slack._active_threads.clear() + + +def event(**changes): + base = { + "type": "message", + "channel_type": "im", + "channel": "D12345678", + "user": "U12345678", + "ts": "1234.5678", + "event_ts": "1234.5678", + "text": "hello", + } + return {**base, **changes} + + +class FakeApp: + def __init__(self): + self.handlers = {} + + def event(self, event_type): + def register(handler): + self.handlers[event_type] = handler + return handler + return register + + def action(self, action_id): + return self.event(f"action:{action_id}") + + +def install_fake_app(monkeypatch): + app = FakeApp() + monkeypatch.setattr(slack, "AsyncApp", lambda **kwargs: app) + slack.create_app() + return app + + +class TestOwnerDmAdmission: + @pytest.mark.parametrize("owner", ["", "jerpint", "C12345678", "U12 345678"]) + def test_missing_or_malformed_owner_fails_before_admission(self, monkeypatch, owner): + monkeypatch.setenv("SLACK_OWNER_USER", owner) + assert slack._accept_owner_dm(event(), {}) == (False, "owner_not_configured") + + def test_non_owner_is_rejected(self): + assert slack._accept_owner_dm(event(user="U87654321"), {}) == ( + False, "not_owner" + ) + + @pytest.mark.parametrize("channel_type", ["channel", "group", "mpim"]) + def test_channel_and_app_mention_surfaces_are_rejected(self, channel_type): + mention = event(type="app_mention", channel_type=channel_type) + assert slack._accept_owner_dm(mention, {}) == (False, "not_dm") + + @pytest.mark.parametrize("changes", [{"bot_id": "B1"}, {"subtype": "message_changed"}]) + def test_bot_and_subtype_events_are_rejected(self, changes): + assert slack._accept_owner_dm(event(**changes), {}) == ( + False, "non_human_event" + ) + + def test_duplicate_and_retried_events_are_claimed_once(self): + body = {"event_id": "Ev123"} + assert slack._accept_owner_dm(event(), body) == (True, "accepted") + assert slack._accept_owner_dm(event(), body) == (False, "duplicate") + + def test_missing_stable_event_identity_fails_closed(self): + incoming = event(ts=None, event_ts=None) + assert slack._accept_owner_dm(incoming, {}) == (False, "missing_event_id") + + +@pytest.mark.asyncio +async def test_every_rejected_surface_returns_before_history_or_session_work(monkeypatch): + class FakeApp: + def __init__(self): + self.handlers = {} + + def event(self, event_type): + def register(handler): + self.handlers[event_type] = handler + return handler + return register + + def action(self, action_id): + return self.event(f"action:{action_id}") + + fake_app = FakeApp() + monkeypatch.setattr(slack, "AsyncApp", lambda **kwargs: fake_app) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + response = Mock(side_effect=AssertionError("model/session work must not run")) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "_build_thread_context", history) + monkeypatch.setattr(slack, "start_claude_session", response) + slack.create_app() + + assert set(fake_app.handlers) == {"message", "action:select_wolt"} + handler = fake_app.handlers["message"] + rejected = [ + (event(user="U87654321"), {"event_id": "EvOther"}), + (event(type="app_mention", channel_type="channel"), {"event_id": "EvMention"}), + (event(bot_id="B1"), {"event_id": "EvBot"}), + (event(subtype="message_changed"), {"event_id": "EvSubtype"}), + ] + for incoming, body in rejected: + await handler(incoming, AsyncMock(), {}, body) + + monkeypatch.setenv("SLACK_OWNER_USER", "not-a-member-id") + await handler(event(), AsyncMock(), {}, {"event_id": "EvNoOwner"}) + monkeypatch.setenv("SLACK_OWNER_USER", "U12345678") + + # A retry is also rejected before downstream work. + duplicate = event(event_ts="2000.1", ts="2000.1") + duplicate_body = {"event_id": "EvDuplicate"} + assert slack._accept_owner_dm(duplicate, duplicate_body) == (True, "accepted") + await handler(duplicate, AsyncMock(), {}, duplicate_body) + + extract.assert_not_called() + history.assert_not_awaited() + response.assert_not_called() + + +@pytest.mark.asyncio +async def test_streaming_success_stops_the_stream_without_posting_fallback(): + client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} + client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} + + await slack._post_text(client, "D1", "1000.1", "U12345678", "answer") + + client.chat_stopStream.assert_awaited_once_with(channel="D1", ts="2000.1") + client.chat_postMessage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_streaming_failure_uses_plain_postmessage_fallback(): + client = AsyncMock() + client.chat_startStream.side_effect = RuntimeError("not an agent app") + + await slack._post_text(client, "D1", "1000.1", "U12345678", "answer") + + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", text="answer" + ) + client.chat_stopStream.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_session_route_requires_native_agent_view(monkeypatch, tmp_path): + deliver = Mock(side_effect=AssertionError("must not deliver without Agent View")) + monkeypatch.setattr(slack, "message_session", deliver) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") + owner = {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", + text=("Slack Agent View is not authorized. Reinstall with " + "`assistant:write` before retrying."), + ) + deliver.assert_not_called() + + +@pytest.mark.asyncio +async def test_dead_session_releases_thread_back_to_dog(monkeypatch, tmp_path): + async def dead(*args): + return {"ok": False, "error": "gone"} + + monkeypatch.setattr(slack, "message_session", Mock(return_value=await dead())) + monkeypatch.setattr(slack.registry, "update", Mock()) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "owners.json") + monkeypatch.setattr(slack, "_thread_sessions", { + "D1:1000.1": {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} + }) + client = AsyncMock() + client.agents_sessions_setStatus.return_value = {"ok": True} + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} + owner = slack._thread_sessions["D1:1000.1"] + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + assert "D1:1000.1" not in slack._thread_sessions + assert "send again" in client.chat_postMessage.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_native_agent_status_replaces_custom_progress(monkeypatch, tmp_path): + result = { + "ok": True, + "status": "delivered", + "url": "https://lodge.test/tui?session=n00b-old-1", + } + monkeypatch.setattr(slack, "message_session", Mock(return_value=result)) + update = Mock() + monkeypatch.setattr(slack.registry, "update", update) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + client = AsyncMock() + client.agents_sessions_setStatus.return_value = { + "ok": True, "status": "processing", "agent_status": "processing" + } + owner = { + "session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon", + "session_link": "https://lodge.test/tui?session=n00b-old-1", + } + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + client.agents_sessions_setStatus.assert_awaited_once_with( + channel_id="D1", thread_ts="1000.1", status="processing" + ) + client.chat_postMessage.assert_not_awaited() + assert any( + call.kwargs.get("slack_progress_mode") == "agent" + for call in update.call_args_list + ) + + +def test_selection_storage_is_atomic_private_and_owner_keyed(monkeypatch, tmp_path): + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + path = tmp_path / "_owner_selections.json" + monkeypatch.setattr(slack, "OWNER_SELECTIONS_FILE", path) + + slack._save_owner_selections({"U12345678": "n00b"}) + + assert path.stat().st_mode & 0o777 == 0o600 + assert path.read_text() == '{"U12345678": "n00b"}\n' + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_picker_caps_static_select_deterministically_but_text_lists_all(): + wolts = {f"wolt-{index:03}": {"name": f"wolt-{index:03}"} for index in range(105)} + blocks = slack._picker_blocks(wolts) + options = blocks[0]["elements"][0]["options"] + + assert len(options) == 100 + assert options[0]["value"] == "wolt-000" + assert options[-1]["value"] == "wolt-099" + assert "105. wolt-104" in slack._picker_text(wolts) + + +def test_pending_storage_is_private_atomic_and_claim_removes_plaintext(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "private task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + claimed = slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) + stored = json.loads(slack.PENDING_MESSAGES_FILE.read_text())["D1:1000.1"] + + assert claimed["text"] == "private task" + assert stored["state"] == "claimed" + assert "text" not in stored + assert slack.PENDING_MESSAGES_FILE.stat().st_mode & 0o777 == 0o600 + assert slack.PENDING_LOCK_FILE.stat().st_mode & 0o777 == 0o600 + assert list(slack.CHAT_DIR.glob("*.tmp")) == [] + + +def test_pending_claim_expires_and_rejects_wrong_binding(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + assert slack._pending_claim("U-other", "D1", "2000.1", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D-other", "2000.1", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D1", "wrong", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=602) is None + stored = json.loads(slack.PENDING_MESSAGES_FILE.read_text())["D1:1000.1"] + assert stored["state"] == "expired" + assert "text" not in stored + + +def test_pending_claim_race_has_one_winner_and_never_replays(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + def claim(_): + return slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) + + with ThreadPoolExecutor(max_workers=12) as pool: + results = list(pool.map(claim, range(12))) + + assert sum(result is not None for result in results) == 1 + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=3) is None + + +def test_ambiguous_pending_recovery_never_replays(): + slack.PENDING_MESSAGES_FILE.write_text('{"D1:1000.1":') + + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) is None + assert json.loads(slack.PENDING_MESSAGES_FILE.read_text()) == {} + + +def test_pending_caps_and_message_size_are_bounded(): + for index in range(slack.PENDING_MAX_PER_OWNER): + slack._pending_create( + "U12345678", "D1", f"1000.{index}", f"Ev{index}", "task", now=1 + ) + with pytest.raises(ValueError, match="too many pending"): + slack._pending_create("U12345678", "D1", "2000.1", "EvX", "task", now=2) + with pytest.raises(ValueError, match="1..32768"): + slack._pending_create("U-other", "D1", "3000.1", "EvY", "x" * 32769, now=2) + + +@pytest.mark.asyncio +async def test_no_selection_shows_picker_without_replaying_or_agent_work(monkeypatch): + app = install_fake_app(monkeypatch) + eligible = {"n00b": {"name": "n00b", "type": "raccoon"}} + monkeypatch.setattr(slack, "_eligible_wolts", lambda: eligible) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + start = Mock(side_effect=AssertionError("session work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack, "_build_thread_context", history) + client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} + + await app.handlers["message"]( + event(text="private first message"), client, {}, {"event_id": "EvPicker"} + ) + + sent = client.chat_postMessage.await_args.kwargs + assert "Choose your wolt" in sent["text"] + assert "private first message" not in str(sent) + extract.assert_not_called() + start.assert_not_called() + history.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bare_top_level_wolt_reopens_picker_without_spawning_or_replaying(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "n00b" + eligible = {"n00b": {"name": "n00b", "type": "raccoon"}} + monkeypatch.setattr(slack, "_eligible_wolts", lambda: eligible) + start = Mock(side_effect=AssertionError("session work must not run")) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "_build_thread_context", history) + client = AsyncMock() + + await app.handlers["message"]( + event(text="wolt"), client, {}, {"event_id": "EvReopenPicker"} + ) + + sent = client.chat_postMessage.await_args.kwargs + assert "Choose your wolt" in sent["text"] + assert "Current selection: `n00b`" in sent["text"] + assert sent["blocks"] == slack._picker_blocks(eligible) + start.assert_not_called() + extract.assert_not_called() + history.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bare_wolt_inside_owned_thread_stays_with_historical_session(monkeypatch): + app = install_fake_app(monkeypatch) + slack._thread_sessions["D12345678:1000.1"] = { + "session": "old-session-1", "wolt": "old-wolt", "creature": "otter" + } + routed = AsyncMock() + monkeypatch.setattr(slack, "_route_to_session", routed) + client = AsyncMock() + + await app.handlers["message"]( + event(text="wolt", thread_ts="1000.1", event_ts="2000.1", ts="2000.1"), + client, {}, {"event_id": "EvThreadWolt"}, + ) + + routed.assert_awaited_once() + assert routed.await_args.args[3]["session"] == "old-session-1" + client.chat_postMessage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_prior_selection_still_opens_picker_without_starting(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "builder" + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "builder": {"name": "builder", "type": "beaver"}, + "other": {"name": "other", "type": "raccoon"}, + }) + monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) + start = Mock(side_effect=AssertionError("must wait for selection")) + monkeypatch.setattr(slack, "start_claude_session", start) + client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} + + await app.handlers["message"]( + event(text="do the work"), client, {}, {"event_id": "EvStart"} + ) + + start.assert_not_called() + assert "Choose your wolt" in client.chat_postMessage.await_args.kwargs["text"] + pending = json.loads(slack.PENDING_MESSAGES_FILE.read_text()) + assert pending["D12345678:1234.5678"]["text"] == "do the work" + + +@pytest.mark.asyncio +async def test_attachment_is_explicitly_deferred(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "builder" + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "builder": {"name": "builder", "type": "beaver"} + }) + monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) + + client = AsyncMock() + + await app.handlers["message"]( + event(text="see file", files=[{"id": "F1"}]), client, {}, {"event_id": "EvFile"} + ) + + assert client.chat_postMessage.await_count == 1 + assert "Attachments are not supported" in client.chat_postMessage.await_args.kwargs["text"] + assert not slack.PENDING_MESSAGES_FILE.exists() + + +@pytest.mark.parametrize("session, expected", [ + ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1"}, + "https://lodge.test/tui?session=n00b-1"), + ({"name": "n00b-1", "url": "http://lodge.test/tui?session=n00b-1"}, ""), + ({"name": "n00b-1", "url": "https://evil.test/tui?session=other"}, ""), + ({"name": "n00b-1", "url": "https://u:p@evil.test/tui?session=n00b-1"}, ""), + ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1#x"}, ""), + ({"name": "n00b-1", "url": None}, ""), +]) +def test_session_link_accepts_only_exact_platform_https_url(session, expected): + assert slack._session_link(session) == expected + + +@pytest.mark.asyncio +async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): + session = { + "name": "n00b-session-1", + "url": "https://lodge.test/tui?session=n00b-session-1", + } + start = Mock(return_value=session) + update = Mock() + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack.registry, "update", update) + client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") + selected = {"name": "n00b", "type": "raccoon"} + claimed = { + "user": "U12345678", "channel": "D1", "root_ts": "1000.1", + "picker_ts": "2000.1", "text": "original task", + } + + await slack._spawn_pending(client, selected, claimed) + + start.assert_called_once() + first = start.call_args + assert first.args == ("original task",) + assert first.kwargs["wolt"] == "n00b" + assert first.kwargs["routing"] == { + "adapter": "slack", "chat_id": "D1", "thread_ts": "1000.1" + } + assert slack._thread_sessions["D1:1000.1"]["session"] == "n00b-session-1" + assert client.chat_update.await_args_list[0].kwargs["text"].startswith("โœ… Accepted") + assert "Slack Agent View is not authorized" in ( + client.chat_update.await_args_list[1].kwargs["text"] + ) + assert "Gnawing" not in client.chat_update.await_args_list[1].kwargs["text"] + assert "" in ( + client.chat_update.await_args_list[1].kwargs["text"] + ) + + +@pytest.mark.asyncio +async def test_native_session_creation_uses_exact_woltspace_slug_as_title(monkeypatch): + session = { + "name": "n00b-muddy-pine-211c27", + "url": "https://lodge.test/tui?session=n00b-muddy-pine-211c27", + } + monkeypatch.setattr(slack, "start_claude_session", Mock(return_value=session)) + monkeypatch.setattr(slack.registry, "update", Mock()) + client = AsyncMock() + client.agents_sessions_setStatus.return_value = {"ok": True} + selected = {"name": "n00b", "type": "raccoon"} + claimed = { + "user": "U12345678", "channel": "D1", "root_ts": "1000.1", + "picker_ts": "2000.1", "text": "original task", + } + + await slack._spawn_pending(client, selected, claimed) + + client.agents_sessions_setStatus.assert_awaited_once_with( + channel_id="D1", + thread_ts="1000.1", + status="processing", + title="n00b-muddy-pine-211c27", + ) + assert "Gnawing" not in client.chat_update.await_args_list[1].kwargs["text"] + + +@pytest.mark.asyncio +async def test_historical_owned_thread_is_not_retargeted_after_selection(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "new-wolt" + slack._thread_sessions["D12345678:1000.1"] = { + "session": "old-wolt-session-1", "wolt": "old-wolt", "creature": "otter" + } + routed = AsyncMock() + monkeypatch.setattr(slack, "_route_to_session", routed) + monkeypatch.setattr( + slack, "start_claude_session", Mock(side_effect=AssertionError("must not retarget")) + ) + + await app.handlers["message"]( + event(thread_ts="1000.1", event_ts="2000.1", ts="2000.1"), + AsyncMock(), {}, {"event_id": "EvOldThread"}, + ) + + assert routed.await_args.args[3]["wolt"] == "old-wolt" + + +@pytest.mark.asyncio +async def test_static_select_revalidates_owner_and_live_option(monkeypatch): + app = install_fake_app(monkeypatch) + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "n00b": {"name": "n00b", "type": "raccoon"} + }) + handler = app.handlers["action:select_wolt"] + ack = AsyncMock() + client = AsyncMock() + spawn = AsyncMock() + monkeypatch.setattr(slack, "_spawn_pending", spawn) + slack._pending_create( + "U12345678", "D12345678", "1000.1", "EvPending", "original task", now=1 + ) + slack._pending_set_picker("D12345678", "1000.1", "2000.1") + base = { + "user": {"id": "U12345678"}, + "channel": {"id": "D12345678"}, + "message": {"ts": "2000.1"}, + "actions": [{"selected_option": {"value": "n00b"}}], + } + + with patch.object(slack.time, "time", return_value=2): + await handler(ack, base, client) + assert slack._owner_selections == {"U12345678": "n00b"} + assert spawn.await_args.args[2]["text"] == "original task" + ack.assert_awaited_once() + + await handler(AsyncMock(), base, client) + assert spawn.await_count == 1 + + slack._owner_selections.clear() + await handler(AsyncMock(), {**base, "user": {"id": "U87654321"}}, AsyncMock()) + assert slack._owner_selections == {} + + await handler(AsyncMock(), { + **base, "actions": [{"selected_option": {"value": "removed-wolt"}}] + }, AsyncMock()) + assert slack._owner_selections == {} diff --git a/test/test_tui_bridge.py b/test/test_tui_bridge.py index 3bb99032..4c4b6009 100644 --- a/test/test_tui_bridge.py +++ b/test/test_tui_bridge.py @@ -42,8 +42,8 @@ def _wheel_root(tmp_path): return root -def test_the_bridge_is_the_second_connector_and_telegram_stays_first(): - assert [connector.name for connector in CONNECTORS] == ["telegram", "tui", "wolf"] +def test_the_bridge_follows_chat_connectors_and_telegram_stays_first(): + assert [connector.name for connector in CONNECTORS] == ["telegram", "slack", "tui", "wolf"] class TestResolution: @@ -180,7 +180,7 @@ def test_missing_bridge_is_a_named_remedy_not_a_crash_loop(self, tmp_path): def test_plan_connectors_keeps_telegram_at_index_zero(self, tmp_path): plans = plan_connectors(_layout(tmp_path), {}) - assert [plan.name for plan in plans] == ["telegram", "tui", "wolf"] + assert [plan.name for plan in plans] == ["telegram", "slack", "tui", "wolf"] class TestSupervisorWiring: diff --git a/test/test_wolf_connector.py b/test/test_wolf_connector.py index 9c59704a..d932b7e4 100644 --- a/test/test_wolf_connector.py +++ b/test/test_wolf_connector.py @@ -100,7 +100,7 @@ def test_an_install_without_the_runtime_is_a_remedy_not_a_crash_loop(self, tmp_p def test_plan_connectors_carries_the_wolf(self, tmp_path): plans = plan_connectors(_layout(tmp_path), ENTRY) - assert [plan.name for plan in plans] == ["telegram", "tui", "wolf"] + assert [plan.name for plan in plans] == ["telegram", "slack", "tui", "wolf"] class TestSupervision: