From 9308558b3d45d9d26147cfcb4f998e9d91c290c8 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 06:55:39 +0200 Subject: [PATCH 01/23] feat: Implement the initial baseline and client for Titan Slack plugin --- .gitignore | 2 + plugins/titan-plugin-slack/AGENTS.md | 44 +++++++++++++++++ plugins/titan-plugin-slack/pyproject.toml | 22 +++++++++ plugins/titan-plugin-slack/tests/__init__.py | 1 + .../tests/clients/test_slack_client.py | 16 ++++++ .../titan-plugin-slack/tests/test_plugin.py | 21 ++++++++ .../titan_plugin_slack/__init__.py | 5 ++ .../titan_plugin_slack/clients/__init__.py | 5 ++ .../clients/slack_client.py | 24 +++++++++ .../titan_plugin_slack/config/__init__.py | 1 + .../titan_plugin_slack/exceptions.py | 17 +++++++ .../titan_plugin_slack/messages.py | 11 +++++ .../titan_plugin_slack/models.py | 35 +++++++++++++ .../titan_plugin_slack/plugin.py | 49 +++++++++++++++++++ .../titan_plugin_slack/steps/__init__.py | 1 + .../titan_plugin_slack/workflows/__init__.py | 1 + 16 files changed, 255 insertions(+) create mode 100644 plugins/titan-plugin-slack/AGENTS.md create mode 100644 plugins/titan-plugin-slack/pyproject.toml create mode 100644 plugins/titan-plugin-slack/tests/__init__.py create mode 100644 plugins/titan-plugin-slack/tests/clients/test_slack_client.py create mode 100644 plugins/titan-plugin-slack/tests/test_plugin.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/exceptions.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/messages.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/models.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/plugin.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/__init__.py diff --git a/.gitignore b/.gitignore index b55d4d9f..66b8d86a 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,8 @@ venv.bak/ .idea/ .vscode/ .codex +.harness +harness/ .titan/worktrees/ *.swp *.swo diff --git a/plugins/titan-plugin-slack/AGENTS.md b/plugins/titan-plugin-slack/AGENTS.md new file mode 100644 index 00000000..a27c6be6 --- /dev/null +++ b/plugins/titan-plugin-slack/AGENTS.md @@ -0,0 +1,44 @@ +# AGENTS.md - Titan Slack Plugin + +Documentation for AI coding agents working on the `titan-plugin-slack`. + +--- + +## Plugin Overview + +**Titan Slack Plugin** provides Slack integration for Titan CLI. + +Current first-phase scope: + +- Official Titan plugin package and discovery entry point +- Bot-oriented Slack client baseline +- Shared project bot policy +- No workflow steps yet +- No built-in workflows yet + +--- + +## Project Structure + +```text +titan_plugin_slack/ +├── __init__.py +├── plugin.py +├── clients/ +│ └── slack_client.py +├── models.py +├── exceptions.py +├── messages.py +├── steps/ +└── workflows/ +``` + +--- + +## Working Rules + +- Keep first-phase scope tight. +- Do not add workflow steps in this phase. +- Do not add built-in workflows in this phase. +- Prefer small, testable public surfaces. +- Keep raw Slack API entities clearly separated from domain return models. diff --git a/plugins/titan-plugin-slack/pyproject.toml b/plugins/titan-plugin-slack/pyproject.toml new file mode 100644 index 00000000..5c14840b --- /dev/null +++ b/plugins/titan-plugin-slack/pyproject.toml @@ -0,0 +1,22 @@ +[tool.poetry] +name = "titan-plugin-slack" +version = "1.0.0" +description = "Titan CLI plugin for Slack integration." +authors = ["MasOrange Apps Team "] +packages = [{include = "titan_plugin_slack"}] + +[tool.poetry.dependencies] +python = ">=3.10" +titan-cli = ">=0.6.0" +slack-sdk = ">=3.27.0" + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.0.0" +pytest-mock = ">=3.10.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.plugins."titan.plugins"] +slack = "titan_plugin_slack.plugin:SlackPlugin" diff --git a/plugins/titan-plugin-slack/tests/__init__.py b/plugins/titan-plugin-slack/tests/__init__.py new file mode 100644 index 00000000..054aed36 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for titan-plugin-slack.""" diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py new file mode 100644 index 00000000..2bef0bd2 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -0,0 +1,16 @@ +import pytest + +from titan_plugin_slack.clients.slack_client import SlackClient +from titan_plugin_slack.exceptions import SlackClientError + + +def test_slack_client_requires_bot_token() -> None: + with pytest.raises(SlackClientError): + SlackClient(bot_token="") + + +def test_slack_client_stores_token() -> None: + client = SlackClient(bot_token="xoxb-test-token") + + assert client.bot_token == "xoxb-test-token" + assert client.web_client is not None diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py new file mode 100644 index 00000000..c965b7aa --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -0,0 +1,21 @@ +from titan_plugin_slack.plugin import SlackPlugin + + +def test_slack_plugin_basic_properties() -> None: + plugin = SlackPlugin() + + assert plugin.name == "slack" + assert plugin.description == "Provides Slack messaging and workspace integration." + assert plugin.dependencies == [] + + +def test_slack_plugin_has_no_steps_in_phase_one() -> None: + plugin = SlackPlugin() + + assert plugin.get_steps() == {} + + +def test_slack_plugin_exposes_workflows_path() -> None: + plugin = SlackPlugin() + + assert plugin.workflows_path.name == "workflows" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/__init__.py new file mode 100644 index 00000000..759a1be4 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/__init__.py @@ -0,0 +1,5 @@ +"""Titan Slack plugin package.""" + +from .plugin import SlackPlugin + +__all__ = ["SlackPlugin"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/__init__.py new file mode 100644 index 00000000..cee91e77 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/__init__.py @@ -0,0 +1,5 @@ +"""Slack client package.""" + +from .slack_client import SlackClient + +__all__ = ["SlackClient"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py new file mode 100644 index 00000000..60680dec --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -0,0 +1,24 @@ +"""Minimal Slack client baseline for the first plugin phase.""" + +try: + from slack_sdk import WebClient +except ImportError: # pragma: no cover - exercised implicitly in repo-level tests + class WebClient: # type: ignore[override] + """Small fallback used until the plugin dependency is installed.""" + + def __init__(self, token: str): + self.token = token + +from ..exceptions import SlackClientError +from ..messages import msg + + +class SlackClient: + """Small Slack client wrapper used by the Slack plugin.""" + + def __init__(self, bot_token: str): + if not bot_token: + raise SlackClientError(msg.Slack.CLIENT_REQUIRES_BOT_TOKEN) + + self.bot_token = bot_token + self.web_client = WebClient(token=bot_token) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py new file mode 100644 index 00000000..8e177d27 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py @@ -0,0 +1 @@ +"""Slack plugin config package.""" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/exceptions.py b/plugins/titan-plugin-slack/titan_plugin_slack/exceptions.py new file mode 100644 index 00000000..684edca1 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/exceptions.py @@ -0,0 +1,17 @@ +"""Custom exceptions for Slack plugin operations.""" + + +class SlackError(Exception): + """Base exception for Slack-related errors.""" + + +class SlackConfigurationError(SlackError): + """Slack plugin configuration is invalid or incomplete.""" + + +class SlackClientError(SlackError): + """Slack client is not initialized or cannot be used.""" + + +class SlackAPIError(SlackError): + """Slack API request failed.""" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/messages.py b/plugins/titan-plugin-slack/titan_plugin_slack/messages.py new file mode 100644 index 00000000..79a66442 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/messages.py @@ -0,0 +1,11 @@ +class Messages: + class Plugin: + SLACK_CLIENT_NOT_AVAILABLE: str = ( + "SlackPlugin not initialized. Slack client may not be available." + ) + + class Slack: + CLIENT_REQUIRES_BOT_TOKEN: str = "Slack client requires a bot token." + + +msg = Messages() diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/models.py b/plugins/titan-plugin-slack/titan_plugin_slack/models.py new file mode 100644 index 00000000..6cbb2997 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/models.py @@ -0,0 +1,35 @@ +"""Core models for the Slack plugin baseline.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class NetworkSlackChannel: + """Raw Slack channel data normalized from the Web API.""" + + id: str + name: str + is_channel: bool = True + is_private: bool = False + + +@dataclass +class NetworkSlackUser: + """Raw Slack user data normalized from the Web API.""" + + id: str + name: str + real_name: Optional[str] = None + is_bot: bool = False + is_active: bool = True + + +@dataclass +class SlackMessageRef: + """Stable reference to a posted Slack message.""" + + channel: str + ts: str + thread_ts: Optional[str] = None + permalink: Optional[str] = None diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py new file mode 100644 index 00000000..d43e75a2 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import Optional + +from titan_cli.core.config import TitanConfig +from titan_cli.core.plugins.plugin_base import TitanPlugin +from titan_cli.core.secrets import SecretManager + +from .clients.slack_client import SlackClient +from .exceptions import SlackClientError +from .messages import msg + + +class SlackPlugin(TitanPlugin): + """Titan CLI plugin for Slack operations.""" + + @property + def name(self) -> str: + return "slack" + + @property + def description(self) -> str: + return "Provides Slack messaging and workspace integration." + + @property + def dependencies(self) -> list[str]: + return [] + + def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: + """Initialize the Slack client baseline for later configuration work.""" + self._client = SlackClient(bot_token="placeholder-token") + + def is_available(self) -> bool: + """Return whether the plugin has an initialized client.""" + return hasattr(self, "_client") and self._client is not None + + def get_client(self) -> SlackClient: + """Return the initialized Slack client instance.""" + if not hasattr(self, "_client") or self._client is None: + raise SlackClientError(msg.Plugin.SLACK_CLIENT_NOT_AVAILABLE) + return self._client + + def get_steps(self) -> dict: + """Return public workflow steps for the plugin.""" + return {} + + @property + def workflows_path(self) -> Optional[Path]: + """Return the plugin workflows directory path.""" + return Path(__file__).parent / "workflows" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py new file mode 100644 index 00000000..9473f1ed --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -0,0 +1 @@ +"""Slack workflow steps package.""" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/__init__.py new file mode 100644 index 00000000..f1492b76 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/__init__.py @@ -0,0 +1 @@ +"""Slack workflows package.""" From 07837545b8c103e77b7ace371396849ca8c5a48e Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 07:06:53 +0200 Subject: [PATCH 02/23] feat: Implement personal user token authentication and configuration schema for Slack plugin --- plugins/titan-plugin-slack/AGENTS.md | 4 +- .../tests/clients/test_slack_client.py | 10 +++-- .../titan-plugin-slack/tests/test_plugin.py | 43 ++++++++++++++++++ .../clients/slack_client.py | 16 ++++--- .../titan_plugin_slack/messages.py | 11 ----- .../titan_plugin_slack/plugin.py | 37 ++++++++++++--- titan_cli/core/plugins/models.py | 45 ++++++++++++++++++- 7 files changed, 136 insertions(+), 30 deletions(-) delete mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/messages.py diff --git a/plugins/titan-plugin-slack/AGENTS.md b/plugins/titan-plugin-slack/AGENTS.md index a27c6be6..7c069b17 100644 --- a/plugins/titan-plugin-slack/AGENTS.md +++ b/plugins/titan-plugin-slack/AGENTS.md @@ -11,8 +11,8 @@ Documentation for AI coding agents working on the `titan-plugin-slack`. Current first-phase scope: - Official Titan plugin package and discovery entry point -- Bot-oriented Slack client baseline -- Shared project bot policy +- Personal user-token Slack client baseline +- Keyring-first secret policy - No workflow steps yet - No built-in workflows yet diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py index 2bef0bd2..527338f7 100644 --- a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -6,11 +6,13 @@ def test_slack_client_requires_bot_token() -> None: with pytest.raises(SlackClientError): - SlackClient(bot_token="") + SlackClient(user_token="") -def test_slack_client_stores_token() -> None: - client = SlackClient(bot_token="xoxb-test-token") +def test_slack_client_stores_user_token() -> None: + client = SlackClient(user_token="xoxp-test-token", team_id="T123", timeout=45) - assert client.bot_token == "xoxb-test-token" + assert client.user_token == "xoxp-test-token" + assert client.team_id == "T123" + assert client.timeout == 45 assert client.web_client is not None diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index c965b7aa..3dc41ada 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -1,4 +1,9 @@ +from unittest.mock import MagicMock + +import pytest + from titan_plugin_slack.plugin import SlackPlugin +from titan_plugin_slack.exceptions import SlackConfigurationError def test_slack_plugin_basic_properties() -> None: @@ -19,3 +24,41 @@ def test_slack_plugin_exposes_workflows_path() -> None: plugin = SlackPlugin() assert plugin.workflows_path.name == "workflows" + + +def test_slack_plugin_exposes_config_schema() -> None: + plugin = SlackPlugin() + + schema = plugin.get_config_schema() + + assert "user_token" in schema["properties"] + assert schema["properties"]["default_team_id"]["config_scope"] == "global" + assert schema["properties"]["auth_mode"]["default"] == "user_token" + + +def test_slack_plugin_initialize_requires_user_token() -> None: + plugin = SlackPlugin() + config = MagicMock() + config.config.plugins = {} + secrets = MagicMock() + secrets.get.return_value = None + + with pytest.raises(SlackConfigurationError): + plugin.initialize(config, secrets) + + +def test_slack_plugin_initialize_uses_personal_token() -> None: + plugin = SlackPlugin() + config = MagicMock() + config.config.plugins = { + "slack": MagicMock(config={"default_team_id": "T123", "timeout": 45}) + } + secrets = MagicMock() + secrets.get.return_value = "xoxp-user-token" + + plugin.initialize(config, secrets) + + client = plugin.get_client() + assert client.user_token == "xoxp-user-token" + assert client.team_id == "T123" + assert client.timeout == 45 diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 60680dec..8f4844ca 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -6,19 +6,21 @@ class WebClient: # type: ignore[override] """Small fallback used until the plugin dependency is installed.""" - def __init__(self, token: str): + def __init__(self, token: str, timeout: int | None = None): self.token = token + self.timeout = timeout from ..exceptions import SlackClientError -from ..messages import msg class SlackClient: """Small Slack client wrapper used by the Slack plugin.""" - def __init__(self, bot_token: str): - if not bot_token: - raise SlackClientError(msg.Slack.CLIENT_REQUIRES_BOT_TOKEN) + def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 30): + if not user_token: + raise SlackClientError("Slack client requires a user token.") - self.bot_token = bot_token - self.web_client = WebClient(token=bot_token) + self.user_token = user_token + self.team_id = team_id + self.timeout = timeout + self.web_client = WebClient(token=user_token, timeout=timeout) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/messages.py b/plugins/titan-plugin-slack/titan_plugin_slack/messages.py deleted file mode 100644 index 79a66442..00000000 --- a/plugins/titan-plugin-slack/titan_plugin_slack/messages.py +++ /dev/null @@ -1,11 +0,0 @@ -class Messages: - class Plugin: - SLACK_CLIENT_NOT_AVAILABLE: str = ( - "SlackPlugin not initialized. Slack client may not be available." - ) - - class Slack: - CLIENT_REQUIRES_BOT_TOKEN: str = "Slack client requires a bot token." - - -msg = Messages() diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index d43e75a2..17e4b378 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -2,12 +2,12 @@ from typing import Optional from titan_cli.core.config import TitanConfig +from titan_cli.core.plugins.models import SlackPluginConfig from titan_cli.core.plugins.plugin_base import TitanPlugin from titan_cli.core.secrets import SecretManager from .clients.slack_client import SlackClient -from .exceptions import SlackClientError -from .messages import msg +from .exceptions import SlackClientError, SlackConfigurationError class SlackPlugin(TitanPlugin): @@ -25,9 +25,34 @@ def description(self) -> str: def dependencies(self) -> list[str]: return [] + def _get_plugin_config(self, config: TitanConfig) -> dict: + """Extract Slack plugin configuration.""" + if "slack" not in config.config.plugins: + return {} + + plugin_entry = config.config.plugins["slack"] + return plugin_entry.config if hasattr(plugin_entry, "config") else {} + + def get_config_schema(self) -> dict: + """Return JSON schema for Slack plugin configuration.""" + return SlackPluginConfig.model_json_schema() + def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: - """Initialize the Slack client baseline for later configuration work.""" - self._client = SlackClient(bot_token="placeholder-token") + """Initialize the Slack client using the current user's personal token.""" + plugin_config_data = self._get_plugin_config(config) + validated_config = SlackPluginConfig(**plugin_config_data) + + user_token = secrets.get("slack_user_token") + if not user_token: + raise SlackConfigurationError( + "Slack user token not found. Configure Slack and store a personal token in keyring, or set SLACK_USER_TOKEN." + ) + + self._client = SlackClient( + user_token=user_token, + team_id=validated_config.default_team_id, + timeout=validated_config.timeout, + ) def is_available(self) -> bool: """Return whether the plugin has an initialized client.""" @@ -36,7 +61,9 @@ def is_available(self) -> bool: def get_client(self) -> SlackClient: """Return the initialized Slack client instance.""" if not hasattr(self, "_client") or self._client is None: - raise SlackClientError(msg.Plugin.SLACK_CLIENT_NOT_AVAILABLE) + raise SlackClientError( + "SlackPlugin not initialized. Slack client may not be available." + ) return self._client def get_steps(self) -> dict: diff --git a/titan_cli/core/plugins/models.py b/titan_cli/core/plugins/models.py index 0f14981d..d65c12e1 100644 --- a/titan_cli/core/plugins/models.py +++ b/titan_cli/core/plugins/models.py @@ -1,5 +1,5 @@ # titan_cli/core/plugins/models.py -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, List from pydantic import BaseModel, Field, field_validator, model_validator @@ -121,3 +121,46 @@ def validate_email(cls, v): if '@' not in v: raise ValueError("email must be a valid email address") return v.lower() # Normalize email to lowercase + + +class SlackPluginConfig(BaseModel): + """Configuration for personal Slack integration.""" + + user_token: Optional[str] = Field( + None, + description="Personal Slack user token stored in keyring.", + json_schema_extra={"format": "password", "required_in_schema": True}, + ) + default_team_id: Optional[str] = Field( + None, + description="Preferred Slack workspace/team ID for the current user.", + json_schema_extra={"config_scope": "global"}, + ) + default_team_name: Optional[str] = Field( + None, + description="Preferred Slack workspace/team name for the current user.", + json_schema_extra={"config_scope": "global"}, + ) + granted_scopes: List[str] = Field( + default_factory=list, + description="Scopes granted to the current user's Slack token.", + json_schema_extra={"config_scope": "global"}, + ) + auth_mode: str = Field( + "user_token", + description="Slack authentication mode for this user.", + json_schema_extra={"config_scope": "global"}, + ) + timeout: int = Field( + 30, + description="Request timeout in seconds.", + json_schema_extra={"config_scope": "global"}, + ) + + @field_validator("auth_mode") + @classmethod + def validate_auth_mode(cls, v: str) -> str: + """Validate the supported auth mode for Slack.""" + if v != "user_token": + raise ValueError("Slack auth_mode must be 'user_token'") + return v From a14f77cdf230eecde4aa7e6a103590a73a1af380 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 07:21:02 +0200 Subject: [PATCH 03/23] feat: Implement auth_test method in SlackClient for token validation --- .../tests/clients/test_slack_client.py | 58 ++++++++++++++++++- .../clients/slack_client.py | 36 +++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py index 527338f7..2e237998 100644 --- a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -1,10 +1,13 @@ +from unittest.mock import MagicMock + import pytest +from titan_plugin_slack.clients import slack_client as slack_client_module from titan_plugin_slack.clients.slack_client import SlackClient -from titan_plugin_slack.exceptions import SlackClientError +from titan_plugin_slack.exceptions import SlackAPIError, SlackClientError -def test_slack_client_requires_bot_token() -> None: +def test_slack_client_requires_user_token() -> None: with pytest.raises(SlackClientError): SlackClient(user_token="") @@ -16,3 +19,54 @@ def test_slack_client_stores_user_token() -> None: assert client.team_id == "T123" assert client.timeout == 45 assert client.web_client is not None + + +def test_slack_client_auth_test_returns_identity_fields() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.auth_test.return_value = { + "ok": True, + "user_id": "U123", + "team_id": "T123", + "team": "Acme", + "url": "https://acme.slack.com", + "bot_id": None, + } + + result = client.auth_test() + + assert result == { + "user_id": "U123", + "team_id": "T123", + "team": "Acme", + "url": "https://acme.slack.com", + "bot_id": None, + } + + +def test_slack_client_auth_test_raises_api_error_for_invalid_token(monkeypatch) -> None: + class FakeSlackApiError(Exception): + def __init__(self, message: str, response=None): + super().__init__(message) + self.response = response + + monkeypatch.setattr(slack_client_module, "SlackApiError", FakeSlackApiError) + + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.auth_test.side_effect = FakeSlackApiError( + "invalid auth", + response={"error": "invalid_auth"}, + ) + + with pytest.raises(SlackAPIError, match="Slack auth failed: invalid_auth"): + client.auth_test() + + +def test_slack_client_auth_test_raises_client_error_for_transport_failure() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.auth_test.side_effect = RuntimeError("network down") + + with pytest.raises(SlackClientError, match="Slack auth request failed: network down"): + client.auth_test() diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 8f4844ca..dc70a91c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -2,6 +2,7 @@ try: from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError except ImportError: # pragma: no cover - exercised implicitly in repo-level tests class WebClient: # type: ignore[override] """Small fallback used until the plugin dependency is installed.""" @@ -10,7 +11,14 @@ def __init__(self, token: str, timeout: int | None = None): self.token = token self.timeout = timeout -from ..exceptions import SlackClientError + class SlackApiError(Exception): + """Fallback Slack API error used when slack-sdk is unavailable.""" + + def __init__(self, message: str, response=None): + super().__init__(message) + self.response = response + +from ..exceptions import SlackAPIError, SlackClientError class SlackClient: @@ -24,3 +32,29 @@ def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 3 self.team_id = team_id self.timeout = timeout self.web_client = WebClient(token=user_token, timeout=timeout) + + def auth_test(self) -> dict: + """Validate the configured user token with Slack auth.test.""" + try: + response = self.web_client.auth_test() + except SlackApiError as exc: + error_code = "unknown_error" + response = getattr(exc, "response", None) + if isinstance(response, dict): + error_code = response.get("error", error_code) + raise SlackAPIError(f"Slack auth failed: {error_code}") from exc + except Exception as exc: + raise SlackClientError(f"Slack auth request failed: {exc}") from exc + + if not response.get("ok", False): + raise SlackAPIError( + f"Slack auth failed: {response.get('error', 'unknown_error')}" + ) + + return { + "user_id": response.get("user_id"), + "team_id": response.get("team_id"), + "team": response.get("team"), + "url": response.get("url"), + "bot_id": response.get("bot_id"), + } From 732d6625479d9ff172705e9535379bfb72343e0f Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 07:44:24 +0200 Subject: [PATCH 04/23] feat: Implement client methods to list users, public channels, and read messages --- .../tests/clients/test_slack_client.py | 128 ++++++++++++++++++ .../clients/slack_client.py | 128 +++++++++++++++++- .../titan_plugin_slack/models.py | 12 ++ 3 files changed, 263 insertions(+), 5 deletions(-) diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py index 2e237998..a052790d 100644 --- a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -70,3 +70,131 @@ def test_slack_client_auth_test_raises_client_error_for_transport_failure() -> N with pytest.raises(SlackClientError, match="Slack auth request failed: network down"): client.auth_test() + + +def test_list_users_maps_members_and_cursor() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.users_list.return_value = { + "ok": True, + "members": [ + { + "id": "U123", + "name": "alex", + "real_name": "Alex", + "is_bot": False, + "deleted": False, + }, + { + "id": "U456", + "name": "bot-user", + "profile": {"real_name": "Bot User"}, + "is_bot": True, + "deleted": True, + }, + ], + "response_metadata": {"next_cursor": "cursor-123"}, + } + + users, next_cursor = client.list_users(limit=50) + + assert next_cursor == "cursor-123" + assert len(users) == 2 + assert users[0].id == "U123" + assert users[0].real_name == "Alex" + assert users[0].is_active is True + assert users[1].is_bot is True + assert users[1].real_name == "Bot User" + assert users[1].is_active is False + + +def test_list_users_raises_api_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.users_list.return_value = {"ok": False, "error": "missing_scope"} + + with pytest.raises(SlackAPIError, match="Slack list_users failed: missing_scope"): + client.list_users() + + +def test_list_public_channels_maps_channels_and_cursor() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.conversations_list.return_value = { + "ok": True, + "channels": [ + {"id": "C123", "name": "general", "is_channel": True, "is_private": False}, + {"id": "C456", "name": "announcements", "is_channel": True, "is_private": False}, + ], + "response_metadata": {"next_cursor": "cursor-456"}, + } + + channels, next_cursor = client.list_public_channels(limit=25) + + assert next_cursor == "cursor-456" + assert len(channels) == 2 + assert channels[0].id == "C123" + assert channels[0].name == "general" + assert channels[1].is_private is False + + +def test_list_public_channels_raises_api_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.conversations_list.return_value = { + "ok": False, + "error": "missing_scope", + } + + with pytest.raises( + SlackAPIError, match="Slack list_public_channels failed: missing_scope" + ): + client.list_public_channels() + + +def test_read_channel_maps_messages_and_pagination() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.conversations_history.return_value = { + "ok": True, + "messages": [ + { + "ts": "123.456", + "text": "Hello", + "user": "U123", + "thread_ts": "123.456", + "reply_count": 2, + "subtype": None, + }, + { + "ts": "123.789", + "text": "World", + "user": "U456", + "reply_count": 0, + }, + ], + "has_more": True, + "response_metadata": {"next_cursor": "cursor-789"}, + } + + messages, next_cursor, has_more = client.read_channel("C123", limit=10) + + assert next_cursor == "cursor-789" + assert has_more is True + assert len(messages) == 2 + assert messages[0].ts == "123.456" + assert messages[0].thread_ts == "123.456" + assert messages[0].reply_count == 2 + assert messages[1].text == "World" + + +def test_read_channel_raises_api_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.web_client = MagicMock() + client.web_client.conversations_history.return_value = { + "ok": False, + "error": "channel_not_found", + } + + with pytest.raises(SlackAPIError, match="Slack read_channel failed: channel_not_found"): + client.read_channel("C404") diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index dc70a91c..5f2dd75c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -19,6 +19,7 @@ def __init__(self, message: str, response=None): self.response = response from ..exceptions import SlackAPIError, SlackClientError +from ..models import NetworkSlackChannel, NetworkSlackMessage, NetworkSlackUser class SlackClient: @@ -33,16 +34,50 @@ def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 3 self.timeout = timeout self.web_client = WebClient(token=user_token, timeout=timeout) + def _handle_api_error(self, exc: SlackApiError, operation: str) -> None: + """Convert Slack SDK API errors into plugin-level exceptions.""" + error_code = "unknown_error" + response = getattr(exc, "response", None) + if isinstance(response, dict): + error_code = response.get("error", error_code) + raise SlackAPIError(f"Slack {operation} failed: {error_code}") from exc + + def _map_user(self, member: dict) -> NetworkSlackUser: + """Normalize a Slack user payload into the plugin model.""" + return NetworkSlackUser( + id=member.get("id", ""), + name=member.get("name", ""), + real_name=member.get("real_name") or member.get("profile", {}).get("real_name"), + is_bot=member.get("is_bot", False), + is_active=not member.get("deleted", False), + ) + + def _map_channel(self, channel: dict) -> NetworkSlackChannel: + """Normalize a Slack conversation payload into the plugin model.""" + return NetworkSlackChannel( + id=channel.get("id", ""), + name=channel.get("name", ""), + is_channel=channel.get("is_channel", True), + is_private=channel.get("is_private", False), + ) + + def _map_message(self, message: dict) -> NetworkSlackMessage: + """Normalize a Slack message payload into the plugin model.""" + return NetworkSlackMessage( + ts=message.get("ts", ""), + text=message.get("text", ""), + user=message.get("user"), + thread_ts=message.get("thread_ts"), + reply_count=message.get("reply_count", 0), + subtype=message.get("subtype"), + ) + def auth_test(self) -> dict: """Validate the configured user token with Slack auth.test.""" try: response = self.web_client.auth_test() except SlackApiError as exc: - error_code = "unknown_error" - response = getattr(exc, "response", None) - if isinstance(response, dict): - error_code = response.get("error", error_code) - raise SlackAPIError(f"Slack auth failed: {error_code}") from exc + self._handle_api_error(exc, "auth") except Exception as exc: raise SlackClientError(f"Slack auth request failed: {exc}") from exc @@ -58,3 +93,86 @@ def auth_test(self) -> dict: "url": response.get("url"), "bot_id": response.get("bot_id"), } + + def list_users( + self, limit: int = 100, cursor: str | None = None + ) -> tuple[list[NetworkSlackUser], str | None]: + """List Slack users visible to the current token.""" + try: + response = self.web_client.users_list(limit=limit, cursor=cursor) + except SlackApiError as exc: + self._handle_api_error(exc, "list_users") + except Exception as exc: + raise SlackClientError(f"Slack users request failed: {exc}") from exc + + if not response.get("ok", False): + raise SlackAPIError( + f"Slack list_users failed: {response.get('error', 'unknown_error')}" + ) + + members = [self._map_user(member) for member in response.get("members", [])] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + return members, next_cursor + + def list_public_channels( + self, + limit: int = 100, + cursor: str | None = None, + exclude_archived: bool = True, + ) -> tuple[list[NetworkSlackChannel], str | None]: + """List public Slack channels visible to the current token.""" + try: + response = self.web_client.conversations_list( + limit=limit, + cursor=cursor, + exclude_archived=exclude_archived, + types="public_channel", + ) + except SlackApiError as exc: + self._handle_api_error(exc, "list_public_channels") + except Exception as exc: + raise SlackClientError(f"Slack conversations request failed: {exc}") from exc + + if not response.get("ok", False): + raise SlackAPIError( + "Slack list_public_channels failed: " + f"{response.get('error', 'unknown_error')}" + ) + + channels = [self._map_channel(channel) for channel in response.get("channels", [])] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + return channels, next_cursor + + def read_channel( + self, + channel_id: str, + limit: int = 20, + cursor: str | None = None, + oldest: str | None = None, + latest: str | None = None, + inclusive: bool = False, + ) -> tuple[list[NetworkSlackMessage], str | None, bool]: + """Read message history from a Slack public channel.""" + try: + response = self.web_client.conversations_history( + channel=channel_id, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + inclusive=inclusive, + ) + except SlackApiError as exc: + self._handle_api_error(exc, "read_channel") + except Exception as exc: + raise SlackClientError(f"Slack channel history request failed: {exc}") from exc + + if not response.get("ok", False): + raise SlackAPIError( + f"Slack read_channel failed: {response.get('error', 'unknown_error')}" + ) + + messages = [self._map_message(message) for message in response.get("messages", [])] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + has_more = response.get("has_more", False) + return messages, next_cursor, has_more diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/models.py b/plugins/titan-plugin-slack/titan_plugin_slack/models.py index 6cbb2997..dea10f59 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/models.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/models.py @@ -33,3 +33,15 @@ class SlackMessageRef: ts: str thread_ts: Optional[str] = None permalink: Optional[str] = None + + +@dataclass +class NetworkSlackMessage: + """Raw Slack message data normalized from the Web API.""" + + ts: str + text: str + user: Optional[str] = None + thread_ts: Optional[str] = None + reply_count: int = 0 + subtype: Optional[str] = None From 6ec5b0e19865c8e8f9b9cc4a3bb222ca49608432 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 08:02:34 +0200 Subject: [PATCH 05/23] feat: Add Slack integration plugin to the CLI --- poetry.lock | 35 ++++++++++++++++++++++++++++- pyproject.toml | 9 ++++++-- tests/core/test_known_plugins.py | 9 ++++++++ titan_cli/core/plugins/available.py | 6 +++++ 4 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_known_plugins.py diff --git a/poetry.lock b/poetry.lock index 025230a9..7ac7f47b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2796,6 +2796,21 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "slack-sdk" +version = "3.42.0" +description = "The Slack API Platform SDK for Python" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff"}, + {file = "slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d"}, +] + +[package.extras] +optional = ["SQLAlchemy (>=1.4,<3)", "aiodns (>1.0)", "aiohttp (>=3.7.3,<4)", "boto3 (<=2)", "websocket-client (>=1,<2)", "websockets (>=9.1,<16)"] + [[package]] name = "sniffio" version = "1.3.1" @@ -2953,6 +2968,24 @@ titan-cli = ">=0.6.0" type = "directory" url = "plugins/titan-plugin-jira" +[[package]] +name = "titan-plugin-slack" +version = "1.0.0" +description = "Titan CLI plugin for Slack integration." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [] +develop = true + +[package.dependencies] +slack-sdk = ">=3.27.0" +titan-cli = ">=0.6.0" + +[package.source] +type = "directory" +url = "plugins/titan-plugin-slack" + [[package]] name = "tomli" version = "2.4.0" @@ -3413,4 +3446,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0.0" -content-hash = "566272a0f7e91784aa16b9e192ff0f92f9692ca4a66d80d804484d0c747b486b" +content-hash = "ec6c0e438f3c94618c439b4d442fe2a030eb3d233a9ae9498596f6781737a0fb" diff --git a/pyproject.toml b/pyproject.toml index 2ff9610f..1532cae5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,8 @@ packages = [ {include = "titan_cli"}, {include = "titan_plugin_git", from = "plugins/titan-plugin-git"}, {include = "titan_plugin_github", from = "plugins/titan-plugin-github"}, - {include = "titan_plugin_jira", from = "plugins/titan-plugin-jira"} + {include = "titan_plugin_jira", from = "plugins/titan-plugin-jira"}, + {include = "titan_plugin_slack", from = "plugins/titan-plugin-slack"} ] [tool.poetry.dependencies] @@ -45,6 +46,7 @@ requests = ">=2.31.0,<3.0.0" packaging = ">=23.0,<25.0" structlog = ">=25.5.0,<26.0.0" h2 = ">=4.1.0,<5.0.0" +slack-sdk = ">=3.27.0,<4.0.0" [tool.poetry.group.docs] optional = true @@ -64,6 +66,7 @@ textual-dev = "^1.0.0" titan-plugin-git = {path = "plugins/titan-plugin-git", develop = true} titan-plugin-github = {path = "plugins/titan-plugin-github", develop = true} titan-plugin-jira = {path = "plugins/titan-plugin-jira", develop = true} +titan-plugin-slack = {path = "plugins/titan-plugin-slack", develop = true} [build-system] requires = ["poetry-core>=1.0.0"] @@ -76,13 +79,15 @@ titan = "titan_cli.cli:app" git = "titan_plugin_git.plugin:GitPlugin" github = "titan_plugin_github.plugin:GitHubPlugin" jira = "titan_plugin_jira.plugin:JiraPlugin" +slack = "titan_plugin_slack.plugin:SlackPlugin" [tool.pytest.ini_options] testpaths = [ "tests", "plugins/titan-plugin-git/tests", "plugins/titan-plugin-github/tests", - "plugins/titan-plugin-jira/tests" + "plugins/titan-plugin-jira/tests", + "plugins/titan-plugin-slack/tests" ] python_files = ["test_*.py"] python_classes = ["Test*"] diff --git a/tests/core/test_known_plugins.py b/tests/core/test_known_plugins.py new file mode 100644 index 00000000..031259c5 --- /dev/null +++ b/tests/core/test_known_plugins.py @@ -0,0 +1,9 @@ +from titan_cli.core.plugins.available import KNOWN_PLUGINS + + +def test_known_plugins_includes_slack() -> None: + slack_plugin = next((plugin for plugin in KNOWN_PLUGINS if plugin["name"] == "slack"), None) + + assert slack_plugin is not None + assert slack_plugin["package_name"] == "titan-plugin-slack" + assert slack_plugin["dependencies"] == [] diff --git a/titan_cli/core/plugins/available.py b/titan_cli/core/plugins/available.py index 7b464abe..009bc5b0 100644 --- a/titan_cli/core/plugins/available.py +++ b/titan_cli/core/plugins/available.py @@ -28,4 +28,10 @@ class KnownPlugin(TypedDict): "package_name": "titan-plugin-jira", "dependencies": [] }, + { + "name": "slack", + "description": "Slack integration for personal messaging and workspace access.", + "package_name": "titan-plugin-slack", + "dependencies": [] + }, ] From 93c177932efae9e3f5ab4c95ac4067c3687ebb80 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 08:13:42 +0200 Subject: [PATCH 06/23] feat: Add support for custom plugin configuration screens --- tests/ui/test_plugin_config_resolver.py | 54 +++++++++++++++++++ titan_cli/core/plugins/plugin_base.py | 8 +++ .../ui/tui/screens/install_plugin_screen.py | 19 ++++--- .../ui/tui/screens/plugin_config_resolver.py | 35 ++++++++++++ titan_cli/ui/tui/screens/plugin_management.py | 13 ++--- .../ui/tui/screens/project_setup_wizard.py | 6 +-- 6 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 tests/ui/test_plugin_config_resolver.py create mode 100644 titan_cli/ui/tui/screens/plugin_config_resolver.py diff --git a/tests/ui/test_plugin_config_resolver.py b/tests/ui/test_plugin_config_resolver.py new file mode 100644 index 00000000..9da5e9fa --- /dev/null +++ b/tests/ui/test_plugin_config_resolver.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock + +from titan_cli.ui.tui.screens.plugin_config_resolver import ( + plugin_has_config_ui, + resolve_plugin_config_screen, +) +from titan_cli.ui.tui.screens.plugin_config_wizard import PluginConfigWizardScreen + + +class _PluginWithSchema: + def has_custom_config_screen(self) -> bool: + return False + + def get_config_schema(self) -> dict: + return {"properties": {"token": {"type": "string"}}} + + +class _PluginWithCustomScreen: + def has_custom_config_screen(self) -> bool: + return True + + def create_config_screen(self, config): + return "custom-screen" + + +def test_plugin_has_config_ui_for_schema_plugin() -> None: + config = MagicMock() + config.registry._plugins = {"sample": _PluginWithSchema()} + + assert plugin_has_config_ui(config, "sample") is True + + +def test_plugin_has_config_ui_for_custom_screen_plugin() -> None: + config = MagicMock() + config.registry._plugins = {"sample": _PluginWithCustomScreen()} + + assert plugin_has_config_ui(config, "sample") is True + + +def test_resolve_plugin_config_screen_prefers_custom_screen() -> None: + config = MagicMock() + config.registry._plugins = {"sample": _PluginWithCustomScreen()} + + assert resolve_plugin_config_screen(config, "sample") == "custom-screen" + + +def test_resolve_plugin_config_screen_falls_back_to_generic_wizard() -> None: + config = MagicMock() + config.registry._plugins = {"sample": _PluginWithSchema()} + + screen = resolve_plugin_config_screen(config, "sample") + + assert isinstance(screen, PluginConfigWizardScreen) + assert screen.plugin_name == "sample" diff --git a/titan_cli/core/plugins/plugin_base.py b/titan_cli/core/plugins/plugin_base.py index b0b38420..ee1d8624 100644 --- a/titan_cli/core/plugins/plugin_base.py +++ b/titan_cli/core/plugins/plugin_base.py @@ -94,6 +94,14 @@ def get_workflow_managers(self, project_root: Optional[Path] = None) -> Optional """ return None + def has_custom_config_screen(self) -> bool: + """Return whether this plugin provides a custom configuration screen.""" + return False + + def create_config_screen(self, config: Any) -> Optional[Any]: + """Create a plugin-specific configuration screen when supported.""" + return None + def get_steps(self) -> Dict[str, Callable]: """ Get workflow steps provided by this plugin. diff --git a/titan_cli/ui/tui/screens/install_plugin_screen.py b/titan_cli/ui/tui/screens/install_plugin_screen.py index b71bc3e5..40ef5e42 100644 --- a/titan_cli/ui/tui/screens/install_plugin_screen.py +++ b/titan_cli/ui/tui/screens/install_plugin_screen.py @@ -497,13 +497,9 @@ async def _run_install(self) -> None: ) await asyncio.to_thread(self.config.load) - installed_plugin = self.config.registry._plugins.get(plugin_name) - if installed_plugin and hasattr(installed_plugin, "get_config_schema"): - try: - schema = installed_plugin.get_config_schema() - self._plugin_has_config = bool(schema.get("properties")) - except Exception: - self._plugin_has_config = False + from .plugin_config_resolver import plugin_has_config_ui + + self._plugin_has_config = plugin_has_config_ui(self.config, plugin_name) body.mount(SuccessText(f"{Icons.SUCCESS} Plugin added to this project.")) @@ -514,9 +510,12 @@ async def _run_install(self) -> None: self._set_next_label("Next") def _open_config_wizard(self, plugin_name: str) -> None: - from .plugin_config_wizard import PluginConfigWizardScreen - wizard = PluginConfigWizardScreen(self.config, plugin_name) - self.app.push_screen(wizard, lambda _: self._load_step(self.current_step + 1)) + from .plugin_config_resolver import resolve_plugin_config_screen + + self.app.push_screen( + resolve_plugin_config_screen(self.config, plugin_name), + lambda _: self._load_step(self.current_step + 1), + ) def _prepare_project_plugin_install(self, plugin_name: str) -> Optional[str]: """Persist the project pin and provision its isolated runtime.""" diff --git a/titan_cli/ui/tui/screens/plugin_config_resolver.py b/titan_cli/ui/tui/screens/plugin_config_resolver.py new file mode 100644 index 00000000..8af93aa2 --- /dev/null +++ b/titan_cli/ui/tui/screens/plugin_config_resolver.py @@ -0,0 +1,35 @@ +"""Helpers for resolving plugin configuration UI screens.""" + +from typing import Any + +from .plugin_config_wizard import PluginConfigWizardScreen + + +def plugin_has_config_ui(config: Any, plugin_name: str) -> bool: + """Return whether a plugin exposes any configuration UI.""" + plugin = config.registry._plugins.get(plugin_name) + if not plugin: + return False + + if hasattr(plugin, "has_custom_config_screen") and plugin.has_custom_config_screen(): + return True + + if hasattr(plugin, "get_config_schema"): + try: + schema = plugin.get_config_schema() + return bool(schema and schema.get("properties")) + except Exception: + return False + + return False + + +def resolve_plugin_config_screen(config: Any, plugin_name: str) -> Any: + """Return the appropriate configuration screen for a plugin.""" + plugin = config.registry._plugins.get(plugin_name) + if plugin and hasattr(plugin, "has_custom_config_screen") and plugin.has_custom_config_screen(): + screen = plugin.create_config_screen(config) + if screen is not None: + return screen + + return PluginConfigWizardScreen(config, plugin_name) diff --git a/titan_cli/ui/tui/screens/plugin_management.py b/titan_cli/ui/tui/screens/plugin_management.py index 608de032..16fa5370 100644 --- a/titan_cli/ui/tui/screens/plugin_management.py +++ b/titan_cli/ui/tui/screens/plugin_management.py @@ -28,7 +28,6 @@ DevSourcePathModal, ) from .base import BaseScreen -from .plugin_config_wizard import PluginConfigWizardScreen from .install_plugin_screen import InstallPluginScreen from titan_cli.core.plugins.local_sources import get_local_plugin_validation_error from titan_cli.core.plugins.community_sources import ( @@ -42,6 +41,8 @@ import tomli import tomli_w +from .plugin_config_resolver import plugin_has_config_ui, resolve_plugin_config_screen + logger = get_logger(__name__) @@ -646,9 +647,7 @@ def action_configure_plugin(self) -> None: self.app.notify("Please select a plugin", severity="warning") return - # Check if plugin has config schema - plugin = self.config.registry._plugins.get(self.selected_plugin) - if not plugin or not hasattr(plugin, 'get_config_schema'): + if not plugin_has_config_ui(self.config, self.selected_plugin): self.app.notify("This plugin has no configuration options", severity="warning") return @@ -663,8 +662,10 @@ def on_wizard_close(result): else: logger.info("plugin_configure_cancelled", plugin=self.selected_plugin) - wizard = PluginConfigWizardScreen(self.config, self.selected_plugin) - self.app.push_screen(wizard, on_wizard_close) + self.app.push_screen( + resolve_plugin_config_screen(self.config, self.selected_plugin), + on_wizard_close, + ) def action_install_plugin(self) -> None: """Open the community plugin install wizard.""" diff --git a/titan_cli/ui/tui/screens/project_setup_wizard.py b/titan_cli/ui/tui/screens/project_setup_wizard.py index a07813fa..0f8e46ea 100644 --- a/titan_cli/ui/tui/screens/project_setup_wizard.py +++ b/titan_cli/ui/tui/screens/project_setup_wizard.py @@ -611,10 +611,10 @@ def on_plugin_config_complete(_=None): self.wizard_data["current_plugin_index"] = current_index + 1 self._configure_next_plugin() - # Launch plugin configuration wizard - from .plugin_config_wizard import PluginConfigWizardScreen + # Launch plugin configuration screen + from .plugin_config_resolver import resolve_plugin_config_screen self.app.push_screen( - PluginConfigWizardScreen(self.config, plugin_name), + resolve_plugin_config_screen(self.config, plugin_name), on_plugin_config_complete ) From 0bc099019191d7e6cf245c7bbd2f82e6022cbcee Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 08:22:56 +0200 Subject: [PATCH 07/23] feat: Add custom configuration screen for Slack plugin --- .../tests/ui/test_slack_config_screen.py | 99 ++++++++ .../titan_plugin_slack/plugin.py | 9 + .../titan_plugin_slack/screens/__init__.py | 5 + .../screens/slack_config_screen.py | 230 ++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/screens/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py new file mode 100644 index 00000000..78e6e20e --- /dev/null +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -0,0 +1,99 @@ +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock + +import tomli + +from titan_plugin_slack.plugin import SlackPlugin +from titan_plugin_slack.screens.slack_config_screen import SlackConfigScreen + + +def _build_config(tmp_path: Path, token: str | None = None, plugin_config: dict | None = None): + config = MagicMock() + config._global_config_path = tmp_path / "config.toml" + config.config = MagicMock() + config.config.config_version = "1.0" + config.config.plugins = {} + if plugin_config is not None: + config.config.plugins["slack"] = MagicMock(config=plugin_config) + + secrets = MagicMock() + secrets.get.return_value = token + config.secrets = secrets + config.load = MagicMock() + return config + + +def test_slack_plugin_returns_custom_config_screen(tmp_path: Path) -> None: + plugin = SlackPlugin() + config = _build_config(tmp_path) + + assert plugin.has_custom_config_screen() is True + assert isinstance(plugin.create_config_screen(config), SlackConfigScreen) + + +def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: + config = _build_config( + tmp_path, + token="xoxp-token", + plugin_config={ + "default_team_id": "T123", + "default_team_name": "Acme", + "granted_scopes": ["users:read", "channels:read"], + "auth_mode": "user_token", + "timeout": 45, + }, + ) + screen = SlackConfigScreen(config) + + state = screen._get_connection_state() + + assert state.has_token is True + assert state.default_team_id == "T123" + assert state.default_team_name == "Acme" + assert state.granted_scopes == ["users:read", "channels:read"] + assert state.timeout == 45 + + +def test_slack_config_screen_disconnect_clears_token_and_metadata(tmp_path: Path) -> None: + config = _build_config(tmp_path, token="xoxp-token") + screen = SlackConfigScreen(config) + + app = MagicMock() + type(screen).app = PropertyMock(return_value=app) + + screen._save_global_slack_config( + { + "default_team_id": "T123", + "default_team_name": "Acme", + "granted_scopes": ["users:read"], + "auth_mode": "user_token", + "timeout": 30, + } + ) + screen._disconnect() + + config.secrets.delete.assert_called_once_with("slack_user_token", scope="user") + with open(config._global_config_path, "rb") as f: + data = tomli.load(f) + + slack_cfg = data["plugins"]["slack"]["config"] + assert "default_team_id" not in slack_cfg + assert "default_team_name" not in slack_cfg + assert "granted_scopes" not in slack_cfg + assert slack_cfg["auth_mode"] == "user_token" + assert slack_cfg["timeout"] == 30 + + +def test_slack_config_screen_connect_shows_oauth_placeholder(tmp_path: Path) -> None: + config = _build_config(tmp_path) + screen = SlackConfigScreen(config) + + app = MagicMock() + type(screen).app = PropertyMock(return_value=app) + + screen._start_oauth_flow() + + app.notify.assert_called_once_with( + "Slack OAuth flow will be implemented in the next step.", + severity="information", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 17e4b378..3e1a2d96 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -8,6 +8,7 @@ from .clients.slack_client import SlackClient from .exceptions import SlackClientError, SlackConfigurationError +from .screens.slack_config_screen import SlackConfigScreen class SlackPlugin(TitanPlugin): @@ -37,6 +38,14 @@ def get_config_schema(self) -> dict: """Return JSON schema for Slack plugin configuration.""" return SlackPluginConfig.model_json_schema() + def has_custom_config_screen(self) -> bool: + """Slack uses a dedicated configuration screen.""" + return True + + def create_config_screen(self, config: TitanConfig) -> SlackConfigScreen: + """Create the Slack-specific configuration screen.""" + return SlackConfigScreen(config) + def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: """Initialize the Slack client using the current user's personal token.""" plugin_config_data = self._get_plugin_config(config) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/__init__.py new file mode 100644 index 00000000..997c31dc --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/__init__.py @@ -0,0 +1,5 @@ +"""Slack plugin screens.""" + +from .slack_config_screen import SlackConfigScreen, SlackConnectionState + +__all__ = ["SlackConfigScreen", "SlackConnectionState"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py new file mode 100644 index 00000000..6006ade1 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -0,0 +1,230 @@ +from dataclasses import dataclass + +import tomli +import tomli_w +from textual.app import ComposeResult +from textual.containers import Container, Horizontal, VerticalScroll +from textual.css.query import NoMatches + +from titan_cli.ui.tui.icons import Icons +from titan_cli.ui.tui.widgets import BoldPrimaryText, BoldText, Button, DimText, Text +from titan_cli.ui.tui.screens.base import BaseScreen + +from ..clients.slack_client import SlackClient + + +@dataclass +class SlackConnectionState: + """Current Slack connection state for the active user.""" + + has_token: bool + default_team_id: str | None + default_team_name: str | None + granted_scopes: list[str] + auth_mode: str + timeout: int + + +class SlackConfigScreen(BaseScreen): + """Slack-specific configuration screen.""" + + CSS = """ + SlackConfigScreen { + align: center middle; + } + + #slack-config-container { + width: 100%; + height: 1fr; + background: $surface-lighten-1; + padding: 0 2 1 2; + } + + #slack-config-panel { + width: 100%; + height: 1fr; + border: round $primary; + border-title-align: center; + background: $surface-lighten-1; + padding: 0; + layout: vertical; + } + + #slack-config-scroll { + height: 1fr; + } + + #slack-config-body { + padding: 1; + height: auto; + } + + #slack-config-buttons { + height: auto; + padding: 1 2; + background: $surface-lighten-1; + border-top: solid $primary; + align: right middle; + } + + #slack-config-buttons Button { + margin-left: 1; + } + """ + + def __init__(self, config): + super().__init__( + config, + title=f"{Icons.SETTINGS} Configure Slack", + show_back=True, + show_status_bar=False, + ) + + def compose_content(self) -> ComposeResult: + with Container(id="slack-config-container"): + panel = Container(id="slack-config-panel") + panel.border_title = "Slack Connection" + with panel: + with VerticalScroll(id="slack-config-scroll"): + yield Container(id="slack-config-body") + + with Horizontal(id="slack-config-buttons"): + yield Button("Connect Slack", variant="primary", id="connect-button") + yield Button("Validate Connection", variant="default", id="validate-button") + yield Button("Disconnect", variant="error", id="disconnect-button") + yield Button("Close", variant="default", id="close-button") + + def on_mount(self) -> None: + self._refresh_view() + + def _load_plugin_config(self) -> dict: + plugin_cfg = getattr(self.config.config, "plugins", {}).get("slack") if self.config.config else None + if not plugin_cfg: + return {} + return plugin_cfg.config if hasattr(plugin_cfg, "config") else {} + + def _has_user_token(self) -> bool: + return bool(self.config.secrets.get("slack_user_token")) + + def _get_connection_state(self) -> SlackConnectionState: + plugin_config = self._load_plugin_config() + return SlackConnectionState( + has_token=self._has_user_token(), + default_team_id=plugin_config.get("default_team_id"), + default_team_name=plugin_config.get("default_team_name"), + granted_scopes=plugin_config.get("granted_scopes", []), + auth_mode=plugin_config.get("auth_mode", "user_token"), + timeout=plugin_config.get("timeout", 30), + ) + + def _save_global_slack_config(self, updates: dict[str, object | None]) -> None: + global_cfg_path = self.config._global_config_path + config_data = {} + if global_cfg_path.exists(): + with open(global_cfg_path, "rb") as f: + config_data = tomli.load(f) + + config_data.setdefault("config_version", getattr(self.config.config, "config_version", "1.0")) + plugins = config_data.setdefault("plugins", {}) + plugin_table = plugins.setdefault("slack", {}) + plugin_config = plugin_table.setdefault("config", {}) + + for key, value in updates.items(): + if value is None: + plugin_config.pop(key, None) + else: + plugin_config[key] = value + + with open(global_cfg_path, "wb") as f: + tomli_w.dump(config_data, f) + + self.config.load() + + def _refresh_view(self) -> None: + state = self._get_connection_state() + try: + body = self.query_one("#slack-config-body", Container) + except NoMatches: + return + body.remove_children() + + body.mount(BoldPrimaryText("Connect your personal Slack account")) + body.mount(Text("")) + body.mount(DimText("Slack uses a personal user token stored securely in your keyring.")) + body.mount(DimText("The primary configuration path for Slack will be OAuth-based.")) + body.mount(Text("")) + + status_label = "Connected" if state.has_token else "Not connected" + body.mount(BoldText("Current Status")) + body.mount(DimText(f" Status: {status_label}")) + body.mount(DimText(f" Auth Mode: {state.auth_mode}")) + body.mount(DimText(f" Timeout: {state.timeout}s")) + body.mount(DimText(f" Team ID: {state.default_team_id or 'Not set'}")) + body.mount(DimText(f" Team Name: {state.default_team_name or 'Not set'}")) + scopes = ", ".join(state.granted_scopes) if state.granted_scopes else "Not recorded" + body.mount(DimText(f" Granted Scopes: {scopes}")) + body.mount(Text("")) + + body.mount(BoldText("Slack MVP0 Scopes")) + body.mount(DimText(" users:read")) + body.mount(DimText(" channels:read")) + body.mount(DimText(" channels:history")) + body.mount(Text("")) + body.mount(DimText("Use Connect Slack to start the Slack-specific connection flow.")) + + self.query_one("#validate-button", Button).disabled = not state.has_token + self.query_one("#disconnect-button", Button).disabled = not state.has_token + + def _start_oauth_flow(self) -> None: + self.app.notify( + "Slack OAuth flow will be implemented in the next step.", + severity="information", + ) + + def _validate_connection(self) -> None: + plugin_config = self._load_plugin_config() + client = SlackClient( + user_token=self.config.secrets.get("slack_user_token") or "", + team_id=plugin_config.get("default_team_id"), + timeout=plugin_config.get("timeout", 30), + ) + result = client.auth_test() + + self._save_global_slack_config( + { + "default_team_id": result.get("team_id"), + "default_team_name": result.get("team"), + "auth_mode": "user_token", + "timeout": plugin_config.get("timeout", 30), + } + ) + self.app.notify("Slack connection validated successfully.", severity="information") + self._refresh_view() + + def _disconnect(self) -> None: + self.config.secrets.delete("slack_user_token", scope="user") + self._save_global_slack_config( + { + "default_team_id": None, + "default_team_name": None, + "granted_scopes": None, + } + ) + self.app.notify("Slack connection removed.", severity="information") + self._refresh_view() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "connect-button": + self._start_oauth_flow() + elif event.button.id == "validate-button": + try: + self._validate_connection() + except Exception as exc: + self.app.notify(f"Slack validation failed: {exc}", severity="error") + elif event.button.id == "disconnect-button": + try: + self._disconnect() + except Exception as exc: + self.app.notify(f"Failed to remove Slack connection: {exc}", severity="error") + elif event.button.id == "close-button": + self.dismiss(result=False) From 3032c811c119e5176d18fb293aafe01da97d2463 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 08:37:12 +0200 Subject: [PATCH 08/23] feat: Implement Slack OAuth authentication flow and configuration screen integration --- plugins/titan-plugin-slack/pyproject.toml | 1 + .../titan-plugin-slack/tests/test_oauth.py | 84 ++++++++ .../tests/ui/test_slack_config_screen.py | 67 ++++++- .../titan_plugin_slack/oauth.py | 180 ++++++++++++++++++ .../screens/slack_config_screen.py | 126 +++++++++++- titan_cli/core/plugins/models.py | 10 + 6 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 plugins/titan-plugin-slack/tests/test_oauth.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/oauth.py diff --git a/plugins/titan-plugin-slack/pyproject.toml b/plugins/titan-plugin-slack/pyproject.toml index 5c14840b..e77f30a3 100644 --- a/plugins/titan-plugin-slack/pyproject.toml +++ b/plugins/titan-plugin-slack/pyproject.toml @@ -9,6 +9,7 @@ packages = [{include = "titan_plugin_slack"}] python = ">=3.10" titan-cli = ">=0.6.0" slack-sdk = ">=3.27.0" +requests = ">=2.31.0" [tool.poetry.group.dev.dependencies] pytest = ">=7.0.0" diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py new file mode 100644 index 00000000..aecd5dd9 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -0,0 +1,84 @@ +from urllib.parse import parse_qs, urlparse + +import requests + +from titan_plugin_slack.oauth import SlackOAuthError, SlackOAuthFlow + + +class _FakeResponse: + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.HTTPError(f"status={self.status_code}") + + def json(self) -> dict: + return self._payload + + +def test_build_authorize_url_contains_expected_oauth_values() -> None: + flow = SlackOAuthFlow(client_id="123", client_secret="secret", redirect_port=8765) + + url = flow.build_authorize_url("state-abc") + parsed = urlparse(url) + query = parse_qs(parsed.query) + + assert parsed.scheme == "https" + assert parsed.netloc == "slack.com" + assert parsed.path == "/oauth/v2_user/authorize" + assert query["client_id"] == ["123"] + assert query["state"] == ["state-abc"] + assert query["redirect_uri"] == ["http://127.0.0.1:8765/slack/callback"] + assert query["scope"] == ["users:read,channels:read,channels:history"] + + +def test_exchange_code_returns_token_and_metadata() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse( + { + "ok": True, + "access_token": "xoxp-token", + "scope": "users:read,channels:read", + "team": {"id": "T123", "name": "Acme"}, + "authed_user": {"id": "U123"}, + } + ) + + flow = SlackOAuthFlow( + client_id="123", + client_secret="secret", + redirect_port=8765, + requests_module=FakeRequests, + ) + + result = flow.exchange_code("code-123") + + assert result.access_token == "xoxp-token" + assert result.team_id == "T123" + assert result.team_name == "Acme" + assert result.authed_user_id == "U123" + assert result.granted_scopes == ["users:read", "channels:read"] + + +def test_exchange_code_raises_on_slack_error() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse({"ok": False, "error": "invalid_code"}) + + flow = SlackOAuthFlow( + client_id="123", + client_secret="secret", + requests_module=FakeRequests, + ) + + try: + flow.exchange_code("bad-code") + except SlackOAuthError as exc: + assert "invalid_code" in str(exc) + else: + raise AssertionError("Expected SlackOAuthError") diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index 78e6e20e..18cf5b64 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -4,6 +4,7 @@ import tomli from titan_plugin_slack.plugin import SlackPlugin +from titan_plugin_slack.oauth import SlackOAuthResult from titan_plugin_slack.screens.slack_config_screen import SlackConfigScreen @@ -36,6 +37,8 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: tmp_path, token="xoxp-token", plugin_config={ + "oauth_client_id": "123", + "oauth_redirect_port": 9999, "default_team_id": "T123", "default_team_name": "Acme", "granted_scopes": ["users:read", "channels:read"], @@ -43,11 +46,18 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: "timeout": 45, }, ) + config.secrets.get.side_effect = lambda key: { + "slack_user_token": "xoxp-token", + "slack_oauth_client_secret": "secret", + }.get(key) screen = SlackConfigScreen(config) state = screen._get_connection_state() assert state.has_token is True + assert state.oauth_client_id == "123" + assert state.has_oauth_client_secret is True + assert state.oauth_redirect_port == 9999 assert state.default_team_id == "T123" assert state.default_team_name == "Acme" assert state.granted_scopes == ["users:read", "channels:read"] @@ -84,16 +94,69 @@ def test_slack_config_screen_disconnect_clears_token_and_metadata(tmp_path: Path assert slack_cfg["timeout"] == 30 -def test_slack_config_screen_connect_shows_oauth_placeholder(tmp_path: Path) -> None: +def test_slack_config_screen_start_oauth_flow_runs_worker(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) app = MagicMock() type(screen).app = PropertyMock(return_value=app) + screen.run_worker = MagicMock() + screen._read_oauth_form_values = MagicMock(return_value=("123", "secret", 8765)) + screen._save_oauth_app_config = MagicMock() + screen._start_oauth_flow() app.notify.assert_called_once_with( - "Slack OAuth flow will be implemented in the next step.", + "Opening browser for Slack authorization...", severity="information", ) + screen.run_worker.assert_called_once() + worker_coro = screen.run_worker.call_args.args[0] + worker_coro.close() + + +def test_slack_config_screen_perform_oauth_connect_uses_backend(monkeypatch, tmp_path: Path) -> None: + config = _build_config(tmp_path) + screen = SlackConfigScreen(config) + + expected = SlackOAuthResult( + access_token="xoxp-token", + granted_scopes=["users:read"], + team_id="T123", + team_name="Acme", + authed_user_id="U123", + ) + + class FakeFlow: + def __init__(self, client_id, client_secret, redirect_port): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_port = redirect_port + + def run(self): + return expected + + monkeypatch.setattr( + "titan_plugin_slack.screens.slack_config_screen.SlackOAuthFlow", + FakeFlow, + ) + + result = screen._perform_oauth_connect("123", "secret", 8765) + + assert result == expected + + +def test_slack_config_screen_saves_oauth_app_config(tmp_path: Path) -> None: + config = _build_config(tmp_path) + screen = SlackConfigScreen(config) + + screen._save_oauth_app_config("123", "secret", 9999) + + config.secrets.set.assert_called_once_with("slack_oauth_client_secret", "secret", scope="user") + with open(config._global_config_path, "rb") as f: + data = tomli.load(f) + + slack_cfg = data["plugins"]["slack"]["config"] + assert slack_cfg["oauth_client_id"] == "123" + assert slack_cfg["oauth_redirect_port"] == 9999 diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py new file mode 100644 index 00000000..0bd279e8 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -0,0 +1,180 @@ +"""Slack OAuth backend helpers for the Slack configuration flow.""" + +from __future__ import annotations + +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Event, Thread +from typing import Callable +from urllib.parse import parse_qs, urlencode, urlparse +import secrets as secrets_module +import webbrowser + +import requests + + +AUTHORIZE_URL = "https://slack.com/oauth/v2_user/authorize" +TOKEN_URL = "https://slack.com/api/oauth.v2.user.access" +DEFAULT_SCOPES = ["users:read", "channels:read", "channels:history"] + + +class SlackOAuthError(Exception): + """Raised when the Slack OAuth flow fails.""" + + +@dataclass +class SlackOAuthResult: + """Successful OAuth exchange result.""" + + access_token: str + granted_scopes: list[str] + team_id: str | None + team_name: str | None + authed_user_id: str | None + + +class SlackOAuthFlow: + """Backend flow for Slack OAuth-based personal connections.""" + + def __init__( + self, + client_id: str, + client_secret: str, + redirect_port: int = 8765, + scopes: list[str] | None = None, + timeout: int = 180, + browser_opener: Callable[[str], bool] | None = None, + requests_module=requests, + ): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_port = redirect_port + self.scopes = scopes or list(DEFAULT_SCOPES) + self.timeout = timeout + self.browser_opener = browser_opener or webbrowser.open + self.requests = requests_module + + @property + def redirect_uri(self) -> str: + """Return the localhost redirect URI used for callback handling.""" + return f"http://127.0.0.1:{self.redirect_port}/slack/callback" + + def build_authorize_url(self, state: str) -> str: + """Build the Slack OAuth authorize URL.""" + query = urlencode( + { + "client_id": self.client_id, + "scope": ",".join(self.scopes), + "redirect_uri": self.redirect_uri, + "state": state, + } + ) + return f"{AUTHORIZE_URL}?{query}" + + def exchange_code(self, code: str) -> SlackOAuthResult: + """Exchange a Slack OAuth code for a user access token.""" + response = self.requests.post( + TOKEN_URL, + data={ + "code": code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + }, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + + if not payload.get("ok", False): + raise SlackOAuthError( + f"Slack OAuth token exchange failed: {payload.get('error', 'unknown_error')}" + ) + + access_token = payload.get("access_token") or payload.get("authed_user", {}).get("access_token") + if not access_token: + raise SlackOAuthError("Slack OAuth token exchange succeeded without an access token.") + + scope_string = payload.get("scope") or payload.get("authed_user", {}).get("scope") or "" + granted_scopes = [scope.strip() for scope in scope_string.split(",") if scope.strip()] + + team = payload.get("team") or {} + authed_user = payload.get("authed_user") or {} + return SlackOAuthResult( + access_token=access_token, + granted_scopes=granted_scopes, + team_id=team.get("id"), + team_name=team.get("name"), + authed_user_id=authed_user.get("id"), + ) + + def _wait_for_callback(self, expected_state: str) -> str: + """Wait for the local OAuth callback and return the authorization code.""" + callback_event = Event() + callback_data: dict[str, str] = {} + + class CallbackHandler(BaseHTTPRequestHandler): + def do_GET(self): # type: ignore[override] + parsed = urlparse(self.path) + if parsed.path != "/slack/callback": + self.send_response(404) + self.end_headers() + self.wfile.write(b"Not found") + return + + query = parse_qs(parsed.query) + callback_data["code"] = query.get("code", [""])[0] + callback_data["state"] = query.get("state", [""])[0] + callback_data["error"] = query.get("error", [""])[0] + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + b"

Slack connection received.

You can return to Titan.

" + ) + callback_event.set() + + def log_message(self, format, *args): # noqa: A003 + return + + server = HTTPServer(("127.0.0.1", self.redirect_port), CallbackHandler) + + def serve_once() -> None: + try: + while not callback_event.is_set(): + server.handle_request() + finally: + server.server_close() + + thread = Thread(target=serve_once, daemon=True) + thread.start() + callback_event.wait(self.timeout) + server.server_close() + thread.join(timeout=1) + + if not callback_event.is_set(): + raise SlackOAuthError("Slack OAuth callback timed out.") + + if callback_data.get("error"): + raise SlackOAuthError(f"Slack OAuth authorization failed: {callback_data['error']}") + + if callback_data.get("state") != expected_state: + raise SlackOAuthError("Slack OAuth state mismatch.") + + code = callback_data.get("code") + if not code: + raise SlackOAuthError("Slack OAuth callback did not include an authorization code.") + + return code + + def run(self) -> SlackOAuthResult: + """Run the complete OAuth flow and return the resulting token data.""" + state = secrets_module.token_urlsafe(24) + authorize_url = self.build_authorize_url(state) + + browser_started = self.browser_opener(authorize_url) + if browser_started is False: + raise SlackOAuthError("Failed to open a browser for Slack OAuth.") + + code = self._wait_for_callback(state) + return self.exchange_code(code) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 6006ade1..050c7f8b 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -1,16 +1,19 @@ from dataclasses import dataclass +import asyncio import tomli import tomli_w from textual.app import ComposeResult from textual.containers import Container, Horizontal, VerticalScroll from textual.css.query import NoMatches +from textual.widgets import Input from titan_cli.ui.tui.icons import Icons from titan_cli.ui.tui.widgets import BoldPrimaryText, BoldText, Button, DimText, Text from titan_cli.ui.tui.screens.base import BaseScreen from ..clients.slack_client import SlackClient +from ..oauth import DEFAULT_SCOPES, SlackOAuthFlow, SlackOAuthResult @dataclass @@ -18,6 +21,9 @@ class SlackConnectionState: """Current Slack connection state for the active user.""" has_token: bool + oauth_client_id: str | None + has_oauth_client_secret: bool + oauth_redirect_port: int default_team_id: str | None default_team_name: str | None granted_scopes: list[str] @@ -70,6 +76,17 @@ class SlackConfigScreen(BaseScreen): #slack-config-buttons Button { margin-left: 1; } + + Input { + width: 100%; + margin-top: 1; + margin-bottom: 1; + border: solid $accent; + } + + Input:focus { + border: solid $primary; + } """ def __init__(self, config): @@ -110,6 +127,9 @@ def _get_connection_state(self) -> SlackConnectionState: plugin_config = self._load_plugin_config() return SlackConnectionState( has_token=self._has_user_token(), + oauth_client_id=plugin_config.get("oauth_client_id"), + has_oauth_client_secret=bool(self.config.secrets.get("slack_oauth_client_secret")), + oauth_redirect_port=plugin_config.get("oauth_redirect_port", 8765), default_team_id=plugin_config.get("default_team_id"), default_team_name=plugin_config.get("default_team_name"), granted_scopes=plugin_config.get("granted_scopes", []), @@ -151,7 +171,7 @@ def _refresh_view(self) -> None: body.mount(BoldPrimaryText("Connect your personal Slack account")) body.mount(Text("")) body.mount(DimText("Slack uses a personal user token stored securely in your keyring.")) - body.mount(DimText("The primary configuration path for Slack will be OAuth-based.")) + body.mount(DimText("The primary configuration path for Slack uses a browser-based OAuth flow.")) body.mount(Text("")) status_label = "Connected" if state.has_token else "Not connected" @@ -159,28 +179,118 @@ def _refresh_view(self) -> None: body.mount(DimText(f" Status: {status_label}")) body.mount(DimText(f" Auth Mode: {state.auth_mode}")) body.mount(DimText(f" Timeout: {state.timeout}s")) + body.mount(DimText(f" OAuth Client ID: {state.oauth_client_id or 'Not set'}")) + body.mount(DimText(f" OAuth Client Secret: {'Stored' if state.has_oauth_client_secret else 'Not set'}")) + body.mount(DimText(f" OAuth Redirect Port: {state.oauth_redirect_port}")) body.mount(DimText(f" Team ID: {state.default_team_id or 'Not set'}")) body.mount(DimText(f" Team Name: {state.default_team_name or 'Not set'}")) scopes = ", ".join(state.granted_scopes) if state.granted_scopes else "Not recorded" body.mount(DimText(f" Granted Scopes: {scopes}")) body.mount(Text("")) + body.mount(BoldText("OAuth App Configuration")) + body.mount(DimText("Client ID")) + body.mount(Input(value=state.oauth_client_id or "", id="oauth-client-id-input")) + body.mount(DimText("Client Secret")) + body.mount(Input(value="", id="oauth-client-secret-input", password=True)) + body.mount(DimText("Redirect Port")) + body.mount(Input(value=str(state.oauth_redirect_port), id="oauth-redirect-port-input")) + body.mount(Text("")) + body.mount(BoldText("Slack MVP0 Scopes")) - body.mount(DimText(" users:read")) - body.mount(DimText(" channels:read")) - body.mount(DimText(" channels:history")) + for scope in DEFAULT_SCOPES: + body.mount(DimText(f" {scope}")) body.mount(Text("")) - body.mount(DimText("Use Connect Slack to start the Slack-specific connection flow.")) + body.mount(DimText("Use Connect Slack to open the browser-based Slack OAuth flow.")) self.query_one("#validate-button", Button).disabled = not state.has_token self.query_one("#disconnect-button", Button).disabled = not state.has_token + def _read_oauth_form_values(self) -> tuple[str, str, int]: + """Read and validate the OAuth app form values from the screen.""" + client_id = self.query_one("#oauth-client-id-input", Input).value.strip() + client_secret_input = self.query_one("#oauth-client-secret-input", Input).value.strip() + redirect_port_raw = self.query_one("#oauth-redirect-port-input", Input).value.strip() or "8765" + + if not client_id: + raise ValueError("Slack OAuth client ID is required.") + + try: + redirect_port = int(redirect_port_raw) + except ValueError as exc: + raise ValueError("Slack OAuth redirect port must be a number.") from exc + + if redirect_port <= 0: + raise ValueError("Slack OAuth redirect port must be greater than zero.") + + client_secret = client_secret_input or self.config.secrets.get("slack_oauth_client_secret") + if not client_secret: + raise ValueError("Slack OAuth client secret is required.") + + return client_id, client_secret, redirect_port + + def _save_oauth_app_config(self, client_id: str, client_secret: str, redirect_port: int) -> None: + """Persist OAuth app settings for Slack.""" + timeout = self._load_plugin_config().get("timeout", 30) + self.config.secrets.set("slack_oauth_client_secret", client_secret, scope="user") + self._save_global_slack_config( + { + "oauth_client_id": client_id, + "oauth_redirect_port": redirect_port, + "timeout": timeout, + "auth_mode": "user_token", + } + ) + + def _perform_oauth_connect(self, client_id: str, client_secret: str, redirect_port: int) -> SlackOAuthResult: + """Run the synchronous Slack OAuth backend flow.""" + flow = SlackOAuthFlow( + client_id=client_id, + client_secret=client_secret, + redirect_port=redirect_port, + ) + return flow.run() + def _start_oauth_flow(self) -> None: - self.app.notify( - "Slack OAuth flow will be implemented in the next step.", - severity="information", + """Start the Slack OAuth flow in a background worker.""" + try: + client_id, client_secret, redirect_port = self._read_oauth_form_values() + self._save_oauth_app_config(client_id, client_secret, redirect_port) + except Exception as exc: + self.app.notify(f"Slack OAuth setup failed: {exc}", severity="error") + return + + self.app.notify("Opening browser for Slack authorization...", severity="information") + self.run_worker( + self._run_oauth_connect(client_id, client_secret, redirect_port), + exclusive=True, ) + async def _run_oauth_connect(self, client_id: str, client_secret: str, redirect_port: int) -> None: + """Run the Slack OAuth flow without blocking the UI thread.""" + try: + result = await asyncio.to_thread( + self._perform_oauth_connect, + client_id, + client_secret, + redirect_port, + ) + self.config.secrets.set("slack_user_token", result.access_token, scope="user") + self._save_global_slack_config( + { + "oauth_client_id": client_id, + "oauth_redirect_port": redirect_port, + "default_team_id": result.team_id, + "default_team_name": result.team_name, + "granted_scopes": result.granted_scopes, + "auth_mode": "user_token", + } + ) + self.app.notify("Slack connected successfully.", severity="information") + self._refresh_view() + except Exception as exc: + self.app.notify(f"Slack OAuth failed: {exc}", severity="error") + def _validate_connection(self) -> None: plugin_config = self._load_plugin_config() client = SlackClient( diff --git a/titan_cli/core/plugins/models.py b/titan_cli/core/plugins/models.py index d65c12e1..a175a92d 100644 --- a/titan_cli/core/plugins/models.py +++ b/titan_cli/core/plugins/models.py @@ -136,6 +136,16 @@ class SlackPluginConfig(BaseModel): description="Preferred Slack workspace/team ID for the current user.", json_schema_extra={"config_scope": "global"}, ) + oauth_client_id: Optional[str] = Field( + None, + description="Slack OAuth client ID used for personal connection setup.", + json_schema_extra={"config_scope": "global"}, + ) + oauth_redirect_port: int = Field( + 8765, + description="Localhost port used for Slack OAuth callback handling.", + json_schema_extra={"config_scope": "global"}, + ) default_team_name: Optional[str] = Field( None, description="Preferred Slack workspace/team name for the current user.", From 34fa25394eb66c9241d9bc16f19bd0d796153754 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 10:03:42 +0200 Subject: [PATCH 09/23] feat: Implement PKCE-based OAuth flow for Slack integration and remove client secret --- plugins/titan-plugin-slack/AGENTS.md | 6 +- .../titan-plugin-slack/tests/test_oauth.py | 26 ++- .../tests/ui/test_slack_config_screen.py | 25 ++- .../titan_plugin_slack/oauth.py | 90 ++++++++-- .../screens/slack_config_screen.py | 164 ++++++++++++------ titan_cli/core/plugins/models.py | 8 + 6 files changed, 227 insertions(+), 92 deletions(-) diff --git a/plugins/titan-plugin-slack/AGENTS.md b/plugins/titan-plugin-slack/AGENTS.md index 7c069b17..366e90cf 100644 --- a/plugins/titan-plugin-slack/AGENTS.md +++ b/plugins/titan-plugin-slack/AGENTS.md @@ -13,6 +13,7 @@ Current first-phase scope: - Official Titan plugin package and discovery entry point - Personal user-token Slack client baseline - Keyring-first secret policy +- BYO Slack App + PKCE connection flow - No workflow steps yet - No built-in workflows yet @@ -26,9 +27,11 @@ titan_plugin_slack/ ├── plugin.py ├── clients/ │ └── slack_client.py +├── screens/ +│ └── slack_config_screen.py ├── models.py ├── exceptions.py -├── messages.py +├── oauth.py ├── steps/ └── workflows/ ``` @@ -42,3 +45,4 @@ titan_plugin_slack/ - Do not add built-in workflows in this phase. - Prefer small, testable public surfaces. - Keep raw Slack API entities clearly separated from domain return models. +- Keep the configuration UX aligned with BYO Slack App + PKCE. diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py index aecd5dd9..4347fb5c 100644 --- a/plugins/titan-plugin-slack/tests/test_oauth.py +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -19,9 +19,10 @@ def json(self) -> dict: def test_build_authorize_url_contains_expected_oauth_values() -> None: - flow = SlackOAuthFlow(client_id="123", client_secret="secret", redirect_port=8765) + flow = SlackOAuthFlow(client_id="123", redirect_port=8765) + session = flow.create_session() - url = flow.build_authorize_url("state-abc") + url = flow.build_authorize_url(session) parsed = urlparse(url) query = parse_qs(parsed.query) @@ -29,9 +30,11 @@ def test_build_authorize_url_contains_expected_oauth_values() -> None: assert parsed.netloc == "slack.com" assert parsed.path == "/oauth/v2_user/authorize" assert query["client_id"] == ["123"] - assert query["state"] == ["state-abc"] + assert query["state"] == [session.state] assert query["redirect_uri"] == ["http://127.0.0.1:8765/slack/callback"] assert query["scope"] == ["users:read,channels:read,channels:history"] + assert query["code_challenge_method"] == ["S256"] + assert "code_challenge" in query def test_exchange_code_returns_token_and_metadata() -> None: @@ -48,14 +51,9 @@ def post(url, data, timeout): } ) - flow = SlackOAuthFlow( - client_id="123", - client_secret="secret", - redirect_port=8765, - requests_module=FakeRequests, - ) + flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) - result = flow.exchange_code("code-123") + result = flow.exchange_code("code-123", "verifier-123") assert result.access_token == "xoxp-token" assert result.team_id == "T123" @@ -70,14 +68,10 @@ class FakeRequests: def post(url, data, timeout): return _FakeResponse({"ok": False, "error": "invalid_code"}) - flow = SlackOAuthFlow( - client_id="123", - client_secret="secret", - requests_module=FakeRequests, - ) + flow = SlackOAuthFlow(client_id="123", requests_module=FakeRequests) try: - flow.exchange_code("bad-code") + flow.exchange_code("bad-code", "verifier-123") except SlackOAuthError as exc: assert "invalid_code" in str(exc) else: diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index 18cf5b64..22f8b0a5 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -11,6 +11,7 @@ def _build_config(tmp_path: Path, token: str | None = None, plugin_config: dict | None = None): config = MagicMock() config._global_config_path = tmp_path / "config.toml" + config.project_config_path = tmp_path / "project-config.toml" config.config = MagicMock() config.config.config_version = "1.0" config.config.plugins = {} @@ -48,7 +49,6 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: ) config.secrets.get.side_effect = lambda key: { "slack_user_token": "xoxp-token", - "slack_oauth_client_secret": "secret", }.get(key) screen = SlackConfigScreen(config) @@ -56,7 +56,6 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: assert state.has_token is True assert state.oauth_client_id == "123" - assert state.has_oauth_client_secret is True assert state.oauth_redirect_port == 9999 assert state.default_team_id == "T123" assert state.default_team_name == "Acme" @@ -102,7 +101,7 @@ def test_slack_config_screen_start_oauth_flow_runs_worker(tmp_path: Path) -> Non type(screen).app = PropertyMock(return_value=app) screen.run_worker = MagicMock() - screen._read_oauth_form_values = MagicMock(return_value=("123", "secret", 8765)) + screen._read_oauth_form_values = MagicMock(return_value=("123", 8765)) screen._save_oauth_app_config = MagicMock() screen._start_oauth_flow() @@ -129,9 +128,8 @@ def test_slack_config_screen_perform_oauth_connect_uses_backend(monkeypatch, tmp ) class FakeFlow: - def __init__(self, client_id, client_secret, redirect_port): + def __init__(self, client_id, redirect_port): self.client_id = client_id - self.client_secret = client_secret self.redirect_port = redirect_port def run(self): @@ -142,7 +140,7 @@ def run(self): FakeFlow, ) - result = screen._perform_oauth_connect("123", "secret", 8765) + result = screen._perform_oauth_connect("123", 8765) assert result == expected @@ -151,12 +149,23 @@ def test_slack_config_screen_saves_oauth_app_config(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) - screen._save_oauth_app_config("123", "secret", 9999) + screen._save_oauth_app_config("123", 9999) - config.secrets.set.assert_called_once_with("slack_oauth_client_secret", "secret", scope="user") with open(config._global_config_path, "rb") as f: data = tomli.load(f) slack_cfg = data["plugins"]["slack"]["config"] assert slack_cfg["oauth_client_id"] == "123" assert slack_cfg["oauth_redirect_port"] == 9999 + + +def test_slack_config_screen_enable_plugin_for_current_project(tmp_path: Path) -> None: + config = _build_config(tmp_path) + screen = SlackConfigScreen(config) + + screen._enable_plugin_for_current_project() + + with open(config.project_config_path, "rb") as f: + data = tomli.load(f) + + assert data["plugins"]["slack"]["enabled"] is True diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index 0bd279e8..23581e5e 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 from dataclasses import dataclass +import hashlib from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from typing import Callable @@ -12,11 +14,15 @@ import requests +from titan_cli.core.logging import get_logger + AUTHORIZE_URL = "https://slack.com/oauth/v2_user/authorize" TOKEN_URL = "https://slack.com/api/oauth.v2.user.access" DEFAULT_SCOPES = ["users:read", "channels:read", "channels:history"] +logger = get_logger(__name__) + class SlackOAuthError(Exception): """Raised when the Slack OAuth flow fails.""" @@ -33,13 +39,20 @@ class SlackOAuthResult: authed_user_id: str | None +@dataclass +class SlackOAuthSession: + """In-memory OAuth session state for a single PKCE flow.""" + + state: str + code_verifier: str + + class SlackOAuthFlow: """Backend flow for Slack OAuth-based personal connections.""" def __init__( self, client_id: str, - client_secret: str, redirect_port: int = 8765, scopes: list[str] | None = None, timeout: int = 180, @@ -47,7 +60,6 @@ def __init__( requests_module=requests, ): self.client_id = client_id - self.client_secret = client_secret self.redirect_port = redirect_port self.scopes = scopes or list(DEFAULT_SCOPES) self.timeout = timeout @@ -59,27 +71,53 @@ def redirect_uri(self) -> str: """Return the localhost redirect URI used for callback handling.""" return f"http://127.0.0.1:{self.redirect_port}/slack/callback" - def build_authorize_url(self, state: str) -> str: + @staticmethod + def _build_code_challenge(code_verifier: str) -> str: + """Build a PKCE code challenge from a verifier.""" + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") + + def create_session(self) -> SlackOAuthSession: + """Create a new OAuth session with state and PKCE verifier.""" + return SlackOAuthSession( + state=secrets_module.token_urlsafe(24), + code_verifier=secrets_module.token_urlsafe(48), + ) + + def build_authorize_url(self, session: SlackOAuthSession) -> str: """Build the Slack OAuth authorize URL.""" query = urlencode( { "client_id": self.client_id, "scope": ",".join(self.scopes), "redirect_uri": self.redirect_uri, - "state": state, + "state": session.state, + "code_challenge": self._build_code_challenge(session.code_verifier), + "code_challenge_method": "S256", } ) - return f"{AUTHORIZE_URL}?{query}" + authorize_url = f"{AUTHORIZE_URL}?{query}" + logger.info( + "slack_oauth_authorize_url_built", + redirect_uri=self.redirect_uri, + scopes=self.scopes, + ) + return authorize_url - def exchange_code(self, code: str) -> SlackOAuthResult: + def exchange_code(self, code: str, code_verifier: str) -> SlackOAuthResult: """Exchange a Slack OAuth code for a user access token.""" + logger.info( + "slack_oauth_exchange_started", + redirect_uri=self.redirect_uri, + ) response = self.requests.post( TOKEN_URL, data={ "code": code, "client_id": self.client_id, - "client_secret": self.client_secret, "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + "code_verifier": code_verifier, }, timeout=30, ) @@ -87,6 +125,11 @@ def exchange_code(self, code: str) -> SlackOAuthResult: payload = response.json() if not payload.get("ok", False): + logger.error( + "slack_oauth_exchange_failed", + error=payload.get("error", "unknown_error"), + payload=payload, + ) raise SlackOAuthError( f"Slack OAuth token exchange failed: {payload.get('error', 'unknown_error')}" ) @@ -100,6 +143,13 @@ def exchange_code(self, code: str) -> SlackOAuthResult: team = payload.get("team") or {} authed_user = payload.get("authed_user") or {} + logger.info( + "slack_oauth_exchange_succeeded", + team_id=team.get("id"), + team_name=team.get("name"), + authed_user_id=authed_user.get("id"), + granted_scopes=granted_scopes, + ) return SlackOAuthResult( access_token=access_token, granted_scopes=granted_scopes, @@ -110,6 +160,11 @@ def exchange_code(self, code: str) -> SlackOAuthResult: def _wait_for_callback(self, expected_state: str) -> str: """Wait for the local OAuth callback and return the authorization code.""" + logger.info( + "slack_oauth_callback_wait_started", + redirect_uri=self.redirect_uri, + timeout=self.timeout, + ) callback_event = Event() callback_data: dict[str, str] = {} @@ -126,6 +181,11 @@ def do_GET(self): # type: ignore[override] callback_data["code"] = query.get("code", [""])[0] callback_data["state"] = query.get("state", [""])[0] callback_data["error"] = query.get("error", [""])[0] + logger.info( + "slack_oauth_callback_received", + has_code=bool(callback_data["code"]), + has_error=bool(callback_data["error"]), + ) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() @@ -153,28 +213,36 @@ def serve_once() -> None: thread.join(timeout=1) if not callback_event.is_set(): + logger.error("slack_oauth_callback_timeout", redirect_uri=self.redirect_uri) raise SlackOAuthError("Slack OAuth callback timed out.") if callback_data.get("error"): + logger.error( + "slack_oauth_callback_error", + error=callback_data["error"], + ) raise SlackOAuthError(f"Slack OAuth authorization failed: {callback_data['error']}") if callback_data.get("state") != expected_state: + logger.error("slack_oauth_state_mismatch") raise SlackOAuthError("Slack OAuth state mismatch.") code = callback_data.get("code") if not code: + logger.error("slack_oauth_callback_missing_code") raise SlackOAuthError("Slack OAuth callback did not include an authorization code.") return code def run(self) -> SlackOAuthResult: """Run the complete OAuth flow and return the resulting token data.""" - state = secrets_module.token_urlsafe(24) - authorize_url = self.build_authorize_url(state) + session = self.create_session() + authorize_url = self.build_authorize_url(session) browser_started = self.browser_opener(authorize_url) if browser_started is False: + logger.error("slack_oauth_browser_open_failed") raise SlackOAuthError("Failed to open a browser for Slack OAuth.") - code = self._wait_for_callback(state) - return self.exchange_code(code) + code = self._wait_for_callback(session.state) + return self.exchange_code(code, session.code_verifier) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 050c7f8b..60c74ed5 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -6,23 +6,26 @@ from textual.app import ComposeResult from textual.containers import Container, Horizontal, VerticalScroll from textual.css.query import NoMatches -from textual.widgets import Input +from textual.widgets import Input, Static from titan_cli.ui.tui.icons import Icons from titan_cli.ui.tui.widgets import BoldPrimaryText, BoldText, Button, DimText, Text from titan_cli.ui.tui.screens.base import BaseScreen +from titan_cli.core.logging import get_logger from ..clients.slack_client import SlackClient from ..oauth import DEFAULT_SCOPES, SlackOAuthFlow, SlackOAuthResult +logger = get_logger(__name__) + + @dataclass class SlackConnectionState: """Current Slack connection state for the active user.""" has_token: bool oauth_client_id: str | None - has_oauth_client_secret: bool oauth_redirect_port: int default_team_id: str | None default_team_name: str | None @@ -65,6 +68,15 @@ class SlackConfigScreen(BaseScreen): height: auto; } + .slack-section-title { + margin-top: 1; + } + + .slack-section-body { + height: auto; + margin-bottom: 1; + } + #slack-config-buttons { height: auto; padding: 1 2; @@ -103,7 +115,26 @@ def compose_content(self) -> ComposeResult: panel.border_title = "Slack Connection" with panel: with VerticalScroll(id="slack-config-scroll"): - yield Container(id="slack-config-body") + with Container(id="slack-config-body"): + yield BoldPrimaryText("Connect your personal Slack account", id="slack-title") + yield Text("") + yield Static(id="slack-intro") + yield Text("") + + yield BoldText("Current Status", classes="slack-section-title") + yield Static(id="slack-status-block", classes="slack-section-body") + + yield BoldText("OAuth App Configuration", classes="slack-section-title") + yield Static(id="slack-oauth-help", classes="slack-section-body") + yield DimText("Client ID") + yield Input(id="oauth-client-id-input") + yield DimText("Redirect Port") + yield Input(value="8765", id="oauth-redirect-port-input") + yield Text("") + + yield BoldText("Slack MVP0 Scopes", classes="slack-section-title") + yield Static(id="slack-scopes-block", classes="slack-section-body") + yield Static(id="slack-connect-help", classes="slack-section-body") with Horizontal(id="slack-config-buttons"): yield Button("Connect Slack", variant="primary", id="connect-button") @@ -128,7 +159,6 @@ def _get_connection_state(self) -> SlackConnectionState: return SlackConnectionState( has_token=self._has_user_token(), oauth_client_id=plugin_config.get("oauth_client_id"), - has_oauth_client_secret=bool(self.config.secrets.get("slack_oauth_client_secret")), oauth_redirect_port=plugin_config.get("oauth_redirect_port", 8765), default_team_id=plugin_config.get("default_team_id"), default_team_name=plugin_config.get("default_team_name"), @@ -160,56 +190,81 @@ def _save_global_slack_config(self, updates: dict[str, object | None]) -> None: self.config.load() + def _enable_plugin_for_current_project(self) -> None: + """Ensure Slack is enabled in the current project's config.""" + project_cfg_path = self.config.project_config_path + if not project_cfg_path: + return + + project_cfg_path.parent.mkdir(parents=True, exist_ok=True) + project_data = {} + if project_cfg_path.exists(): + with open(project_cfg_path, "rb") as f: + project_data = tomli.load(f) + + plugins = project_data.setdefault("plugins", {}) + plugin_table = plugins.setdefault("slack", {}) + plugin_table["enabled"] = True + + with open(project_cfg_path, "wb") as f: + tomli_w.dump(project_data, f) + + self.config.load() + def _refresh_view(self) -> None: state = self._get_connection_state() try: - body = self.query_one("#slack-config-body", Container) + intro = self.query_one("#slack-intro", Static) + status_block = self.query_one("#slack-status-block", Static) + oauth_help = self.query_one("#slack-oauth-help", Static) + scopes_block = self.query_one("#slack-scopes-block", Static) + connect_help = self.query_one("#slack-connect-help", Static) + client_id_input = self.query_one("#oauth-client-id-input", Input) + redirect_port_input = self.query_one("#oauth-redirect-port-input", Input) except NoMatches: return - body.remove_children() - - body.mount(BoldPrimaryText("Connect your personal Slack account")) - body.mount(Text("")) - body.mount(DimText("Slack uses a personal user token stored securely in your keyring.")) - body.mount(DimText("The primary configuration path for Slack uses a browser-based OAuth flow.")) - body.mount(Text("")) status_label = "Connected" if state.has_token else "Not connected" - body.mount(BoldText("Current Status")) - body.mount(DimText(f" Status: {status_label}")) - body.mount(DimText(f" Auth Mode: {state.auth_mode}")) - body.mount(DimText(f" Timeout: {state.timeout}s")) - body.mount(DimText(f" OAuth Client ID: {state.oauth_client_id or 'Not set'}")) - body.mount(DimText(f" OAuth Client Secret: {'Stored' if state.has_oauth_client_secret else 'Not set'}")) - body.mount(DimText(f" OAuth Redirect Port: {state.oauth_redirect_port}")) - body.mount(DimText(f" Team ID: {state.default_team_id or 'Not set'}")) - body.mount(DimText(f" Team Name: {state.default_team_name or 'Not set'}")) scopes = ", ".join(state.granted_scopes) if state.granted_scopes else "Not recorded" - body.mount(DimText(f" Granted Scopes: {scopes}")) - body.mount(Text("")) - - body.mount(BoldText("OAuth App Configuration")) - body.mount(DimText("Client ID")) - body.mount(Input(value=state.oauth_client_id or "", id="oauth-client-id-input")) - body.mount(DimText("Client Secret")) - body.mount(Input(value="", id="oauth-client-secret-input", password=True)) - body.mount(DimText("Redirect Port")) - body.mount(Input(value=str(state.oauth_redirect_port), id="oauth-redirect-port-input")) - body.mount(Text("")) - - body.mount(BoldText("Slack MVP0 Scopes")) - for scope in DEFAULT_SCOPES: - body.mount(DimText(f" {scope}")) - body.mount(Text("")) - body.mount(DimText("Use Connect Slack to open the browser-based Slack OAuth flow.")) + + intro.update( + "Slack uses a personal user token stored securely in your keyring.\n" + "The primary configuration path for Slack uses a browser-based OAuth flow." + ) + status_block.update( + f" Status: {status_label}\n" + f" Auth Mode: {state.auth_mode}\n" + f" Timeout: {state.timeout}s\n" + f" OAuth Client ID: {state.oauth_client_id or 'Not set'}\n" + f" OAuth Redirect Port: {state.oauth_redirect_port}\n" + f" Team ID: {state.default_team_id or 'Not set'}\n" + f" Team Name: {state.default_team_name or 'Not set'}\n" + f" Granted Scopes: {scopes}" + ) + oauth_help.update( + "Titan will open Slack in your browser and complete the OAuth PKCE flow.\n" + "Create your own Slack App, enable PKCE, and configure this exact redirect URL in Slack OAuth settings:\n" + f" {self._build_redirect_uri(state.oauth_redirect_port)}\n" + "The redirect URL in Slack must match exactly, including host, port, and path.\n" + "For example, `127.0.0.1` and `localhost` are different values for Slack." + ) + scopes_block.update("\n".join(f" {scope}" for scope in DEFAULT_SCOPES)) + connect_help.update("Use Connect Slack to open the browser-based Slack OAuth flow.") + + client_id_input.value = state.oauth_client_id or "" + redirect_port_input.value = str(state.oauth_redirect_port) self.query_one("#validate-button", Button).disabled = not state.has_token self.query_one("#disconnect-button", Button).disabled = not state.has_token - def _read_oauth_form_values(self) -> tuple[str, str, int]: + @staticmethod + def _build_redirect_uri(port: int) -> str: + """Build the localhost redirect URI shown to the user.""" + return f"http://127.0.0.1:{port}/slack/callback" + + def _read_oauth_form_values(self) -> tuple[str, int]: """Read and validate the OAuth app form values from the screen.""" client_id = self.query_one("#oauth-client-id-input", Input).value.strip() - client_secret_input = self.query_one("#oauth-client-secret-input", Input).value.strip() redirect_port_raw = self.query_one("#oauth-redirect-port-input", Input).value.strip() or "8765" if not client_id: @@ -223,16 +278,11 @@ def _read_oauth_form_values(self) -> tuple[str, str, int]: if redirect_port <= 0: raise ValueError("Slack OAuth redirect port must be greater than zero.") - client_secret = client_secret_input or self.config.secrets.get("slack_oauth_client_secret") - if not client_secret: - raise ValueError("Slack OAuth client secret is required.") - - return client_id, client_secret, redirect_port + return client_id, redirect_port - def _save_oauth_app_config(self, client_id: str, client_secret: str, redirect_port: int) -> None: + def _save_oauth_app_config(self, client_id: str, redirect_port: int) -> None: """Persist OAuth app settings for Slack.""" timeout = self._load_plugin_config().get("timeout", 30) - self.config.secrets.set("slack_oauth_client_secret", client_secret, scope="user") self._save_global_slack_config( { "oauth_client_id": client_id, @@ -242,11 +292,10 @@ def _save_oauth_app_config(self, client_id: str, client_secret: str, redirect_po } ) - def _perform_oauth_connect(self, client_id: str, client_secret: str, redirect_port: int) -> SlackOAuthResult: + def _perform_oauth_connect(self, client_id: str, redirect_port: int) -> SlackOAuthResult: """Run the synchronous Slack OAuth backend flow.""" flow = SlackOAuthFlow( client_id=client_id, - client_secret=client_secret, redirect_port=redirect_port, ) return flow.run() @@ -254,25 +303,25 @@ def _perform_oauth_connect(self, client_id: str, client_secret: str, redirect_po def _start_oauth_flow(self) -> None: """Start the Slack OAuth flow in a background worker.""" try: - client_id, client_secret, redirect_port = self._read_oauth_form_values() - self._save_oauth_app_config(client_id, client_secret, redirect_port) + client_id, redirect_port = self._read_oauth_form_values() + self._save_oauth_app_config(client_id, redirect_port) except Exception as exc: + logger.exception("slack_oauth_setup_failed") self.app.notify(f"Slack OAuth setup failed: {exc}", severity="error") return self.app.notify("Opening browser for Slack authorization...", severity="information") self.run_worker( - self._run_oauth_connect(client_id, client_secret, redirect_port), + self._run_oauth_connect(client_id, redirect_port), exclusive=True, ) - async def _run_oauth_connect(self, client_id: str, client_secret: str, redirect_port: int) -> None: + async def _run_oauth_connect(self, client_id: str, redirect_port: int) -> None: """Run the Slack OAuth flow without blocking the UI thread.""" try: result = await asyncio.to_thread( self._perform_oauth_connect, client_id, - client_secret, redirect_port, ) self.config.secrets.set("slack_user_token", result.access_token, scope="user") @@ -286,9 +335,11 @@ async def _run_oauth_connect(self, client_id: str, client_secret: str, redirect_ "auth_mode": "user_token", } ) + self._enable_plugin_for_current_project() self.app.notify("Slack connected successfully.", severity="information") - self._refresh_view() + self.dismiss(result=True) except Exception as exc: + logger.exception("slack_oauth_run_failed") self.app.notify(f"Slack OAuth failed: {exc}", severity="error") def _validate_connection(self) -> None: @@ -308,8 +359,9 @@ def _validate_connection(self) -> None: "timeout": plugin_config.get("timeout", 30), } ) + self._enable_plugin_for_current_project() self.app.notify("Slack connection validated successfully.", severity="information") - self._refresh_view() + self.dismiss(result=True) def _disconnect(self) -> None: self.config.secrets.delete("slack_user_token", scope="user") diff --git a/titan_cli/core/plugins/models.py b/titan_cli/core/plugins/models.py index a175a92d..663d4182 100644 --- a/titan_cli/core/plugins/models.py +++ b/titan_cli/core/plugins/models.py @@ -174,3 +174,11 @@ def validate_auth_mode(cls, v: str) -> str: if v != "user_token": raise ValueError("Slack auth_mode must be 'user_token'") return v + + @field_validator("oauth_redirect_port") + @classmethod + def validate_oauth_redirect_port(cls, v: int) -> int: + """Validate Slack OAuth redirect port.""" + if v <= 0: + raise ValueError("Slack oauth_redirect_port must be greater than zero") + return v From 6434abd014194af5b937cfd9714f0e641083a5fa Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 10:56:32 +0200 Subject: [PATCH 10/23] feat: Implement Slack plugin with workspace discovery workflow and discovery steps --- .../_generated/slack-step-inventory.json | 105 +++++++++++ docs/plugins/_meta/slack-step-groups.json | 13 ++ .../plugins/generated/slack-step-reference.md | 145 +++++++++++++++ docs/plugins/index.md | 6 +- docs/plugins/slack/built-in-workflows.md | 21 +++ docs/plugins/slack/client-api.md | 73 ++++++++ docs/plugins/slack/overview.md | 50 +++++ docs/plugins/slack/workflow-steps.md | 160 ++++++++++++++++ .../titan-plugin-slack/tests/test_plugin.py | 10 +- .../titan-plugin-slack/tests/test_steps.py | 113 ++++++++++++ .../tests/test_workflows.py | 28 +++ .../titan_plugin_slack/plugin.py | 12 +- .../titan_plugin_slack/steps/__init__.py | 12 ++ .../steps/discovery_steps.py | 173 ++++++++++++++++++ .../workflows/discover-slack-workspace.yaml | 27 +++ .../test_workflow_context_builder_slack.py | 34 ++++ titan_cli/engine/builder.py | 29 +++ titan_cli/engine/context.py | 1 + 18 files changed, 1008 insertions(+), 4 deletions(-) create mode 100644 docs/plugins/_generated/slack-step-inventory.json create mode 100644 docs/plugins/_meta/slack-step-groups.json create mode 100644 docs/plugins/generated/slack-step-reference.md create mode 100644 docs/plugins/slack/built-in-workflows.md create mode 100644 docs/plugins/slack/client-api.md create mode 100644 docs/plugins/slack/overview.md create mode 100644 docs/plugins/slack/workflow-steps.md create mode 100644 plugins/titan-plugin-slack/tests/test_steps.py create mode 100644 plugins/titan-plugin-slack/tests/test_workflows.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml create mode 100644 tests/engine/test_workflow_context_builder_slack.py diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json new file mode 100644 index 00000000..5dce4222 --- /dev/null +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -0,0 +1,105 @@ +{ + "plugin": "slack", + "groups": [ + { + "name": "Validation and Discovery", + "steps": [ + { + "name": "validate_connection", + "summary": "Validate the configured Slack token and expose identity metadata." + }, + { + "name": "list_public_channels", + "summary": "List public channels visible to the current Slack token." + }, + { + "name": "list_users", + "summary": "List users visible to the current Slack token." + } + ] + } + ], + "steps": [ + { + "name": "validate_connection", + "group": "Validation and Discovery", + "module": "titan_plugin_slack.steps.discovery_steps", + "function": "validate_connection_step", + "summary": "Validate the configured Slack connection and expose identity metadata.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [], + "Outputs (saved to ctx.data)": [ + " slack_auth (dict): Slack auth identity details from `auth_test()`.", + " slack_team_id (str | None): Team identifier reported by Slack.", + " slack_team_name (str | None): Team name reported by Slack.", + " slack_user_id (str | None): User identifier reported by Slack." + ], + "Returns": [ + " Success: If the Slack connection validates successfully.", + " Error: If the Slack client is not available or the auth request fails." + ] + }, + "used_by_workflows": [ + "discover-slack-workspace" + ] + }, + { + "name": "list_public_channels", + "group": "Validation and Discovery", + "module": "titan_plugin_slack.steps.discovery_steps", + "function": "list_public_channels_step", + "summary": "List public Slack channels visible to the current token.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_limit (int, optional): Maximum number of channels to request. Defaults to 100.", + " slack_cursor (str, optional): Pagination cursor for the next page.", + " slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True." + ], + "Outputs (saved to ctx.data)": [ + " slack_channels (list[NetworkSlackChannel]): Public channels returned by Slack.", + " slack_channels_next_cursor (str | None): Pagination cursor for a later request." + ], + "Returns": [ + " Success: If the channel list is retrieved successfully.", + " Error: If the Slack client is not available or the Slack request fails." + ] + }, + "used_by_workflows": [ + "discover-slack-workspace" + ] + }, + { + "name": "list_users", + "group": "Validation and Discovery", + "module": "titan_plugin_slack.steps.discovery_steps", + "function": "list_users_step", + "summary": "List Slack users visible to the current token.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_limit (int, optional): Maximum number of users to request. Defaults to 100.", + " slack_cursor (str, optional): Pagination cursor for the next page." + ], + "Outputs (saved to ctx.data)": [ + " slack_users (list[NetworkSlackUser]): Users returned by Slack.", + " slack_users_next_cursor (str | None): Pagination cursor for a later request." + ], + "Returns": [ + " Success: If the user list is retrieved successfully.", + " Error: If the Slack client is not available or the Slack request fails." + ] + }, + "used_by_workflows": [ + "discover-slack-workspace" + ] + } + ] +} diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json new file mode 100644 index 00000000..af33ac73 --- /dev/null +++ b/docs/plugins/_meta/slack-step-groups.json @@ -0,0 +1,13 @@ +{ + "plugin": "slack", + "groups": [ + { + "name": "Validation and Discovery", + "steps": [ + {"name": "validate_connection", "summary": "Validate the configured Slack token and expose identity metadata."}, + {"name": "list_public_channels", "summary": "List public channels visible to the current Slack token."}, + {"name": "list_users", "summary": "List users visible to the current Slack token."} + ] + } + ] +} diff --git a/docs/plugins/generated/slack-step-reference.md b/docs/plugins/generated/slack-step-reference.md new file mode 100644 index 00000000..068cff1f --- /dev/null +++ b/docs/plugins/generated/slack-step-reference.md @@ -0,0 +1,145 @@ +# Slack Step Reference + +This page is generated from the public step inventory and shows the documented workflow contract for each public step. + +## Validation and Discovery + +### `validate_connection` + +Validate the configured Slack connection and expose identity metadata. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: validate_connection +``` + +**Used by built-in workflows:** `discover-slack-workspace` + +**Available to later steps:** `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_auth` | dict | Slack auth identity details from `auth_test()`. | +| `slack_team_id` | str \| None | Team identifier reported by Slack. | +| `slack_team_name` | str \| None | Team name reported by Slack. | +| `slack_user_id` | str \| None | User identifier reported by Slack. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` | If the Slack connection validates successfully. | +| `Error` | - | If the Slack client is not available or the auth request fails. | + +### `list_public_channels` + +List public Slack channels visible to the current token. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: list_public_channels +``` + +**Used by built-in workflows:** `discover-slack-workspace` + +**Available to later steps:** `slack_channels`, `slack_channels_next_cursor` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_limit` | int, optional | Maximum number of channels to request. Defaults to 100. | +| `slack_cursor` | str, optional | Pagination cursor for the next page. | +| `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_channels` | list[NetworkSlackChannel] | Public channels returned by Slack. | +| `slack_channels_next_cursor` | str \| None | Pagination cursor for a later request. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_channels`, `slack_channels_next_cursor` | If the channel list is retrieved successfully. | +| `Error` | - | If the Slack client is not available or the Slack request fails. | + +### `list_users` + +List Slack users visible to the current token. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: list_users +``` + +**Used by built-in workflows:** `discover-slack-workspace` + +**Available to later steps:** `slack_users`, `slack_users_next_cursor` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_limit` | int, optional | Maximum number of users to request. Defaults to 100. | +| `slack_cursor` | str, optional | Pagination cursor for the next page. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_users` | list[NetworkSlackUser] | Users returned by Slack. | +| `slack_users_next_cursor` | str \| None | Pagination cursor for a later request. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_users`, `slack_users_next_cursor` | If the user list is retrieved successfully. | +| `Error` | - | If the Slack client is not available or the Slack request fails. | diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 509b2938..abaea335 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -11,13 +11,14 @@ plugin clients directly and compose workflows from reusable public steps. ## Official plugins -Titan ships with three official plugins: +Titan ships with four official plugins: | Plugin | Description | |--------|-------------| | **git** | Smart commits, branch management, AI-powered commit messages | | **github** | Create PRs with AI descriptions, manage issues, code reviews | | **jira** | Search issues, AI-powered analysis, workflow automation | +| **slack** | Personal Slack auth, workspace validation, and read-only discovery | Enable them per project in `.titan/config.toml`: @@ -30,6 +31,9 @@ enabled = true [plugins.jira] enabled = true + +[plugins.slack] +enabled = true ``` For each plugin, the docs are split into: diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md new file mode 100644 index 00000000..c1ef15bb --- /dev/null +++ b/docs/plugins/slack/built-in-workflows.md @@ -0,0 +1,21 @@ +# Slack Built-in Workflows + +The Slack plugin currently ships one small built-in workflow for connection validation and read-only discovery. + +## `discover-slack-workspace` + +Validate the current Slack connection, list public channels, and list visible users. + +**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml` + +### Default flow + +1. `slack.validate_connection` +2. `slack.list_public_channels` +3. `slack.list_users` + +### Typical usage + +- verify that Slack OAuth configuration is working end to end +- inspect what the current personal token can read before building richer workflows +- confirm the first public Slack step surface behaves coherently inside Titan diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md new file mode 100644 index 00000000..b395c386 --- /dev/null +++ b/docs/plugins/slack/client-api.md @@ -0,0 +1,73 @@ +# Slack Client API + +The Slack plugin adds read-oriented Slack operations to Titan through `SlackClient`. + +## Requirements + +To use the Slack client in Titan code: + +- enable the `slack` plugin +- complete Slack OAuth configuration so a personal token is available + +## Accessing the client + +```python +slack_plugin = config.registry.get_plugin("slack") +client = slack_plugin.get_client() +``` + +## Connection validation + +### `auth_test()` + +Validate the configured personal Slack token and return identity metadata. + +**Call:** + +```python +client.auth_test() +``` + +**Parameters:** + +- No parameters. + +## Discovery operations + +### `list_users(limit=100, cursor=None)` + +List Slack users visible to the current token. + +**Parameters:** + +- `limit`: Optional maximum number of users to return. +- `cursor`: Optional pagination cursor. + +### `list_public_channels(limit=100, cursor=None, exclude_archived=True)` + +List public Slack channels visible to the current token. + +**Parameters:** + +- `limit`: Optional maximum number of channels to return. +- `cursor`: Optional pagination cursor. +- `exclude_archived`: Optional flag to skip archived channels. + +### `read_channel(channel_id, limit=20, cursor=None, oldest=None, latest=None, inclusive=False)` + +Read message history from a Slack public channel. + +**Parameters:** + +- `channel_id`: Required Slack channel ID. +- `limit`: Optional maximum number of messages to return. +- `cursor`: Optional pagination cursor. +- `oldest`: Optional oldest timestamp bound. +- `latest`: Optional latest timestamp bound. +- `inclusive`: Optional boundary inclusion flag. + +## Usage constraints + +- The current public workflow surface only exposes validation and discovery steps. +- `read_channel()` exists in the client API but is not yet exposed as a public workflow step. +- The first public Slack surface assumes one active personal connection per user. diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md new file mode 100644 index 00000000..f24328b3 --- /dev/null +++ b/docs/plugins/slack/overview.md @@ -0,0 +1,50 @@ +# Slack Plugin + +The Slack plugin provides Titan's Slack integration for personal user authentication, workspace validation, and read-only discovery of users and public channels. It exposes: + +- a high-level `SlackClient` for direct use from Titan code +- reusable workflow `steps` for connection validation and discovery +- one built-in discovery workflow for validating and inspecting the current Slack workspace surface + +## Requirements + +To use the Slack plugin in a project: + +- Enable the `slack` plugin in `.titan/config.toml` +- Configure Slack through Titan's Slack-specific configuration screen +- Complete the BYO Slack App + PKCE connection flow +- Store the resulting personal Slack token in Titan secrets/keyring + +Example project configuration: + +```toml +[plugins.slack] +enabled = true +``` + +Slack stores the personal token in secrets, not in the config file. + +## Public surfaces + +- [Client API](./client-api.md): direct Python methods exposed by `SlackClient` +- [Workflow Steps](./workflow-steps.md): public reusable workflow steps grouped by functionality +- [Built-in Workflows](./built-in-workflows.md): workflows shipped by the plugin + +## Accessing the client + +In Titan code, the public entry point is the Slack plugin client: + +```python +slack_plugin = config.registry.get_plugin("slack") +client = slack_plugin.get_client() +``` + +## Public workflow steps + +The Slack plugin currently exposes public reusable steps for: + +- validating the current Slack connection +- listing public channels visible to the current token +- listing users visible to the current token + +The grouped reference lives in [Workflow Steps](./workflow-steps.md). diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md new file mode 100644 index 00000000..9ad38b14 --- /dev/null +++ b/docs/plugins/slack/workflow-steps.md @@ -0,0 +1,160 @@ +# Slack Workflow Steps + +The Slack plugin exposes public reusable workflow steps through `SlackPlugin.get_steps()`. The first Slack step surface is intentionally small and focused on connection validation plus read-only discovery. + +For full contract details for every public step, including documented inputs, outputs, and return behavior, see the [detailed step reference](../generated/slack-step-reference.md). + +## Functional groups + +- [Validation and Discovery](#validation-and-discovery) + +## Summary + +| Step | Group | Used by built-in workflows | +|------|-------|----------------------------| +| `validate_connection` | Validation and Discovery | `discover-slack-workspace` | +| `list_public_channels` | Validation and Discovery | `discover-slack-workspace` | +| `list_users` | Validation and Discovery | `discover-slack-workspace` | + +## Validation and Discovery + +Use these steps to validate the current Slack connection and inspect the accessible workspace surface. + +- `validate_connection`: validate the configured Slack token and expose identity metadata +- `list_public_channels`: list public channels visible to the current token +- `list_users`: list users visible to the current token + +## Detailed Step Contracts + +The summaries above show what each slack step is for. The sections below show the documented contract for each public step: what it expects from `ctx.data`, what it saves back, and what result types it may return. + +How to read these contracts: + +- `Inputs (from ctx.data)` = values the step expects before it runs. +- `Outputs (saved to ctx.data)` = metadata keys saved for later steps when the step returns `Success` or `Skip`. +- `Returns` = the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate payload. + +### Validation and Discovery + +??? info "`validate_connection`" + Validate the configured Slack connection and expose identity metadata. + + **Workflow usage** + + ```yaml + - plugin: slack + step: validate_connection + ``` + + **Used by built-in workflows:** `discover-slack-workspace` + + **Available to later steps:** `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + None documented. + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_auth` | dict | Slack auth identity details from `auth_test()`. | + | `slack_team_id` | str \| None | Team identifier reported by Slack. | + | `slack_team_name` | str \| None | Team name reported by Slack. | + | `slack_user_id` | str \| None | User identifier reported by Slack. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` | If the Slack connection validates successfully. | + | `Error` | - | If the Slack client is not available or the auth request fails. | + +??? info "`list_public_channels`" + List public Slack channels visible to the current token. + + **Workflow usage** + + ```yaml + - plugin: slack + step: list_public_channels + ``` + + **Used by built-in workflows:** `discover-slack-workspace` + + **Available to later steps:** `slack_channels`, `slack_channels_next_cursor` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_limit` | int, optional | Maximum number of channels to request. Defaults to 100. | + | `slack_cursor` | str, optional | Pagination cursor for the next page. | + | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_channels` | list[NetworkSlackChannel] | Public channels returned by Slack. | + | `slack_channels_next_cursor` | str \| None | Pagination cursor for a later request. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_channels`, `slack_channels_next_cursor` | If the channel list is retrieved successfully. | + | `Error` | - | If the Slack client is not available or the Slack request fails. | + +??? info "`list_users`" + List Slack users visible to the current token. + + **Workflow usage** + + ```yaml + - plugin: slack + step: list_users + ``` + + **Used by built-in workflows:** `discover-slack-workspace` + + **Available to later steps:** `slack_users`, `slack_users_next_cursor` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_limit` | int, optional | Maximum number of users to request. Defaults to 100. | + | `slack_cursor` | str, optional | Pagination cursor for the next page. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_users` | list[NetworkSlackUser] | Users returned by Slack. | + | `slack_users_next_cursor` | str \| None | Pagination cursor for a later request. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_users`, `slack_users_next_cursor` | If the user list is retrieved successfully. | + | `Error` | - | If the Slack client is not available or the Slack request fails. | diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 3dc41ada..aa5c8cb4 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -14,10 +14,16 @@ def test_slack_plugin_basic_properties() -> None: assert plugin.dependencies == [] -def test_slack_plugin_has_no_steps_in_phase_one() -> None: +def test_slack_plugin_exposes_public_steps() -> None: plugin = SlackPlugin() - assert plugin.get_steps() == {} + steps = plugin.get_steps() + + assert set(steps) == { + "validate_connection", + "list_public_channels", + "list_users", + } def test_slack_plugin_exposes_workflows_path() -> None: diff --git a/plugins/titan-plugin-slack/tests/test_steps.py b/plugins/titan-plugin-slack/tests/test_steps.py new file mode 100644 index 00000000..7c73332e --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_steps.py @@ -0,0 +1,113 @@ +from unittest.mock import MagicMock + +from titan_cli.engine import Error, Success +from titan_cli.engine.context import WorkflowContext +from titan_plugin_slack.models import NetworkSlackChannel, NetworkSlackUser +from titan_plugin_slack.steps.discovery_steps import ( + list_public_channels_step, + list_users_step, + validate_connection_step, +) + + +def _build_context() -> WorkflowContext: + ctx = WorkflowContext(secrets=MagicMock()) + ctx.textual = MagicMock() + + loading_mock = MagicMock() + loading_mock.__enter__ = MagicMock(return_value=loading_mock) + loading_mock.__exit__ = MagicMock(return_value=None) + ctx.textual.loading = MagicMock(return_value=loading_mock) + + return ctx + + +def test_validate_connection_step_returns_error_without_slack_client() -> None: + ctx = _build_context() + + result = validate_connection_step(ctx) + + assert isinstance(result, Error) + assert result.message == "Slack client not available" + + +def test_validate_connection_step_returns_auth_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.slack.auth_test.return_value = { + "user_id": "U123", + "team_id": "T123", + "team": "Acme", + "url": "https://acme.slack.com", + "bot_id": None, + } + + result = validate_connection_step(ctx) + + assert isinstance(result, Success) + assert result.metadata == { + "slack_auth": { + "user_id": "U123", + "team_id": "T123", + "team": "Acme", + "url": "https://acme.slack.com", + "bot_id": None, + }, + "slack_team_id": "T123", + "slack_team_name": "Acme", + "slack_user_id": "U123", + } + + +def test_list_public_channels_step_returns_channels_and_cursor() -> None: + ctx = _build_context() + ctx.data.update({"slack_limit": 25, "slack_cursor": "cursor-1"}) + ctx.slack = MagicMock() + ctx.slack.list_public_channels.return_value = ( + [ + NetworkSlackChannel(id="C123", name="general"), + NetworkSlackChannel(id="C456", name="announcements"), + ], + "cursor-2", + ) + + result = list_public_channels_step(ctx) + + assert isinstance(result, Success) + ctx.slack.list_public_channels.assert_called_once_with( + limit=25, + cursor="cursor-1", + exclude_archived=True, + ) + assert result.metadata == { + "slack_channels": [ + NetworkSlackChannel(id="C123", name="general"), + NetworkSlackChannel(id="C456", name="announcements"), + ], + "slack_channels_next_cursor": "cursor-2", + } + + +def test_list_users_step_returns_users_and_cursor() -> None: + ctx = _build_context() + ctx.data.update({"slack_limit": 10, "slack_cursor": "cursor-a"}) + ctx.slack = MagicMock() + ctx.slack.list_users.return_value = ( + [ + NetworkSlackUser(id="U123", name="alex", real_name="Alex"), + NetworkSlackUser(id="U456", name="sam", real_name="Sam"), + ], + "cursor-b", + ) + + result = list_users_step(ctx) + + assert isinstance(result, Success) + ctx.slack.list_users.assert_called_once_with(limit=10, cursor="cursor-a") + assert result.metadata == { + "slack_users": [ + NetworkSlackUser(id="U123", name="alex", real_name="Alex"), + NetworkSlackUser(id="U456", name="sam", real_name="Sam"), + ], + "slack_users_next_cursor": "cursor-b", + } diff --git a/plugins/titan-plugin-slack/tests/test_workflows.py b/plugins/titan-plugin-slack/tests/test_workflows.py new file mode 100644 index 00000000..939b7ecc --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_workflows.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import yaml + + +def test_discover_slack_workspace_workflow_structure() -> None: + workflow_path = ( + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "discover-slack-workspace.yaml" + ) + + with open(workflow_path, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + assert workflow["name"] == "Discover Slack Workspace" + assert workflow["params"]["slack_limit"] == 20 + assert workflow["params"]["slack_exclude_archived"] is True + + steps = workflow["steps"] + assert [step["id"] for step in steps] == [ + "validate_connection", + "list_public_channels", + "list_users", + ] + + assert steps[0]["plugin"] == "slack" + assert steps[0]["step"] == "validate_connection" + assert steps[1]["step"] == "list_public_channels" + assert steps[2]["step"] == "list_users" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 3e1a2d96..886a67f0 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -77,7 +77,17 @@ def get_client(self) -> SlackClient: def get_steps(self) -> dict: """Return public workflow steps for the plugin.""" - return {} + from .steps import ( + list_public_channels_step, + list_users_step, + validate_connection_step, + ) + + return { + "validate_connection": validate_connection_step, + "list_public_channels": list_public_channels_step, + "list_users": list_users_step, + } @property def workflows_path(self) -> Optional[Path]: diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index 9473f1ed..62d80a46 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -1 +1,13 @@ """Slack workflow steps package.""" + +from .discovery_steps import ( + list_public_channels_step, + list_users_step, + validate_connection_step, +) + +__all__ = [ + "validate_connection_step", + "list_public_channels_step", + "list_users_step", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py new file mode 100644 index 00000000..36f8b7cc --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py @@ -0,0 +1,173 @@ +"""Public Slack workflow steps for validation and read-only discovery.""" + +from titan_cli.engine import Error, Success, WorkflowContext, WorkflowResult + + +def validate_connection_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Validate the configured Slack connection and expose identity metadata. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + None documented. + + Outputs (saved to ctx.data): + slack_auth (dict): Slack auth identity details from `auth_test()`. + slack_team_id (str | None): Team identifier reported by Slack. + slack_team_name (str | None): Team name reported by Slack. + slack_user_id (str | None): User identifier reported by Slack. + + Returns: + Success: If the Slack connection validates successfully. + Error: If the Slack client is not available or the auth request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Validate Slack Connection") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + with ctx.textual.loading("Validating Slack connection..."): + auth = ctx.slack.auth_test() + + ctx.textual.success_text( + f"Connected to Slack team {auth.get('team') or 'Unknown'} as {auth.get('user_id') or 'Unknown'}" + ) + ctx.textual.end_step("success") + return Success( + "Slack connection validated", + metadata={ + "slack_auth": auth, + "slack_team_id": auth.get("team_id"), + "slack_team_name": auth.get("team"), + "slack_user_id": auth.get("user_id"), + }, + ) + + +def list_public_channels_step(ctx: WorkflowContext) -> WorkflowResult: + """ + List public Slack channels visible to the current token. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_limit (int, optional): Maximum number of channels to request. Defaults to 100. + slack_cursor (str, optional): Pagination cursor for the next page. + slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True. + + Outputs (saved to ctx.data): + slack_channels (list[NetworkSlackChannel]): Public channels returned by Slack. + slack_channels_next_cursor (str | None): Pagination cursor for a later request. + + Returns: + Success: If the channel list is retrieved successfully. + Error: If the Slack client is not available or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("List Slack Public Channels") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + limit = ctx.get("slack_limit", 100) + cursor = ctx.get("slack_cursor") + exclude_archived = ctx.get("slack_exclude_archived", True) + + with ctx.textual.loading("Loading Slack public channels..."): + channels, next_cursor = ctx.slack.list_public_channels( + limit=limit, + cursor=cursor, + exclude_archived=exclude_archived, + ) + + if not channels: + ctx.textual.dim_text("No public Slack channels were returned.") + else: + ctx.textual.success_text(f"Found {len(channels)} public Slack channels") + for channel in channels[:10]: + ctx.textual.text(f"- #{channel.name} ({channel.id})") + if len(channels) > 10: + ctx.textual.dim_text(f"... and {len(channels) - 10} more") + + ctx.textual.end_step("success") + return Success( + f"Retrieved {len(channels)} public Slack channels", + metadata={ + "slack_channels": channels, + "slack_channels_next_cursor": next_cursor, + }, + ) + + +def list_users_step(ctx: WorkflowContext) -> WorkflowResult: + """ + List Slack users visible to the current token. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_limit (int, optional): Maximum number of users to request. Defaults to 100. + slack_cursor (str, optional): Pagination cursor for the next page. + + Outputs (saved to ctx.data): + slack_users (list[NetworkSlackUser]): Users returned by Slack. + slack_users_next_cursor (str | None): Pagination cursor for a later request. + + Returns: + Success: If the user list is retrieved successfully. + Error: If the Slack client is not available or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("List Slack Users") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + limit = ctx.get("slack_limit", 100) + cursor = ctx.get("slack_cursor") + + with ctx.textual.loading("Loading Slack users..."): + users, next_cursor = ctx.slack.list_users(limit=limit, cursor=cursor) + + if not users: + ctx.textual.dim_text("No Slack users were returned.") + else: + ctx.textual.success_text(f"Found {len(users)} Slack users") + for user in users[:10]: + label = user.real_name or user.name or user.id + ctx.textual.text(f"- {label} ({user.id})") + if len(users) > 10: + ctx.textual.dim_text(f"... and {len(users) - 10} more") + + ctx.textual.end_step("success") + return Success( + f"Retrieved {len(users)} Slack users", + metadata={ + "slack_users": users, + "slack_users_next_cursor": next_cursor, + }, + ) + + +__all__ = [ + "validate_connection_step", + "list_public_channels_step", + "list_users_step", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml new file mode 100644 index 00000000..d230b73f --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml @@ -0,0 +1,27 @@ +name: "Discover Slack Workspace" +description: "Validate the current Slack connection and inspect accessible users and public channels" + +params: + slack_limit: 20 + slack_exclude_archived: true + +steps: + - id: validate_connection + name: "Validate Slack Connection" + plugin: slack + step: validate_connection + + - id: list_public_channels + name: "List Public Channels" + plugin: slack + step: list_public_channels + params: + slack_limit: "${slack_limit}" + slack_exclude_archived: "${slack_exclude_archived}" + + - id: list_users + name: "List Users" + plugin: slack + step: list_users + params: + slack_limit: "${slack_limit}" diff --git a/tests/engine/test_workflow_context_builder_slack.py b/tests/engine/test_workflow_context_builder_slack.py new file mode 100644 index 00000000..ff945c3c --- /dev/null +++ b/tests/engine/test_workflow_context_builder_slack.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from titan_cli.engine.builder import WorkflowContextBuilder + + +def test_with_slack_loads_client_from_plugin_registry() -> None: + plugin_registry = MagicMock() + slack_plugin = MagicMock() + slack_client = MagicMock() + + plugin_registry.get_plugin.return_value = slack_plugin + slack_plugin.is_available.return_value = True + slack_plugin.get_client.return_value = slack_client + + ctx = WorkflowContextBuilder( + plugin_registry=plugin_registry, + secrets=MagicMock(), + ai_config=None, + ).with_slack().build() + + assert ctx.slack is slack_client + + +def test_with_slack_uses_explicit_client() -> None: + plugin_registry = MagicMock() + slack_client = MagicMock() + + ctx = WorkflowContextBuilder( + plugin_registry=plugin_registry, + secrets=MagicMock(), + ai_config=None, + ).with_slack(slack_client).build() + + assert ctx.slack is slack_client diff --git a/titan_cli/engine/builder.py b/titan_cli/engine/builder.py index 06e66449..e31e00bf 100644 --- a/titan_cli/engine/builder.py +++ b/titan_cli/engine/builder.py @@ -59,6 +59,7 @@ def __init__( self._git = None self._github = None self._jira = None + self._slack = None # Plugin managers (keyed by plugin name) self._plugin_managers: dict = {} @@ -170,6 +171,33 @@ def with_jira(self, jira_client: Optional[Any] = None) -> WorkflowContextBuilder self._jira = None return self + def with_slack(self, slack_client: Optional[Any] = None) -> WorkflowContextBuilder: + """ + Add Slack client to workflow context. + + The Slack client is optional and only used by Slack plugin steps. + Other plugin steps will have ctx.slack = None and should ignore it. + + Args: + slack_client: Optional SlackClient instance (auto-loaded if None). + If plugin is not available or fails to load, sets ctx.slack = None. + + Returns: + Self for method chaining + """ + if slack_client: + self._slack = slack_client + else: + slack_plugin = self._plugin_registry.get_plugin("slack") + if slack_plugin and slack_plugin.is_available(): + try: + self._slack = slack_plugin.get_client() + except Exception: + self._slack = None + else: + self._slack = None + return self + def build(self) -> WorkflowContext: """Build the WorkflowContext.""" @@ -181,4 +209,5 @@ def build(self) -> WorkflowContext: github=self._github, github_managers=self._plugin_managers.get("github"), jira=self._jira, + slack=self._slack, ) diff --git a/titan_cli/engine/context.py b/titan_cli/engine/context.py index 13d1e0ea..d726a0fd 100644 --- a/titan_cli/engine/context.py +++ b/titan_cli/engine/context.py @@ -41,6 +41,7 @@ class WorkflowContext: github: Optional[Any] = None github_managers: Optional[Any] = None jira: Optional[Any] = None + slack: Optional[Any] = None # Workflow metadata (set by executor) workflow_name: Optional[str] = None From 4287c1dc8d1110a1e61b8a85c38f8cfa685fb0ad Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 11:33:03 +0200 Subject: [PATCH 11/23] feat: Enable Slack plugin and update integration documentation --- docs/plugins/slack/built-in-workflows.md | 6 + docs/plugins/slack/client-api.md | 10 +- docs/plugins/slack/overview.md | 10 +- docs/plugins/slack/workflow-steps.md | 135 ----------------------- 4 files changed, 21 insertions(+), 140 deletions(-) diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index c1ef15bb..e214c909 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -19,3 +19,9 @@ Validate the current Slack connection, list public channels, and list visible us - verify that Slack OAuth configuration is working end to end - inspect what the current personal token can read before building richer workflows - confirm the first public Slack step surface behaves coherently inside Titan + +### Scope constraints + +- the workflow stays read-only +- it does not read channel history yet +- it assumes one active personal Slack connection per user diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md index b395c386..773ee129 100644 --- a/docs/plugins/slack/client-api.md +++ b/docs/plugins/slack/client-api.md @@ -1,6 +1,6 @@ # Slack Client API -The Slack plugin adds read-oriented Slack operations to Titan through `SlackClient`. +The Slack plugin adds read-oriented Slack operations to Titan through `SlackClient`. This page documents the plugin from a functional point of view and shows how each capability is called and which parameters it needs. ## Requirements @@ -9,6 +9,8 @@ To use the Slack client in Titan code: - enable the `slack` plugin - complete Slack OAuth configuration so a personal token is available +--- + ## Accessing the client ```python @@ -16,6 +18,8 @@ slack_plugin = config.registry.get_plugin("slack") client = slack_plugin.get_client() ``` +--- + ## Connection validation ### `auth_test()` @@ -32,6 +36,8 @@ client.auth_test() - No parameters. +--- + ## Discovery operations ### `list_users(limit=100, cursor=None)` @@ -66,6 +72,8 @@ Read message history from a Slack public channel. - `latest`: Optional latest timestamp bound. - `inclusive`: Optional boundary inclusion flag. +--- + ## Usage constraints - The current public workflow surface only exposes validation and discovery steps. diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index f24328b3..1296468c 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -1,10 +1,10 @@ # Slack Plugin -The Slack plugin provides Titan's Slack integration for personal user authentication, workspace validation, and read-only discovery of users and public channels. It exposes: +The Slack plugin provides Titan's Slack integration for personal user authentication, workspace validation, and read-only discovery. It exposes: - a high-level `SlackClient` for direct use from Titan code - reusable workflow `steps` for connection validation and discovery -- one built-in discovery workflow for validating and inspecting the current Slack workspace surface +- one built-in workflow for validating and inspecting the current Slack workspace surface ## Requirements @@ -13,7 +13,7 @@ To use the Slack plugin in a project: - Enable the `slack` plugin in `.titan/config.toml` - Configure Slack through Titan's Slack-specific configuration screen - Complete the BYO Slack App + PKCE connection flow -- Store the resulting personal Slack token in Titan secrets/keyring +- Store the resulting personal Slack token in Titan secrets Example project configuration: @@ -22,7 +22,7 @@ Example project configuration: enabled = true ``` -Slack stores the personal token in secrets, not in the config file. +Slack stores the personal token in Titan secrets/keyring, not in the config file. ## Public surfaces @@ -39,6 +39,8 @@ slack_plugin = config.registry.get_plugin("slack") client = slack_plugin.get_client() ``` +The client returns direct values and raises plugin-level exceptions when Slack operations fail. + ## Public workflow steps The Slack plugin currently exposes public reusable steps for: diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index 9ad38b14..5bb103e5 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -23,138 +23,3 @@ Use these steps to validate the current Slack connection and inspect the accessi - `validate_connection`: validate the configured Slack token and expose identity metadata - `list_public_channels`: list public channels visible to the current token - `list_users`: list users visible to the current token - -## Detailed Step Contracts - -The summaries above show what each slack step is for. The sections below show the documented contract for each public step: what it expects from `ctx.data`, what it saves back, and what result types it may return. - -How to read these contracts: - -- `Inputs (from ctx.data)` = values the step expects before it runs. -- `Outputs (saved to ctx.data)` = metadata keys saved for later steps when the step returns `Success` or `Skip`. -- `Returns` = the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate payload. - -### Validation and Discovery - -??? info "`validate_connection`" - Validate the configured Slack connection and expose identity metadata. - - **Workflow usage** - - ```yaml - - plugin: slack - step: validate_connection - ``` - - **Used by built-in workflows:** `discover-slack-workspace` - - **Available to later steps:** `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` - - **Requires** - - | Name | Type | Description | - |------|------|-------------| - | `ctx.slack` | - | An initialized SlackClient. | - - **Inputs (from ctx.data)** - - None documented. - - **Outputs (saved to ctx.data)** - - | Name | Type | Description | - |------|------|-------------| - | `slack_auth` | dict | Slack auth identity details from `auth_test()`. | - | `slack_team_id` | str \| None | Team identifier reported by Slack. | - | `slack_team_name` | str \| None | Team name reported by Slack. | - | `slack_user_id` | str \| None | User identifier reported by Slack. | - - **Returns** - - | Result | Saved for later steps | Description | - |--------|-----------------------|-------------| - | `Success` | `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` | If the Slack connection validates successfully. | - | `Error` | - | If the Slack client is not available or the auth request fails. | - -??? info "`list_public_channels`" - List public Slack channels visible to the current token. - - **Workflow usage** - - ```yaml - - plugin: slack - step: list_public_channels - ``` - - **Used by built-in workflows:** `discover-slack-workspace` - - **Available to later steps:** `slack_channels`, `slack_channels_next_cursor` - - **Requires** - - | Name | Type | Description | - |------|------|-------------| - | `ctx.slack` | - | An initialized SlackClient. | - - **Inputs (from ctx.data)** - - | Name | Type | Description | - |------|------|-------------| - | `slack_limit` | int, optional | Maximum number of channels to request. Defaults to 100. | - | `slack_cursor` | str, optional | Pagination cursor for the next page. | - | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | - - **Outputs (saved to ctx.data)** - - | Name | Type | Description | - |------|------|-------------| - | `slack_channels` | list[NetworkSlackChannel] | Public channels returned by Slack. | - | `slack_channels_next_cursor` | str \| None | Pagination cursor for a later request. | - - **Returns** - - | Result | Saved for later steps | Description | - |--------|-----------------------|-------------| - | `Success` | `slack_channels`, `slack_channels_next_cursor` | If the channel list is retrieved successfully. | - | `Error` | - | If the Slack client is not available or the Slack request fails. | - -??? info "`list_users`" - List Slack users visible to the current token. - - **Workflow usage** - - ```yaml - - plugin: slack - step: list_users - ``` - - **Used by built-in workflows:** `discover-slack-workspace` - - **Available to later steps:** `slack_users`, `slack_users_next_cursor` - - **Requires** - - | Name | Type | Description | - |------|------|-------------| - | `ctx.slack` | - | An initialized SlackClient. | - - **Inputs (from ctx.data)** - - | Name | Type | Description | - |------|------|-------------| - | `slack_limit` | int, optional | Maximum number of users to request. Defaults to 100. | - | `slack_cursor` | str, optional | Pagination cursor for the next page. | - - **Outputs (saved to ctx.data)** - - | Name | Type | Description | - |------|------|-------------| - | `slack_users` | list[NetworkSlackUser] | Users returned by Slack. | - | `slack_users_next_cursor` | str \| None | Pagination cursor for a later request. | - - **Returns** - - | Result | Saved for later steps | Description | - |--------|-----------------------|-------------| - | `Success` | `slack_users`, `slack_users_next_cursor` | If the user list is retrieved successfully. | - | `Error` | - | If the Slack client is not available or the Slack request fails. | From 3e6e8906313985019e3d4037103200e81c2e83ff Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 12:38:03 +0200 Subject: [PATCH 12/23] feat: Add Slack target selection steps for users and channels --- .../_generated/slack-step-inventory.json | 69 +++++ docs/plugins/_meta/slack-step-groups.json | 7 + .../plugins/generated/slack-step-reference.md | 100 +++++++ docs/plugins/slack/overview.md | 1 + docs/plugins/slack/workflow-steps.md | 10 + .../tests/clients/test_slack_client.py | 274 ++++++++++-------- .../test_target_resolution_operations.py | 59 ++++ .../tests/services/test_auth_service.py | 63 ++++ .../services/test_conversation_service.py | 58 ++++ .../tests/services/test_directory_service.py | 145 +++++++++ .../titan-plugin-slack/tests/test_plugin.py | 2 + .../titan-plugin-slack/tests/test_steps.py | 69 +++-- .../tests/test_target_steps.py | 86 ++++++ .../titan_plugin_slack/clients/sdk.py | 22 ++ .../clients/services/__init__.py | 11 + .../clients/services/auth_service.py | 58 ++++ .../clients/services/conversation_service.py | 93 ++++++ .../clients/services/directory_service.py | 237 +++++++++++++++ .../clients/slack_client.py | 223 ++++++-------- .../titan_plugin_slack/models.py | 55 ++++ .../titan_plugin_slack/operations/__init__.py | 17 ++ .../target_resolution_operations.py | 96 ++++++ .../titan_plugin_slack/plugin.py | 4 + .../screens/slack_config_screen.py | 30 +- .../titan_plugin_slack/steps/__init__.py | 3 + .../steps/discovery_steps.py | 131 +++++---- .../titan_plugin_slack/steps/target_steps.py | 222 ++++++++++++++ 27 files changed, 1790 insertions(+), 355 deletions(-) create mode 100644 plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py create mode 100644 plugins/titan-plugin-slack/tests/services/test_auth_service.py create mode 100644 plugins/titan-plugin-slack/tests/services/test_conversation_service.py create mode 100644 plugins/titan-plugin-slack/tests/services/test_directory_service.py create mode 100644 plugins/titan-plugin-slack/tests/test_target_steps.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/sdk.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/auth_service.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 5dce4222..949b840d 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -17,6 +17,19 @@ "summary": "List users visible to the current Slack token." } ] + }, + { + "name": "Selection and Target Resolution", + "steps": [ + { + "name": "select_user_target", + "summary": "Filter visible Slack users by query and select one canonical user target." + }, + { + "name": "select_channel_target", + "summary": "Filter visible Slack channels by query and select one canonical channel target." + } + ] } ], "steps": [ @@ -100,6 +113,62 @@ "used_by_workflows": [ "discover-slack-workspace" ] + }, + { + "name": "select_user_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_user_target_step", + "summary": "Filter visible Slack users by query and select one canonical user target.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_users (list[UISlackUser]): Users visible to the current Slack token.", + " slack_target_query (str, optional): Pre-filled query used to filter Slack users." + ], + "Outputs (saved to ctx.data)": [ + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`user`).", + " slack_target_id (str): Slack user ID.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection." + ], + "Returns": [ + " Success: If the user target is selected successfully.", + " Error: If Slack is unavailable, no users are available, the query is invalid, or no match is selected." + ] + }, + "used_by_workflows": [] + }, + { + "name": "select_channel_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_channel_target_step", + "summary": "Filter visible Slack channels by query and select one canonical channel target.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_channels (list[UISlackChannel]): Public channels visible to the current Slack token.", + " slack_target_query (str, optional): Pre-filled query used to filter Slack channels." + ], + "Outputs (saved to ctx.data)": [ + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`channel`).", + " slack_target_id (str): Slack channel ID.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection." + ], + "Returns": [ + " Success: If the channel target is selected successfully.", + " Error: If Slack is unavailable, no channels are available, the query is invalid, or no match is selected." + ] + }, + "used_by_workflows": [] } ] } diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json index af33ac73..d2dcfff5 100644 --- a/docs/plugins/_meta/slack-step-groups.json +++ b/docs/plugins/_meta/slack-step-groups.json @@ -8,6 +8,13 @@ {"name": "list_public_channels", "summary": "List public channels visible to the current Slack token."}, {"name": "list_users", "summary": "List users visible to the current Slack token."} ] + }, + { + "name": "Selection and Target Resolution", + "steps": [ + {"name": "select_user_target", "summary": "Filter visible Slack users by query and select one canonical user target."}, + {"name": "select_channel_target", "summary": "Filter visible Slack channels by query and select one canonical channel target."} + ] } ] } diff --git a/docs/plugins/generated/slack-step-reference.md b/docs/plugins/generated/slack-step-reference.md index 068cff1f..9e382e52 100644 --- a/docs/plugins/generated/slack-step-reference.md +++ b/docs/plugins/generated/slack-step-reference.md @@ -143,3 +143,103 @@ List Slack users visible to the current token. |--------|-----------------------|-------------| | `Success` | `slack_users`, `slack_users_next_cursor` | If the user list is retrieved successfully. | | `Error` | - | If the Slack client is not available or the Slack request fails. | + +## Selection and Target Resolution + +### `select_user_target` + +Filter visible Slack users by query and select one canonical user target. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: select_user_target +``` + +**Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_users` | list[UISlackUser] | Users visible to the current Slack token. | +| `slack_target_query` | str, optional | Pre-filled query used to filter Slack users. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Canonical selected Slack target. | +| `slack_target_type` | str | Selected target type (`user`). | +| `slack_target_id` | str | Slack user ID. | +| `slack_target_name` | str | User-facing target name. | +| `slack_target_query` | str | Query used to resolve the selection. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the user target is selected successfully. | +| `Error` | - | If Slack is unavailable, no users are available, the query is invalid, or no match is selected. | + +### `select_channel_target` + +Filter visible Slack channels by query and select one canonical channel target. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: select_channel_target +``` + +**Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_channels` | list[UISlackChannel] | Public channels visible to the current Slack token. | +| `slack_target_query` | str, optional | Pre-filled query used to filter Slack channels. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Canonical selected Slack target. | +| `slack_target_type` | str | Selected target type (`channel`). | +| `slack_target_id` | str | Slack channel ID. | +| `slack_target_name` | str | User-facing target name. | +| `slack_target_query` | str | Query used to resolve the selection. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | +| `Error` | - | If Slack is unavailable, no channels are available, the query is invalid, or no match is selected. | diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 1296468c..a8335020 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -48,5 +48,6 @@ The Slack plugin currently exposes public reusable steps for: - validating the current Slack connection - listing public channels visible to the current token - listing users visible to the current token +- selecting a reusable Slack target from users or channels for later workflows The grouped reference lives in [Workflow Steps](./workflow-steps.md). diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index 5bb103e5..20b1681e 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -7,6 +7,7 @@ For full contract details for every public step, including documented inputs, ou ## Functional groups - [Validation and Discovery](#validation-and-discovery) +- [Selection and Target Resolution](#selection-and-target-resolution) ## Summary @@ -15,6 +16,8 @@ For full contract details for every public step, including documented inputs, ou | `validate_connection` | Validation and Discovery | `discover-slack-workspace` | | `list_public_channels` | Validation and Discovery | `discover-slack-workspace` | | `list_users` | Validation and Discovery | `discover-slack-workspace` | +| `select_user_target` | Selection and Target Resolution | - | +| `select_channel_target` | Selection and Target Resolution | - | ## Validation and Discovery @@ -23,3 +26,10 @@ Use these steps to validate the current Slack connection and inspect the accessi - `validate_connection`: validate the configured Slack token and expose identity metadata - `list_public_channels`: list public channels visible to the current token - `list_users`: list users visible to the current token + +## Selection and Target Resolution + +Use these steps to resolve a reusable Slack target object for later workflows. + +- `select_user_target`: filter visible Slack users by query and select one canonical user target +- `select_channel_target`: filter visible Slack channels by query and select one canonical channel target diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py index a052790d..63c3807d 100644 --- a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -2,9 +2,11 @@ import pytest +from titan_cli.core.result import ClientError, ClientSuccess from titan_plugin_slack.clients import slack_client as slack_client_module from titan_plugin_slack.clients.slack_client import SlackClient -from titan_plugin_slack.exceptions import SlackAPIError, SlackClientError +from titan_plugin_slack.exceptions import SlackClientError +from titan_plugin_slack.models import UISlackAuth def test_slack_client_requires_user_token() -> None: @@ -24,80 +26,71 @@ def test_slack_client_stores_user_token() -> None: def test_slack_client_auth_test_returns_identity_fields() -> None: client = SlackClient(user_token="xoxp-test-token") client.web_client = MagicMock() - client.web_client.auth_test.return_value = { - "ok": True, - "user_id": "U123", - "team_id": "T123", - "team": "Acme", - "url": "https://acme.slack.com", - "bot_id": None, - } + client.auth_service = MagicMock() + client.auth_service.auth_test.return_value = ClientSuccess( + data=UISlackAuth( + user_id="U123", + team_id="T123", + team="Acme", + url="https://acme.slack.com", + bot_id=None, + ) + ) result = client.auth_test() - assert result == { - "user_id": "U123", - "team_id": "T123", - "team": "Acme", - "url": "https://acme.slack.com", - "bot_id": None, - } - - -def test_slack_client_auth_test_raises_api_error_for_invalid_token(monkeypatch) -> None: - class FakeSlackApiError(Exception): - def __init__(self, message: str, response=None): - super().__init__(message) - self.response = response + assert isinstance(result, ClientSuccess) + assert result.data.user_id == "U123" + assert result.data.team_id == "T123" - monkeypatch.setattr(slack_client_module, "SlackApiError", FakeSlackApiError) +def test_slack_client_auth_test_returns_client_error_for_invalid_token() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.auth_test.side_effect = FakeSlackApiError( - "invalid auth", - response={"error": "invalid_auth"}, + client.auth_service = MagicMock() + client.auth_service.auth_test.return_value = ClientError( + error_message="Slack auth failed: invalid_auth", + error_code="AUTH_ERROR", ) - with pytest.raises(SlackAPIError, match="Slack auth failed: invalid_auth"): - client.auth_test() + result = client.auth_test() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack auth failed: invalid_auth" -def test_slack_client_auth_test_raises_client_error_for_transport_failure() -> None: +def test_slack_client_auth_test_returns_client_error_for_transport_failure() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.auth_test.side_effect = RuntimeError("network down") + client.auth_service = MagicMock() + client.auth_service.auth_test.return_value = ClientError( + error_message="Slack auth request failed: network down", + error_code="AUTH_REQUEST_ERROR", + ) + + result = client.auth_test() - with pytest.raises(SlackClientError, match="Slack auth request failed: network down"): - client.auth_test() + assert isinstance(result, ClientError) + assert result.error_message == "Slack auth request failed: network down" def test_list_users_maps_members_and_cursor() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.users_list.return_value = { - "ok": True, - "members": [ - { - "id": "U123", - "name": "alex", - "real_name": "Alex", - "is_bot": False, - "deleted": False, - }, - { - "id": "U456", - "name": "bot-user", - "profile": {"real_name": "Bot User"}, - "is_bot": True, - "deleted": True, - }, - ], - "response_metadata": {"next_cursor": "cursor-123"}, - } - - users, next_cursor = client.list_users(limit=50) + client.directory_service = MagicMock() + client.directory_service.list_users.return_value = ClientSuccess( + data=( + [ + slack_client_module.UISlackUser(id="U123", name="alex", real_name="Alex"), + slack_client_module.UISlackUser( + id="U456", name="bot-user", real_name="Bot User", is_bot=True, is_active=False + ), + ], + "cursor-123", + ) + ) + + result = client.list_users(limit=50) + assert isinstance(result, ClientSuccess) + users, next_cursor = result.data assert next_cursor == "cursor-123" assert len(users) == 2 assert users[0].id == "U123" @@ -108,29 +101,37 @@ def test_list_users_maps_members_and_cursor() -> None: assert users[1].is_active is False -def test_list_users_raises_api_error() -> None: +def test_list_users_returns_client_error() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.users_list.return_value = {"ok": False, "error": "missing_scope"} + client.directory_service = MagicMock() + client.directory_service.list_users.return_value = ClientError( + error_message="Slack list_users failed: missing_scope", + error_code="LIST_USERS_ERROR", + ) - with pytest.raises(SlackAPIError, match="Slack list_users failed: missing_scope"): - client.list_users() + result = client.list_users() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack list_users failed: missing_scope" def test_list_public_channels_maps_channels_and_cursor() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.conversations_list.return_value = { - "ok": True, - "channels": [ - {"id": "C123", "name": "general", "is_channel": True, "is_private": False}, - {"id": "C456", "name": "announcements", "is_channel": True, "is_private": False}, - ], - "response_metadata": {"next_cursor": "cursor-456"}, - } + client.directory_service = MagicMock() + client.directory_service.list_public_channels.return_value = ClientSuccess( + data=( + [ + slack_client_module.UISlackChannel(id="C123", name="general"), + slack_client_module.UISlackChannel(id="C456", name="announcements"), + ], + "cursor-456", + ) + ) - channels, next_cursor = client.list_public_channels(limit=25) + result = client.list_public_channels(limit=25) + assert isinstance(result, ClientSuccess) + channels, next_cursor = result.data assert next_cursor == "cursor-456" assert len(channels) == 2 assert channels[0].id == "C123" @@ -138,47 +139,49 @@ def test_list_public_channels_maps_channels_and_cursor() -> None: assert channels[1].is_private is False -def test_list_public_channels_raises_api_error() -> None: +def test_list_public_channels_returns_client_error() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.conversations_list.return_value = { - "ok": False, - "error": "missing_scope", - } + client.directory_service = MagicMock() + client.directory_service.list_public_channels.return_value = ClientError( + error_message="Slack list_public_channels failed: missing_scope", + error_code="LIST_PUBLIC_CHANNELS_ERROR", + ) - with pytest.raises( - SlackAPIError, match="Slack list_public_channels failed: missing_scope" - ): - client.list_public_channels() + result = client.list_public_channels() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack list_public_channels failed: missing_scope" def test_read_channel_maps_messages_and_pagination() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.conversations_history.return_value = { - "ok": True, - "messages": [ - { - "ts": "123.456", - "text": "Hello", - "user": "U123", - "thread_ts": "123.456", - "reply_count": 2, - "subtype": None, - }, - { - "ts": "123.789", - "text": "World", - "user": "U456", - "reply_count": 0, - }, - ], - "has_more": True, - "response_metadata": {"next_cursor": "cursor-789"}, - } - - messages, next_cursor, has_more = client.read_channel("C123", limit=10) + client.conversation_service = MagicMock() + client.conversation_service.read_conversation.return_value = ClientSuccess( + data=( + [ + slack_client_module.UISlackMessage( + ts="123.456", + text="Hello", + user="U123", + thread_ts="123.456", + reply_count=2, + ), + slack_client_module.UISlackMessage( + ts="123.789", + text="World", + user="U456", + reply_count=0, + ), + ], + "cursor-789", + True, + ) + ) + result = client.read_channel("C123", limit=10) + + assert isinstance(result, ClientSuccess) + messages, next_cursor, has_more = result.data assert next_cursor == "cursor-789" assert has_more is True assert len(messages) == 2 @@ -188,13 +191,54 @@ def test_read_channel_maps_messages_and_pagination() -> None: assert messages[1].text == "World" -def test_read_channel_raises_api_error() -> None: +def test_read_channel_returns_client_error() -> None: client = SlackClient(user_token="xoxp-test-token") - client.web_client = MagicMock() - client.web_client.conversations_history.return_value = { - "ok": False, - "error": "channel_not_found", - } + client.conversation_service = MagicMock() + client.conversation_service.read_conversation.return_value = ClientError( + error_message="Slack read_channel failed: channel_not_found", + error_code="READ_CHANNEL_ERROR", + ) + + result = client.read_channel("C404") + + assert isinstance(result, ClientError) + assert result.error_message == "Slack read_channel failed: channel_not_found" - with pytest.raises(SlackAPIError, match="Slack read_channel failed: channel_not_found"): - client.read_channel("C404") + +def test_search_users_delegates_to_directory_service() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.directory_service = MagicMock() + client.directory_service.search_users.return_value = ClientSuccess(data=[]) + + result = client.search_users("alex", max_matches=5, page_size=50, max_pages=3) + + assert isinstance(result, ClientSuccess) + client.directory_service.search_users.assert_called_once_with( + "alex", + max_matches=5, + page_size=50, + max_pages=3, + ) + + +def test_search_public_channels_delegates_to_directory_service() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.directory_service = MagicMock() + client.directory_service.search_public_channels.return_value = ClientSuccess(data=[]) + + result = client.search_public_channels( + "eng", + max_matches=5, + page_size=50, + max_pages=3, + exclude_archived=False, + ) + + assert isinstance(result, ClientSuccess) + client.directory_service.search_public_channels.assert_called_once_with( + "eng", + max_matches=5, + page_size=50, + max_pages=3, + exclude_archived=False, + ) diff --git a/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py new file mode 100644 index 00000000..66808cbf --- /dev/null +++ b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py @@ -0,0 +1,59 @@ +from titan_plugin_slack.models import UISlackChannel, UISlackUser +from titan_plugin_slack.operations.target_resolution_operations import ( + build_channel_target, + build_user_target, + filter_channels_for_query, + filter_users_for_query, + normalize_search_query, +) + + +def test_normalize_search_query_collapses_case_and_spaces() -> None: + assert normalize_search_query(" Alex Smith ") == "alex smith" + + +def test_filter_users_for_query_prioritizes_exact_then_prefix() -> None: + users = [ + UISlackUser(id="U1", name="alex", real_name="Alex"), + UISlackUser(id="U2", name="alex-team", real_name="Alex Team"), + UISlackUser(id="U3", name="sam", real_name="Samantha Alex"), + ] + + matches = filter_users_for_query(users, "alex") + + assert [user.id for user in matches] == ["U1", "U2", "U3"] + + +def test_filter_channels_for_query_strips_hash_and_limits_results() -> None: + channels = [ + UISlackChannel(id="C1", name="engineering"), + UISlackChannel(id="C2", name="eng-backend"), + UISlackChannel(id="C3", name="random"), + ] + + matches = filter_channels_for_query(channels, "#eng", limit=2) + + assert [channel.id for channel in matches] == ["C2", "C1"] + + +def test_build_user_target_uses_real_name_when_available() -> None: + user = UISlackUser(id="U1", name="alex", real_name="Alex Smith") + + target = build_user_target(user, team_id="T1", connection_id="default") + + assert target.target_type == "user" + assert target.target_id == "U1" + assert target.target_name == "Alex Smith" + assert target.team_id == "T1" + assert target.connection_id == "default" + + +def test_build_channel_target_preserves_channel_name() -> None: + channel = UISlackChannel(id="C1", name="engineering") + + target = build_channel_target(channel, team_id="T1") + + assert target.target_type == "channel" + assert target.target_id == "C1" + assert target.target_name == "engineering" + assert target.team_id == "T1" diff --git a/plugins/titan-plugin-slack/tests/services/test_auth_service.py b/plugins/titan-plugin-slack/tests/services/test_auth_service.py new file mode 100644 index 00000000..4dada4b6 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/services/test_auth_service.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_plugin_slack.clients import sdk as slack_sdk_module +from titan_plugin_slack.clients.services.auth_service import AuthService + + +def test_auth_service_returns_identity_fields() -> None: + web_client = MagicMock() + web_client.auth_test.return_value = { + "ok": True, + "user_id": "U123", + "team_id": "T123", + "team": "Acme", + "url": "https://acme.slack.com", + "bot_id": None, + } + + service = AuthService(web_client) + + result = service.auth_test() + + assert isinstance(result, ClientSuccess) + assert result.data.user_id == "U123" + assert result.data.team_id == "T123" + + +def test_auth_service_raises_api_error_for_invalid_token(monkeypatch) -> None: + class FakeSlackApiError(Exception): + def __init__(self, message: str, response=None): + super().__init__(message) + self.response = response + + monkeypatch.setattr(slack_sdk_module, "SlackApiError", FakeSlackApiError) + monkeypatch.setattr( + "titan_plugin_slack.clients.services.auth_service.SlackApiError", + FakeSlackApiError, + ) + + web_client = MagicMock() + web_client.auth_test.side_effect = FakeSlackApiError( + "invalid auth", + response={"error": "invalid_auth"}, + ) + + service = AuthService(web_client) + + result = service.auth_test() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack auth failed: invalid_auth" + + +def test_auth_service_raises_client_error_for_transport_failure() -> None: + web_client = MagicMock() + web_client.auth_test.side_effect = RuntimeError("network down") + + service = AuthService(web_client) + + result = service.auth_test() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack auth request failed: network down" diff --git a/plugins/titan-plugin-slack/tests/services/test_conversation_service.py b/plugins/titan-plugin-slack/tests/services/test_conversation_service.py new file mode 100644 index 00000000..17683ad9 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/services/test_conversation_service.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_plugin_slack.clients.services.conversation_service import ConversationService + + +def test_read_conversation_maps_messages_and_pagination() -> None: + web_client = MagicMock() + web_client.conversations_history.return_value = { + "ok": True, + "messages": [ + { + "ts": "123.456", + "text": "Hello", + "user": "U123", + "thread_ts": "123.456", + "reply_count": 2, + "subtype": None, + }, + { + "ts": "123.789", + "text": "World", + "user": "U456", + "reply_count": 0, + }, + ], + "has_more": True, + "response_metadata": {"next_cursor": "cursor-789"}, + } + + service = ConversationService(web_client) + + result = service.read_conversation("C123", limit=10) + + assert isinstance(result, ClientSuccess) + messages, next_cursor, has_more = result.data + assert next_cursor == "cursor-789" + assert has_more is True + assert len(messages) == 2 + assert messages[0].ts == "123.456" + assert messages[0].thread_ts == "123.456" + assert messages[0].reply_count == 2 + assert messages[1].text == "World" + + +def test_read_conversation_raises_api_error() -> None: + web_client = MagicMock() + web_client.conversations_history.return_value = { + "ok": False, + "error": "channel_not_found", + } + + service = ConversationService(web_client) + + result = service.read_conversation("C404") + + assert isinstance(result, ClientError) + assert result.error_message == "Slack read_channel failed: channel_not_found" diff --git a/plugins/titan-plugin-slack/tests/services/test_directory_service.py b/plugins/titan-plugin-slack/tests/services/test_directory_service.py new file mode 100644 index 00000000..c4b66b4a --- /dev/null +++ b/plugins/titan-plugin-slack/tests/services/test_directory_service.py @@ -0,0 +1,145 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_plugin_slack.clients.services.directory_service import DirectoryService +from titan_plugin_slack.models import UISlackChannel, UISlackUser + + +def test_list_users_maps_members_and_cursor() -> None: + web_client = MagicMock() + web_client.users_list.return_value = { + "ok": True, + "members": [ + { + "id": "U123", + "name": "alex", + "real_name": "Alex", + "is_bot": False, + "deleted": False, + }, + { + "id": "U456", + "name": "bot-user", + "profile": {"real_name": "Bot User"}, + "is_bot": True, + "deleted": True, + }, + ], + "response_metadata": {"next_cursor": "cursor-123"}, + } + + service = DirectoryService(web_client) + + result = service.list_users(limit=50) + + assert isinstance(result, ClientSuccess) + users, next_cursor = result.data + assert next_cursor == "cursor-123" + assert len(users) == 2 + assert users[0].id == "U123" + assert users[0].real_name == "Alex" + assert users[0].is_active is True + assert users[1].is_bot is True + assert users[1].real_name == "Bot User" + assert users[1].is_active is False + + +def test_list_users_raises_api_error() -> None: + web_client = MagicMock() + web_client.users_list.return_value = {"ok": False, "error": "missing_scope"} + + service = DirectoryService(web_client) + + result = service.list_users() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack list_users failed: missing_scope" + + +def test_list_public_channels_maps_channels_and_cursor() -> None: + web_client = MagicMock() + web_client.conversations_list.return_value = { + "ok": True, + "channels": [ + {"id": "C123", "name": "general", "is_channel": True, "is_private": False}, + {"id": "C456", "name": "announcements", "is_channel": True, "is_private": False}, + ], + "response_metadata": {"next_cursor": "cursor-456"}, + } + + service = DirectoryService(web_client) + + result = service.list_public_channels(limit=25) + + assert isinstance(result, ClientSuccess) + channels, next_cursor = result.data + assert next_cursor == "cursor-456" + assert len(channels) == 2 + assert channels[0].id == "C123" + assert channels[0].name == "general" + assert channels[1].is_private is False + + +def test_list_public_channels_raises_api_error() -> None: + web_client = MagicMock() + web_client.conversations_list.return_value = { + "ok": False, + "error": "missing_scope", + } + + service = DirectoryService(web_client) + + result = service.list_public_channels() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack list_public_channels failed: missing_scope" + + +def test_search_users_scans_multiple_pages_until_match() -> None: + web_client = MagicMock() + service = DirectoryService(web_client) + service.list_users = MagicMock( + side_effect=[ + ClientSuccess( + data=([ + UISlackUser(id="U1", name="sam", real_name="Sam One"), + ], "cursor-1") + ), + ClientSuccess( + data=([ + UISlackUser(id="U2", name="alex", real_name="Alex Smith"), + ], None) + ), + ] + ) + + result = service.search_users("alex", max_matches=10, page_size=100, max_pages=5) + + assert isinstance(result, ClientSuccess) + matches = result.data + assert [user.id for user in matches] == ["U2"] + + +def test_search_public_channels_scans_multiple_pages_until_match() -> None: + web_client = MagicMock() + service = DirectoryService(web_client) + service.list_public_channels = MagicMock( + side_effect=[ + ClientSuccess( + data=([ + UISlackChannel(id="C1", name="general"), + ], "cursor-1") + ), + ClientSuccess( + data=([ + UISlackChannel(id="C2", name="eng-backend"), + ], None) + ), + ] + ) + + result = service.search_public_channels("eng", max_matches=10, page_size=100, max_pages=5) + + assert isinstance(result, ClientSuccess) + matches = result.data + assert [channel.id for channel in matches] == ["C2"] diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index aa5c8cb4..5addc381 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -23,6 +23,8 @@ def test_slack_plugin_exposes_public_steps() -> None: "validate_connection", "list_public_channels", "list_users", + "select_user_target", + "select_channel_target", } diff --git a/plugins/titan-plugin-slack/tests/test_steps.py b/plugins/titan-plugin-slack/tests/test_steps.py index 7c73332e..d8eca2d7 100644 --- a/plugins/titan-plugin-slack/tests/test_steps.py +++ b/plugins/titan-plugin-slack/tests/test_steps.py @@ -1,8 +1,9 @@ from unittest.mock import MagicMock +from titan_cli.core.result import ClientSuccess from titan_cli.engine import Error, Success from titan_cli.engine.context import WorkflowContext -from titan_plugin_slack.models import NetworkSlackChannel, NetworkSlackUser +from titan_plugin_slack.models import UISlackAuth, UISlackChannel, UISlackUser from titan_plugin_slack.steps.discovery_steps import ( list_public_channels_step, list_users_step, @@ -34,25 +35,27 @@ def test_validate_connection_step_returns_error_without_slack_client() -> None: def test_validate_connection_step_returns_auth_metadata() -> None: ctx = _build_context() ctx.slack = MagicMock() - ctx.slack.auth_test.return_value = { - "user_id": "U123", - "team_id": "T123", - "team": "Acme", - "url": "https://acme.slack.com", - "bot_id": None, - } + ctx.slack.auth_test.return_value = ClientSuccess( + data=UISlackAuth( + user_id="U123", + team_id="T123", + team="Acme", + url="https://acme.slack.com", + bot_id=None, + ) + ) result = validate_connection_step(ctx) assert isinstance(result, Success) assert result.metadata == { - "slack_auth": { - "user_id": "U123", - "team_id": "T123", - "team": "Acme", - "url": "https://acme.slack.com", - "bot_id": None, - }, + "slack_auth": UISlackAuth( + user_id="U123", + team_id="T123", + team="Acme", + url="https://acme.slack.com", + bot_id=None, + ), "slack_team_id": "T123", "slack_team_name": "Acme", "slack_user_id": "U123", @@ -63,12 +66,14 @@ def test_list_public_channels_step_returns_channels_and_cursor() -> None: ctx = _build_context() ctx.data.update({"slack_limit": 25, "slack_cursor": "cursor-1"}) ctx.slack = MagicMock() - ctx.slack.list_public_channels.return_value = ( - [ - NetworkSlackChannel(id="C123", name="general"), - NetworkSlackChannel(id="C456", name="announcements"), - ], - "cursor-2", + ctx.slack.list_public_channels.return_value = ClientSuccess( + data=( + [ + UISlackChannel(id="C123", name="general"), + UISlackChannel(id="C456", name="announcements"), + ], + "cursor-2", + ) ) result = list_public_channels_step(ctx) @@ -81,8 +86,8 @@ def test_list_public_channels_step_returns_channels_and_cursor() -> None: ) assert result.metadata == { "slack_channels": [ - NetworkSlackChannel(id="C123", name="general"), - NetworkSlackChannel(id="C456", name="announcements"), + UISlackChannel(id="C123", name="general"), + UISlackChannel(id="C456", name="announcements"), ], "slack_channels_next_cursor": "cursor-2", } @@ -92,12 +97,14 @@ def test_list_users_step_returns_users_and_cursor() -> None: ctx = _build_context() ctx.data.update({"slack_limit": 10, "slack_cursor": "cursor-a"}) ctx.slack = MagicMock() - ctx.slack.list_users.return_value = ( - [ - NetworkSlackUser(id="U123", name="alex", real_name="Alex"), - NetworkSlackUser(id="U456", name="sam", real_name="Sam"), - ], - "cursor-b", + ctx.slack.list_users.return_value = ClientSuccess( + data=( + [ + UISlackUser(id="U123", name="alex", real_name="Alex"), + UISlackUser(id="U456", name="sam", real_name="Sam"), + ], + "cursor-b", + ) ) result = list_users_step(ctx) @@ -106,8 +113,8 @@ def test_list_users_step_returns_users_and_cursor() -> None: ctx.slack.list_users.assert_called_once_with(limit=10, cursor="cursor-a") assert result.metadata == { "slack_users": [ - NetworkSlackUser(id="U123", name="alex", real_name="Alex"), - NetworkSlackUser(id="U456", name="sam", real_name="Sam"), + UISlackUser(id="U123", name="alex", real_name="Alex"), + UISlackUser(id="U456", name="sam", real_name="Sam"), ], "slack_users_next_cursor": "cursor-b", } diff --git a/plugins/titan-plugin-slack/tests/test_target_steps.py b/plugins/titan-plugin-slack/tests/test_target_steps.py new file mode 100644 index 00000000..9c2aa9c4 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_target_steps.py @@ -0,0 +1,86 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientSuccess +from titan_cli.engine import Error, Success +from titan_cli.engine.context import WorkflowContext +from titan_plugin_slack.models import UISlackChannel, UISlackTarget, UISlackUser +from titan_plugin_slack.steps.target_steps import ( + select_channel_target_step, + select_user_target_step, +) + + +def _build_context() -> WorkflowContext: + ctx = WorkflowContext(secrets=MagicMock()) + ctx.textual = MagicMock() + return ctx + + +def test_select_user_target_returns_error_without_source_users() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.textual.ask_text.return_value = "alex" + ctx.slack.search_users.return_value = ClientSuccess(data=[]) + + result = select_user_target_step(ctx) + + assert isinstance(result, Error) + assert result.message == "No Slack users matched that query." + + +def test_select_user_target_returns_target_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_team_id"] = "T1" + ctx.textual.ask_text.return_value = "alex" + user = UISlackUser(id="U1", name="alex", real_name="Alex Smith") + ctx.slack.search_users.return_value = ClientSuccess(data=[user]) + ctx.textual.ask_option.return_value = user + + result = select_user_target_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_target"] == UISlackTarget( + target_type="user", + target_id="U1", + target_name="Alex Smith", + team_id="T1", + connection_id=None, + ) + assert result.metadata["slack_target_type"] == "user" + assert result.metadata["slack_target_id"] == "U1" + assert result.metadata["slack_target_name"] == "Alex Smith" + + +def test_select_channel_target_returns_error_for_short_query() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.textual.ask_text.return_value = "g" + + result = select_channel_target_step(ctx) + + assert isinstance(result, Error) + assert result.message == "Enter at least 2 characters to search Slack channels." + + +def test_select_channel_target_returns_target_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.textual.ask_text.return_value = "eng" + channel = UISlackChannel(id="C2", name="eng-backend") + ctx.slack.search_public_channels.return_value = ClientSuccess(data=[channel]) + ctx.textual.ask_option.return_value = channel + + result = select_channel_target_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_target"] == UISlackTarget( + target_type="channel", + target_id="C2", + target_name="eng-backend", + team_id=None, + connection_id=None, + ) + assert result.metadata["slack_target_type"] == "channel" + assert result.metadata["slack_target_id"] == "C2" + assert result.metadata["slack_target_name"] == "eng-backend" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/sdk.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/sdk.py new file mode 100644 index 00000000..c92c963e --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/sdk.py @@ -0,0 +1,22 @@ +"""Slack SDK compatibility layer used by the Slack client and services.""" + +try: + from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError +except ImportError: # pragma: no cover - exercised implicitly in repo-level tests + class WebClient: # type: ignore[override] + """Small fallback used until the plugin dependency is installed.""" + + def __init__(self, token: str, timeout: int | None = None): + self.token = token + self.timeout = timeout + + class SlackApiError(Exception): + """Fallback Slack API error used when slack-sdk is unavailable.""" + + def __init__(self, message: str, response=None): + super().__init__(message) + self.response = response + + +__all__ = ["WebClient", "SlackApiError"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py new file mode 100644 index 00000000..0862b4b9 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py @@ -0,0 +1,11 @@ +"""Internal services for the Slack client facade.""" + +from .auth_service import AuthService +from .conversation_service import ConversationService +from .directory_service import DirectoryService + +__all__ = [ + "AuthService", + "DirectoryService", + "ConversationService", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/auth_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/auth_service.py new file mode 100644 index 00000000..65cfd452 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/auth_service.py @@ -0,0 +1,58 @@ +"""Internal service for Slack auth operations.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +from ...models import UISlackAuth + + +class AuthService: + """Service for validating Slack authentication.""" + + def __init__(self, web_client): + self.web_client = web_client + + @staticmethod + def _build_api_error(exc: SlackApiError, operation: str) -> ClientError: + error_code = "unknown_error" + response = getattr(exc, "response", None) + if isinstance(response, dict): + error_code = response.get("error", error_code) + elif hasattr(response, "data") and isinstance(response.data, dict): + error_code = response.data.get("error", error_code) + return ClientError( + error_message=f"Slack {operation} failed: {error_code}", + error_code="AUTH_ERROR", + details={"slack_error": error_code}, + ) + + def auth_test(self) -> ClientResult[UISlackAuth]: + """Validate the configured user token with Slack auth.test.""" + try: + response = self.web_client.auth_test() + except SlackApiError as exc: + return self._build_api_error(exc, "auth") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error(exc, "auth") + return ClientError( + error_message=f"Slack auth request failed: {exc}", + error_code="AUTH_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return ClientError( + error_message=f"Slack auth failed: {response.get('error', 'unknown_error')}", + error_code="AUTH_ERROR", + ) + + return ClientSuccess( + data=UISlackAuth( + user_id=response.get("user_id"), + team_id=response.get("team_id"), + team=response.get("team"), + url=response.get("url"), + bot_id=response.get("bot_id"), + ), + message="Slack auth validated", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py new file mode 100644 index 00000000..8991b08b --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py @@ -0,0 +1,93 @@ +"""Internal service for Slack conversation history operations.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +from ...models import NetworkSlackMessage, UISlackMessage + + +class ConversationService: + """Service for Slack conversation and history access.""" + + def __init__(self, web_client): + self.web_client = web_client + + @staticmethod + def _build_api_error(exc: SlackApiError, operation: str) -> ClientError: + error_code = "unknown_error" + response = getattr(exc, "response", None) + if isinstance(response, dict): + error_code = response.get("error", error_code) + elif hasattr(response, "data") and isinstance(response.data, dict): + error_code = response.data.get("error", error_code) + return ClientError( + error_message=f"Slack {operation} failed: {error_code}", + error_code="READ_CHANNEL_ERROR", + details={"slack_error": error_code}, + ) + + @staticmethod + def _map_message(message: dict) -> NetworkSlackMessage: + return NetworkSlackMessage( + ts=message.get("ts", ""), + text=message.get("text", ""), + user=message.get("user"), + thread_ts=message.get("thread_ts"), + reply_count=message.get("reply_count", 0), + subtype=message.get("subtype"), + ) + + @staticmethod + def _to_ui_message(message: NetworkSlackMessage) -> UISlackMessage: + return UISlackMessage( + ts=message.ts, + text=message.text, + user=message.user, + thread_ts=message.thread_ts, + reply_count=message.reply_count, + subtype=message.subtype, + ) + + def read_conversation( + self, + conversation_id: str, + limit: int = 20, + cursor: str | None = None, + oldest: str | None = None, + latest: str | None = None, + inclusive: bool = False, + ) -> ClientResult[tuple[list[UISlackMessage], str | None, bool]]: + """Read message history from a Slack conversation.""" + try: + response = self.web_client.conversations_history( + channel=conversation_id, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + inclusive=inclusive, + ) + except SlackApiError as exc: + return self._build_api_error(exc, "read_channel") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error(exc, "read_channel") + return ClientError( + error_message=f"Slack channel history request failed: {exc}", + error_code="READ_CHANNEL_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return ClientError( + error_message=f"Slack read_channel failed: {response.get('error', 'unknown_error')}", + error_code="READ_CHANNEL_ERROR", + ) + + messages = [self._map_message(message) for message in response.get("messages", [])] + ui_messages = [self._to_ui_message(message) for message in messages] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + has_more = response.get("has_more", False) + return ClientSuccess( + data=(ui_messages, next_cursor, has_more), + message=f"Retrieved {len(ui_messages)} Slack messages", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py new file mode 100644 index 00000000..e3739951 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py @@ -0,0 +1,237 @@ +"""Internal service for Slack directory and discovery operations.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +from ...operations import filter_channels_for_query, filter_users_for_query +from ...models import ( + NetworkSlackChannel, + NetworkSlackUser, + UISlackChannel, + UISlackUser, +) + + +class DirectoryService: + """Service for Slack user and public channel discovery.""" + + def __init__(self, web_client): + self.web_client = web_client + + @staticmethod + def _build_api_error(exc: SlackApiError, operation: str, error_code_name: str) -> ClientError: + error_code = "unknown_error" + response = getattr(exc, "response", None) + if isinstance(response, dict): + error_code = response.get("error", error_code) + elif hasattr(response, "data") and isinstance(response.data, dict): + error_code = response.data.get("error", error_code) + return ClientError( + error_message=f"Slack {operation} failed: {error_code}", + error_code=error_code_name, + details={"slack_error": error_code}, + ) + + @staticmethod + def _map_user(member: dict) -> NetworkSlackUser: + return NetworkSlackUser( + id=member.get("id", ""), + name=member.get("name", ""), + real_name=member.get("real_name") or member.get("profile", {}).get("real_name"), + is_bot=member.get("is_bot", False), + is_active=not member.get("deleted", False), + ) + + @staticmethod + def _map_channel(channel: dict) -> NetworkSlackChannel: + return NetworkSlackChannel( + id=channel.get("id", ""), + name=channel.get("name", ""), + is_channel=channel.get("is_channel", True), + is_private=channel.get("is_private", False), + ) + + @staticmethod + def _to_ui_user(user: NetworkSlackUser) -> UISlackUser: + return UISlackUser( + id=user.id, + name=user.name, + real_name=user.real_name, + is_bot=user.is_bot, + is_active=user.is_active, + ) + + @staticmethod + def _to_ui_channel(channel: NetworkSlackChannel) -> UISlackChannel: + return UISlackChannel( + id=channel.id, + name=channel.name, + is_channel=channel.is_channel, + is_private=channel.is_private, + ) + + def list_users( + self, limit: int = 100, cursor: str | None = None + ) -> ClientResult[tuple[list[UISlackUser], str | None]]: + """List Slack users visible to the current token.""" + try: + response = self.web_client.users_list(limit=limit, cursor=cursor) + except SlackApiError as exc: + return self._build_api_error(exc, "list_users", "LIST_USERS_ERROR") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error(exc, "list_users", "LIST_USERS_ERROR") + return ClientError( + error_message=f"Slack users request failed: {exc}", + error_code="LIST_USERS_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return ClientError( + error_message=f"Slack list_users failed: {response.get('error', 'unknown_error')}", + error_code="LIST_USERS_ERROR", + ) + + members = [self._map_user(member) for member in response.get("members", [])] + ui_users = [self._to_ui_user(member) for member in members] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + return ClientSuccess( + data=(ui_users, next_cursor), + message=f"Retrieved {len(ui_users)} Slack users", + ) + + def list_public_channels( + self, + limit: int = 100, + cursor: str | None = None, + exclude_archived: bool = True, + ) -> ClientResult[tuple[list[UISlackChannel], str | None]]: + """List public Slack channels visible to the current token.""" + try: + response = self.web_client.conversations_list( + limit=limit, + cursor=cursor, + exclude_archived=exclude_archived, + types="public_channel", + ) + except SlackApiError as exc: + return self._build_api_error( + exc, + "list_public_channels", + "LIST_PUBLIC_CHANNELS_ERROR", + ) + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error( + exc, + "list_public_channels", + "LIST_PUBLIC_CHANNELS_ERROR", + ) + return ClientError( + error_message=f"Slack conversations request failed: {exc}", + error_code="LIST_PUBLIC_CHANNELS_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return ClientError( + error_message=( + "Slack list_public_channels failed: " + f"{response.get('error', 'unknown_error')}" + ), + error_code="LIST_PUBLIC_CHANNELS_ERROR", + ) + + channels = [self._map_channel(channel) for channel in response.get("channels", [])] + ui_channels = [self._to_ui_channel(channel) for channel in channels] + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + return ClientSuccess( + data=(ui_channels, next_cursor), + message=f"Retrieved {len(ui_channels)} public Slack channels", + ) + + def search_users( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 10, + ) -> ClientResult[list[UISlackUser]]: + """Search Slack users by paging through visible users and filtering locally.""" + cursor: str | None = None + scanned_pages = 0 + collected: list[UISlackUser] = [] + seen_ids: set[str] = set() + + while scanned_pages < max_pages: + page_result = self.list_users(limit=page_size, cursor=cursor) + match page_result: + case ClientSuccess(data=(users, next_cursor)): + for user in users: + if user.id not in seen_ids: + seen_ids.add(user.id) + collected.append(user) + + matches = filter_users_for_query(collected, query, limit=max_matches) + if len(matches) >= max_matches or not next_cursor: + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack users for query", + ) + + cursor = next_cursor + scanned_pages += 1 + case ClientError() as err: + return err + + matches = filter_users_for_query(collected, query, limit=max_matches) + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack users for query", + ) + + def search_public_channels( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 10, + exclude_archived: bool = True, + ) -> ClientResult[list[UISlackChannel]]: + """Search Slack public channels by paging through visible channels and filtering locally.""" + cursor: str | None = None + scanned_pages = 0 + collected: list[UISlackChannel] = [] + seen_ids: set[str] = set() + + while scanned_pages < max_pages: + page_result = self.list_public_channels( + limit=page_size, + cursor=cursor, + exclude_archived=exclude_archived, + ) + match page_result: + case ClientSuccess(data=(channels, next_cursor)): + for channel in channels: + if channel.id not in seen_ids: + seen_ids.add(channel.id) + collected.append(channel) + + matches = filter_channels_for_query(collected, query, limit=max_matches) + if len(matches) >= max_matches or not next_cursor: + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack channels for query", + ) + + cursor = next_cursor + scanned_pages += 1 + case ClientError() as err: + return err + + matches = filter_channels_for_query(collected, query, limit=max_matches) + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack channels for query", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 5f2dd75c..41490dfb 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -1,29 +1,18 @@ -"""Minimal Slack client baseline for the first plugin phase.""" +"""Slack client facade backed by internal services.""" -try: - from slack_sdk import WebClient - from slack_sdk.errors import SlackApiError -except ImportError: # pragma: no cover - exercised implicitly in repo-level tests - class WebClient: # type: ignore[override] - """Small fallback used until the plugin dependency is installed.""" +from . import sdk as slack_sdk_module +from .services import AuthService, ConversationService, DirectoryService +from titan_cli.core.result import ClientResult - def __init__(self, token: str, timeout: int | None = None): - self.token = token - self.timeout = timeout +from ..exceptions import SlackClientError +from ..models import UISlackAuth, UISlackChannel, UISlackMessage, UISlackUser - class SlackApiError(Exception): - """Fallback Slack API error used when slack-sdk is unavailable.""" - - def __init__(self, message: str, response=None): - super().__init__(message) - self.response = response - -from ..exceptions import SlackAPIError, SlackClientError -from ..models import NetworkSlackChannel, NetworkSlackMessage, NetworkSlackUser +SlackApiError = slack_sdk_module.SlackApiError +WebClient = slack_sdk_module.WebClient class SlackClient: - """Small Slack client wrapper used by the Slack plugin.""" + """Slack client facade used by the Slack plugin.""" def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 30): if not user_token: @@ -32,116 +21,81 @@ def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 3 self.user_token = user_token self.team_id = team_id self.timeout = timeout - self.web_client = WebClient(token=user_token, timeout=timeout) - - def _handle_api_error(self, exc: SlackApiError, operation: str) -> None: - """Convert Slack SDK API errors into plugin-level exceptions.""" - error_code = "unknown_error" - response = getattr(exc, "response", None) - if isinstance(response, dict): - error_code = response.get("error", error_code) - raise SlackAPIError(f"Slack {operation} failed: {error_code}") from exc - - def _map_user(self, member: dict) -> NetworkSlackUser: - """Normalize a Slack user payload into the plugin model.""" - return NetworkSlackUser( - id=member.get("id", ""), - name=member.get("name", ""), - real_name=member.get("real_name") or member.get("profile", {}).get("real_name"), - is_bot=member.get("is_bot", False), - is_active=not member.get("deleted", False), - ) - - def _map_channel(self, channel: dict) -> NetworkSlackChannel: - """Normalize a Slack conversation payload into the plugin model.""" - return NetworkSlackChannel( - id=channel.get("id", ""), - name=channel.get("name", ""), - is_channel=channel.get("is_channel", True), - is_private=channel.get("is_private", False), - ) - - def _map_message(self, message: dict) -> NetworkSlackMessage: - """Normalize a Slack message payload into the plugin model.""" - return NetworkSlackMessage( - ts=message.get("ts", ""), - text=message.get("text", ""), - user=message.get("user"), - thread_ts=message.get("thread_ts"), - reply_count=message.get("reply_count", 0), - subtype=message.get("subtype"), - ) - - def auth_test(self) -> dict: + self._web_client = WebClient(token=user_token, timeout=timeout) + + self.auth_service = AuthService(self._web_client) + self.directory_service = DirectoryService(self._web_client) + self.conversation_service = ConversationService(self._web_client) + + @property + def web_client(self): + """Expose the underlying Slack WebClient for compatibility and testing.""" + return self._web_client + + @web_client.setter + def web_client(self, value) -> None: + """Keep internal services aligned when tests or callers replace the WebClient.""" + self._web_client = value + self.auth_service.web_client = value + self.directory_service.web_client = value + self.conversation_service.web_client = value + + def auth_test(self) -> ClientResult[UISlackAuth]: """Validate the configured user token with Slack auth.test.""" - try: - response = self.web_client.auth_test() - except SlackApiError as exc: - self._handle_api_error(exc, "auth") - except Exception as exc: - raise SlackClientError(f"Slack auth request failed: {exc}") from exc - - if not response.get("ok", False): - raise SlackAPIError( - f"Slack auth failed: {response.get('error', 'unknown_error')}" - ) - - return { - "user_id": response.get("user_id"), - "team_id": response.get("team_id"), - "team": response.get("team"), - "url": response.get("url"), - "bot_id": response.get("bot_id"), - } + return self.auth_service.auth_test() def list_users( self, limit: int = 100, cursor: str | None = None - ) -> tuple[list[NetworkSlackUser], str | None]: + ) -> ClientResult[tuple[list[UISlackUser], str | None]]: """List Slack users visible to the current token.""" - try: - response = self.web_client.users_list(limit=limit, cursor=cursor) - except SlackApiError as exc: - self._handle_api_error(exc, "list_users") - except Exception as exc: - raise SlackClientError(f"Slack users request failed: {exc}") from exc - - if not response.get("ok", False): - raise SlackAPIError( - f"Slack list_users failed: {response.get('error', 'unknown_error')}" - ) - - members = [self._map_user(member) for member in response.get("members", [])] - next_cursor = response.get("response_metadata", {}).get("next_cursor") or None - return members, next_cursor + return self.directory_service.list_users(limit=limit, cursor=cursor) def list_public_channels( self, limit: int = 100, cursor: str | None = None, exclude_archived: bool = True, - ) -> tuple[list[NetworkSlackChannel], str | None]: + ) -> ClientResult[tuple[list[UISlackChannel], str | None]]: """List public Slack channels visible to the current token.""" - try: - response = self.web_client.conversations_list( - limit=limit, - cursor=cursor, - exclude_archived=exclude_archived, - types="public_channel", - ) - except SlackApiError as exc: - self._handle_api_error(exc, "list_public_channels") - except Exception as exc: - raise SlackClientError(f"Slack conversations request failed: {exc}") from exc - - if not response.get("ok", False): - raise SlackAPIError( - "Slack list_public_channels failed: " - f"{response.get('error', 'unknown_error')}" - ) - - channels = [self._map_channel(channel) for channel in response.get("channels", [])] - next_cursor = response.get("response_metadata", {}).get("next_cursor") or None - return channels, next_cursor + return self.directory_service.list_public_channels( + limit=limit, + cursor=cursor, + exclude_archived=exclude_archived, + ) + + def search_users( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 10, + ) -> ClientResult[list[UISlackUser]]: + """Search Slack users across multiple pages of visible users.""" + return self.directory_service.search_users( + query, + max_matches=max_matches, + page_size=page_size, + max_pages=max_pages, + ) + + def search_public_channels( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 10, + exclude_archived: bool = True, + ) -> ClientResult[list[UISlackChannel]]: + """Search public Slack channels across multiple pages of visible channels.""" + return self.directory_service.search_public_channels( + query, + max_matches=max_matches, + page_size=page_size, + max_pages=max_pages, + exclude_archived=exclude_archived, + ) def read_channel( self, @@ -151,28 +105,13 @@ def read_channel( oldest: str | None = None, latest: str | None = None, inclusive: bool = False, - ) -> tuple[list[NetworkSlackMessage], str | None, bool]: + ) -> ClientResult[tuple[list[UISlackMessage], str | None, bool]]: """Read message history from a Slack public channel.""" - try: - response = self.web_client.conversations_history( - channel=channel_id, - limit=limit, - cursor=cursor, - oldest=oldest, - latest=latest, - inclusive=inclusive, - ) - except SlackApiError as exc: - self._handle_api_error(exc, "read_channel") - except Exception as exc: - raise SlackClientError(f"Slack channel history request failed: {exc}") from exc - - if not response.get("ok", False): - raise SlackAPIError( - f"Slack read_channel failed: {response.get('error', 'unknown_error')}" - ) - - messages = [self._map_message(message) for message in response.get("messages", [])] - next_cursor = response.get("response_metadata", {}).get("next_cursor") or None - has_more = response.get("has_more", False) - return messages, next_cursor, has_more + return self.conversation_service.read_conversation( + conversation_id=channel_id, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + inclusive=inclusive, + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/models.py b/plugins/titan-plugin-slack/titan_plugin_slack/models.py index dea10f59..43e874d5 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/models.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/models.py @@ -25,6 +25,17 @@ class NetworkSlackUser: is_active: bool = True +@dataclass +class UISlackUser: + """User model returned by Slack client and services.""" + + id: str + name: str + real_name: Optional[str] = None + is_bot: bool = False + is_active: bool = True + + @dataclass class SlackMessageRef: """Stable reference to a posted Slack message.""" @@ -45,3 +56,47 @@ class NetworkSlackMessage: thread_ts: Optional[str] = None reply_count: int = 0 subtype: Optional[str] = None + + +@dataclass +class UISlackChannel: + """Channel model returned by Slack client and services.""" + + id: str + name: str + is_channel: bool = True + is_private: bool = False + + +@dataclass +class UISlackMessage: + """Message model returned by Slack client and services.""" + + ts: str + text: str + user: Optional[str] = None + thread_ts: Optional[str] = None + reply_count: int = 0 + subtype: Optional[str] = None + + +@dataclass +class UISlackAuth: + """Auth identity model returned by Slack auth validation.""" + + user_id: Optional[str] = None + team_id: Optional[str] = None + team: Optional[str] = None + url: Optional[str] = None + bot_id: Optional[str] = None + + +@dataclass +class UISlackTarget: + """Reusable Slack target model for users and channels.""" + + target_type: str + target_id: str + target_name: str + team_id: Optional[str] = None + connection_id: Optional[str] = None diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py new file mode 100644 index 00000000..84bcfd5b --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py @@ -0,0 +1,17 @@ +"""Operations for reusable Slack target resolution.""" + +from .target_resolution_operations import ( + build_channel_target, + build_user_target, + filter_channels_for_query, + filter_users_for_query, + normalize_search_query, +) + +__all__ = [ + "normalize_search_query", + "filter_users_for_query", + "filter_channels_for_query", + "build_user_target", + "build_channel_target", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py new file mode 100644 index 00000000..b3d3c95c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py @@ -0,0 +1,96 @@ +"""Pure operations for Slack target resolution and filtering.""" + +from __future__ import annotations + +from ..models import UISlackChannel, UISlackTarget, UISlackUser + + +def normalize_search_query(query: str) -> str: + """Normalize a free-text query for user or channel filtering.""" + return " ".join(query.strip().lower().split()) + + +def _score_match(query: str, *candidates: str) -> int | None: + """Score a normalized query against one or more normalized candidate strings.""" + best_score: int | None = None + for candidate in candidates: + if not candidate: + continue + if candidate == query: + score = 0 + elif candidate.startswith(query): + score = 1 + elif query in candidate: + score = 2 + else: + continue + if best_score is None or score < best_score: + best_score = score + return best_score + + +def filter_users_for_query( + users: list[UISlackUser], query: str, limit: int = 20 +) -> list[UISlackUser]: + """Return the best matching Slack users for a free-text query.""" + normalized_query = normalize_search_query(query) + ranked: list[tuple[int, str, UISlackUser]] = [] + + for user in users: + name = normalize_search_query(user.name) + real_name = normalize_search_query(user.real_name or "") + score = _score_match(normalized_query, name, real_name) + if score is None: + continue + ranked.append((score, real_name or name, user)) + + ranked.sort(key=lambda item: (item[0], item[1], item[2].id)) + return [user for _, _, user in ranked[:limit]] + + +def filter_channels_for_query( + channels: list[UISlackChannel], query: str, limit: int = 20 +) -> list[UISlackChannel]: + """Return the best matching Slack channels for a free-text query.""" + normalized_query = normalize_search_query(query).lstrip("#") + ranked: list[tuple[int, str, UISlackChannel]] = [] + + for channel in channels: + name = normalize_search_query(channel.name).lstrip("#") + score = _score_match(normalized_query, name) + if score is None: + continue + ranked.append((score, name, channel)) + + ranked.sort(key=lambda item: (item[0], item[1], item[2].id)) + return [channel for _, _, channel in ranked[:limit]] + + +def build_user_target( + user: UISlackUser, + team_id: str | None = None, + connection_id: str | None = None, +) -> UISlackTarget: + """Build the canonical Slack target model for a user target.""" + return UISlackTarget( + target_type="user", + target_id=user.id, + target_name=user.real_name or user.name, + team_id=team_id, + connection_id=connection_id, + ) + + +def build_channel_target( + channel: UISlackChannel, + team_id: str | None = None, + connection_id: str | None = None, +) -> UISlackTarget: + """Build the canonical Slack target model for a channel target.""" + return UISlackTarget( + target_type="channel", + target_id=channel.id, + target_name=channel.name, + team_id=team_id, + connection_id=connection_id, + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 886a67f0..0a64f93c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -80,6 +80,8 @@ def get_steps(self) -> dict: from .steps import ( list_public_channels_step, list_users_step, + select_channel_target_step, + select_user_target_step, validate_connection_step, ) @@ -87,6 +89,8 @@ def get_steps(self) -> dict: "validate_connection": validate_connection_step, "list_public_channels": list_public_channels_step, "list_users": list_users_step, + "select_user_target": select_user_target_step, + "select_channel_target": select_channel_target_step, } @property diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 60c74ed5..9f4f973b 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -13,6 +13,8 @@ from titan_cli.ui.tui.screens.base import BaseScreen from titan_cli.core.logging import get_logger +from titan_cli.core.result import ClientError, ClientSuccess + from ..clients.slack_client import SlackClient from ..oauth import DEFAULT_SCOPES, SlackOAuthFlow, SlackOAuthResult @@ -351,17 +353,23 @@ def _validate_connection(self) -> None: ) result = client.auth_test() - self._save_global_slack_config( - { - "default_team_id": result.get("team_id"), - "default_team_name": result.get("team"), - "auth_mode": "user_token", - "timeout": plugin_config.get("timeout", 30), - } - ) - self._enable_plugin_for_current_project() - self.app.notify("Slack connection validated successfully.", severity="information") - self.dismiss(result=True) + match result: + case ClientSuccess(data=auth): + self._save_global_slack_config( + { + "default_team_id": auth.team_id, + "default_team_name": auth.team, + "auth_mode": "user_token", + "timeout": plugin_config.get("timeout", 30), + } + ) + self._enable_plugin_for_current_project() + self.app.notify( + "Slack connection validated successfully.", severity="information" + ) + self.dismiss(result=True) + case ClientError(error_message=err): + raise RuntimeError(err) def _disconnect(self) -> None: self.config.secrets.delete("slack_user_token", scope="user") diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index 62d80a46..9d8a9c95 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -5,9 +5,12 @@ list_users_step, validate_connection_step, ) +from .target_steps import select_channel_target_step, select_user_target_step __all__ = [ "validate_connection_step", "list_public_channels_step", "list_users_step", + "select_user_target_step", + "select_channel_target_step", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py index 36f8b7cc..5b63343f 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py @@ -1,5 +1,6 @@ """Public Slack workflow steps for validation and read-only discovery.""" +from titan_cli.core.result import ClientError, ClientSuccess from titan_cli.engine import Error, Success, WorkflowContext, WorkflowResult @@ -14,7 +15,7 @@ def validate_connection_step(ctx: WorkflowContext) -> WorkflowResult: None documented. Outputs (saved to ctx.data): - slack_auth (dict): Slack auth identity details from `auth_test()`. + slack_auth (UISlackAuth): Slack auth identity details from `auth_test()`. slack_team_id (str | None): Team identifier reported by Slack. slack_team_name (str | None): Team name reported by Slack. slack_user_id (str | None): User identifier reported by Slack. @@ -34,21 +35,27 @@ def validate_connection_step(ctx: WorkflowContext) -> WorkflowResult: return Error("Slack client not available") with ctx.textual.loading("Validating Slack connection..."): - auth = ctx.slack.auth_test() - - ctx.textual.success_text( - f"Connected to Slack team {auth.get('team') or 'Unknown'} as {auth.get('user_id') or 'Unknown'}" - ) - ctx.textual.end_step("success") - return Success( - "Slack connection validated", - metadata={ - "slack_auth": auth, - "slack_team_id": auth.get("team_id"), - "slack_team_name": auth.get("team"), - "slack_user_id": auth.get("user_id"), - }, - ) + result = ctx.slack.auth_test() + + match result: + case ClientSuccess(data=auth): + ctx.textual.success_text( + f"Connected to Slack team {auth.team or 'Unknown'} as {auth.user_id or 'Unknown'}" + ) + ctx.textual.end_step("success") + return Success( + "Slack connection validated", + metadata={ + "slack_auth": auth, + "slack_team_id": auth.team_id, + "slack_team_name": auth.team, + "slack_user_id": auth.user_id, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) def list_public_channels_step(ctx: WorkflowContext) -> WorkflowResult: @@ -64,7 +71,7 @@ def list_public_channels_step(ctx: WorkflowContext) -> WorkflowResult: slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True. Outputs (saved to ctx.data): - slack_channels (list[NetworkSlackChannel]): Public channels returned by Slack. + slack_channels (list[UISlackChannel]): Public channels returned by Slack. slack_channels_next_cursor (str | None): Pagination cursor for a later request. Returns: @@ -86,29 +93,35 @@ def list_public_channels_step(ctx: WorkflowContext) -> WorkflowResult: exclude_archived = ctx.get("slack_exclude_archived", True) with ctx.textual.loading("Loading Slack public channels..."): - channels, next_cursor = ctx.slack.list_public_channels( + result = ctx.slack.list_public_channels( limit=limit, cursor=cursor, exclude_archived=exclude_archived, ) - if not channels: - ctx.textual.dim_text("No public Slack channels were returned.") - else: - ctx.textual.success_text(f"Found {len(channels)} public Slack channels") - for channel in channels[:10]: - ctx.textual.text(f"- #{channel.name} ({channel.id})") - if len(channels) > 10: - ctx.textual.dim_text(f"... and {len(channels) - 10} more") - - ctx.textual.end_step("success") - return Success( - f"Retrieved {len(channels)} public Slack channels", - metadata={ - "slack_channels": channels, - "slack_channels_next_cursor": next_cursor, - }, - ) + match result: + case ClientSuccess(data=(channels, next_cursor)): + if not channels: + ctx.textual.dim_text("No public Slack channels were returned.") + else: + ctx.textual.success_text(f"Found {len(channels)} public Slack channels") + for channel in channels[:10]: + ctx.textual.text(f"- #{channel.name} ({channel.id})") + if len(channels) > 10: + ctx.textual.dim_text(f"... and {len(channels) - 10} more") + + ctx.textual.end_step("success") + return Success( + f"Retrieved {len(channels)} public Slack channels", + metadata={ + "slack_channels": channels, + "slack_channels_next_cursor": next_cursor, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) def list_users_step(ctx: WorkflowContext) -> WorkflowResult: @@ -123,7 +136,7 @@ def list_users_step(ctx: WorkflowContext) -> WorkflowResult: slack_cursor (str, optional): Pagination cursor for the next page. Outputs (saved to ctx.data): - slack_users (list[NetworkSlackUser]): Users returned by Slack. + slack_users (list[UISlackUser]): Users returned by Slack. slack_users_next_cursor (str | None): Pagination cursor for a later request. Returns: @@ -144,26 +157,32 @@ def list_users_step(ctx: WorkflowContext) -> WorkflowResult: cursor = ctx.get("slack_cursor") with ctx.textual.loading("Loading Slack users..."): - users, next_cursor = ctx.slack.list_users(limit=limit, cursor=cursor) - - if not users: - ctx.textual.dim_text("No Slack users were returned.") - else: - ctx.textual.success_text(f"Found {len(users)} Slack users") - for user in users[:10]: - label = user.real_name or user.name or user.id - ctx.textual.text(f"- {label} ({user.id})") - if len(users) > 10: - ctx.textual.dim_text(f"... and {len(users) - 10} more") - - ctx.textual.end_step("success") - return Success( - f"Retrieved {len(users)} Slack users", - metadata={ - "slack_users": users, - "slack_users_next_cursor": next_cursor, - }, - ) + result = ctx.slack.list_users(limit=limit, cursor=cursor) + + match result: + case ClientSuccess(data=(users, next_cursor)): + if not users: + ctx.textual.dim_text("No Slack users were returned.") + else: + ctx.textual.success_text(f"Found {len(users)} Slack users") + for user in users[:10]: + label = user.real_name or user.name or user.id + ctx.textual.text(f"- {label} ({user.id})") + if len(users) > 10: + ctx.textual.dim_text(f"... and {len(users) - 10} more") + + ctx.textual.end_step("success") + return Success( + f"Retrieved {len(users)} Slack users", + metadata={ + "slack_users": users, + "slack_users_next_cursor": next_cursor, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) __all__ = [ diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py new file mode 100644 index 00000000..4089b0b5 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py @@ -0,0 +1,222 @@ +"""Reusable Slack target selection steps for users and channels.""" + +from titan_cli.ui.tui.widgets import OptionItem + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_cli.engine import Error, Success, WorkflowContext, WorkflowResult +from ..operations import ( + build_channel_target, + build_user_target, + normalize_search_query, +) + + +MIN_QUERY_LENGTH = 2 +MAX_TARGET_OPTIONS = 20 + + +def select_user_target_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Select a Slack user target through query filtering and final confirmation. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target_query (str, optional): Pre-filled query used to filter Slack users. + slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20. + slack_search_page_size (int, optional): Page size used while scanning Slack users. Defaults to 200. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 10. + + Outputs (saved to ctx.data): + slack_target (UISlackTarget): Canonical selected Slack target. + slack_target_type (str): Selected target type (`user`). + slack_target_id (str): Slack user ID. + slack_target_name (str): User-facing target name. + slack_target_query (str): Query used to resolve the selection. + + Returns: + Success: If the user target is selected successfully. + Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected. + """ + return _select_target_step( + ctx, + step_title="Select Slack User Target", + empty_list_error="No Slack users are available for selection.", + query_prompt="Search Slack users by name or real name:", + short_query_error=f"Enter at least {MIN_QUERY_LENGTH} characters to search Slack users.", + no_match_error="No Slack users matched that query.", + options_prompt="Select the Slack user target:", + search_func=lambda query, limit, page_size, max_pages, exclude_archived: ctx.slack.search_users( + query, + max_matches=limit, + page_size=page_size, + max_pages=max_pages, + ), + option_builder=_build_user_option, + target_builder=lambda item, team_id, connection_id: build_user_target( + item, + team_id=team_id, + connection_id=connection_id, + ), + ) + + +def select_channel_target_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Select a Slack channel target through query filtering and final confirmation. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target_query (str, optional): Pre-filled query used to filter Slack channels. + slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20. + slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 10. + slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True. + + Outputs (saved to ctx.data): + slack_target (UISlackTarget): Canonical selected Slack target. + slack_target_type (str): Selected target type (`channel`). + slack_target_id (str): Slack channel ID. + slack_target_name (str): User-facing target name. + slack_target_query (str): Query used to resolve the selection. + + Returns: + Success: If the channel target is selected successfully. + Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected. + """ + return _select_target_step( + ctx, + step_title="Select Slack Channel Target", + empty_list_error="No Slack channels are available for selection.", + query_prompt="Search Slack channels by name:", + short_query_error=f"Enter at least {MIN_QUERY_LENGTH} characters to search Slack channels.", + no_match_error="No Slack channels matched that query.", + options_prompt="Select the Slack channel target:", + search_func=lambda query, limit, page_size, max_pages, exclude_archived: ctx.slack.search_public_channels( + query, + max_matches=limit, + page_size=page_size, + max_pages=max_pages, + exclude_archived=exclude_archived, + ), + option_builder=_build_channel_option, + target_builder=lambda item, team_id, connection_id: build_channel_target( + item, + team_id=team_id, + connection_id=connection_id, + ), + ) + + +def _select_target_step( + ctx: WorkflowContext, + *, + step_title: str, + empty_list_error: str, + query_prompt: str, + short_query_error: str, + no_match_error: str, + options_prompt: str, + search_func, + option_builder, + target_builder, +) -> WorkflowResult: + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step(step_title) + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + raw_query = ctx.get("slack_target_query") + if not raw_query: + raw_query = ctx.textual.ask_text(query_prompt, default="") + + if not raw_query: + ctx.textual.error_text(short_query_error) + ctx.textual.end_step("error") + return Error(short_query_error) + + normalized_query = normalize_search_query(raw_query) + if len(normalized_query.lstrip("#")) < MIN_QUERY_LENGTH: + ctx.textual.error_text(short_query_error) + ctx.textual.end_step("error") + return Error(short_query_error) + + search_limit = ctx.get("slack_search_limit", MAX_TARGET_OPTIONS) + page_size = ctx.get("slack_search_page_size", 200) + max_pages = ctx.get("slack_search_max_pages", 10) + exclude_archived = ctx.get("slack_exclude_archived", True) + + with ctx.textual.loading("Searching Slack targets..."): + result = search_func( + raw_query, + search_limit, + page_size, + max_pages, + exclude_archived, + ) + + match result: + case ClientSuccess(data=matches): + if not matches: + ctx.textual.error_text(no_match_error) + ctx.textual.end_step("error") + return Error(no_match_error) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + options = [option_builder(item) for item in matches] + selected = ctx.textual.ask_option(options_prompt, options=options) + if not selected: + ctx.textual.error_text("No Slack target was selected.") + ctx.textual.end_step("error") + return Error("No Slack target was selected.") + + team_id = ctx.get("slack_team_id") + connection_id = ctx.get("slack_connection_id") + target = target_builder(selected, team_id, connection_id) + + ctx.textual.success_text(f"Selected Slack target: {target.target_name} ({target.target_id})") + ctx.textual.end_step("success") + return Success( + f"Selected Slack {target.target_type} target", + metadata={ + "slack_target": target, + "slack_target_type": target.target_type, + "slack_target_id": target.target_id, + "slack_target_name": target.target_name, + "slack_target_query": raw_query, + }, + ) + + +def _build_user_option(user) -> OptionItem: + display_name = user.real_name or user.name or user.id + description = f"@{user.name} ({user.id})" + if not user.is_active: + description += " - inactive" + elif user.is_bot: + description += " - bot" + return OptionItem(value=user, title=display_name, description=description) + + +def _build_channel_option(channel) -> OptionItem: + description = f"#{channel.name} ({channel.id})" + if channel.is_private: + description += " - private" + return OptionItem(value=channel, title=f"#{channel.name}", description=description) + + +__all__ = [ + "select_user_target_step", + "select_channel_target_step", +] From 456876373aafe925159d92fddfcc02e0b4c01c7a Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 10 Jun 2026 13:15:04 +0200 Subject: [PATCH 13/23] feat: Implement Slack messaging service, steps, and direct message workflow --- .../_generated/slack-step-inventory.json | 96 +++++++++ docs/plugins/_meta/slack-step-groups.json | 8 + .../plugins/generated/slack-step-reference.md | 140 +++++++++++++ docs/plugins/slack/built-in-workflows.md | 24 +++ docs/plugins/slack/client-api.md | 18 ++ docs/plugins/slack/overview.md | 1 + docs/plugins/slack/workflow-steps.md | 12 ++ .../tests/clients/test_slack_client.py | 24 +++ .../test_target_resolution_operations.py | 11 + .../services/test_conversation_service.py | 34 +++ .../tests/services/test_message_service.py | 35 ++++ .../tests/test_dm_workflow.py | 22 ++ .../tests/test_message_steps.py | 107 ++++++++++ .../titan-plugin-slack/tests/test_oauth.py | 4 +- .../titan-plugin-slack/tests/test_plugin.py | 3 + .../clients/services/__init__.py | 2 + .../clients/services/conversation_service.py | 93 ++++++++- .../clients/services/directory_service.py | 11 +- .../clients/services/message_service.py | 98 +++++++++ .../clients/slack_client.py | 31 ++- .../titan_plugin_slack/models.py | 20 ++ .../titan_plugin_slack/oauth.py | 11 +- .../target_resolution_operations.py | 7 +- .../titan_plugin_slack/plugin.py | 6 + .../titan_plugin_slack/steps/__init__.py | 8 + .../titan_plugin_slack/steps/message_steps.py | 193 ++++++++++++++++++ .../titan_plugin_slack/steps/target_steps.py | 6 +- .../workflows/send-slack-direct-message.yaml | 28 +++ titan_cli/core/logging/config.py | 24 ++- 29 files changed, 1059 insertions(+), 18 deletions(-) create mode 100644 plugins/titan-plugin-slack/tests/services/test_message_service.py create mode 100644 plugins/titan-plugin-slack/tests/test_dm_workflow.py create mode 100644 plugins/titan-plugin-slack/tests/test_message_steps.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/message_service.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 949b840d..14cc563e 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -30,6 +30,23 @@ "summary": "Filter visible Slack channels by query and select one canonical channel target." } ] + }, + { + "name": "Messaging", + "steps": [ + { + "name": "open_direct_message", + "summary": "Open or reuse a direct message conversation for the selected user target." + }, + { + "name": "prompt_message_body", + "summary": "Capture a multiline Slack message body for later posting." + }, + { + "name": "post_message", + "summary": "Post the prepared message to the selected Slack conversation." + } + ] } ], "steps": [ @@ -169,6 +186,85 @@ ] }, "used_by_workflows": [] + }, + { + "name": "open_direct_message", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "open_direct_message_step", + "summary": "Open or reuse a direct message conversation for the selected user target.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_target (UISlackTarget): Selected Slack target. Must be a `user` target." + ], + "Outputs (saved to ctx.data)": [ + " slack_conversation (UISlackConversation): Opened or reused Slack conversation.", + " slack_conversation_id (str): Conversation ID used for later message operations." + ], + "Returns": [ + " Success: If the direct message conversation is ready.", + " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." + ] + }, + "used_by_workflows": [ + "send-slack-direct-message" + ] + }, + { + "name": "prompt_message_body", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "prompt_message_body_step", + "summary": "Capture a multiline Slack message body for later posting.", + "docstring_sections": { + "Requires": [], + "Inputs (from ctx.data)": [ + " slack_message_text (str, optional): Pre-filled message text. If already present, the prompt is skipped." + ], + "Outputs (saved to ctx.data)": [ + " slack_message_text (str): Message text to post later." + ], + "Returns": [ + " Success: If the message body is captured successfully.", + " Skip: If the message body already exists in context.", + " Error: If the user cancels or the message body is empty." + ] + }, + "used_by_workflows": [ + "send-slack-direct-message" + ] + }, + { + "name": "post_message", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "post_message_step", + "summary": "Post the prepared message to the selected Slack conversation.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_conversation_id (str): Slack conversation ID to post into.", + " slack_message_text (str): Message body to post.", + " slack_thread_ts (str, optional): Thread timestamp for replies." + ], + "Outputs (saved to ctx.data)": [ + " slack_message (UISlackPostedMessage): Posted Slack message metadata.", + " slack_message_ts (str): Timestamp of the posted message.", + " slack_message_channel (str): Channel or conversation ID where the message was posted." + ], + "Returns": [ + " Success: If the Slack message is posted successfully.", + " Error: If Slack is unavailable, required context is missing, or the Slack request fails." + ] + }, + "used_by_workflows": [ + "send-slack-direct-message" + ] } ] } diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json index d2dcfff5..b8b1a3b9 100644 --- a/docs/plugins/_meta/slack-step-groups.json +++ b/docs/plugins/_meta/slack-step-groups.json @@ -15,6 +15,14 @@ {"name": "select_user_target", "summary": "Filter visible Slack users by query and select one canonical user target."}, {"name": "select_channel_target", "summary": "Filter visible Slack channels by query and select one canonical channel target."} ] + }, + { + "name": "Messaging", + "steps": [ + {"name": "open_direct_message", "summary": "Open or reuse a direct message conversation for the selected user target."}, + {"name": "prompt_message_body", "summary": "Capture a multiline Slack message body for later posting."}, + {"name": "post_message", "summary": "Post the prepared message to the selected Slack conversation."} + ] } ] } diff --git a/docs/plugins/generated/slack-step-reference.md b/docs/plugins/generated/slack-step-reference.md index 9e382e52..46f82a5a 100644 --- a/docs/plugins/generated/slack-step-reference.md +++ b/docs/plugins/generated/slack-step-reference.md @@ -243,3 +243,143 @@ Filter visible Slack channels by query and select one canonical channel target. |--------|-----------------------|-------------| | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | | `Error` | - | If Slack is unavailable, no channels are available, the query is invalid, or no match is selected. | + +## Messaging + +### `open_direct_message` + +Open or reuse a direct message conversation for the selected user target. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: open_direct_message +``` + +**Used by built-in workflows:** `send-slack-direct-message` + +**Available to later steps:** `slack_conversation`, `slack_conversation_id` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Selected Slack target. Must be a `user` target. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_conversation` | UISlackConversation | Opened or reused Slack conversation. | +| `slack_conversation_id` | str | Conversation ID used for later message operations. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_conversation`, `slack_conversation_id` | If the direct message conversation is ready. | +| `Error` | - | If Slack is unavailable, the target is missing or invalid, or the Slack request fails. | + +### `prompt_message_body` + +Capture a multiline Slack message body for later posting. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: prompt_message_body +``` + +**Used by built-in workflows:** `send-slack-direct-message` + +**Available to later steps:** `slack_message_text` + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_message_text` | str, optional | Pre-filled message text. If already present, the prompt is skipped. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_message_text` | str | Message text to post later. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_message_text` | If the message body is captured successfully. | +| `Skip` | `slack_message_text` | If the message body already exists in context. | +| `Error` | - | If the user cancels or the message body is empty. | + +### `post_message` + +Post the prepared message to the selected Slack conversation. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: post_message +``` + +**Used by built-in workflows:** `send-slack-direct-message` + +**Available to later steps:** `slack_message`, `slack_message_ts`, `slack_message_channel` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_conversation_id` | str | Slack conversation ID to post into. | +| `slack_message_text` | str | Message body to post. | +| `slack_thread_ts` | str, optional | Thread timestamp for replies. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_message` | UISlackPostedMessage | Posted Slack message metadata. | +| `slack_message_ts` | str | Timestamp of the posted message. | +| `slack_message_channel` | str | Channel or conversation ID where the message was posted. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_message`, `slack_message_ts`, `slack_message_channel` | If the Slack message is posted successfully. | +| `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index e214c909..ea4168e1 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -25,3 +25,27 @@ Validate the current Slack connection, list public channels, and list visible us - the workflow stays read-only - it does not read channel history yet - it assumes one active personal Slack connection per user + +## `send-slack-direct-message` + +Select a person, open or reuse a direct message conversation, compose a message, and send it. + +**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml` + +### Default flow + +1. `slack.validate_connection` +2. `slack.select_user_target` +3. `slack.open_direct_message` +4. `slack.prompt_message_body` +5. `slack.post_message` + +### Typical usage + +- send a personal message to one selected Slack user from Titan +- validate that DM-specific Slack scopes and the direct-message path are working end to end + +### Scope constraints + +- this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline +- it still assumes one active personal Slack connection per user diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md index 773ee129..e584421c 100644 --- a/docs/plugins/slack/client-api.md +++ b/docs/plugins/slack/client-api.md @@ -72,6 +72,24 @@ Read message history from a Slack public channel. - `latest`: Optional latest timestamp bound. - `inclusive`: Optional boundary inclusion flag. +### `open_direct_message(user_id)` + +Open or reuse a direct message conversation with a Slack user. + +**Parameters:** + +- `user_id`: Required Slack user ID. + +### `post_message(channel_id, text, thread_ts=None)` + +Post a plain-text message to a Slack conversation. + +**Parameters:** + +- `channel_id`: Required conversation ID. +- `text`: Required message text. +- `thread_ts`: Optional thread timestamp for replies. + --- ## Usage constraints diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index a8335020..874f071d 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -49,5 +49,6 @@ The Slack plugin currently exposes public reusable steps for: - listing public channels visible to the current token - listing users visible to the current token - selecting a reusable Slack target from users or channels for later workflows +- opening a direct message and posting a Slack message through reusable messaging steps The grouped reference lives in [Workflow Steps](./workflow-steps.md). diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index 20b1681e..f5e0b439 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -8,6 +8,7 @@ For full contract details for every public step, including documented inputs, ou - [Validation and Discovery](#validation-and-discovery) - [Selection and Target Resolution](#selection-and-target-resolution) +- [Messaging](#messaging) ## Summary @@ -18,6 +19,9 @@ For full contract details for every public step, including documented inputs, ou | `list_users` | Validation and Discovery | `discover-slack-workspace` | | `select_user_target` | Selection and Target Resolution | - | | `select_channel_target` | Selection and Target Resolution | - | +| `open_direct_message` | Messaging | `send-slack-direct-message` | +| `prompt_message_body` | Messaging | `send-slack-direct-message` | +| `post_message` | Messaging | `send-slack-direct-message` | ## Validation and Discovery @@ -33,3 +37,11 @@ Use these steps to resolve a reusable Slack target object for later workflows. - `select_user_target`: filter visible Slack users by query and select one canonical user target - `select_channel_target`: filter visible Slack channels by query and select one canonical channel target + +## Messaging + +Use these steps to open a direct message conversation and post a plain-text Slack message. + +- `open_direct_message`: open or reuse a direct message conversation for the selected user target +- `prompt_message_body`: capture a multiline Slack message body for later posting +- `post_message`: post the prepared message to the selected conversation diff --git a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py index 63c3807d..29cd2c8d 100644 --- a/plugins/titan-plugin-slack/tests/clients/test_slack_client.py +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -242,3 +242,27 @@ def test_search_public_channels_delegates_to_directory_service() -> None: max_pages=3, exclude_archived=False, ) + + +def test_open_direct_message_delegates_to_conversation_service() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.conversation_service = MagicMock() + client.conversation_service.open_direct_message.return_value = ClientSuccess(data=MagicMock()) + + result = client.open_direct_message("U123") + + assert isinstance(result, ClientSuccess) + client.conversation_service.open_direct_message.assert_called_once_with("U123") + + +def test_post_message_delegates_to_message_service() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.message_service = MagicMock() + client.message_service.post_message.return_value = ClientSuccess(data=MagicMock()) + + result = client.post_message("D123", "Hello", thread_ts="123.456") + + assert isinstance(result, ClientSuccess) + client.message_service.post_message.assert_called_once_with( + "D123", "Hello", thread_ts="123.456" + ) diff --git a/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py index 66808cbf..ad08a1ac 100644 --- a/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py +++ b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py @@ -24,6 +24,17 @@ def test_filter_users_for_query_prioritizes_exact_then_prefix() -> None: assert [user.id for user in matches] == ["U1", "U2", "U3"] +def test_filter_users_for_query_matches_without_accents() -> None: + users = [ + UISlackUser(id="U1", name="gabriel", real_name="Gabriel Garcia Lopez"), + UISlackUser(id="U2", name="gabriel-2", real_name="Gabriel García López"), + ] + + matches = filter_users_for_query(users, "garcia") + + assert [user.id for user in matches] == ["U1", "U2"] + + def test_filter_channels_for_query_strips_hash_and_limits_results() -> None: channels = [ UISlackChannel(id="C1", name="engineering"), diff --git a/plugins/titan-plugin-slack/tests/services/test_conversation_service.py b/plugins/titan-plugin-slack/tests/services/test_conversation_service.py index 17683ad9..6dd6ab6a 100644 --- a/plugins/titan-plugin-slack/tests/services/test_conversation_service.py +++ b/plugins/titan-plugin-slack/tests/services/test_conversation_service.py @@ -56,3 +56,37 @@ def test_read_conversation_raises_api_error() -> None: assert isinstance(result, ClientError) assert result.error_message == "Slack read_channel failed: channel_not_found" + + +def test_open_direct_message_returns_conversation() -> None: + web_client = MagicMock() + web_client.conversations_open.return_value = { + "ok": True, + "channel": { + "id": "D123", + "is_im": True, + "user": "U123", + "context_team_id": "T123", + }, + } + + service = ConversationService(web_client) + + result = service.open_direct_message("U123") + + assert isinstance(result, ClientSuccess) + assert result.data.id == "D123" + assert result.data.user_id == "U123" + assert result.data.team_id == "T123" + + +def test_open_direct_message_returns_client_error() -> None: + web_client = MagicMock() + web_client.conversations_open.return_value = {"ok": False, "error": "missing_scope"} + + service = ConversationService(web_client) + + result = service.open_direct_message("U123") + + assert isinstance(result, ClientError) + assert result.error_message == "Slack open_direct_message failed: missing_scope" diff --git a/plugins/titan-plugin-slack/tests/services/test_message_service.py b/plugins/titan-plugin-slack/tests/services/test_message_service.py new file mode 100644 index 00000000..3b076140 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/services/test_message_service.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_plugin_slack.clients.services.message_service import MessageService + + +def test_post_message_returns_posted_message() -> None: + web_client = MagicMock() + web_client.chat_postMessage.return_value = { + "ok": True, + "channel": "D123", + "ts": "123.456", + "message": {"text": "Hello there", "thread_ts": None}, + } + + service = MessageService(web_client) + + result = service.post_message("D123", "Hello there") + + assert isinstance(result, ClientSuccess) + assert result.data.channel == "D123" + assert result.data.ts == "123.456" + assert result.data.text == "Hello there" + + +def test_post_message_returns_client_error_on_api_failure() -> None: + web_client = MagicMock() + web_client.chat_postMessage.return_value = {"ok": False, "error": "missing_scope"} + + service = MessageService(web_client) + + result = service.post_message("D123", "Hello there") + + assert isinstance(result, ClientError) + assert result.error_message == "Slack post_message failed: missing_scope" diff --git a/plugins/titan-plugin-slack/tests/test_dm_workflow.py b/plugins/titan-plugin-slack/tests/test_dm_workflow.py new file mode 100644 index 00000000..c9810ed7 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_dm_workflow.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import yaml + + +def test_send_slack_direct_message_workflow_structure() -> None: + workflow_path = ( + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-direct-message.yaml" + ) + + with open(workflow_path, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + assert workflow["name"] == "Send Slack Direct Message" + step_ids = [step["id"] for step in workflow["steps"]] + assert step_ids == [ + "validate_connection", + "select_user_target", + "open_direct_message", + "prompt_message_body", + "post_message", + ] diff --git a/plugins/titan-plugin-slack/tests/test_message_steps.py b/plugins/titan-plugin-slack/tests/test_message_steps.py new file mode 100644 index 00000000..c5c56320 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_message_steps.py @@ -0,0 +1,107 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_cli.engine import Error, Skip, Success +from titan_cli.engine.context import WorkflowContext +from titan_plugin_slack.models import UISlackConversation, UISlackPostedMessage, UISlackTarget +from titan_plugin_slack.steps.message_steps import ( + open_direct_message_step, + post_message_step, + prompt_message_body_step, +) + + +def _build_context() -> WorkflowContext: + ctx = WorkflowContext(secrets=MagicMock()) + ctx.textual = MagicMock() + + loading_mock = MagicMock() + loading_mock.__enter__ = MagicMock(return_value=loading_mock) + loading_mock.__exit__ = MagicMock(return_value=None) + ctx.textual.loading = MagicMock(return_value=loading_mock) + return ctx + + +def test_open_direct_message_step_requires_user_target() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_target"] = UISlackTarget( + target_type="channel", + target_id="C123", + target_name="general", + ) + + result = open_direct_message_step(ctx) + + assert isinstance(result, Error) + assert result.message == "Direct messages require a Slack user target" + + +def test_open_direct_message_step_returns_conversation_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_target"] = UISlackTarget( + target_type="user", + target_id="U123", + target_name="Alex Smith", + ) + conversation = UISlackConversation(id="D123", is_im=True, user_id="U123", team_id="T1") + ctx.slack.open_direct_message.return_value = ClientSuccess(data=conversation) + + result = open_direct_message_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_conversation"] == conversation + assert result.metadata["slack_conversation_id"] == "D123" + + +def test_prompt_message_body_step_skips_when_preset_exists() -> None: + ctx = _build_context() + ctx.data["slack_message_text"] = "Hello" + + result = prompt_message_body_step(ctx) + + assert isinstance(result, Skip) + assert result.metadata == {"slack_message_text": "Hello"} + + +def test_prompt_message_body_step_returns_message_text() -> None: + ctx = _build_context() + ctx.textual.ask_multiline.return_value = "Hello there" + + result = prompt_message_body_step(ctx) + + assert isinstance(result, Success) + assert result.metadata == {"slack_message_text": "Hello there"} + + +def test_post_message_step_returns_message_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_conversation_id"] = "D123" + ctx.data["slack_message_text"] = "Hello there" + posted = UISlackPostedMessage(channel="D123", ts="123.456", text="Hello there") + ctx.slack.post_message.return_value = ClientSuccess(data=posted) + + result = post_message_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_message"] == posted + assert result.metadata["slack_message_ts"] == "123.456" + assert result.metadata["slack_message_channel"] == "D123" + + +def test_post_message_step_returns_error_from_client() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_conversation_id"] = "D123" + ctx.data["slack_message_text"] = "Hello there" + ctx.slack.post_message.return_value = ClientError( + error_message="Slack post_message failed: missing_scope", + error_code="POST_MESSAGE_ERROR", + ) + + result = post_message_step(ctx) + + assert isinstance(result, Error) + assert result.message == "Slack post_message failed: missing_scope" diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py index 4347fb5c..8da0546d 100644 --- a/plugins/titan-plugin-slack/tests/test_oauth.py +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -32,7 +32,9 @@ def test_build_authorize_url_contains_expected_oauth_values() -> None: assert query["client_id"] == ["123"] assert query["state"] == [session.state] assert query["redirect_uri"] == ["http://127.0.0.1:8765/slack/callback"] - assert query["scope"] == ["users:read,channels:read,channels:history"] + assert query["scope"] == [ + "users:read,channels:read,channels:history,chat:write,im:write,mpim:write,channels:write,groups:write" + ] assert query["code_challenge_method"] == ["S256"] assert "code_challenge" in query diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 5addc381..b038b6f5 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -25,6 +25,9 @@ def test_slack_plugin_exposes_public_steps() -> None: "list_users", "select_user_target", "select_channel_target", + "open_direct_message", + "prompt_message_body", + "post_message", } diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py index 0862b4b9..24431da9 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py @@ -3,9 +3,11 @@ from .auth_service import AuthService from .conversation_service import ConversationService from .directory_service import DirectoryService +from .message_service import MessageService __all__ = [ "AuthService", "DirectoryService", "ConversationService", + "MessageService", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py index 8991b08b..cca44782 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py @@ -3,12 +3,21 @@ from titan_cli.core.result import ClientError, ClientSuccess, ClientResult from ..sdk import SlackApiError -from ...models import NetworkSlackMessage, UISlackMessage +from ...models import NetworkSlackMessage, UISlackConversation, UISlackMessage class ConversationService: """Service for Slack conversation and history access.""" + @staticmethod + def _extract_scope_context(response) -> tuple[str | None, str | None]: + """Extract needed/provided scope context from Slack error responses.""" + if isinstance(response, dict): + return response.get("needed"), response.get("provided") + if hasattr(response, "data") and isinstance(response.data, dict): + return response.data.get("needed"), response.data.get("provided") + return None, None + def __init__(self, web_client): self.web_client = web_client @@ -16,14 +25,25 @@ def __init__(self, web_client): def _build_api_error(exc: SlackApiError, operation: str) -> ClientError: error_code = "unknown_error" response = getattr(exc, "response", None) + needed, provided = ConversationService._extract_scope_context(response) if isinstance(response, dict): error_code = response.get("error", error_code) elif hasattr(response, "data") and isinstance(response.data, dict): error_code = response.data.get("error", error_code) + message = f"Slack {operation} failed: {error_code}" + details = {"slack_error": error_code} + if error_code == "missing_scope" and needed: + message += ( + f". Missing scopes: {needed}. " + "Reconnect Slack configuration to grant the required scopes." + ) + details["needed_scopes"] = needed + if provided: + details["provided_scopes"] = provided return ClientError( - error_message=f"Slack {operation} failed: {error_code}", + error_message=message, error_code="READ_CHANNEL_ERROR", - details={"slack_error": error_code}, + details=details, ) @staticmethod @@ -78,9 +98,20 @@ def read_conversation( ) if not response.get("ok", False): + needed = response.get("needed") + provided = response.get("provided") + message = f"Slack read_channel failed: {response.get('error', 'unknown_error')}" + details = None + if response.get("error") == "missing_scope" and needed: + message += ( + f". Missing scopes: {needed}. " + "Reconnect Slack configuration to grant the required scopes." + ) + details = {"needed_scopes": needed, "provided_scopes": provided} return ClientError( - error_message=f"Slack read_channel failed: {response.get('error', 'unknown_error')}", + error_message=message, error_code="READ_CHANNEL_ERROR", + details=details, ) messages = [self._map_message(message) for message in response.get("messages", [])] @@ -91,3 +122,57 @@ def read_conversation( data=(ui_messages, next_cursor, has_more), message=f"Retrieved {len(ui_messages)} Slack messages", ) + + def open_direct_message(self, user_id: str) -> ClientResult[UISlackConversation]: + """Open or reuse a direct message conversation with the given user.""" + try: + response = self.web_client.conversations_open(users=user_id, return_im=True) + except SlackApiError as exc: + built = self._build_api_error(exc, "open_direct_message") + return ClientError( + error_message=built.error_message, + error_code="OPEN_DIRECT_MESSAGE_ERROR", + details=built.details, + ) + except Exception as exc: + if hasattr(exc, "response"): + built = self._build_api_error(exc, "open_direct_message") + return ClientError( + error_message=built.error_message, + error_code="OPEN_DIRECT_MESSAGE_ERROR", + details=built.details, + ) + return ClientError( + error_message=f"Slack open_direct_message request failed: {exc}", + error_code="OPEN_DIRECT_MESSAGE_REQUEST_ERROR", + ) + + if not response.get("ok", False): + needed = response.get("needed") + provided = response.get("provided") + message = ( + f"Slack open_direct_message failed: {response.get('error', 'unknown_error')}" + ) + details = None + if response.get("error") == "missing_scope" and needed: + message += ( + f". Missing scopes: {needed}. " + "Reconnect Slack configuration to grant the required scopes." + ) + details = {"needed_scopes": needed, "provided_scopes": provided} + return ClientError( + error_message=message, + error_code="OPEN_DIRECT_MESSAGE_ERROR", + details=details, + ) + + channel = response.get("channel") or {} + return ClientSuccess( + data=UISlackConversation( + id=channel.get("id", ""), + is_im=channel.get("is_im", True), + user_id=channel.get("user") or user_id, + team_id=channel.get("context_team_id"), + ), + message="Slack direct message conversation ready", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py index e3739951..c8a445e2 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py @@ -34,10 +34,15 @@ def _build_api_error(exc: SlackApiError, operation: str, error_code_name: str) - @staticmethod def _map_user(member: dict) -> NetworkSlackUser: + profile = member.get("profile", {}) return NetworkSlackUser( id=member.get("id", ""), name=member.get("name", ""), - real_name=member.get("real_name") or member.get("profile", {}).get("real_name"), + real_name=( + member.get("real_name") + or profile.get("real_name") + or profile.get("display_name") + ), is_bot=member.get("is_bot", False), is_active=not member.get("deleted", False), ) @@ -155,7 +160,7 @@ def search_users( *, max_matches: int = 20, page_size: int = 200, - max_pages: int = 10, + max_pages: int = 50, ) -> ClientResult[list[UISlackUser]]: """Search Slack users by paging through visible users and filtering locally.""" cursor: str | None = None @@ -196,7 +201,7 @@ def search_public_channels( *, max_matches: int = 20, page_size: int = 200, - max_pages: int = 10, + max_pages: int = 50, exclude_archived: bool = True, ) -> ClientResult[list[UISlackChannel]]: """Search Slack public channels by paging through visible channels and filtering locally.""" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/message_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/message_service.py new file mode 100644 index 00000000..5834da19 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/message_service.py @@ -0,0 +1,98 @@ +"""Internal service for Slack message posting operations.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +from ...models import UISlackPostedMessage + + +class MessageService: + """Service for Slack message posting.""" + + @staticmethod + def _extract_scope_context(response) -> tuple[str | None, str | None]: + """Extract needed/provided scope context from Slack error responses.""" + if isinstance(response, dict): + return response.get("needed"), response.get("provided") + if hasattr(response, "data") and isinstance(response.data, dict): + return response.data.get("needed"), response.data.get("provided") + return None, None + + def __init__(self, web_client): + self.web_client = web_client + + @staticmethod + def _build_api_error(exc: SlackApiError, operation: str) -> ClientError: + error_code = "unknown_error" + response = getattr(exc, "response", None) + needed, provided = MessageService._extract_scope_context(response) + if isinstance(response, dict): + error_code = response.get("error", error_code) + elif hasattr(response, "data") and isinstance(response.data, dict): + error_code = response.data.get("error", error_code) + message = f"Slack {operation} failed: {error_code}" + details = {"slack_error": error_code} + if error_code == "missing_scope" and needed: + message += ( + f". Missing scopes: {needed}. " + "Reconnect Slack configuration to grant the required scopes." + ) + details["needed_scopes"] = needed + if provided: + details["provided_scopes"] = provided + return ClientError( + error_message=message, + error_code="POST_MESSAGE_ERROR", + details=details, + ) + + def post_message( + self, + channel_id: str, + text: str, + *, + thread_ts: str | None = None, + ) -> ClientResult[UISlackPostedMessage]: + """Post a plain-text Slack message to a conversation.""" + try: + response = self.web_client.chat_postMessage( + channel=channel_id, + text=text, + thread_ts=thread_ts, + ) + except SlackApiError as exc: + return self._build_api_error(exc, "post_message") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error(exc, "post_message") + return ClientError( + error_message=f"Slack post_message request failed: {exc}", + error_code="POST_MESSAGE_REQUEST_ERROR", + ) + + if not response.get("ok", False): + needed = response.get("needed") + provided = response.get("provided") + message = f"Slack post_message failed: {response.get('error', 'unknown_error')}" + details = None + if response.get("error") == "missing_scope" and needed: + message += ( + f". Missing scopes: {needed}. " + "Reconnect Slack configuration to grant the required scopes." + ) + details = {"needed_scopes": needed, "provided_scopes": provided} + return ClientError( + error_message=message, + error_code="POST_MESSAGE_ERROR", + details=details, + ) + + return ClientSuccess( + data=UISlackPostedMessage( + channel=response.get("channel", channel_id), + ts=response.get("ts", ""), + text=response.get("message", {}).get("text", text), + thread_ts=response.get("message", {}).get("thread_ts") or thread_ts, + ), + message="Slack message posted", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 41490dfb..0b4c19b2 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -1,11 +1,18 @@ """Slack client facade backed by internal services.""" from . import sdk as slack_sdk_module -from .services import AuthService, ConversationService, DirectoryService +from .services import AuthService, ConversationService, DirectoryService, MessageService from titan_cli.core.result import ClientResult from ..exceptions import SlackClientError -from ..models import UISlackAuth, UISlackChannel, UISlackMessage, UISlackUser +from ..models import ( + UISlackAuth, + UISlackChannel, + UISlackConversation, + UISlackMessage, + UISlackPostedMessage, + UISlackUser, +) SlackApiError = slack_sdk_module.SlackApiError WebClient = slack_sdk_module.WebClient @@ -26,6 +33,7 @@ def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 3 self.auth_service = AuthService(self._web_client) self.directory_service = DirectoryService(self._web_client) self.conversation_service = ConversationService(self._web_client) + self.message_service = MessageService(self._web_client) @property def web_client(self): @@ -39,6 +47,7 @@ def web_client(self, value) -> None: self.auth_service.web_client = value self.directory_service.web_client = value self.conversation_service.web_client = value + self.message_service.web_client = value def auth_test(self) -> ClientResult[UISlackAuth]: """Validate the configured user token with Slack auth.test.""" @@ -69,7 +78,7 @@ def search_users( *, max_matches: int = 20, page_size: int = 200, - max_pages: int = 10, + max_pages: int = 50, ) -> ClientResult[list[UISlackUser]]: """Search Slack users across multiple pages of visible users.""" return self.directory_service.search_users( @@ -85,7 +94,7 @@ def search_public_channels( *, max_matches: int = 20, page_size: int = 200, - max_pages: int = 10, + max_pages: int = 50, exclude_archived: bool = True, ) -> ClientResult[list[UISlackChannel]]: """Search public Slack channels across multiple pages of visible channels.""" @@ -115,3 +124,17 @@ def read_channel( latest=latest, inclusive=inclusive, ) + + def open_direct_message(self, user_id: str) -> ClientResult[UISlackConversation]: + """Open or reuse a direct message conversation with a Slack user.""" + return self.conversation_service.open_direct_message(user_id) + + def post_message( + self, + channel_id: str, + text: str, + *, + thread_ts: str | None = None, + ) -> ClientResult[UISlackPostedMessage]: + """Post a plain-text message to a Slack conversation.""" + return self.message_service.post_message(channel_id, text, thread_ts=thread_ts) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/models.py b/plugins/titan-plugin-slack/titan_plugin_slack/models.py index 43e874d5..de9d4654 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/models.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/models.py @@ -100,3 +100,23 @@ class UISlackTarget: target_name: str team_id: Optional[str] = None connection_id: Optional[str] = None + + +@dataclass +class UISlackConversation: + """Conversation model returned when Slack opens or resolves a DM.""" + + id: str + is_im: bool = False + user_id: Optional[str] = None + team_id: Optional[str] = None + + +@dataclass +class UISlackPostedMessage: + """Posted Slack message metadata returned by message sending operations.""" + + channel: str + ts: str + text: Optional[str] = None + thread_ts: Optional[str] = None diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index 23581e5e..a63ba54f 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -19,7 +19,16 @@ AUTHORIZE_URL = "https://slack.com/oauth/v2_user/authorize" TOKEN_URL = "https://slack.com/api/oauth.v2.user.access" -DEFAULT_SCOPES = ["users:read", "channels:read", "channels:history"] +DEFAULT_SCOPES = [ + "users:read", + "channels:read", + "channels:history", + "chat:write", + "im:write", + "mpim:write", + "channels:write", + "groups:write", +] logger = get_logger(__name__) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py index b3d3c95c..bd3ffc3c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py @@ -2,12 +2,17 @@ from __future__ import annotations +import unicodedata + from ..models import UISlackChannel, UISlackTarget, UISlackUser def normalize_search_query(query: str) -> str: """Normalize a free-text query for user or channel filtering.""" - return " ".join(query.strip().lower().split()) + collapsed = " ".join(query.strip().lower().split()) + return "".join( + char for char in unicodedata.normalize("NFKD", collapsed) if not unicodedata.combining(char) + ) def _score_match(query: str, *candidates: str) -> int | None: diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 0a64f93c..eb9196b8 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -80,6 +80,9 @@ def get_steps(self) -> dict: from .steps import ( list_public_channels_step, list_users_step, + open_direct_message_step, + post_message_step, + prompt_message_body_step, select_channel_target_step, select_user_target_step, validate_connection_step, @@ -91,6 +94,9 @@ def get_steps(self) -> dict: "list_users": list_users_step, "select_user_target": select_user_target_step, "select_channel_target": select_channel_target_step, + "open_direct_message": open_direct_message_step, + "prompt_message_body": prompt_message_body_step, + "post_message": post_message_step, } @property diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index 9d8a9c95..b70a3a7d 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -5,12 +5,20 @@ list_users_step, validate_connection_step, ) +from .message_steps import ( + open_direct_message_step, + post_message_step, + prompt_message_body_step, +) from .target_steps import select_channel_target_step, select_user_target_step __all__ = [ "validate_connection_step", "list_public_channels_step", "list_users_step", + "open_direct_message_step", + "prompt_message_body_step", + "post_message_step", "select_user_target_step", "select_channel_target_step", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py new file mode 100644 index 00000000..c1d6798c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py @@ -0,0 +1,193 @@ +"""Reusable Slack messaging steps for direct messages and later channels.""" + +from titan_cli.core.result import ClientError, ClientSuccess +from titan_cli.engine import Error, Skip, Success, WorkflowContext, WorkflowResult + + +def open_direct_message_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Open or reuse a direct message conversation for the selected Slack user target. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target (UISlackTarget): Selected Slack target. Must be a `user` target. + + Outputs (saved to ctx.data): + slack_conversation (UISlackConversation): Opened or reused Slack conversation. + slack_conversation_id (str): Conversation ID used for later message operations. + + Returns: + Success: If the direct message conversation is ready. + Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Open Slack Direct Message") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + target = ctx.get("slack_target") + if not target: + ctx.textual.error_text("Slack target not found in context") + ctx.textual.end_step("error") + return Error("Slack target not found in context") + + if target.target_type != "user": + ctx.textual.error_text("Direct messages require a Slack user target") + ctx.textual.end_step("error") + return Error("Direct messages require a Slack user target") + + with ctx.textual.loading("Opening Slack direct message..."): + result = ctx.slack.open_direct_message(target.target_id) + + match result: + case ClientSuccess(data=conversation): + ctx.textual.success_text( + f"Slack direct message ready: {conversation.id} for {target.target_name}" + ) + ctx.textual.end_step("success") + return Success( + "Slack direct message ready", + metadata={ + "slack_conversation": conversation, + "slack_conversation_id": conversation.id, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + +def prompt_message_body_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Capture a multiline Slack message body for later posting. + + Inputs (from ctx.data): + slack_message_text (str, optional): Pre-filled message text. If already present, the prompt is skipped. + + Outputs (saved to ctx.data): + slack_message_text (str): Message text to post later. + + Returns: + Success: If the message body is captured successfully. + Skip: If the message body already exists in context. + Error: If the user cancels or the message body is empty. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Compose Slack Message") + + existing = ctx.get("slack_message_text") + if existing: + ctx.textual.dim_text("Slack message text already provided, skipping prompt.") + ctx.textual.end_step("skip") + return Skip( + "Slack message text already provided", + metadata={"slack_message_text": existing}, + ) + + try: + body = ctx.textual.ask_multiline("Enter the Slack message:", default="") + except (KeyboardInterrupt, EOFError): + ctx.textual.end_step("error") + return Error("User cancelled Slack message composition") + except Exception as exc: + ctx.textual.end_step("error") + return Error(f"Failed to prompt for Slack message: {exc}", exception=exc) + + if not body or not body.strip(): + ctx.textual.error_text("Slack message text cannot be empty") + ctx.textual.end_step("error") + return Error("Slack message text cannot be empty") + + ctx.textual.success_text("Slack message composed") + ctx.textual.end_step("success") + return Success( + "Slack message text captured", + metadata={"slack_message_text": body.strip()}, + ) + + +def post_message_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Post a plain-text Slack message to the prepared conversation. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_conversation_id (str): Slack conversation ID to post into. + slack_message_text (str): Message body to post. + slack_thread_ts (str, optional): Thread timestamp for replies. + + Outputs (saved to ctx.data): + slack_message (UISlackPostedMessage): Posted Slack message metadata. + slack_message_ts (str): Timestamp of the posted message. + slack_message_channel (str): Channel or conversation ID where the message was posted. + + Returns: + Success: If the Slack message is posted successfully. + Error: If Slack is unavailable, required context is missing, or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Post Slack Message") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + conversation_id = ctx.get("slack_conversation_id") + if not conversation_id: + ctx.textual.error_text("Slack conversation ID not found in context") + ctx.textual.end_step("error") + return Error("Slack conversation ID not found in context") + + message_text = ctx.get("slack_message_text") + if not message_text: + ctx.textual.error_text("Slack message text not found in context") + ctx.textual.end_step("error") + return Error("Slack message text not found in context") + + thread_ts = ctx.get("slack_thread_ts") + + with ctx.textual.loading("Posting Slack message..."): + result = ctx.slack.post_message( + conversation_id, + message_text, + thread_ts=thread_ts, + ) + + match result: + case ClientSuccess(data=message): + ctx.textual.success_text(f"Slack message posted to {message.channel}") + ctx.textual.end_step("success") + return Success( + "Slack message posted", + metadata={ + "slack_message": message, + "slack_message_ts": message.ts, + "slack_message_channel": message.channel, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + +__all__ = [ + "open_direct_message_step", + "prompt_message_body_step", + "post_message_step", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py index 4089b0b5..7764f931 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py @@ -26,7 +26,7 @@ def select_user_target_step(ctx: WorkflowContext) -> WorkflowResult: slack_target_query (str, optional): Pre-filled query used to filter Slack users. slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20. slack_search_page_size (int, optional): Page size used while scanning Slack users. Defaults to 200. - slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 10. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50. Outputs (saved to ctx.data): slack_target (UISlackTarget): Canonical selected Slack target. @@ -73,7 +73,7 @@ def select_channel_target_step(ctx: WorkflowContext) -> WorkflowResult: slack_target_query (str, optional): Pre-filled query used to filter Slack channels. slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20. slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200. - slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 10. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50. slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True. Outputs (saved to ctx.data): @@ -151,7 +151,7 @@ def _select_target_step( search_limit = ctx.get("slack_search_limit", MAX_TARGET_OPTIONS) page_size = ctx.get("slack_search_page_size", 200) - max_pages = ctx.get("slack_search_max_pages", 10) + max_pages = ctx.get("slack_search_max_pages", 50) exclude_archived = ctx.get("slack_exclude_archived", True) with ctx.textual.loading("Searching Slack targets..."): diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml new file mode 100644 index 00000000..60de4f5e --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml @@ -0,0 +1,28 @@ +name: "Send Slack Direct Message" +description: "Select a person, open or reuse a direct message conversation, and send a Slack message" + +steps: + - id: validate_connection + name: "Validate Slack Connection" + plugin: slack + step: validate_connection + + - id: select_user_target + name: "Select Slack User Target" + plugin: slack + step: select_user_target + + - id: open_direct_message + name: "Open Direct Message" + plugin: slack + step: open_direct_message + + - id: prompt_message_body + name: "Compose Message" + plugin: slack + step: prompt_message_body + + - id: post_message + name: "Send Message" + plugin: slack + step: post_message diff --git a/titan_cli/core/logging/config.py b/titan_cli/core/logging/config.py index 96c886f1..2fd9bc8c 100644 --- a/titan_cli/core/logging/config.py +++ b/titan_cli/core/logging/config.py @@ -14,11 +14,27 @@ from datetime import datetime, timezone from pathlib import Path from logging.handlers import RotatingFileHandler -from typing import Optional +from typing import Final, Optional import structlog +_SLACK_SDK_NOISY_MESSAGES: Final[tuple[str, ...]] = ( + "Received the following response", +) + + +class _SlackSdkNoiseFilter(logging.Filter): + """Drop extremely verbose Slack SDK wire-response logs.""" + + def filter(self, record: logging.LogRecord) -> bool: + if not record.name.startswith("slack_sdk"): + return True + + message = record.getMessage() + return not any(noisy in message for noisy in _SLACK_SDK_NOISY_MESSAGES) + + def setup_logging( verbose: bool = False, debug: bool = False, @@ -162,6 +178,7 @@ def _setup_file_handler(log_file: Optional[Path], is_dev: bool) -> None: # File always logs at DEBUG in dev, INFO in prod file_handler.setLevel(logging.DEBUG if is_dev else logging.INFO) + file_handler.addFilter(_SlackSdkNoiseFilter()) # Add to root logger root_logger = logging.getLogger() @@ -187,6 +204,7 @@ def _setup_console_handler(log_level: int, is_dev: bool) -> None: # Format is handled by structlog processors console_handler.setFormatter(logging.Formatter("%(message)s")) + console_handler.addFilter(_SlackSdkNoiseFilter()) # Add to root logger root_logger = logging.getLogger() @@ -196,6 +214,10 @@ def _setup_console_handler(log_level: int, is_dev: bool) -> None: # This prevents EVENT/SYSTEM spam in the console while keeping app logs visible logging.getLogger("textual").setLevel(logging.WARNING) logging.getLogger("rich").setLevel(logging.WARNING) + logging.getLogger("slack_sdk").setLevel(logging.DEBUG if is_dev else logging.WARNING) + logging.getLogger("slack_sdk.web.base_client").setLevel( + logging.DEBUG if is_dev else logging.WARNING + ) def _configure_structlog(is_dev: bool) -> None: From 51ddfbf67f3b7dbf5b7655e52fe1969e5bf69e6c Mon Sep 17 00:00:00 2001 From: finxo Date: Mon, 15 Jun 2026 15:33:50 +0200 Subject: [PATCH 14/23] feat: Implement AI-powered Slack conversation summarization workflow and steps --- .../_generated/slack-step-inventory.json | 138 +++++++ docs/plugins/_meta/slack-step-groups.json | 9 + .../plugins/generated/slack-step-reference.md | 197 +++++++++ docs/plugins/slack/built-in-workflows.md | 24 ++ docs/plugins/slack/client-api.md | 13 + docs/plugins/slack/overview.md | 1 + docs/plugins/slack/workflow-steps.md | 14 + .../test_message_summary_operations.py | 35 ++ .../titan-plugin-slack/tests/test_oauth.py | 2 +- .../titan-plugin-slack/tests/test_plugin.py | 4 + .../tests/test_summary_steps.py | 125 ++++++ .../tests/test_summary_workflow.py | 22 + .../tests/test_target_steps.py | 2 +- .../clients/services/directory_service.py | 74 ++++ .../clients/slack_client.py | 37 ++ .../titan_plugin_slack/oauth.py | 4 + .../titan_plugin_slack/operations/__init__.py | 8 + .../operations/message_summary_operations.py | 62 +++ .../titan_plugin_slack/plugin.py | 8 + .../titan_plugin_slack/steps/__init__.py | 10 + .../titan_plugin_slack/steps/summary_steps.py | 377 ++++++++++++++++++ .../titan_plugin_slack/steps/target_steps.py | 2 +- .../workflows/summarize-slack-target.yaml | 33 ++ tests/ai/providers/test_litellm_provider.py | 37 ++ titan_cli/ai/providers/litellm.py | 62 ++- 25 files changed, 1296 insertions(+), 4 deletions(-) create mode 100644 plugins/titan-plugin-slack/tests/operations/test_message_summary_operations.py create mode 100644 plugins/titan-plugin-slack/tests/test_summary_steps.py create mode 100644 plugins/titan-plugin-slack/tests/test_summary_workflow.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 14cc563e..412aa6f9 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -47,6 +47,27 @@ "summary": "Post the prepared message to the selected Slack conversation." } ] + }, + { + "name": "Conversation Summaries", + "steps": [ + { + "name": "select_target", + "summary": "Search both users and channels and select one unified Slack target." + }, + { + "name": "ensure_target_conversation", + "summary": "Resolve a Slack conversation from the selected target." + }, + { + "name": "read_recent_messages", + "summary": "Read the most recent messages from the resolved Slack conversation." + }, + { + "name": "ai_summarize_messages", + "summary": "Summarize the retrieved Slack messages with AI." + } + ] } ], "steps": [ @@ -265,6 +286,123 @@ "used_by_workflows": [ "send-slack-direct-message" ] + }, + { + "name": "select_target", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "select_target_step", + "summary": "Search both users and channels for a single unified target selection.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_target_query (str, optional): Query used to search both users and channels.", + " slack_search_limit (int, optional): Maximum number of matches to keep from each search. Defaults to 10.", + " slack_search_page_size (int, optional): Page size used while scanning Slack. Defaults to 200.", + " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", + " slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True." + ], + "Outputs (saved to ctx.data)": [ + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`user` or `channel`).", + " slack_target_id (str): Slack target identifier.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection." + ], + "Returns": [ + " Success: If the unified target is selected successfully.", + " Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] + }, + { + "name": "ensure_target_conversation", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "ensure_target_conversation_step", + "summary": "Resolve a Slack conversation from the selected target.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_target (UISlackTarget): Selected Slack target." + ], + "Outputs (saved to ctx.data)": [ + " slack_conversation (UISlackConversation): Resolved Slack conversation.", + " slack_conversation_id (str): Conversation ID used for later operations." + ], + "Returns": [ + " Success: If the target conversation is resolved successfully.", + " Error: If Slack is unavailable, the target is missing, or the Slack request fails." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] + }, + { + "name": "read_recent_messages", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "read_recent_messages_step", + "summary": "Read the most recent messages from the resolved Slack conversation.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_conversation_id (str): Slack conversation ID to read.", + " slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 50." + ], + "Outputs (saved to ctx.data)": [ + " slack_messages (list[UISlackMessage]): Retrieved Slack messages.", + " slack_messages_next_cursor (str | None): Pagination cursor for later reads.", + " slack_messages_has_more (bool): Whether more messages are available." + ], + "Returns": [ + " Success: If recent messages are retrieved successfully.", + " Error: If Slack is unavailable, required context is missing, or the Slack request fails." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] + }, + { + "name": "ai_summarize_messages", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "ai_summarize_messages_step", + "summary": "Summarize the retrieved Slack messages with AI.", + "docstring_sections": { + "Requires": [ + " ctx.textual: Textual UI context." + ], + "Inputs (from ctx.data)": [ + " slack_messages (list[UISlackMessage]): Messages to summarize.", + " slack_target_name (str, optional): Human-facing target label for the summary.", + " slack_summary_max_chars (int, optional): Maximum transcript size passed to AI. Defaults to 12000." + ], + "Outputs (saved to ctx.data)": [ + " slack_summary (str): AI-generated Slack summary.", + " slack_summary_source_count (int): Number of source messages summarized.", + " slack_summary_transcript_chars (int): Transcript size sent to AI after truncation." + ], + "Returns": [ + " Success: If the summary is generated successfully.", + " Skip: If AI is not configured or not available.", + " Error: If messages are missing or the AI request fails." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] } ] } diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json index b8b1a3b9..b1719f29 100644 --- a/docs/plugins/_meta/slack-step-groups.json +++ b/docs/plugins/_meta/slack-step-groups.json @@ -23,6 +23,15 @@ {"name": "prompt_message_body", "summary": "Capture a multiline Slack message body for later posting."}, {"name": "post_message", "summary": "Post the prepared message to the selected Slack conversation."} ] + }, + { + "name": "Conversation Summaries", + "steps": [ + {"name": "select_target", "summary": "Search both users and channels and select one unified Slack target."}, + {"name": "ensure_target_conversation", "summary": "Resolve a Slack conversation from the selected target."}, + {"name": "read_recent_messages", "summary": "Read the most recent messages from the resolved Slack conversation."}, + {"name": "ai_summarize_messages", "summary": "Summarize the retrieved Slack messages with AI."} + ] } ] } diff --git a/docs/plugins/generated/slack-step-reference.md b/docs/plugins/generated/slack-step-reference.md index 46f82a5a..dae4f59f 100644 --- a/docs/plugins/generated/slack-step-reference.md +++ b/docs/plugins/generated/slack-step-reference.md @@ -383,3 +383,200 @@ Post the prepared message to the selected Slack conversation. |--------|-----------------------|-------------| | `Success` | `slack_message`, `slack_message_ts`, `slack_message_channel` | If the Slack message is posted successfully. | | `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | + +## Conversation Summaries + +### `select_target` + +Search both users and channels for a single unified target selection. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: select_target +``` + +**Used by built-in workflows:** `summarize-slack-target` + +**Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target_query` | str, optional | Query used to search both users and channels. | +| `slack_search_limit` | int, optional | Maximum number of matches to keep from each search. Defaults to 10. | +| `slack_search_page_size` | int, optional | Page size used while scanning Slack. Defaults to 200. | +| `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | +| `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Canonical selected Slack target. | +| `slack_target_type` | str | Selected target type (`user` or `channel`). | +| `slack_target_id` | str | Slack target identifier. | +| `slack_target_name` | str | User-facing target name. | +| `slack_target_query` | str | Query used to resolve the selection. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the unified target is selected successfully. | +| `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | + +### `ensure_target_conversation` + +Resolve a Slack conversation from the selected target. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: ensure_target_conversation +``` + +**Used by built-in workflows:** `summarize-slack-target` + +**Available to later steps:** `slack_conversation`, `slack_conversation_id` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Selected Slack target. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_conversation` | UISlackConversation | Resolved Slack conversation. | +| `slack_conversation_id` | str | Conversation ID used for later operations. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_conversation`, `slack_conversation_id` | If the target conversation is resolved successfully. | +| `Error` | - | If Slack is unavailable, the target is missing, or the Slack request fails. | + +### `read_recent_messages` + +Read the most recent messages from the resolved Slack conversation. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: read_recent_messages +``` + +**Used by built-in workflows:** `summarize-slack-target` + +**Available to later steps:** `slack_messages`, `slack_messages_next_cursor`, `slack_messages_has_more` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_conversation_id` | str | Slack conversation ID to read. | +| `slack_history_limit` | int, optional | Number of recent messages to fetch. Defaults to 50. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_messages` | list[UISlackMessage] | Retrieved Slack messages. | +| `slack_messages_next_cursor` | str \| None | Pagination cursor for later reads. | +| `slack_messages_has_more` | bool | Whether more messages are available. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_messages`, `slack_messages_next_cursor`, `slack_messages_has_more` | If recent messages are retrieved successfully. | +| `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | + +### `ai_summarize_messages` + +Summarize recent Slack messages with AI. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: ai_summarize_messages +``` + +**Used by built-in workflows:** `summarize-slack-target` + +**Available to later steps:** `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_messages` | list[UISlackMessage] | Messages to summarize. | +| `slack_target_name` | str, optional | Human-facing target label for the summary. | +| `slack_summary_max_chars` | int, optional | Maximum transcript size passed to AI. Defaults to 12000. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_summary` | str | AI-generated Slack summary. | +| `slack_summary_source_count` | int | Number of source messages summarized. | +| `slack_summary_transcript_chars` | int | Transcript size sent to AI after truncation. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` | If the summary is generated successfully. | +| `Skip` | - | If AI is not configured or not available. | +| `Error` | - | If messages are missing or the AI request fails. | diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index ea4168e1..30b13af7 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -49,3 +49,27 @@ Select a person, open or reuse a direct message conversation, compose a message, - this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline - it still assumes one active personal Slack connection per user + +## `summarize-slack-target` + +Search for a person or channel, resolve the backing conversation, read recent Slack messages, and summarize them with AI. + +**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml` + +### Default flow + +1. `slack.validate_connection` +2. `slack.select_target` +3. `slack.ensure_target_conversation` +4. `slack.read_recent_messages` +5. `slack.ai_summarize_messages` + +### Typical usage + +- summarize a recent conversation without manually browsing the Slack UI +- inspect recent channel or DM context from Titan before taking action + +### Scope constraints + +- this workflow depends on conversation-history scopes and AI configuration +- it assumes one active personal Slack connection per user diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md index e584421c..02674530 100644 --- a/docs/plugins/slack/client-api.md +++ b/docs/plugins/slack/client-api.md @@ -72,6 +72,19 @@ Read message history from a Slack public channel. - `latest`: Optional latest timestamp bound. - `inclusive`: Optional boundary inclusion flag. +### `read_conversation(conversation_id, limit=20, cursor=None, oldest=None, latest=None, inclusive=False)` + +Read message history from any Slack conversation ID. + +**Parameters:** + +- `conversation_id`: Required conversation ID. +- `limit`: Optional maximum number of messages to return. +- `cursor`: Optional pagination cursor. +- `oldest`: Optional oldest timestamp bound. +- `latest`: Optional latest timestamp bound. +- `inclusive`: Optional boundary inclusion flag. + ### `open_direct_message(user_id)` Open or reuse a direct message conversation with a Slack user. diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 874f071d..9522b396 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -50,5 +50,6 @@ The Slack plugin currently exposes public reusable steps for: - listing users visible to the current token - selecting a reusable Slack target from users or channels for later workflows - opening a direct message and posting a Slack message through reusable messaging steps +- resolving a target conversation, reading recent messages, and summarizing them with AI The grouped reference lives in [Workflow Steps](./workflow-steps.md). diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index f5e0b439..3148dcf8 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -9,6 +9,7 @@ For full contract details for every public step, including documented inputs, ou - [Validation and Discovery](#validation-and-discovery) - [Selection and Target Resolution](#selection-and-target-resolution) - [Messaging](#messaging) +- [Conversation Summaries](#conversation-summaries) ## Summary @@ -22,6 +23,10 @@ For full contract details for every public step, including documented inputs, ou | `open_direct_message` | Messaging | `send-slack-direct-message` | | `prompt_message_body` | Messaging | `send-slack-direct-message` | | `post_message` | Messaging | `send-slack-direct-message` | +| `select_target` | Conversation Summaries | `summarize-slack-target` | +| `ensure_target_conversation` | Conversation Summaries | `summarize-slack-target` | +| `read_recent_messages` | Conversation Summaries | `summarize-slack-target` | +| `ai_summarize_messages` | Conversation Summaries | `summarize-slack-target` | ## Validation and Discovery @@ -45,3 +50,12 @@ Use these steps to open a direct message conversation and post a plain-text Slac - `open_direct_message`: open or reuse a direct message conversation for the selected user target - `prompt_message_body`: capture a multiline Slack message body for later posting - `post_message`: post the prepared message to the selected conversation + +## Conversation Summaries + +Use these steps to resolve a target conversation, read its recent messages, and summarize them with AI. + +- `select_target`: search both users and channels and select one unified Slack target +- `ensure_target_conversation`: resolve a Slack conversation from the selected target +- `read_recent_messages`: read the latest messages from the resolved conversation +- `ai_summarize_messages`: summarize the retrieved messages with AI diff --git a/plugins/titan-plugin-slack/tests/operations/test_message_summary_operations.py b/plugins/titan-plugin-slack/tests/operations/test_message_summary_operations.py new file mode 100644 index 00000000..4a421f78 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/operations/test_message_summary_operations.py @@ -0,0 +1,35 @@ +from titan_plugin_slack.models import UISlackMessage +from titan_plugin_slack.operations.message_summary_operations import ( + build_summary_prompt, + format_messages_as_transcript, + truncate_transcript_for_summary, +) + + +def test_format_messages_as_transcript_includes_target_and_messages() -> None: + messages = [ + UISlackMessage(ts="1718013600.000001", text="Hello", user="U123"), + UISlackMessage(ts="1718013660.000002", text="World", user="U456"), + ] + + transcript = format_messages_as_transcript(messages, target_name="general") + + assert "Target: general" in transcript + assert "U123: Hello" in transcript + assert "U456: World" in transcript + + +def test_truncate_transcript_for_summary_marks_truncation() -> None: + transcript = "a" * 100 + + truncated = truncate_transcript_for_summary(transcript, max_chars=40) + + assert truncated.endswith("[Transcript truncated]") + assert len(truncated) <= 40 + + +def test_build_summary_prompt_mentions_target() -> None: + prompt = build_summary_prompt("general", "message transcript") + + assert "general" in prompt + assert "message transcript" in prompt diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py index 8da0546d..53e584bd 100644 --- a/plugins/titan-plugin-slack/tests/test_oauth.py +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -33,7 +33,7 @@ def test_build_authorize_url_contains_expected_oauth_values() -> None: assert query["state"] == [session.state] assert query["redirect_uri"] == ["http://127.0.0.1:8765/slack/callback"] assert query["scope"] == [ - "users:read,channels:read,channels:history,chat:write,im:write,mpim:write,channels:write,groups:write" + "users:read,channels:read,channels:history,groups:read,groups:history,im:history,mpim:history,chat:write,im:write,mpim:write,channels:write,groups:write" ] assert query["code_challenge_method"] == ["S256"] assert "code_challenge" in query diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index b038b6f5..48330de4 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -25,6 +25,10 @@ def test_slack_plugin_exposes_public_steps() -> None: "list_users", "select_user_target", "select_channel_target", + "select_target", + "ensure_target_conversation", + "read_recent_messages", + "ai_summarize_messages", "open_direct_message", "prompt_message_body", "post_message", diff --git a/plugins/titan-plugin-slack/tests/test_summary_steps.py b/plugins/titan-plugin-slack/tests/test_summary_steps.py new file mode 100644 index 00000000..d2229975 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_summary_steps.py @@ -0,0 +1,125 @@ +from unittest.mock import MagicMock + +from titan_cli.core.result import ClientSuccess +from titan_cli.engine import Error, Skip, Success +from titan_cli.engine.context import WorkflowContext +from titan_plugin_slack.models import UISlackConversation, UISlackMessage, UISlackTarget +from titan_plugin_slack.steps.summary_steps import ( + ai_summarize_messages_step, + ensure_target_conversation_step, + read_recent_messages_step, + select_target_step, +) + + +def _build_context() -> WorkflowContext: + ctx = WorkflowContext(secrets=MagicMock()) + ctx.textual = MagicMock() + + loading_mock = MagicMock() + loading_mock.__enter__ = MagicMock(return_value=loading_mock) + loading_mock.__exit__ = MagicMock(return_value=None) + ctx.textual.loading = MagicMock(return_value=loading_mock) + return ctx + + +def test_select_target_returns_error_for_short_query() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.textual.ask_text.return_value = "g" + + result = select_target_step(ctx) + + assert isinstance(result, Error) + assert result.message == "Enter at least 2 characters to search Slack targets." + + +def test_select_target_returns_selected_target_metadata() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.textual.ask_text.return_value = "gabriel" + user_target = UISlackTarget( + target_type="user", + target_id="U123", + target_name="Gabriel Garcia Lopez", + ) + ctx.slack.search_users.return_value = ClientSuccess( + data=[MagicMock(id="U123", name="gabriel", real_name="Gabriel Garcia Lopez")] + ) + ctx.slack.search_channels.return_value = ClientSuccess(data=[]) + ctx.textual.ask_option.return_value = user_target + + result = select_target_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_target"] == user_target + assert result.metadata["slack_target_type"] == "user" + + +def test_ensure_target_conversation_uses_channel_target_directly() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_target"] = UISlackTarget( + target_type="channel", + target_id="C123", + target_name="general", + ) + + result = ensure_target_conversation_step(ctx) + + assert isinstance(result, Success) + conversation = result.metadata["slack_conversation"] + assert isinstance(conversation, UISlackConversation) + assert conversation.id == "C123" + + +def test_read_recent_messages_returns_messages() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_conversation_id"] = "C123" + ctx.slack.read_conversation.return_value = ClientSuccess( + data=([ + UISlackMessage(ts="1", text="Hello", user="U123"), + ], None, False) + ) + + result = read_recent_messages_step(ctx) + + assert isinstance(result, Success) + assert len(result.metadata["slack_messages"]) == 1 + + +def test_ai_summarize_messages_skips_without_ai() -> None: + ctx = _build_context() + ctx.data["slack_messages"] = [UISlackMessage(ts="1", text="Hello", user="U123")] + + result = ai_summarize_messages_step(ctx) + + assert isinstance(result, Skip) + + +def test_ai_summarize_messages_returns_summary() -> None: + ctx = _build_context() + ctx.ai = MagicMock() + ctx.ai.is_available.return_value = True + ctx.ai.generate.return_value = MagicMock(content="Summary text") + ctx.data["slack_messages"] = [UISlackMessage(ts="1", text="Hello", user="U123")] + ctx.data["slack_target_name"] = "general" + + result = ai_summarize_messages_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_summary"] == "Summary text" + + +def test_ai_summarize_messages_returns_error_for_empty_summary() -> None: + ctx = _build_context() + ctx.ai = MagicMock() + ctx.ai.is_available.return_value = True + ctx.ai.generate.return_value = MagicMock(content=" ") + ctx.data["slack_messages"] = [UISlackMessage(ts="1", text="Hello", user="U123")] + + result = ai_summarize_messages_step(ctx) + + assert isinstance(result, Error) + assert result.message == "AI returned an empty Slack summary." diff --git a/plugins/titan-plugin-slack/tests/test_summary_workflow.py b/plugins/titan-plugin-slack/tests/test_summary_workflow.py new file mode 100644 index 00000000..1f72f6cc --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_summary_workflow.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import yaml + + +def test_summarize_slack_target_workflow_structure() -> None: + workflow_path = ( + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "summarize-slack-target.yaml" + ) + + with open(workflow_path, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + assert workflow["name"] == "Summarize Slack Target" + assert workflow["params"]["slack_history_limit"] == 50 + assert [step["id"] for step in workflow["steps"]] == [ + "validate_connection", + "select_target", + "ensure_target_conversation", + "read_recent_messages", + "ai_summarize_messages", + ] diff --git a/plugins/titan-plugin-slack/tests/test_target_steps.py b/plugins/titan-plugin-slack/tests/test_target_steps.py index 9c2aa9c4..70489943 100644 --- a/plugins/titan-plugin-slack/tests/test_target_steps.py +++ b/plugins/titan-plugin-slack/tests/test_target_steps.py @@ -68,7 +68,7 @@ def test_select_channel_target_returns_target_metadata() -> None: ctx.slack = MagicMock() ctx.textual.ask_text.return_value = "eng" channel = UISlackChannel(id="C2", name="eng-backend") - ctx.slack.search_public_channels.return_value = ClientSuccess(data=[channel]) + ctx.slack.search_channels.return_value = ClientSuccess(data=[channel]) ctx.textual.ask_option.return_value = channel result = select_channel_target_step(ctx) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py index c8a445e2..dab618ea 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py @@ -240,3 +240,77 @@ def search_public_channels( data=matches, message=f"Found {len(matches)} Slack channels for query", ) + + def search_channels( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 50, + exclude_archived: bool = True, + ) -> ClientResult[list[UISlackChannel]]: + """Search accessible public and private Slack channels by paging and filtering locally.""" + cursor: str | None = None + scanned_pages = 0 + collected: list[UISlackChannel] = [] + seen_ids: set[str] = set() + + while scanned_pages < max_pages: + try: + response = self.web_client.conversations_list( + limit=page_size, + cursor=cursor, + exclude_archived=exclude_archived, + types="public_channel,private_channel", + ) + except SlackApiError as exc: + return self._build_api_error( + exc, + "search_channels", + "SEARCH_CHANNELS_ERROR", + ) + except Exception as exc: + if hasattr(exc, "response"): + return self._build_api_error( + exc, + "search_channels", + "SEARCH_CHANNELS_ERROR", + ) + return ClientError( + error_message=f"Slack channel search request failed: {exc}", + error_code="SEARCH_CHANNELS_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return ClientError( + error_message=( + f"Slack search_channels failed: {response.get('error', 'unknown_error')}" + ), + error_code="SEARCH_CHANNELS_ERROR", + ) + + channels = [self._map_channel(channel) for channel in response.get("channels", [])] + ui_channels = [self._to_ui_channel(channel) for channel in channels] + + for channel in ui_channels: + if channel.id not in seen_ids: + seen_ids.add(channel.id) + collected.append(channel) + + matches = filter_channels_for_query(collected, query, limit=max_matches) + next_cursor = response.get("response_metadata", {}).get("next_cursor") or None + if len(matches) >= max_matches or not next_cursor: + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack channels for query", + ) + + cursor = next_cursor + scanned_pages += 1 + + matches = filter_channels_for_query(collected, query, limit=max_matches) + return ClientSuccess( + data=matches, + message=f"Found {len(matches)} Slack channels for query", + ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 0b4c19b2..3d74b1ab 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -106,6 +106,24 @@ def search_public_channels( exclude_archived=exclude_archived, ) + def search_channels( + self, + query: str, + *, + max_matches: int = 20, + page_size: int = 200, + max_pages: int = 50, + exclude_archived: bool = True, + ) -> ClientResult[list[UISlackChannel]]: + """Search accessible public and private Slack channels.""" + return self.directory_service.search_channels( + query, + max_matches=max_matches, + page_size=page_size, + max_pages=max_pages, + exclude_archived=exclude_archived, + ) + def read_channel( self, channel_id: str, @@ -125,6 +143,25 @@ def read_channel( inclusive=inclusive, ) + def read_conversation( + self, + conversation_id: str, + limit: int = 20, + cursor: str | None = None, + oldest: str | None = None, + latest: str | None = None, + inclusive: bool = False, + ) -> ClientResult[tuple[list[UISlackMessage], str | None, bool]]: + """Read message history from any Slack conversation ID.""" + return self.conversation_service.read_conversation( + conversation_id=conversation_id, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + inclusive=inclusive, + ) + def open_direct_message(self, user_id: str) -> ClientResult[UISlackConversation]: """Open or reuse a direct message conversation with a Slack user.""" return self.conversation_service.open_direct_message(user_id) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index a63ba54f..0fcf09e0 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -23,6 +23,10 @@ "users:read", "channels:read", "channels:history", + "groups:read", + "groups:history", + "im:history", + "mpim:history", "chat:write", "im:write", "mpim:write", diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py index 84bcfd5b..7197943c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py @@ -7,6 +7,11 @@ filter_users_for_query, normalize_search_query, ) +from .message_summary_operations import ( + build_summary_prompt, + format_messages_as_transcript, + truncate_transcript_for_summary, +) __all__ = [ "normalize_search_query", @@ -14,4 +19,7 @@ "filter_channels_for_query", "build_user_target", "build_channel_target", + "format_messages_as_transcript", + "truncate_transcript_for_summary", + "build_summary_prompt", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py new file mode 100644 index 00000000..f49b8b97 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py @@ -0,0 +1,62 @@ +"""Pure operations for formatting Slack messages for AI summaries.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from ..models import UISlackMessage + + +def format_messages_as_transcript( + messages: list[UISlackMessage], + *, + target_name: str | None = None, +) -> str: + """Format Slack messages as a compact transcript for downstream AI steps.""" + lines: list[str] = [] + if target_name: + lines.append(f"Target: {target_name}") + lines.append("") + + for message in messages: + lines.append( + f"[{_format_slack_timestamp(message.ts)}] {message.user or 'Unknown'}: {message.text.strip()}" + ) + return "\n".join(lines).strip() + + +def truncate_transcript_for_summary(transcript: str, max_chars: int = 12000) -> str: + """Truncate a transcript conservatively before sending it to AI.""" + if len(transcript) <= max_chars: + return transcript + marker = "[Transcript truncated]" + if max_chars <= len(marker): + return marker[:max_chars] + prefix = transcript[: max_chars - len(marker) - 2].rstrip() + return f"{prefix}\n\n{marker}" + + +def build_summary_prompt(target_name: str | None, transcript: str) -> str: + """Build a reusable Slack summary prompt from transcript content.""" + target_label = target_name or "the selected Slack conversation" + return ( + f"Summarize the latest activity in {target_label}.\n\n" + "Focus on:\n" + "1. Main topics or decisions\n" + "2. Action items and owners when visible\n" + "3. Open questions or blockers\n" + "4. Any notable links, incidents, or follow-up context\n\n" + "Keep the summary concise but useful for someone who did not read the thread.\n\n" + "Transcript:\n" + f"{transcript}" + ) + + +def _format_slack_timestamp(ts: str) -> str: + """Render a Slack timestamp into a stable UTC label for transcript output.""" + try: + timestamp = float(ts) + except (TypeError, ValueError): + return ts or "unknown-ts" + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index eb9196b8..90fd7aa0 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -78,11 +78,15 @@ def get_client(self) -> SlackClient: def get_steps(self) -> dict: """Return public workflow steps for the plugin.""" from .steps import ( + ai_summarize_messages_step, + ensure_target_conversation_step, list_public_channels_step, list_users_step, open_direct_message_step, post_message_step, prompt_message_body_step, + read_recent_messages_step, + select_target_step, select_channel_target_step, select_user_target_step, validate_connection_step, @@ -94,6 +98,10 @@ def get_steps(self) -> dict: "list_users": list_users_step, "select_user_target": select_user_target_step, "select_channel_target": select_channel_target_step, + "select_target": select_target_step, + "ensure_target_conversation": ensure_target_conversation_step, + "read_recent_messages": read_recent_messages_step, + "ai_summarize_messages": ai_summarize_messages_step, "open_direct_message": open_direct_message_step, "prompt_message_body": prompt_message_body_step, "post_message": post_message_step, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index b70a3a7d..a9035497 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -10,6 +10,12 @@ post_message_step, prompt_message_body_step, ) +from .summary_steps import ( + ai_summarize_messages_step, + ensure_target_conversation_step, + read_recent_messages_step, + select_target_step, +) from .target_steps import select_channel_target_step, select_user_target_step __all__ = [ @@ -19,6 +25,10 @@ "open_direct_message_step", "prompt_message_body_step", "post_message_step", + "select_target_step", + "ensure_target_conversation_step", + "read_recent_messages_step", + "ai_summarize_messages_step", "select_user_target_step", "select_channel_target_step", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py new file mode 100644 index 00000000..71bf485c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py @@ -0,0 +1,377 @@ +"""Slack target resolution and AI summary steps.""" + +from titan_cli.ai.models import AIMessage +from titan_cli.core.logging import get_logger +from titan_cli.core.result import ClientError, ClientSuccess +from titan_cli.ui.tui.widgets import OptionItem + +from titan_cli.engine import Error, Skip, Success, WorkflowContext, WorkflowResult +from ..models import UISlackConversation, UISlackTarget +from ..operations import ( + build_summary_prompt, + format_messages_as_transcript, + truncate_transcript_for_summary, +) + + +logger = get_logger(__name__) + + +MAX_COMBINED_TARGET_OPTIONS = 20 + + +def select_target_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Search both Slack users and channels for a single unified target selection. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target_query (str, optional): Query used to search both users and channels. + slack_search_limit (int, optional): Maximum number of matches to keep from each search. Defaults to 10. + slack_search_page_size (int, optional): Page size used while scanning Slack. Defaults to 200. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50. + slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True. + + Outputs (saved to ctx.data): + slack_target (UISlackTarget): Canonical selected Slack target. + slack_target_type (str): Selected target type (`user` or `channel`). + slack_target_id (str): Slack target identifier. + slack_target_name (str): User-facing target name. + slack_target_query (str): Query used to resolve the selection. + + Returns: + Success: If the unified target is selected successfully. + Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Select Slack Target") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + raw_query = ctx.get("slack_target_query") or ctx.textual.ask_text( + "Search Slack people or channels:", default="" + ) + if not raw_query or len(raw_query.strip()) < 2: + message = "Enter at least 2 characters to search Slack targets." + ctx.textual.error_text(message) + ctx.textual.end_step("error") + return Error(message) + + search_limit = ctx.get("slack_search_limit", 10) + page_size = ctx.get("slack_search_page_size", 200) + max_pages = ctx.get("slack_search_max_pages", 50) + exclude_archived = ctx.get("slack_exclude_archived", True) + + with ctx.textual.loading("Searching Slack users and channels..."): + users_result = ctx.slack.search_users( + raw_query, + max_matches=search_limit, + page_size=page_size, + max_pages=max_pages, + ) + channels_result = ctx.slack.search_channels( + raw_query, + max_matches=search_limit, + page_size=page_size, + max_pages=max_pages, + exclude_archived=exclude_archived, + ) + + match users_result: + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + case ClientSuccess(data=users): + pass + + match channels_result: + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + case ClientSuccess(data=channels): + pass + + options = [] + for user in users: + display_name = user.real_name or user.name or user.id + options.append( + OptionItem( + value=UISlackTarget( + target_type="user", + target_id=user.id, + target_name=display_name, + team_id=ctx.get("slack_team_id"), + connection_id=ctx.get("slack_connection_id"), + ), + title=display_name, + description=f"Person @ {user.name} ({user.id})", + ) + ) + for channel in channels: + options.append( + OptionItem( + value=UISlackTarget( + target_type="channel", + target_id=channel.id, + target_name=channel.name, + team_id=ctx.get("slack_team_id"), + connection_id=ctx.get("slack_connection_id"), + ), + title=f"#{channel.name}", + description=f"Channel ({channel.id})", + ) + ) + + if not options: + message = "No Slack users or channels matched that query." + ctx.textual.error_text(message) + ctx.textual.end_step("error") + return Error(message) + + selected = ctx.textual.ask_option( + "Select the Slack target:", + options=options[:MAX_COMBINED_TARGET_OPTIONS], + ) + if not selected: + message = "No Slack target was selected." + ctx.textual.error_text(message) + ctx.textual.end_step("error") + return Error(message) + + ctx.textual.success_text( + f"Selected Slack {selected.target_type} target: {selected.target_name} ({selected.target_id})" + ) + ctx.textual.end_step("success") + return Success( + f"Selected Slack {selected.target_type} target", + metadata={ + "slack_target": selected, + "slack_target_type": selected.target_type, + "slack_target_id": selected.target_id, + "slack_target_name": selected.target_name, + "slack_target_query": raw_query, + }, + ) + + +def ensure_target_conversation_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Resolve a Slack conversation from the selected target. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target (UISlackTarget): Selected Slack target. + + Outputs (saved to ctx.data): + slack_conversation (UISlackConversation): Resolved Slack conversation. + slack_conversation_id (str): Conversation ID used for later operations. + + Returns: + Success: If the target conversation is resolved successfully. + Error: If Slack is unavailable, the target is missing, or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Resolve Slack Conversation") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + target = ctx.get("slack_target") + if not target: + ctx.textual.error_text("Slack target not found in context") + ctx.textual.end_step("error") + return Error("Slack target not found in context") + + if target.target_type == "user": + with ctx.textual.loading("Opening Slack direct message..."): + result = ctx.slack.open_direct_message(target.target_id) + match result: + case ClientSuccess(data=conversation): + pass + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + else: + conversation = UISlackConversation( + id=target.target_id, + is_im=False, + team_id=target.team_id, + ) + + ctx.textual.success_text( + f"Slack conversation ready: {conversation.id} for {target.target_name}" + ) + ctx.textual.end_step("success") + return Success( + "Slack conversation ready", + metadata={ + "slack_conversation": conversation, + "slack_conversation_id": conversation.id, + }, + ) + + +def read_recent_messages_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Read the most recent messages from the resolved Slack conversation. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_conversation_id (str): Slack conversation ID to read. + slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 50. + + Outputs (saved to ctx.data): + slack_messages (list[UISlackMessage]): Retrieved Slack messages. + slack_messages_next_cursor (str | None): Pagination cursor for later reads. + slack_messages_has_more (bool): Whether more messages are available. + + Returns: + Success: If recent messages are retrieved successfully. + Error: If Slack is unavailable, required context is missing, or the Slack request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Read Recent Slack Messages") + + if not ctx.slack: + ctx.textual.error_text("Slack client not available") + ctx.textual.end_step("error") + return Error("Slack client not available") + + conversation_id = ctx.get("slack_conversation_id") + if not conversation_id: + ctx.textual.error_text("Slack conversation ID not found in context") + ctx.textual.end_step("error") + return Error("Slack conversation ID not found in context") + + limit = ctx.get("slack_history_limit", 50) + + with ctx.textual.loading("Reading recent Slack messages..."): + result = ctx.slack.read_conversation(conversation_id, limit=limit) + + match result: + case ClientSuccess(data=(messages, next_cursor, has_more)): + ctx.textual.success_text(f"Retrieved {len(messages)} Slack messages") + ctx.textual.end_step("success") + return Success( + f"Retrieved {len(messages)} Slack messages", + metadata={ + "slack_messages": messages, + "slack_messages_next_cursor": next_cursor, + "slack_messages_has_more": has_more, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + +def ai_summarize_messages_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Summarize recent Slack messages with AI. + + Requires: + ctx.textual: Textual UI context. + + Inputs (from ctx.data): + slack_messages (list[UISlackMessage]): Messages to summarize. + slack_target_name (str, optional): Human-facing target label for the summary. + slack_summary_max_chars (int, optional): Maximum transcript size passed to AI. Defaults to 12000. + + Outputs (saved to ctx.data): + slack_summary (str): AI-generated Slack summary. + slack_summary_source_count (int): Number of source messages summarized. + slack_summary_transcript_chars (int): Transcript size sent to AI after truncation. + + Returns: + Success: If the summary is generated successfully. + Skip: If AI is not configured or not available. + Error: If messages are missing or the AI request fails. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Summarize Slack Messages") + + if not ctx.ai or not ctx.ai.is_available(): + ctx.textual.dim_text("AI not configured - skipping Slack summary.") + ctx.textual.end_step("skip") + return Skip("AI not configured - skipping Slack summary.") + + messages = ctx.get("slack_messages") + if not messages: + ctx.textual.error_text("Slack messages not found in context") + ctx.textual.end_step("error") + return Error("Slack messages not found in context") + + target_name = ctx.get("slack_target_name") + max_chars = ctx.get("slack_summary_max_chars", 12000) + transcript = format_messages_as_transcript(messages, target_name=target_name) + transcript = truncate_transcript_for_summary(transcript, max_chars=max_chars) + prompt = build_summary_prompt(target_name, transcript) + + with ctx.textual.loading("Summarizing Slack messages with AI..."): + response = ctx.ai.generate( + [AIMessage(role="user", content=prompt)], + max_tokens=1024, + temperature=0.3, + ) + + summary = response.content.strip() + logger.info( + "slack_summary_ai_response_received", + target_name=target_name, + source_count=len(messages), + transcript_chars=len(transcript), + response_chars=len(response.content or ""), + summary_chars=len(summary), + ) + if not summary: + logger.warning( + "slack_summary_ai_response_empty", + target_name=target_name, + source_count=len(messages), + transcript_chars=len(transcript), + ) + ctx.textual.error_text("AI returned an empty Slack summary.") + ctx.textual.end_step("error") + return Error("AI returned an empty Slack summary.") + + ctx.textual.markdown(summary) + ctx.textual.end_step("success") + return Success( + "Slack summary generated", + metadata={ + "slack_summary": summary, + "slack_summary_source_count": len(messages), + "slack_summary_transcript_chars": len(transcript), + }, + ) + + +__all__ = [ + "select_target_step", + "ensure_target_conversation_step", + "read_recent_messages_step", + "ai_summarize_messages_step", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py index 7764f931..6927b110 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py @@ -95,7 +95,7 @@ def select_channel_target_step(ctx: WorkflowContext) -> WorkflowResult: short_query_error=f"Enter at least {MIN_QUERY_LENGTH} characters to search Slack channels.", no_match_error="No Slack channels matched that query.", options_prompt="Select the Slack channel target:", - search_func=lambda query, limit, page_size, max_pages, exclude_archived: ctx.slack.search_public_channels( + search_func=lambda query, limit, page_size, max_pages, exclude_archived: ctx.slack.search_channels( query, max_matches=limit, page_size=page_size, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml new file mode 100644 index 00000000..1e346c53 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml @@ -0,0 +1,33 @@ +name: "Summarize Slack Target" +description: "Search for a person or channel, read recent Slack messages, and summarize them with AI" + +params: + slack_history_limit: 50 + +steps: + - id: validate_connection + name: "Validate Slack Connection" + plugin: slack + step: validate_connection + + - id: select_target + name: "Select Slack Target" + plugin: slack + step: select_target + + - id: ensure_target_conversation + name: "Resolve Target Conversation" + plugin: slack + step: ensure_target_conversation + + - id: read_recent_messages + name: "Read Recent Messages" + plugin: slack + step: read_recent_messages + params: + slack_history_limit: "${slack_history_limit}" + + - id: ai_summarize_messages + name: "Summarize Messages" + plugin: slack + step: ai_summarize_messages diff --git a/tests/ai/providers/test_litellm_provider.py b/tests/ai/providers/test_litellm_provider.py index 826fbc49..b1a700a7 100644 --- a/tests/ai/providers/test_litellm_provider.py +++ b/tests/ai/providers/test_litellm_provider.py @@ -195,6 +195,43 @@ def test_generate_includes_optional_params_when_provided(self, mock_litellm_clie stream=False, ) + @patch("titan_cli.ai.providers.litellm.LiteLLMClient") + def test_generate_extracts_content_from_structured_message_payload(self, mock_litellm_client): + """Test gateways that return structured content instead of plain message.content.""" + mock_client = Mock() + mock_litellm_client.return_value = self._make_gateway_client(client=mock_client) + + message = Mock() + message.content = "" + message.model_dump.return_value = { + "content": [ + {"type": "text", "text": "Structured response"}, + ] + } + + choice = Mock() + choice.message = message + choice.finish_reason = "stop" + choice.model_dump.return_value = {"message": message.model_dump.return_value} + + response = Mock() + response.choices = [choice] + response.model = "gpt-3.5-turbo" + response.usage = None + + mock_client.chat.completions.create.return_value = response + + provider = LiteLLMProvider( + base_url="http://localhost:4000", + model="gpt-3.5-turbo", + ) + + request = AIRequest(messages=[AIMessage(role="user", content="Hello")]) + + result = provider.generate(request) + + assert result.content == "Structured response" + @patch("titan_cli.ai.providers.litellm.LiteLLMClient") def test_generate_authentication_error(self, mock_litellm_client): """Test handling of authentication errors.""" diff --git a/titan_cli/ai/providers/litellm.py b/titan_cli/ai/providers/litellm.py index a64ac6eb..509ee413 100644 --- a/titan_cli/ai/providers/litellm.py +++ b/titan_cli/ai/providers/litellm.py @@ -126,7 +126,7 @@ def generate(self, request: AIRequest) -> AIResponse: response = self._client.chat.completions.create(**request_kwargs) choice = response.choices[0] usage = response.usage - content = choice.message.content or "" + content = self._extract_choice_content(choice) response_model = response.model or self._model finish_reason = choice.finish_reason or "stop" @@ -162,6 +162,66 @@ def generate(self, request: AIRequest) -> AIResponse: f"LiteLLM provider error: {str(e)}" ) + @classmethod + def _extract_choice_content(cls, choice) -> str: + """Extract text content robustly from OpenAI-compatible response choices.""" + message = getattr(choice, "message", None) + direct_content = getattr(message, "content", None) + text = cls._coerce_content_to_text(direct_content) + if text: + return text + + if message is not None and hasattr(message, "model_dump"): + text = cls._extract_text_from_mapping(message.model_dump()) + if text: + return text + + if hasattr(choice, "model_dump"): + text = cls._extract_text_from_mapping(choice.model_dump()) + if text: + return text + + return "" + + @classmethod + def _extract_text_from_mapping(cls, data) -> str: + if not isinstance(data, dict): + return "" + + for key in ("content", "text", "output_text", "reasoning_content"): + text = cls._coerce_content_to_text(data.get(key)) + if text: + return text + + message = data.get("message") + if isinstance(message, dict): + text = cls._extract_text_from_mapping(message) + if text: + return text + + return "" + + @classmethod + def _coerce_content_to_text(cls, content) -> str: + if content is None: + return "" + + if isinstance(content, str): + return content.strip() + + if isinstance(content, dict): + if "text" in content and isinstance(content["text"], str): + return content["text"].strip() + if "content" in content: + return cls._coerce_content_to_text(content["content"]) + return "" + + if isinstance(content, (list, tuple)): + parts = [cls._coerce_content_to_text(item) for item in content] + return "\n".join(part for part in parts if part).strip() + + return "" + def validate_api_key(self, api_key: Optional[str] = None) -> bool: """ Validate API key by making a minimal test request. From 8cad721c79cb636f2b5355b08119146a3ce42d03 Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 16 Jun 2026 10:34:37 +0200 Subject: [PATCH 15/23] feat: Add send-slack-channel-message workflow with unified prepare_message_destination step --- .../_generated/slack-step-inventory.json | 49 ++++++-- docs/plugins/_meta/slack-step-groups.json | 1 + docs/plugins/slack/built-in-workflows.md | 28 ++++- docs/plugins/slack/workflow-steps.md | 16 +-- .../tests/test_dm_workflow.py | 2 +- .../tests/test_message_steps.py | 19 ++++ .../titan-plugin-slack/tests/test_plugin.py | 1 + .../tests/test_workflows.py | 37 ++++++ .../operations/message_summary_operations.py | 28 +++-- .../titan_plugin_slack/plugin.py | 2 + .../titan_plugin_slack/steps/__init__.py | 2 + .../titan_plugin_slack/steps/message_steps.py | 107 +++++++++++++----- .../workflows/send-slack-channel-message.yaml | 28 +++++ .../workflows/send-slack-direct-message.yaml | 6 +- titan_cli/ai/providers/litellm.py | 49 ++++++-- 15 files changed, 312 insertions(+), 63 deletions(-) create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 412aa6f9..849d32fb 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -34,6 +34,10 @@ { "name": "Messaging", "steps": [ + { + "name": "prepare_message_destination", + "summary": "Resolve the selected Slack user or channel target into the destination conversation used for posting." + }, { "name": "open_direct_message", "summary": "Open or reuse a direct message conversation for the selected user target." @@ -178,7 +182,9 @@ " Error: If Slack is unavailable, no users are available, the query is invalid, or no match is selected." ] }, - "used_by_workflows": [] + "used_by_workflows": [ + "send-slack-direct-message" + ] }, { "name": "select_channel_target", @@ -206,7 +212,36 @@ " Error: If Slack is unavailable, no channels are available, the query is invalid, or no match is selected." ] }, - "used_by_workflows": [] + "used_by_workflows": [ + "send-slack-channel-message" + ] + }, + { + "name": "prepare_message_destination", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "prepare_message_destination_step", + "summary": "Prepare a Slack message destination from the selected target.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_target (UISlackTarget): Selected Slack target. Must be a `user` or `channel` target." + ], + "Outputs (saved to ctx.data)": [ + " slack_conversation (UISlackConversation): Resolved Slack destination conversation.", + " slack_conversation_id (str): Conversation or channel ID used for later message operations." + ], + "Returns": [ + " Success: If the Slack message destination is ready.", + " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." + ] + }, + "used_by_workflows": [ + "send-slack-direct-message", + "send-slack-channel-message" + ] }, { "name": "open_direct_message", @@ -230,9 +265,7 @@ " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." ] }, - "used_by_workflows": [ - "send-slack-direct-message" - ] + "used_by_workflows": [] }, { "name": "prompt_message_body", @@ -255,7 +288,8 @@ ] }, "used_by_workflows": [ - "send-slack-direct-message" + "send-slack-direct-message", + "send-slack-channel-message" ] }, { @@ -284,7 +318,8 @@ ] }, "used_by_workflows": [ - "send-slack-direct-message" + "send-slack-direct-message", + "send-slack-channel-message" ] }, { diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json index b1719f29..aa31b37f 100644 --- a/docs/plugins/_meta/slack-step-groups.json +++ b/docs/plugins/_meta/slack-step-groups.json @@ -19,6 +19,7 @@ { "name": "Messaging", "steps": [ + {"name": "prepare_message_destination", "summary": "Resolve the selected Slack user or channel target into the destination conversation used for posting."}, {"name": "open_direct_message", "summary": "Open or reuse a direct message conversation for the selected user target."}, {"name": "prompt_message_body", "summary": "Capture a multiline Slack message body for later posting."}, {"name": "post_message", "summary": "Post the prepared message to the selected Slack conversation."} diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index 30b13af7..411f7d39 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -1,6 +1,6 @@ # Slack Built-in Workflows -The Slack plugin currently ships one small built-in workflow for connection validation and read-only discovery. +The Slack plugin currently ships a small set of built-in workflows for workspace discovery, direct messaging, channel messaging, and conversation summaries. ## `discover-slack-workspace` @@ -36,7 +36,7 @@ Select a person, open or reuse a direct message conversation, compose a message, 1. `slack.validate_connection` 2. `slack.select_user_target` -3. `slack.open_direct_message` +3. `slack.prepare_message_destination` 4. `slack.prompt_message_body` 5. `slack.post_message` @@ -50,6 +50,30 @@ Select a person, open or reuse a direct message conversation, compose a message, - this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline - it still assumes one active personal Slack connection per user +## `send-slack-channel-message` + +Select a channel, prepare the destination from the selected target, compose a message, and send it. + +**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml` + +### Default flow + +1. `slack.validate_connection` +2. `slack.select_channel_target` +3. `slack.prepare_message_destination` +4. `slack.prompt_message_body` +5. `slack.post_message` + +### Typical usage + +- send a message to one selected Slack channel from Titan +- validate that channel-posting scopes and the shared messaging path are working end to end + +### Scope constraints + +- this workflow depends on channel-posting Slack scopes beyond the earlier DM and discovery slices +- it still assumes one active personal Slack connection per user + ## `summarize-slack-target` Search for a person or channel, resolve the backing conversation, read recent Slack messages, and summarize them with AI. diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index 3148dcf8..9394b3d6 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -1,6 +1,6 @@ # Slack Workflow Steps -The Slack plugin exposes public reusable workflow steps through `SlackPlugin.get_steps()`. The first Slack step surface is intentionally small and focused on connection validation plus read-only discovery. +The Slack plugin exposes public reusable workflow steps through `SlackPlugin.get_steps()`. The current step surface stays intentionally small, but now covers connection validation, target selection, messaging, and conversation summaries. For full contract details for every public step, including documented inputs, outputs, and return behavior, see the [detailed step reference](../generated/slack-step-reference.md). @@ -18,11 +18,12 @@ For full contract details for every public step, including documented inputs, ou | `validate_connection` | Validation and Discovery | `discover-slack-workspace` | | `list_public_channels` | Validation and Discovery | `discover-slack-workspace` | | `list_users` | Validation and Discovery | `discover-slack-workspace` | -| `select_user_target` | Selection and Target Resolution | - | -| `select_channel_target` | Selection and Target Resolution | - | -| `open_direct_message` | Messaging | `send-slack-direct-message` | -| `prompt_message_body` | Messaging | `send-slack-direct-message` | -| `post_message` | Messaging | `send-slack-direct-message` | +| `select_user_target` | Selection and Target Resolution | `send-slack-direct-message` | +| `select_channel_target` | Selection and Target Resolution | `send-slack-channel-message` | +| `prepare_message_destination` | Messaging | `send-slack-direct-message`, `send-slack-channel-message` | +| `open_direct_message` | Messaging | - | +| `prompt_message_body` | Messaging | `send-slack-direct-message`, `send-slack-channel-message` | +| `post_message` | Messaging | `send-slack-direct-message`, `send-slack-channel-message` | | `select_target` | Conversation Summaries | `summarize-slack-target` | | `ensure_target_conversation` | Conversation Summaries | `summarize-slack-target` | | `read_recent_messages` | Conversation Summaries | `summarize-slack-target` | @@ -45,8 +46,9 @@ Use these steps to resolve a reusable Slack target object for later workflows. ## Messaging -Use these steps to open a direct message conversation and post a plain-text Slack message. +Use these steps to resolve a message destination and post a plain-text Slack message. +- `prepare_message_destination`: resolve the selected user or channel target into the destination conversation used for posting - `open_direct_message`: open or reuse a direct message conversation for the selected user target - `prompt_message_body`: capture a multiline Slack message body for later posting - `post_message`: post the prepared message to the selected conversation diff --git a/plugins/titan-plugin-slack/tests/test_dm_workflow.py b/plugins/titan-plugin-slack/tests/test_dm_workflow.py index c9810ed7..77b9ab9c 100644 --- a/plugins/titan-plugin-slack/tests/test_dm_workflow.py +++ b/plugins/titan-plugin-slack/tests/test_dm_workflow.py @@ -16,7 +16,7 @@ def test_send_slack_direct_message_workflow_structure() -> None: assert step_ids == [ "validate_connection", "select_user_target", - "open_direct_message", + "prepare_message_destination", "prompt_message_body", "post_message", ] diff --git a/plugins/titan-plugin-slack/tests/test_message_steps.py b/plugins/titan-plugin-slack/tests/test_message_steps.py index c5c56320..995245ef 100644 --- a/plugins/titan-plugin-slack/tests/test_message_steps.py +++ b/plugins/titan-plugin-slack/tests/test_message_steps.py @@ -6,6 +6,7 @@ from titan_plugin_slack.models import UISlackConversation, UISlackPostedMessage, UISlackTarget from titan_plugin_slack.steps.message_steps import ( open_direct_message_step, + prepare_message_destination_step, post_message_step, prompt_message_body_step, ) @@ -55,6 +56,24 @@ def test_open_direct_message_step_returns_conversation_metadata() -> None: assert result.metadata["slack_conversation_id"] == "D123" +def test_prepare_message_destination_step_uses_channel_target_directly() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.data["slack_target"] = UISlackTarget( + target_type="channel", + target_id="C123", + target_name="general", + team_id="T1", + ) + + result = prepare_message_destination_step(ctx) + + assert isinstance(result, Success) + conversation = result.metadata["slack_conversation"] + assert conversation.id == "C123" + assert conversation.is_im is False + + def test_prompt_message_body_step_skips_when_preset_exists() -> None: ctx = _build_context() ctx.data["slack_message_text"] = "Hello" diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 48330de4..0ae1aa5e 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -26,6 +26,7 @@ def test_slack_plugin_exposes_public_steps() -> None: "select_user_target", "select_channel_target", "select_target", + "prepare_message_destination", "ensure_target_conversation", "read_recent_messages", "ai_summarize_messages", diff --git a/plugins/titan-plugin-slack/tests/test_workflows.py b/plugins/titan-plugin-slack/tests/test_workflows.py index 939b7ecc..9138b528 100644 --- a/plugins/titan-plugin-slack/tests/test_workflows.py +++ b/plugins/titan-plugin-slack/tests/test_workflows.py @@ -26,3 +26,40 @@ def test_discover_slack_workspace_workflow_structure() -> None: assert steps[0]["step"] == "validate_connection" assert steps[1]["step"] == "list_public_channels" assert steps[2]["step"] == "list_users" + + +def test_send_slack_direct_message_workflow_structure() -> None: + workflow_path = ( + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-direct-message.yaml" + ) + + with open(workflow_path, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + assert workflow["name"] == "Send Slack Direct Message" + assert [step["id"] for step in workflow["steps"]] == [ + "validate_connection", + "select_user_target", + "prepare_message_destination", + "prompt_message_body", + "post_message", + ] + assert workflow["steps"][2]["step"] == "prepare_message_destination" + + +def test_send_slack_channel_message_workflow_structure() -> None: + workflow_path = ( + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-channel-message.yaml" + ) + + with open(workflow_path, encoding="utf-8") as handle: + workflow = yaml.safe_load(handle) + + assert workflow["name"] == "Send Slack Channel Message" + assert [step["id"] for step in workflow["steps"]] == [ + "validate_connection", + "select_channel_target", + "prepare_message_destination", + "prompt_message_body", + "post_message", + ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py index f49b8b97..b7dca4ef 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py @@ -40,13 +40,27 @@ def build_summary_prompt(target_name: str | None, transcript: str) -> str: """Build a reusable Slack summary prompt from transcript content.""" target_label = target_name or "the selected Slack conversation" return ( - f"Summarize the latest activity in {target_label}.\n\n" - "Focus on:\n" - "1. Main topics or decisions\n" - "2. Action items and owners when visible\n" - "3. Open questions or blockers\n" - "4. Any notable links, incidents, or follow-up context\n\n" - "Keep the summary concise but useful for someone who did not read the thread.\n\n" + f"You are summarizing recent Slack activity in {target_label}.\n\n" + "Write a concise, high-signal summary for someone who did not read the conversation. " + "Prioritize substance over chronology and ignore low-value chatter unless it changes the outcome.\n\n" + "Use exactly these sections and omit bullets only when there is truly nothing to report:\n" + "Main topics:\n" + "- 2 to 5 bullets covering the important discussion points or decisions\n\n" + "Action items:\n" + "- bullets in the form ': '\n" + "- if no owner is visible, start with 'Unassigned:'\n" + "- if there are no action items, write '- None'\n\n" + "Open questions or blockers:\n" + "- bullets for unresolved decisions, risks, or blockers\n" + "- if there are none, write '- None'\n\n" + "Notable context:\n" + "- optional bullets for incidents, deadlines, links, or follow-up context that materially matter\n" + "- if there is nothing notable, write '- None'\n\n" + "Style rules:\n" + "- Be specific and factual\n" + "- Do not invent owners, intent, or decisions\n" + "- Prefer short bullets over paragraphs\n" + "- Keep the whole answer compact\n\n" "Transcript:\n" f"{transcript}" ) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 90fd7aa0..fa8199de 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -83,6 +83,7 @@ def get_steps(self) -> dict: list_public_channels_step, list_users_step, open_direct_message_step, + prepare_message_destination_step, post_message_step, prompt_message_body_step, read_recent_messages_step, @@ -99,6 +100,7 @@ def get_steps(self) -> dict: "select_user_target": select_user_target_step, "select_channel_target": select_channel_target_step, "select_target": select_target_step, + "prepare_message_destination": prepare_message_destination_step, "ensure_target_conversation": ensure_target_conversation_step, "read_recent_messages": read_recent_messages_step, "ai_summarize_messages": ai_summarize_messages_step, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index a9035497..815a38bf 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -7,6 +7,7 @@ ) from .message_steps import ( open_direct_message_step, + prepare_message_destination_step, post_message_step, prompt_message_body_step, ) @@ -22,6 +23,7 @@ "validate_connection_step", "list_public_channels_step", "list_users_step", + "prepare_message_destination_step", "open_direct_message_step", "prompt_message_body_step", "post_message_step", diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py index c1d6798c..8971c9af 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py @@ -2,30 +2,31 @@ from titan_cli.core.result import ClientError, ClientSuccess from titan_cli.engine import Error, Skip, Success, WorkflowContext, WorkflowResult +from ..models import UISlackConversation -def open_direct_message_step(ctx: WorkflowContext) -> WorkflowResult: +def prepare_message_destination_step(ctx: WorkflowContext) -> WorkflowResult: """ - Open or reuse a direct message conversation for the selected Slack user target. + Prepare a Slack message destination from the selected target. Requires: ctx.slack: An initialized SlackClient. Inputs (from ctx.data): - slack_target (UISlackTarget): Selected Slack target. Must be a `user` target. + slack_target (UISlackTarget): Selected Slack target. Must be a `user` or `channel` target. Outputs (saved to ctx.data): - slack_conversation (UISlackConversation): Opened or reused Slack conversation. - slack_conversation_id (str): Conversation ID used for later message operations. + slack_conversation (UISlackConversation): Resolved Slack destination conversation. + slack_conversation_id (str): Conversation or channel ID used for later message operations. Returns: - Success: If the direct message conversation is ready. + Success: If the Slack message destination is ready. Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails. """ if not ctx.textual: return Error("Textual UI context is not available for this step.") - ctx.textual.begin_step("Open Slack Direct Message") + ctx.textual.begin_step("Prepare Slack Message Destination") if not ctx.slack: ctx.textual.error_text("Slack client not available") @@ -38,31 +39,78 @@ def open_direct_message_step(ctx: WorkflowContext) -> WorkflowResult: ctx.textual.end_step("error") return Error("Slack target not found in context") - if target.target_type != "user": - ctx.textual.error_text("Direct messages require a Slack user target") - ctx.textual.end_step("error") - return Error("Direct messages require a Slack user target") + if target.target_type == "user": + with ctx.textual.loading("Opening Slack direct message..."): + result = ctx.slack.open_direct_message(target.target_id) + + match result: + case ClientSuccess(data=conversation): + ctx.textual.success_text( + f"Slack direct message ready: {conversation.id} for {target.target_name}" + ) + ctx.textual.end_step("success") + return Success( + "Slack direct message ready", + metadata={ + "slack_conversation": conversation, + "slack_conversation_id": conversation.id, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + if target.target_type == "channel": + conversation = UISlackConversation( + id=target.target_id, + is_im=False, + team_id=target.team_id, + ) + ctx.textual.success_text( + f"Slack channel destination ready: {target.target_name} ({conversation.id})" + ) + ctx.textual.end_step("success") + return Success( + "Slack channel destination ready", + metadata={ + "slack_conversation": conversation, + "slack_conversation_id": conversation.id, + }, + ) - with ctx.textual.loading("Opening Slack direct message..."): - result = ctx.slack.open_direct_message(target.target_id) + ctx.textual.error_text("Slack message destinations require a user or channel target") + ctx.textual.end_step("error") + return Error("Slack message destinations require a user or channel target") - match result: - case ClientSuccess(data=conversation): - ctx.textual.success_text( - f"Slack direct message ready: {conversation.id} for {target.target_name}" - ) - ctx.textual.end_step("success") - return Success( - "Slack direct message ready", - metadata={ - "slack_conversation": conversation, - "slack_conversation_id": conversation.id, - }, - ) - case ClientError(error_message=err): - ctx.textual.error_text(err) + +def open_direct_message_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Open or reuse a direct message conversation for the selected Slack user target. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target (UISlackTarget): Selected Slack target. Must be a `user` target. + + Outputs (saved to ctx.data): + slack_conversation (UISlackConversation): Opened or reused Slack conversation. + slack_conversation_id (str): Conversation ID used for later message operations. + + Returns: + Success: If the direct message conversation is ready. + Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails. + """ + target = ctx.get("slack_target") + if target and target.target_type != "user": + if ctx.textual: + ctx.textual.begin_step("Open Slack Direct Message") + ctx.textual.error_text("Direct messages require a Slack user target") ctx.textual.end_step("error") - return Error(err) + return Error("Direct messages require a Slack user target") + + return prepare_message_destination_step(ctx) def prompt_message_body_step(ctx: WorkflowContext) -> WorkflowResult: @@ -187,6 +235,7 @@ def post_message_step(ctx: WorkflowContext) -> WorkflowResult: __all__ = [ + "prepare_message_destination_step", "open_direct_message_step", "prompt_message_body_step", "post_message_step", diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml new file mode 100644 index 00000000..2c4d2a17 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml @@ -0,0 +1,28 @@ +name: "Send Slack Channel Message" +description: "Select a channel, prepare the channel destination, compose a message, and send it" + +steps: + - id: validate_connection + name: "Validate Slack Connection" + plugin: slack + step: validate_connection + + - id: select_channel_target + name: "Select Slack Channel Target" + plugin: slack + step: select_channel_target + + - id: prepare_message_destination + name: "Prepare Message Destination" + plugin: slack + step: prepare_message_destination + + - id: prompt_message_body + name: "Compose Message" + plugin: slack + step: prompt_message_body + + - id: post_message + name: "Send Message" + plugin: slack + step: post_message diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml index 60de4f5e..f440165f 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml @@ -12,10 +12,10 @@ steps: plugin: slack step: select_user_target - - id: open_direct_message - name: "Open Direct Message" + - id: prepare_message_destination + name: "Prepare Message Destination" plugin: slack - step: open_direct_message + step: prepare_message_destination - id: prompt_message_body name: "Compose Message" diff --git a/titan_cli/ai/providers/litellm.py b/titan_cli/ai/providers/litellm.py index 509ee413..75e15749 100644 --- a/titan_cli/ai/providers/litellm.py +++ b/titan_cli/ai/providers/litellm.py @@ -8,6 +8,7 @@ - Other OpenAI-compatible services """ +from collections.abc import Mapping, Sequence from typing import Optional try: @@ -126,9 +127,14 @@ def generate(self, request: AIRequest) -> AIResponse: response = self._client.chat.completions.create(**request_kwargs) choice = response.choices[0] usage = response.usage - content = self._extract_choice_content(choice) response_model = response.model or self._model finish_reason = choice.finish_reason or "stop" + content = self._extract_choice_content(choice) + + if not content and finish_reason == "length": + raise AIProviderAPIError( + "LiteLLM response was truncated before yielding textual content." + ) return AIResponse( content=content, @@ -185,7 +191,7 @@ def _extract_choice_content(cls, choice) -> str: @classmethod def _extract_text_from_mapping(cls, data) -> str: - if not isinstance(data, dict): + if not isinstance(data, Mapping): return "" for key in ("content", "text", "output_text", "reasoning_content"): @@ -193,9 +199,18 @@ def _extract_text_from_mapping(cls, data) -> str: if text: return text - message = data.get("message") - if isinstance(message, dict): - text = cls._extract_text_from_mapping(message) + for key in ( + "parts", + "candidates", + "output", + "outputs", + "message", + "provider_payload", + "provider_response", + "raw_response", + "response", + ): + text = cls._coerce_content_to_text(data.get(key)) if text: return text @@ -209,14 +224,34 @@ def _coerce_content_to_text(cls, content) -> str: if isinstance(content, str): return content.strip() - if isinstance(content, dict): + if isinstance(content, Mapping): if "text" in content and isinstance(content["text"], str): return content["text"].strip() + if "parts" in content: + return cls._coerce_content_to_text(content["parts"]) + if "candidates" in content: + return cls._coerce_content_to_text(content["candidates"]) + if "output" in content: + return cls._coerce_content_to_text(content["output"]) + if "outputs" in content: + return cls._coerce_content_to_text(content["outputs"]) if "content" in content: return cls._coerce_content_to_text(content["content"]) + if "message" in content: + return cls._coerce_content_to_text(content["message"]) + if "provider_payload" in content: + return cls._coerce_content_to_text(content["provider_payload"]) + if "provider_response" in content: + return cls._coerce_content_to_text(content["provider_response"]) + if "raw_response" in content: + return cls._coerce_content_to_text(content["raw_response"]) + if "response" in content: + return cls._coerce_content_to_text(content["response"]) return "" - if isinstance(content, (list, tuple)): + if isinstance(content, Sequence) and not isinstance( + content, (str, bytes, bytearray) + ): parts = [cls._coerce_content_to_text(item) for item in content] return "\n".join(part for part in parts if part).strip() From b7d33233f8f827221ac943ebb86fd76da310d57b Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 16 Jun 2026 11:10:27 +0200 Subject: [PATCH 16/23] feat: Add project-scoped Slack workspace configuration and update plugin integration --- docs/plugins/slack/built-in-workflows.md | 10 +- docs/plugins/slack/client-api.md | 6 +- docs/plugins/slack/overview.md | 17 +- .../titan-plugin-slack/tests/test_plugin.py | 13 +- .../tests/ui/test_slack_config_screen.py | 43 +++-- .../titan_plugin_slack/config/__init__.py | 12 +- .../titan_plugin_slack/plugin.py | 14 +- .../screens/slack_config_screen.py | 149 +++++++++--------- titan_cli/core/config.py | 9 ++ titan_cli/core/plugins/models.py | 71 +++++---- titan_cli/ui/tui/screens/plugin_management.py | 24 ++- 11 files changed, 207 insertions(+), 161 deletions(-) diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index 411f7d39..4ca93b65 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -17,14 +17,14 @@ Validate the current Slack connection, list public channels, and list visible us ### Typical usage - verify that Slack OAuth configuration is working end to end -- inspect what the current personal token can read before building richer workflows +- inspect what the current project's Slack token can read before building richer workflows - confirm the first public Slack step surface behaves coherently inside Titan ### Scope constraints - the workflow stays read-only - it does not read channel history yet -- it assumes one active personal Slack connection per user +- it assumes one active Slack workspace binding for the current repository ## `send-slack-direct-message` @@ -48,7 +48,7 @@ Select a person, open or reuse a direct message conversation, compose a message, ### Scope constraints - this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline -- it still assumes one active personal Slack connection per user +- it assumes one active Slack workspace binding for the current repository ## `send-slack-channel-message` @@ -72,7 +72,7 @@ Select a channel, prepare the destination from the selected target, compose a me ### Scope constraints - this workflow depends on channel-posting Slack scopes beyond the earlier DM and discovery slices -- it still assumes one active personal Slack connection per user +- it assumes one active Slack workspace binding for the current repository ## `summarize-slack-target` @@ -96,4 +96,4 @@ Search for a person or channel, resolve the backing conversation, read recent Sl ### Scope constraints - this workflow depends on conversation-history scopes and AI configuration -- it assumes one active personal Slack connection per user +- it assumes one active Slack workspace binding for the current repository diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md index 02674530..0c8e7ecb 100644 --- a/docs/plugins/slack/client-api.md +++ b/docs/plugins/slack/client-api.md @@ -7,7 +7,7 @@ The Slack plugin adds read-oriented Slack operations to Titan through `SlackClie To use the Slack client in Titan code: - enable the `slack` plugin -- complete Slack OAuth configuration so a personal token is available +- complete project-scoped Slack OAuth configuration so a personal token is available in keyring for the active repository --- @@ -107,6 +107,6 @@ Post a plain-text message to a Slack conversation. ## Usage constraints -- The current public workflow surface only exposes validation and discovery steps. +- The current client surface backs discovery, messaging, and summary workflows. - `read_channel()` exists in the client API but is not yet exposed as a public workflow step. -- The first public Slack surface assumes one active personal connection per user. +- The current Slack integration assumes one active Slack workspace binding per repository. diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 9522b396..17dba11a 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -1,10 +1,10 @@ # Slack Plugin -The Slack plugin provides Titan's Slack integration for personal user authentication, workspace validation, and read-only discovery. It exposes: +The Slack plugin provides Titan's Slack integration for project-scoped Slack App configuration, personal user authentication, workspace validation, messaging, and discovery. It exposes: - a high-level `SlackClient` for direct use from Titan code - reusable workflow `steps` for connection validation and discovery -- one built-in workflow for validating and inspecting the current Slack workspace surface +- built-in workflows for validating, inspecting, summarizing, and messaging against the current project's Slack workspace ## Requirements @@ -13,16 +13,23 @@ To use the Slack plugin in a project: - Enable the `slack` plugin in `.titan/config.toml` - Configure Slack through Titan's Slack-specific configuration screen - Complete the BYO Slack App + PKCE connection flow -- Store the resulting personal Slack token in Titan secrets +- Store the resulting personal Slack token in keyring for the current project Example project configuration: ```toml [plugins.slack] enabled = true + +[plugins.slack.config] +oauth_client_id = "1234567890.1234567890" +default_team_id = "T12345678" +default_team_name = "My Workspace" +granted_scopes = ["chat:write", "channels:read"] +default_channels = ["chapter-apps-android", "release-notes"] ``` -Slack stores the personal token in Titan secrets/keyring, not in the config file. +Slack stores the personal token for the current project in keyring, not in the config file. ## Public surfaces @@ -49,7 +56,7 @@ The Slack plugin currently exposes public reusable steps for: - listing public channels visible to the current token - listing users visible to the current token - selecting a reusable Slack target from users or channels for later workflows -- opening a direct message and posting a Slack message through reusable messaging steps +- preparing a unified Slack message destination and posting messages to direct messages or channels - resolving a target conversation, reading recent messages, and summarizing them with AI The grouped reference lives in [Workflow Steps](./workflow-steps.md). diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 0ae1aa5e..97a00d57 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -48,14 +48,15 @@ def test_slack_plugin_exposes_config_schema() -> None: schema = plugin.get_config_schema() assert "user_token" in schema["properties"] - assert schema["properties"]["default_team_id"]["config_scope"] == "global" - assert schema["properties"]["auth_mode"]["default"] == "user_token" + assert schema["properties"]["default_team_id"]["config_scope"] == "project" + assert schema["properties"]["default_channels"]["config_scope"] == "project" def test_slack_plugin_initialize_requires_user_token() -> None: plugin = SlackPlugin() config = MagicMock() - config.config.plugins = {} + config.config.plugins = {"slack": MagicMock(config={"oauth_client_id": "123"})} + config.get_project_name.return_value = "demo-project" secrets = MagicMock() secrets.get.return_value = None @@ -67,8 +68,9 @@ def test_slack_plugin_initialize_uses_personal_token() -> None: plugin = SlackPlugin() config = MagicMock() config.config.plugins = { - "slack": MagicMock(config={"default_team_id": "T123", "timeout": 45}) + "slack": MagicMock(config={"default_team_id": "T123"}) } + config.get_project_name.return_value = "demo-project" secrets = MagicMock() secrets.get.return_value = "xoxp-user-token" @@ -77,4 +79,5 @@ def test_slack_plugin_initialize_uses_personal_token() -> None: client = plugin.get_client() assert client.user_token == "xoxp-user-token" assert client.team_id == "T123" - assert client.timeout == 45 + assert client.timeout == 30 + secrets.get.assert_called_once_with("demo-project_slack_user_token") diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index 22f8b0a5..0c7d942b 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -12,6 +12,7 @@ def _build_config(tmp_path: Path, token: str | None = None, plugin_config: dict config = MagicMock() config._global_config_path = tmp_path / "config.toml" config.project_config_path = tmp_path / "project-config.toml" + config.get_project_name.return_value = "demo-project" config.config = MagicMock() config.config.config_version = "1.0" config.config.plugins = {} @@ -39,16 +40,14 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: token="xoxp-token", plugin_config={ "oauth_client_id": "123", - "oauth_redirect_port": 9999, "default_team_id": "T123", "default_team_name": "Acme", "granted_scopes": ["users:read", "channels:read"], - "auth_mode": "user_token", - "timeout": 45, + "default_channels": ["general", "release-notes"], }, ) config.secrets.get.side_effect = lambda key: { - "slack_user_token": "xoxp-token", + "demo-project_slack_user_token": "xoxp-token", }.get(key) screen = SlackConfigScreen(config) @@ -56,41 +55,35 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: assert state.has_token is True assert state.oauth_client_id == "123" - assert state.oauth_redirect_port == 9999 assert state.default_team_id == "T123" assert state.default_team_name == "Acme" assert state.granted_scopes == ["users:read", "channels:read"] - assert state.timeout == 45 + assert state.default_channels == ["general", "release-notes"] -def test_slack_config_screen_disconnect_clears_token_and_metadata(tmp_path: Path) -> None: +def test_slack_config_screen_disconnect_disables_plugin_and_deletes_project_token(tmp_path: Path) -> None: config = _build_config(tmp_path, token="xoxp-token") screen = SlackConfigScreen(config) app = MagicMock() type(screen).app = PropertyMock(return_value=app) - screen._save_global_slack_config( + screen._save_project_slack_config( { + "oauth_client_id": "123", "default_team_id": "T123", "default_team_name": "Acme", "granted_scopes": ["users:read"], - "auth_mode": "user_token", - "timeout": 30, + "default_channels": ["general"], } ) screen._disconnect() - config.secrets.delete.assert_called_once_with("slack_user_token", scope="user") - with open(config._global_config_path, "rb") as f: + config.secrets.delete.assert_called_once_with("demo-project_slack_user_token", scope="user") + with open(config.project_config_path, "rb") as f: data = tomli.load(f) - slack_cfg = data["plugins"]["slack"]["config"] - assert "default_team_id" not in slack_cfg - assert "default_team_name" not in slack_cfg - assert "granted_scopes" not in slack_cfg - assert slack_cfg["auth_mode"] == "user_token" - assert slack_cfg["timeout"] == 30 + assert data.get("plugins", {}) == {} def test_slack_config_screen_start_oauth_flow_runs_worker(tmp_path: Path) -> None: @@ -101,7 +94,7 @@ def test_slack_config_screen_start_oauth_flow_runs_worker(tmp_path: Path) -> Non type(screen).app = PropertyMock(return_value=app) screen.run_worker = MagicMock() - screen._read_oauth_form_values = MagicMock(return_value=("123", 8765)) + screen._read_oauth_form_values = MagicMock(return_value="123") screen._save_oauth_app_config = MagicMock() screen._start_oauth_flow() @@ -140,7 +133,7 @@ def run(self): FakeFlow, ) - result = screen._perform_oauth_connect("123", 8765) + result = screen._perform_oauth_connect("123") assert result == expected @@ -149,21 +142,21 @@ def test_slack_config_screen_saves_oauth_app_config(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) - screen._save_oauth_app_config("123", 9999) + screen._save_oauth_app_config("123") - with open(config._global_config_path, "rb") as f: + with open(config.project_config_path, "rb") as f: data = tomli.load(f) slack_cfg = data["plugins"]["slack"]["config"] assert slack_cfg["oauth_client_id"] == "123" - assert slack_cfg["oauth_redirect_port"] == 9999 + assert data["plugins"]["slack"]["enabled"] is True -def test_slack_config_screen_enable_plugin_for_current_project(tmp_path: Path) -> None: +def test_slack_config_screen_save_project_config_enables_plugin(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) - screen._enable_plugin_for_current_project() + screen._save_project_slack_config({"oauth_client_id": "123"}) with open(config.project_config_path, "rb") as f: data = tomli.load(f) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py index 8e177d27..bb577b16 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py @@ -1 +1,11 @@ -"""Slack plugin config package.""" +"""Slack plugin config helpers.""" + + +def build_project_slack_token_key(project_name: str | None) -> str: + """Return the keyring key used for the current project's Slack token.""" + if not project_name: + raise ValueError("Slack project token key requires a configured project name.") + return f"{project_name}_slack_user_token" + + +__all__ = ["build_project_slack_token_key"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index fa8199de..ead9d8fb 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -7,6 +7,7 @@ from titan_cli.core.secrets import SecretManager from .clients.slack_client import SlackClient +from .config import build_project_slack_token_key from .exceptions import SlackClientError, SlackConfigurationError from .screens.slack_config_screen import SlackConfigScreen @@ -49,18 +50,25 @@ def create_config_screen(self, config: TitanConfig) -> SlackConfigScreen: def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: """Initialize the Slack client using the current user's personal token.""" plugin_config_data = self._get_plugin_config(config) + if not plugin_config_data: + raise SlackConfigurationError( + "Slack is enabled for this project but no Slack project configuration was found. Configure Slack in this repository first." + ) + validated_config = SlackPluginConfig(**plugin_config_data) - user_token = secrets.get("slack_user_token") + project_name = config.get_project_name() + token_key = build_project_slack_token_key(project_name) + + user_token = secrets.get(token_key) if not user_token: raise SlackConfigurationError( - "Slack user token not found. Configure Slack and store a personal token in keyring, or set SLACK_USER_TOKEN." + f"Slack user token not found for project '{project_name}'. Configure Slack for this repository first." ) self._client = SlackClient( user_token=user_token, team_id=validated_config.default_team_id, - timeout=validated_config.timeout, ) def is_available(self) -> bool: diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 9f4f973b..c1ca9875 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -16,10 +16,12 @@ from titan_cli.core.result import ClientError, ClientSuccess from ..clients.slack_client import SlackClient -from ..oauth import DEFAULT_SCOPES, SlackOAuthFlow, SlackOAuthResult +from ..config import build_project_slack_token_key +from ..oauth import SlackOAuthFlow, SlackOAuthResult logger = get_logger(__name__) +DEFAULT_OAUTH_REDIRECT_PORT = 8765 @dataclass @@ -28,12 +30,10 @@ class SlackConnectionState: has_token: bool oauth_client_id: str | None - oauth_redirect_port: int default_team_id: str | None default_team_name: str | None granted_scopes: list[str] - auth_mode: str - timeout: int + default_channels: list[str] class SlackConfigScreen(BaseScreen): @@ -130,11 +130,9 @@ def compose_content(self) -> ComposeResult: yield Static(id="slack-oauth-help", classes="slack-section-body") yield DimText("Client ID") yield Input(id="oauth-client-id-input") - yield DimText("Redirect Port") - yield Input(value="8765", id="oauth-redirect-port-input") yield Text("") - yield BoldText("Slack MVP0 Scopes", classes="slack-section-title") + yield BoldText("Required Capabilities", classes="slack-section-title") yield Static(id="slack-scopes-block", classes="slack-section-body") yield Static(id="slack-connect-help", classes="slack-section-body") @@ -154,31 +152,43 @@ def _load_plugin_config(self) -> dict: return plugin_cfg.config if hasattr(plugin_cfg, "config") else {} def _has_user_token(self) -> bool: - return bool(self.config.secrets.get("slack_user_token")) + return bool(self.config.secrets.get(self._get_project_token_key())) + + def _get_project_name(self) -> str: + project_name = self.config.get_project_name() + if not project_name: + raise ValueError("Slack configuration requires an active Titan project.") + return project_name + + def _get_project_token_key(self) -> str: + return build_project_slack_token_key(self._get_project_name()) def _get_connection_state(self) -> SlackConnectionState: plugin_config = self._load_plugin_config() return SlackConnectionState( has_token=self._has_user_token(), oauth_client_id=plugin_config.get("oauth_client_id"), - oauth_redirect_port=plugin_config.get("oauth_redirect_port", 8765), default_team_id=plugin_config.get("default_team_id"), default_team_name=plugin_config.get("default_team_name"), granted_scopes=plugin_config.get("granted_scopes", []), - auth_mode=plugin_config.get("auth_mode", "user_token"), - timeout=plugin_config.get("timeout", 30), + default_channels=plugin_config.get("default_channels", []), ) - def _save_global_slack_config(self, updates: dict[str, object | None]) -> None: - global_cfg_path = self.config._global_config_path + def _save_project_slack_config(self, updates: dict[str, object | None]) -> None: + project_cfg_path = self.config.project_config_path + if not project_cfg_path: + raise ValueError("Slack configuration requires a project config path.") + config_data = {} - if global_cfg_path.exists(): - with open(global_cfg_path, "rb") as f: + if project_cfg_path.exists(): + with open(project_cfg_path, "rb") as f: config_data = tomli.load(f) config_data.setdefault("config_version", getattr(self.config.config, "config_version", "1.0")) + project_cfg_path.parent.mkdir(parents=True, exist_ok=True) plugins = config_data.setdefault("plugins", {}) plugin_table = plugins.setdefault("slack", {}) + plugin_table["enabled"] = True plugin_config = plugin_table.setdefault("config", {}) for key, value in updates.items(): @@ -187,13 +197,13 @@ def _save_global_slack_config(self, updates: dict[str, object | None]) -> None: else: plugin_config[key] = value - with open(global_cfg_path, "wb") as f: + with open(project_cfg_path, "wb") as f: tomli_w.dump(config_data, f) self.config.load() - def _enable_plugin_for_current_project(self) -> None: - """Ensure Slack is enabled in the current project's config.""" + def _disable_plugin_for_current_project(self) -> None: + """Remove Slack from the current project's config so it is no longer enabled.""" project_cfg_path = self.config.project_config_path if not project_cfg_path: return @@ -204,9 +214,11 @@ def _enable_plugin_for_current_project(self) -> None: with open(project_cfg_path, "rb") as f: project_data = tomli.load(f) - plugins = project_data.setdefault("plugins", {}) - plugin_table = plugins.setdefault("slack", {}) - plugin_table["enabled"] = True + plugins = project_data.get("plugins", {}) + if "slack" in plugins: + del plugins["slack"] + if not plugins and "plugins" in project_data: + del project_data["plugins"] with open(project_cfg_path, "wb") as f: tomli_w.dump(project_data, f) @@ -222,7 +234,6 @@ def _refresh_view(self) -> None: scopes_block = self.query_one("#slack-scopes-block", Static) connect_help = self.query_one("#slack-connect-help", Static) client_id_input = self.query_one("#oauth-client-id-input", Input) - redirect_port_input = self.query_one("#oauth-redirect-port-input", Input) except NoMatches: return @@ -230,83 +241,74 @@ def _refresh_view(self) -> None: scopes = ", ".join(state.granted_scopes) if state.granted_scopes else "Not recorded" intro.update( - "Slack uses a personal user token stored securely in your keyring.\n" - "The primary configuration path for Slack uses a browser-based OAuth flow." + "Slack stores a personal user token for this project in your keyring.\n" + "The Slack App, workspace binding, scopes, and default channels are configured per repository." ) status_block.update( f" Status: {status_label}\n" - f" Auth Mode: {state.auth_mode}\n" - f" Timeout: {state.timeout}s\n" f" OAuth Client ID: {state.oauth_client_id or 'Not set'}\n" - f" OAuth Redirect Port: {state.oauth_redirect_port}\n" + f" OAuth Redirect Port: {DEFAULT_OAUTH_REDIRECT_PORT}\n" f" Team ID: {state.default_team_id or 'Not set'}\n" f" Team Name: {state.default_team_name or 'Not set'}\n" - f" Granted Scopes: {scopes}" + f" Granted Scopes: {scopes}\n" + f" Default Channels: {', '.join('#' + channel for channel in state.default_channels) if state.default_channels else 'Not set'}" ) oauth_help.update( "Titan will open Slack in your browser and complete the OAuth PKCE flow.\n" - "Create your own Slack App, enable PKCE, and configure this exact redirect URL in Slack OAuth settings:\n" - f" {self._build_redirect_uri(state.oauth_redirect_port)}\n" + "Create your project's Slack App, enable PKCE, and configure this exact redirect URL in Slack OAuth settings:\n" + f" {self._build_redirect_uri()}\n" "The redirect URL in Slack must match exactly, including host, port, and path.\n" "For example, `127.0.0.1` and `localhost` are different values for Slack." ) - scopes_block.update("\n".join(f" {scope}" for scope in DEFAULT_SCOPES)) - connect_help.update("Use Connect Slack to open the browser-based Slack OAuth flow.") + scopes_block.update( + "Slack needs scopes that cover:\n" + " - user and channel discovery\n" + " - conversation history for summaries\n" + " - posting messages to direct messages and channels\n\n" + "After you connect, Titan records the granted scopes above in Current Status." + ) + connect_help.update("Use Connect Slack to open the browser-based Slack OAuth flow for this repository.") client_id_input.value = state.oauth_client_id or "" - redirect_port_input.value = str(state.oauth_redirect_port) self.query_one("#validate-button", Button).disabled = not state.has_token self.query_one("#disconnect-button", Button).disabled = not state.has_token @staticmethod - def _build_redirect_uri(port: int) -> str: + def _build_redirect_uri() -> str: """Build the localhost redirect URI shown to the user.""" - return f"http://127.0.0.1:{port}/slack/callback" + return f"http://127.0.0.1:{DEFAULT_OAUTH_REDIRECT_PORT}/slack/callback" - def _read_oauth_form_values(self) -> tuple[str, int]: + def _read_oauth_form_values(self) -> str: """Read and validate the OAuth app form values from the screen.""" client_id = self.query_one("#oauth-client-id-input", Input).value.strip() - redirect_port_raw = self.query_one("#oauth-redirect-port-input", Input).value.strip() or "8765" if not client_id: raise ValueError("Slack OAuth client ID is required.") - try: - redirect_port = int(redirect_port_raw) - except ValueError as exc: - raise ValueError("Slack OAuth redirect port must be a number.") from exc - - if redirect_port <= 0: - raise ValueError("Slack OAuth redirect port must be greater than zero.") + return client_id - return client_id, redirect_port - - def _save_oauth_app_config(self, client_id: str, redirect_port: int) -> None: + def _save_oauth_app_config(self, client_id: str) -> None: """Persist OAuth app settings for Slack.""" - timeout = self._load_plugin_config().get("timeout", 30) - self._save_global_slack_config( + self._save_project_slack_config( { "oauth_client_id": client_id, - "oauth_redirect_port": redirect_port, - "timeout": timeout, - "auth_mode": "user_token", } ) - def _perform_oauth_connect(self, client_id: str, redirect_port: int) -> SlackOAuthResult: + def _perform_oauth_connect(self, client_id: str) -> SlackOAuthResult: """Run the synchronous Slack OAuth backend flow.""" flow = SlackOAuthFlow( client_id=client_id, - redirect_port=redirect_port, + redirect_port=DEFAULT_OAUTH_REDIRECT_PORT, ) return flow.run() def _start_oauth_flow(self) -> None: """Start the Slack OAuth flow in a background worker.""" try: - client_id, redirect_port = self._read_oauth_form_values() - self._save_oauth_app_config(client_id, redirect_port) + client_id = self._read_oauth_form_values() + self._save_oauth_app_config(client_id) except Exception as exc: logger.exception("slack_oauth_setup_failed") self.app.notify(f"Slack OAuth setup failed: {exc}", severity="error") @@ -314,30 +316,29 @@ def _start_oauth_flow(self) -> None: self.app.notify("Opening browser for Slack authorization...", severity="information") self.run_worker( - self._run_oauth_connect(client_id, redirect_port), + self._run_oauth_connect(client_id), exclusive=True, ) - async def _run_oauth_connect(self, client_id: str, redirect_port: int) -> None: + async def _run_oauth_connect(self, client_id: str) -> None: """Run the Slack OAuth flow without blocking the UI thread.""" try: result = await asyncio.to_thread( self._perform_oauth_connect, client_id, - redirect_port, ) - self.config.secrets.set("slack_user_token", result.access_token, scope="user") - self._save_global_slack_config( + self.config.secrets.set( + self._get_project_token_key(), result.access_token, scope="user" + ) + self._save_project_slack_config( { "oauth_client_id": client_id, - "oauth_redirect_port": redirect_port, "default_team_id": result.team_id, "default_team_name": result.team_name, "granted_scopes": result.granted_scopes, - "auth_mode": "user_token", + "default_channels": self._load_plugin_config().get("default_channels", []), } ) - self._enable_plugin_for_current_project() self.app.notify("Slack connected successfully.", severity="information") self.dismiss(result=True) except Exception as exc: @@ -347,23 +348,21 @@ async def _run_oauth_connect(self, client_id: str, redirect_port: int) -> None: def _validate_connection(self) -> None: plugin_config = self._load_plugin_config() client = SlackClient( - user_token=self.config.secrets.get("slack_user_token") or "", + user_token=self.config.secrets.get(self._get_project_token_key()) or "", team_id=plugin_config.get("default_team_id"), - timeout=plugin_config.get("timeout", 30), ) result = client.auth_test() match result: case ClientSuccess(data=auth): - self._save_global_slack_config( + self._save_project_slack_config( { "default_team_id": auth.team_id, "default_team_name": auth.team, - "auth_mode": "user_token", - "timeout": plugin_config.get("timeout", 30), + "granted_scopes": plugin_config.get("granted_scopes", []), + "default_channels": plugin_config.get("default_channels", []), } ) - self._enable_plugin_for_current_project() self.app.notify( "Slack connection validated successfully.", severity="information" ) @@ -372,14 +371,8 @@ def _validate_connection(self) -> None: raise RuntimeError(err) def _disconnect(self) -> None: - self.config.secrets.delete("slack_user_token", scope="user") - self._save_global_slack_config( - { - "default_team_id": None, - "default_team_name": None, - "granted_scopes": None, - } - ) + self.config.secrets.delete(self._get_project_token_key(), scope="user") + self._disable_plugin_for_current_project() self.app.notify("Slack connection removed.", severity="information") self._refresh_view() diff --git a/titan_cli/core/config.py b/titan_cli/core/config.py index 26650241..4faed23a 100644 --- a/titan_cli/core/config.py +++ b/titan_cli/core/config.py @@ -383,6 +383,15 @@ def is_plugin_enabled(self, plugin_name: str) -> bool: project_plugins = self.project_config.get("plugins", {}) if self.project_config else {} if plugin_name not in project_plugins: return False + + # Slack is repo-scoped: it is only considered enabled when the current + # project explicitly contains a non-empty Slack config block. + if plugin_name == "slack": + project_plugin_cfg = project_plugins.get(plugin_name, {}) + project_plugin_config = project_plugin_cfg.get("config", {}) + if not project_plugin_config: + return False + plugin_cfg = self.config.plugins.get(plugin_name) return plugin_cfg.enabled if plugin_cfg else False diff --git a/titan_cli/core/plugins/models.py b/titan_cli/core/plugins/models.py index 663d4182..84970569 100644 --- a/titan_cli/core/plugins/models.py +++ b/titan_cli/core/plugins/models.py @@ -133,52 +133,53 @@ class SlackPluginConfig(BaseModel): ) default_team_id: Optional[str] = Field( None, - description="Preferred Slack workspace/team ID for the current user.", - json_schema_extra={"config_scope": "global"}, + description="Slack workspace/team ID bound to the current project.", + json_schema_extra={"config_scope": "project"}, ) oauth_client_id: Optional[str] = Field( None, - description="Slack OAuth client ID used for personal connection setup.", - json_schema_extra={"config_scope": "global"}, - ) - oauth_redirect_port: int = Field( - 8765, - description="Localhost port used for Slack OAuth callback handling.", - json_schema_extra={"config_scope": "global"}, + description="Slack OAuth client ID used by the current project's Slack App.", + json_schema_extra={"config_scope": "project"}, ) default_team_name: Optional[str] = Field( None, - description="Preferred Slack workspace/team name for the current user.", - json_schema_extra={"config_scope": "global"}, + description="Slack workspace/team name bound to the current project.", + json_schema_extra={"config_scope": "project"}, ) granted_scopes: List[str] = Field( default_factory=list, - description="Scopes granted to the current user's Slack token.", - json_schema_extra={"config_scope": "global"}, - ) - auth_mode: str = Field( - "user_token", - description="Slack authentication mode for this user.", - json_schema_extra={"config_scope": "global"}, + description="Scopes granted to the current project's Slack integration.", + json_schema_extra={"config_scope": "project"}, ) - timeout: int = Field( - 30, - description="Request timeout in seconds.", - json_schema_extra={"config_scope": "global"}, + default_channels: List[str] = Field( + default_factory=list, + description="Default Slack channel names for this project. Names may include or omit '#'.", + json_schema_extra={"config_scope": "project"}, ) - @field_validator("auth_mode") + @field_validator("oauth_client_id") @classmethod - def validate_auth_mode(cls, v: str) -> str: - """Validate the supported auth mode for Slack.""" - if v != "user_token": - raise ValueError("Slack auth_mode must be 'user_token'") - return v - - @field_validator("oauth_redirect_port") + def normalize_oauth_client_id(cls, v: Optional[str]) -> Optional[str]: + """Normalize optional OAuth client ID values.""" + if v is None: + return None + stripped = v.strip() + return stripped or None + + @field_validator("default_channels") @classmethod - def validate_oauth_redirect_port(cls, v: int) -> int: - """Validate Slack OAuth redirect port.""" - if v <= 0: - raise ValueError("Slack oauth_redirect_port must be greater than zero") - return v + def normalize_default_channels(cls, values: List[str]) -> List[str]: + """Normalize default channel names while preserving user-friendly config.""" + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + channel = value.strip() + if not channel: + continue + normalized_name = channel.lstrip("#") + key = normalized_name.casefold() + if key in seen: + continue + seen.add(key) + normalized.append(normalized_name) + return normalized diff --git a/titan_cli/ui/tui/screens/plugin_management.py b/titan_cli/ui/tui/screens/plugin_management.py index 16fa5370..1ae1ad7b 100644 --- a/titan_cli/ui/tui/screens/plugin_management.py +++ b/titan_cli/ui/tui/screens/plugin_management.py @@ -416,8 +416,30 @@ def _show_plugin_details(self, plugin_name: str) -> None: # Don't show secrets if any(secret in key.lower() for secret in ['token', 'password', 'secret', 'api_key']): details.mount(DimText(f" {key}: ••••••••")) + elif isinstance(value, list): + label = key.replace("_", " ").title() + if not value: + details.mount(DimText(f" {label}: Not set")) + else: + details.mount(DimText(f" {label}:")) + for item in value: + details.mount(DimText(f" - {item}")) + elif isinstance(value, dict): + label = key.replace("_", " ").title() + if not value: + details.mount(DimText(f" {label}: Not set")) + else: + details.mount(DimText(f" {label}:")) + for nested_key, nested_value in value.items(): + details.mount( + DimText( + f" {nested_key.replace('_', ' ').title()}: {nested_value}" + ) + ) else: - details.mount(DimText(f" {key}: {value}")) + label = key.replace("_", " ").title() + rendered = value if value not in (None, "") else "Not set" + details.mount(DimText(f" {label}: {rendered}")) active_rec = self._build_stable_record(plugin_name) is_community_plugin = self._is_community_plugin(plugin_name) From f3eb9391c2f704ac819b07516c05b3eb7223c3ca Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 16 Jun 2026 12:29:07 +0200 Subject: [PATCH 17/23] feat: Add Slack plugin configuration and OAuth flow support --- .../tests/ui/test_slack_config_screen.py | 43 +++- .../titan_plugin_slack/oauth.py | 94 +++++++- .../screens/slack_config_screen.py | 224 ++++++++++++++---- titan_cli/core/config.py | 10 +- titan_cli/ui/tui/screens/plugin_management.py | 35 ++- 5 files changed, 346 insertions(+), 60 deletions(-) diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index 0c7d942b..fa97671c 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -53,6 +53,7 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: state = screen._get_connection_state() + assert state.has_project_config is True assert state.has_token is True assert state.oauth_client_id == "123" assert state.default_team_id == "T123" @@ -61,7 +62,7 @@ def test_slack_config_screen_reports_connection_state(tmp_path: Path) -> None: assert state.default_channels == ["general", "release-notes"] -def test_slack_config_screen_disconnect_disables_plugin_and_deletes_project_token(tmp_path: Path) -> None: +def test_slack_config_screen_disconnect_only_deletes_project_token(tmp_path: Path) -> None: config = _build_config(tmp_path, token="xoxp-token") screen = SlackConfigScreen(config) @@ -79,6 +80,33 @@ def test_slack_config_screen_disconnect_disables_plugin_and_deletes_project_toke ) screen._disconnect() + config.secrets.delete.assert_called_once_with("demo-project_slack_user_token", scope="user") + with open(config.project_config_path, "rb") as f: + data = tomli.load(f) + + assert data["plugins"]["slack"]["enabled"] is True + assert data["plugins"]["slack"]["config"]["oauth_client_id"] == "123" + + +def test_slack_config_screen_remove_project_config_clears_plugin_entry_and_token(tmp_path: Path) -> None: + config = _build_config(tmp_path, token="xoxp-token") + screen = SlackConfigScreen(config) + + app = MagicMock() + type(screen).app = PropertyMock(return_value=app) + + screen._save_project_slack_config( + { + "oauth_client_id": "123", + "default_team_id": "T123", + "default_team_name": "Acme", + "granted_scopes": ["users:read"], + "default_channels": ["general"], + } + ) + + screen._remove_project_config() + config.secrets.delete.assert_called_once_with("demo-project_slack_user_token", scope="user") with open(config.project_config_path, "rb") as f: data = tomli.load(f) @@ -94,7 +122,7 @@ def test_slack_config_screen_start_oauth_flow_runs_worker(tmp_path: Path) -> Non type(screen).app = PropertyMock(return_value=app) screen.run_worker = MagicMock() - screen._read_oauth_form_values = MagicMock(return_value="123") + screen._read_oauth_form_values = MagicMock(return_value=("123", ["general"])) screen._save_oauth_app_config = MagicMock() screen._start_oauth_flow() @@ -142,13 +170,14 @@ def test_slack_config_screen_saves_oauth_app_config(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) - screen._save_oauth_app_config("123") + screen._save_oauth_app_config("123", ["general", "release-notes"]) with open(config.project_config_path, "rb") as f: data = tomli.load(f) slack_cfg = data["plugins"]["slack"]["config"] assert slack_cfg["oauth_client_id"] == "123" + assert slack_cfg["default_channels"] == ["general", "release-notes"] assert data["plugins"]["slack"]["enabled"] is True @@ -162,3 +191,11 @@ def test_slack_config_screen_save_project_config_enables_plugin(tmp_path: Path) data = tomli.load(f) assert data["plugins"]["slack"]["enabled"] is True + + +def test_parse_default_channels_normalizes_and_deduplicates() -> None: + result = SlackConfigScreen._parse_default_channels( + "#general, release-notes, general, ,\n#alerts" + ) + + assert result == ["general", "release-notes", "alerts"] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index 0fcf09e0..d05d19d6 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -37,6 +37,96 @@ logger = get_logger(__name__) +CALLBACK_SUCCESS_HTML = """ + + + + + Titan Slack Connection + + + +
+
Titan • Slack
+

Slack connection received

+

+ Titan has received the OAuth callback successfully. You can now return to the CLI and continue. +

+
You can close this tab.
+
+ + +""".encode("utf-8") + + class SlackOAuthError(Exception): """Raised when the Slack OAuth flow fails.""" @@ -202,9 +292,7 @@ def do_GET(self): # type: ignore[override] self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() - self.wfile.write( - b"

Slack connection received.

You can return to Titan.

" - ) + self.wfile.write(CALLBACK_SUCCESS_HTML) callback_event.set() def log_message(self, format, *args): # noqa: A003 diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index c1ca9875..229cfae6 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -28,6 +28,7 @@ class SlackConnectionState: """Current Slack connection state for the active user.""" + has_project_config: bool has_token: bool oauth_client_id: str | None default_team_id: str | None @@ -110,6 +111,8 @@ def __init__(self, config): show_back=True, show_status_bar=False, ) + self._reconfigure_project_mode = False + self._has_changes = False def compose_content(self) -> ComposeResult: with Container(id="slack-config-container"): @@ -130,6 +133,9 @@ def compose_content(self) -> ComposeResult: yield Static(id="slack-oauth-help", classes="slack-section-body") yield DimText("Client ID") yield Input(id="oauth-client-id-input") + yield DimText("Default Channels") + yield Input(placeholder="general, release-notes", id="default-channels-input") + yield DimText("Enter channel names separated by commas, for example: general, release-notes") yield Text("") yield BoldText("Required Capabilities", classes="slack-section-title") @@ -137,9 +143,11 @@ def compose_content(self) -> ComposeResult: yield Static(id="slack-connect-help", classes="slack-section-body") with Horizontal(id="slack-config-buttons"): - yield Button("Connect Slack", variant="primary", id="connect-button") + yield Button("Configure Slack", variant="primary", id="connect-button") yield Button("Validate Connection", variant="default", id="validate-button") - yield Button("Disconnect", variant="error", id="disconnect-button") + yield Button("Reconfigure Project", variant="warning", id="reconfigure-project-button") + yield Button("Disconnect Account", variant="default", id="disconnect-button") + yield Button("Remove Project Config", variant="error", id="remove-project-config-button") yield Button("Close", variant="default", id="close-button") def on_mount(self) -> None: @@ -166,6 +174,7 @@ def _get_project_token_key(self) -> str: def _get_connection_state(self) -> SlackConnectionState: plugin_config = self._load_plugin_config() return SlackConnectionState( + has_project_config=bool(plugin_config), has_token=self._has_user_token(), oauth_client_id=plugin_config.get("oauth_client_id"), default_team_id=plugin_config.get("default_team_id"), @@ -201,29 +210,7 @@ def _save_project_slack_config(self, updates: dict[str, object | None]) -> None: tomli_w.dump(config_data, f) self.config.load() - - def _disable_plugin_for_current_project(self) -> None: - """Remove Slack from the current project's config so it is no longer enabled.""" - project_cfg_path = self.config.project_config_path - if not project_cfg_path: - return - - project_cfg_path.parent.mkdir(parents=True, exist_ok=True) - project_data = {} - if project_cfg_path.exists(): - with open(project_cfg_path, "rb") as f: - project_data = tomli.load(f) - - plugins = project_data.get("plugins", {}) - if "slack" in plugins: - del plugins["slack"] - if not plugins and "plugins" in project_data: - del project_data["plugins"] - - with open(project_cfg_path, "wb") as f: - tomli_w.dump(project_data, f) - - self.config.load() + self._has_changes = True def _refresh_view(self) -> None: state = self._get_connection_state() @@ -234,18 +221,44 @@ def _refresh_view(self) -> None: scopes_block = self.query_one("#slack-scopes-block", Static) connect_help = self.query_one("#slack-connect-help", Static) client_id_input = self.query_one("#oauth-client-id-input", Input) + default_channels_input = self.query_one("#default-channels-input", Input) + connect_button = self.query_one("#connect-button", Button) + validate_button = self.query_one("#validate-button", Button) + reconfigure_button = self.query_one("#reconfigure-project-button", Button) + disconnect_button = self.query_one("#disconnect-button", Button) + remove_project_button = self.query_one("#remove-project-config-button", Button) except NoMatches: return - status_label = "Connected" if state.has_token else "Not connected" + if state.has_project_config and state.has_token: + repo_status = "Configured" + account_status = "Connected" + elif state.has_project_config: + repo_status = "Configured" + account_status = "Not connected" + else: + repo_status = "Not configured" + account_status = "Not connected" scopes = ", ".join(state.granted_scopes) if state.granted_scopes else "Not recorded" - intro.update( - "Slack stores a personal user token for this project in your keyring.\n" - "The Slack App, workspace binding, scopes, and default channels are configured per repository." - ) + if state.has_project_config and self._reconfigure_project_mode: + intro.update( + "You are editing this repository's shared Slack configuration.\n" + "Saving and connecting will update the project Slack App settings, default channels, and then sign in with your account." + ) + elif state.has_project_config: + intro.update( + "This repository has its own Slack configuration.\n" + "Each user only needs to sign in with their own Slack account for this project." + ) + else: + intro.update( + "Slack is not configured for this repository yet.\n" + "Configure the repository's Slack App and default channels first, then sign in with your personal Slack account." + ) status_block.update( - f" Status: {status_label}\n" + f" Repository Config: {repo_status}\n" + f" Personal Account: {account_status}\n" f" OAuth Client ID: {state.oauth_client_id or 'Not set'}\n" f" OAuth Redirect Port: {DEFAULT_OAUTH_REDIRECT_PORT}\n" f" Team ID: {state.default_team_id or 'Not set'}\n" @@ -267,32 +280,81 @@ def _refresh_view(self) -> None: " - posting messages to direct messages and channels\n\n" "After you connect, Titan records the granted scopes above in Current Status." ) - connect_help.update("Use Connect Slack to open the browser-based Slack OAuth flow for this repository.") + if state.has_project_config and self._reconfigure_project_mode: + connect_help.update( + "Use Save Config and Connect to replace this repository's Slack App configuration and then sign in with Slack." + ) + elif state.has_project_config and not state.has_token: + connect_help.update( + "Use Sign In to Slack to connect your own account using this repository's existing Slack configuration." + ) + elif state.has_project_config: + connect_help.update( + "Use Reconnect Slack if you need to refresh your personal Slack account for this repository." + ) + else: + connect_help.update( + "Use Configure Slack to save this repository's Slack App configuration and sign in with Slack." + ) client_id_input.value = state.oauth_client_id or "" + default_channels_input.value = ", ".join(state.default_channels) + client_id_input.disabled = state.has_project_config and not self._reconfigure_project_mode + default_channels_input.disabled = ( + state.has_project_config and not self._reconfigure_project_mode + ) + + if state.has_project_config and self._reconfigure_project_mode: + connect_button.label = "Save Config and Connect" + elif state.has_project_config and state.has_token: + connect_button.label = "Reconnect Slack" + elif state.has_project_config: + connect_button.label = "Sign In to Slack" + else: + connect_button.label = "Configure Slack" - self.query_one("#validate-button", Button).disabled = not state.has_token - self.query_one("#disconnect-button", Button).disabled = not state.has_token + validate_button.disabled = not state.has_token + reconfigure_button.disabled = not state.has_project_config + disconnect_button.disabled = not state.has_token + remove_project_button.disabled = not state.has_project_config @staticmethod def _build_redirect_uri() -> str: """Build the localhost redirect URI shown to the user.""" return f"http://127.0.0.1:{DEFAULT_OAUTH_REDIRECT_PORT}/slack/callback" - def _read_oauth_form_values(self) -> str: + @staticmethod + def _parse_default_channels(raw_value: str) -> list[str]: + """Parse a comma-separated list of default channel names.""" + channels: list[str] = [] + seen: set[str] = set() + for item in raw_value.replace("\n", ",").split(","): + channel = item.strip().lstrip("#") + if not channel: + continue + key = channel.casefold() + if key in seen: + continue + seen.add(key) + channels.append(channel) + return channels + + def _read_oauth_form_values(self) -> tuple[str, list[str]]: """Read and validate the OAuth app form values from the screen.""" client_id = self.query_one("#oauth-client-id-input", Input).value.strip() + default_channels_raw = self.query_one("#default-channels-input", Input).value.strip() if not client_id: raise ValueError("Slack OAuth client ID is required.") - return client_id + return client_id, self._parse_default_channels(default_channels_raw) - def _save_oauth_app_config(self, client_id: str) -> None: + def _save_oauth_app_config(self, client_id: str, default_channels: list[str]) -> None: """Persist OAuth app settings for Slack.""" self._save_project_slack_config( { "oauth_client_id": client_id, + "default_channels": default_channels, } ) @@ -307,8 +369,16 @@ def _perform_oauth_connect(self, client_id: str) -> SlackOAuthResult: def _start_oauth_flow(self) -> None: """Start the Slack OAuth flow in a background worker.""" try: - client_id = self._read_oauth_form_values() - self._save_oauth_app_config(client_id) + plugin_config = self._load_plugin_config() + if plugin_config and not self._reconfigure_project_mode: + client_id = plugin_config.get("oauth_client_id") + default_channels = plugin_config.get("default_channels", []) + if not client_id: + raise ValueError( + "This repository is marked as configured for Slack but has no OAuth client ID. Reconfigure the project to continue." + ) + else: + client_id, default_channels = self._read_oauth_form_values() except Exception as exc: logger.exception("slack_oauth_setup_failed") self.app.notify(f"Slack OAuth setup failed: {exc}", severity="error") @@ -316,32 +386,50 @@ def _start_oauth_flow(self) -> None: self.app.notify("Opening browser for Slack authorization...", severity="information") self.run_worker( - self._run_oauth_connect(client_id), + self._run_oauth_connect(client_id, default_channels), exclusive=True, ) - async def _run_oauth_connect(self, client_id: str) -> None: + async def _run_oauth_connect(self, client_id: str, default_channels: list[str]) -> None: """Run the Slack OAuth flow without blocking the UI thread.""" + config_written = False + token_written = False try: result = await asyncio.to_thread( self._perform_oauth_connect, client_id, ) - self.config.secrets.set( - self._get_project_token_key(), result.access_token, scope="user" - ) self._save_project_slack_config( { "oauth_client_id": client_id, "default_team_id": result.team_id, "default_team_name": result.team_name, "granted_scopes": result.granted_scopes, - "default_channels": self._load_plugin_config().get("default_channels", []), + "default_channels": default_channels, } ) + config_written = True + self.config.secrets.set( + self._get_project_token_key(), result.access_token, scope="user" + ) + token_written = True + self._reconfigure_project_mode = False + self._has_changes = True self.app.notify("Slack connected successfully.", severity="information") self.dismiss(result=True) except Exception as exc: + if token_written: + try: + self.config.secrets.delete(self._get_project_token_key(), scope="user") + except Exception: + pass + + if config_written: + try: + self._remove_project_config() + except Exception: + pass + logger.exception("slack_oauth_run_failed") self.app.notify(f"Slack OAuth failed: {exc}", severity="error") @@ -363,6 +451,7 @@ def _validate_connection(self) -> None: "default_channels": plugin_config.get("default_channels", []), } ) + self._has_changes = True self.app.notify( "Slack connection validated successfully.", severity="information" ) @@ -372,10 +461,40 @@ def _validate_connection(self) -> None: def _disconnect(self) -> None: self.config.secrets.delete(self._get_project_token_key(), scope="user") - self._disable_plugin_for_current_project() - self.app.notify("Slack connection removed.", severity="information") + self._reconfigure_project_mode = False + self._has_changes = True + self.app.notify("Slack account disconnected for this project.", severity="information") + self._refresh_view() + + def _remove_project_config(self) -> None: + self.config.secrets.delete(self._get_project_token_key(), scope="user") + project_cfg_path = self.config.project_config_path + if project_cfg_path and project_cfg_path.exists(): + with open(project_cfg_path, "rb") as f: + project_data = tomli.load(f) + + plugins = project_data.get("plugins", {}) + if "slack" in plugins: + del plugins["slack"] + if not plugins and "plugins" in project_data: + del project_data["plugins"] + + with open(project_cfg_path, "wb") as f: + tomli_w.dump(project_data, f) + + self.config.load() + self._reconfigure_project_mode = False + self._has_changes = True + self.app.notify("Slack project configuration removed.", severity="information") + self._refresh_view() + + def _enable_reconfigure_project_mode(self) -> None: + self._reconfigure_project_mode = True self._refresh_view() + def action_go_back(self) -> None: + self.dismiss(result=self._has_changes) + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "connect-button": self._start_oauth_flow() @@ -384,10 +503,17 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._validate_connection() except Exception as exc: self.app.notify(f"Slack validation failed: {exc}", severity="error") + elif event.button.id == "reconfigure-project-button": + self._enable_reconfigure_project_mode() elif event.button.id == "disconnect-button": try: self._disconnect() except Exception as exc: - self.app.notify(f"Failed to remove Slack connection: {exc}", severity="error") + self.app.notify(f"Failed to disconnect Slack account: {exc}", severity="error") + elif event.button.id == "remove-project-config-button": + try: + self._remove_project_config() + except Exception as exc: + self.app.notify(f"Failed to remove Slack project config: {exc}", severity="error") elif event.button.id == "close-button": - self.dismiss(result=False) + self.dismiss(result=self._has_changes) diff --git a/titan_cli/core/config.py b/titan_cli/core/config.py index 4faed23a..3171463a 100644 --- a/titan_cli/core/config.py +++ b/titan_cli/core/config.py @@ -385,11 +385,17 @@ def is_plugin_enabled(self, plugin_name: str) -> bool: return False # Slack is repo-scoped: it is only considered enabled when the current - # project explicitly contains a non-empty Slack config block. + # project explicitly contains a complete Slack config block. if plugin_name == "slack": project_plugin_cfg = project_plugins.get(plugin_name, {}) project_plugin_config = project_plugin_cfg.get("config", {}) - if not project_plugin_config: + required_keys = { + "oauth_client_id", + "default_team_id", + "default_team_name", + "granted_scopes", + } + if not project_plugin_config or not required_keys.issubset(project_plugin_config): return False plugin_cfg = self.config.plugins.get(plugin_name) diff --git a/titan_cli/ui/tui/screens/plugin_management.py b/titan_cli/ui/tui/screens/plugin_management.py index 1ae1ad7b..80643a22 100644 --- a/titan_cli/ui/tui/screens/plugin_management.py +++ b/titan_cli/ui/tui/screens/plugin_management.py @@ -217,6 +217,14 @@ def on_mount(self) -> None: """Initialize the screen with plugin list.""" self._load_plugins() + def on_resume(self) -> None: + """Refresh plugin status after returning from child screens.""" + super().on_resume() + try: + self._load_plugins() + except Exception: + pass + def _load_plugins(self) -> None: """Load and display installed plugins.""" self.installed_plugins = self.config.registry.list_installed() @@ -247,8 +255,13 @@ def _load_plugins(self) -> None: # Add installed plugin options for plugin_name in self.installed_plugins: is_enabled = self.config.is_plugin_enabled(plugin_name) - status_icon = Icons.SUCCESS if is_enabled else Icons.ERROR - status_text = "Enabled" if is_enabled else "Disabled" + needs_attention = self._plugin_needs_attention(plugin_name, is_enabled) + if needs_attention: + status_icon = Icons.WARNING + status_text = "Setup needed" + else: + status_icon = Icons.SUCCESS if is_enabled else Icons.ERROR + status_text = "Enabled" if is_enabled else "Disabled" active_rec = self._build_stable_record(plugin_name) badge = " [community]" if active_rec else "" @@ -289,6 +302,18 @@ def _load_plugins(self) -> None: self.selected_plugin = target self._show_plugin_details(target) + def _plugin_needs_attention(self, plugin_name: str, is_enabled: bool) -> bool: + """Return whether a plugin is enabled but still needs user attention.""" + if not is_enabled or plugin_name != "slack": + return False + + project_name = self.config.get_project_name() + if not project_name: + return False + + token_key = f"{project_name}_slack_user_token" + return not bool(self.config.secrets.get(token_key)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: """Handle plugin selection (Enter key).""" if event.option.id == "none": @@ -360,6 +385,7 @@ def _show_plugin_details(self, plugin_name: str) -> None: # Get plugin info is_enabled = self.config.is_plugin_enabled(plugin_name) + needs_attention = self._plugin_needs_attention(plugin_name, is_enabled) # Clear and rebuild details details = self.query_one("#details-content", Container) @@ -370,7 +396,10 @@ def _show_plugin_details(self, plugin_name: str) -> None: details.mount(Text("")) # Status - if is_enabled: + if needs_attention: + details.mount(Static("[bold]Status:[/bold] [yellow]Setup needed[/yellow]")) + details.mount(DimText("Slack is configured for this repository, but your personal Slack account is not connected yet.")) + elif is_enabled: details.mount(Static("[bold]Status:[/bold] [green]Enabled[/green]")) else: details.mount(Static("[bold]Status:[/bold] [red]Disabled[/red]")) From d076b6d9574af19fcf2602bfcdea36bb6b0e81c4 Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 16 Jun 2026 13:32:20 +0200 Subject: [PATCH 18/23] feat: Add Slack identity resolver with caching and update plugin configuration --- .../clients/services/__init__.py | 2 + .../clients/services/identity_resolver.py | 122 ++++++++++++++++++ .../clients/slack_client.py | 18 ++- .../titan_plugin_slack/operations/__init__.py | 8 ++ .../identity_resolution_operations.py | 65 ++++++++++ .../operations/message_summary_operations.py | 14 +- .../titan_plugin_slack/steps/summary_steps.py | 34 ++++- 7 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/clients/services/identity_resolver.py create mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/operations/identity_resolution_operations.py diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py index 24431da9..3e2feedd 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py @@ -3,11 +3,13 @@ from .auth_service import AuthService from .conversation_service import ConversationService from .directory_service import DirectoryService +from .identity_resolver import IdentityResolver from .message_service import MessageService __all__ = [ "AuthService", "DirectoryService", "ConversationService", + "IdentityResolver", "MessageService", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/identity_resolver.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/identity_resolver.py new file mode 100644 index 00000000..e97ca5dd --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/identity_resolver.py @@ -0,0 +1,122 @@ +"""Internal Slack identity resolver with simple in-memory caching.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +from ...models import NetworkSlackChannel, NetworkSlackUser, UISlackChannel, UISlackUser + + +class IdentityResolver: + """Resolve Slack users and channels by ID with per-client caching.""" + + def __init__(self, web_client): + self.web_client = web_client + self._user_cache: dict[str, UISlackUser] = {} + self._channel_cache: dict[str, UISlackChannel] = {} + + @staticmethod + def _map_user(member: dict) -> NetworkSlackUser: + profile = member.get("profile", {}) + return NetworkSlackUser( + id=member.get("id", ""), + name=member.get("name", ""), + real_name=( + member.get("real_name") + or profile.get("real_name") + or profile.get("display_name") + ), + is_bot=member.get("is_bot", False), + is_active=not member.get("deleted", False), + ) + + @staticmethod + def _map_channel(channel: dict) -> NetworkSlackChannel: + return NetworkSlackChannel( + id=channel.get("id", ""), + name=channel.get("name", ""), + is_channel=channel.get("is_channel", True), + is_private=channel.get("is_private", False), + ) + + @staticmethod + def _to_ui_user(user: NetworkSlackUser) -> UISlackUser: + return UISlackUser( + id=user.id, + name=user.name, + real_name=user.real_name, + is_bot=user.is_bot, + is_active=user.is_active, + ) + + @staticmethod + def _to_ui_channel(channel: NetworkSlackChannel) -> UISlackChannel: + return UISlackChannel( + id=channel.id, + name=channel.name, + is_channel=channel.is_channel, + is_private=channel.is_private, + ) + + @staticmethod + def _build_error(operation: str, exc_or_response, error_code: str) -> ClientError: + response = getattr(exc_or_response, "response", exc_or_response) + slack_error = "unknown_error" + if isinstance(response, dict): + slack_error = response.get("error", slack_error) + elif hasattr(response, "data") and isinstance(response.data, dict): + slack_error = response.data.get("error", slack_error) + return ClientError( + error_message=f"Slack {operation} failed: {slack_error}", + error_code=error_code, + details={"slack_error": slack_error}, + ) + + def get_user(self, user_id: str) -> ClientResult[UISlackUser]: + """Resolve a Slack user by ID, using cache when available.""" + if user_id in self._user_cache: + return ClientSuccess(data=self._user_cache[user_id], message="Slack user resolved") + + try: + response = self.web_client.users_info(user=user_id) + except SlackApiError as exc: + return self._build_error("get_user", exc, "GET_USER_ERROR") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_error("get_user", exc, "GET_USER_ERROR") + return ClientError( + error_message=f"Slack get_user request failed: {exc}", + error_code="GET_USER_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return self._build_error("get_user", response, "GET_USER_ERROR") + + user = self._to_ui_user(self._map_user(response.get("user", {}))) + self._user_cache[user_id] = user + return ClientSuccess(data=user, message="Slack user resolved") + + def get_channel(self, channel_id: str) -> ClientResult[UISlackChannel]: + """Resolve a Slack channel/conversation by ID, using cache when available.""" + if channel_id in self._channel_cache: + return ClientSuccess( + data=self._channel_cache[channel_id], message="Slack channel resolved" + ) + + try: + response = self.web_client.conversations_info(channel=channel_id) + except SlackApiError as exc: + return self._build_error("get_channel", exc, "GET_CHANNEL_ERROR") + except Exception as exc: + if hasattr(exc, "response"): + return self._build_error("get_channel", exc, "GET_CHANNEL_ERROR") + return ClientError( + error_message=f"Slack get_channel request failed: {exc}", + error_code="GET_CHANNEL_REQUEST_ERROR", + ) + + if not response.get("ok", False): + return self._build_error("get_channel", response, "GET_CHANNEL_ERROR") + + channel = self._to_ui_channel(self._map_channel(response.get("channel", {}))) + self._channel_cache[channel_id] = channel + return ClientSuccess(data=channel, message="Slack channel resolved") diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 3d74b1ab..2c5ab899 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -1,7 +1,13 @@ """Slack client facade backed by internal services.""" from . import sdk as slack_sdk_module -from .services import AuthService, ConversationService, DirectoryService, MessageService +from .services import ( + AuthService, + ConversationService, + DirectoryService, + IdentityResolver, + MessageService, +) from titan_cli.core.result import ClientResult from ..exceptions import SlackClientError @@ -33,6 +39,7 @@ def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 3 self.auth_service = AuthService(self._web_client) self.directory_service = DirectoryService(self._web_client) self.conversation_service = ConversationService(self._web_client) + self.identity_resolver = IdentityResolver(self._web_client) self.message_service = MessageService(self._web_client) @property @@ -47,6 +54,7 @@ def web_client(self, value) -> None: self.auth_service.web_client = value self.directory_service.web_client = value self.conversation_service.web_client = value + self.identity_resolver.web_client = value self.message_service.web_client = value def auth_test(self) -> ClientResult[UISlackAuth]: @@ -166,6 +174,14 @@ def open_direct_message(self, user_id: str) -> ClientResult[UISlackConversation] """Open or reuse a direct message conversation with a Slack user.""" return self.conversation_service.open_direct_message(user_id) + def get_user(self, user_id: str) -> ClientResult[UISlackUser]: + """Resolve a Slack user by ID.""" + return self.identity_resolver.get_user(user_id) + + def get_channel(self, channel_id: str) -> ClientResult[UISlackChannel]: + """Resolve a Slack channel by ID.""" + return self.identity_resolver.get_channel(channel_id) + def post_message( self, channel_id: str, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py index 7197943c..817f8963 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py @@ -7,6 +7,11 @@ filter_users_for_query, normalize_search_query, ) +from .identity_resolution_operations import ( + extract_identity_ids_from_messages, + replace_slack_mentions, + build_user_display_label, +) from .message_summary_operations import ( build_summary_prompt, format_messages_as_transcript, @@ -19,6 +24,9 @@ "filter_channels_for_query", "build_user_target", "build_channel_target", + "extract_identity_ids_from_messages", + "replace_slack_mentions", + "build_user_display_label", "format_messages_as_transcript", "truncate_transcript_for_summary", "build_summary_prompt", diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/identity_resolution_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/identity_resolution_operations.py new file mode 100644 index 00000000..9e38e1b7 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/identity_resolution_operations.py @@ -0,0 +1,65 @@ +"""Reusable operations for resolving Slack user and channel identities.""" + +from __future__ import annotations + +import re + +from ..models import UISlackMessage + + +USER_MENTION_RE = re.compile(r"<@([A-Z0-9]+)>") +CHANNEL_MENTION_RE = re.compile(r"<#([A-Z0-9]+)(?:\|[^>]+)?>") + + +def extract_identity_ids_from_messages( + messages: list[UISlackMessage], +) -> tuple[set[str], set[str]]: + """Extract unique Slack user and channel IDs referenced in messages.""" + user_ids: set[str] = set() + channel_ids: set[str] = set() + + for message in messages: + if message.user: + user_ids.add(message.user) + + text = message.text or "" + user_ids.update(USER_MENTION_RE.findall(text)) + channel_ids.update(CHANNEL_MENTION_RE.findall(text)) + + return user_ids, channel_ids + + +def build_user_display_label(user_display_names: dict[str, str], user_id: str | None) -> str: + """Return the preferred author label for a Slack user ID.""" + if not user_id: + return "Unknown" + return user_display_names.get(user_id, user_id) + + +def replace_slack_mentions( + text: str, + *, + user_display_names: dict[str, str] | None = None, + channel_display_names: dict[str, str] | None = None, +) -> str: + """Replace Slack user and channel mention markup with readable labels.""" + user_display_names = user_display_names or {} + channel_display_names = channel_display_names or {} + + def _replace_user(match: re.Match[str]) -> str: + user_id = match.group(1) + display_name = user_display_names.get(user_id) + if not display_name: + return f"@{user_id}" + return f"@{display_name}" + + def _replace_channel(match: re.Match[str]) -> str: + channel_id = match.group(1) + display_name = channel_display_names.get(channel_id) + if not display_name: + return f"#{channel_id}" + return f"#{display_name}" + + text = USER_MENTION_RE.sub(_replace_user, text) + text = CHANNEL_MENTION_RE.sub(_replace_channel, text) + return text diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py index b7dca4ef..2f20ff7c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py @@ -5,12 +5,18 @@ from datetime import datetime, timezone from ..models import UISlackMessage +from .identity_resolution_operations import ( + build_user_display_label, + replace_slack_mentions, +) def format_messages_as_transcript( messages: list[UISlackMessage], *, target_name: str | None = None, + user_display_names: dict[str, str] | None = None, + channel_display_names: dict[str, str] | None = None, ) -> str: """Format Slack messages as a compact transcript for downstream AI steps.""" lines: list[str] = [] @@ -19,8 +25,14 @@ def format_messages_as_transcript( lines.append("") for message in messages: + author = build_user_display_label(user_display_names or {}, message.user) + text = replace_slack_mentions( + message.text.strip(), + user_display_names=user_display_names, + channel_display_names=channel_display_names, + ) lines.append( - f"[{_format_slack_timestamp(message.ts)}] {message.user or 'Unknown'}: {message.text.strip()}" + f"[{_format_slack_timestamp(message.ts)}] {author}: {text}" ) return "\n".join(lines).strip() diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py index 71bf485c..8fc1aea6 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py @@ -9,6 +9,7 @@ from ..models import UISlackConversation, UISlackTarget from ..operations import ( build_summary_prompt, + extract_identity_ids_from_messages, format_messages_as_transcript, truncate_transcript_for_summary, ) @@ -240,6 +241,8 @@ def read_recent_messages_step(ctx: WorkflowContext) -> WorkflowResult: Outputs (saved to ctx.data): slack_messages (list[UISlackMessage]): Retrieved Slack messages. + slack_user_display_names (dict[str, str]): Resolved Slack user display names keyed by user ID. + slack_channel_display_names (dict[str, str]): Resolved Slack channel names keyed by channel ID. slack_messages_next_cursor (str | None): Pagination cursor for later reads. slack_messages_has_more (bool): Whether more messages are available. @@ -270,12 +273,34 @@ def read_recent_messages_step(ctx: WorkflowContext) -> WorkflowResult: match result: case ClientSuccess(data=(messages, next_cursor, has_more)): + user_display_names: dict[str, str] = {} + channel_display_names: dict[str, str] = {} + user_ids, channel_ids = extract_identity_ids_from_messages(messages) + + for user_id in sorted(user_ids): + resolved_user = ctx.slack.get_user(user_id) + match resolved_user: + case ClientSuccess(data=user): + user_display_names[user_id] = user.real_name or user.name or user.id + case ClientError(): + pass + + for channel_id in sorted(channel_ids): + resolved_channel = ctx.slack.get_channel(channel_id) + match resolved_channel: + case ClientSuccess(data=channel): + channel_display_names[channel_id] = channel.name or channel.id + case ClientError(): + pass + ctx.textual.success_text(f"Retrieved {len(messages)} Slack messages") ctx.textual.end_step("success") return Success( f"Retrieved {len(messages)} Slack messages", metadata={ "slack_messages": messages, + "slack_user_display_names": user_display_names, + "slack_channel_display_names": channel_display_names, "slack_messages_next_cursor": next_cursor, "slack_messages_has_more": has_more, }, @@ -326,7 +351,14 @@ def ai_summarize_messages_step(ctx: WorkflowContext) -> WorkflowResult: target_name = ctx.get("slack_target_name") max_chars = ctx.get("slack_summary_max_chars", 12000) - transcript = format_messages_as_transcript(messages, target_name=target_name) + user_display_names = ctx.get("slack_user_display_names", {}) + channel_display_names = ctx.get("slack_channel_display_names", {}) + transcript = format_messages_as_transcript( + messages, + target_name=target_name, + user_display_names=user_display_names, + channel_display_names=channel_display_names, + ) transcript = truncate_transcript_for_summary(transcript, max_chars=max_chars) prompt = build_summary_prompt(target_name, transcript) From 23901b61160a9c7359f0fe34a856e17a10089a8e Mon Sep 17 00:00:00 2001 From: finxo Date: Tue, 16 Jun 2026 16:10:08 +0200 Subject: [PATCH 19/23] feat: Add select_default_or_search_channel_target step for Slack summary workflow --- .../_generated/slack-step-inventory.json | 41 ++++- docs/plugins/_meta/slack-step-groups.json | 3 +- docs/plugins/slack/built-in-workflows.md | 8 +- docs/plugins/slack/workflow-steps.md | 2 + .../titan-plugin-slack/tests/test_plugin.py | 1 + .../tests/test_summary_workflow.py | 1 + .../tests/test_target_steps.py | 30 ++++ .../clients/slack_client.py | 9 +- .../titan_plugin_slack/plugin.py | 3 + .../titan_plugin_slack/steps/__init__.py | 7 +- .../titan_plugin_slack/steps/target_steps.py | 154 ++++++++++++++++++ .../workflows/summarize-slack-target.yaml | 4 +- 12 files changed, 251 insertions(+), 12 deletions(-) diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 849d32fb..22553e97 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -28,6 +28,10 @@ { "name": "select_channel_target", "summary": "Filter visible Slack channels by query and select one canonical channel target." + }, + { + "name": "select_default_or_search_channel_target", + "summary": "Choose one configured default channel or fall back to manual Slack channel search." } ] }, @@ -216,6 +220,39 @@ "send-slack-channel-message" ] }, + { + "name": "select_default_or_search_channel_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_default_or_search_channel_target_step", + "summary": "Select a Slack channel from the configured defaults or search for another one.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " slack_target_query (str, optional): Pre-filled query used if the user chooses to search manually.", + " slack_search_limit (int, optional): Maximum number of matches to return during manual search. Defaults to 20.", + " slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200.", + " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", + " slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True." + ], + "Outputs (saved to ctx.data)": [ + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`channel`).", + " slack_target_id (str): Slack channel ID.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection, when manual search was used." + ], + "Returns": [ + " Success: If the channel target is selected successfully.", + " Error: If Slack is unavailable, the configured channel cannot be resolved, or no match is selected." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] + }, { "name": "prepare_message_destination", "group": "Messaging", @@ -351,9 +388,7 @@ " Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected." ] }, - "used_by_workflows": [ - "summarize-slack-target" - ] + "used_by_workflows": [] }, { "name": "ensure_target_conversation", diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json index aa31b37f..934206f6 100644 --- a/docs/plugins/_meta/slack-step-groups.json +++ b/docs/plugins/_meta/slack-step-groups.json @@ -13,7 +13,8 @@ "name": "Selection and Target Resolution", "steps": [ {"name": "select_user_target", "summary": "Filter visible Slack users by query and select one canonical user target."}, - {"name": "select_channel_target", "summary": "Filter visible Slack channels by query and select one canonical channel target."} + {"name": "select_channel_target", "summary": "Filter visible Slack channels by query and select one canonical channel target."}, + {"name": "select_default_or_search_channel_target", "summary": "Choose one configured default channel or fall back to manual Slack channel search."} ] }, { diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index 4ca93b65..81fb12f4 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -76,22 +76,22 @@ Select a channel, prepare the destination from the selected target, compose a me ## `summarize-slack-target` -Search for a person or channel, resolve the backing conversation, read recent Slack messages, and summarize them with AI. +Choose one configured default channel or search for another one, read recent Slack messages, and summarize them with AI. **Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml` ### Default flow 1. `slack.validate_connection` -2. `slack.select_target` +2. `slack.select_default_or_search_channel_target` 3. `slack.ensure_target_conversation` 4. `slack.read_recent_messages` 5. `slack.ai_summarize_messages` ### Typical usage -- summarize a recent conversation without manually browsing the Slack UI -- inspect recent channel or DM context from Titan before taking action +- summarize a recent channel without manually browsing the Slack UI +- reuse repository-level default channels for common summary workflows while still allowing manual search when needed ### Scope constraints diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index 9394b3d6..f520b464 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -20,6 +20,7 @@ For full contract details for every public step, including documented inputs, ou | `list_users` | Validation and Discovery | `discover-slack-workspace` | | `select_user_target` | Selection and Target Resolution | `send-slack-direct-message` | | `select_channel_target` | Selection and Target Resolution | `send-slack-channel-message` | +| `select_default_or_search_channel_target` | Selection and Target Resolution | `summarize-slack-target` | | `prepare_message_destination` | Messaging | `send-slack-direct-message`, `send-slack-channel-message` | | `open_direct_message` | Messaging | - | | `prompt_message_body` | Messaging | `send-slack-direct-message`, `send-slack-channel-message` | @@ -43,6 +44,7 @@ Use these steps to resolve a reusable Slack target object for later workflows. - `select_user_target`: filter visible Slack users by query and select one canonical user target - `select_channel_target`: filter visible Slack channels by query and select one canonical channel target +- `select_default_or_search_channel_target`: choose one configured default channel or fall back to manual Slack channel search ## Messaging diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 97a00d57..3bfceb8e 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -25,6 +25,7 @@ def test_slack_plugin_exposes_public_steps() -> None: "list_users", "select_user_target", "select_channel_target", + "select_default_or_search_channel_target", "select_target", "prepare_message_destination", "ensure_target_conversation", diff --git a/plugins/titan-plugin-slack/tests/test_summary_workflow.py b/plugins/titan-plugin-slack/tests/test_summary_workflow.py index 1f72f6cc..a02ee25a 100644 --- a/plugins/titan-plugin-slack/tests/test_summary_workflow.py +++ b/plugins/titan-plugin-slack/tests/test_summary_workflow.py @@ -20,3 +20,4 @@ def test_summarize_slack_target_workflow_structure() -> None: "read_recent_messages", "ai_summarize_messages", ] + assert workflow["steps"][1]["step"] == "select_default_or_search_channel_target" diff --git a/plugins/titan-plugin-slack/tests/test_target_steps.py b/plugins/titan-plugin-slack/tests/test_target_steps.py index 70489943..9baa396f 100644 --- a/plugins/titan-plugin-slack/tests/test_target_steps.py +++ b/plugins/titan-plugin-slack/tests/test_target_steps.py @@ -6,6 +6,7 @@ from titan_plugin_slack.models import UISlackChannel, UISlackTarget, UISlackUser from titan_plugin_slack.steps.target_steps import ( select_channel_target_step, + select_default_or_search_channel_target_step, select_user_target_step, ) @@ -84,3 +85,32 @@ def test_select_channel_target_returns_target_metadata() -> None: assert result.metadata["slack_target_type"] == "channel" assert result.metadata["slack_target_id"] == "C2" assert result.metadata["slack_target_name"] == "eng-backend" + + +def test_select_default_or_search_channel_target_uses_configured_default() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.slack.default_channels = ["eng-backend"] + channel = UISlackChannel(id="C2", name="eng-backend") + ctx.textual.ask_option.return_value = "eng-backend" + ctx.slack.search_channels.return_value = ClientSuccess(data=[channel]) + + result = select_default_or_search_channel_target_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_target_id"] == "C2" + + +def test_select_default_or_search_channel_target_falls_back_to_search_when_no_defaults() -> None: + ctx = _build_context() + ctx.slack = MagicMock() + ctx.slack.default_channels = [] + ctx.textual.ask_text.return_value = "eng" + channel = UISlackChannel(id="C2", name="eng-backend") + ctx.slack.search_channels.return_value = ClientSuccess(data=[channel]) + ctx.textual.ask_option.return_value = channel + + result = select_default_or_search_channel_target_step(ctx) + + assert isinstance(result, Success) + assert result.metadata["slack_target_id"] == "C2" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py index 2c5ab899..58151941 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -27,13 +27,20 @@ class SlackClient: """Slack client facade used by the Slack plugin.""" - def __init__(self, user_token: str, team_id: str | None = None, timeout: int = 30): + def __init__( + self, + user_token: str, + team_id: str | None = None, + timeout: int = 30, + default_channels: list[str] | None = None, + ): if not user_token: raise SlackClientError("Slack client requires a user token.") self.user_token = user_token self.team_id = team_id self.timeout = timeout + self.default_channels = default_channels or [] self._web_client = WebClient(token=user_token, timeout=timeout) self.auth_service = AuthService(self._web_client) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index ead9d8fb..6856936e 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -69,6 +69,7 @@ def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: self._client = SlackClient( user_token=user_token, team_id=validated_config.default_team_id, + default_channels=validated_config.default_channels, ) def is_available(self) -> bool: @@ -97,6 +98,7 @@ def get_steps(self) -> dict: read_recent_messages_step, select_target_step, select_channel_target_step, + select_default_or_search_channel_target_step, select_user_target_step, validate_connection_step, ) @@ -107,6 +109,7 @@ def get_steps(self) -> dict: "list_users": list_users_step, "select_user_target": select_user_target_step, "select_channel_target": select_channel_target_step, + "select_default_or_search_channel_target": select_default_or_search_channel_target_step, "select_target": select_target_step, "prepare_message_destination": prepare_message_destination_step, "ensure_target_conversation": ensure_target_conversation_step, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py index 815a38bf..fbe02197 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -17,7 +17,11 @@ read_recent_messages_step, select_target_step, ) -from .target_steps import select_channel_target_step, select_user_target_step +from .target_steps import ( + select_channel_target_step, + select_default_or_search_channel_target_step, + select_user_target_step, +) __all__ = [ "validate_connection_step", @@ -33,4 +37,5 @@ "ai_summarize_messages_step", "select_user_target_step", "select_channel_target_step", + "select_default_or_search_channel_target_step", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py index 6927b110..019b4d6c 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py @@ -13,6 +13,7 @@ MIN_QUERY_LENGTH = 2 MAX_TARGET_OPTIONS = 20 +SEARCH_ANOTHER_CHANNEL = "__search_another_channel__" def select_user_target_step(ctx: WorkflowContext) -> WorkflowResult: @@ -111,6 +112,103 @@ def select_channel_target_step(ctx: WorkflowContext) -> WorkflowResult: ) +def select_default_or_search_channel_target_step(ctx: WorkflowContext) -> WorkflowResult: + """ + Select a Slack channel from the configured defaults or search for another one. + + Requires: + ctx.slack: An initialized SlackClient. + + Inputs (from ctx.data): + slack_target_query (str, optional): Pre-filled query used if the user chooses to search manually. + slack_search_limit (int, optional): Maximum number of matches to return during manual search. Defaults to 20. + slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200. + slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50. + slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True. + + Outputs (saved to ctx.data): + slack_target (UISlackTarget): Canonical selected Slack target. + slack_target_type (str): Selected target type (`channel`). + slack_target_id (str): Slack channel ID. + slack_target_name (str): User-facing target name. + slack_target_query (str): Query used to resolve the selection, when manual search was used. + + Returns: + Success: If the channel target is selected successfully. + Error: If Slack is unavailable, the configured channel cannot be resolved, or no match is selected. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + if not ctx.slack: + return Error("Slack client not available") + + configured_channels = getattr(ctx.slack, "default_channels", []) or [] + if not configured_channels: + return select_channel_target_step(ctx) + + ctx.textual.begin_step("Select Slack Channel Target") + + options = [ + OptionItem( + value=channel_name, + title=f"#{channel_name}", + description="Configured default channel", + ) + for channel_name in configured_channels + ] + options.append( + OptionItem( + value=SEARCH_ANOTHER_CHANNEL, + title="Search another channel", + description="Look up a channel by name", + ) + ) + + selected = ctx.textual.ask_option( + "Select a configured channel or search for another one:", + options=options, + ) + if not selected: + ctx.textual.error_text("No Slack channel was selected.") + ctx.textual.end_step("error") + return Error("No Slack channel was selected.") + + if selected == SEARCH_ANOTHER_CHANNEL: + ctx.textual.end_step("skip") + return select_channel_target_step(ctx) + + configured_channel_name = str(selected) + resolved = _resolve_channel_by_name(ctx, configured_channel_name) + match resolved: + case ClientSuccess(data=channel): + team_id = ctx.get("slack_team_id") + connection_id = ctx.get("slack_connection_id") + target = build_channel_target( + channel, + team_id=team_id, + connection_id=connection_id, + ) + ctx.textual.success_text( + f"Selected Slack target: {target.target_name} ({target.target_id})" + ) + ctx.textual.end_step("success") + return Success( + "Selected Slack channel target", + metadata={ + "slack_target": target, + "slack_target_type": target.target_type, + "slack_target_id": target.target_id, + "slack_target_name": target.target_name, + "slack_target_query": configured_channel_name, + }, + ) + case ClientError(error_message=err): + ctx.textual.error_text(err) + ctx.textual.end_step("error") + return Error(err) + + def _select_target_step( ctx: WorkflowContext, *, @@ -216,7 +314,63 @@ def _build_channel_option(channel) -> OptionItem: return OptionItem(value=channel, title=f"#{channel.name}", description=description) +def _normalize_channel_name(value: str) -> str: + return normalize_search_query(value).lstrip("#") + + +def _resolve_channel_by_name(ctx: WorkflowContext, channel_name: str): + normalized_name = _normalize_channel_name(channel_name) + if len(normalized_name) < MIN_QUERY_LENGTH: + return ClientError( + error_message="Configured Slack channel names must contain at least 2 characters.", + error_code="CHANNEL_NAME_TOO_SHORT", + ) + + search_limit = ctx.get("slack_search_limit", MAX_TARGET_OPTIONS) + page_size = ctx.get("slack_search_page_size", 200) + max_pages = ctx.get("slack_search_max_pages", 50) + exclude_archived = ctx.get("slack_exclude_archived", True) + + with ctx.textual.loading(f"Resolving configured channel #{normalized_name}..."): + result = ctx.slack.search_channels( + normalized_name, + max_matches=search_limit, + page_size=page_size, + max_pages=max_pages, + exclude_archived=exclude_archived, + ) + + match result: + case ClientError() as err: + return err + case ClientSuccess(data=channels): + exact_matches = [ + channel + for channel in channels + if _normalize_channel_name(channel.name) == normalized_name + ] + if not exact_matches: + return ClientError( + error_message=( + f"Configured Slack channel '#{channel_name}' was not found in this workspace." + ), + error_code="CONFIGURED_CHANNEL_NOT_FOUND", + ) + if len(exact_matches) > 1: + return ClientError( + error_message=( + f"Configured Slack channel '#{channel_name}' matched multiple channels." + ), + error_code="CONFIGURED_CHANNEL_AMBIGUOUS", + ) + return ClientSuccess( + data=exact_matches[0], + message="Configured Slack channel resolved", + ) + + __all__ = [ "select_user_target_step", "select_channel_target_step", + "select_default_or_search_channel_target_step", ] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml index 1e346c53..85aa7dd4 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml @@ -11,9 +11,9 @@ steps: step: validate_connection - id: select_target - name: "Select Slack Target" + name: "Select Slack Channel Target" plugin: slack - step: select_target + step: select_default_or_search_channel_target - id: ensure_target_conversation name: "Resolve Target Conversation" From 1800654d2cab9bfa4a7ded3b3f7b9da2ff128bfb Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 17 Jun 2026 08:55:36 +0200 Subject: [PATCH 20/23] docs: Update Slack plugin docs and enable Slack config in project --- docs/plugins/slack/built-in-workflows.md | 4 + docs/plugins/slack/client-api.md | 3 +- docs/plugins/slack/overview.md | 82 ++++++++++++++----- docs/plugins/slack/workflow-steps.md | 8 +- .../screens/slack_config_screen.py | 16 ++-- 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index 81fb12f4..2ae89ff6 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -25,6 +25,7 @@ Validate the current Slack connection, list public channels, and list visible us - the workflow stays read-only - it does not read channel history yet - it assumes one active Slack workspace binding for the current repository +- it uses the scopes granted in the last successful Slack OAuth connection for this project ## `send-slack-direct-message` @@ -49,6 +50,7 @@ Select a person, open or reuse a direct message conversation, compose a message, - this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline - it assumes one active Slack workspace binding for the current repository +- it does not use repo-configured `default_channels` ## `send-slack-channel-message` @@ -73,6 +75,7 @@ Select a channel, prepare the destination from the selected target, compose a me - this workflow depends on channel-posting Slack scopes beyond the earlier DM and discovery slices - it assumes one active Slack workspace binding for the current repository +- it selects channels through manual search ## `summarize-slack-target` @@ -97,3 +100,4 @@ Choose one configured default channel or search for another one, read recent Sla - this workflow depends on conversation-history scopes and AI configuration - it assumes one active Slack workspace binding for the current repository +- it currently follows a channel-oriented path through `select_default_or_search_channel_target` diff --git a/docs/plugins/slack/client-api.md b/docs/plugins/slack/client-api.md index 0c8e7ecb..e6828833 100644 --- a/docs/plugins/slack/client-api.md +++ b/docs/plugins/slack/client-api.md @@ -1,6 +1,6 @@ # Slack Client API -The Slack plugin adds read-oriented Slack operations to Titan through `SlackClient`. This page documents the plugin from a functional point of view and shows how each capability is called and which parameters it needs. +The Slack plugin exposes Slack operations through `SlackClient`. This page documents the current public client surface and the parameters each method accepts. ## Requirements @@ -110,3 +110,4 @@ Post a plain-text message to a Slack conversation. - The current client surface backs discovery, messaging, and summary workflows. - `read_channel()` exists in the client API but is not yet exposed as a public workflow step. - The current Slack integration assumes one active Slack workspace binding per repository. +- `granted_scopes` is recorded during OAuth connection setup; `auth_test()` validates the token but does not refresh that stored scope snapshot. diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 17dba11a..08216028 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -1,10 +1,12 @@ # Slack Plugin -The Slack plugin provides Titan's Slack integration for project-scoped Slack App configuration, personal user authentication, workspace validation, messaging, and discovery. It exposes: +The Slack plugin provides Titan's Slack integration for repo-scoped Slack App configuration, personal user authentication, workspace validation, discovery, messaging, and conversation summaries. -- a high-level `SlackClient` for direct use from Titan code -- reusable workflow `steps` for connection validation and discovery -- built-in workflows for validating, inspecting, summarizing, and messaging against the current project's Slack workspace +It exposes: + +- a public `SlackClient` +- reusable workflow steps +- built-in workflows for discovery, direct messages, channel messages, and channel summaries ## Requirements @@ -12,8 +14,8 @@ To use the Slack plugin in a project: - Enable the `slack` plugin in `.titan/config.toml` - Configure Slack through Titan's Slack-specific configuration screen -- Complete the BYO Slack App + PKCE connection flow -- Store the resulting personal Slack token in keyring for the current project +- Complete the BYO Slack App + PKCE flow +- Store the resulting personal Slack token in keyring for the active Titan project Example project configuration: @@ -25,38 +27,74 @@ enabled = true oauth_client_id = "1234567890.1234567890" default_team_id = "T12345678" default_team_name = "My Workspace" -granted_scopes = ["chat:write", "channels:read"] +granted_scopes = [ + "users:read", + "channels:read", + "channels:history", + "groups:read", + "groups:history", + "im:history", + "mpim:history", + "chat:write", + "im:write", + "mpim:write", + "channels:write", + "groups:write", +] default_channels = ["chapter-apps-android", "release-notes"] ``` -Slack stores the personal token for the current project in keyring, not in the config file. +Slack stores the personal token in keyring, not in the config file. + +## Slack App Setup + +Current setup expectations: + +- Use your own Slack App +- Enable PKCE for the OAuth flow +- Configure this exact redirect URI in Slack: + `http://127.0.0.1:8765/slack/callback` +- The redirect URI must match exactly, including host, port, and path +- `127.0.0.1` and `localhost` are different values for Slack OAuth + +## Scope Snapshot + +Titan currently requests these Slack scopes during OAuth: -## Public surfaces +- `users:read` +- `channels:read` +- `channels:history` +- `groups:read` +- `groups:history` +- `im:history` +- `mpim:history` +- `chat:write` +- `im:write` +- `mpim:write` +- `channels:write` +- `groups:write` + +`granted_scopes` in project config is the scope snapshot recorded from the last successful OAuth connection. + +## Public Surfaces - [Client API](./client-api.md): direct Python methods exposed by `SlackClient` - [Workflow Steps](./workflow-steps.md): public reusable workflow steps grouped by functionality - [Built-in Workflows](./built-in-workflows.md): workflows shipped by the plugin -## Accessing the client - -In Titan code, the public entry point is the Slack plugin client: +## Accessing the Client ```python slack_plugin = config.registry.get_plugin("slack") client = slack_plugin.get_client() ``` -The client returns direct values and raises plugin-level exceptions when Slack operations fail. - -## Public workflow steps +## Public Workflow Steps The Slack plugin currently exposes public reusable steps for: - validating the current Slack connection -- listing public channels visible to the current token -- listing users visible to the current token -- selecting a reusable Slack target from users or channels for later workflows -- preparing a unified Slack message destination and posting messages to direct messages or channels -- resolving a target conversation, reading recent messages, and summarizing them with AI - -The grouped reference lives in [Workflow Steps](./workflow-steps.md). +- listing visible users and public channels +- selecting Slack users or channels as workflow targets +- preparing a message destination and posting messages +- resolving a conversation, reading recent messages, and summarizing them with AI diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index f520b464..e2671814 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -1,6 +1,6 @@ # Slack Workflow Steps -The Slack plugin exposes public reusable workflow steps through `SlackPlugin.get_steps()`. The current step surface stays intentionally small, but now covers connection validation, target selection, messaging, and conversation summaries. +The Slack plugin exposes public reusable workflow steps through `SlackPlugin.get_steps()`. The current surface covers connection validation, target selection, messaging, and conversation summaries. For full contract details for every public step, including documented inputs, outputs, and return behavior, see the [detailed step reference](../generated/slack-step-reference.md). @@ -63,3 +63,9 @@ Use these steps to resolve a target conversation, read its recent messages, and - `ensure_target_conversation`: resolve a Slack conversation from the selected target - `read_recent_messages`: read the latest messages from the resolved conversation - `ai_summarize_messages`: summarize the retrieved messages with AI + +## Notes + +- Built-in workflows may use only a subset of these steps. +- `select_default_or_search_channel_target` is the step that uses repo-configured `default_channels`. +- The built-in summary workflow currently uses the channel-oriented default/search step, not the unified `select_target` step. diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 229cfae6..133a7760 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -263,7 +263,7 @@ def _refresh_view(self) -> None: f" OAuth Redirect Port: {DEFAULT_OAUTH_REDIRECT_PORT}\n" f" Team ID: {state.default_team_id or 'Not set'}\n" f" Team Name: {state.default_team_name or 'Not set'}\n" - f" Granted Scopes: {scopes}\n" + f" Recorded Granted Scopes: {scopes}\n" f" Default Channels: {', '.join('#' + channel for channel in state.default_channels) if state.default_channels else 'Not set'}" ) oauth_help.update( @@ -274,11 +274,15 @@ def _refresh_view(self) -> None: "For example, `127.0.0.1` and `localhost` are different values for Slack." ) scopes_block.update( - "Slack needs scopes that cover:\n" - " - user and channel discovery\n" - " - conversation history for summaries\n" - " - posting messages to direct messages and channels\n\n" - "After you connect, Titan records the granted scopes above in Current Status." + "Slack currently requests these scopes during OAuth:\n" + " - users:read\n" + " - channels:read, channels:history, channels:write\n" + " - groups:read, groups:history, groups:write\n" + " - im:history, im:write\n" + " - mpim:history, mpim:write\n" + " - chat:write\n\n" + "Current Status shows the scopes recorded from the last successful OAuth connection. " + "Use Reconnect Slack after changing scopes in your Slack App." ) if state.has_project_config and self._reconfigure_project_mode: connect_help.update( From 7d6f56ab7273d205c5cc40cc1a7a20a580bd3499 Mon Sep 17 00:00:00 2001 From: finxo Date: Wed, 17 Jun 2026 09:07:08 +0200 Subject: [PATCH 21/23] docs: Add Slack plugin documentation and update step inventory with new workflow steps --- .titan/operations/plugin_docs_operations.py | 5 + .../_generated/slack-step-inventory.json | 358 +++++----- .../plugins/generated/slack-step-reference.md | 174 ++++- docs/plugins/index.md | 2 +- docs/plugins/slack/built-in-workflows.md | 85 +-- docs/plugins/slack/overview.md | 10 +- docs/plugins/slack/workflow-steps.md | 614 ++++++++++++++++++ mkdocs.yml | 5 + .../tests/test_dm_workflow.py | 22 - .../tests/test_workflows.py | 61 +- .../workflows/discover-slack-workspace.yaml | 27 - .../workflows/send-slack-channel-message.yaml | 28 - .../workflows/send-slack-direct-message.yaml | 28 - 13 files changed, 968 insertions(+), 451 deletions(-) delete mode 100644 plugins/titan-plugin-slack/tests/test_dm_workflow.py delete mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml delete mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml delete mode 100644 plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml diff --git a/.titan/operations/plugin_docs_operations.py b/.titan/operations/plugin_docs_operations.py index 311b20df..2d0b67e0 100644 --- a/.titan/operations/plugin_docs_operations.py +++ b/.titan/operations/plugin_docs_operations.py @@ -24,12 +24,17 @@ "package_dir": "plugins/titan-plugin-jira", "plugin_ref": "titan_plugin_jira.plugin:JiraPlugin", }, + "slack": { + "package_dir": "plugins/titan-plugin-slack", + "plugin_ref": "titan_plugin_slack.plugin:SlackPlugin", + }, } WORKFLOW_STEPS_PAGE_PATHS = { "git": "docs/plugins/git/workflow-steps.md", "github": "docs/plugins/github/workflow-steps.md", "jira": "docs/plugins/jira/workflow-steps.md", + "slack": "docs/plugins/slack/workflow-steps.md", } SECTION_HEADERS = [ diff --git a/docs/plugins/_generated/slack-step-inventory.json b/docs/plugins/_generated/slack-step-inventory.json index 22553e97..35c97e5c 100644 --- a/docs/plugins/_generated/slack-step-inventory.json +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -80,29 +80,59 @@ ], "steps": [ { - "name": "validate_connection", - "group": "Validation and Discovery", - "module": "titan_plugin_slack.steps.discovery_steps", - "function": "validate_connection_step", - "summary": "Validate the configured Slack connection and expose identity metadata.", + "name": "ai_summarize_messages", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "ai_summarize_messages_step", + "summary": "Summarize recent Slack messages with AI.", + "docstring_sections": { + "Requires": [ + " ctx.textual: Textual UI context." + ], + "Inputs (from ctx.data)": [ + " slack_messages (list[UISlackMessage]): Messages to summarize.", + " slack_target_name (str, optional): Human-facing target label for the summary.", + " slack_summary_max_chars (int, optional): Maximum transcript size passed to AI. Defaults to 12000." + ], + "Outputs (saved to ctx.data)": [ + " slack_summary (str): AI-generated Slack summary.", + " slack_summary_source_count (int): Number of source messages summarized.", + " slack_summary_transcript_chars (int): Transcript size sent to AI after truncation." + ], + "Returns": [ + " Success: If the summary is generated successfully.", + " Skip: If AI is not configured or not available.", + " Error: If messages are missing or the AI request fails." + ] + }, + "used_by_workflows": [ + "summarize-slack-target" + ] + }, + { + "name": "ensure_target_conversation", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "ensure_target_conversation_step", + "summary": "Resolve a Slack conversation from the selected target.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], - "Inputs (from ctx.data)": [], + "Inputs (from ctx.data)": [ + " slack_target (UISlackTarget): Selected Slack target." + ], "Outputs (saved to ctx.data)": [ - " slack_auth (dict): Slack auth identity details from `auth_test()`.", - " slack_team_id (str | None): Team identifier reported by Slack.", - " slack_team_name (str | None): Team name reported by Slack.", - " slack_user_id (str | None): User identifier reported by Slack." + " slack_conversation (UISlackConversation): Resolved Slack conversation.", + " slack_conversation_id (str): Conversation ID used for later operations." ], "Returns": [ - " Success: If the Slack connection validates successfully.", - " Error: If the Slack client is not available or the auth request fails." + " Success: If the target conversation is resolved successfully.", + " Error: If Slack is unavailable, the target is missing, or the Slack request fails." ] }, "used_by_workflows": [ - "discover-slack-workspace" + "summarize-slack-target" ] }, { @@ -121,7 +151,7 @@ " slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True." ], "Outputs (saved to ctx.data)": [ - " slack_channels (list[NetworkSlackChannel]): Public channels returned by Slack.", + " slack_channels (list[UISlackChannel]): Public channels returned by Slack.", " slack_channels_next_cursor (str | None): Pagination cursor for a later request." ], "Returns": [ @@ -129,9 +159,7 @@ " Error: If the Slack client is not available or the Slack request fails." ] }, - "used_by_workflows": [ - "discover-slack-workspace" - ] + "used_by_workflows": [] }, { "name": "list_users", @@ -148,7 +176,7 @@ " slack_cursor (str, optional): Pagination cursor for the next page." ], "Outputs (saved to ctx.data)": [ - " slack_users (list[NetworkSlackUser]): Users returned by Slack.", + " slack_users (list[UISlackUser]): Users returned by Slack.", " slack_users_next_cursor (str | None): Pagination cursor for a later request." ], "Returns": [ @@ -156,102 +184,58 @@ " Error: If the Slack client is not available or the Slack request fails." ] }, - "used_by_workflows": [ - "discover-slack-workspace" - ] - }, - { - "name": "select_user_target", - "group": "Selection and Target Resolution", - "module": "titan_plugin_slack.steps.target_steps", - "function": "select_user_target_step", - "summary": "Filter visible Slack users by query and select one canonical user target.", - "docstring_sections": { - "Requires": [ - " ctx.slack: An initialized SlackClient." - ], - "Inputs (from ctx.data)": [ - " slack_users (list[UISlackUser]): Users visible to the current Slack token.", - " slack_target_query (str, optional): Pre-filled query used to filter Slack users." - ], - "Outputs (saved to ctx.data)": [ - " slack_target (UISlackTarget): Canonical selected Slack target.", - " slack_target_type (str): Selected target type (`user`).", - " slack_target_id (str): Slack user ID.", - " slack_target_name (str): User-facing target name.", - " slack_target_query (str): Query used to resolve the selection." - ], - "Returns": [ - " Success: If the user target is selected successfully.", - " Error: If Slack is unavailable, no users are available, the query is invalid, or no match is selected." - ] - }, - "used_by_workflows": [ - "send-slack-direct-message" - ] + "used_by_workflows": [] }, { - "name": "select_channel_target", - "group": "Selection and Target Resolution", - "module": "titan_plugin_slack.steps.target_steps", - "function": "select_channel_target_step", - "summary": "Filter visible Slack channels by query and select one canonical channel target.", + "name": "open_direct_message", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "open_direct_message_step", + "summary": "Open or reuse a direct message conversation for the selected Slack user target.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_channels (list[UISlackChannel]): Public channels visible to the current Slack token.", - " slack_target_query (str, optional): Pre-filled query used to filter Slack channels." + " slack_target (UISlackTarget): Selected Slack target. Must be a `user` target." ], "Outputs (saved to ctx.data)": [ - " slack_target (UISlackTarget): Canonical selected Slack target.", - " slack_target_type (str): Selected target type (`channel`).", - " slack_target_id (str): Slack channel ID.", - " slack_target_name (str): User-facing target name.", - " slack_target_query (str): Query used to resolve the selection." + " slack_conversation (UISlackConversation): Opened or reused Slack conversation.", + " slack_conversation_id (str): Conversation ID used for later message operations." ], "Returns": [ - " Success: If the channel target is selected successfully.", - " Error: If Slack is unavailable, no channels are available, the query is invalid, or no match is selected." + " Success: If the direct message conversation is ready.", + " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." ] }, - "used_by_workflows": [ - "send-slack-channel-message" - ] + "used_by_workflows": [] }, { - "name": "select_default_or_search_channel_target", - "group": "Selection and Target Resolution", - "module": "titan_plugin_slack.steps.target_steps", - "function": "select_default_or_search_channel_target_step", - "summary": "Select a Slack channel from the configured defaults or search for another one.", + "name": "post_message", + "group": "Messaging", + "module": "titan_plugin_slack.steps.message_steps", + "function": "post_message_step", + "summary": "Post a plain-text Slack message to the prepared conversation.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_target_query (str, optional): Pre-filled query used if the user chooses to search manually.", - " slack_search_limit (int, optional): Maximum number of matches to return during manual search. Defaults to 20.", - " slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200.", - " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", - " slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True." + " slack_conversation_id (str): Slack conversation ID to post into.", + " slack_message_text (str): Message body to post.", + " slack_thread_ts (str, optional): Thread timestamp for replies." ], "Outputs (saved to ctx.data)": [ - " slack_target (UISlackTarget): Canonical selected Slack target.", - " slack_target_type (str): Selected target type (`channel`).", - " slack_target_id (str): Slack channel ID.", - " slack_target_name (str): User-facing target name.", - " slack_target_query (str): Query used to resolve the selection, when manual search was used." + " slack_message (UISlackPostedMessage): Posted Slack message metadata.", + " slack_message_ts (str): Timestamp of the posted message.", + " slack_message_channel (str): Channel or conversation ID where the message was posted." ], "Returns": [ - " Success: If the channel target is selected successfully.", - " Error: If Slack is unavailable, the configured channel cannot be resolved, or no match is selected." + " Success: If the Slack message is posted successfully.", + " Error: If Slack is unavailable, required context is missing, or the Slack request fails." ] }, - "used_by_workflows": [ - "summarize-slack-target" - ] + "used_by_workflows": [] }, { "name": "prepare_message_destination", @@ -275,33 +259,6 @@ " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." ] }, - "used_by_workflows": [ - "send-slack-direct-message", - "send-slack-channel-message" - ] - }, - { - "name": "open_direct_message", - "group": "Messaging", - "module": "titan_plugin_slack.steps.message_steps", - "function": "open_direct_message_step", - "summary": "Open or reuse a direct message conversation for the selected user target.", - "docstring_sections": { - "Requires": [ - " ctx.slack: An initialized SlackClient." - ], - "Inputs (from ctx.data)": [ - " slack_target (UISlackTarget): Selected Slack target. Must be a `user` target." - ], - "Outputs (saved to ctx.data)": [ - " slack_conversation (UISlackConversation): Opened or reused Slack conversation.", - " slack_conversation_id (str): Conversation ID used for later message operations." - ], - "Returns": [ - " Success: If the direct message conversation is ready.", - " Error: If Slack is unavailable, the target is missing or invalid, or the Slack request fails." - ] - }, "used_by_workflows": [] }, { @@ -324,92 +281,96 @@ " Error: If the user cancels or the message body is empty." ] }, - "used_by_workflows": [ - "send-slack-direct-message", - "send-slack-channel-message" - ] + "used_by_workflows": [] }, { - "name": "post_message", - "group": "Messaging", - "module": "titan_plugin_slack.steps.message_steps", - "function": "post_message_step", - "summary": "Post the prepared message to the selected Slack conversation.", + "name": "read_recent_messages", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "function": "read_recent_messages_step", + "summary": "Read the most recent messages from the resolved Slack conversation.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_conversation_id (str): Slack conversation ID to post into.", - " slack_message_text (str): Message body to post.", - " slack_thread_ts (str, optional): Thread timestamp for replies." + " slack_conversation_id (str): Slack conversation ID to read.", + " slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 50." ], "Outputs (saved to ctx.data)": [ - " slack_message (UISlackPostedMessage): Posted Slack message metadata.", - " slack_message_ts (str): Timestamp of the posted message.", - " slack_message_channel (str): Channel or conversation ID where the message was posted." + " slack_messages (list[UISlackMessage]): Retrieved Slack messages.", + " slack_user_display_names (dict[str, str]): Resolved Slack user display names keyed by user ID.", + " slack_channel_display_names (dict[str, str]): Resolved Slack channel names keyed by channel ID.", + " slack_messages_next_cursor (str | None): Pagination cursor for later reads.", + " slack_messages_has_more (bool): Whether more messages are available." ], "Returns": [ - " Success: If the Slack message is posted successfully.", + " Success: If recent messages are retrieved successfully.", " Error: If Slack is unavailable, required context is missing, or the Slack request fails." ] }, "used_by_workflows": [ - "send-slack-direct-message", - "send-slack-channel-message" + "summarize-slack-target" ] }, { - "name": "select_target", - "group": "Conversation Summaries", - "module": "titan_plugin_slack.steps.summary_steps", - "function": "select_target_step", - "summary": "Search both users and channels for a single unified target selection.", + "name": "select_channel_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_channel_target_step", + "summary": "Select a Slack channel target through query filtering and final confirmation.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_target_query (str, optional): Query used to search both users and channels.", - " slack_search_limit (int, optional): Maximum number of matches to keep from each search. Defaults to 10.", - " slack_search_page_size (int, optional): Page size used while scanning Slack. Defaults to 200.", + " slack_target_query (str, optional): Pre-filled query used to filter Slack channels.", + " slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20.", + " slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200.", " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", - " slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True." + " slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True." ], "Outputs (saved to ctx.data)": [ " slack_target (UISlackTarget): Canonical selected Slack target.", - " slack_target_type (str): Selected target type (`user` or `channel`).", - " slack_target_id (str): Slack target identifier.", + " slack_target_type (str): Selected target type (`channel`).", + " slack_target_id (str): Slack channel ID.", " slack_target_name (str): User-facing target name.", " slack_target_query (str): Query used to resolve the selection." ], "Returns": [ - " Success: If the unified target is selected successfully.", + " Success: If the channel target is selected successfully.", " Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected." ] }, "used_by_workflows": [] }, { - "name": "ensure_target_conversation", - "group": "Conversation Summaries", - "module": "titan_plugin_slack.steps.summary_steps", - "function": "ensure_target_conversation_step", - "summary": "Resolve a Slack conversation from the selected target.", + "name": "select_default_or_search_channel_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_default_or_search_channel_target_step", + "summary": "Select a Slack channel from the configured defaults or search for another one.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_target (UISlackTarget): Selected Slack target." + " slack_target_query (str, optional): Pre-filled query used if the user chooses to search manually.", + " slack_search_limit (int, optional): Maximum number of matches to return during manual search. Defaults to 20.", + " slack_search_page_size (int, optional): Page size used while scanning Slack channels. Defaults to 200.", + " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", + " slack_exclude_archived (bool, optional): Whether to exclude archived channels while searching. Defaults to True." ], "Outputs (saved to ctx.data)": [ - " slack_conversation (UISlackConversation): Resolved Slack conversation.", - " slack_conversation_id (str): Conversation ID used for later operations." + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`channel`).", + " slack_target_id (str): Slack channel ID.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection, when manual search was used." ], "Returns": [ - " Success: If the target conversation is resolved successfully.", - " Error: If Slack is unavailable, the target is missing, or the Slack request fails." + " Success: If the channel target is selected successfully.", + " Error: If Slack is unavailable, the configured channel cannot be resolved, or no match is selected." ] }, "used_by_workflows": [ @@ -417,57 +378,88 @@ ] }, { - "name": "read_recent_messages", + "name": "select_target", "group": "Conversation Summaries", "module": "titan_plugin_slack.steps.summary_steps", - "function": "read_recent_messages_step", - "summary": "Read the most recent messages from the resolved Slack conversation.", + "function": "select_target_step", + "summary": "Search both Slack users and channels for a single unified target selection.", "docstring_sections": { "Requires": [ " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_conversation_id (str): Slack conversation ID to read.", - " slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 50." + " slack_target_query (str, optional): Query used to search both users and channels.", + " slack_search_limit (int, optional): Maximum number of matches to keep from each search. Defaults to 10.", + " slack_search_page_size (int, optional): Page size used while scanning Slack. Defaults to 200.", + " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50.", + " slack_exclude_archived (bool, optional): Whether to exclude archived channels. Defaults to True." ], "Outputs (saved to ctx.data)": [ - " slack_messages (list[UISlackMessage]): Retrieved Slack messages.", - " slack_messages_next_cursor (str | None): Pagination cursor for later reads.", - " slack_messages_has_more (bool): Whether more messages are available." + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`user` or `channel`).", + " slack_target_id (str): Slack target identifier.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection." ], "Returns": [ - " Success: If recent messages are retrieved successfully.", - " Error: If Slack is unavailable, required context is missing, or the Slack request fails." + " Success: If the unified target is selected successfully.", + " Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected." ] }, - "used_by_workflows": [ - "summarize-slack-target" - ] + "used_by_workflows": [] }, { - "name": "ai_summarize_messages", - "group": "Conversation Summaries", - "module": "titan_plugin_slack.steps.summary_steps", - "function": "ai_summarize_messages_step", - "summary": "Summarize the retrieved Slack messages with AI.", + "name": "select_user_target", + "group": "Selection and Target Resolution", + "module": "titan_plugin_slack.steps.target_steps", + "function": "select_user_target_step", + "summary": "Select a Slack user target through query filtering and final confirmation.", "docstring_sections": { "Requires": [ - " ctx.textual: Textual UI context." + " ctx.slack: An initialized SlackClient." ], "Inputs (from ctx.data)": [ - " slack_messages (list[UISlackMessage]): Messages to summarize.", - " slack_target_name (str, optional): Human-facing target label for the summary.", - " slack_summary_max_chars (int, optional): Maximum transcript size passed to AI. Defaults to 12000." + " slack_target_query (str, optional): Pre-filled query used to filter Slack users.", + " slack_search_limit (int, optional): Maximum number of matches to return. Defaults to 20.", + " slack_search_page_size (int, optional): Page size used while scanning Slack users. Defaults to 200.", + " slack_search_max_pages (int, optional): Maximum pages to scan while searching. Defaults to 50." ], "Outputs (saved to ctx.data)": [ - " slack_summary (str): AI-generated Slack summary.", - " slack_summary_source_count (int): Number of source messages summarized.", - " slack_summary_transcript_chars (int): Transcript size sent to AI after truncation." + " slack_target (UISlackTarget): Canonical selected Slack target.", + " slack_target_type (str): Selected target type (`user`).", + " slack_target_id (str): Slack user ID.", + " slack_target_name (str): User-facing target name.", + " slack_target_query (str): Query used to resolve the selection." ], "Returns": [ - " Success: If the summary is generated successfully.", - " Skip: If AI is not configured or not available.", - " Error: If messages are missing or the AI request fails." + " Success: If the user target is selected successfully.", + " Error: If Slack is unavailable, the query is invalid, the search fails, or no match is selected." + ] + }, + "used_by_workflows": [] + }, + { + "name": "validate_connection", + "group": "Validation and Discovery", + "module": "titan_plugin_slack.steps.discovery_steps", + "function": "validate_connection_step", + "summary": "Validate the configured Slack connection and expose identity metadata.", + "docstring_sections": { + "Requires": [ + " ctx.slack: An initialized SlackClient." + ], + "Inputs (from ctx.data)": [ + " None documented." + ], + "Outputs (saved to ctx.data)": [ + " slack_auth (UISlackAuth): Slack auth identity details from `auth_test()`.", + " slack_team_id (str | None): Team identifier reported by Slack.", + " slack_team_name (str | None): Team name reported by Slack.", + " slack_user_id (str | None): User identifier reported by Slack." + ], + "Returns": [ + " Success: If the Slack connection validates successfully.", + " Error: If the Slack client is not available or the auth request fails." ] }, "used_by_workflows": [ diff --git a/docs/plugins/generated/slack-step-reference.md b/docs/plugins/generated/slack-step-reference.md index dae4f59f..d6166598 100644 --- a/docs/plugins/generated/slack-step-reference.md +++ b/docs/plugins/generated/slack-step-reference.md @@ -21,7 +21,7 @@ Validate the configured Slack connection and expose identity metadata. step: validate_connection ``` -**Used by built-in workflows:** `discover-slack-workspace` +**Used by built-in workflows:** `summarize-slack-target` **Available to later steps:** `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` @@ -31,14 +31,20 @@ Validate the configured Slack connection and expose identity metadata. |------|------|-------------| | `ctx.slack` | - | An initialized SlackClient. | +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| None documented. | - | - | + **Outputs (saved to ctx.data)** | Name | Type | Description | |------|------|-------------| -| `slack_auth` | dict | Slack auth identity details from `auth_test()`. | -| `slack_team_id` | str \| None | Team identifier reported by Slack. | -| `slack_team_name` | str \| None | Team name reported by Slack. | -| `slack_user_id` | str \| None | User identifier reported by Slack. | +| `slack_auth` | UISlackAuth | Slack auth identity details from `auth_test()`. | +| `slack_team_id` | str | None | Team identifier reported by Slack. | +| `slack_team_name` | str | None | Team name reported by Slack. | +| `slack_user_id` | str | None | User identifier reported by Slack. | **Returns** @@ -64,8 +70,6 @@ List public Slack channels visible to the current token. step: list_public_channels ``` -**Used by built-in workflows:** `discover-slack-workspace` - **Available to later steps:** `slack_channels`, `slack_channels_next_cursor` **Requires** @@ -86,8 +90,8 @@ List public Slack channels visible to the current token. | Name | Type | Description | |------|------|-------------| -| `slack_channels` | list[NetworkSlackChannel] | Public channels returned by Slack. | -| `slack_channels_next_cursor` | str \| None | Pagination cursor for a later request. | +| `slack_channels` | list[UISlackChannel] | Public channels returned by Slack. | +| `slack_channels_next_cursor` | str | None | Pagination cursor for a later request. | **Returns** @@ -113,8 +117,6 @@ List Slack users visible to the current token. step: list_users ``` -**Used by built-in workflows:** `discover-slack-workspace` - **Available to later steps:** `slack_users`, `slack_users_next_cursor` **Requires** @@ -134,8 +136,8 @@ List Slack users visible to the current token. | Name | Type | Description | |------|------|-------------| -| `slack_users` | list[NetworkSlackUser] | Users returned by Slack. | -| `slack_users_next_cursor` | str \| None | Pagination cursor for a later request. | +| `slack_users` | list[UISlackUser] | Users returned by Slack. | +| `slack_users_next_cursor` | str | None | Pagination cursor for a later request. | **Returns** @@ -148,7 +150,7 @@ List Slack users visible to the current token. ### `select_user_target` -Filter visible Slack users by query and select one canonical user target. +Select a Slack user target through query filtering and final confirmation. **How to read this contract** @@ -175,8 +177,10 @@ Filter visible Slack users by query and select one canonical user target. | Name | Type | Description | |------|------|-------------| -| `slack_users` | list[UISlackUser] | Users visible to the current Slack token. | | `slack_target_query` | str, optional | Pre-filled query used to filter Slack users. | +| `slack_search_limit` | int, optional | Maximum number of matches to return. Defaults to 20. | +| `slack_search_page_size` | int, optional | Page size used while scanning Slack users. Defaults to 200. | +| `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | **Outputs (saved to ctx.data)** @@ -193,11 +197,11 @@ Filter visible Slack users by query and select one canonical user target. | Result | Saved for later steps | Description | |--------|-----------------------|-------------| | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the user target is selected successfully. | -| `Error` | - | If Slack is unavailable, no users are available, the query is invalid, or no match is selected. | +| `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | ### `select_channel_target` -Filter visible Slack channels by query and select one canonical channel target. +Select a Slack channel target through query filtering and final confirmation. **How to read this contract** @@ -224,8 +228,11 @@ Filter visible Slack channels by query and select one canonical channel target. | Name | Type | Description | |------|------|-------------| -| `slack_channels` | list[UISlackChannel] | Public channels visible to the current Slack token. | | `slack_target_query` | str, optional | Pre-filled query used to filter Slack channels. | +| `slack_search_limit` | int, optional | Maximum number of matches to return. Defaults to 20. | +| `slack_search_page_size` | int, optional | Page size used while scanning Slack channels. Defaults to 200. | +| `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | +| `slack_exclude_archived` | bool, optional | Whether to exclude archived channels while searching. Defaults to True. | **Outputs (saved to ctx.data)** @@ -242,13 +249,112 @@ Filter visible Slack channels by query and select one canonical channel target. | Result | Saved for later steps | Description | |--------|-----------------------|-------------| | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | -| `Error` | - | If Slack is unavailable, no channels are available, the query is invalid, or no match is selected. | +| `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | + +### `select_default_or_search_channel_target` + +Select a Slack channel from the configured defaults or search for another one. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: select_default_or_search_channel_target +``` + +**Used by built-in workflows:** `summarize-slack-target` + +**Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target_query` | str, optional | Pre-filled query used if the user chooses to search manually. | +| `slack_search_limit` | int, optional | Maximum number of matches to return during manual search. Defaults to 20. | +| `slack_search_page_size` | int, optional | Page size used while scanning Slack channels. Defaults to 200. | +| `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | +| `slack_exclude_archived` | bool, optional | Whether to exclude archived channels while searching. Defaults to True. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Canonical selected Slack target. | +| `slack_target_type` | str | Selected target type (`channel`). | +| `slack_target_id` | str | Slack channel ID. | +| `slack_target_name` | str | User-facing target name. | +| `slack_target_query` | str | Query used to resolve the selection, when manual search was used. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | +| `Error` | - | If Slack is unavailable, the configured channel cannot be resolved, or no match is selected. | ## Messaging +### `prepare_message_destination` + +Prepare a Slack message destination from the selected target. + +**How to read this contract** + +- `Inputs (from ctx.data)` shows what the step expects before it runs. +- `Outputs (saved to ctx.data)` shows the metadata keys later steps can read after `Success` or `Skip`. +- `Returns` describes the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate function return payload. + +**Workflow usage** + +```yaml +- plugin: slack + step: prepare_message_destination +``` + +**Available to later steps:** `slack_conversation`, `slack_conversation_id` + +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.slack` | - | An initialized SlackClient. | + +**Inputs (from ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_target` | UISlackTarget | Selected Slack target. Must be a `user` or `channel` target. | + +**Outputs (saved to ctx.data)** + +| Name | Type | Description | +|------|------|-------------| +| `slack_conversation` | UISlackConversation | Resolved Slack destination conversation. | +| `slack_conversation_id` | str | Conversation or channel ID used for later message operations. | + +**Returns** + +| Result | Saved for later steps | Description | +|--------|-----------------------|-------------| +| `Success` | `slack_conversation`, `slack_conversation_id` | If the Slack message destination is ready. | +| `Error` | - | If Slack is unavailable, the target is missing or invalid, or the Slack request fails. | + ### `open_direct_message` -Open or reuse a direct message conversation for the selected user target. +Open or reuse a direct message conversation for the selected Slack user target. **How to read this contract** @@ -263,8 +369,6 @@ Open or reuse a direct message conversation for the selected user target. step: open_direct_message ``` -**Used by built-in workflows:** `send-slack-direct-message` - **Available to later steps:** `slack_conversation`, `slack_conversation_id` **Requires** @@ -310,8 +414,6 @@ Capture a multiline Slack message body for later posting. step: prompt_message_body ``` -**Used by built-in workflows:** `send-slack-direct-message` - **Available to later steps:** `slack_message_text` **Inputs (from ctx.data)** @@ -336,7 +438,7 @@ Capture a multiline Slack message body for later posting. ### `post_message` -Post the prepared message to the selected Slack conversation. +Post a plain-text Slack message to the prepared conversation. **How to read this contract** @@ -351,8 +453,6 @@ Post the prepared message to the selected Slack conversation. step: post_message ``` -**Used by built-in workflows:** `send-slack-direct-message` - **Available to later steps:** `slack_message`, `slack_message_ts`, `slack_message_channel` **Requires** @@ -388,7 +488,7 @@ Post the prepared message to the selected Slack conversation. ### `select_target` -Search both users and channels for a single unified target selection. +Search both Slack users and channels for a single unified target selection. **How to read this contract** @@ -403,8 +503,6 @@ Search both users and channels for a single unified target selection. step: select_target ``` -**Used by built-in workflows:** `summarize-slack-target` - **Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` **Requires** @@ -506,7 +604,7 @@ Read the most recent messages from the resolved Slack conversation. **Used by built-in workflows:** `summarize-slack-target` -**Available to later steps:** `slack_messages`, `slack_messages_next_cursor`, `slack_messages_has_more` +**Available to later steps:** `slack_messages`, `slack_user_display_names`, `slack_channel_display_names`, `slack_messages_next_cursor`, `slack_messages_has_more` **Requires** @@ -526,14 +624,16 @@ Read the most recent messages from the resolved Slack conversation. | Name | Type | Description | |------|------|-------------| | `slack_messages` | list[UISlackMessage] | Retrieved Slack messages. | -| `slack_messages_next_cursor` | str \| None | Pagination cursor for later reads. | +| `slack_user_display_names` | dict[str, str] | Resolved Slack user display names keyed by user ID. | +| `slack_channel_display_names` | dict[str, str] | Resolved Slack channel names keyed by channel ID. | +| `slack_messages_next_cursor` | str | None | Pagination cursor for later reads. | | `slack_messages_has_more` | bool | Whether more messages are available. | **Returns** | Result | Saved for later steps | Description | |--------|-----------------------|-------------| -| `Success` | `slack_messages`, `slack_messages_next_cursor`, `slack_messages_has_more` | If recent messages are retrieved successfully. | +| `Success` | `slack_messages`, `slack_user_display_names`, `slack_channel_display_names`, `slack_messages_next_cursor`, `slack_messages_has_more` | If recent messages are retrieved successfully. | | `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | ### `ai_summarize_messages` @@ -557,6 +657,12 @@ Summarize recent Slack messages with AI. **Available to later steps:** `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` +**Requires** + +| Name | Type | Description | +|------|------|-------------| +| `ctx.textual` | - | Textual UI context. | + **Inputs (from ctx.data)** | Name | Type | Description | @@ -578,5 +684,5 @@ Summarize recent Slack messages with AI. | Result | Saved for later steps | Description | |--------|-----------------------|-------------| | `Success` | `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` | If the summary is generated successfully. | -| `Skip` | - | If AI is not configured or not available. | +| `Skip` | `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` | If AI is not configured or not available. | | `Error` | - | If messages are missing or the AI request fails. | diff --git a/docs/plugins/index.md b/docs/plugins/index.md index abaea335..036f25cc 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -18,7 +18,7 @@ Titan ships with four official plugins: | **git** | Smart commits, branch management, AI-powered commit messages | | **github** | Create PRs with AI descriptions, manage issues, code reviews | | **jira** | Search issues, AI-powered analysis, workflow automation | -| **slack** | Personal Slack auth, workspace validation, and read-only discovery | +| **slack** | Personal Slack auth, workspace summaries, and reusable Slack workflow steps | Enable them per project in `.titan/config.toml`: diff --git a/docs/plugins/slack/built-in-workflows.md b/docs/plugins/slack/built-in-workflows.md index 2ae89ff6..1f9b2f0a 100644 --- a/docs/plugins/slack/built-in-workflows.md +++ b/docs/plugins/slack/built-in-workflows.md @@ -1,81 +1,6 @@ # Slack Built-in Workflows -The Slack plugin currently ships a small set of built-in workflows for workspace discovery, direct messaging, channel messaging, and conversation summaries. - -## `discover-slack-workspace` - -Validate the current Slack connection, list public channels, and list visible users. - -**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml` - -### Default flow - -1. `slack.validate_connection` -2. `slack.list_public_channels` -3. `slack.list_users` - -### Typical usage - -- verify that Slack OAuth configuration is working end to end -- inspect what the current project's Slack token can read before building richer workflows -- confirm the first public Slack step surface behaves coherently inside Titan - -### Scope constraints - -- the workflow stays read-only -- it does not read channel history yet -- it assumes one active Slack workspace binding for the current repository -- it uses the scopes granted in the last successful Slack OAuth connection for this project - -## `send-slack-direct-message` - -Select a person, open or reuse a direct message conversation, compose a message, and send it. - -**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml` - -### Default flow - -1. `slack.validate_connection` -2. `slack.select_user_target` -3. `slack.prepare_message_destination` -4. `slack.prompt_message_body` -5. `slack.post_message` - -### Typical usage - -- send a personal message to one selected Slack user from Titan -- validate that DM-specific Slack scopes and the direct-message path are working end to end - -### Scope constraints - -- this workflow depends on DM-related Slack scopes beyond the original discovery-only baseline -- it assumes one active Slack workspace binding for the current repository -- it does not use repo-configured `default_channels` - -## `send-slack-channel-message` - -Select a channel, prepare the destination from the selected target, compose a message, and send it. - -**Source workflow:** `plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml` - -### Default flow - -1. `slack.validate_connection` -2. `slack.select_channel_target` -3. `slack.prepare_message_destination` -4. `slack.prompt_message_body` -5. `slack.post_message` - -### Typical usage - -- send a message to one selected Slack channel from Titan -- validate that channel-posting scopes and the shared messaging path are working end to end - -### Scope constraints - -- this workflow depends on channel-posting Slack scopes beyond the earlier DM and discovery slices -- it assumes one active Slack workspace binding for the current repository -- it selects channels through manual search +The Slack plugin currently ships one built-in workflow for channel summaries. ## `summarize-slack-target` @@ -101,3 +26,11 @@ Choose one configured default channel or search for another one, read recent Sla - this workflow depends on conversation-history scopes and AI configuration - it assumes one active Slack workspace binding for the current repository - it currently follows a channel-oriented path through `select_default_or_search_channel_target` + +### Related public steps + +- `validate_connection` +- `select_default_or_search_channel_target` +- `ensure_target_conversation` +- `read_recent_messages` +- `ai_summarize_messages` diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 08216028..7ba21ba2 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -6,7 +6,7 @@ It exposes: - a public `SlackClient` - reusable workflow steps -- built-in workflows for discovery, direct messages, channel messages, and channel summaries +- one built-in workflow for channel summaries ## Requirements @@ -98,3 +98,11 @@ The Slack plugin currently exposes public reusable steps for: - selecting Slack users or channels as workflow targets - preparing a message destination and posting messages - resolving a conversation, reading recent messages, and summarizing them with AI + +## Built-in Workflows + +The Slack plugin currently ships one built-in workflow: + +- `summarize-slack-target` + +Other Slack capabilities remain available as public reusable steps for composition from project workflows or other plugin workflows. diff --git a/docs/plugins/slack/workflow-steps.md b/docs/plugins/slack/workflow-steps.md index e2671814..05bcea73 100644 --- a/docs/plugins/slack/workflow-steps.md +++ b/docs/plugins/slack/workflow-steps.md @@ -69,3 +69,617 @@ Use these steps to resolve a target conversation, read its recent messages, and - Built-in workflows may use only a subset of these steps. - `select_default_or_search_channel_target` is the step that uses repo-configured `default_channels`. - The built-in summary workflow currently uses the channel-oriented default/search step, not the unified `select_target` step. + + +## Detailed Step Contracts + +The summaries above show what each slack step is for. The sections below show the documented contract for each public step: what it expects from `ctx.data`, what it saves back, and what result types it may return. + +Expand a step to see its workflow usage, required context, inputs, outputs, and result behavior. + +How to read these contracts: + +- `Inputs (from ctx.data)` = values the step expects before it runs. +- `Outputs (saved to ctx.data)` = metadata keys saved for later steps when the step returns `Success` or `Skip`. +- `Returns` = the workflow result type (`Success`, `Skip`, `Error`, `Exit`), not a separate payload. + +### Validation and Discovery + +??? info "`validate_connection`" + Validate the configured Slack connection and expose identity metadata. + + **Workflow usage** + + ```yaml + - plugin: slack + step: validate_connection + ``` + + **Used by built-in workflows:** `summarize-slack-target` + + **Available to later steps:** `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | None documented. | - | - | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_auth` | UISlackAuth | Slack auth identity details from `auth_test()`. | + | `slack_team_id` | str | None | Team identifier reported by Slack. | + | `slack_team_name` | str | None | Team name reported by Slack. | + | `slack_user_id` | str | None | User identifier reported by Slack. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_auth`, `slack_team_id`, `slack_team_name`, `slack_user_id` | If the Slack connection validates successfully. | + | `Error` | - | If the Slack client is not available or the auth request fails. | + + +??? info "`list_public_channels`" + List public Slack channels visible to the current token. + + **Workflow usage** + + ```yaml + - plugin: slack + step: list_public_channels + ``` + + **Available to later steps:** `slack_channels`, `slack_channels_next_cursor` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_limit` | int, optional | Maximum number of channels to request. Defaults to 100. | + | `slack_cursor` | str, optional | Pagination cursor for the next page. | + | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_channels` | list[UISlackChannel] | Public channels returned by Slack. | + | `slack_channels_next_cursor` | str | None | Pagination cursor for a later request. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_channels`, `slack_channels_next_cursor` | If the channel list is retrieved successfully. | + | `Error` | - | If the Slack client is not available or the Slack request fails. | + + +??? info "`list_users`" + List Slack users visible to the current token. + + **Workflow usage** + + ```yaml + - plugin: slack + step: list_users + ``` + + **Available to later steps:** `slack_users`, `slack_users_next_cursor` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_limit` | int, optional | Maximum number of users to request. Defaults to 100. | + | `slack_cursor` | str, optional | Pagination cursor for the next page. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_users` | list[UISlackUser] | Users returned by Slack. | + | `slack_users_next_cursor` | str | None | Pagination cursor for a later request. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_users`, `slack_users_next_cursor` | If the user list is retrieved successfully. | + | `Error` | - | If the Slack client is not available or the Slack request fails. | + + +### Selection and Target Resolution + +??? info "`select_user_target`" + Select a Slack user target through query filtering and final confirmation. + + **Workflow usage** + + ```yaml + - plugin: slack + step: select_user_target + ``` + + **Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target_query` | str, optional | Pre-filled query used to filter Slack users. | + | `slack_search_limit` | int, optional | Maximum number of matches to return. Defaults to 20. | + | `slack_search_page_size` | int, optional | Page size used while scanning Slack users. Defaults to 200. | + | `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Canonical selected Slack target. | + | `slack_target_type` | str | Selected target type (`user`). | + | `slack_target_id` | str | Slack user ID. | + | `slack_target_name` | str | User-facing target name. | + | `slack_target_query` | str | Query used to resolve the selection. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the user target is selected successfully. | + | `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | + + +??? info "`select_channel_target`" + Select a Slack channel target through query filtering and final confirmation. + + **Workflow usage** + + ```yaml + - plugin: slack + step: select_channel_target + ``` + + **Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target_query` | str, optional | Pre-filled query used to filter Slack channels. | + | `slack_search_limit` | int, optional | Maximum number of matches to return. Defaults to 20. | + | `slack_search_page_size` | int, optional | Page size used while scanning Slack channels. Defaults to 200. | + | `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | + | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels while searching. Defaults to True. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Canonical selected Slack target. | + | `slack_target_type` | str | Selected target type (`channel`). | + | `slack_target_id` | str | Slack channel ID. | + | `slack_target_name` | str | User-facing target name. | + | `slack_target_query` | str | Query used to resolve the selection. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | + | `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | + + +??? info "`select_default_or_search_channel_target`" + Select a Slack channel from the configured defaults or search for another one. + + **Workflow usage** + + ```yaml + - plugin: slack + step: select_default_or_search_channel_target + ``` + + **Used by built-in workflows:** `summarize-slack-target` + + **Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target_query` | str, optional | Pre-filled query used if the user chooses to search manually. | + | `slack_search_limit` | int, optional | Maximum number of matches to return during manual search. Defaults to 20. | + | `slack_search_page_size` | int, optional | Page size used while scanning Slack channels. Defaults to 200. | + | `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | + | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels while searching. Defaults to True. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Canonical selected Slack target. | + | `slack_target_type` | str | Selected target type (`channel`). | + | `slack_target_id` | str | Slack channel ID. | + | `slack_target_name` | str | User-facing target name. | + | `slack_target_query` | str | Query used to resolve the selection, when manual search was used. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the channel target is selected successfully. | + | `Error` | - | If Slack is unavailable, the configured channel cannot be resolved, or no match is selected. | + + +### Messaging + +??? info "`prepare_message_destination`" + Prepare a Slack message destination from the selected target. + + **Workflow usage** + + ```yaml + - plugin: slack + step: prepare_message_destination + ``` + + **Available to later steps:** `slack_conversation`, `slack_conversation_id` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Selected Slack target. Must be a `user` or `channel` target. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_conversation` | UISlackConversation | Resolved Slack destination conversation. | + | `slack_conversation_id` | str | Conversation or channel ID used for later message operations. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_conversation`, `slack_conversation_id` | If the Slack message destination is ready. | + | `Error` | - | If Slack is unavailable, the target is missing or invalid, or the Slack request fails. | + + +??? info "`open_direct_message`" + Open or reuse a direct message conversation for the selected Slack user target. + + **Workflow usage** + + ```yaml + - plugin: slack + step: open_direct_message + ``` + + **Available to later steps:** `slack_conversation`, `slack_conversation_id` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Selected Slack target. Must be a `user` target. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_conversation` | UISlackConversation | Opened or reused Slack conversation. | + | `slack_conversation_id` | str | Conversation ID used for later message operations. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_conversation`, `slack_conversation_id` | If the direct message conversation is ready. | + | `Error` | - | If Slack is unavailable, the target is missing or invalid, or the Slack request fails. | + + +??? info "`prompt_message_body`" + Capture a multiline Slack message body for later posting. + + **Workflow usage** + + ```yaml + - plugin: slack + step: prompt_message_body + ``` + + **Available to later steps:** `slack_message_text` + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_message_text` | str, optional | Pre-filled message text. If already present, the prompt is skipped. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_message_text` | str | Message text to post later. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_message_text` | If the message body is captured successfully. | + | `Skip` | `slack_message_text` | If the message body already exists in context. | + | `Error` | - | If the user cancels or the message body is empty. | + + +??? info "`post_message`" + Post a plain-text Slack message to the prepared conversation. + + **Workflow usage** + + ```yaml + - plugin: slack + step: post_message + ``` + + **Available to later steps:** `slack_message`, `slack_message_ts`, `slack_message_channel` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_conversation_id` | str | Slack conversation ID to post into. | + | `slack_message_text` | str | Message body to post. | + | `slack_thread_ts` | str, optional | Thread timestamp for replies. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_message` | UISlackPostedMessage | Posted Slack message metadata. | + | `slack_message_ts` | str | Timestamp of the posted message. | + | `slack_message_channel` | str | Channel or conversation ID where the message was posted. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_message`, `slack_message_ts`, `slack_message_channel` | If the Slack message is posted successfully. | + | `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | + + +### Conversation Summaries + +??? info "`select_target`" + Search both Slack users and channels for a single unified target selection. + + **Workflow usage** + + ```yaml + - plugin: slack + step: select_target + ``` + + **Available to later steps:** `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target_query` | str, optional | Query used to search both users and channels. | + | `slack_search_limit` | int, optional | Maximum number of matches to keep from each search. Defaults to 10. | + | `slack_search_page_size` | int, optional | Page size used while scanning Slack. Defaults to 200. | + | `slack_search_max_pages` | int, optional | Maximum pages to scan while searching. Defaults to 50. | + | `slack_exclude_archived` | bool, optional | Whether to exclude archived channels. Defaults to True. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Canonical selected Slack target. | + | `slack_target_type` | str | Selected target type (`user` or `channel`). | + | `slack_target_id` | str | Slack target identifier. | + | `slack_target_name` | str | User-facing target name. | + | `slack_target_query` | str | Query used to resolve the selection. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_target`, `slack_target_type`, `slack_target_id`, `slack_target_name`, `slack_target_query` | If the unified target is selected successfully. | + | `Error` | - | If Slack is unavailable, the query is invalid, the search fails, or no match is selected. | + + +??? info "`ensure_target_conversation`" + Resolve a Slack conversation from the selected target. + + **Workflow usage** + + ```yaml + - plugin: slack + step: ensure_target_conversation + ``` + + **Used by built-in workflows:** `summarize-slack-target` + + **Available to later steps:** `slack_conversation`, `slack_conversation_id` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_target` | UISlackTarget | Selected Slack target. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_conversation` | UISlackConversation | Resolved Slack conversation. | + | `slack_conversation_id` | str | Conversation ID used for later operations. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_conversation`, `slack_conversation_id` | If the target conversation is resolved successfully. | + | `Error` | - | If Slack is unavailable, the target is missing, or the Slack request fails. | + + +??? info "`read_recent_messages`" + Read the most recent messages from the resolved Slack conversation. + + **Workflow usage** + + ```yaml + - plugin: slack + step: read_recent_messages + ``` + + **Used by built-in workflows:** `summarize-slack-target` + + **Available to later steps:** `slack_messages`, `slack_user_display_names`, `slack_channel_display_names`, `slack_messages_next_cursor`, `slack_messages_has_more` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.slack` | - | An initialized SlackClient. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_conversation_id` | str | Slack conversation ID to read. | + | `slack_history_limit` | int, optional | Number of recent messages to fetch. Defaults to 50. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_messages` | list[UISlackMessage] | Retrieved Slack messages. | + | `slack_user_display_names` | dict[str, str] | Resolved Slack user display names keyed by user ID. | + | `slack_channel_display_names` | dict[str, str] | Resolved Slack channel names keyed by channel ID. | + | `slack_messages_next_cursor` | str | None | Pagination cursor for later reads. | + | `slack_messages_has_more` | bool | Whether more messages are available. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_messages`, `slack_user_display_names`, `slack_channel_display_names`, `slack_messages_next_cursor`, `slack_messages_has_more` | If recent messages are retrieved successfully. | + | `Error` | - | If Slack is unavailable, required context is missing, or the Slack request fails. | + + +??? info "`ai_summarize_messages`" + Summarize recent Slack messages with AI. + + **Workflow usage** + + ```yaml + - plugin: slack + step: ai_summarize_messages + ``` + + **Used by built-in workflows:** `summarize-slack-target` + + **Available to later steps:** `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` + + **Requires** + + | Name | Type | Description | + |------|------|-------------| + | `ctx.textual` | - | Textual UI context. | + + **Inputs (from ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_messages` | list[UISlackMessage] | Messages to summarize. | + | `slack_target_name` | str, optional | Human-facing target label for the summary. | + | `slack_summary_max_chars` | int, optional | Maximum transcript size passed to AI. Defaults to 12000. | + + **Outputs (saved to ctx.data)** + + | Name | Type | Description | + |------|------|-------------| + | `slack_summary` | str | AI-generated Slack summary. | + | `slack_summary_source_count` | int | Number of source messages summarized. | + | `slack_summary_transcript_chars` | int | Transcript size sent to AI after truncation. | + + **Returns** + + | Result | Saved for later steps | Description | + |--------|-----------------------|-------------| + | `Success` | `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` | If the summary is generated successfully. | + | `Skip` | `slack_summary`, `slack_summary_source_count`, `slack_summary_transcript_chars` | If AI is not configured or not available. | + | `Error` | - | If messages are missing or the AI request fails. | + diff --git a/mkdocs.yml b/mkdocs.yml index 9872d8e0..4b4928c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,11 @@ nav: - Client API: plugins/jira/client-api.md - Workflow Steps: plugins/jira/workflow-steps.md - Built-in Workflows: plugins/jira/built-in-workflows.md + - Slack: + - Overview: plugins/slack/overview.md + - Client API: plugins/slack/client-api.md + - Workflow Steps: plugins/slack/workflow-steps.md + - Built-in Workflows: plugins/slack/built-in-workflows.md - Contributing: - Development Setup: contributing/development-setup.md - Architecture: contributing/architecture.md diff --git a/plugins/titan-plugin-slack/tests/test_dm_workflow.py b/plugins/titan-plugin-slack/tests/test_dm_workflow.py deleted file mode 100644 index 77b9ab9c..00000000 --- a/plugins/titan-plugin-slack/tests/test_dm_workflow.py +++ /dev/null @@ -1,22 +0,0 @@ -from pathlib import Path - -import yaml - - -def test_send_slack_direct_message_workflow_structure() -> None: - workflow_path = ( - Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-direct-message.yaml" - ) - - with open(workflow_path, encoding="utf-8") as handle: - workflow = yaml.safe_load(handle) - - assert workflow["name"] == "Send Slack Direct Message" - step_ids = [step["id"] for step in workflow["steps"]] - assert step_ids == [ - "validate_connection", - "select_user_target", - "prepare_message_destination", - "prompt_message_body", - "post_message", - ] diff --git a/plugins/titan-plugin-slack/tests/test_workflows.py b/plugins/titan-plugin-slack/tests/test_workflows.py index 9138b528..f93a2d55 100644 --- a/plugins/titan-plugin-slack/tests/test_workflows.py +++ b/plugins/titan-plugin-slack/tests/test_workflows.py @@ -3,63 +3,22 @@ import yaml -def test_discover_slack_workspace_workflow_structure() -> None: +def test_summarize_slack_target_workflow_structure() -> None: workflow_path = ( - Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "discover-slack-workspace.yaml" + Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "summarize-slack-target.yaml" ) with open(workflow_path, encoding="utf-8") as handle: workflow = yaml.safe_load(handle) - assert workflow["name"] == "Discover Slack Workspace" - assert workflow["params"]["slack_limit"] == 20 - assert workflow["params"]["slack_exclude_archived"] is True - - steps = workflow["steps"] - assert [step["id"] for step in steps] == [ - "validate_connection", - "list_public_channels", - "list_users", - ] - - assert steps[0]["plugin"] == "slack" - assert steps[0]["step"] == "validate_connection" - assert steps[1]["step"] == "list_public_channels" - assert steps[2]["step"] == "list_users" - - -def test_send_slack_direct_message_workflow_structure() -> None: - workflow_path = ( - Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-direct-message.yaml" - ) - - with open(workflow_path, encoding="utf-8") as handle: - workflow = yaml.safe_load(handle) - - assert workflow["name"] == "Send Slack Direct Message" - assert [step["id"] for step in workflow["steps"]] == [ - "validate_connection", - "select_user_target", - "prepare_message_destination", - "prompt_message_body", - "post_message", - ] - assert workflow["steps"][2]["step"] == "prepare_message_destination" - - -def test_send_slack_channel_message_workflow_structure() -> None: - workflow_path = ( - Path(__file__).parent.parent / "titan_plugin_slack" / "workflows" / "send-slack-channel-message.yaml" - ) - - with open(workflow_path, encoding="utf-8") as handle: - workflow = yaml.safe_load(handle) - - assert workflow["name"] == "Send Slack Channel Message" + assert workflow["name"] == "Summarize Slack Target" + assert workflow["params"]["slack_history_limit"] == 50 assert [step["id"] for step in workflow["steps"]] == [ "validate_connection", - "select_channel_target", - "prepare_message_destination", - "prompt_message_body", - "post_message", + "select_target", + "ensure_target_conversation", + "read_recent_messages", + "ai_summarize_messages", ] + assert workflow["steps"][1]["step"] == "select_default_or_search_channel_target" + assert workflow["steps"][3]["params"]["slack_history_limit"] == "${slack_history_limit}" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml deleted file mode 100644 index d230b73f..00000000 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/discover-slack-workspace.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Discover Slack Workspace" -description: "Validate the current Slack connection and inspect accessible users and public channels" - -params: - slack_limit: 20 - slack_exclude_archived: true - -steps: - - id: validate_connection - name: "Validate Slack Connection" - plugin: slack - step: validate_connection - - - id: list_public_channels - name: "List Public Channels" - plugin: slack - step: list_public_channels - params: - slack_limit: "${slack_limit}" - slack_exclude_archived: "${slack_exclude_archived}" - - - id: list_users - name: "List Users" - plugin: slack - step: list_users - params: - slack_limit: "${slack_limit}" diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml deleted file mode 100644 index 2c4d2a17..00000000 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-channel-message.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Send Slack Channel Message" -description: "Select a channel, prepare the channel destination, compose a message, and send it" - -steps: - - id: validate_connection - name: "Validate Slack Connection" - plugin: slack - step: validate_connection - - - id: select_channel_target - name: "Select Slack Channel Target" - plugin: slack - step: select_channel_target - - - id: prepare_message_destination - name: "Prepare Message Destination" - plugin: slack - step: prepare_message_destination - - - id: prompt_message_body - name: "Compose Message" - plugin: slack - step: prompt_message_body - - - id: post_message - name: "Send Message" - plugin: slack - step: post_message diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml deleted file mode 100644 index f440165f..00000000 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/send-slack-direct-message.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Send Slack Direct Message" -description: "Select a person, open or reuse a direct message conversation, and send a Slack message" - -steps: - - id: validate_connection - name: "Validate Slack Connection" - plugin: slack - step: validate_connection - - - id: select_user_target - name: "Select Slack User Target" - plugin: slack - step: select_user_target - - - id: prepare_message_destination - name: "Prepare Message Destination" - plugin: slack - step: prepare_message_destination - - - id: prompt_message_body - name: "Compose Message" - plugin: slack - step: prompt_message_body - - - id: post_message - name: "Send Message" - plugin: slack - step: post_message From a6acf6c6c4c39278a4813ce1889e63b2087527ba Mon Sep 17 00:00:00 2001 From: finxo Date: Mon, 22 Jun 2026 10:22:16 +0200 Subject: [PATCH 22/23] refactor: Remove keyring fallback and enforce authed_user token extraction in OAuth flow --- .../titan-plugin-slack/tests/test_oauth.py | 43 ++++++++++++++++++- .../tests/ui/test_slack_config_screen.py | 28 ++++++++++++ .../titan_plugin_slack/oauth.py | 15 +++++-- tests/core/test_secrets.py | 11 ++++- titan_cli/core/secrets.py | 7 +-- 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py index 53e584bd..7783b6ae 100644 --- a/plugins/titan-plugin-slack/tests/test_oauth.py +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -1,5 +1,6 @@ from urllib.parse import parse_qs, urlparse +import pytest import requests from titan_plugin_slack.oauth import SlackOAuthError, SlackOAuthFlow @@ -46,10 +47,9 @@ def post(url, data, timeout): return _FakeResponse( { "ok": True, - "access_token": "xoxp-token", "scope": "users:read,channels:read", "team": {"id": "T123", "name": "Acme"}, - "authed_user": {"id": "U123"}, + "authed_user": {"id": "U123", "access_token": "xoxp-token"}, } ) @@ -64,6 +64,45 @@ def post(url, data, timeout): assert result.granted_scopes == ["users:read", "channels:read"] +def test_exchange_code_raises_when_authed_user_payload_is_missing() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse( + { + "ok": True, + "access_token": "xoxp-top-level-token", + "scope": "users:read,channels:read", + "team": {"id": "T123", "name": "Acme"}, + } + ) + + flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) + + with pytest.raises(SlackOAuthError, match="authed_user payload"): + flow.exchange_code("code-123", "verifier-123") + + +def test_exchange_code_raises_when_authed_user_access_token_is_missing() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse( + { + "ok": True, + "access_token": "xoxp-top-level-token", + "scope": "users:read,channels:read", + "team": {"id": "T123", "name": "Acme"}, + "authed_user": {"id": "U123"}, + } + ) + + flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) + + with pytest.raises(SlackOAuthError, match="authed_user.access_token"): + flow.exchange_code("code-123", "verifier-123") + + def test_exchange_code_raises_on_slack_error() -> None: class FakeRequests: @staticmethod diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index fa97671c..84e19ad6 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -1,4 +1,5 @@ from pathlib import Path +import asyncio from unittest.mock import MagicMock, PropertyMock import tomli @@ -181,6 +182,33 @@ def test_slack_config_screen_saves_oauth_app_config(tmp_path: Path) -> None: assert data["plugins"]["slack"]["enabled"] is True +def test_slack_config_screen_oauth_connect_fails_when_keyring_write_fails(tmp_path: Path) -> None: + config = _build_config(tmp_path) + screen = SlackConfigScreen(config) + + app = MagicMock() + type(screen).app = PropertyMock(return_value=app) + + expected = SlackOAuthResult( + access_token="xoxp-token", + granted_scopes=["users:read"], + team_id="T123", + team_name="Acme", + authed_user_id="U123", + ) + screen._perform_oauth_connect = MagicMock(return_value=expected) + config.secrets.set.side_effect = RuntimeError("keyring unavailable") + screen._remove_project_config = MagicMock() + + asyncio.run(screen._run_oauth_connect("123", ["general"])) + + app.notify.assert_called_once_with( + "Slack OAuth failed: keyring unavailable", + severity="error", + ) + screen._remove_project_config.assert_called_once() + + def test_slack_config_screen_save_project_config_enables_plugin(tmp_path: Path) -> None: config = _build_config(tmp_path) screen = SlackConfigScreen(config) diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index d05d19d6..2e11fb87 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -237,15 +237,22 @@ def exchange_code(self, code: str, code_verifier: str) -> SlackOAuthResult: f"Slack OAuth token exchange failed: {payload.get('error', 'unknown_error')}" ) - access_token = payload.get("access_token") or payload.get("authed_user", {}).get("access_token") + authed_user = payload.get("authed_user") + if not isinstance(authed_user, dict): + raise SlackOAuthError( + "Slack OAuth token exchange succeeded without an authed_user payload." + ) + + access_token = authed_user.get("access_token") if not access_token: - raise SlackOAuthError("Slack OAuth token exchange succeeded without an access token.") + raise SlackOAuthError( + "Slack OAuth token exchange succeeded without authed_user.access_token." + ) - scope_string = payload.get("scope") or payload.get("authed_user", {}).get("scope") or "" + scope_string = payload.get("scope") or authed_user.get("scope") or "" granted_scopes = [scope.strip() for scope in scope_string.split(",") if scope.strip()] team = payload.get("team") or {} - authed_user = payload.get("authed_user") or {} logger.info( "slack_oauth_exchange_succeeded", team_id=team.get("id"), diff --git a/tests/core/test_secrets.py b/tests/core/test_secrets.py index be776ba6..e9be347f 100644 --- a/tests/core/test_secrets.py +++ b/tests/core/test_secrets.py @@ -77,6 +77,16 @@ def test_set_user_scope(mock_keyring): sm.set("my_user_secret", "user_value", scope="user") mock_keyring[1].assert_called_once_with("titan", "my_user_secret", "user_value") + +def test_set_user_scope_raises_when_keyring_write_fails(mock_keyring, tmp_project_path): + mock_keyring[1].side_effect = RuntimeError("keyring unavailable") + sm = SecretManager(project_path=tmp_project_path) + + with pytest.raises(RuntimeError, match="keyring unavailable"): + sm.set("my_user_secret", "user_value", scope="user") + + assert not (tmp_project_path / ".titan" / "secrets.env").exists() + def test_set_project_scope_new_secret(tmp_project_path): sm = SecretManager(project_path=tmp_project_path) sm.set("my_project_secret", "project_value", scope="project") @@ -140,4 +150,3 @@ def test_delete_project_scope_secret_not_found(tmp_project_path): with open(secrets_file, "r") as f: content = f.read() assert "OTHER_KEY='other_value'" in content # Content should be unchanged - diff --git a/titan_cli/core/secrets.py b/titan_cli/core/secrets.py index c572725d..208663d6 100644 --- a/titan_cli/core/secrets.py +++ b/titan_cli/core/secrets.py @@ -78,12 +78,7 @@ def set( elif scope == "user": # Store in system keyring (most secure) - try: - keyring.set_password(namespace, key, value) - except Exception: - # Fallback to project scope if keyring fails (common on macOS with unsigned apps) - # Recursively call with project scope - self.set(key, value, scope="project") + keyring.set_password(namespace, key, value) elif scope == "project": # Store in .titan/secrets.env From b5ce582ea43599fd3c640553da0fe61f42a05251 Mon Sep 17 00:00:00 2001 From: finxo Date: Mon, 22 Jun 2026 14:16:57 +0200 Subject: [PATCH 23/23] feat: Add PKCE token refresh support and update Slack OAuth flow to handle expiring tokens with rotation --- docs/plugins/slack/overview.md | 5 +- .../titan-plugin-slack/tests/test_oauth.py | 62 ++++++++-- .../titan-plugin-slack/tests/test_plugin.py | 90 +++++++++++++- .../tests/test_summary_steps.py | 19 +++ .../tests/test_summary_workflow.py | 2 +- .../tests/test_workflows.py | 2 +- .../tests/ui/test_slack_config_screen.py | 14 ++- .../titan_plugin_slack/config/__init__.py | 20 +++- .../titan_plugin_slack/oauth.py | 66 +++++++++-- .../titan_plugin_slack/plugin.py | 111 +++++++++++++++++- .../screens/slack_config_screen.py | 35 +++++- .../titan_plugin_slack/steps/summary_steps.py | 45 +++++-- .../workflows/summarize-slack-target.yaml | 2 +- 13 files changed, 438 insertions(+), 35 deletions(-) diff --git a/docs/plugins/slack/overview.md b/docs/plugins/slack/overview.md index 7ba21ba2..d4d97b6c 100644 --- a/docs/plugins/slack/overview.md +++ b/docs/plugins/slack/overview.md @@ -44,7 +44,8 @@ granted_scopes = [ default_channels = ["chapter-apps-android", "release-notes"] ``` -Slack stores the personal token in keyring, not in the config file. +Slack stores the personal access token, refresh token, and token-expiry metadata in keyring, not in the config file. +Project config stores shared non-secret Slack metadata such as workspace binding and granted scopes. ## Slack App Setup @@ -57,6 +58,8 @@ Current setup expectations: - The redirect URI must match exactly, including host, port, and path - `127.0.0.1` and `localhost` are different values for Slack OAuth +With PKCE enabled for localhost redirects, Slack may issue expiring user tokens together with refresh tokens. Titan refreshes these tokens automatically for the active project and persists the rotated secrets in keyring. + ## Scope Snapshot Titan currently requests these Slack scopes during OAuth: diff --git a/plugins/titan-plugin-slack/tests/test_oauth.py b/plugins/titan-plugin-slack/tests/test_oauth.py index 7783b6ae..8fa4273c 100644 --- a/plugins/titan-plugin-slack/tests/test_oauth.py +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -47,9 +47,13 @@ def post(url, data, timeout): return _FakeResponse( { "ok": True, + "access_token": "xoxe.xoxp-token", + "refresh_token": "xoxe-refresh-token", + "expires_in": 43200, + "token_type": "Bearer", "scope": "users:read,channels:read", "team": {"id": "T123", "name": "Acme"}, - "authed_user": {"id": "U123", "access_token": "xoxp-token"}, + "user_id": "U123", } ) @@ -57,40 +61,53 @@ def post(url, data, timeout): result = flow.exchange_code("code-123", "verifier-123") - assert result.access_token == "xoxp-token" + assert result.access_token == "xoxe.xoxp-token" + assert result.refresh_token == "xoxe-refresh-token" + assert result.expires_in == 43200 + assert result.token_type == "Bearer" assert result.team_id == "T123" assert result.team_name == "Acme" assert result.authed_user_id == "U123" assert result.granted_scopes == ["users:read", "channels:read"] -def test_exchange_code_raises_when_authed_user_payload_is_missing() -> None: +def test_exchange_code_falls_back_to_authed_user_access_token() -> None: class FakeRequests: @staticmethod def post(url, data, timeout): return _FakeResponse( { "ok": True, - "access_token": "xoxp-top-level-token", "scope": "users:read,channels:read", "team": {"id": "T123", "name": "Acme"}, + "authed_user": { + "id": "U123", + "access_token": "xoxp-token", + "refresh_token": "xoxe-refresh-token", + "expires_in": 43200, + "token_type": "user", + }, } ) flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) - with pytest.raises(SlackOAuthError, match="authed_user payload"): - flow.exchange_code("code-123", "verifier-123") + result = flow.exchange_code("code-123", "verifier-123") + + assert result.access_token == "xoxp-token" + assert result.refresh_token == "xoxe-refresh-token" + assert result.expires_in == 43200 + assert result.token_type == "user" + assert result.authed_user_id == "U123" -def test_exchange_code_raises_when_authed_user_access_token_is_missing() -> None: +def test_exchange_code_raises_when_access_token_is_missing() -> None: class FakeRequests: @staticmethod def post(url, data, timeout): return _FakeResponse( { "ok": True, - "access_token": "xoxp-top-level-token", "scope": "users:read,channels:read", "team": {"id": "T123", "name": "Acme"}, "authed_user": {"id": "U123"}, @@ -99,10 +116,37 @@ def post(url, data, timeout): flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) - with pytest.raises(SlackOAuthError, match="authed_user.access_token"): + with pytest.raises(SlackOAuthError, match="did not include an access token"): flow.exchange_code("code-123", "verifier-123") +def test_refresh_access_token_returns_rotated_credentials() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + assert data["grant_type"] == "refresh_token" + assert data["refresh_token"] == "xoxe-refresh-token" + return _FakeResponse( + { + "ok": True, + "access_token": "xoxe.xoxp-new-token", + "refresh_token": "xoxe-new-refresh-token", + "expires_in": 43200, + "token_type": "Bearer", + "scope": "users:read,channels:read", + "team": {"id": "T123", "name": "Acme"}, + } + ) + + flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) + + result = flow.refresh_access_token("xoxe-refresh-token") + + assert result.access_token == "xoxe.xoxp-new-token" + assert result.refresh_token == "xoxe-new-refresh-token" + assert result.expires_in == 43200 + + def test_exchange_code_raises_on_slack_error() -> None: class FakeRequests: @staticmethod diff --git a/plugins/titan-plugin-slack/tests/test_plugin.py b/plugins/titan-plugin-slack/tests/test_plugin.py index 3bfceb8e..5bf63d82 100644 --- a/plugins/titan-plugin-slack/tests/test_plugin.py +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -1,9 +1,13 @@ +from pathlib import Path from unittest.mock import MagicMock +import tomli + import pytest from titan_plugin_slack.plugin import SlackPlugin from titan_plugin_slack.exceptions import SlackConfigurationError +from titan_plugin_slack.oauth import SlackOAuthResult def test_slack_plugin_basic_properties() -> None: @@ -73,7 +77,7 @@ def test_slack_plugin_initialize_uses_personal_token() -> None: } config.get_project_name.return_value = "demo-project" secrets = MagicMock() - secrets.get.return_value = "xoxp-user-token" + secrets.get.side_effect = ["xoxp-user-token", None, None] plugin.initialize(config, secrets) @@ -81,4 +85,86 @@ def test_slack_plugin_initialize_uses_personal_token() -> None: assert client.user_token == "xoxp-user-token" assert client.team_id == "T123" assert client.timeout == 30 - secrets.get.assert_called_once_with("demo-project_slack_user_token") + assert secrets.get.call_args_list[0].args == ("demo-project_slack_user_token",) + assert secrets.get.call_args_list[1].args == ("demo-project_slack_refresh_token",) + + +def test_slack_plugin_initialize_refreshes_expiring_pkce_token(tmp_path: Path, monkeypatch) -> None: + plugin = SlackPlugin() + project_config_path = tmp_path / "project-config.toml" + project_config_path.write_text( + """ +[plugins.slack] +enabled = true + +[plugins.slack.config] +oauth_client_id = "123" +default_team_id = "T123" +default_team_name = "Acme" +granted_scopes = ["users:read"] +default_channels = ["general"] +""".strip() + ) + + config = MagicMock() + config.project_config_path = project_config_path + config.get_project_name.return_value = "demo-project" + config.config = MagicMock() + config.config.config_version = "1.0" + config.config.plugins = { + "slack": MagicMock( + config={ + "oauth_client_id": "123", + "default_team_id": "T123", + "default_team_name": "Acme", + "granted_scopes": ["users:read"], + "default_channels": ["general"], + } + ) + } + + def fake_load() -> None: + with open(project_config_path, "rb") as f: + data = tomli.load(f) + config.config.plugins = { + "slack": MagicMock(config=data["plugins"]["slack"]["config"]) + } + + config.load = MagicMock(side_effect=fake_load) + + secrets = MagicMock() + secrets.get.side_effect = ["xoxe-old-token", "xoxe-old-refresh-token", "1"] + + refreshed = SlackOAuthResult( + access_token="xoxe-new-token", + refresh_token="xoxe-new-refresh-token", + expires_in=43200, + token_type="Bearer", + granted_scopes=["users:read", "channels:read"], + team_id="T123", + team_name="Acme", + authed_user_id=None, + ) + + class FakeFlow: + def __init__(self, client_id): + self.client_id = client_id + + def refresh_access_token(self, refresh_token): + assert refresh_token == "xoxe-old-refresh-token" + return refreshed + + monkeypatch.setattr("titan_plugin_slack.plugin.SlackOAuthFlow", FakeFlow) + + plugin.initialize(config, secrets) + + client = plugin.get_client() + assert client.user_token == "xoxe-new-token" + secrets.set.assert_any_call("demo-project_slack_user_token", "xoxe-new-token", scope="user") + secrets.set.assert_any_call( + "demo-project_slack_refresh_token", "xoxe-new-refresh-token", scope="user" + ) + expires_at_call = next( + call for call in secrets.set.call_args_list if call.args[0] == "demo-project_slack_token_expires_at" + ) + assert expires_at_call.kwargs["scope"] == "user" diff --git a/plugins/titan-plugin-slack/tests/test_summary_steps.py b/plugins/titan-plugin-slack/tests/test_summary_steps.py index d2229975..2c3c3924 100644 --- a/plugins/titan-plugin-slack/tests/test_summary_steps.py +++ b/plugins/titan-plugin-slack/tests/test_summary_steps.py @@ -87,6 +87,7 @@ def test_read_recent_messages_returns_messages() -> None: assert isinstance(result, Success) assert len(result.metadata["slack_messages"]) == 1 + ctx.slack.read_conversation.assert_called_once_with("C123", limit=30) def test_ai_summarize_messages_skips_without_ai() -> None: @@ -123,3 +124,21 @@ def test_ai_summarize_messages_returns_error_for_empty_summary() -> None: assert isinstance(result, Error) assert result.message == "AI returned an empty Slack summary." + + +def test_ai_summarize_messages_returns_visual_error_for_rate_limit() -> None: + ctx = _build_context() + ctx.ai = MagicMock() + ctx.ai.is_available.return_value = True + ctx.ai.generate.side_effect = RuntimeError("Rate limit exceeded: 429 RESOURCE_EXHAUSTED") + ctx.data["slack_messages"] = [UISlackMessage(ts="1", text="Hello", user="U123")] + + result = ai_summarize_messages_step(ctx) + + assert isinstance(result, Error) + assert result.message == ( + "AI summary is temporarily rate limited by the configured AI provider. " + "Please wait and try again." + ) + ctx.textual.error_text.assert_called_once_with(result.message) + ctx.textual.end_step.assert_called_with("error") diff --git a/plugins/titan-plugin-slack/tests/test_summary_workflow.py b/plugins/titan-plugin-slack/tests/test_summary_workflow.py index a02ee25a..acf206ff 100644 --- a/plugins/titan-plugin-slack/tests/test_summary_workflow.py +++ b/plugins/titan-plugin-slack/tests/test_summary_workflow.py @@ -12,7 +12,7 @@ def test_summarize_slack_target_workflow_structure() -> None: workflow = yaml.safe_load(handle) assert workflow["name"] == "Summarize Slack Target" - assert workflow["params"]["slack_history_limit"] == 50 + assert workflow["params"]["slack_history_limit"] == 30 assert [step["id"] for step in workflow["steps"]] == [ "validate_connection", "select_target", diff --git a/plugins/titan-plugin-slack/tests/test_workflows.py b/plugins/titan-plugin-slack/tests/test_workflows.py index f93a2d55..1666396f 100644 --- a/plugins/titan-plugin-slack/tests/test_workflows.py +++ b/plugins/titan-plugin-slack/tests/test_workflows.py @@ -12,7 +12,7 @@ def test_summarize_slack_target_workflow_structure() -> None: workflow = yaml.safe_load(handle) assert workflow["name"] == "Summarize Slack Target" - assert workflow["params"]["slack_history_limit"] == 50 + assert workflow["params"]["slack_history_limit"] == 30 assert [step["id"] for step in workflow["steps"]] == [ "validate_connection", "select_target", diff --git a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py index 84e19ad6..ff9edc91 100644 --- a/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -81,7 +81,9 @@ def test_slack_config_screen_disconnect_only_deletes_project_token(tmp_path: Pat ) screen._disconnect() - config.secrets.delete.assert_called_once_with("demo-project_slack_user_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_user_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_refresh_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_token_expires_at", scope="user") with open(config.project_config_path, "rb") as f: data = tomli.load(f) @@ -108,7 +110,9 @@ def test_slack_config_screen_remove_project_config_clears_plugin_entry_and_token screen._remove_project_config() - config.secrets.delete.assert_called_once_with("demo-project_slack_user_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_user_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_refresh_token", scope="user") + config.secrets.delete.assert_any_call("demo-project_slack_token_expires_at", scope="user") with open(config.project_config_path, "rb") as f: data = tomli.load(f) @@ -143,6 +147,9 @@ def test_slack_config_screen_perform_oauth_connect_uses_backend(monkeypatch, tmp expected = SlackOAuthResult( access_token="xoxp-token", + refresh_token="xoxe-refresh-token", + expires_in=43200, + token_type="Bearer", granted_scopes=["users:read"], team_id="T123", team_name="Acme", @@ -191,6 +198,9 @@ def test_slack_config_screen_oauth_connect_fails_when_keyring_write_fails(tmp_pa expected = SlackOAuthResult( access_token="xoxp-token", + refresh_token="xoxe-refresh-token", + expires_in=43200, + token_type="Bearer", granted_scopes=["users:read"], team_id="T123", team_name="Acme", diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py index bb577b16..174a9902 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py @@ -8,4 +8,22 @@ def build_project_slack_token_key(project_name: str | None) -> str: return f"{project_name}_slack_user_token" -__all__ = ["build_project_slack_token_key"] +def build_project_slack_refresh_token_key(project_name: str | None) -> str: + """Return the keyring key used for the current project's Slack refresh token.""" + if not project_name: + raise ValueError("Slack project refresh token key requires a configured project name.") + return f"{project_name}_slack_refresh_token" + + +def build_project_slack_token_expires_at_key(project_name: str | None) -> str: + """Return the keyring key used for the current project's Slack token expiry metadata.""" + if not project_name: + raise ValueError("Slack project token expiry key requires a configured project name.") + return f"{project_name}_slack_token_expires_at" + + +__all__ = [ + "build_project_slack_token_key", + "build_project_slack_refresh_token_key", + "build_project_slack_token_expires_at_key", +] diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py index 2e11fb87..e43a6c14 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -136,6 +136,9 @@ class SlackOAuthResult: """Successful OAuth exchange result.""" access_token: str + refresh_token: str | None + expires_in: int | None + token_type: str | None granted_scopes: list[str] team_id: str | None team_name: str | None @@ -237,19 +240,61 @@ def exchange_code(self, code: str, code_verifier: str) -> SlackOAuthResult: f"Slack OAuth token exchange failed: {payload.get('error', 'unknown_error')}" ) - authed_user = payload.get("authed_user") - if not isinstance(authed_user, dict): + return self._build_oauth_result(payload) + + def refresh_access_token(self, refresh_token: str) -> SlackOAuthResult: + """Refresh a Slack PKCE access token.""" + logger.info("slack_oauth_refresh_started", redirect_uri=self.redirect_uri) + response = self.requests.post( + TOKEN_URL, + data={ + "client_id": self.client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + + if not payload.get("ok", False): + logger.error( + "slack_oauth_refresh_failed", + error=payload.get("error", "unknown_error"), + payload=payload, + ) raise SlackOAuthError( - "Slack OAuth token exchange succeeded without an authed_user payload." + f"Slack OAuth refresh failed: {payload.get('error', 'unknown_error')}" ) - access_token = authed_user.get("access_token") + return self._build_oauth_result(payload) + + @staticmethod + def _build_oauth_result(payload: dict) -> SlackOAuthResult: + authed_user = payload.get("authed_user") + authed_user_data = authed_user if isinstance(authed_user, dict) else None + + access_token = payload.get("access_token") or ( + authed_user_data.get("access_token") if authed_user_data else None + ) if not access_token: raise SlackOAuthError( - "Slack OAuth token exchange succeeded without authed_user.access_token." + "Slack OAuth response did not include an access token." ) - scope_string = payload.get("scope") or authed_user.get("scope") or "" + refresh_token = payload.get("refresh_token") or ( + authed_user_data.get("refresh_token") if authed_user_data else None + ) + expires_in = payload.get("expires_in") + if expires_in is None and authed_user_data: + expires_in = authed_user_data.get("expires_in") + token_type = payload.get("token_type") or ( + authed_user_data.get("token_type") if authed_user_data else None + ) + + scope_string = payload.get("scope") or ( + authed_user_data.get("scope") if authed_user_data else "" + ) granted_scopes = [scope.strip() for scope in scope_string.split(",") if scope.strip()] team = payload.get("team") or {} @@ -257,15 +302,20 @@ def exchange_code(self, code: str, code_verifier: str) -> SlackOAuthResult: "slack_oauth_exchange_succeeded", team_id=team.get("id"), team_name=team.get("name"), - authed_user_id=authed_user.get("id"), + authed_user_id=(authed_user_data.get("id") if authed_user_data else payload.get("user_id")), granted_scopes=granted_scopes, + has_refresh_token=bool(refresh_token), + expires_in=expires_in, ) return SlackOAuthResult( access_token=access_token, + refresh_token=refresh_token, + expires_in=expires_in, + token_type=token_type, granted_scopes=granted_scopes, team_id=team.get("id"), team_name=team.get("name"), - authed_user_id=authed_user.get("id"), + authed_user_id=(authed_user_data.get("id") if authed_user_data else payload.get("user_id")), ) def _wait_for_callback(self, expected_state: str) -> str: diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py index 6856936e..dfaeca58 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -1,20 +1,31 @@ +import time from pathlib import Path from typing import Optional +import tomli +import tomli_w + from titan_cli.core.config import TitanConfig from titan_cli.core.plugins.models import SlackPluginConfig from titan_cli.core.plugins.plugin_base import TitanPlugin from titan_cli.core.secrets import SecretManager from .clients.slack_client import SlackClient -from .config import build_project_slack_token_key +from .config import ( + build_project_slack_refresh_token_key, + build_project_slack_token_expires_at_key, + build_project_slack_token_key, +) from .exceptions import SlackClientError, SlackConfigurationError +from .oauth import SlackOAuthFlow, SlackOAuthResult from .screens.slack_config_screen import SlackConfigScreen class SlackPlugin(TitanPlugin): """Titan CLI plugin for Slack operations.""" + TOKEN_REFRESH_MARGIN_SECONDS = 300 + @property def name(self) -> str: return "slack" @@ -47,6 +58,76 @@ def create_config_screen(self, config: TitanConfig) -> SlackConfigScreen: """Create the Slack-specific configuration screen.""" return SlackConfigScreen(config) + def _save_project_slack_config(self, config: TitanConfig, updates: dict[str, object | None]) -> None: + """Persist Slack project config updates.""" + project_cfg_path = config.project_config_path + if not project_cfg_path: + raise SlackConfigurationError("Slack configuration requires a project config path.") + + config_data = {} + if project_cfg_path.exists(): + with open(project_cfg_path, "rb") as f: + config_data = tomli.load(f) + + config_data.setdefault("config_version", getattr(config.config, "config_version", "1.0")) + project_cfg_path.parent.mkdir(parents=True, exist_ok=True) + plugins = config_data.setdefault("plugins", {}) + plugin_table = plugins.setdefault("slack", {}) + plugin_table["enabled"] = True + plugin_config = plugin_table.setdefault("config", {}) + + for key, value in updates.items(): + if value is None: + plugin_config.pop(key, None) + else: + plugin_config[key] = value + + with open(project_cfg_path, "wb") as f: + tomli_w.dump(config_data, f) + + config.load() + + def _should_refresh_token(self, token_expires_at: int | None, refresh_token: str | None) -> bool: + """Return whether the current token should be refreshed before use.""" + if not refresh_token: + return False + if token_expires_at is None: + return True + return token_expires_at <= int(time.time()) + self.TOKEN_REFRESH_MARGIN_SECONDS + + def _persist_refreshed_tokens( + self, + config: TitanConfig, + secrets: SecretManager, + project_name: str, + result: SlackOAuthResult, + validated_config: SlackPluginConfig, + ) -> None: + """Persist refreshed Slack OAuth credentials and metadata.""" + token_key = build_project_slack_token_key(project_name) + refresh_token_key = build_project_slack_refresh_token_key(project_name) + token_expires_at_key = build_project_slack_token_expires_at_key(project_name) + secrets.set(token_key, result.access_token, scope="user") + if result.refresh_token: + secrets.set(refresh_token_key, result.refresh_token, scope="user") + if result.expires_in: + secrets.set( + token_expires_at_key, + str(int(time.time()) + result.expires_in), + scope="user", + ) + + self._save_project_slack_config( + config, + { + "default_team_id": result.team_id or validated_config.default_team_id, + "default_team_name": result.team_name or validated_config.default_team_name, + "token_type": None, + "token_expires_at": None, + "granted_scopes": result.granted_scopes or validated_config.granted_scopes, + }, + ) + def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: """Initialize the Slack client using the current user's personal token.""" plugin_config_data = self._get_plugin_config(config) @@ -59,6 +140,8 @@ def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: project_name = config.get_project_name() token_key = build_project_slack_token_key(project_name) + refresh_token_key = build_project_slack_refresh_token_key(project_name) + token_expires_at_key = build_project_slack_token_expires_at_key(project_name) user_token = secrets.get(token_key) if not user_token: @@ -66,6 +149,32 @@ def initialize(self, config: TitanConfig, secrets: SecretManager) -> None: f"Slack user token not found for project '{project_name}'. Configure Slack for this repository first." ) + refresh_token = secrets.get(refresh_token_key) + token_expires_at_raw = secrets.get(token_expires_at_key) + try: + token_expires_at = int(token_expires_at_raw) if token_expires_at_raw else None + except ValueError: + token_expires_at = None + + if self._should_refresh_token(token_expires_at, refresh_token): + if not validated_config.oauth_client_id: + raise SlackConfigurationError( + "Slack token refresh requires an OAuth client ID in project configuration." + ) + flow = SlackOAuthFlow(client_id=validated_config.oauth_client_id) + refreshed = flow.refresh_access_token(refresh_token) + self._persist_refreshed_tokens( + config, + secrets, + project_name, + refreshed, + validated_config, + ) + user_token = refreshed.access_token + refresh_token = refreshed.refresh_token or refresh_token + refreshed_config_data = self._get_plugin_config(config) + validated_config = SlackPluginConfig(**refreshed_config_data) + self._client = SlackClient( user_token=user_token, team_id=validated_config.default_team_id, diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py index 133a7760..1184ea32 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import asyncio +import time import tomli import tomli_w @@ -16,7 +17,11 @@ from titan_cli.core.result import ClientError, ClientSuccess from ..clients.slack_client import SlackClient -from ..config import build_project_slack_token_key +from ..config import ( + build_project_slack_refresh_token_key, + build_project_slack_token_expires_at_key, + build_project_slack_token_key, +) from ..oauth import SlackOAuthFlow, SlackOAuthResult @@ -171,6 +176,12 @@ def _get_project_name(self) -> str: def _get_project_token_key(self) -> str: return build_project_slack_token_key(self._get_project_name()) + def _get_project_refresh_token_key(self) -> str: + return build_project_slack_refresh_token_key(self._get_project_name()) + + def _get_project_token_expires_at_key(self) -> str: + return build_project_slack_token_expires_at_key(self._get_project_name()) + def _get_connection_state(self) -> SlackConnectionState: plugin_config = self._load_plugin_config() return SlackConnectionState( @@ -408,6 +419,8 @@ async def _run_oauth_connect(self, client_id: str, default_channels: list[str]) "oauth_client_id": client_id, "default_team_id": result.team_id, "default_team_name": result.team_name, + "token_type": None, + "token_expires_at": None, "granted_scopes": result.granted_scopes, "default_channels": default_channels, } @@ -416,6 +429,16 @@ async def _run_oauth_connect(self, client_id: str, default_channels: list[str]) self.config.secrets.set( self._get_project_token_key(), result.access_token, scope="user" ) + if result.refresh_token: + self.config.secrets.set( + self._get_project_refresh_token_key(), result.refresh_token, scope="user" + ) + if result.expires_in: + self.config.secrets.set( + self._get_project_token_expires_at_key(), + str(int(time.time()) + result.expires_in), + scope="user", + ) token_written = True self._reconfigure_project_mode = False self._has_changes = True @@ -425,6 +448,12 @@ async def _run_oauth_connect(self, client_id: str, default_channels: list[str]) if token_written: try: self.config.secrets.delete(self._get_project_token_key(), scope="user") + self.config.secrets.delete( + self._get_project_refresh_token_key(), scope="user" + ) + self.config.secrets.delete( + self._get_project_token_expires_at_key(), scope="user" + ) except Exception: pass @@ -465,6 +494,8 @@ def _validate_connection(self) -> None: def _disconnect(self) -> None: self.config.secrets.delete(self._get_project_token_key(), scope="user") + self.config.secrets.delete(self._get_project_refresh_token_key(), scope="user") + self.config.secrets.delete(self._get_project_token_expires_at_key(), scope="user") self._reconfigure_project_mode = False self._has_changes = True self.app.notify("Slack account disconnected for this project.", severity="information") @@ -472,6 +503,8 @@ def _disconnect(self) -> None: def _remove_project_config(self) -> None: self.config.secrets.delete(self._get_project_token_key(), scope="user") + self.config.secrets.delete(self._get_project_refresh_token_key(), scope="user") + self.config.secrets.delete(self._get_project_token_expires_at_key(), scope="user") project_cfg_path = self.config.project_config_path if project_cfg_path and project_cfg_path.exists(): with open(project_cfg_path, "rb") as f: diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py index 8fc1aea6..09db9e53 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py @@ -19,6 +19,24 @@ MAX_COMBINED_TARGET_OPTIONS = 20 +DEFAULT_SLACK_HISTORY_LIMIT = 30 + + +def _summarization_error_message(exc: Exception) -> str: + """Convert AI summary errors into a concise user-facing message.""" + error_text = str(exc) + normalized = error_text.lower() + if ( + "429" in normalized + or "rate limit" in normalized + or "resource_exhausted" in normalized + or "throttling_error" in normalized + ): + return ( + "AI summary is temporarily rate limited by the configured AI provider. " + "Please wait and try again." + ) + return f"AI summary failed: {error_text}" def select_target_step(ctx: WorkflowContext) -> WorkflowResult: @@ -237,7 +255,7 @@ def read_recent_messages_step(ctx: WorkflowContext) -> WorkflowResult: Inputs (from ctx.data): slack_conversation_id (str): Slack conversation ID to read. - slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 50. + slack_history_limit (int, optional): Number of recent messages to fetch. Defaults to 30. Outputs (saved to ctx.data): slack_messages (list[UISlackMessage]): Retrieved Slack messages. @@ -266,7 +284,7 @@ def read_recent_messages_step(ctx: WorkflowContext) -> WorkflowResult: ctx.textual.end_step("error") return Error("Slack conversation ID not found in context") - limit = ctx.get("slack_history_limit", 50) + limit = ctx.get("slack_history_limit", DEFAULT_SLACK_HISTORY_LIMIT) with ctx.textual.loading("Reading recent Slack messages..."): result = ctx.slack.read_conversation(conversation_id, limit=limit) @@ -362,12 +380,25 @@ def ai_summarize_messages_step(ctx: WorkflowContext) -> WorkflowResult: transcript = truncate_transcript_for_summary(transcript, max_chars=max_chars) prompt = build_summary_prompt(target_name, transcript) - with ctx.textual.loading("Summarizing Slack messages with AI..."): - response = ctx.ai.generate( - [AIMessage(role="user", content=prompt)], - max_tokens=1024, - temperature=0.3, + try: + with ctx.textual.loading("Summarizing Slack messages with AI..."): + response = ctx.ai.generate( + [AIMessage(role="user", content=prompt)], + max_tokens=1024, + temperature=0.3, + ) + except Exception as exc: + message = _summarization_error_message(exc) + logger.warning( + "slack_summary_ai_request_failed", + target_name=target_name, + source_count=len(messages), + transcript_chars=len(transcript), + error=str(exc), ) + ctx.textual.error_text(message) + ctx.textual.end_step("error") + return Error(message, exc) summary = response.content.strip() logger.info( diff --git a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml index 85aa7dd4..7bd2260b 100644 --- a/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml +++ b/plugins/titan-plugin-slack/titan_plugin_slack/workflows/summarize-slack-target.yaml @@ -2,7 +2,7 @@ name: "Summarize Slack Target" description: "Search for a person or channel, read recent Slack messages, and summarize them with AI" params: - slack_history_limit: 50 + slack_history_limit: 30 steps: - id: validate_connection