webhooks.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. from dataclasses import asdict, dataclass, field
  2. from typing import TYPE_CHECKING, Optional
  3. import requests
  4. from django.conf import settings
  5. from django.db.models import F
  6. from .constants import RecipientType
  7. if TYPE_CHECKING:
  8. from .models import Notification
  9. @dataclass
  10. class WebhookAttachmentField:
  11. title: str
  12. value: str
  13. short: bool
  14. @dataclass
  15. class WebhookAttachment:
  16. title: str
  17. title_link: str
  18. text: str
  19. image_url: Optional[str] = None
  20. color: Optional[str] = None
  21. fields: Optional[list[WebhookAttachmentField]] = None
  22. mrkdown_in: Optional[list[str]] = None
  23. @dataclass
  24. class MSTeamsSection:
  25. """
  26. Similar to WebhookAttachment but for MS Teams
  27. https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL
  28. """
  29. activityTitle: str
  30. activitySubtitle: str
  31. @dataclass
  32. class WebhookPayload:
  33. alias: str
  34. text: str
  35. attachments: list[WebhookAttachment]
  36. sections: list[MSTeamsSection]
  37. def send_webhook(
  38. url: str,
  39. message: str,
  40. attachments: Optional[list[WebhookAttachment]] = None,
  41. sections: Optional[list[MSTeamsSection]] = None,
  42. ):
  43. if not attachments:
  44. attachments = []
  45. if not sections:
  46. sections = []
  47. data = WebhookPayload(
  48. alias="GlitchTip", text=message, attachments=attachments, sections=sections
  49. )
  50. return requests.post(
  51. url, json=asdict(data), headers={"Content-type": "application/json"}, timeout=10
  52. )
  53. def send_issue_as_webhook(url, issues: list, issue_count: int = 1):
  54. """
  55. Notification about issues via webhook.
  56. url: Webhook URL
  57. issues: This should be only the issues to send as attachment
  58. issue_count - total issues, may be greater than len(issues)
  59. """
  60. attachments: list[WebhookAttachment] = []
  61. sections: list[MSTeamsSection] = []
  62. for issue in issues:
  63. fields = [
  64. WebhookAttachmentField(
  65. title="Project",
  66. value=issue.project.name,
  67. short=True,
  68. )
  69. ]
  70. environment = (
  71. issue.issuetag_set.filter(tag_key__key="environment")
  72. .values(value=F("tag_value__value"))
  73. .first()
  74. )
  75. if environment:
  76. fields.append(
  77. WebhookAttachmentField(
  78. title="Environment",
  79. value=environment['value'],
  80. short=True,
  81. )
  82. )
  83. release = (
  84. issue.issuetag_set.filter(tag_key__key="release")
  85. .values(value=F("tag_value__value"))
  86. .first()
  87. )
  88. if release:
  89. fields.append(
  90. WebhookAttachmentField(
  91. title="Release",
  92. value=release['value'],
  93. short=False,
  94. )
  95. )
  96. attachments.append(
  97. WebhookAttachment(
  98. mrkdown_in=["text"],
  99. title=str(issue),
  100. title_link=issue.get_detail_url(),
  101. text=issue.culprit,
  102. color=issue.get_hex_color(),
  103. fields=fields,
  104. )
  105. )
  106. sections.append(
  107. MSTeamsSection(
  108. activityTitle=str(issue),
  109. activitySubtitle=f"[View Issue {issue.short_id_display}]({issue.get_detail_url()})",
  110. )
  111. )
  112. message = "GlitchTip Alert"
  113. if issue_count > 1:
  114. message += f" ({issue_count} issues)"
  115. return send_webhook(url, message, attachments, sections)
  116. @dataclass
  117. class DiscordField:
  118. name: str
  119. value: str
  120. inline: bool = False
  121. @dataclass
  122. class DiscordEmbed:
  123. title: str
  124. description: str
  125. color: int
  126. url: str
  127. fields: list[DiscordField]
  128. @dataclass
  129. class DiscordWebhookPayload:
  130. content: str
  131. embeds: list[DiscordEmbed]
  132. def send_issue_as_discord_webhook(url, issues: list, issue_count: int = 1):
  133. embeds: list[DiscordEmbed] = []
  134. for issue in issues:
  135. fields = [
  136. DiscordField(
  137. name="Project",
  138. value=issue.project.name,
  139. inline=True,
  140. )
  141. ]
  142. environment = (
  143. issue.issuetag_set.filter(tag_key__key="environment")
  144. .values(value=F("tag_value__value"))
  145. .first()
  146. )
  147. if environment:
  148. fields.append(
  149. DiscordField(
  150. name="Environment",
  151. value=environment['value'],
  152. inline=True,
  153. )
  154. )
  155. release = (
  156. issue.issuetag_set.filter(tag_key__key="release")
  157. .values(value=F("tag_value__value"))
  158. .first()
  159. )
  160. if release:
  161. fields.append(
  162. DiscordField(
  163. name="Release",
  164. value=release['value'],
  165. inline=False,
  166. )
  167. )
  168. embeds.append(
  169. DiscordEmbed(
  170. title=str(issue),
  171. description=issue.culprit,
  172. color=int(issue.get_hex_color()[1:], 16)
  173. if issue.get_hex_color() is not None
  174. else None,
  175. url=issue.get_detail_url(),
  176. fields=fields,
  177. )
  178. )
  179. message = "GlitchTip Alert"
  180. if issue_count > 1:
  181. message += f" ({issue_count} issues)"
  182. return send_discord_webhook(url, message, embeds)
  183. def send_discord_webhook(url: str, message: str, embeds: list[DiscordEmbed]):
  184. payload = DiscordWebhookPayload(content=message, embeds=embeds)
  185. return requests.post(url, json=asdict(payload), timeout=10)
  186. @dataclass
  187. class GoogleChatCard:
  188. header: Optional[dict] = None
  189. sections: Optional[list[dict]] = None
  190. def construct_uptime_card(self, title: str, subtitle: str, text: str, url: str):
  191. self.header = dict(
  192. title=title,
  193. subtitle=subtitle,
  194. )
  195. self.sections = [
  196. dict(
  197. widgets=[
  198. dict(
  199. decoratedText=dict(
  200. text=text,
  201. button=dict(
  202. text="View", onClick=dict(openLink=dict(url=url))
  203. ),
  204. )
  205. )
  206. ]
  207. )
  208. ]
  209. return self
  210. def construct_issue_card(self, title: str, issue):
  211. self.header = dict(title=title, subtitle=issue.project.name)
  212. section_header = "<font color='{}'>{}</font>".format(
  213. issue.get_hex_color(), str(issue)
  214. )
  215. widgets = []
  216. widgets.append(dict(decoratedText=dict(topLabel="Culprit", text=issue.culprit)))
  217. environment = (
  218. issue.issuetag_set.filter(tag_key__key="environment")
  219. .values(value=F("tag_value__value"))
  220. .first()
  221. )
  222. if environment:
  223. widgets.append(
  224. dict(decoratedText=dict(topLabel="Environment", text=environment['value']))
  225. )
  226. release = (
  227. issue.issuetag_set.filter(tag_key__key="release")
  228. .values(value=F("tag_value__value"))
  229. .first()
  230. )
  231. if release:
  232. widgets.append(
  233. dict(decoratedText=dict(topLabel="Release", text=release['value']))
  234. )
  235. widgets.append(
  236. dict(
  237. buttonList=dict(
  238. buttons=[
  239. dict(
  240. text="View Issue {}".format(issue.short_id_display),
  241. onClick=dict(openLink=dict(url=issue.get_detail_url())),
  242. )
  243. ]
  244. )
  245. )
  246. )
  247. self.sections = [dict(header=section_header, widgets=widgets)]
  248. return self
  249. @dataclass
  250. class GoogleChatWebhookPayload:
  251. cardsV2: list[dict[str, GoogleChatCard]] = field(default_factory=list)
  252. def add_card(self, card):
  253. return self.cardsV2.append(dict(cardId="createCardMessage", card=card))
  254. def send_googlechat_webhook(url: str, cards: list[GoogleChatCard]):
  255. """
  256. Send Google Chat compatible message as documented in
  257. https://developers.google.com/chat/messages-overview
  258. """
  259. payload = GoogleChatWebhookPayload()
  260. [payload.add_card(card) for card in cards]
  261. return requests.post(url, json=asdict(payload), timeout=10)
  262. def send_issue_as_googlechat_webhook(url, issues: list):
  263. cards = []
  264. for issue in issues:
  265. card = GoogleChatCard().construct_issue_card(
  266. title="GlitchTip Alert", issue=issue
  267. )
  268. cards.append(card)
  269. return send_googlechat_webhook(url, cards)
  270. def send_webhook_notification(
  271. notification: "Notification", url: str, recipient_type: str
  272. ):
  273. issue_count = notification.issues.count()
  274. issues = notification.issues.all()[: settings.MAX_ISSUES_PER_ALERT]
  275. if recipient_type == RecipientType.DISCORD:
  276. send_issue_as_discord_webhook(url, issues, issue_count)
  277. elif recipient_type == RecipientType.GOOGLE_CHAT:
  278. send_issue_as_googlechat_webhook(url, issues)
  279. else:
  280. send_issue_as_webhook(url, issues, issue_count)