From dbad795b52c137b49a70a2fde1a235e92dbcebba Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 24 Dec 2025 11:04:39 +0000 Subject: [PATCH 01/44] update: add app command for test --- packages/slackBotFunction/app/slack/slack_handlers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 2100be2d..45b82d2b 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -41,6 +41,7 @@ def setup_handlers(app: App) -> None: app.event("message")(ack=respond_to_events, lazy=[unified_message_handler]) app.action("feedback_yes")(ack=respond_to_action, lazy=[feedback_handler]) app.action("feedback_no")(ack=respond_to_action, lazy=[feedback_handler]) + app.command("test")(ack=respond_to_command, lazy=[feedback_handler]) for i in range(1, 10): app.action(f"cite_{i}")(ack=respond_to_action, lazy=[feedback_handler]) @@ -64,6 +65,11 @@ def respond_to_action(ack: Ack): ack() +def respond_to_command(ack: Ack): + logger.debug("Sending ack response") + ack() + + def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: """Handle feedback button clicks (both positive and negative).""" try: From 43cbf6367d14c7c6ee9993b4e9bbaf0b8c1d2327 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 24 Dec 2025 11:24:17 +0000 Subject: [PATCH 02/44] update: add app command for test --- packages/cdk/resources/Apis.ts | 11 +++++++++++ .../slackBotFunction/app/slack/slack_handlers.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/cdk/resources/Apis.ts b/packages/cdk/resources/Apis.ts index 39702ddb..1ea5475d 100644 --- a/packages/cdk/resources/Apis.ts +++ b/packages/cdk/resources/Apis.ts @@ -37,6 +37,17 @@ export class Apis extends Construct { lambdaFunction: props.functions.slackBot }) + // Create the '/slack/commands' POST endpoint for Slack Events API + // This endpoint will handle slash commands, such as /test + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const slackCommandsEndpoint = new LambdaEndpoint(this, "SlackCommandsEndpoint", { + parentResource: slackResource, + resourceName: "commands", + method: HttpMethod.POST, + restApiGatewayRole: apiGateway.role, + lambdaFunction: props.functions.slackBot + }) + this.apis = { api: apiGateway } diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 45b82d2b..e535ab75 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -41,7 +41,7 @@ def setup_handlers(app: App) -> None: app.event("message")(ack=respond_to_events, lazy=[unified_message_handler]) app.action("feedback_yes")(ack=respond_to_action, lazy=[feedback_handler]) app.action("feedback_no")(ack=respond_to_action, lazy=[feedback_handler]) - app.command("test")(ack=respond_to_command, lazy=[feedback_handler]) + app.command("test")(ack=respond_to_command, lazy=[prompt_test_handler]) for i in range(1, 10): app.action(f"cite_{i}")(ack=respond_to_action, lazy=[feedback_handler]) @@ -133,3 +133,14 @@ def unified_message_handler(client: WebClient, event: Dict[str, Any], body: Dict process_async_slack_event(event=event, event_id=event_id, client=client) except Exception: logger.error("Error triggering async processing", extra={"error": traceback.format_exc()}) + + +def prompt_test_handler(body: Dict[str, Any], event: Dict[str, Any], client: WebClient) -> None: + """Handle /test command to prompt the bot to respond.""" + try: + event_id = gate_common(event=event, body=body) + logger.debug("logging result of gate_common", extra={"event_id": event_id, "body": body}) + if not event_id: + return + except Exception as e: + logger.error(f"Error handling /test command: {e}", extra={"error": traceback.format_exc()}) From bba70d6129301f9dd7c2531dbde031fce97a9b0e Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 24 Dec 2025 11:35:13 +0000 Subject: [PATCH 03/44] update: add app command for test --- packages/cdk/nagSuppressions.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/cdk/nagSuppressions.ts b/packages/cdk/nagSuppressions.ts index 9f3c61b1..d0f9220f 100644 --- a/packages/cdk/nagSuppressions.ts +++ b/packages/cdk/nagSuppressions.ts @@ -45,6 +45,22 @@ export const nagSuppressions = (stack: Stack) => { safeAddNagSuppression( stack, "/EpsAssistMeStack/Apis/EpsAssistApiGateway/ApiGateway/Default/slack/events/POST/Resource", + [ + { + id: "AwsSolutions-APIG4", + reason: "Slack event endpoint is intentionally unauthenticated." + }, + { + id: "AwsSolutions-COG4", + reason: "Cognito not required for this public endpoint." + } + ] + ) + + // Suppress unauthenticated API route warnings + safeAddNagSuppression( + stack, + "/EpsAssistMeStack/Apis/EpsAssistApiGateway/ApiGateway/Default/slack/commands/POST/Resource", [ { id: "AwsSolutions-APIG4", From a187fc8afe5154c400c58e2f8b1f7e0dea497695 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 10:58:35 +0000 Subject: [PATCH 04/44] feat: Merge Pull Request Lambda Forwarding --- packages/cdk/stacks/EpsAssistMeStack.ts | 6 +++ .../app/slack/slack_events.py | 11 ++-- .../app/slack/slack_handlers.py | 49 +++++++++++++++--- .../app/utils/handler_utils.py | 47 ++++++++--------- .../tests/test_forward_to_lambda.py | 50 ++++++++++++++----- .../test_slack_events_actions.py | 6 +-- .../test_slack_events_messages.py | 22 ++++---- .../tests/test_slack_handlers.py | 47 ++++++++++------- 8 files changed, 159 insertions(+), 79 deletions(-) diff --git a/packages/cdk/stacks/EpsAssistMeStack.ts b/packages/cdk/stacks/EpsAssistMeStack.ts index 24bb3ee7..fd0c99cb 100644 --- a/packages/cdk/stacks/EpsAssistMeStack.ts +++ b/packages/cdk/stacks/EpsAssistMeStack.ts @@ -216,6 +216,12 @@ export class EpsAssistMeStack extends Stack { description: "Slack Events API endpoint for @mentions and direct messages" }) + // Output: SlackBot Endpoint + new CfnOutput(this, "SlackBotCommandsEndpoint", { + value: `https://${apis.apis["api"].api.domainName?.domainName}/slack/commands`, + description: "Slack Commands API endpoint for slash commands" + }) + // Output: Bedrock Prompt ARN new CfnOutput(this, "QueryReformulationPromptArn", { value: bedrockPromptResources.queryReformulationPrompt.promptArn, diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index cce33cbd..d9b8ccea 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -28,7 +28,7 @@ from app.utils.handler_utils import ( conversation_key_and_root, extract_pull_request_id, - forward_event_to_pull_request_lambda, + forward_to_pull_request_lambda, is_duplicate_event, is_latest_message, strip_mentions, @@ -397,8 +397,13 @@ def process_async_slack_event(event: Dict[str, Any], event_id: str, client: WebC if message_text.lower().startswith(constants.PULL_REQUEST_PREFIX): try: pull_request_id, _ = extract_pull_request_id(text=message_text) - forward_event_to_pull_request_lambda( - pull_request_id=pull_request_id, event=event, event_id=event_id, store_pull_request_id=True + forward_to_pull_request_lambda( + body={}, + pull_request_id=pull_request_id, + event=event, + event_id=event_id, + store_pull_request_id=True, + type="event", ) except Exception as e: logger.error(f"Can not find pull request details: {e}", extra={"error": traceback.format_exc()}) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index e535ab75..33b93cbf 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -19,8 +19,7 @@ from app.utils.handler_utils import ( conversation_key_and_root, extract_session_pull_request_id, - forward_action_to_pull_request_lambda, - forward_event_to_pull_request_lambda, + forward_to_pull_request_lambda, gate_common, respond_with_eyes, should_reply_to_message, @@ -41,9 +40,9 @@ def setup_handlers(app: App) -> None: app.event("message")(ack=respond_to_events, lazy=[unified_message_handler]) app.action("feedback_yes")(ack=respond_to_action, lazy=[feedback_handler]) app.action("feedback_no")(ack=respond_to_action, lazy=[feedback_handler]) - app.command("test")(ack=respond_to_command, lazy=[prompt_test_handler]) for i in range(1, 10): app.action(f"cite_{i}")(ack=respond_to_action, lazy=[feedback_handler]) + app.command("test")(ack=respond_to_command, lazy=[prompt_test_handler]) # ================================================================ @@ -83,7 +82,14 @@ def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: f"Feedback in pull request session {session_pull_request_id}", extra={"session_pull_request_id": session_pull_request_id}, ) - forward_action_to_pull_request_lambda(body=body, pull_request_id=session_pull_request_id) + forward_to_pull_request_lambda( + body=body, + event=None, + event_id="", + store_pull_request_id=False, + pull_request_id=session_pull_request_id, + type="action", + ) return process_async_slack_action(body=body, client=client) except Exception as e: @@ -123,8 +129,13 @@ def unified_message_handler(client: WebClient, event: Dict[str, Any], body: Dict f"Message in pull request session {session_pull_request_id} from user {user_id}", extra={"session_pull_request_id": session_pull_request_id}, ) - forward_event_to_pull_request_lambda( - event=event, pull_request_id=session_pull_request_id, event_id=event_id, store_pull_request_id=False + forward_to_pull_request_lambda( + body=body, + event=event, + pull_request_id=session_pull_request_id, + event_id=event_id, + store_pull_request_id=False, + type="event", ) return @@ -135,6 +146,7 @@ def unified_message_handler(client: WebClient, event: Dict[str, Any], body: Dict logger.error("Error triggering async processing", extra={"error": traceback.format_exc()}) +# TODO: Remove duplication with unified_message_handler def prompt_test_handler(body: Dict[str, Any], event: Dict[str, Any], client: WebClient) -> None: """Handle /test command to prompt the bot to respond.""" try: @@ -144,3 +156,28 @@ def prompt_test_handler(body: Dict[str, Any], event: Dict[str, Any], client: Web return except Exception as e: logger.error(f"Error handling /test command: {e}", extra={"error": traceback.format_exc()}) + # if its in a group chat + # and its a message + # and its not in a thread + # then ignore it as it will be handled as an app_mention event + if not should_reply_to_message(event, client): + logger.debug("Ignoring message in group chat not in a thread or bot not in thread", extra={"event": event}) + # ignore messages in group chats or threads where bot wasn't mentioned + return + user_id = event.get("user", "unknown") + conversation_key, _ = conversation_key_and_root(event=event) + session_pull_request_id = extract_session_pull_request_id(conversation_key) + if session_pull_request_id: + logger.info( + f"Message in pull request session {session_pull_request_id} from user {user_id}", + extra={"session_pull_request_id": session_pull_request_id}, + ) + forward_to_pull_request_lambda( + body=body, + event=event, + pull_request_id=session_pull_request_id, + event_id=event_id, + store_pull_request_id=False, + type="event", + ) + return diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index f75b1cf1..1d810510 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -67,20 +67,20 @@ def get_pull_request_lambda_arn(pull_request_id: str) -> str: raise e -def forward_event_to_pull_request_lambda( - pull_request_id: str, event: Dict[str, Any], event_id: str, store_pull_request_id: bool +def forward_to_pull_request_lambda( + body: Dict[str, Any], + event: Dict[str, Any], + event_id: str, + pull_request_id: str, + store_pull_request_id: bool, + type: str, ) -> None: lambda_client: LambdaClient = boto3.client("lambda") try: pull_request_lambda_arn = get_pull_request_lambda_arn(pull_request_id=pull_request_id) - # strip pull request prefix and id from message text - message_text = event["text"] - _, extracted_message = extract_pull_request_id(message_text) - event["text"] = extracted_message - - lambda_payload = {"pull_request_event": True, "slack_event": {"event": event, "event_id": event_id}} + lambda_payload = get_forward_payload(body=body, event=event, event_id=event_id, type=type) logger.debug( - "Forwarding event to pull request lambda", + f"Forwarding {type} to pull request lambda", extra={"lambda_arn": pull_request_lambda_arn, "lambda_payload": lambda_payload}, ) lambda_client.invoke( @@ -94,27 +94,22 @@ def forward_event_to_pull_request_lambda( store_state_information(item=item) except Exception as e: - logger.error("Failed to trigger pull request lambda", extra={"error": traceback.format_exc()}) + logger.error("Failed to forward request to pull request lambda", extra={"error": traceback.format_exc()}) raise e -def forward_action_to_pull_request_lambda(body: Dict[str, Any], pull_request_id: str) -> None: - lambda_client: LambdaClient = boto3.client("lambda") - try: - pull_request_lambda_arn = get_pull_request_lambda_arn(pull_request_id=pull_request_id) - lambda_payload = {"pull_request_action": True, "slack_body": body} - logger.debug( - "Forwarding action to pull request lambda", - extra={"lambda_arn": pull_request_lambda_arn, "lambda_payload": lambda_payload}, - ) - lambda_client.invoke( - FunctionName=pull_request_lambda_arn, InvocationType="Event", Payload=json.dumps(lambda_payload) - ) - logger.info("Triggered pull request lambda", extra={"lambda_arn": pull_request_lambda_arn}) +def get_forward_payload(body: Dict[str, Any], event: Dict[str, Any], event_id: str, type: str) -> Dict[str, Any]: + if type != "event": + return {f"pull_request_{type}": True, "slack_body": body} - except Exception as e: - logger.error("Failed to forward request to pull request lambda", extra={"error": traceback.format_exc()}) - raise e + if event_id is None or event["text"] is None: + logger.error("Missing required fields to forward pull request event") + return None + + message_text = event["text"] + _, extracted_message = extract_pull_request_id(message_text) + event["text"] = extracted_message + return {"pull_request_event": True, "slack_event": {"event": event, "event_id": event_id}} def is_latest_message(conversation_key: str, message_ts: str) -> bool: diff --git a/packages/slackBotFunction/tests/test_forward_to_lambda.py b/packages/slackBotFunction/tests/test_forward_to_lambda.py index 20672462..31935cf6 100644 --- a/packages/slackBotFunction/tests/test_forward_to_lambda.py +++ b/packages/slackBotFunction/tests/test_forward_to_lambda.py @@ -25,14 +25,19 @@ def client_side_effect(service_name, *args, **kwargs): # delete and import module to test if "app.utils.handler_utils" in sys.modules: del sys.modules["app.utils.handler_utils"] - from app.utils.handler_utils import forward_event_to_pull_request_lambda + from app.utils.handler_utils import forward_to_pull_request_lambda # perform operation event_data = {"test": "data", "channel": "C123", "ts": "12345.6789", "text": "foo_bar"} with patch("app.utils.handler_utils.get_pull_request_lambda_arn", return_value="output_SlackBotLambdaArn"): - forward_event_to_pull_request_lambda( - pull_request_id="123", event=event_data, event_id="evt123", store_pull_request_id=True + forward_to_pull_request_lambda( + body={}, + pull_request_id="123", + event=event_data, + event_id="evt123", + store_pull_request_id=True, + type="event", ) # assertions @@ -72,14 +77,19 @@ def client_side_effect(service_name, *args, **kwargs): # delete and import module to test if "app.utils.handler_utils" in sys.modules: del sys.modules["app.utils.handler_utils"] - from app.utils.handler_utils import forward_event_to_pull_request_lambda + from app.utils.handler_utils import forward_to_pull_request_lambda # perform operation event_data = {"test": "data", "channel": "C123", "ts": "12345.6789", "text": "foo_bar"} with patch("app.utils.handler_utils.get_pull_request_lambda_arn", return_value="output_SlackBotLambdaArn"): - forward_event_to_pull_request_lambda( - pull_request_id="123", event=event_data, event_id="evt123", store_pull_request_id=False + forward_to_pull_request_lambda( + body={}, + pull_request_id="123", + event=event_data, + event_id="evt123", + store_pull_request_id=False, + type="event", ) # assertions @@ -115,15 +125,20 @@ def client_side_effect(service_name, *args, **kwargs): # delete and import module to test if "app.utils.handler_utils" in sys.modules: del sys.modules["app.utils.handler_utils"] - from app.utils.handler_utils import forward_event_to_pull_request_lambda + from app.utils.handler_utils import forward_to_pull_request_lambda # perform operation event_data = {"test": "data"} with patch("app.utils.handler_utils.get_pull_request_lambda_arn") as mock_get_pull_request_lambda_arn: mock_get_pull_request_lambda_arn.side_effect = Exception("Error getting lambda arn") with pytest.raises(Exception): - forward_event_to_pull_request_lambda( - pull_request_id="123", event=event_data, event_id="evt123", store_pull_request_id=False + forward_to_pull_request_lambda( + body={}, + pull_request_id="123", + event=event_data, + event_id="evt123", + store_pull_request_id=False, + type="event", ) # assertions @@ -149,14 +164,21 @@ def client_side_effect(service_name, *args, **kwargs): # delete and import module to test if "app.utils.handler_utils" in sys.modules: del sys.modules["app.utils.handler_utils"] - from app.utils.handler_utils import forward_action_to_pull_request_lambda + from app.utils.handler_utils import forward_to_pull_request_lambda # perform operation mock_body = {"type": "block_actions", "user": {"id": "U123"}, "actions": []} with patch("app.utils.handler_utils.get_pull_request_lambda_arn") as mock_get_pull_request_lambda_arn: mock_get_pull_request_lambda_arn.side_effect = Exception("Error getting lambda arn") with pytest.raises(Exception): - forward_action_to_pull_request_lambda(pull_request_id="123", body=mock_body) + forward_to_pull_request_lambda( + pull_request_id="123", + body=mock_body, + type="action", + event=None, + event_id="", + store_pull_request_id=False, + ) # assertions @@ -183,13 +205,15 @@ def client_side_effect(service_name, *args, **kwargs): # delete and import module to test if "app.utils.handler_utils" in sys.modules: del sys.modules["app.utils.handler_utils"] - from app.utils.handler_utils import forward_action_to_pull_request_lambda + from app.utils.handler_utils import forward_to_pull_request_lambda # perform operation mock_body = {"type": "block_actions", "user": {"id": "U123"}, "actions": []} with patch("app.utils.handler_utils.get_pull_request_lambda_arn", return_value="output_SlackBotLambdaArn"): - forward_action_to_pull_request_lambda(pull_request_id="123", body=mock_body) + forward_to_pull_request_lambda( + pull_request_id="123", body=mock_body, type="action", event=None, event_id="", store_pull_request_id=False + ) # assertions expected_lambda_payload = { diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py index 635920e7..a931ca66 100644 --- a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py @@ -8,9 +8,9 @@ def mock_logger(): return MagicMock() -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_process_async_slack_event_feedback( - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_get_parameter: Mock, mock_env: Mock, ): @@ -34,7 +34,7 @@ def test_process_async_slack_event_feedback( "app.slack.slack_events.process_slack_message" ) as mock_process_slack_message: process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() mock_process_feedback_event.assert_called_once_with( message_text="feedback: this is some feedback", conversation_key="thread#C789#1234567890.123", diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py index cd180566..5eb7fd8c 100644 --- a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py @@ -8,9 +8,9 @@ def mock_logger(): return MagicMock() -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_process_async_slack_event_normal_message( - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_get_parameter: Mock, mock_env: Mock, ): @@ -29,16 +29,16 @@ def test_process_async_slack_event_normal_message( "app.slack.slack_events.process_slack_message" ) as mock_process_slack_message: process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() mock_process_feedback_event.assert_not_called() mock_process_slack_message.assert_called_once_with( event=slack_event_data, event_id="evt123", client=mock_client ) -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_process_async_slack_event_pull_request_with_mention( - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_get_parameter: Mock, mock_env: Mock, ): @@ -62,19 +62,21 @@ def test_process_async_slack_event_pull_request_with_mention( "app.slack.slack_events.process_slack_message" ) as mock_process_slack_message: process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_called_once_with( + mock_forward_to_pull_request_lambda.assert_called_once_with( + body={}, pull_request_id="123", event=slack_event_data, event_id="evt123", store_pull_request_id=True, + type="event", ) mock_process_feedback_event.assert_not_called() mock_process_slack_message.assert_not_called() -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_process_async_slack_event_pull_request_with_no_mention( - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_get_parameter: Mock, mock_env: Mock, ): @@ -98,11 +100,13 @@ def test_process_async_slack_event_pull_request_with_no_mention( "app.slack.slack_events.process_slack_message" ) as mock_process_slack_message: process_async_slack_event(event=slack_event_data, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_called_once_with( + mock_forward_to_pull_request_lambda.assert_called_once_with( + body={}, pull_request_id="123", event=slack_event_data, event_id="evt123", store_pull_request_id=True, + type="event", ) mock_process_feedback_event.assert_not_called() mock_process_slack_message.assert_not_called() diff --git a/packages/slackBotFunction/tests/test_slack_handlers.py b/packages/slackBotFunction/tests/test_slack_handlers.py index c4dfa9ef..3844fce1 100644 --- a/packages/slackBotFunction/tests/test_slack_handlers.py +++ b/packages/slackBotFunction/tests/test_slack_handlers.py @@ -5,11 +5,11 @@ # unified message handler @patch("app.slack.slack_events.process_async_slack_event") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") @patch("app.utils.handler_utils.gate_common") def test_unified_message_handler_successful_call( mock_gate_common: Mock, - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_process_async_slack_event: Mock, mock_get_parameter: Mock, @@ -39,16 +39,16 @@ def test_unified_message_handler_successful_call( # assertions mock_process_async_slack_event.assert_called_once_with(event=mock_event, event_id="evt123", client=mock_client) - mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() @patch("app.slack.slack_events.process_async_slack_event") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") @patch("app.utils.handler_utils.gate_common") def test_unified_message_handler_messages_with_no_thread_are_dropped( mock_gate_common: Mock, - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_process_async_slack_event: Mock, mock_get_parameter: Mock, @@ -78,16 +78,16 @@ def test_unified_message_handler_messages_with_no_thread_are_dropped( # assertions mock_process_async_slack_event.assert_not_called() - mock_forward_event_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() @patch("app.slack.slack_events.process_async_slack_event") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_event_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") @patch("app.utils.handler_utils.gate_common") def test_unified_message_handler_pull_request_call( mock_gate_common: Mock, - mock_forward_event_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_process_async_slack_event: Mock, mock_get_parameter: Mock, @@ -116,8 +116,13 @@ def test_unified_message_handler_pull_request_call( unified_message_handler(event=mock_event, body=mock_body, client=mock_client) # assertions - mock_forward_event_to_pull_request_lambda.assert_called_once_with( - event=mock_event, pull_request_id="123", event_id="evt123", store_pull_request_id=False + mock_forward_to_pull_request_lambda.assert_called_once_with( + body=mock_body, + event=mock_event, + pull_request_id="123", + event_id="evt123", + store_pull_request_id=False, + type="event", ) mock_process_async_slack_event.assert_not_called() @@ -125,9 +130,9 @@ def test_unified_message_handler_pull_request_call( # feedback action handler @patch("app.slack.slack_events.process_async_slack_action") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_action_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_feedback_handler_success( - mock_forward_action_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_process_async_slack_action: Mock, mock_get_parameter: Mock, @@ -160,14 +165,14 @@ def test_feedback_handler_success( body=mock_body, client=mock_client, ) - mock_forward_action_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() @patch("app.slack.slack_events.process_async_slack_action") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_action_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_feedback_handler_pull_request( - mock_forward_action_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_process_async_slack_action: Mock, mock_get_parameter: Mock, @@ -196,18 +201,22 @@ def test_feedback_handler_pull_request( feedback_handler(body=mock_body, client=mock_client) # assertions - mock_forward_action_to_pull_request_lambda.assert_called_once_with( + mock_forward_to_pull_request_lambda.assert_called_once_with( body=mock_body, pull_request_id="123", + type="action", + event=None, + event_id="", + store_pull_request_id=False, ) mock_process_async_slack_action.assert_not_called() @patch("app.slack.slack_events.open_citation") @patch("app.utils.handler_utils.extract_session_pull_request_id") -@patch("app.utils.handler_utils.forward_action_to_pull_request_lambda") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") def test_citation( - mock_forward_action_to_pull_request_lambda: Mock, + mock_forward_to_pull_request_lambda: Mock, mock_extract_session_pull_request_id: Mock, mock_open_citation: Mock, mock_get_parameter: Mock, @@ -241,4 +250,4 @@ def test_citation( # assertions mock_open_citation.assert_called_once() - mock_forward_action_to_pull_request_lambda.assert_not_called() + mock_forward_to_pull_request_lambda.assert_not_called() From 72bdeeeab5d2c66c636ccb56e5907a315f96b900 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 12:15:23 +0000 Subject: [PATCH 05/44] feat: Tidy up command requests before development --- .../app/slack/slack_handlers.py | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 33b93cbf..e41d4101 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -11,7 +11,7 @@ from functools import lru_cache import traceback from typing import Any, Dict -from slack_bolt import Ack, App +from slack_bolt import Ack, App, Say from slack_sdk import WebClient from app.core.config import ( get_logger, @@ -42,7 +42,7 @@ def setup_handlers(app: App) -> None: app.action("feedback_no")(ack=respond_to_action, lazy=[feedback_handler]) for i in range(1, 10): app.action(f"cite_{i}")(ack=respond_to_action, lazy=[feedback_handler]) - app.command("test")(ack=respond_to_command, lazy=[prompt_test_handler]) + app.command("test")(ack=respond_to_command, lazy=[command_handler]) # ================================================================ @@ -54,19 +54,21 @@ def setup_handlers(app: App) -> None: def respond_to_events(event: Dict[str, Any], ack: Ack, client: WebClient): if should_reply_to_message(event, client): respond_with_eyes(event=event, client=client) - logger.debug("Sending ack response") + logger.debug("Sending ack response for event") ack() # ack function for actions where we just send an ack response back def respond_to_action(ack: Ack): - logger.debug("Sending ack response") + logger.debug("Sending ack response for action") ack() -def respond_to_command(ack: Ack): - logger.debug("Sending ack response") +# ack function for commands where we just send an ack response back +def respond_to_command(ack: Ack, say: Say): + logger.debug("Sending ack response for command") ack() + say("Certainly! Preparing test results...") def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: @@ -146,38 +148,6 @@ def unified_message_handler(client: WebClient, event: Dict[str, Any], body: Dict logger.error("Error triggering async processing", extra={"error": traceback.format_exc()}) -# TODO: Remove duplication with unified_message_handler -def prompt_test_handler(body: Dict[str, Any], event: Dict[str, Any], client: WebClient) -> None: +def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebClient) -> None: """Handle /test command to prompt the bot to respond.""" - try: - event_id = gate_common(event=event, body=body) - logger.debug("logging result of gate_common", extra={"event_id": event_id, "body": body}) - if not event_id: - return - except Exception as e: - logger.error(f"Error handling /test command: {e}", extra={"error": traceback.format_exc()}) - # if its in a group chat - # and its a message - # and its not in a thread - # then ignore it as it will be handled as an app_mention event - if not should_reply_to_message(event, client): - logger.debug("Ignoring message in group chat not in a thread or bot not in thread", extra={"event": event}) - # ignore messages in group chats or threads where bot wasn't mentioned - return - user_id = event.get("user", "unknown") - conversation_key, _ = conversation_key_and_root(event=event) - session_pull_request_id = extract_session_pull_request_id(conversation_key) - if session_pull_request_id: - logger.info( - f"Message in pull request session {session_pull_request_id} from user {user_id}", - extra={"session_pull_request_id": session_pull_request_id}, - ) - forward_to_pull_request_lambda( - body=body, - event=event, - pull_request_id=session_pull_request_id, - event_id=event_id, - store_pull_request_id=False, - type="event", - ) - return + logger.info("Received /test command from user", extra={"body": body, "command": command, "client": client}) From 65c6bfe8a014249ede9bf6660d83e20f4ddaa345 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 13:44:28 +0000 Subject: [PATCH 06/44] feat: Manage command requests with PR ids --- .../app/slack/slack_events.py | 6 ++++ .../app/slack/slack_handlers.py | 32 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index d9b8ccea..b7ea50ee 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -423,6 +423,12 @@ def process_async_slack_event(event: Dict[str, Any], event_id: str, client: WebC process_slack_message(event=event, event_id=event_id, client=client) +def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> None: + logger.debug("Processing async Slack command", extra={"command": command}) + + logger.debug("Command not implemented") + + def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClient) -> None: """ Process Slack events asynchronously after initial acknowledgment diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index e41d4101..fcdbae75 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -18,13 +18,14 @@ ) from app.utils.handler_utils import ( conversation_key_and_root, + extract_pull_request_id, extract_session_pull_request_id, forward_to_pull_request_lambda, gate_common, respond_with_eyes, should_reply_to_message, ) -from app.slack.slack_events import process_async_slack_action, process_async_slack_event +from app.slack.slack_events import process_async_slack_action, process_async_slack_event, process_async_slack_command logger = get_logger() @@ -145,9 +146,34 @@ def unified_message_handler(client: WebClient, event: Dict[str, Any], body: Dict try: process_async_slack_event(event=event, event_id=event_id, client=client) except Exception: - logger.error("Error triggering async processing", extra={"error": traceback.format_exc()}) + logger.error("Error triggering async processing for event", extra={"error": traceback.format_exc()}) def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebClient) -> None: """Handle /test command to prompt the bot to respond.""" - logger.info("Received /test command from user", extra={"body": body, "command": command, "client": client}) + logger.info("Received command from user", extra={"body": body, "command": command, "client": client}) + if not command: + logger.error("Invalid command payload") + return + + user_id = command.get("user_id") + session_pull_request_id = extract_pull_request_id(command.get("text").strip()) + if session_pull_request_id: + logger.info( + f"Command in pull request session {session_pull_request_id} from user {user_id}", + extra={"session_pull_request_id": session_pull_request_id}, + ) + forward_to_pull_request_lambda( + body=body, + event=None, + pull_request_id=session_pull_request_id, + event_id="", + store_pull_request_id=False, + type="command", + ) + return + + try: + process_async_slack_command(command, client) + except Exception: + logger.error("Error triggering async processing for command", extra={"error": traceback.format_exc()}) From c046aa22b66c36e1a63ef13e71f30b4629198954 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 14:56:08 +0000 Subject: [PATCH 07/44] feat: Use correct command id --- packages/slackBotFunction/app/slack/slack_handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index fcdbae75..f65e1f71 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -43,7 +43,7 @@ def setup_handlers(app: App) -> None: app.action("feedback_no")(ack=respond_to_action, lazy=[feedback_handler]) for i in range(1, 10): app.action(f"cite_{i}")(ack=respond_to_action, lazy=[feedback_handler]) - app.command("test")(ack=respond_to_command, lazy=[command_handler]) + app.command("/test")(ack=respond_to_command, lazy=[command_handler]) # ================================================================ From 2979738bdff20c9d70de06d682f0e8e8932fd5e3 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 17:02:44 +0000 Subject: [PATCH 08/44] feat: reply with a list of questions (unanswered) --- .../app/services/sample_questions.py | 57 +++++++++++++++++++ .../app/slack/slack_events.py | 18 +++++- .../app/slack/slack_handlers.py | 4 +- .../app/utils/handler_utils.py | 29 +++++++++- .../tests/example_command.json | 36 ++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 packages/slackBotFunction/app/services/sample_questions.py create mode 100644 packages/slackBotFunction/tests/example_command.json diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py new file mode 100644 index 00000000..d1e2ae65 --- /dev/null +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -0,0 +1,57 @@ +# flake8: noqa: E501 +# fmt: off +class SampleQuestionBank: + """A collection of sample questions for testing purposes.""" + + def __init__(self): + self.questions = [] + # Append data as tuples: (id, text) + self.questions.append((0, "can a prescribed item have more than one endorsement or is it expected that a single item has no more than one endorsement?")) # noqa: E501 + self.questions.append((1, "for the non-repudiation screen, Note - is this the note to pharmacy? note to patient?")) # noqa: E501 + self.questions.append((2, """We are getting an error from DSS "Unauthorised". We have checked the credentials and they appear to be correct. What could be the cause? Note that, the CIS2 key is different to the key used in the EPS application (which has the signing api attached to it)""")) # noqa: E501 + self.questions.append((3, """Can you clarify this requirement? + +"The connecting system must monitor the sending of prescription order request and alert the NHSE service desk if requests fail to send as expected"a)Is this meant to be an automated process, or should an option be given to the user to send an e-mail? +b)What will be the e-mail used? +c)Any certain format needed for the report? +d)Should this be done only after 2 hours of retries that failed?""")) # noqa: E501 + self.questions.append((4, "how do we find if a patient is a cross-border patient?")) # noqa: E501 + self.questions.append((5, "When a prescription has only one prescribed item and the prescriber cancels the prescribed item will the prescription be downloaded when a bulk download request is made to /FHIR/R4/Task/$release?")) # noqa: E501 + self.questions.append((6, "When I have authenticated with a smartcard using CIS2 over an HSCN connection what happens when the smartcard is removed?")) # noqa: E501 + self.questions.append((7, "direct me to the web page that has the details on how to uninstall Oberthur SR1")) # noqa: E501 + self.questions.append((8, """Should the Dispensing system send a Claim notification message when all the prescription items on the prescription have expired, and if it should then what value should be sent for the overall prescription form status for the FHIAR API extension "https://fhir.nhs.uk/StructureDefinition/Extension-EPS-TaskBusinessStatus"?""")) # noqa: E501 + self.questions.append((9, """For an EPS FHIR API Dispense Notification message, when should the value "on-hold" be used for resource: { "resourceType": "MedicationDispense", "status"} for a prescription item ?""")) # noqa: E501 + self.questions.append((10, "confirm if dispensing systems are required to include an option for the user to select the MedicationDispenseReasoncodes of 004, 005, and 009")) # noqa: E501 + self.questions.append((11, "how many lines can a prescription have?")) # noqa: E501 + self.questions.append((12, "Is extension:controlledDrugSchedule.value.userSelected. used for EPS?")) # noqa: E501 + self.questions.append((13, "Can we use dosageInstruction.timing.repeat.boundsPeriod.start or ...dosageInstruction.timing.repeat.boundsPeriod.end?")) # noqa: E501 + self.questions.append((14, "show me example of prescription successfully created response within the Electronic Prescribing System (EPS), where a unique identifier for the prescription, often referred to as the prescription ID is shown")) # noqa: E501 + self.questions.append((15, "please provide a documentation link where i find the specific format of prescribing token?")) # noqa: E501 + self.questions.append((16, """I have a query related to cancelling Repeat Dispensing (RD) courses. We would like to know what the Spine allows in terms of cancelling particular issues of a RD course.For example, let's say a patient has an ongoing RD course which originally had 6 issues. Let's say that issue 1 has been dispensed (status 'Dispensed'), and issue 2 has been pulled down from the Spine by the pharmacist (status 'With dispenser'). The remaining issues 3-6 are still on spine.Can the 'Cancel' request sent to Spine handle the following scenarios? And what will happen if so? + +Doctor requests to cancel all issues, 1-6 +Doctor requests to cancel all remaining issues on Spine, 3-6""")) # noqa: E501 + self.questions.append((17, "Does a Cancel request for a medication that has been downloaded include the pharmacy details of the pharmacy that had downloaded the medication in the response")) # noqa: E501 + self.questions.append((18, """For MedicationRequest "entry. resource. groupIdentifier. extension. valueIdentifier" what is the Long-form Prescription ID?""")) # noqa: E501 + self.questions.append((19, "for the non-repudiation screen, do we use the patient friendly text for dosage information?")) # noqa: E501 + self.questions.append((20, "Can an API have multiple callback URLs")) # noqa: E501 + + def get_questions(self, start: int, end: int): + """ + Pulls a selection of questions + """ + if start < 0: + start = 0 + + if end < 0 or end < start: + end = start + + if start == 0: + start += 1 + end += 1 + + # Extract only the text (index 1) from the tuple + return [q[1] for q in self.questions[start - 1 : end]] + + def add_questions(self, question_text: str): + self.questions.append((len(self.questions), question_text)) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index b7ea50ee..33ed43e6 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -24,10 +24,12 @@ update_state_information, ) +from app.services.sample_questions import SampleQuestionBank from app.services.slack import get_friendly_channel_name, post_error_message from app.utils.handler_utils import ( conversation_key_and_root, extract_pull_request_id, + extract_test_command_params, forward_to_pull_request_lambda, is_duplicate_event, is_latest_message, @@ -426,7 +428,21 @@ def process_async_slack_event(event: Dict[str, Any], event_id: str, client: WebC def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> None: logger.debug("Processing async Slack command", extra={"command": command}) - logger.debug("Command not implemented") + params = extract_test_command_params(command.get("text")) + pr = params.get("pr", "") + pr = f"pr: {pr}" if pr else "" + + start = params.get("start", "") + end = params.get("end", "") + logger.info("Test command parameters", extra={"start": start, "end": end}) + + test_questions = SampleQuestionBank().get_questions(start=int(start), end=int(end)) + for question in test_questions: + post_params = { + "channel": command["channel_id"], + "text": f"{pr} {question}", + } + client.chat_postMessage(**post_params) def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClient) -> None: diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index f65e1f71..4d3a97fd 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -18,8 +18,8 @@ ) from app.utils.handler_utils import ( conversation_key_and_root, - extract_pull_request_id, extract_session_pull_request_id, + extract_test_command_params, forward_to_pull_request_lambda, gate_common, respond_with_eyes, @@ -157,7 +157,7 @@ def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebCl return user_id = command.get("user_id") - session_pull_request_id = extract_pull_request_id(command.get("text").strip()) + session_pull_request_id = extract_test_command_params(command.get("text")).get("pr") if session_pull_request_id: logger.info( f"Command in pull request session {session_pull_request_id} from user {user_id}", diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 1d810510..38827269 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -158,7 +158,7 @@ def strip_mentions(message_text: str) -> str: def extract_pull_request_id(text: str) -> Tuple[str | None, str]: prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex - pattern = rf"^(<[^>]+>\s*)?{prefix}\s*(\d+)\b" + pattern = rf"^(<[^>]+>\s*)?({prefix})\s*(\d+)\b" match = re.match(pattern, text, flags=re.IGNORECASE) if match: @@ -173,6 +173,33 @@ def extract_pull_request_id(text: str) -> Tuple[str | None, str]: return None, text.strip() +def extract_test_command_params(text: str) -> Dict[str, str]: + """ + Extract parameters from the /test command text. + + Expected format: /test pr: 123 q1-2 + """ + params = { + "pr": None, + "q-start": "0", + "q-end": "20", + } + prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex + pr_pattern = rf"(<[^>]+>\s*)?({prefix})\s*(\d+)\b" + q_pattern = r"\bq-?(\d+)(?:-(\d+))?" + + pr_match = re.match(pr_pattern, text, flags=re.IGNORECASE) + if pr_match: + params["pr"] = pr_match.group(2) + + q_match = re.search(q_pattern, text, flags=re.IGNORECASE) + if q_match: + params["q-start"] = q_match.group(1) + params["q-end"] = q_match.group(2) if q_match.group(2) else q_match.group(1) + + return params + + def conversation_key_and_root(event: Dict[str, Any]) -> Tuple[str, str]: """ Build a stable conversation scope and its root timestamp. diff --git a/packages/slackBotFunction/tests/example_command.json b/packages/slackBotFunction/tests/example_command.json new file mode 100644 index 00000000..d506414c --- /dev/null +++ b/packages/slackBotFunction/tests/example_command.json @@ -0,0 +1,36 @@ +{ + "body": { + "token": "QxKSL5PxaCmAd9hhII4pIbCe", + "team_id": "TRK2Y2T1U", + "team_domain": "nhsdigital-platforms", + "channel_id": "D0A0BQDBCA0", + "channel_name": "directmessage", + "user_id": "U09TSP13P9P", + "user_name": "kieran.wilkinson4", + "command": "/test", + "text": "pr: 261", + "api_app_id": "A09FAPJQUG7", + "is_enterprise_install": "false", + "enterprise_id": "E02KFHR77DL", + "enterprise_name": "NHS England", + "response_url": "https://hooks.slack.com/commands/TRK2Y2T1U/10233876396961/KLkYSgUizMbBlRyUal0ZW5cc", + "trigger_id": "10224545323140.869100095062.9f251bf0cd683d4306291c2547a77e0f" + }, + "command": { + "token": "QxKSL5PxaCmAd9hhII4pIbCe", + "team_id": "TRK2Y2T1U", + "team_domain": "nhsdigital-platforms", + "channel_id": "D0A0BQDBCA0", + "channel_name": "directmessage", + "user_id": "U09TSP13P9P", + "user_name": "kieran.wilkinson4", + "command": "/test", + "text": "pr: 261", + "api_app_id": "A09FAPJQUG7", + "is_enterprise_install": "false", + "enterprise_id": "E02KFHR77DL", + "enterprise_name": "NHS England", + "response_url": "https://hooks.slack.com/commands/TRK2Y2T1U/10233876396961/KLkYSgUizMbBlRyUal0ZW5cc", + "trigger_id": "10224545323140.869100095062.9f251bf0cd683d4306291c2547a77e0f" + } +} From 26ddcdf1dfe3efd91676c65c32c8c6ff99ccbbca Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Fri, 2 Jan 2026 17:10:22 +0000 Subject: [PATCH 09/44] feat: revert regex fix --- packages/slackBotFunction/app/utils/handler_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 38827269..f152e7d2 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -158,7 +158,7 @@ def strip_mentions(message_text: str) -> str: def extract_pull_request_id(text: str) -> Tuple[str | None, str]: prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex - pattern = rf"^(<[^>]+>\s*)?({prefix})\s*(\d+)\b" + pattern = rf"^(<[^>]+>\s*)?{prefix}\s*(\d+)\b" match = re.match(pattern, text, flags=re.IGNORECASE) if match: From fa4cd1ebe36fbb7dfb2b4c481df913570d5a71ff Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 09:17:56 +0000 Subject: [PATCH 10/44] feat: handle questions --- .../app/services/sample_questions.py | 20 +++++++++++-------- .../app/slack/slack_events.py | 4 ++-- .../app/utils/handler_utils.py | 11 +++++----- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index d1e2ae65..90b06548 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -5,7 +5,7 @@ class SampleQuestionBank: def __init__(self): self.questions = [] - # Append data as tuples: (id, text) + # Append data as tuples: (id, text) just to make it easier to read for users. self.questions.append((0, "can a prescribed item have more than one endorsement or is it expected that a single item has no more than one endorsement?")) # noqa: E501 self.questions.append((1, "for the non-repudiation screen, Note - is this the note to pharmacy? note to patient?")) # noqa: E501 self.questions.append((2, """We are getting an error from DSS "Unauthorised". We have checked the credentials and they appear to be correct. What could be the cause? Note that, the CIS2 key is different to the key used in the EPS application (which has the signing api attached to it)""")) # noqa: E501 @@ -40,15 +40,19 @@ def get_questions(self, start: int, end: int): """ Pulls a selection of questions """ + # Must be integers + if not isinstance(start, int): + raise TypeError(f"'start' must be an integer, got {type(start).__name__}") + + if not isinstance(end, int): + raise TypeError(f"'end' must be an integer, got {type(end).__name__}") + + # Must be in valid range if start < 0: - start = 0 - + raise ValueError("'start' cannot be negative") + if end < 0 or end < start: - end = start - - if start == 0: - start += 1 - end += 1 + raise ValueError("'end' must be non-negative and greater than or equal to 'start'") # Extract only the text (index 1) from the tuple return [q[1] for q in self.questions[start - 1 : end]] diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 33ed43e6..df3482d9 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -432,8 +432,8 @@ def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> N pr = params.get("pr", "") pr = f"pr: {pr}" if pr else "" - start = params.get("start", "") - end = params.get("end", "") + start = params.get("start", 0) + end = params.get("end", 20) logger.info("Test command parameters", extra={"start": start, "end": end}) test_questions = SampleQuestionBank().get_questions(start=int(start), end=int(end)) diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index f152e7d2..b6970249 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -181,11 +181,11 @@ def extract_test_command_params(text: str) -> Dict[str, str]: """ params = { "pr": None, - "q-start": "0", - "q-end": "20", + "start": "0", + "end": "20", } prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex - pr_pattern = rf"(<[^>]+>\s*)?({prefix})\s*(\d+)\b" + pr_pattern = rf"{prefix}\s*(\d+)\b" q_pattern = r"\bq-?(\d+)(?:-(\d+))?" pr_match = re.match(pr_pattern, text, flags=re.IGNORECASE) @@ -194,9 +194,10 @@ def extract_test_command_params(text: str) -> Dict[str, str]: q_match = re.search(q_pattern, text, flags=re.IGNORECASE) if q_match: - params["q-start"] = q_match.group(1) - params["q-end"] = q_match.group(2) if q_match.group(2) else q_match.group(1) + params["start"] = q_match.group(1) + params["end"] = q_match.group(2) if q_match.group(2) else q_match.group(1) + logger.debug("Extracted test command parameters", extra={"params": params}) return params From 50f38efaa1d361ed63f9c463a5db960246b23c77 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 09:40:38 +0000 Subject: [PATCH 11/44] feat: add more logs --- .../app/services/sample_questions.py | 2 +- .../app/slack/slack_events.py | 37 +++++++++++-------- .../app/slack/slack_handlers.py | 2 +- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index 90b06548..f3954de4 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -55,7 +55,7 @@ def get_questions(self, start: int, end: int): raise ValueError("'end' must be non-negative and greater than or equal to 'start'") # Extract only the text (index 1) from the tuple - return [q[1] for q in self.questions[start - 1 : end]] + return [q[1] for q in self.questions[start : end]] def add_questions(self, question_text: str): self.questions.append((len(self.questions), question_text)) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index df3482d9..00ec98fb 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -428,21 +428,28 @@ def process_async_slack_event(event: Dict[str, Any], event_id: str, client: WebC def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> None: logger.debug("Processing async Slack command", extra={"command": command}) - params = extract_test_command_params(command.get("text")) - pr = params.get("pr", "") - pr = f"pr: {pr}" if pr else "" - - start = params.get("start", 0) - end = params.get("end", 20) - logger.info("Test command parameters", extra={"start": start, "end": end}) - - test_questions = SampleQuestionBank().get_questions(start=int(start), end=int(end)) - for question in test_questions: - post_params = { - "channel": command["channel_id"], - "text": f"{pr} {question}", - } - client.chat_postMessage(**post_params) + try: + params = extract_test_command_params(command.get("text")) + pr = params.get("pr", "") + pr = f"pr: {pr}" if pr else "" + + start = int(params.get("start", 0)) + end = int(params.get("end", 20)) + logger.info("Test command parameters", extra={"start": start, "end": end}) + + test_questions = SampleQuestionBank().get_questions(start=start, end=end) + logger.info("Retrieved test questions", extra={"count": len(test_questions)}) + + for question in test_questions: + logger.info("Posting test question", extra={"question": question}) + post_params = { + "channel": command["channel_id"], + "text": f"{pr} {question}", + } + client.chat_postMessage(**post_params) + except Exception as e: + logger.error(f"Error processing test command: {e}", extra={"error": traceback.format_exc()}) + post_error_message(channel=command["channel_id"], thread_ts=None, client=client) def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClient) -> None: diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 4d3a97fd..a44f7d75 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -69,7 +69,7 @@ def respond_to_action(ack: Ack): def respond_to_command(ack: Ack, say: Say): logger.debug("Sending ack response for command") ack() - say("Certainly! Preparing test results...") + say("Certainly! Preparing tests...") def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: From 30ba64916abc71fed22e57d746ab3e65aa453466 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 11:01:44 +0000 Subject: [PATCH 12/44] feat: Add helper command --- .../app/services/sample_questions.py | 2 +- .../app/slack/slack_events.py | 83 +++++++++++++++---- .../app/slack/slack_handlers.py | 1 - .../app/utils/handler_utils.py | 1 + 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index f3954de4..5050ed3f 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -55,7 +55,7 @@ def get_questions(self, start: int, end: int): raise ValueError("'end' must be non-negative and greater than or equal to 'start'") # Extract only the text (index 1) from the tuple - return [q[1] for q in self.questions[start : end]] + return [q[1] for q in self.questions[start : end + 1]] def add_questions(self, question_text: str): self.questions.append((len(self.questions), question_text)) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 00ec98fb..921d8459 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -429,24 +429,9 @@ def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> N logger.debug("Processing async Slack command", extra={"command": command}) try: - params = extract_test_command_params(command.get("text")) - pr = params.get("pr", "") - pr = f"pr: {pr}" if pr else "" - - start = int(params.get("start", 0)) - end = int(params.get("end", 20)) - logger.info("Test command parameters", extra={"start": start, "end": end}) - - test_questions = SampleQuestionBank().get_questions(start=start, end=end) - logger.info("Retrieved test questions", extra={"count": len(test_questions)}) - - for question in test_questions: - logger.info("Posting test question", extra={"question": question}) - post_params = { - "channel": command["channel_id"], - "text": f"{pr} {question}", - } - client.chat_postMessage(**post_params) + command_arg = command.get("command", "").strip() + if command_arg == "/test": + process_command_test(command=command, client=client) except Exception as e: logger.error(f"Error processing test command: {e}", extra={"error": traceback.format_exc()}) post_error_message(channel=command["channel_id"], thread_ts=None, client=client) @@ -758,6 +743,68 @@ def _toggle_button_style(element: dict) -> bool: return True +# ================================================================ +# Command management +# ================================================================ +def process_command_test(command: Dict[str, Any], client: WebClient) -> None: + if "test" in command.get("text"): + process_command_test_help(command=command, client=client) + else: + process_command_test_response(command=command, client=client) + + +def process_command_test_response(command: Dict[str, Any], client: WebClient) -> None: + # Initial acknowledgment + post_params = { + "channel": command["channel_id"], + "text": "Certainly! Here are some sample test questions:\n\n", + } + client.chat_postMessage(**post_params) + + # Extract parameters + params = extract_test_command_params(command.get("text")) + + pr = params.get("pr", "") + start = int(params.get("start", 0)) + end = int(params.get("end", 20)) + logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) + + # Retrieve sample questions + test_questions = SampleQuestionBank().get_questions(start=start, end=end) + logger.info("Retrieved test questions", extra={"count": len(test_questions)}) + + # Post each test question + for question in test_questions: + logger.info("Posting test question", extra={"question": question}) + post_params["text"] = f"{pr} {question}\n" + client.chat_postMessage(**post_params) + + +def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: + help_text = """ + Certainly! Here is some help testing me! + + You can use the `/test` command to send sample test questions to the bot. + The command supports parameters to specify the range of questions or have me target a specific pull request. + + - Usage: + - /test [pr: ] [q-] + + - Parameters: + - : (optional) Specify a pull request ID to associate the questions with. + - : (optional) The starting and ending index of the sample questions (default is 0-20). + - : The ending index of the sample questions (default is 20). + + - Examples: + - /test --> Sends questions 0 to 20 + - /test q15 --> Sends question 15 only + - /test q10-25 --> Sends questions 10 to 25 + - /test pr:12345 --> Sends questions 0 to 20 for pull request 12345 + - /test pr:12345 q5-15 --> Sends questions 5 to 15 for pull request 12345 + """ + client.chat_postMessage(channel=command["channel_id"], text=help_text) + + # ================================================================ # Session management # ================================================================ diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index a44f7d75..0dd89df5 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -69,7 +69,6 @@ def respond_to_action(ack: Ack): def respond_to_command(ack: Ack, say: Say): logger.debug("Sending ack response for command") ack() - say("Certainly! Preparing tests...") def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index b6970249..503c562c 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -191,6 +191,7 @@ def extract_test_command_params(text: str) -> Dict[str, str]: pr_match = re.match(pr_pattern, text, flags=re.IGNORECASE) if pr_match: params["pr"] = pr_match.group(2) + params["pr"] = f"pr: {params["pr"]}" if params["pr"] else "" q_match = re.search(q_pattern, text, flags=re.IGNORECASE) if q_match: From 1e31384f00e08e7b41dd57a4e10247caf893f386 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 11:17:00 +0000 Subject: [PATCH 13/44] feat: Process and format message --- .../slackBotFunction/app/services/sample_questions.py | 2 +- packages/slackBotFunction/app/slack/slack_events.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index 5050ed3f..b12db9a2 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -55,7 +55,7 @@ def get_questions(self, start: int, end: int): raise ValueError("'end' must be non-negative and greater than or equal to 'start'") # Extract only the text (index 1) from the tuple - return [q[1] for q in self.questions[start : end + 1]] + return list(self.questions[start : end + 1]) def add_questions(self, question_text: str): self.questions.append((len(self.questions), question_text)) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 921d8459..9ac7b459 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -757,7 +757,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Initial acknowledgment post_params = { "channel": command["channel_id"], - "text": "Certainly! Here are some sample test questions:\n\n", + "text": "Test acknowledged. Processing...", } client.chat_postMessage(**post_params) @@ -768,6 +768,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> start = int(params.get("start", 0)) end = int(params.get("end", 20)) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) + client.chat_postMessage(channel=command["channel_id"], text=f"Testing questions {start} to {end}.\n") # Retrieve sample questions test_questions = SampleQuestionBank().get_questions(start=start, end=end) @@ -776,8 +777,10 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: logger.info("Posting test question", extra={"question": question}) - post_params["text"] = f"{pr} {question}\n" - client.chat_postMessage(**post_params) + post_params["text"] = f"{question[0]}\n> {question[1]}\n" + response = client.chat_postMessage(**post_params) + + logger.debug("Simulating user message", extra={"response": response}) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: From 359faac216731442175cdda1af9a219c4bc267ef Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 12:03:41 +0000 Subject: [PATCH 14/44] feat: Process slack message --- packages/slackBotFunction/app/slack/slack_events.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 9ac7b459..e5aefebf 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -777,10 +777,17 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: logger.info("Posting test question", extra={"question": question}) - post_params["text"] = f"{question[0]}\n> {question[1]}\n" + post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\\n', '\\n> ')}\n" response = client.chat_postMessage(**post_params) - logger.debug("Simulating user message", extra={"response": response}) + message_params = { + "user": response["bot_profile"]["user_id"], + "channel": response["channel"], + "text": question[1], + "thread_ts": response["ts"], + } + + process_slack_message(event=message_params, event_id=f"command-{response['ts']}", client=client) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: From adc18da90dfcf595c19c7de2552a35b8d40d71f0 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 12:27:30 +0000 Subject: [PATCH 15/44] feat: Process response --- .../app/services/sample_questions.py | 2 +- packages/slackBotFunction/app/slack/slack_events.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index b12db9a2..f929c277 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -36,7 +36,7 @@ def __init__(self): self.questions.append((19, "for the non-repudiation screen, do we use the patient friendly text for dosage information?")) # noqa: E501 self.questions.append((20, "Can an API have multiple callback URLs")) # noqa: E501 - def get_questions(self, start: int, end: int): + def get_questions(self, start: int, end: int) -> list[tuple[int, str]]: """ Pulls a selection of questions """ diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index e5aefebf..24b708aa 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -757,7 +757,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Initial acknowledgment post_params = { "channel": command["channel_id"], - "text": "Test acknowledged. Processing...", + "text": "Test Initialised...\n\n", } client.chat_postMessage(**post_params) @@ -768,7 +768,6 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> start = int(params.get("start", 0)) end = int(params.get("end", 20)) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) - client.chat_postMessage(channel=command["channel_id"], text=f"Testing questions {start} to {end}.\n") # Retrieve sample questions test_questions = SampleQuestionBank().get_questions(start=start, end=end) @@ -776,12 +775,15 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: - logger.info("Posting test question", extra={"question": question}) - post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\\n', '\\n> ')}\n" + index = question[0] + text = f"Question {index}:\n> {question[1].replace('\\n', '\\n> ')}\n" + logger.info("Posting test question", extra={"index": index, "question": text}) + + post_params["text"] = f"Question {index}:\n> {text}\n" response = client.chat_postMessage(**post_params) message_params = { - "user": response["bot_profile"]["user_id"], + "user": response["message"]["user"], "channel": response["channel"], "text": question[1], "thread_ts": response["ts"], From fa2578a3a328361e99b687c22f312d56141fcf6d Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 13:02:14 +0000 Subject: [PATCH 16/44] feat: Evoke message event --- packages/slackBotFunction/app/slack/slack_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 24b708aa..1ff6a3e7 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -776,7 +776,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: index = question[0] - text = f"Question {index}:\n> {question[1].replace('\\n', '\\n> ')}\n" + text = f"Question {index}:\n> {question[1].replace('\n', '\n> ')}\n" logger.info("Posting test question", extra={"index": index, "question": text}) post_params["text"] = f"Question {index}:\n> {text}\n" From f7ce1bd2de2cd2677cc3d3683599ca3723c3621d Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 13:02:29 +0000 Subject: [PATCH 17/44] feat: Evoke message event --- .../app/slack/slack_events.py | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 1ff6a3e7..7b643f89 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -757,9 +757,9 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Initial acknowledgment post_params = { "channel": command["channel_id"], - "text": "Test Initialised...\n\n", + "text": "Initialising tests...\n", } - client.chat_postMessage(**post_params) + client.chat_meMessage(**post_params) # Extract parameters params = extract_test_command_params(command.get("text")) @@ -775,21 +775,18 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: - index = question[0] - text = f"Question {index}:\n> {question[1].replace('\n', '\n> ')}\n" - logger.info("Posting test question", extra={"index": index, "question": text}) - - post_params["text"] = f"Question {index}:\n> {text}\n" + # Construct message to evoke event processing + post_params["text"] = question[1] + post_params["as_user"] = True response = client.chat_postMessage(**post_params) - message_params = { - "user": response["message"]["user"], - "channel": response["channel"], - "text": question[1], - "thread_ts": response["ts"], - } + # Update message to make it more user-friendly + post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" + post_params["thread_ts"] = response["ts"] + client.chat_update(**post_params) - process_slack_message(event=message_params, event_id=f"command-{response['ts']}", client=client) + post_params["text"] = "\nTesting complete.\n" + client.chat_meMessage(**post_params) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: From bd3989e9a1950ec94213c22d6a2e3776844b5eea Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 13:03:20 +0000 Subject: [PATCH 18/44] feat: Evoke PR message event --- packages/slackBotFunction/app/slack/slack_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 7b643f89..43ac1b41 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -776,7 +776,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: # Construct message to evoke event processing - post_params["text"] = question[1] + post_params["text"] = f"{pr} {question[1]}" post_params["as_user"] = True response = client.chat_postMessage(**post_params) From 22ba2e10f2d1725305dd0fb0f33efc5688896773 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 14:06:31 +0000 Subject: [PATCH 19/44] feat: Fix PR and ts --- packages/slackBotFunction/app/slack/slack_events.py | 8 ++++---- packages/slackBotFunction/app/utils/handler_utils.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 43ac1b41..c413006f 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -757,14 +757,14 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Initial acknowledgment post_params = { "channel": command["channel_id"], - "text": "Initialising tests...\n", + "text": "Initialising tests...\n", # TODO - LIST PR NUMBERS } client.chat_meMessage(**post_params) # Extract parameters params = extract_test_command_params(command.get("text")) - pr = params.get("pr", "") + pr = params.get("pr", "").trim() start = int(params.get("start", 0)) end = int(params.get("end", 20)) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) @@ -782,7 +782,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Update message to make it more user-friendly post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" - post_params["thread_ts"] = response["ts"] + post_params["ts"] = response["ts"] client.chat_update(**post_params) post_params["text"] = "\nTesting complete.\n" @@ -811,7 +811,7 @@ def process_command_test_help(command: Dict[str, Any], client: WebClient) -> Non - /test pr:12345 --> Sends questions 0 to 20 for pull request 12345 - /test pr:12345 q5-15 --> Sends questions 5 to 15 for pull request 12345 """ - client.chat_postMessage(channel=command["channel_id"], text=help_text) + client.chat_meMessage(channel=command["channel_id"], text=help_text) # ================================================================ diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 503c562c..303479fb 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -180,7 +180,7 @@ def extract_test_command_params(text: str) -> Dict[str, str]: Expected format: /test pr: 123 q1-2 """ params = { - "pr": None, + "pr": "", "start": "0", "end": "20", } @@ -190,7 +190,7 @@ def extract_test_command_params(text: str) -> Dict[str, str]: pr_match = re.match(pr_pattern, text, flags=re.IGNORECASE) if pr_match: - params["pr"] = pr_match.group(2) + params["pr"] = pr_match.group(1) params["pr"] = f"pr: {params["pr"]}" if params["pr"] else "" q_match = re.search(q_pattern, text, flags=re.IGNORECASE) From 09be49b66a269e5e8605cdd1b461c54d513d7d1f Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 14:47:12 +0000 Subject: [PATCH 20/44] feat: Pr formatting and sort out help --- packages/slackBotFunction/app/slack/slack_events.py | 8 +++++--- packages/slackBotFunction/app/utils/handler_utils.py | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index c413006f..fc1f6f06 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -747,7 +747,7 @@ def _toggle_button_style(element: dict) -> bool: # Command management # ================================================================ def process_command_test(command: Dict[str, Any], client: WebClient) -> None: - if "test" in command.get("text"): + if "help" in command.get("text"): process_command_test_help(command=command, client=client) else: process_command_test_response(command=command, client=client) @@ -757,14 +757,16 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Initial acknowledgment post_params = { "channel": command["channel_id"], - "text": "Initialising tests...\n", # TODO - LIST PR NUMBERS + "text": "Initialising tests...\n", } client.chat_meMessage(**post_params) # Extract parameters params = extract_test_command_params(command.get("text")) - pr = params.get("pr", "").trim() + pr = params.get("pr", "").strip() + pr = f"pr: {pr}" if pr else "" + start = int(params.get("start", 0)) end = int(params.get("end", 20)) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 303479fb..f47d926b 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -191,7 +191,6 @@ def extract_test_command_params(text: str) -> Dict[str, str]: pr_match = re.match(pr_pattern, text, flags=re.IGNORECASE) if pr_match: params["pr"] = pr_match.group(1) - params["pr"] = f"pr: {params["pr"]}" if params["pr"] else "" q_match = re.search(q_pattern, text, flags=re.IGNORECASE) if q_match: From 4db10fe48368dab6743ed8415245529e630f5046 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 15:48:01 +0000 Subject: [PATCH 21/44] feat: Attempt to post as user --- packages/slackBotFunction/app/slack/slack_events.py | 2 ++ packages/slackBotFunction/app/utils/handler_utils.py | 5 +++-- packages/slackBotFunction/tests/test_forward_to_lambda.py | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index fc1f6f06..b51dac63 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -780,6 +780,8 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Construct message to evoke event processing post_params["text"] = f"{pr} {question[1]}" post_params["as_user"] = True + post_params["token"] = command.get("token") + post_params["username"] = command.get("user_name") response = client.chat_postMessage(**post_params) # Update message to make it more user-friendly diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index f47d926b..8960b2c6 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -80,11 +80,11 @@ def forward_to_pull_request_lambda( pull_request_lambda_arn = get_pull_request_lambda_arn(pull_request_id=pull_request_id) lambda_payload = get_forward_payload(body=body, event=event, event_id=event_id, type=type) logger.debug( - f"Forwarding {type} to pull request lambda", + f"Forwarding '{type}' to pull request lambda", extra={"lambda_arn": pull_request_lambda_arn, "lambda_payload": lambda_payload}, ) lambda_client.invoke( - FunctionName=pull_request_lambda_arn, InvocationType="Event", Payload=json.dumps(lambda_payload) + FunctionName=pull_request_lambda_arn, InvocationType=type.title(), Payload=json.dumps(lambda_payload) ) logger.info("Triggered pull request lambda", extra={"lambda_arn": pull_request_lambda_arn}) @@ -317,6 +317,7 @@ def should_reply_to_message(event: Dict[str, Any], client: WebClient = None) -> - Message is in a group chat (channel_type == 'group') but not in a thread - Message is in a channel or group thread where the bot was not initially mentioned """ + logger.debug("Checking if should reply to message", extra={"event": event, "client": client}) # we don't reply to non-threaded messages in group chats if event.get("channel_type") == "group" and event.get("type") == "message" and event.get("thread_ts") is None: diff --git a/packages/slackBotFunction/tests/test_forward_to_lambda.py b/packages/slackBotFunction/tests/test_forward_to_lambda.py index 31935cf6..c6c15c60 100644 --- a/packages/slackBotFunction/tests/test_forward_to_lambda.py +++ b/packages/slackBotFunction/tests/test_forward_to_lambda.py @@ -222,6 +222,8 @@ def client_side_effect(service_name, *args, **kwargs): } mock_lambda_client.invoke.assert_called_once_with( - FunctionName="output_SlackBotLambdaArn", InvocationType="Event", Payload=json.dumps(expected_lambda_payload) + FunctionName="output_SlackBotLambdaArn", + InvocationType="Action", + Payload=json.dumps(expected_lambda_payload), ) mock_store_state_information.assert_not_called() From b3882f213e5c2e97fc41bb039c015a3d447a65ea Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 16:19:52 +0000 Subject: [PATCH 22/44] feat: Invoke event directly --- .../slackBotFunction/app/slack/slack_events.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index b51dac63..a364e620 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -777,17 +777,13 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Post each test question for question in test_questions: - # Construct message to evoke event processing - post_params["text"] = f"{pr} {question[1]}" - post_params["as_user"] = True - post_params["token"] = command.get("token") - post_params["username"] = command.get("user_name") - response = client.chat_postMessage(**post_params) - # Update message to make it more user-friendly post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" - post_params["ts"] = response["ts"] - client.chat_update(**post_params) + response = client.chat_postMessage(**post_params) + + # Process as normal message + response["text"] = f"{pr} {question[1]}" + process_slack_message(event=response, event_id=f"test_{response['ts']}", client=client) post_params["text"] = "\nTesting complete.\n" client.chat_meMessage(**post_params) From 8c437d5666db7b13210462cd10a20cbdd56f3469 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 16:48:29 +0000 Subject: [PATCH 23/44] feat: Handle PR redirect --- packages/slackBotFunction/app/handler.py | 16 ++++++++++++- .../app/slack/slack_events.py | 23 +++++++++++++++---- .../app/utils/handler_utils.py | 4 ++-- .../tests/test_forward_to_lambda.py | 4 ++-- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/slackBotFunction/app/handler.py b/packages/slackBotFunction/app/handler.py index e7a3426a..26fce43c 100644 --- a/packages/slackBotFunction/app/handler.py +++ b/packages/slackBotFunction/app/handler.py @@ -19,7 +19,11 @@ create_error_response, ) from app.services.app import get_app -from app.slack.slack_events import process_pull_request_slack_action, process_pull_request_slack_event +from app.slack.slack_events import ( + process_pull_request_slack_action, + process_pull_request_slack_command, + process_pull_request_slack_event, +) logger = get_logger() @@ -55,6 +59,16 @@ def handler(event: dict, context: LambdaContext) -> dict: process_pull_request_slack_event(slack_event_data=slack_event_data) return {"statusCode": 200} + + if event.get("pull_request_command"): + slack_body_data = event.get("slack_event") + if not slack_body_data: + logger.error("Pull request processing requested but no slack_event provided") + return {"statusCode": 400} + + process_pull_request_slack_command(slack_command_data=slack_body_data) + return {"statusCode": 200} + if event.get("pull_request_action"): slack_body_data = event.get("slack_body") if not slack_body_data: diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index a364e620..ade9dd53 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -542,6 +542,21 @@ def process_pull_request_slack_event(slack_event_data: Dict[str, Any]) -> None: logger.error("Error processing message", extra={"event_id": event_id, "error": traceback.format_exc()}) +def process_pull_request_slack_command(slack_command_data: Dict[str, Any]) -> None: + # separate function to process pull requests so that we can ensure we store session information + logger.debug( + "Processing pull request slack command", extra={"slack_command_data": slack_command_data} + ) # Removed line after debugging + try: + command = slack_command_data["event"] + token = get_bot_token() + client = WebClient(token=token) + process_async_slack_command(command=command, client=client) + except Exception: + # we cant post a reply to slack for this error as we may not have details about where to post it + logger.error("Error processing message", extra={"error": traceback.format_exc()}) + + def process_pull_request_slack_action(slack_body_data: Dict[str, Any]) -> None: # separate function to process pull requests so that we can ensure we store session information try: @@ -782,11 +797,9 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> response = client.chat_postMessage(**post_params) # Process as normal message - response["text"] = f"{pr} {question[1]}" - process_slack_message(event=response, event_id=f"test_{response['ts']}", client=client) - - post_params["text"] = "\nTesting complete.\n" - client.chat_meMessage(**post_params) + slack_message = {**response, "text": f"{pr} {question[1]}"} + logger.debug("Processing test question", extra={"slack_message": slack_message}) + process_slack_message(event=slack_message, event_id=f"test_{response['ts']}", client=client) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 8960b2c6..1105acb2 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -84,7 +84,7 @@ def forward_to_pull_request_lambda( extra={"lambda_arn": pull_request_lambda_arn, "lambda_payload": lambda_payload}, ) lambda_client.invoke( - FunctionName=pull_request_lambda_arn, InvocationType=type.title(), Payload=json.dumps(lambda_payload) + FunctionName=pull_request_lambda_arn, InvocationType="Event", Payload=json.dumps(lambda_payload) ) logger.info("Triggered pull request lambda", extra={"lambda_arn": pull_request_lambda_arn}) @@ -100,7 +100,7 @@ def forward_to_pull_request_lambda( def get_forward_payload(body: Dict[str, Any], event: Dict[str, Any], event_id: str, type: str) -> Dict[str, Any]: if type != "event": - return {f"pull_request_{type}": True, "slack_body": body} + return {"pull_request_event": True, "slack_body": body} if event_id is None or event["text"] is None: logger.error("Missing required fields to forward pull request event") diff --git a/packages/slackBotFunction/tests/test_forward_to_lambda.py b/packages/slackBotFunction/tests/test_forward_to_lambda.py index c6c15c60..ea071f05 100644 --- a/packages/slackBotFunction/tests/test_forward_to_lambda.py +++ b/packages/slackBotFunction/tests/test_forward_to_lambda.py @@ -212,7 +212,7 @@ def client_side_effect(service_name, *args, **kwargs): with patch("app.utils.handler_utils.get_pull_request_lambda_arn", return_value="output_SlackBotLambdaArn"): forward_to_pull_request_lambda( - pull_request_id="123", body=mock_body, type="action", event=None, event_id="", store_pull_request_id=False + pull_request_id="123", body=mock_body, type="Event", event=None, event_id="", store_pull_request_id=False ) # assertions @@ -223,7 +223,7 @@ def client_side_effect(service_name, *args, **kwargs): mock_lambda_client.invoke.assert_called_once_with( FunctionName="output_SlackBotLambdaArn", - InvocationType="Action", + InvocationType="Event", Payload=json.dumps(expected_lambda_payload), ) mock_store_state_information.assert_not_called() From d85183d93577b6a8a08b33268e3e1ad22438cb3b Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Mon, 5 Jan 2026 17:03:44 +0000 Subject: [PATCH 24/44] feat: Handle PR redirect --- packages/slackBotFunction/app/handler.py | 7 ++++--- packages/slackBotFunction/app/utils/handler_utils.py | 4 ++-- packages/slackBotFunction/tests/test_forward_to_lambda.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/slackBotFunction/app/handler.py b/packages/slackBotFunction/app/handler.py index 26fce43c..099816bf 100644 --- a/packages/slackBotFunction/app/handler.py +++ b/packages/slackBotFunction/app/handler.py @@ -50,11 +50,12 @@ def handler(event: dict, context: LambdaContext) -> dict: return handle_direct_invocation(event, context) app = get_app(logger=logger) + error_msg = "Pull request processing requested but no slack_event provided" # handle pull request processing requests if event.get("pull_request_event"): slack_event_data = event.get("slack_event") if not slack_event_data: - logger.error("Pull request processing requested but no slack_event provided") + logger.error(error_msg) return {"statusCode": 400} process_pull_request_slack_event(slack_event_data=slack_event_data) @@ -63,7 +64,7 @@ def handler(event: dict, context: LambdaContext) -> dict: if event.get("pull_request_command"): slack_body_data = event.get("slack_event") if not slack_body_data: - logger.error("Pull request processing requested but no slack_event provided") + logger.error(error_msg) return {"statusCode": 400} process_pull_request_slack_command(slack_command_data=slack_body_data) @@ -72,7 +73,7 @@ def handler(event: dict, context: LambdaContext) -> dict: if event.get("pull_request_action"): slack_body_data = event.get("slack_body") if not slack_body_data: - logger.error("Pull request processing requested but no slack_event provided") + logger.error(error_msg) return {"statusCode": 400} process_pull_request_slack_action(slack_body_data=slack_body_data) diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 1105acb2..ca756172 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -99,8 +99,8 @@ def forward_to_pull_request_lambda( def get_forward_payload(body: Dict[str, Any], event: Dict[str, Any], event_id: str, type: str) -> Dict[str, Any]: - if type != "event": - return {"pull_request_event": True, "slack_body": body} + if type == "action": + return {"pull_request_action": True, "slack_body": body} if event_id is None or event["text"] is None: logger.error("Missing required fields to forward pull request event") diff --git a/packages/slackBotFunction/tests/test_forward_to_lambda.py b/packages/slackBotFunction/tests/test_forward_to_lambda.py index ea071f05..e4eccaf7 100644 --- a/packages/slackBotFunction/tests/test_forward_to_lambda.py +++ b/packages/slackBotFunction/tests/test_forward_to_lambda.py @@ -212,7 +212,7 @@ def client_side_effect(service_name, *args, **kwargs): with patch("app.utils.handler_utils.get_pull_request_lambda_arn", return_value="output_SlackBotLambdaArn"): forward_to_pull_request_lambda( - pull_request_id="123", body=mock_body, type="Event", event=None, event_id="", store_pull_request_id=False + pull_request_id="123", body=mock_body, type="action", event=None, event_id="", store_pull_request_id=False ) # assertions From b792047e401e7ae08a3062bd8e0dd7b12ca31e97 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Tue, 6 Jan 2026 12:28:06 +0000 Subject: [PATCH 25/44] feat: Process AI Query Directly --- .../app/services/sample_questions.py | 3 + .../app/slack/slack_events.py | 107 ++++++--- ...vents_actions.py => test_slack_actions.py} | 0 .../tests/test_slack_commands.py | 223 ++++++++++++++++++ .../test_slack_events_citations.py | 2 + .../test_slack_events_messages.py | 12 +- 6 files changed, 306 insertions(+), 41 deletions(-) rename packages/slackBotFunction/tests/{test_slack_events/test_slack_events_actions.py => test_slack_actions.py} (100%) create mode 100644 packages/slackBotFunction/tests/test_slack_commands.py diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index f929c277..9d757cfb 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -53,6 +53,9 @@ def get_questions(self, start: int, end: int) -> list[tuple[int, str]]: if end < 0 or end < start: raise ValueError("'end' must be non-negative and greater than or equal to 'start'") + + if end > len(self.questions) - 1: + raise ValueError(f"'end' must be less than {len(self.questions)}") # Extract only the text (index 1) from the tuple return list(self.questions[start : end + 1]) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index ade9dd53..d5dbc9f4 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -41,6 +41,8 @@ logger = get_logger() +processing_error_message = "Error processing message" + # ================================================================ # Privacy and Q&A management helpers @@ -147,16 +149,11 @@ def _handle_session_management( def _create_feedback_blocks( response_text: str, citations: list[dict[str, str]], - conversation_key: str, - channel: str, - message_ts: str, - thread_ts: str, + feedback_data: dict[str, str], ) -> list[dict[str, Any]]: """Create Slack blocks with feedback buttons""" - # Create compact feedback payload for button actions - feedback_data = {"ck": conversation_key, "ch": channel, "mt": message_ts} - if thread_ts: # Only include thread_ts for channel threads, not DMs - feedback_data["tt"] = thread_ts + if feedback_data.get("thread_ts"): # Only include thread_ts for channel threads, not DMs + feedback_data["tt"] = feedback_data["thread_ts"] feedback_value = json.dumps(feedback_data, separators=(",", ":")) # Main response block @@ -469,49 +466,33 @@ def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClien session_data = get_conversation_session_data(conversation_key) session_id = session_data.get("session_id") if session_data else None - ai_response = process_ai_query(user_query, session_id) - kb_response = ai_response["kb_response"] - response_text = ai_response["text"] - - # Split out citation block if present - # Citations are not returned in the object without using `$output_format_instructions$` which overrides the - # system prompt. Instead, pull out and format the citations in the prompt manually - prompt_value_keys = ["source_number", "title", "excerpt", "relevance_score"] - split = response_text.split("------") # Citations are separated from main body by ------ - - citations: list[dict[str, str]] = [] - if len(split) != 1: - response_text = split[0] - citation_block = split[1] - raw_citations = [] - raw_citations = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE).findall(citation_block) - if len(raw_citations) > 0: - logger.info("Found citation(s)", extra={"Raw Citations": raw_citations}) - citations = [dict(zip(prompt_value_keys, citation.split("||"))) for citation in raw_citations] - logger.info("Parsed citation(s)", extra={"citations": citations}) - # Post the answer (plain) to get message_ts - post_params = {"channel": channel, "text": response_text} + post_params = {"channel": channel, "text": "Processing..."} if thread_ts: # Only add thread_ts for channel threads, not DMs post_params["thread_ts"] = thread_ts post = client.chat_postMessage(**post_params) message_ts = post["ts"] + # Create compact feedback payload for button actions + feedback_data = {"channel": channel, "message_ts": message_ts, "thread_ts": thread_ts} + + # Call Bedrock to process the user query + kb_response, response_text, blocks = process_formatted_bedrock_query( + user_query=user_query, session_id=session_id, feedback_data={**feedback_data, "ck": conversation_key} + ) + _handle_session_management( - conversation_key=conversation_key, + **feedback_data, session_data=session_data, session_id=session_id, kb_response=kb_response, user_id=user_id, - channel=channel, - thread_ts=thread_ts, - message_ts=message_ts, + conversation_key=conversation_key, ) # Store Q&A pair for feedback correlation store_qa_pair(conversation_key, user_query, response_text, message_ts, kb_response.get("sessionId"), user_id) - blocks = _create_feedback_blocks(response_text, citations, conversation_key, channel, message_ts, thread_ts) try: client.chat_update(channel=channel, ts=message_ts, text=response_text, blocks=blocks) except Exception as e: @@ -539,7 +520,7 @@ def process_pull_request_slack_event(slack_event_data: Dict[str, Any]) -> None: process_async_slack_event(event=event, event_id=event_id, client=client) except Exception: # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error("Error processing message", extra={"event_id": event_id, "error": traceback.format_exc()}) + logger.error(processing_error_message, extra={"event_id": event_id, "error": traceback.format_exc()}) def process_pull_request_slack_command(slack_command_data: Dict[str, Any]) -> None: @@ -554,7 +535,7 @@ def process_pull_request_slack_command(slack_command_data: Dict[str, Any]) -> No process_async_slack_command(command=command, client=client) except Exception: # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error("Error processing message", extra={"error": traceback.format_exc()}) + logger.error(processing_error_message, extra={"error": traceback.format_exc()}) def process_pull_request_slack_action(slack_body_data: Dict[str, Any]) -> None: @@ -565,7 +546,7 @@ def process_pull_request_slack_action(slack_body_data: Dict[str, Any]) -> None: process_async_slack_action(body=slack_body_data, client=client) except Exception: # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error("Error processing message", extra={"error": traceback.format_exc()}) + logger.error(processing_error_message, extra={"error": traceback.format_exc()}) def log_query_stats(user_query: str, event: Dict[str, Any], channel: str, client: WebClient, thread_ts: str) -> None: @@ -680,6 +661,36 @@ def store_feedback( logger.error(f"Error storing feedback: {e}", extra={"error": traceback.format_exc()}) +def process_formatted_bedrock_query( + user_query: str, session_id: str | None, feedback_data: Dict[str, Any] +) -> Dict[str, Any]: + """Process the user query with Bedrock and return the response dict""" + ai_response = process_ai_query(user_query, session_id) + kb_response = ai_response["kb_response"] + response_text = ai_response["text"] + + # Split out citation block if present + # Citations are not returned in the object without using `$output_format_instructions$` which overrides the + # system prompt. Instead, pull out and format the citations in the prompt manually + prompt_value_keys = ["source_number", "title", "excerpt", "relevance_score"] + split = response_text.split("------") # Citations are separated from main body by ------ + + citations: list[dict[str, str]] = [] + if len(split) != 1: + response_text = split[0] + citation_block = split[1] + raw_citations = [] + raw_citations = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE).findall(citation_block) + if len(raw_citations) > 0: + logger.info("Found citation(s)", extra={"Raw Citations": raw_citations}) + citations = [dict(zip(prompt_value_keys, citation.split("||"))) for citation in raw_citations] + logger.info("Parsed citation(s)", extra={"citations": citations}) + + blocks = _create_feedback_blocks(response_text, citations, feedback_data) + + return kb_response, response_text, blocks + + def open_citation(channel: str, timestamp: str, message: Any, params: Dict[str, Any], client: WebClient) -> None: """Open citation - update/replace message to include citation content""" logger.info("Opening citation", extra={"channel": channel, "timestamp": timestamp}) @@ -799,7 +810,25 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Process as normal message slack_message = {**response, "text": f"{pr} {question[1]}"} logger.debug("Processing test question", extra={"slack_message": slack_message}) - process_slack_message(event=slack_message, event_id=f"test_{response['ts']}", client=client) + + message_ts = response.get("thread_ts") + channel = response.get("channel") + + feedback_data = { + "ck": None, + "ch": channel, + "mt": message_ts, + "thread_ts": message_ts, + } + + _, response_text, blocks = process_formatted_bedrock_query(question[1], None, feedback_data) + try: + client.chat_update(channel=channel, ts=message_ts, text=response_text, blocks=blocks) + except Exception as e: + logger.error( + f"Failed to attach feedback buttons: {e}", + extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, + ) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py b/packages/slackBotFunction/tests/test_slack_actions.py similarity index 100% rename from packages/slackBotFunction/tests/test_slack_events/test_slack_events_actions.py rename to packages/slackBotFunction/tests/test_slack_actions.py diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py new file mode 100644 index 00000000..f3d39ba6 --- /dev/null +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -0,0 +1,223 @@ +import sys +import pytest +from unittest.mock import Mock, patch, MagicMock + + +@pytest.fixture +def mock_logger(): + return MagicMock() + + +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") +def test_process_slack_command( + mock_forward_to_pull_request_lambda: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + "command": "/test", + } + with patch("app.slack.slack_events.process_command_test_response") as mock_process_command_test_response, patch( + "app.slack.slack_events.process_slack_message" + ) as mock_process_slack_message, patch( + "app.slack.slack_events.process_async_slack_event" + ) as mock_process_async_slack_event: + process_async_slack_command(command=slack_command_data, client=mock_client) + mock_forward_to_pull_request_lambda.assert_not_called() + mock_process_command_test_response.assert_called_once_with( + command={**slack_command_data}, + client=mock_client, + ) + mock_process_slack_message.assert_not_called() + mock_process_async_slack_event.assert_not_called() + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_questions_default( + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "", + "user": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_called() + assert mock_client.chat_postMessage.call_count == 21 + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_questions_single_question( + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "q2", + "user": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_called() + mock_client.chat_postMessage.assert_called_once() + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_questions_two_questions( + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "q2-3", + "user": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_called() + assert mock_client.chat_postMessage.call_count == 2 + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_questions_too_many_questions_error( + mock_process_ai_query: Mock, + mock_logger: Mock, +): + """Test successful command processing""" + # set up mocks + with patch("app.core.config.get_logger", return_value=mock_logger): + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "q0-100", + "user": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + + mock_process_ai_query.return_value = { + "text": "ai response", + "session_id": None, + "citations": [], + "kb_response": {}, + } + + # with pytest.raises(ValueError, match="'end' must be less than 21"): + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.asset_not_called() + + mock_logger.error.assert_called_once() + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_help( + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + if "app.slack.slack_events" in sys.modules: + del sys.modules["app.slack.slack_events"] + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "help", + "user": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_meMessage.assert_called_once() + mock_client.chat_postMessage.assert_not_called() diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py index 8b256e0f..f054b483 100644 --- a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_citations.py @@ -24,6 +24,7 @@ def test_citation_processing( """Test block builder is being called correctly""" # set up mocks mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": ""} mock_process_ai_query.return_value = { "text": "AI response", "session_id": "session-123", @@ -640,6 +641,7 @@ def test_create_citation_logs_citations( with patch("app.core.config.get_logger", return_value=mock_logger): # set up mocks mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": ""} raw_citation = "1||This is the Title||This is the excerpt/ citation||0.99" mock_process_ai_query.return_value = { "text": "AI response" + "------" + f"{raw_citation}", diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py index 5eb7fd8c..60d52c7d 100644 --- a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py @@ -182,7 +182,7 @@ def test_process_slack_message_with_thread_ts( assert mock_client.chat_postMessage.call_count >= 1 first_call = mock_client.chat_postMessage.call_args_list[0] assert first_call[1]["thread_ts"] == "1234567888.111" - assert first_call[1]["text"] == "AI response" + assert first_call[1]["text"] == "Processing..." @patch("app.services.dynamo.get_state_information") @@ -198,6 +198,7 @@ def test_regex_text_processing( """Test regex text processing functionality within process_async_slack_event""" # set up mocks mock_client = Mock() + mock_client.chat_postMessage.return_value = {"ts": ""} mock_process_ai_query.return_value = { "text": "AI response", "session_id": "session-123", @@ -240,6 +241,7 @@ def test_process_slack_message_with_session_storage( mock_client.chat_update.return_value = {"ok": True} mock_process_ai_query.return_value = { "text": "AI response", + "ck": "thread#123", "session_id": "new-session-123", "citations": [], "kb_response": {"output": {"text": "AI response"}, "sessionId": "new-session-123"}, @@ -251,7 +253,13 @@ def test_process_slack_message_with_session_storage( from app.slack.slack_events import process_slack_message # perform operation - slack_event_data = {"text": "test question", "user": "U456", "channel": "C789", "ts": "1234567890.123"} + slack_event_data = { + "text": "test question", + "user": "U456", + "channel": "C789", + "ts": "1234567890.123", + "event_ts": "123", + } process_slack_message(event=slack_event_data, event_id="evt123", client=mock_client) From 4cea3ca9ebd92aa712e912ecaf61f31e6844872e Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Tue, 6 Jan 2026 14:52:50 +0000 Subject: [PATCH 26/44] feat: Process AI Query Directly --- .../slackBotFunction/app/slack/slack_events.py | 4 ++-- .../slackBotFunction/tests/test_slack_commands.py | 14 +++++++++++--- .../test_slack_events_messages.py | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index d5dbc9f4..b36e848e 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -467,7 +467,7 @@ def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClien session_id = session_data.get("session_id") if session_data else None # Post the answer (plain) to get message_ts - post_params = {"channel": channel, "text": "Processing..."} + post_params = {"channel": channel, "text": ":spinner:"} if thread_ts: # Only add thread_ts for channel threads, not DMs post_params["thread_ts"] = thread_ts post = client.chat_postMessage(**post_params) @@ -808,7 +808,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> response = client.chat_postMessage(**post_params) # Process as normal message - slack_message = {**response, "text": f"{pr} {question[1]}"} + slack_message = {**response.data, "text": f"{pr} {question[1]}"} logger.debug("Processing test question", extra={"slack_message": slack_message}) message_ts = response.get("thread_ts") diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index f3d39ba6..bca74160 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -66,7 +66,10 @@ def test_process_slack_command_test_questions_default( "command": "/test", } - mock_client.chat_postMessage.return_value = {} + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} @@ -100,7 +103,10 @@ def test_process_slack_command_test_questions_single_question( "command": "/test", } - mock_client.chat_postMessage.return_value = {} + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} @@ -211,7 +217,9 @@ def test_process_slack_command_test_help( "command": "/test", } - mock_client.chat_postMessage.return_value = {} + mock_response = MagicMock() + mock_response.data = {} + mock_client.chat_postMessage.return_value = mock_response mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} diff --git a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py index 60d52c7d..56b673ea 100644 --- a/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py +++ b/packages/slackBotFunction/tests/test_slack_events/test_slack_events_messages.py @@ -182,7 +182,7 @@ def test_process_slack_message_with_thread_ts( assert mock_client.chat_postMessage.call_count >= 1 first_call = mock_client.chat_postMessage.call_args_list[0] assert first_call[1]["thread_ts"] == "1234567888.111" - assert first_call[1]["text"] == "Processing..." + assert first_call[1]["text"] == ":spinner:" @patch("app.services.dynamo.get_state_information") From 35e09b586658082c21ada38c65374f0fab0e8b91 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Tue, 6 Jan 2026 15:41:42 +0000 Subject: [PATCH 27/44] feat: Process AI Query Directly --- packages/slackBotFunction/app/slack/slack_events.py | 2 +- packages/slackBotFunction/app/slack/slack_handlers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index b36e848e..536ab674 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -811,7 +811,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> slack_message = {**response.data, "text": f"{pr} {question[1]}"} logger.debug("Processing test question", extra={"slack_message": slack_message}) - message_ts = response.get("thread_ts") + message_ts = response.get("ts") channel = response.get("channel") feedback_data = { diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 0dd89df5..e4251aa6 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -164,7 +164,7 @@ def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebCl ) forward_to_pull_request_lambda( body=body, - event=None, + event=command, pull_request_id=session_pull_request_id, event_id="", store_pull_request_id=False, From 28b186b2e6d0e0c4b286a0481f3845c36fb66b05 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Tue, 6 Jan 2026 16:04:18 +0000 Subject: [PATCH 28/44] feat: Respond to message instead of replacing message --- packages/slackBotFunction/app/slack/slack_events.py | 2 +- packages/slackBotFunction/app/slack/slack_handlers.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 536ab674..851f4999 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -823,7 +823,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> _, response_text, blocks = process_formatted_bedrock_query(question[1], None, feedback_data) try: - client.chat_update(channel=channel, ts=message_ts, text=response_text, blocks=blocks) + client.chat_postMessage(channel=channel, thread_ts=message_ts, text=response_text, blocks=blocks) except Exception as e: logger.error( f"Failed to attach feedback buttons: {e}", diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index e4251aa6..7222d41e 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -8,6 +8,7 @@ """ import json +import time from functools import lru_cache import traceback from typing import Any, Dict @@ -87,7 +88,7 @@ def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: forward_to_pull_request_lambda( body=body, event=None, - event_id="", + event_id=f"feedback-{time.time()}", store_pull_request_id=False, pull_request_id=session_pull_request_id, type="action", @@ -166,7 +167,7 @@ def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebCl body=body, event=command, pull_request_id=session_pull_request_id, - event_id="", + event_id=f"/command-{time.time()}", store_pull_request_id=False, type="command", ) From 807ee355b644f9a85519a43fec3c99898f60a7b2 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Tue, 6 Jan 2026 16:25:40 +0000 Subject: [PATCH 29/44] feat: Respond to message instead of replacing message --- packages/slackBotFunction/app/slack/slack_handlers.py | 2 +- packages/slackBotFunction/tests/test_slack_commands.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index 7222d41e..bf78111b 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -88,7 +88,7 @@ def feedback_handler(body: Dict[str, Any], client: WebClient) -> None: forward_to_pull_request_lambda( body=body, event=None, - event_id=f"feedback-{time.time()}", + event_id="", store_pull_request_id=False, pull_request_id=session_pull_request_id, type="action", diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index bca74160..cb79d736 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -78,7 +78,9 @@ def test_process_slack_command_test_questions_default( # assertions mock_client.chat_postMessage.assert_called() - assert mock_client.chat_postMessage.call_count == 21 + assert ( + mock_client.chat_postMessage.call_count == 42 + ) # 21 Tests - Posts once with question information, then replies with answer @patch("app.services.ai_processor.process_ai_query") @@ -115,7 +117,9 @@ def test_process_slack_command_test_questions_single_question( # assertions mock_client.chat_postMessage.assert_called() - mock_client.chat_postMessage.assert_called_once() + assert ( + mock_client.chat_postMessage.call_count == 2 + ) # 1 Test - Posts once with question information, then replies with answer @patch("app.services.ai_processor.process_ai_query") From 247441b439c65fecf9e1c3a57f5f3991c9ff66df Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 09:40:22 +0000 Subject: [PATCH 30/44] feat: Tidy up responses --- .../app/services/sample_questions.py | 64 +++++---- .../app/slack/slack_events.py | 132 ++++++++++-------- .../app/utils/handler_utils.py | 6 +- .../tests/test_slack_commands.py | 2 +- 4 files changed, 114 insertions(+), 90 deletions(-) diff --git a/packages/slackBotFunction/app/services/sample_questions.py b/packages/slackBotFunction/app/services/sample_questions.py index 9d757cfb..2c45ee3e 100644 --- a/packages/slackBotFunction/app/services/sample_questions.py +++ b/packages/slackBotFunction/app/services/sample_questions.py @@ -6,59 +6,67 @@ class SampleQuestionBank: def __init__(self): self.questions = [] # Append data as tuples: (id, text) just to make it easier to read for users. - self.questions.append((0, "can a prescribed item have more than one endorsement or is it expected that a single item has no more than one endorsement?")) # noqa: E501 - self.questions.append((1, "for the non-repudiation screen, Note - is this the note to pharmacy? note to patient?")) # noqa: E501 - self.questions.append((2, """We are getting an error from DSS "Unauthorised". We have checked the credentials and they appear to be correct. What could be the cause? Note that, the CIS2 key is different to the key used in the EPS application (which has the signing api attached to it)""")) # noqa: E501 - self.questions.append((3, """Can you clarify this requirement? + self.questions.append((1, "can a prescribed item have more than one endorsement or is it expected that a single item has no more than one endorsement?")) # noqa: E501 + self.questions.append((2, "for the non-repudiation screen, Note - is this the note to pharmacy? note to patient?")) # noqa: E501 + self.questions.append((3, """We are getting an error from DSS "Unauthorised". We have checked the credentials and they appear to be correct. What could be the cause? Note that, the CIS2 key is different to the key used in the EPS application (which has the signing api attached to it)""")) # noqa: E501 + self.questions.append((4, """Can you clarify this requirement? "The connecting system must monitor the sending of prescription order request and alert the NHSE service desk if requests fail to send as expected"a)Is this meant to be an automated process, or should an option be given to the user to send an e-mail? b)What will be the e-mail used? c)Any certain format needed for the report? d)Should this be done only after 2 hours of retries that failed?""")) # noqa: E501 - self.questions.append((4, "how do we find if a patient is a cross-border patient?")) # noqa: E501 - self.questions.append((5, "When a prescription has only one prescribed item and the prescriber cancels the prescribed item will the prescription be downloaded when a bulk download request is made to /FHIR/R4/Task/$release?")) # noqa: E501 - self.questions.append((6, "When I have authenticated with a smartcard using CIS2 over an HSCN connection what happens when the smartcard is removed?")) # noqa: E501 - self.questions.append((7, "direct me to the web page that has the details on how to uninstall Oberthur SR1")) # noqa: E501 - self.questions.append((8, """Should the Dispensing system send a Claim notification message when all the prescription items on the prescription have expired, and if it should then what value should be sent for the overall prescription form status for the FHIAR API extension "https://fhir.nhs.uk/StructureDefinition/Extension-EPS-TaskBusinessStatus"?""")) # noqa: E501 - self.questions.append((9, """For an EPS FHIR API Dispense Notification message, when should the value "on-hold" be used for resource: { "resourceType": "MedicationDispense", "status"} for a prescription item ?""")) # noqa: E501 - self.questions.append((10, "confirm if dispensing systems are required to include an option for the user to select the MedicationDispenseReasoncodes of 004, 005, and 009")) # noqa: E501 - self.questions.append((11, "how many lines can a prescription have?")) # noqa: E501 - self.questions.append((12, "Is extension:controlledDrugSchedule.value.userSelected. used for EPS?")) # noqa: E501 - self.questions.append((13, "Can we use dosageInstruction.timing.repeat.boundsPeriod.start or ...dosageInstruction.timing.repeat.boundsPeriod.end?")) # noqa: E501 - self.questions.append((14, "show me example of prescription successfully created response within the Electronic Prescribing System (EPS), where a unique identifier for the prescription, often referred to as the prescription ID is shown")) # noqa: E501 - self.questions.append((15, "please provide a documentation link where i find the specific format of prescribing token?")) # noqa: E501 - self.questions.append((16, """I have a query related to cancelling Repeat Dispensing (RD) courses. We would like to know what the Spine allows in terms of cancelling particular issues of a RD course.For example, let's say a patient has an ongoing RD course which originally had 6 issues. Let's say that issue 1 has been dispensed (status 'Dispensed'), and issue 2 has been pulled down from the Spine by the pharmacist (status 'With dispenser'). The remaining issues 3-6 are still on spine.Can the 'Cancel' request sent to Spine handle the following scenarios? And what will happen if so? + self.questions.append((5, "how do we find if a patient is a cross-border patient?")) # noqa: E501 + self.questions.append((6, "When a prescription has only one prescribed item and the prescriber cancels the prescribed item will the prescription be downloaded when a bulk download request is made to /FHIR/R4/Task/$release?")) # noqa: E501 + self.questions.append((7, "When I have authenticated with a smartcard using CIS2 over an HSCN connection what happens when the smartcard is removed?")) # noqa: E501 + self.questions.append((8, "direct me to the web page that has the details on how to uninstall Oberthur SR1")) # noqa: E501 + self.questions.append((9, """Should the Dispensing system send a Claim notification message when all the prescription items on the prescription have expired, and if it should then what value should be sent for the overall prescription form status for the FHIAR API extension "https://fhir.nhs.uk/StructureDefinition/Extension-EPS-TaskBusinessStatus"?""")) # noqa: E501 + self.questions.append((10, """For an EPS FHIR API Dispense Notification message, when should the value "on-hold" be used for resource: { "resourceType": "MedicationDispense", "status"} for a prescription item ?""")) # noqa: E501 + self.questions.append((11, "confirm if dispensing systems are required to include an option for the user to select the MedicationDispenseReasoncodes of 004, 005, and 009")) # noqa: E501 + self.questions.append((12, "how many lines can a prescription have?")) # noqa: E501 + self.questions.append((13, "Is extension:controlledDrugSchedule.value.userSelected. used for EPS?")) # noqa: E501 + self.questions.append((14, "Can we use dosageInstruction.timing.repeat.boundsPeriod.start or ...dosageInstruction.timing.repeat.boundsPeriod.end?")) # noqa: E501 + self.questions.append((15, "show me example of prescription successfully created response within the Electronic Prescribing System (EPS), where a unique identifier for the prescription, often referred to as the prescription ID is shown")) # noqa: E501 + self.questions.append((16, "please provide a documentation link where i find the specific format of prescribing token?")) # noqa: E501 + self.questions.append((17, """I have a query related to cancelling Repeat Dispensing (RD) courses. We would like to know what the Spine allows in terms of cancelling particular issues of a RD course.For example, let's say a patient has an ongoing RD course which originally had 6 issues. Let's say that issue 1 has been dispensed (status 'Dispensed'), and issue 2 has been pulled down from the Spine by the pharmacist (status 'With dispenser'). The remaining issues 3-6 are still on spine.Can the 'Cancel' request sent to Spine handle the following scenarios? And what will happen if so? Doctor requests to cancel all issues, 1-6 Doctor requests to cancel all remaining issues on Spine, 3-6""")) # noqa: E501 - self.questions.append((17, "Does a Cancel request for a medication that has been downloaded include the pharmacy details of the pharmacy that had downloaded the medication in the response")) # noqa: E501 - self.questions.append((18, """For MedicationRequest "entry. resource. groupIdentifier. extension. valueIdentifier" what is the Long-form Prescription ID?""")) # noqa: E501 - self.questions.append((19, "for the non-repudiation screen, do we use the patient friendly text for dosage information?")) # noqa: E501 - self.questions.append((20, "Can an API have multiple callback URLs")) # noqa: E501 + self.questions.append((18, "Does a Cancel request for a medication that has been downloaded include the pharmacy details of the pharmacy that had downloaded the medication in the response")) # noqa: E501 + self.questions.append((19, """For MedicationRequest "entry. resource. groupIdentifier. extension. valueIdentifier" what is the Long-form Prescription ID?""")) # noqa: E501 + self.questions.append((20, "for the non-repudiation screen, do we use the patient friendly text for dosage information?")) # noqa: E501 + self.questions.append((21, "Can an API have multiple callback URLs")) # noqa: E501 - def get_questions(self, start: int, end: int) -> list[tuple[int, str]]: + def get_questions(self, start, end) -> list[tuple[int, str]]: """ Pulls a selection of questions """ + default_info = "must be positive whole number" + # Must be integers if not isinstance(start, int): - raise TypeError(f"'start' must be an integer, got {type(start).__name__}") + raise TypeError(f"'start' {default_info}, got {type(start).__name__}") if not isinstance(end, int): - raise TypeError(f"'end' must be an integer, got {type(end).__name__}") + raise TypeError(f"'end' {default_info}, got {type(end).__name__}") # Must be in valid range if start < 0: - raise ValueError("'start' cannot be negative") + raise ValueError(f"'start' {default_info}") if end < 0 or end < start: - raise ValueError("'end' must be non-negative and greater than or equal to 'start'") + raise ValueError(f"'end' {default_info} greater than or equal to 'start'") - if end > len(self.questions) - 1: - raise ValueError(f"'end' must be less than {len(self.questions)}") + if end > len(self.questions): + raise ValueError(f"'end' {default_info} less than {len(self.questions) + 1}") # Extract only the text (index 1) from the tuple return list(self.questions[start : end + 1]) def add_questions(self, question_text: str): self.questions.append((len(self.questions), question_text)) + + def length(self) -> int: + """ + Gets number of questions + """ + return len(self.questions) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 851f4999..8ca33369 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -434,6 +434,57 @@ def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> N post_error_message(channel=command["channel_id"], thread_ts=None, client=client) +# ================================================================ +# Pull Request Re-routing +# ================================================================ + + +def process_pull_request_slack_event(slack_event_data: Dict[str, Any]) -> None: + # separate function to process pull requests so that we can ensure we store session information + try: + event_id = slack_event_data["event_id"] + event = slack_event_data["event"] + token = get_bot_token() + client = WebClient(token=token) + if is_duplicate_event(event_id=event_id): + return + process_async_slack_event(event=event, event_id=event_id, client=client) + except Exception: + # we cant post a reply to slack for this error as we may not have details about where to post it + logger.error(processing_error_message, extra={"event_id": event_id, "error": traceback.format_exc()}) + + +def process_pull_request_slack_command(slack_command_data: Dict[str, Any]) -> None: + # separate function to process pull requests so that we can ensure we store session information + logger.debug( + "Processing pull request slack command", extra={"slack_command_data": slack_command_data} + ) # Removed line after debugging + try: + command = slack_command_data["event"] + token = get_bot_token() + client = WebClient(token=token) + process_async_slack_command(command=command, client=client) + except Exception: + # we cant post a reply to slack for this error as we may not have details about where to post it + logger.error(processing_error_message, extra={"error": traceback.format_exc()}) + + +def process_pull_request_slack_action(slack_body_data: Dict[str, Any]) -> None: + # separate function to process pull requests so that we can ensure we store session information + try: + token = get_bot_token() + client = WebClient(token=token) + process_async_slack_action(body=slack_body_data, client=client) + except Exception: + # we cant post a reply to slack for this error as we may not have details about where to post it + logger.error(processing_error_message, extra={"error": traceback.format_exc()}) + + +# ================================================================ +# Slack Message management +# ================================================================ + + def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClient) -> None: """ Process Slack events asynchronously after initial acknowledgment @@ -508,47 +559,6 @@ def process_slack_message(event: Dict[str, Any], event_id: str, client: WebClien post_error_message(channel=channel, thread_ts=thread_ts, client=client) -def process_pull_request_slack_event(slack_event_data: Dict[str, Any]) -> None: - # separate function to process pull requests so that we can ensure we store session information - try: - event_id = slack_event_data["event_id"] - event = slack_event_data["event"] - token = get_bot_token() - client = WebClient(token=token) - if is_duplicate_event(event_id=event_id): - return - process_async_slack_event(event=event, event_id=event_id, client=client) - except Exception: - # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error(processing_error_message, extra={"event_id": event_id, "error": traceback.format_exc()}) - - -def process_pull_request_slack_command(slack_command_data: Dict[str, Any]) -> None: - # separate function to process pull requests so that we can ensure we store session information - logger.debug( - "Processing pull request slack command", extra={"slack_command_data": slack_command_data} - ) # Removed line after debugging - try: - command = slack_command_data["event"] - token = get_bot_token() - client = WebClient(token=token) - process_async_slack_command(command=command, client=client) - except Exception: - # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error(processing_error_message, extra={"error": traceback.format_exc()}) - - -def process_pull_request_slack_action(slack_body_data: Dict[str, Any]) -> None: - # separate function to process pull requests so that we can ensure we store session information - try: - token = get_bot_token() - client = WebClient(token=token) - process_async_slack_action(body=slack_body_data, client=client) - except Exception: - # we cant post a reply to slack for this error as we may not have details about where to post it - logger.error(processing_error_message, extra={"error": traceback.format_exc()}) - - def log_query_stats(user_query: str, event: Dict[str, Any], channel: str, client: WebClient, thread_ts: str) -> None: query_length = len(user_query) start_time = float(event["event_ts"]) @@ -569,7 +579,7 @@ def log_query_stats(user_query: str, event: Dict[str, Any], channel: str, client # ================================================================ -# Feedback management +# Slack Feedback management # ================================================================ @@ -661,6 +671,11 @@ def store_feedback( logger.error(f"Error storing feedback: {e}", extra={"error": traceback.format_exc()}) +# ================================================================ +# AI Response Formatting +# ================================================================ + + def process_formatted_bedrock_query( user_query: str, session_id: str | None, feedback_data: Dict[str, Any] ) -> Dict[str, Any]: @@ -770,8 +785,10 @@ def _toggle_button_style(element: dict) -> bool: # ================================================================ -# Command management +# Slack Command management # ================================================================ + + def process_command_test(command: Dict[str, Any], client: WebClient) -> None: if "help" in command.get("text"): process_command_test_help(command=command, client=client) @@ -785,7 +802,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> "channel": command["channel_id"], "text": "Initialising tests...\n", } - client.chat_meMessage(**post_params) + client.chat_postEphemeral(**post_params) # Extract parameters params = extract_test_command_params(command.get("text")) @@ -793,8 +810,8 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> pr = params.get("pr", "").strip() pr = f"pr: {pr}" if pr else "" - start = int(params.get("start", 0)) - end = int(params.get("end", 20)) + start = int(params.get("start", 1)) - 1 + end = int(params.get("end", 21)) - 1 logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) # Retrieve sample questions @@ -830,30 +847,33 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, ) + post_params["text"] = "Testing complete" + client.chat_postEphemeral(**post_params) + def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: - help_text = """ + length = SampleQuestionBank().length() + 1 + help_text = f""" Certainly! Here is some help testing me! You can use the `/test` command to send sample test questions to the bot. The command supports parameters to specify the range of questions or have me target a specific pull request. - Usage: - - /test [pr: ] [q-] + - /test [q-] - Parameters: - - : (optional) Specify a pull request ID to associate the questions with. - - : (optional) The starting and ending index of the sample questions (default is 0-20). - - : The ending index of the sample questions (default is 20). + - : (optional) The starting and ending index of the sample questions (default is 1-{length}). + - : The ending index of the sample questions (default is {length}). - Examples: - - /test --> Sends questions 0 to 20 + - /test --> Sends questions 1 to {length} - /test q15 --> Sends question 15 only - - /test q10-25 --> Sends questions 10 to 25 - - /test pr:12345 --> Sends questions 0 to 20 for pull request 12345 - - /test pr:12345 q5-15 --> Sends questions 5 to 15 for pull request 12345 + - /test q10-16 --> Sends questions 10 to 16 + + Note: To mention me in another channel, you can use "/test @eps-assist-me [q-]" """ - client.chat_meMessage(channel=command["channel_id"], text=help_text) + client.chat_postEphemeral(channel=command["channel_id"], text=help_text) # ================================================================ diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index ca756172..637f4e29 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -179,11 +179,7 @@ def extract_test_command_params(text: str) -> Dict[str, str]: Expected format: /test pr: 123 q1-2 """ - params = { - "pr": "", - "start": "0", - "end": "20", - } + params = {} prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex pr_pattern = rf"{prefix}\s*(\d+)\b" q_pattern = r"\bq-?(\d+)(?:-(\d+))?" diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index cb79d736..27a83e5c 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -231,5 +231,5 @@ def test_process_slack_command_test_help( process_async_slack_command(command=slack_command_data, client=mock_client) # assertions - mock_client.chat_meMessage.assert_called_once() mock_client.chat_postMessage.assert_not_called() + mock_client.chat_postEphemeral.assert_called() From cc0522c8bb7b4f9ce4965e9edc17400e0e892147 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 09:40:52 +0000 Subject: [PATCH 31/44] feat: concurrent bedrock requests --- .../app/slack/slack_events.py | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 8ca33369..140317e1 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -7,6 +7,7 @@ import time import traceback import json +import threading from typing import Any, Dict from botocore.exceptions import ClientError from slack_sdk import WebClient @@ -818,39 +819,51 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> test_questions = SampleQuestionBank().get_questions(start=start, end=end) logger.info("Retrieved test questions", extra={"count": len(test_questions)}) + threads = [] # Post each test question for question in test_questions: - # Update message to make it more user-friendly - post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" - response = client.chat_postMessage(**post_params) - - # Process as normal message - slack_message = {**response.data, "text": f"{pr} {question[1]}"} - logger.debug("Processing test question", extra={"slack_message": slack_message}) - - message_ts = response.get("ts") - channel = response.get("channel") - - feedback_data = { - "ck": None, - "ch": channel, - "mt": message_ts, - "thread_ts": message_ts, - } + thread = threading.Thread( + target=process_command_test_ai_request(question=question, pr=pr, post_params=post_params, client=client) + ) + threads.append(thread) + thread.start() - _, response_text, blocks = process_formatted_bedrock_query(question[1], None, feedback_data) - try: - client.chat_postMessage(channel=channel, thread_ts=message_ts, text=response_text, blocks=blocks) - except Exception as e: - logger.error( - f"Failed to attach feedback buttons: {e}", - extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, - ) + for thread in threads: + thread.join() post_params["text"] = "Testing complete" client.chat_postEphemeral(**post_params) +def process_command_test_ai_request(question, pr, post_params: Dict[str, str], client: WebClient): + # Update message to make it more user-friendly + post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" + response = client.chat_postMessage(**post_params) + + # Process as normal message + slack_message = {**response.data, "text": f"{pr} {question[1]}"} + logger.debug("Processing test question", extra={"slack_message": slack_message}) + + message_ts = response.get("ts") + channel = response.get("channel") + + feedback_data = { + "ck": None, + "ch": channel, + "mt": message_ts, + "thread_ts": message_ts, + } + + _, response_text, blocks = process_formatted_bedrock_query(question[1], None, feedback_data) + try: + client.chat_postMessage(channel=channel, thread_ts=message_ts, text=response_text, blocks=blocks) + except Exception as e: + logger.error( + f"Failed to attach feedback buttons: {e}", + extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, + ) + + def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: length = SampleQuestionBank().length() + 1 help_text = f""" From 05e40c2bf040cda5625f9ad4bcdafb1076efb179 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 09:58:16 +0000 Subject: [PATCH 32/44] feat: concurrent bedrock requests --- packages/slackBotFunction/app/slack/slack_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 140317e1..36c44a6b 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -803,7 +803,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> "channel": command["channel_id"], "text": "Initialising tests...\n", } - client.chat_postEphemeral(**post_params) + client.chat_postEphemeral({**post_params, "user_id": command.get("user_id")}) # Extract parameters params = extract_test_command_params(command.get("text")) @@ -832,7 +832,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> thread.join() post_params["text"] = "Testing complete" - client.chat_postEphemeral(**post_params) + client.chat_postEphemeral({**post_params, "user_id": command.get("user_id")}) def process_command_test_ai_request(question, pr, post_params: Dict[str, str], client: WebClient): From e4c55770b433c5ac8774ebbce7c73adbf04cfbdf Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 09:59:30 +0000 Subject: [PATCH 33/44] feat: concurrent bedrock requests --- packages/slackBotFunction/app/slack/slack_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 36c44a6b..1586a476 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -803,7 +803,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> "channel": command["channel_id"], "text": "Initialising tests...\n", } - client.chat_postEphemeral({**post_params, "user_id": command.get("user_id")}) + client.chat_postEphemeral({**post_params, "user": command.get("user_id")}) # Extract parameters params = extract_test_command_params(command.get("text")) @@ -832,7 +832,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> thread.join() post_params["text"] = "Testing complete" - client.chat_postEphemeral({**post_params, "user_id": command.get("user_id")}) + client.chat_postEphemeral({**post_params, "user": command.get("user_id")}) def process_command_test_ai_request(question, pr, post_params: Dict[str, str], client: WebClient): From ad613bc0102e104476fb8fa9564fe702863a8c5f Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 10:22:34 +0000 Subject: [PATCH 34/44] feat: concurrent bedrock requests --- packages/slackBotFunction/app/slack/slack_events.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 1586a476..f0c09b95 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -803,7 +803,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> "channel": command["channel_id"], "text": "Initialising tests...\n", } - client.chat_postEphemeral({**post_params, "user": command.get("user_id")}) + client.chat_postEphemeral(**post_params, user=command.get("user_id")) # Extract parameters params = extract_test_command_params(command.get("text")) @@ -832,7 +832,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> thread.join() post_params["text"] = "Testing complete" - client.chat_postEphemeral({**post_params, "user": command.get("user_id")}) + client.chat_postEphemeral(**post_params, user=command.get("user_id")) def process_command_test_ai_request(question, pr, post_params: Dict[str, str], client: WebClient): @@ -886,7 +886,7 @@ def process_command_test_help(command: Dict[str, Any], client: WebClient) -> Non Note: To mention me in another channel, you can use "/test @eps-assist-me [q-]" """ - client.chat_postEphemeral(channel=command["channel_id"], text=help_text) + client.chat_postEphemeral(channel=command["channel_id"], user=command["user_id"], text=help_text) # ================================================================ From 1b207a3185a66cc196f147530486de075047713e Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 10:41:21 +0000 Subject: [PATCH 35/44] feat: concurrent bedrock requests --- .../slackBotFunction/tests/test_slack_commands.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 27a83e5c..6feaed34 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -24,7 +24,7 @@ def test_process_slack_command( # perform operation slack_command_data = { "text": "", - "user": "U456", + "user_id": "U456", "channel": "C789", "ts": "1234567890.123", "command": "/test", @@ -60,7 +60,7 @@ def test_process_slack_command_test_questions_default( # perform operation slack_command_data = { "text": "", - "user": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", @@ -99,7 +99,7 @@ def test_process_slack_command_test_questions_single_question( # perform operation slack_command_data = { "text": "q2", - "user": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", @@ -138,7 +138,7 @@ def test_process_slack_command_test_questions_two_questions( # perform operation slack_command_data = { "text": "q2-3", - "user": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", @@ -174,7 +174,7 @@ def test_process_slack_command_test_questions_too_many_questions_error( # perform operation slack_command_data = { "text": "q0-100", - "user": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", @@ -215,7 +215,7 @@ def test_process_slack_command_test_help( # perform operation slack_command_data = { "text": "help", - "user": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", @@ -223,7 +223,7 @@ def test_process_slack_command_test_help( mock_response = MagicMock() mock_response.data = {} - mock_client.chat_postMessage.return_value = mock_response + mock_client.chat_postEphemeral.return_value = mock_response mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} From 59d22c8882304bccf006529d58d3d71bb228219e Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 11:24:56 +0000 Subject: [PATCH 36/44] feat: concurrent bedrock requests --- .../app/slack/slack_events.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index f0c09b95..8014a7d5 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -7,7 +7,7 @@ import time import traceback import json -import threading +from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict from botocore.exceptions import ClientError from slack_sdk import WebClient @@ -819,17 +819,15 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> test_questions = SampleQuestionBank().get_questions(start=start, end=end) logger.info("Retrieved test questions", extra={"count": len(test_questions)}) - threads = [] - # Post each test question - for question in test_questions: - thread = threading.Thread( - target=process_command_test_ai_request(question=question, pr=pr, post_params=post_params, client=client) - ) - threads.append(thread) - thread.start() - - for thread in threads: - thread.join() + with ThreadPoolExecutor(max_workers=25) as executor: + for question in test_questions: + executor.submit( + process_command_test_ai_request, + question=question, + pr=pr, + post_params=post_params, + client=client, + ) post_params["text"] = "Testing complete" client.chat_postEphemeral(**post_params, user=command.get("user_id")) From 36f8c898d74a1a645dd5912a4ac8987a3bd26220 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 11:51:19 +0000 Subject: [PATCH 37/44] feat: concurrent bedrock requests --- .../app/slack/slack_events.py | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 8014a7d5..0a2554d8 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -819,28 +819,32 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> test_questions = SampleQuestionBank().get_questions(start=start, end=end) logger.info("Retrieved test questions", extra={"count": len(test_questions)}) - with ThreadPoolExecutor(max_workers=25) as executor: + with ThreadPoolExecutor(max_workers=20) as executor: + futures = [] + for question in test_questions: - executor.submit( + # This happens sequentially, ensuring questions appear 1, 2, 3... + post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" + response = client.chat_postMessage(**post_params) + + # We submit the work to the pool. It starts immediately. + future = executor.submit( process_command_test_ai_request, question=question, pr=pr, - post_params=post_params, + response=response, # Pass the response object we just got client=client, ) + futures.append(future) post_params["text"] = "Testing complete" client.chat_postEphemeral(**post_params, user=command.get("user_id")) -def process_command_test_ai_request(question, pr, post_params: Dict[str, str], client: WebClient): - # Update message to make it more user-friendly - post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" - response = client.chat_postMessage(**post_params) - +def process_command_test_ai_request(question, pr, response, client: WebClient): # Process as normal message slack_message = {**response.data, "text": f"{pr} {question[1]}"} - logger.debug("Processing test question", extra={"slack_message": slack_message}) + logger.debug("Processing test question on new thread", extra={"slack_message": slack_message}) message_ts = response.get("ts") channel = response.get("channel") @@ -860,6 +864,7 @@ def process_command_test_ai_request(question, pr, post_params: Dict[str, str], c f"Failed to attach feedback buttons: {e}", extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, ) + logger.debug("question complete", extra={"response_text": response_text, "blocks": blocks}) def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: From 86a4a0ffc7160306e7f234be5620c8e4a770cc3a Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 15:08:47 +0000 Subject: [PATCH 38/44] feat: Add unit tests for command handler --- .../app/slack/slack_handlers.py | 5 +- .../tests/test_slack_commands.py | 102 ++++++++++++++---- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index bf78111b..a08004bc 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -159,10 +159,7 @@ def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebCl user_id = command.get("user_id") session_pull_request_id = extract_test_command_params(command.get("text")).get("pr") if session_pull_request_id: - logger.info( - f"Command in pull request session {session_pull_request_id} from user {user_id}", - extra={"session_pull_request_id": session_pull_request_id}, - ) + logger.info(f"Command in pull request session {session_pull_request_id} from user {user_id}") forward_to_pull_request_lambda( body=body, event=command, diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 6feaed34..42af9330 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -8,40 +8,96 @@ def mock_logger(): return MagicMock() -@patch("app.utils.handler_utils.forward_to_pull_request_lambda") -def test_process_slack_command( - mock_forward_to_pull_request_lambda: Mock, -): +@patch("app.slack.slack_events.process_async_slack_command") +def test_process_slack_command_handler_succeeds(mock_process_async_slack_command: Mock): """Test successful command processing""" # set up mocks mock_client = Mock() - # delete and import module to test - if "app.slack.slack_events" in sys.modules: - del sys.modules["app.slack.slack_events"] - from app.slack.slack_events import process_async_slack_command - - # perform operation slack_command_data = { "text": "", "user_id": "U456", - "channel": "C789", + "channel_id": "C789", "ts": "1234567890.123", "command": "/test", } - with patch("app.slack.slack_events.process_command_test_response") as mock_process_command_test_response, patch( - "app.slack.slack_events.process_slack_message" - ) as mock_process_slack_message, patch( - "app.slack.slack_events.process_async_slack_event" - ) as mock_process_async_slack_event: - process_async_slack_command(command=slack_command_data, client=mock_client) + + # delete and import module to test + if "app.slack.slack_handlers" in sys.modules: + del sys.modules["app.slack.slack_handlers"] + from app.slack.slack_handlers import command_handler + + # perform operation + command_handler(slack_command_data, slack_command_data, mock_client) + + # assertions + mock_process_async_slack_command.assert_called_once() + + +@patch("boto3.client") +@patch("app.utils.handler_utils.extract_test_command_params") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") +def test_process_slack_command_handler_forwards_pr( + mock_forward_to_pull_request_lambda: Mock, + mock_extract_test_command_params: Mock, + mock_boto_client: Mock, + mock_logger, +): + """Test redirect to PR lambda""" + with patch("app.core.config.get_logger", return_value=mock_logger): + # setup mock + mock_client = Mock() + + pr_number = "123" + user_id = "U456" + slack_command_data = { + "text": f"pr: {pr_number}", + "user_id": user_id, + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + # perform operation + # delete and import module to test + if "app.slack.slack_handlers" in sys.modules: + del sys.modules["app.slack.slack_handlers"] + from app.slack.slack_handlers import command_handler + + mock_extract_test_command_params.return_value = {"pr": pr_number} + + command_handler(slack_command_data, slack_command_data, mock_client) + + # assertions + mock_forward_to_pull_request_lambda.assert_called_once() + mock_extract_test_command_params.assert_called_once() + mock_logger.info.assert_called_with(f"Command in pull request session {pr_number} from user {user_id}") + + +@patch("app.slack.slack_events.process_async_slack_command") +@patch("app.utils.handler_utils.forward_to_pull_request_lambda") +def test_process_slack_command_handler_no_command( + mock_forward_to_pull_request_lambda: Mock, mock_process_async_slack_command: Mock, mock_logger +): + """Test redirect to PR lambda""" + with patch("app.core.config.get_logger", return_value=mock_logger): + # setup mock + mock_client = Mock() + + slack_command_data = None + + # perform operation + # delete and import module to test + if "app.slack.slack_handlers" in sys.modules: + del sys.modules["app.slack.slack_handlers"] + from app.slack.slack_handlers import command_handler + + command_handler(slack_command_data, slack_command_data, mock_client) + + # assertions mock_forward_to_pull_request_lambda.assert_not_called() - mock_process_command_test_response.assert_called_once_with( - command={**slack_command_data}, - client=mock_client, - ) - mock_process_slack_message.assert_not_called() - mock_process_async_slack_event.assert_not_called() + mock_process_async_slack_command.assert_not_called() + mock_logger.error.assert_called_once() @patch("app.services.ai_processor.process_ai_query") From ee2f57753e52ae69102c796a000c7868425fd31f Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 15:25:09 +0000 Subject: [PATCH 39/44] feat: Add unit tests for command handler --- .../slackBotFunction/tests/test_handlers.py | 59 +++++++++++++++++++ .../tests/test_slack_commands.py | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/slackBotFunction/tests/test_handlers.py b/packages/slackBotFunction/tests/test_handlers.py index 34c7ce4d..a8e7522e 100644 --- a/packages/slackBotFunction/tests/test_handlers.py +++ b/packages/slackBotFunction/tests/test_handlers.py @@ -151,6 +151,65 @@ def test_handler_pull_request_action_missing_slack_event( mock_process_pull_request_slack_action.assert_not_called() +@patch("app.services.app.get_app") +@patch("app.slack.slack_events.process_pull_request_slack_command") +def test_handler_pull_request_command_processing( + mock_process_pull_request_slack_command: Mock, + mock_get_app: Mock, + mock_get_parameter: Mock, + mock_slack_app: Mock, + mock_env: Mock, + lambda_context: Mock, +): + """Test Lambda handler function for pull request processing""" + # set up mocks + mock_get_app.return_value = mock_slack_app + + # delete and import module to test + if "app.handler" in sys.modules: + del sys.modules["app.handler"] + from app.handler import handler + + # perform operation + event = {"pull_request_command": True, "slack_event": {"body": "test command"}} + handler(event, lambda_context) + + # assertions + mock_process_pull_request_slack_command.assert_called_once_with(slack_command_data={"body": "test command"}) + + +@patch("app.services.app.get_app") +@patch("app.slack.slack_events.process_pull_request_slack_command") +def test_handler_pull_request_command_processing_missing_slack_command( + mock_process_pull_request_slack_command: Mock, + mock_get_app: Mock, + mock_slack_app: Mock, + mock_env: Mock, + mock_get_parameter: Mock, + lambda_context: Mock, +): + """Test Lambda handler function for async processing without slack_command data""" + # set up mocks + mock_get_app.return_value = mock_slack_app + + # delete and import module to test + if "app.handler" in sys.modules: + del sys.modules["app.handler"] + from app.handler import handler + + # perform operation + # Test async processing without slack_event - should return 400 + event = {"pull_request_command": True} # Missing slack_command + result = handler(event, lambda_context) + + # assertions + # Check that result is a dict with statusCode + assert isinstance(result, dict) + assert "statusCode" in result + assert result["statusCode"] == 400 + mock_process_pull_request_slack_command.assert_not_called() + + @patch("app.services.ai_processor.process_ai_query") def test_handler_direct_invocation( mock_process_ai_query: Mock, diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 42af9330..2a4e09a3 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -271,7 +271,7 @@ def test_process_slack_command_test_help( # perform operation slack_command_data = { "text": "help", - "user_id": "U456", + "user_id_": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", From 7a0a36b19b0b87a64d8a7c2319f7678791f44f3b Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 15:35:14 +0000 Subject: [PATCH 40/44] feat: Add unit tests for command handler --- packages/slackBotFunction/app/slack/slack_handlers.py | 2 +- packages/slackBotFunction/app/utils/handler_utils.py | 2 +- packages/slackBotFunction/tests/test_slack_commands.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_handlers.py b/packages/slackBotFunction/app/slack/slack_handlers.py index a08004bc..734ac2b6 100644 --- a/packages/slackBotFunction/app/slack/slack_handlers.py +++ b/packages/slackBotFunction/app/slack/slack_handlers.py @@ -162,7 +162,7 @@ def command_handler(body: Dict[str, Any], command: Dict[str, Any], client: WebCl logger.info(f"Command in pull request session {session_pull_request_id} from user {user_id}") forward_to_pull_request_lambda( body=body, - event=command, + event={**command, "channel": command.get("channel_id")}, pull_request_id=session_pull_request_id, event_id=f"/command-{time.time()}", store_pull_request_id=False, diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 637f4e29..9ad1204a 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -109,7 +109,7 @@ def get_forward_payload(body: Dict[str, Any], event: Dict[str, Any], event_id: s message_text = event["text"] _, extracted_message = extract_pull_request_id(message_text) event["text"] = extracted_message - return {"pull_request_event": True, "slack_event": {"event": event, "event_id": event_id}} + return {f"pull_request_{type}": True, "slack_event": {"event": event, "event_id": event_id}} def is_latest_message(conversation_key: str, message_ts: str) -> bool: diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 2a4e09a3..42af9330 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -271,7 +271,7 @@ def test_process_slack_command_test_help( # perform operation slack_command_data = { "text": "help", - "user_id_": "U456", + "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", "command": "/test", From 642282de84986456f234c2b95b957fa923f26a98 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 16:11:06 +0000 Subject: [PATCH 41/44] feat: Output tests to file --- .../app/slack/slack_events.py | 65 +++++++++++++++---- .../app/utils/handler_utils.py | 5 +- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 0a2554d8..33171f19 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -7,6 +7,7 @@ import time import traceback import json +from datetime import datetime from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict from botocore.exceptions import ClientError @@ -808,11 +809,16 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> # Extract parameters params = extract_test_command_params(command.get("text")) + # Is the command targeting a PR pr = params.get("pr", "").strip() pr = f"pr: {pr}" if pr else "" + # Has the user defined any questions start = int(params.get("start", 1)) - 1 end = int(params.get("end", 21)) - 1 + + # Should the answer be output to the channel + output = params.get("output", False) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) # Retrieve sample questions @@ -824,8 +830,9 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> for question in test_questions: # This happens sequentially, ensuring questions appear 1, 2, 3... - post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" - response = client.chat_postMessage(**post_params) + if output: + post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" + response = client.chat_postMessage(**post_params) # We submit the work to the pool. It starts immediately. future = executor.submit( @@ -833,15 +840,40 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> question=question, pr=pr, response=response, # Pass the response object we just got + output=output, client=client, ) futures.append(future) - post_params["text"] = "Testing complete" + post_params["text"] = "Testing complete, creating file..." client.chat_postEphemeral(**post_params, user=command.get("user_id")) + aggregated_results = [] + for i, future in enumerate(futures): + try: + result_text = future.result() + aggregated_results.append(f"--- Question {i + 1} ---\n") + aggregated_results.append(f"{test_questions[i][1]}\n\n") + aggregated_results.append(f"{result_text}\n\n") + except Exception as e: + aggregated_results.append(f"--- Question {i + 1} ---\nError processing request: {str(e)}\n\n") + + # 4. Create the file content + final_file_content = "\n".join(aggregated_results) + timestamp = datetime.now().strftime("%y_%m_%d_%H:%M") + filename = f"test_results_{timestamp}.txt" + + # 5. Upload the file to Slack + client.files_upload_v2( + channel=command["channel_id"], + content=final_file_content, + title="Test Results", + filename=filename, + initial_comment="Testing complete. Here are the results:", + ) + -def process_command_test_ai_request(question, pr, response, client: WebClient): +def process_command_test_ai_request(question, pr, response, output: bool, client: WebClient) -> str: # Process as normal message slack_message = {**response.data, "text": f"{pr} {question[1]}"} logger.debug("Processing test question on new thread", extra={"slack_message": slack_message}) @@ -857,15 +889,19 @@ def process_command_test_ai_request(question, pr, response, client: WebClient): } _, response_text, blocks = process_formatted_bedrock_query(question[1], None, feedback_data) - try: - client.chat_postMessage(channel=channel, thread_ts=message_ts, text=response_text, blocks=blocks) - except Exception as e: - logger.error( - f"Failed to attach feedback buttons: {e}", - extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, - ) logger.debug("question complete", extra={"response_text": response_text, "blocks": blocks}) + if output: + try: + client.chat_postMessage(channel=channel, thread_ts=message_ts, text=response_text, blocks=blocks) + except Exception as e: + logger.error( + f"Failed to attach feedback buttons: {e}", + extra={"event_id": None, "message_ts": message_ts, "error": traceback.format_exc()}, + ) + + return response_text + def process_command_test_help(command: Dict[str, Any], client: WebClient) -> None: length = SampleQuestionBank().length() + 1 @@ -876,18 +912,19 @@ def process_command_test_help(command: Dict[str, Any], client: WebClient) -> Non The command supports parameters to specify the range of questions or have me target a specific pull request. - Usage: - - /test [q-] + - /test [q-] [] - Parameters: - : (optional) The starting and ending index of the sample questions (default is 1-{length}). - - : The ending index of the sample questions (default is {length}). + - : (optional) The ending index of the sample questions (default is {length}). + - (optional) If provided, will post questions and answers (this won't effect if the file is returned) - Examples: - /test --> Sends questions 1 to {length} - /test q15 --> Sends question 15 only - /test q10-16 --> Sends questions 10 to 16 - Note: To mention me in another channel, you can use "/test @eps-assist-me [q-]" + Note: To mention me in another channel, you can use "/test @eps-assist-me [q-] []" """ client.chat_postEphemeral(channel=command["channel_id"], user=command["user_id"], text=help_text) diff --git a/packages/slackBotFunction/app/utils/handler_utils.py b/packages/slackBotFunction/app/utils/handler_utils.py index 9ad1204a..e7233623 100644 --- a/packages/slackBotFunction/app/utils/handler_utils.py +++ b/packages/slackBotFunction/app/utils/handler_utils.py @@ -177,7 +177,7 @@ def extract_test_command_params(text: str) -> Dict[str, str]: """ Extract parameters from the /test command text. - Expected format: /test pr: 123 q1-2 + Expected format: /test pr: 123 q1-2 output """ params = {} prefix = re.escape(constants.PULL_REQUEST_PREFIX) # safely escape for regex @@ -193,6 +193,9 @@ def extract_test_command_params(text: str) -> Dict[str, str]: params["start"] = q_match.group(1) params["end"] = q_match.group(2) if q_match.group(2) else q_match.group(1) + if "output" in text.lower(): + params["output"] = True + logger.debug("Extracted test command parameters", extra={"params": params}) return params From 3450247a97496b984bdde234e0c8cab0b3bd5c0c Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Wed, 7 Jan 2026 17:02:21 +0000 Subject: [PATCH 42/44] feat: Output tests to file --- .../app/slack/slack_events.py | 7 +- .../tests/test_slack_commands.py | 127 ++++++++++++++++-- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 33171f19..442b8562 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -830,6 +830,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> for question in test_questions: # This happens sequentially, ensuring questions appear 1, 2, 3... + response = None if output: post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" response = client.chat_postMessage(**post_params) @@ -838,7 +839,6 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> future = executor.submit( process_command_test_ai_request, question=question, - pr=pr, response=response, # Pass the response object we just got output=output, client=client, @@ -873,10 +873,9 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> ) -def process_command_test_ai_request(question, pr, response, output: bool, client: WebClient) -> str: +def process_command_test_ai_request(question, response, output: bool, client: WebClient) -> str: # Process as normal message - slack_message = {**response.data, "text": f"{pr} {question[1]}"} - logger.debug("Processing test question on new thread", extra={"slack_message": slack_message}) + logger.debug("Processing test question on new thread", extra={"question": question}) message_ts = response.get("ts") channel = response.get("channel") diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 42af9330..401f4fe5 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -101,7 +101,118 @@ def test_process_slack_command_handler_no_command( @patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.process_command_test_ai_request") def test_process_slack_command_test_questions_default( + mock_process_command_test_ai_request: Mock, + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # import module to test + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "", + "user_id": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_not_called() + mock_client.files_upload_v2.assert_called_once() + assert mock_process_command_test_ai_request.call_count == 21 + + +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.process_command_test_ai_request") +def test_process_slack_command_test_questions_single_question( + mock_process_command_test_ai_request: Mock, + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # delete and import module to test + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "q2", + "user_id": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_not_called() + mock_client.files_upload_v2.assert_called_once() + assert mock_process_command_test_ai_request.call_count == 1 + + +@patch("app.services.ai_processor.process_ai_query") +@patch("app.slack.slack_events.process_command_test_ai_request") +def test_process_slack_command_test_questions_two_questions( + mock_process_command_test_ai_request: Mock, + mock_process_ai_query: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "q2-3", + "user_id": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_client.chat_postMessage.return_value = {} + mock_process_command_test_ai_request.return_value = "test" + + mock_process_ai_query.return_value = {"text": "ai response", "session_id": None, "citations": [], "kb_response": {}} + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + mock_client.chat_postMessage.assert_not_called() + mock_client.files_upload_v2.assert_called_once() + assert mock_process_command_test_ai_request.call_count == 2 + + +@patch("app.services.ai_processor.process_ai_query") +def test_process_slack_command_test_questions_default_output( mock_process_ai_query: Mock, ): """Test successful command processing""" @@ -115,7 +226,7 @@ def test_process_slack_command_test_questions_default( # perform operation slack_command_data = { - "text": "", + "text": "output", "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", @@ -140,7 +251,7 @@ def test_process_slack_command_test_questions_default( @patch("app.services.ai_processor.process_ai_query") -def test_process_slack_command_test_questions_single_question( +def test_process_slack_command_test_questions_single_question_output( mock_process_ai_query: Mock, ): """Test successful command processing""" @@ -154,7 +265,7 @@ def test_process_slack_command_test_questions_single_question( # perform operation slack_command_data = { - "text": "q2", + "text": "q2 output", "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", @@ -173,13 +284,11 @@ def test_process_slack_command_test_questions_single_question( # assertions mock_client.chat_postMessage.assert_called() - assert ( - mock_client.chat_postMessage.call_count == 2 - ) # 1 Test - Posts once with question information, then replies with answer + assert mock_client.chat_postMessage.call_count == 2 # 1 questions + 1 answers @patch("app.services.ai_processor.process_ai_query") -def test_process_slack_command_test_questions_two_questions( +def test_process_slack_command_test_questions_two_questions_output( mock_process_ai_query: Mock, ): """Test successful command processing""" @@ -193,7 +302,7 @@ def test_process_slack_command_test_questions_two_questions( # perform operation slack_command_data = { - "text": "q2-3", + "text": "q2-3 output", "user_id": "U456", "channel_id": "C789", "ts": "1234567890.123", @@ -209,7 +318,7 @@ def test_process_slack_command_test_questions_two_questions( # assertions mock_client.chat_postMessage.assert_called() - assert mock_client.chat_postMessage.call_count == 2 + assert mock_client.chat_postMessage.call_count == 4 # 2 questions + 2 answers @patch("app.services.ai_processor.process_ai_query") From bbd66973fa99c4dd337ca4a3bbead764bb5f385d Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Thu, 8 Jan 2026 11:50:30 +0000 Subject: [PATCH 43/44] feat: Test files and slack outputs both hit ai query --- .../app/slack/slack_events.py | 14 ++-- .../tests/test_slack_commands.py | 79 +++++++++++++++++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index 442b8562..d1ba35fa 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -430,7 +430,7 @@ def process_async_slack_command(command: Dict[str, Any], client: WebClient) -> N try: command_arg = command.get("command", "").strip() if command_arg == "/test": - process_command_test(command=command, client=client) + process_command_test_request(command=command, client=client) except Exception as e: logger.error(f"Error processing test command: {e}", extra={"error": traceback.format_exc()}) post_error_message(channel=command["channel_id"], thread_ts=None, client=client) @@ -791,14 +791,14 @@ def _toggle_button_style(element: dict) -> bool: # ================================================================ -def process_command_test(command: Dict[str, Any], client: WebClient) -> None: +def process_command_test_request(command: Dict[str, Any], client: WebClient) -> None: if "help" in command.get("text"): process_command_test_help(command=command, client=client) else: - process_command_test_response(command=command, client=client) + process_command_test_questions(command=command, client=client) -def process_command_test_response(command: Dict[str, Any], client: WebClient) -> None: +def process_command_test_questions(command: Dict[str, Any], client: WebClient) -> None: # Initial acknowledgment post_params = { "channel": command["channel_id"], @@ -830,7 +830,7 @@ def process_command_test_response(command: Dict[str, Any], client: WebClient) -> for question in test_questions: # This happens sequentially, ensuring questions appear 1, 2, 3... - response = None + response = {} if output: post_params["text"] = f"Question {question[0]}:\n> {question[1].replace('\n', '\n> ')}\n" response = client.chat_postMessage(**post_params) @@ -916,7 +916,7 @@ def process_command_test_help(command: Dict[str, Any], client: WebClient) -> Non - Parameters: - : (optional) The starting and ending index of the sample questions (default is 1-{length}). - : (optional) The ending index of the sample questions (default is {length}). - - (optional) If provided, will post questions and answers (this won't effect if the file is returned) + - (optional) If provided, will post questions and answers to slack (this won't effect if the file is returned) - Examples: - /test --> Sends questions 1 to {length} @@ -924,7 +924,7 @@ def process_command_test_help(command: Dict[str, Any], client: WebClient) -> Non - /test q10-16 --> Sends questions 10 to 16 Note: To mention me in another channel, you can use "/test @eps-assist-me [q-] []" - """ + """ # noqa: E501 client.chat_postEphemeral(channel=command["channel_id"], user=command["user_id"], text=help_text) diff --git a/packages/slackBotFunction/tests/test_slack_commands.py b/packages/slackBotFunction/tests/test_slack_commands.py index 401f4fe5..c5599a95 100644 --- a/packages/slackBotFunction/tests/test_slack_commands.py +++ b/packages/slackBotFunction/tests/test_slack_commands.py @@ -100,9 +100,42 @@ def test_process_slack_command_handler_no_command( mock_logger.error.assert_called_once() +@pytest.fixture(scope="module") +@patch("app.services.bedrock.query_bedrock") +def test_process_slack_command_test_questions_ai_request_to_file( + mock_query_bedrock: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # import module to test + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "", + "user_id": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + assert mock_query_bedrock.call_count == 21 + + @patch("app.services.ai_processor.process_ai_query") @patch("app.slack.slack_events.process_command_test_ai_request") -def test_process_slack_command_test_questions_default( +def test_process_slack_command_test_questions_default_to_file( mock_process_command_test_ai_request: Mock, mock_process_ai_query: Mock, ): @@ -140,7 +173,7 @@ def test_process_slack_command_test_questions_default( @patch("app.services.ai_processor.process_ai_query") @patch("app.slack.slack_events.process_command_test_ai_request") -def test_process_slack_command_test_questions_single_question( +def test_process_slack_command_test_questions_single_question_to_file( mock_process_command_test_ai_request: Mock, mock_process_ai_query: Mock, ): @@ -148,6 +181,7 @@ def test_process_slack_command_test_questions_single_question( # set up mocks mock_client = Mock() + mock_client.retrieve_and_generate.return_value = {"output": {"text": "bedrock response"}} # delete and import module to test from app.slack.slack_events import process_async_slack_command @@ -178,7 +212,7 @@ def test_process_slack_command_test_questions_single_question( @patch("app.services.ai_processor.process_ai_query") @patch("app.slack.slack_events.process_command_test_ai_request") -def test_process_slack_command_test_questions_two_questions( +def test_process_slack_command_test_questions_two_questions_to_file( mock_process_command_test_ai_request: Mock, mock_process_ai_query: Mock, ): @@ -211,8 +245,41 @@ def test_process_slack_command_test_questions_two_questions( assert mock_process_command_test_ai_request.call_count == 2 +@pytest.fixture(scope="module") +@patch("app.services.bedrock.query_bedrock") +def test_process_slack_command_test_questions_ai_request_to_slack( + mock_query_bedrock: Mock, +): + """Test successful command processing""" + # set up mocks + mock_client = Mock() + + # import module to test + from app.slack.slack_events import process_async_slack_command + + # perform operation + slack_command_data = { + "text": "output", + "user_id": "U456", + "channel_id": "C789", + "ts": "1234567890.123", + "command": "/test", + } + + mock_response = MagicMock() + mock_response.data = {} + mock_response.get.side_effect = lambda k: {"thread_ts": "1234567890.123456", "channel": "C12345678"}.get(k) + mock_client.chat_postMessage.return_value = mock_response + + # perform operation + process_async_slack_command(command=slack_command_data, client=mock_client) + + # assertions + assert mock_query_bedrock.call_count == 21 + + @patch("app.services.ai_processor.process_ai_query") -def test_process_slack_command_test_questions_default_output( +def test_process_slack_command_test_questions_default_to_slack( mock_process_ai_query: Mock, ): """Test successful command processing""" @@ -251,7 +318,7 @@ def test_process_slack_command_test_questions_default_output( @patch("app.services.ai_processor.process_ai_query") -def test_process_slack_command_test_questions_single_question_output( +def test_process_slack_command_test_questions_single_question_to_slack( mock_process_ai_query: Mock, ): """Test successful command processing""" @@ -288,7 +355,7 @@ def test_process_slack_command_test_questions_single_question_output( @patch("app.services.ai_processor.process_ai_query") -def test_process_slack_command_test_questions_two_questions_output( +def test_process_slack_command_test_questions_two_questions_to_slack( mock_process_ai_query: Mock, ): """Test successful command processing""" From 0971825624d6115624454d783df60bec85d962d5 Mon Sep 17 00:00:00 2001 From: Kieran Wilkinson Date: Thu, 8 Jan 2026 13:44:18 +0000 Subject: [PATCH 44/44] feat: More useful initial message --- .../app/slack/slack_events.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/slackBotFunction/app/slack/slack_events.py b/packages/slackBotFunction/app/slack/slack_events.py index d1ba35fa..6e37015c 100644 --- a/packages/slackBotFunction/app/slack/slack_events.py +++ b/packages/slackBotFunction/app/slack/slack_events.py @@ -799,27 +799,34 @@ def process_command_test_request(command: Dict[str, Any], client: WebClient) -> def process_command_test_questions(command: Dict[str, Any], client: WebClient) -> None: - # Initial acknowledgment - post_params = { - "channel": command["channel_id"], - "text": "Initialising tests...\n", - } - client.chat_postEphemeral(**post_params, user=command.get("user_id")) + # Prepare response + acknowledgement_msg = "Initialise tests\n" # Extract parameters params = extract_test_command_params(command.get("text")) # Is the command targeting a PR pr = params.get("pr", "").strip() - pr = f"pr: {pr}" if pr else "" + if pr: + pr = f"pr: {pr}" + acknowledgement_msg += f"for {pr}\n" # Has the user defined any questions start = int(params.get("start", 1)) - 1 end = int(params.get("end", 21)) - 1 + acknowledgement_msg += f"Asking question {start}{f"to {end}" if end != start else ""}" # Should the answer be output to the channel output = params.get("output", False) logger.info("Test command parameters", extra={"pr": pr, "start": start, "end": end}) + acknowledgement_msg += "and printing results to channel" if output else "" + + # Initial acknowledgment + post_params = { + "channel": command["channel_id"], + "text": acknowledgement_msg, + } + client.chat_postEphemeral(**post_params, user=command.get("user_id")) # Retrieve sample questions test_questions = SampleQuestionBank().get_questions(start=start, end=end)