1- import requests # type: ignore
21import json
3- from fastapi import APIRouter , Request , HTTPException
4- from models . webhooks import AwsSnsPayload , WebhookPayload
5- from core . logging import get_module_logger
2+ from typing import Union , Dict , Any , cast
3+
4+ import requests # type: ignore
65from api .dependencies .rate_limits import get_limiter
6+ from core .logging import get_module_logger
7+ from fastapi import APIRouter , HTTPException , Request , Body
78from integrations .sentinel import log_to_sentinel
9+ from models .webhooks import (
10+ AwsSnsPayload ,
11+ WebhookPayload ,
12+ AccessRequest ,
13+ UpptimePayload ,
14+ WebhookResult ,
15+ )
816from modules .slack import webhooks
17+ from modules .webhooks .base import validate_payload
918from server .event_handlers import aws
10- from server .utils import (
11- log_ops_message ,
12- )
19+ from server .utils import log_ops_message
1320
1421
1522logger = get_module_logger ()
1623router = APIRouter (tags = ["Access" ])
1724limiter = get_limiter ()
1825
1926
20- @router .post ("/hook/{id }" )
27+ @router .post ("/hook/{webhook_id }" )
2128@limiter .limit (
2229 "30/minute"
2330) # since some slack channels use this for alerting, we want to be generous with the rate limiting on this one
2431def handle_webhook (
25- id : str ,
26- payload : WebhookPayload | str ,
32+ webhook_id : str ,
2733 request : Request ,
34+ payload : Union [Dict [Any , Any ], str ] = Body (...),
2835):
29- webhook = webhooks .get_webhook (id )
30- webhook_payload = WebhookPayload ()
31- if webhook :
32- hook_type : str = webhook .get ("hook_type" , {"S" : "alert" })["S" ]
33- # if the webhook is active, then send forward the response to the webhook
34- if webhooks .is_active (id ):
35- webhooks .increment_invocation_count (id )
36- if isinstance (payload , str ):
37- processed_payload = handle_string_payload (payload , request )
38- if isinstance (processed_payload , dict ):
39- return processed_payload
40- else :
41- logger .info (
42- "payload_processed" ,
43- payload = processed_payload ,
44- webhook_id = id ,
45- )
46- webhook_payload = processed_payload
47- else :
48- webhook_payload = payload
49- webhook_payload .channel = webhook ["channel" ]["S" ]
50- if hook_type == "alert" :
51- webhook_payload = append_incident_buttons (webhook_payload , id )
52- try :
53- webhook_payload_parsed = webhook_payload .model_dump (exclude_none = True )
54- request .state .bot .client .api_call (
55- "chat.postMessage" , json = webhook_payload_parsed
56- )
57- log_to_sentinel (
58- "webhook_sent" ,
59- {"webhook" : webhook , "payload" : webhook_payload_parsed },
60- )
61- return {"ok" : True }
62- except Exception as e :
63- logger .exception (
64- "webhook_posting_error" ,
65- webhook_id = id ,
66- webhook_payload = webhook_payload ,
67- error = str (e ),
68- )
69- body = webhook_payload .model_dump (exclude_none = True )
70- log_ops_message (
71- request .state .bot .client , f"Error posting message: ```{ body } ```"
72- )
73- raise HTTPException (
74- status_code = 500 , detail = "Failed to send message"
75- ) from e
76- else :
77- logger .info (
78- "webhook_not_active" ,
79- webhook_id = id ,
80- webhook_payload = webhook_payload ,
81- error = "Webhook is not active" ,
82- )
83- raise HTTPException (status_code = 404 , detail = "Webhook not active" )
36+ """Handle incoming webhook requests and post to Slack channel.
37+
38+ Args:
39+ webhook_id (str): The ID of the webhook to handle.
40+ request (Request): The incoming HTTP request.
41+ payload (Union[Dict[Any, Any], str]): The incoming webhook payload, either as
42+ a JSON string or a dictionary.
43+
44+ Raises:
45+ HTTPException: If the webhook is not found, not active, or if there are issues
46+ with payload validation or posting to Slack.
47+ Returns:
48+ dict: A dictionary indicating success if the message was posted successfully.
49+ """
50+ if isinstance (payload , dict ):
51+ payload_dict = payload
8452 else :
53+ try :
54+ payload_dict = json .loads (payload )
55+ except json .JSONDecodeError as e :
56+ logger .error ("payload_validation_error" , error = str (e ), payload = str (payload ))
57+ raise HTTPException (status_code = 400 , detail = str (e )) from e
58+
59+ webhook = webhooks .get_webhook (webhook_id )
60+ if not webhook :
8561 raise HTTPException (status_code = 404 , detail = "Webhook not found" )
8662
63+ if not webhook .get ("active" , {}).get ("BOOL" , False ):
64+ logger .info (
65+ "webhook_not_active" ,
66+ webhook_id = webhook_id ,
67+ error = "Webhook is not active" ,
68+ )
69+ raise HTTPException (status_code = 404 , detail = "Webhook not active" )
70+ webhooks .increment_invocation_count (webhook_id )
8771
88- def handle_string_payload (
89- payload : str ,
72+ webhook_result = handle_webhook_payload (payload_dict , request )
73+
74+ if webhook_result .status == "error" :
75+ raise HTTPException (status_code = 400 , detail = "Invalid payload" )
76+
77+ if webhook_result .action == "post" and isinstance (
78+ webhook_result .payload , WebhookPayload
79+ ):
80+ webhook_payload = webhook_result .payload
81+ webhook_payload .channel = webhook ["channel" ]["S" ]
82+ hook_type = webhook ["hook_type" ]["S" ]
83+ if hook_type == "alert" :
84+ webhook_payload = append_incident_buttons (webhook_payload , webhook_id )
85+
86+ webhook_payload_parsed = webhook_payload .model_dump (exclude_none = True )
87+
88+ try :
89+ request .state .bot .client .api_call (
90+ "chat.postMessage" , json = webhook_payload_parsed
91+ )
92+ log_to_sentinel (
93+ "webhook_sent" ,
94+ {"webhook" : webhook , "payload" : webhook_payload_parsed },
95+ )
96+
97+ except Exception as e :
98+ logger .exception (
99+ "webhook_posting_error" ,
100+ webhook_id = webhook_id ,
101+ error = str (e ),
102+ )
103+ raise HTTPException (status_code = 500 , detail = "Failed to send message" ) from e
104+
105+ return {"ok" : True }
106+
107+
108+ def handle_webhook_payload (
109+ payload_dict : dict ,
90110 request : Request ,
91- ) -> WebhookPayload | dict :
111+ ) -> WebhookResult :
112+ """Process and validate the webhook payload.
92113
93- string_payload_type , validated_payload = webhooks .validate_string_payload_type (
94- payload
95- )
96- logger .info (
97- "string_payload_type" ,
98- payload = payload ,
99- string_payload_type = string_payload_type ,
100- validated_payload = validated_payload ,
114+ Returns:
115+ dict: A dictionary containing:
116+ - status (str): The status of the operation (e.g., "success", "error").
117+ - action (Literal["post", "log", "none"]): The action to take.
118+ - payload (Optional[WebhookPayload]): The payload to post, if applicable.
119+ """
120+ logger .info ("processing_webhook_payload" , payload = payload_dict )
121+ payload_validation_result = validate_payload (payload_dict )
122+
123+ webhook_result = WebhookResult (
124+ status = "error" , message = "Failed to process payload for unknown reasons"
101125 )
102- match string_payload_type :
126+ if payload_validation_result is not None :
127+ payload_type , validated_payload = payload_validation_result
128+ else :
129+ error_message = "No matching model found for payload"
130+ return WebhookResult (status = "error" , message = error_message )
131+
132+ match payload_type .__name__ :
103133 case "WebhookPayload" :
104- webhook_payload = WebhookPayload (** validated_payload )
134+ webhook_result = WebhookResult (
135+ status = "success" , action = "post" , payload = validated_payload
136+ )
105137 case "AwsSnsPayload" :
106- awsSnsPayload = aws .validate_sns_payload (
107- AwsSnsPayload (** validated_payload ),
138+ aws_sns_payload_instance = cast (AwsSnsPayload , validated_payload )
139+ aws_sns_payload = aws .validate_sns_payload (
140+ aws_sns_payload_instance ,
108141 request .state .bot .client ,
109142 )
110- if awsSnsPayload .Type == "SubscriptionConfirmation" :
111- requests .get (awsSnsPayload .SubscribeURL , timeout = 60 )
143+
144+ if aws_sns_payload .Type == "SubscriptionConfirmation" :
145+ requests .get (aws_sns_payload .SubscribeURL , timeout = 60 )
112146 logger .info (
113147 "subscribed_webhook_to_topic" ,
114- webhook_id = awsSnsPayload .TopicArn ,
115- subscribed_topic = awsSnsPayload .TopicArn ,
148+ webhook_id = aws_sns_payload .TopicArn ,
149+ subscribed_topic = aws_sns_payload .TopicArn ,
116150 )
117151 log_ops_message (
118152 request .state .bot .client ,
119- f"Subscribed webhook { id } to topic { awsSnsPayload .TopicArn } " ,
153+ f"Subscribed webhook { id } to topic { aws_sns_payload .TopicArn } " ,
154+ )
155+ webhook_result = WebhookResult (
156+ status = "success" , action = "log" , payload = None
120157 )
121- return { "ok" : True }
122- if awsSnsPayload .Type == "UnsubscribeConfirmation" :
158+
159+ if aws_sns_payload .Type == "UnsubscribeConfirmation" :
123160 log_ops_message (
124161 request .state .bot .client ,
125- f"{ awsSnsPayload .TopicArn } unsubscribed from webhook { id } " ,
162+ f"{ aws_sns_payload .TopicArn } unsubscribed from webhook { id } " ,
163+ )
164+ webhook_result = WebhookResult (
165+ status = "success" , action = "log" , payload = None
126166 )
127- return {"ok" : True }
128- if awsSnsPayload .Type == "Notification" :
129- blocks = aws .parse (awsSnsPayload , request .state .bot .client )
130- # if we have an empty message, log that we have an empty
131- # message and return without posting to slack
167+
168+ if aws_sns_payload .Type == "Notification" :
169+ blocks = aws .parse (aws_sns_payload , request .state .bot .client )
132170 if not blocks :
133171 logger .info (
134172 "payload_empty_message" ,
173+ payload_type = "AwsSnsPayload" ,
174+ sns_type = aws_sns_payload .Type ,
135175 )
136- return {"ok" : True }
137- webhook_payload = WebhookPayload (blocks = blocks )
176+ return WebhookResult (
177+ status = "error" ,
178+ action = "none" ,
179+ message = "Empty AWS SNS Notification message" ,
180+ )
181+ webhook_result = WebhookResult (
182+ status = "success" ,
183+ action = "post" ,
184+ payload = WebhookPayload (blocks = blocks ),
185+ )
186+
138187 case "AccessRequest" :
139- # Temporary fix for the Access Request payloads
140- message = json .dumps (validated_payload )
141- webhook_payload = WebhookPayload (text = message )
188+ message = str (cast (AccessRequest , validated_payload ).model_dump ())
189+ webhook_result = WebhookResult (
190+ status = "success" ,
191+ action = "post" ,
192+ payload = WebhookPayload (text = message ),
193+ )
194+
142195 case "UpptimePayload" :
143- # Temporary fix for Upptime payloads
144- text = validated_payload .get ("text" , "" )
196+ text = cast (UpptimePayload , validated_payload ).text
145197 header_text = "📈 Web Application Status Changed!"
146198 blocks = [
147199 {"type" : "section" , "text" : {"type" : "mrkdwn" , "text" : " " }},
@@ -157,16 +209,22 @@ def handle_string_payload(
157209 },
158210 },
159211 ]
160- webhook_payload = WebhookPayload (blocks = blocks )
212+ webhook_result = WebhookResult (
213+ status = "success" ,
214+ action = "post" ,
215+ payload = WebhookPayload (blocks = blocks ),
216+ )
217+
161218 case _:
162- raise HTTPException (
163- status_code = 500 ,
164- detail = "Invalid payload type. Must be a WebhookPayload object or a recognized string payload type. " ,
219+ webhook_result = WebhookResult (
220+ status = "error" ,
221+ message = "No matching model found for payload" ,
165222 )
166- return WebhookPayload (** webhook_payload .model_dump (exclude_none = True ))
223+
224+ return webhook_result
167225
168226
169- def append_incident_buttons (payload : WebhookPayload , webhook_id ):
227+ def append_incident_buttons (payload : WebhookPayload , webhook_id ) -> WebhookPayload :
170228 if payload .attachments is None :
171229 payload .attachments = []
172230 elif isinstance (payload .attachments , str ):
0 commit comments