Skip to content
16 changes: 16 additions & 0 deletions packages/cdk/nagSuppressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions packages/cdk/resources/Apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cdk/stacks/EpsAssistMeStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions packages/slackBotFunction/app/services/sample_questions.py
Original file line number Diff line number Diff line change
@@ -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))
33 changes: 30 additions & 3 deletions packages/slackBotFunction/app/slack/slack_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
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,
forward_event_to_pull_request_lambda,
extract_test_command_params,
forward_to_pull_request_lambda,
is_duplicate_event,
is_latest_message,
strip_mentions,
Expand Down Expand Up @@ -397,8 +399,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()})
Expand All @@ -418,6 +425,26 @@ 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})

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:
"""
Process Slack events asynchronously after initial acknowledgment
Expand Down
70 changes: 60 additions & 10 deletions packages/slackBotFunction/app/slack/slack_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@
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,
)
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,
extract_test_command_params,
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()

Expand All @@ -43,6 +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])


# ================================================================
Expand All @@ -54,16 +55,23 @@ 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()


# 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:
"""Handle feedback button clicks (both positive and negative)."""
try:
Expand All @@ -77,7 +85,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:
Expand Down Expand Up @@ -117,13 +132,48 @@ 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

# note - we dont do post an error message if this fails as its handled by process_async_slack_event
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 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_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},
)
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()})
Loading