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/.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 new file mode 100644 index 00000000..35c97e5c --- /dev/null +++ b/docs/plugins/_generated/slack-step-inventory.json @@ -0,0 +1,470 @@ +{ + "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." + } + ] + }, + { + "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_default_or_search_channel_target", + "summary": "Choose one configured default channel or fall back to manual Slack channel search." + } + ] + }, + { + "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." + } + ] + }, + { + "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": [ + { + "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)": [ + " 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": "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[UISlackChannel]): 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": [] + }, + { + "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[UISlackUser]): 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": [] + }, + { + "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_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": [] + }, + { + "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_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": [] + }, + { + "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": [] + }, + { + "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": [] + }, + { + "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_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 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": "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): 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)": [ + " 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." + ] + }, + "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.", + "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": "select_target", + "group": "Conversation Summaries", + "module": "titan_plugin_slack.steps.summary_steps", + "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_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": [] + }, + { + "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.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 50." + ], + "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." + ] + }, + "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": [ + "summarize-slack-target" + ] + } + ] +} diff --git a/docs/plugins/_meta/slack-step-groups.json b/docs/plugins/_meta/slack-step-groups.json new file mode 100644 index 00000000..934206f6 --- /dev/null +++ b/docs/plugins/_meta/slack-step-groups.json @@ -0,0 +1,39 @@ +{ + "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."} + ] + }, + { + "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_default_or_search_channel_target", "summary": "Choose one configured default channel or fall back to manual Slack channel search."} + ] + }, + { + "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."} + ] + }, + { + "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 new file mode 100644 index 00000000..d6166598 --- /dev/null +++ b/docs/plugins/generated/slack-step-reference.md @@ -0,0 +1,688 @@ +# 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:** `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. | + +### `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 +``` + +**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. | + +### `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 +``` + +**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 + +### `select_user_target` + +Select a Slack user target through query filtering and final confirmation. + +**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_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. | + +### `select_channel_target` + +Select a Slack channel target through query filtering and final confirmation. + +**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_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. | + +### `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 Slack 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 +``` + +**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 +``` + +**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 a plain-text Slack message to the prepared 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 +``` + +**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 + +### `select_target` + +Search both Slack 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 +``` + +**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_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. | + +### `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` + +**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/docs/plugins/index.md b/docs/plugins/index.md index 509b2938..036f25cc 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 summaries, and reusable Slack workflow steps | 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..1f9b2f0a --- /dev/null +++ b/docs/plugins/slack/built-in-workflows.md @@ -0,0 +1,36 @@ +# Slack Built-in Workflows + +The Slack plugin currently ships one built-in workflow for channel summaries. + +## `summarize-slack-target` + +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_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 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 + +- 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/client-api.md b/docs/plugins/slack/client-api.md new file mode 100644 index 00000000..e6828833 --- /dev/null +++ b/docs/plugins/slack/client-api.md @@ -0,0 +1,113 @@ +# Slack Client API + +The Slack plugin exposes Slack operations through `SlackClient`. This page documents the current public client surface and the parameters each method accepts. + +## Requirements + +To use the Slack client in Titan code: + +- enable the `slack` plugin +- complete project-scoped Slack OAuth configuration so a personal token is available in keyring for the active repository + +--- + +## 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. + +### `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. + +**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 + +- 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 new file mode 100644 index 00000000..d4d97b6c --- /dev/null +++ b/docs/plugins/slack/overview.md @@ -0,0 +1,111 @@ +# Slack Plugin + +The Slack plugin provides Titan's Slack integration for repo-scoped Slack App configuration, personal user authentication, workspace validation, discovery, messaging, and conversation summaries. + +It exposes: + +- a public `SlackClient` +- reusable workflow steps +- one built-in workflow for channel summaries + +## 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 flow +- Store the resulting personal Slack token in keyring for the active Titan 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 = [ + "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 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 + +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 + +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: + +- `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 + +```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 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 + +## 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 new file mode 100644 index 00000000..05bcea73 --- /dev/null +++ b/docs/plugins/slack/workflow-steps.md @@ -0,0 +1,685 @@ +# Slack Workflow Steps + +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). + +## Functional groups + +- [Validation and Discovery](#validation-and-discovery) +- [Selection and Target Resolution](#selection-and-target-resolution) +- [Messaging](#messaging) +- [Conversation Summaries](#conversation-summaries) + +## 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` | +| `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` | +| `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` | +| `ai_summarize_messages` | Conversation Summaries | `summarize-slack-target` | + +## 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 + +## 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 +- `select_default_or_search_channel_target`: choose one configured default channel or fall back to manual Slack channel search + +## Messaging + +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 + +## 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 + +## 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. + + +## 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/AGENTS.md b/plugins/titan-plugin-slack/AGENTS.md new file mode 100644 index 00000000..366e90cf --- /dev/null +++ b/plugins/titan-plugin-slack/AGENTS.md @@ -0,0 +1,48 @@ +# 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 +- 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 + +--- + +## Project Structure + +```text +titan_plugin_slack/ +├── __init__.py +├── plugin.py +├── clients/ +│ └── slack_client.py +├── screens/ +│ └── slack_config_screen.py +├── models.py +├── exceptions.py +├── oauth.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. +- Keep the configuration UX aligned with BYO Slack App + PKCE. diff --git a/plugins/titan-plugin-slack/pyproject.toml b/plugins/titan-plugin-slack/pyproject.toml new file mode 100644 index 00000000..e77f30a3 --- /dev/null +++ b/plugins/titan-plugin-slack/pyproject.toml @@ -0,0 +1,23 @@ +[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" +requests = ">=2.31.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..29cd2c8d --- /dev/null +++ b/plugins/titan-plugin-slack/tests/clients/test_slack_client.py @@ -0,0 +1,268 @@ +from unittest.mock import MagicMock + +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 SlackClientError +from titan_plugin_slack.models import UISlackAuth + + +def test_slack_client_requires_user_token() -> None: + with pytest.raises(SlackClientError): + SlackClient(user_token="") + + +def test_slack_client_stores_user_token() -> None: + client = SlackClient(user_token="xoxp-test-token", team_id="T123", timeout=45) + + assert client.user_token == "xoxp-test-token" + 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.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 isinstance(result, ClientSuccess) + assert result.data.user_id == "U123" + assert result.data.team_id == "T123" + + +def test_slack_client_auth_test_returns_client_error_for_invalid_token() -> None: + client = SlackClient(user_token="xoxp-test-token") + client.auth_service = MagicMock() + client.auth_service.auth_test.return_value = ClientError( + error_message="Slack auth failed: invalid_auth", + error_code="AUTH_ERROR", + ) + + result = client.auth_test() + + assert isinstance(result, ClientError) + assert result.error_message == "Slack auth failed: invalid_auth" + + +def test_slack_client_auth_test_returns_client_error_for_transport_failure() -> None: + client = SlackClient(user_token="xoxp-test-token") + 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() + + 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.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" + 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_returns_client_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + 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", + ) + + 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.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", + ) + ) + + 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" + assert channels[0].name == "general" + assert channels[1].is_private is False + + +def test_list_public_channels_returns_client_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + 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", + ) + + 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.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 + 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_returns_client_error() -> None: + client = SlackClient(user_token="xoxp-test-token") + 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" + + +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, + ) + + +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_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/operations/test_target_resolution_operations.py b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py new file mode 100644 index 00000000..ad08a1ac --- /dev/null +++ b/plugins/titan-plugin-slack/tests/operations/test_target_resolution_operations.py @@ -0,0 +1,70 @@ +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_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"), + 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..6dd6ab6a --- /dev/null +++ b/plugins/titan-plugin-slack/tests/services/test_conversation_service.py @@ -0,0 +1,92 @@ +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" + + +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_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/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_message_steps.py b/plugins/titan-plugin-slack/tests/test_message_steps.py new file mode 100644 index 00000000..995245ef --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_message_steps.py @@ -0,0 +1,126 @@ +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, + prepare_message_destination_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_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" + + 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 new file mode 100644 index 00000000..8fa4273c --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_oauth.py @@ -0,0 +1,163 @@ +from urllib.parse import parse_qs, urlparse + +import pytest +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", redirect_port=8765) + session = flow.create_session() + + url = flow.build_authorize_url(session) + 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"] == [session.state] + assert query["redirect_uri"] == ["http://127.0.0.1:8765/slack/callback"] + assert query["scope"] == [ + "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 + + +def test_exchange_code_returns_token_and_metadata() -> None: + class FakeRequests: + @staticmethod + 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"}, + "user_id": "U123", + } + ) + + flow = SlackOAuthFlow(client_id="123", redirect_port=8765, requests_module=FakeRequests) + + result = flow.exchange_code("code-123", "verifier-123") + + 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_falls_back_to_authed_user_access_token() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse( + { + "ok": True, + "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) + + 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_access_token_is_missing() -> None: + class FakeRequests: + @staticmethod + def post(url, data, timeout): + return _FakeResponse( + { + "ok": True, + "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="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 + def post(url, data, timeout): + return _FakeResponse({"ok": False, "error": "invalid_code"}) + + flow = SlackOAuthFlow(client_id="123", requests_module=FakeRequests) + + try: + flow.exchange_code("bad-code", "verifier-123") + except SlackOAuthError as exc: + assert "invalid_code" in str(exc) + else: + raise AssertionError("Expected SlackOAuthError") 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..5bf63d82 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_plugin.py @@ -0,0 +1,170 @@ +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: + plugin = SlackPlugin() + + assert plugin.name == "slack" + assert plugin.description == "Provides Slack messaging and workspace integration." + assert plugin.dependencies == [] + + +def test_slack_plugin_exposes_public_steps() -> None: + plugin = SlackPlugin() + + steps = plugin.get_steps() + + assert set(steps) == { + "validate_connection", + "list_public_channels", + "list_users", + "select_user_target", + "select_channel_target", + "select_default_or_search_channel_target", + "select_target", + "prepare_message_destination", + "ensure_target_conversation", + "read_recent_messages", + "ai_summarize_messages", + "open_direct_message", + "prompt_message_body", + "post_message", + } + + +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"] == "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 = {"slack": MagicMock(config={"oauth_client_id": "123"})} + config.get_project_name.return_value = "demo-project" + 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"}) + } + config.get_project_name.return_value = "demo-project" + secrets = MagicMock() + secrets.get.side_effect = ["xoxp-user-token", None, None] + + plugin.initialize(config, secrets) + + client = plugin.get_client() + assert client.user_token == "xoxp-user-token" + assert client.team_id == "T123" + assert client.timeout == 30 + 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_steps.py b/plugins/titan-plugin-slack/tests/test_steps.py new file mode 100644 index 00000000..d8eca2d7 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_steps.py @@ -0,0 +1,120 @@ +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 UISlackAuth, UISlackChannel, UISlackUser +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 = 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": 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", + } + + +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 = ClientSuccess( + data=( + [ + UISlackChannel(id="C123", name="general"), + UISlackChannel(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": [ + UISlackChannel(id="C123", name="general"), + UISlackChannel(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 = 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) + + assert isinstance(result, Success) + ctx.slack.list_users.assert_called_once_with(limit=10, cursor="cursor-a") + assert result.metadata == { + "slack_users": [ + 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_summary_steps.py b/plugins/titan-plugin-slack/tests/test_summary_steps.py new file mode 100644 index 00000000..2c3c3924 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_summary_steps.py @@ -0,0 +1,144 @@ +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 + ctx.slack.read_conversation.assert_called_once_with("C123", limit=30) + + +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." + + +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 new file mode 100644 index 00000000..acf206ff --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_summary_workflow.py @@ -0,0 +1,23 @@ +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"] == 30 + assert [step["id"] for step in workflow["steps"]] == [ + "validate_connection", + "select_target", + "ensure_target_conversation", + "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 new file mode 100644 index 00000000..9baa396f --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_target_steps.py @@ -0,0 +1,116 @@ +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_default_or_search_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_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" + + +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/tests/test_workflows.py b/plugins/titan-plugin-slack/tests/test_workflows.py new file mode 100644 index 00000000..1666396f --- /dev/null +++ b/plugins/titan-plugin-slack/tests/test_workflows.py @@ -0,0 +1,24 @@ +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"] == 30 + assert [step["id"] for step in workflow["steps"]] == [ + "validate_connection", + "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/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..ff9edc91 --- /dev/null +++ b/plugins/titan-plugin-slack/tests/ui/test_slack_config_screen.py @@ -0,0 +1,239 @@ +from pathlib import Path +import asyncio +from unittest.mock import MagicMock, PropertyMock + +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 + + +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.get_project_name.return_value = "demo-project" + 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={ + "oauth_client_id": "123", + "default_team_id": "T123", + "default_team_name": "Acme", + "granted_scopes": ["users:read", "channels:read"], + "default_channels": ["general", "release-notes"], + }, + ) + config.secrets.get.side_effect = lambda key: { + "demo-project_slack_user_token": "xoxp-token", + }.get(key) + screen = SlackConfigScreen(config) + + 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" + assert state.default_team_name == "Acme" + assert state.granted_scopes == ["users:read", "channels:read"] + assert state.default_channels == ["general", "release-notes"] + + +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) + + 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._disconnect() + + 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) + + 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_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) + + assert data.get("plugins", {}) == {} + + +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", ["general"])) + screen._save_oauth_app_config = MagicMock() + + screen._start_oauth_flow() + + app.notify.assert_called_once_with( + "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", + refresh_token="xoxe-refresh-token", + expires_in=43200, + token_type="Bearer", + granted_scopes=["users:read"], + team_id="T123", + team_name="Acme", + authed_user_id="U123", + ) + + class FakeFlow: + def __init__(self, client_id, redirect_port): + self.client_id = client_id + 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") + + 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", ["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 + + +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", + refresh_token="xoxe-refresh-token", + expires_in=43200, + token_type="Bearer", + 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) + + screen._save_project_slack_config({"oauth_client_id": "123"}) + + with open(config.project_config_path, "rb") as f: + 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/__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/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..3e2feedd --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/__init__.py @@ -0,0 +1,15 @@ +"""Internal services for the Slack client facade.""" + +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/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..cca44782 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/conversation_service.py @@ -0,0 +1,178 @@ +"""Internal service for Slack conversation history operations.""" + +from titan_cli.core.result import ClientError, ClientSuccess, ClientResult + +from ..sdk import SlackApiError +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 + + @staticmethod + 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=message, + error_code="READ_CHANNEL_ERROR", + details=details, + ) + + @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): + 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=message, + error_code="READ_CHANNEL_ERROR", + details=details, + ) + + 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", + ) + + 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 new file mode 100644 index 00000000..dab618ea --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/services/directory_service.py @@ -0,0 +1,316 @@ +"""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: + 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, + ) + + 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 = 50, + ) -> 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 = 50, + 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", + ) + + 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/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/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 new file mode 100644 index 00000000..58151941 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/clients/slack_client.py @@ -0,0 +1,200 @@ +"""Slack client facade backed by internal services.""" + +from . import sdk as slack_sdk_module +from .services import ( + AuthService, + ConversationService, + DirectoryService, + IdentityResolver, + MessageService, +) +from titan_cli.core.result import ClientResult + +from ..exceptions import SlackClientError +from ..models import ( + UISlackAuth, + UISlackChannel, + UISlackConversation, + UISlackMessage, + UISlackPostedMessage, + UISlackUser, +) + +SlackApiError = slack_sdk_module.SlackApiError +WebClient = slack_sdk_module.WebClient + + +class SlackClient: + """Slack client facade used by the Slack plugin.""" + + 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) + 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 + 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 + self.identity_resolver.web_client = value + self.message_service.web_client = value + + def auth_test(self) -> ClientResult[UISlackAuth]: + """Validate the configured user token with Slack auth.test.""" + return self.auth_service.auth_test() + + 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.""" + 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, + ) -> ClientResult[tuple[list[UISlackChannel], str | None]]: + """List public Slack channels visible to the current token.""" + 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 = 50, + ) -> 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 = 50, + 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 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, + 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 public channel.""" + return self.conversation_service.read_conversation( + conversation_id=channel_id, + limit=limit, + cursor=cursor, + oldest=oldest, + latest=latest, + 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) + + 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, + 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/config/__init__.py b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py new file mode 100644 index 00000000..174a9902 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/config/__init__.py @@ -0,0 +1,29 @@ +"""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" + + +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/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/models.py b/plugins/titan-plugin-slack/titan_plugin_slack/models.py new file mode 100644 index 00000000..de9d4654 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/models.py @@ -0,0 +1,122 @@ +"""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 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.""" + + channel: str + 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 + + +@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 + + +@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 new file mode 100644 index 00000000..e43a6c14 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/oauth.py @@ -0,0 +1,406 @@ +"""Slack OAuth backend helpers for the Slack configuration flow.""" + +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 +from urllib.parse import parse_qs, urlencode, urlparse +import secrets as secrets_module +import webbrowser + +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", + "groups:read", + "groups:history", + "im:history", + "mpim:history", + "chat:write", + "im:write", + "mpim:write", + "channels:write", + "groups:write", +] + +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.""" + + +@dataclass +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 + 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, + 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.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" + + @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": session.state, + "code_challenge": self._build_code_challenge(session.code_verifier), + "code_challenge_method": "S256", + } + ) + 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, 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, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + "code_verifier": code_verifier, + }, + timeout=30, + ) + response.raise_for_status() + 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')}" + ) + + 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( + f"Slack OAuth refresh failed: {payload.get('error', 'unknown_error')}" + ) + + 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 response did not include an access token." + ) + + 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 {} + logger.info( + "slack_oauth_exchange_succeeded", + team_id=team.get("id"), + team_name=team.get("name"), + 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_data.get("id") if authed_user_data else payload.get("user_id")), + ) + + 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] = {} + + 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] + 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() + self.wfile.write(CALLBACK_SUCCESS_HTML) + 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(): + 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.""" + 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(session.state) + return self.exchange_code(code, session.code_verifier) 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..817f8963 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/__init__.py @@ -0,0 +1,33 @@ +"""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, +) +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, + truncate_transcript_for_summary, +) + +__all__ = [ + "normalize_search_query", + "filter_users_for_query", + "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 new file mode 100644 index 00000000..2f20ff7c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/message_summary_operations.py @@ -0,0 +1,88 @@ +"""Pure operations for formatting Slack messages for AI summaries.""" + +from __future__ import annotations + +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] = [] + if target_name: + lines.append(f"Target: {target_name}") + 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)}] {author}: {text}" + ) + 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"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}" + ) + + +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/operations/target_resolution_operations.py b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py new file mode 100644 index 00000000..bd3ffc3c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/operations/target_resolution_operations.py @@ -0,0 +1,101 @@ +"""Pure operations for Slack target resolution and filtering.""" + +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.""" + 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: + """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 new file mode 100644 index 00000000..dfaeca58 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/plugin.py @@ -0,0 +1,235 @@ +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_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" + + @property + def description(self) -> str: + return "Provides Slack messaging and workspace integration." + + @property + 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 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 _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) + 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) + + 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: + raise SlackConfigurationError( + 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, + default_channels=validated_config.default_channels, + ) + + 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( + "SlackPlugin not initialized. Slack client may not be available." + ) + return self._client + + 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, + prepare_message_destination_step, + post_message_step, + prompt_message_body_step, + 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, + ) + + return { + "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, + "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, + "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, + } + + @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/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..1184ea32 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/screens/slack_config_screen.py @@ -0,0 +1,556 @@ +from dataclasses import dataclass +import asyncio +import time + +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, 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 titan_cli.core.result import ClientError, ClientSuccess + +from ..clients.slack_client import SlackClient +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 + + +logger = get_logger(__name__) +DEFAULT_OAUTH_REDIRECT_PORT = 8765 + + +@dataclass +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 + default_team_name: str | None + granted_scopes: list[str] + default_channels: list[str] + + +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-section-title { + margin-top: 1; + } + + .slack-section-body { + height: auto; + margin-bottom: 1; + } + + #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; + } + + Input { + width: 100%; + margin-top: 1; + margin-bottom: 1; + border: solid $accent; + } + + Input:focus { + border: solid $primary; + } + """ + + def __init__(self, config): + super().__init__( + config, + title=f"{Icons.SETTINGS} Configure Slack", + 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"): + panel = Container(id="slack-config-panel") + panel.border_title = "Slack Connection" + with panel: + with VerticalScroll(id="slack-config-scroll"): + 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("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") + 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("Configure Slack", variant="primary", id="connect-button") + yield Button("Validate Connection", variant="default", id="validate-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: + 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(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_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( + 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"), + default_team_name=plugin_config.get("default_team_name"), + granted_scopes=plugin_config.get("granted_scopes", []), + default_channels=plugin_config.get("default_channels", []), + ) + + 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 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(): + 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) + + self.config.load() + self._has_changes = True + + def _refresh_view(self) -> None: + state = self._get_connection_state() + try: + 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) + 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 + + 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" + + 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" 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" + f" Team Name: {state.default_team_name or 'Not set'}\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( + "Titan will open Slack in your browser and complete the OAuth PKCE flow.\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( + "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( + "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" + + 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" + + @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, self._parse_default_channels(default_channels_raw) + + 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, + } + ) + + def _perform_oauth_connect(self, client_id: str) -> SlackOAuthResult: + """Run the synchronous Slack OAuth backend flow.""" + flow = SlackOAuthFlow( + client_id=client_id, + 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: + 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") + return + + self.app.notify("Opening browser for Slack authorization...", severity="information") + self.run_worker( + self._run_oauth_connect(client_id, default_channels), + exclusive=True, + ) + + 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._save_project_slack_config( + { + "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, + } + ) + config_written = True + 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 + 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") + 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 + + 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") + + def _validate_connection(self) -> None: + plugin_config = self._load_plugin_config() + client = SlackClient( + user_token=self.config.secrets.get(self._get_project_token_key()) or "", + team_id=plugin_config.get("default_team_id"), + ) + result = client.auth_test() + + match result: + case ClientSuccess(data=auth): + self._save_project_slack_config( + { + "default_team_id": auth.team_id, + "default_team_name": auth.team, + "granted_scopes": plugin_config.get("granted_scopes", []), + "default_channels": plugin_config.get("default_channels", []), + } + ) + self._has_changes = True + 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(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") + self._refresh_view() + + 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: + 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() + 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 == "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 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=self._has_changes) 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..fbe02197 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/__init__.py @@ -0,0 +1,41 @@ +"""Slack workflow steps package.""" + +from .discovery_steps import ( + list_public_channels_step, + list_users_step, + validate_connection_step, +) +from .message_steps import ( + open_direct_message_step, + prepare_message_destination_step, + 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_default_or_search_channel_target_step, + select_user_target_step, +) + +__all__ = [ + "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", + "select_target_step", + "ensure_target_conversation_step", + "read_recent_messages_step", + "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/discovery_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py new file mode 100644 index 00000000..5b63343f --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/discovery_steps.py @@ -0,0 +1,192 @@ +"""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 + + +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 (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. + """ + 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..."): + 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: + """ + 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[UISlackChannel]): 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..."): + result = ctx.slack.list_public_channels( + limit=limit, + cursor=cursor, + exclude_archived=exclude_archived, + ) + + 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: + """ + 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[UISlackUser]): 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..."): + 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__ = [ + "validate_connection_step", + "list_public_channels_step", + "list_users_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..8971c9af --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/message_steps.py @@ -0,0 +1,242 @@ +"""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 +from ..models import UISlackConversation + + +def prepare_message_destination_step(ctx: WorkflowContext) -> WorkflowResult: + """ + 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` 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. + """ + if not ctx.textual: + return Error("Textual UI context is not available for this step.") + + ctx.textual.begin_step("Prepare Slack Message Destination") + + 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): + 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, + }, + ) + + 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") + + +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("Direct messages require a Slack user target") + + return prepare_message_destination_step(ctx) + + +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__ = [ + "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/summary_steps.py b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py new file mode 100644 index 00000000..09db9e53 --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/summary_steps.py @@ -0,0 +1,440 @@ +"""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, + extract_identity_ids_from_messages, + format_messages_as_transcript, + truncate_transcript_for_summary, +) + + +logger = get_logger(__name__) + + +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: + """ + 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 30. + + 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. + + 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", DEFAULT_SLACK_HISTORY_LIMIT) + + 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)): + 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, + }, + ) + 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) + 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) + + 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( + "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 new file mode 100644 index 00000000..019b4d6c --- /dev/null +++ b/plugins/titan-plugin-slack/titan_plugin_slack/steps/target_steps.py @@ -0,0 +1,376 @@ +"""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 +SEARCH_ANOTHER_CHANNEL = "__search_another_channel__" + + +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 50. + + 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 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. + + 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_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_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, + *, + 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", 50) + 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) + + +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/__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.""" 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..7bd2260b --- /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: 30 + +steps: + - id: validate_connection + name: "Validate Slack Connection" + plugin: slack + step: validate_connection + + - id: select_target + name: "Select Slack Channel Target" + plugin: slack + step: select_default_or_search_channel_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/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/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/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/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/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/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/ai/providers/litellm.py b/titan_cli/ai/providers/litellm.py index a64ac6eb..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 = choice.message.content or "" 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, @@ -162,6 +168,95 @@ 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, Mapping): + return "" + + for key in ("content", "text", "output_text", "reasoning_content"): + text = cls._coerce_content_to_text(data.get(key)) + if text: + return text + + 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 + + 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, 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, 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() + + return "" + def validate_api_key(self, api_key: Optional[str] = None) -> bool: """ Validate API key by making a minimal test request. diff --git a/titan_cli/core/config.py b/titan_cli/core/config.py index 26650241..3171463a 100644 --- a/titan_cli/core/config.py +++ b/titan_cli/core/config.py @@ -383,6 +383,21 @@ 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 complete Slack config block. + if plugin_name == "slack": + project_plugin_cfg = project_plugins.get(plugin_name, {}) + project_plugin_config = project_plugin_cfg.get("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) return plugin_cfg.enabled if plugin_cfg else False 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: 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": [] + }, ] diff --git a/titan_cli/core/plugins/models.py b/titan_cli/core/plugins/models.py index 0f14981d..84970569 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,65 @@ 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="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 by the current project's Slack App.", + json_schema_extra={"config_scope": "project"}, + ) + default_team_name: Optional[str] = Field( + None, + 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 project's Slack integration.", + json_schema_extra={"config_scope": "project"}, + ) + 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("oauth_client_id") + @classmethod + 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 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/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/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 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 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..80643a22 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__) @@ -216,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() @@ -246,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 "" @@ -288,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": @@ -359,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) @@ -369,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]")) @@ -415,8 +445,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) @@ -646,9 +698,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 +713,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 )