You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

488 lines
15 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. from django.conf import settings
  2. from django.template.loader import render_to_string
  3. from django.utils import timezone
  4. import json
  5. import requests
  6. from urllib.parse import quote, urlencode
  7. from hc.accounts.models import Profile
  8. from hc.lib import emails
  9. try:
  10. import apprise
  11. except ImportError:
  12. # Enforce
  13. settings.APPRISE_ENABLED = False
  14. def tmpl(template_name, **ctx):
  15. template_path = "integrations/%s" % template_name
  16. # \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
  17. # and cost twice the money.
  18. return render_to_string(template_path, ctx).strip().replace("\xa0", " ")
  19. class Transport(object):
  20. def __init__(self, channel):
  21. self.channel = channel
  22. def notify(self, check):
  23. """ Send notification about current status of the check.
  24. This method returns None on success, and error message
  25. on error.
  26. """
  27. raise NotImplementedError()
  28. def is_noop(self, check):
  29. """ Return True if transport will ignore check's current status.
  30. This method is overriden in Webhook subclass where the user can
  31. configure webhook urls for "up" and "down" events, and both are
  32. optional.
  33. """
  34. return False
  35. def checks(self):
  36. return self.channel.project.check_set.order_by("created")
  37. class Email(Transport):
  38. def notify(self, check, bounce_url):
  39. if not self.channel.email_verified:
  40. return "Email not verified"
  41. unsub_link = self.channel.get_unsub_link()
  42. headers = {"X-Bounce-Url": bounce_url, "List-Unsubscribe": unsub_link}
  43. try:
  44. # Look up the sorting preference for this email address
  45. p = Profile.objects.get(user__email=self.channel.email_value)
  46. sort = p.sort
  47. except Profile.DoesNotExist:
  48. # Default sort order is by check's creation time
  49. sort = "created"
  50. # list() executes the query, to avoid DB access while
  51. # rendering a template
  52. ctx = {
  53. "check": check,
  54. "checks": list(self.checks()),
  55. "sort": sort,
  56. "now": timezone.now(),
  57. "unsub_link": unsub_link,
  58. }
  59. emails.alert(self.channel.email_value, ctx, headers)
  60. def is_noop(self, check):
  61. if not self.channel.email_verified:
  62. return True
  63. if check.status == "down":
  64. return not self.channel.email_notify_down
  65. else:
  66. return not self.channel.email_notify_up
  67. class HttpTransport(Transport):
  68. @classmethod
  69. def _request(cls, method, url, **kwargs):
  70. try:
  71. options = dict(kwargs)
  72. options["timeout"] = 5
  73. if "headers" not in options:
  74. options["headers"] = {}
  75. if "User-Agent" not in options["headers"]:
  76. options["headers"]["User-Agent"] = "healthchecks.io"
  77. r = requests.request(method, url, **options)
  78. if r.status_code not in (200, 201, 202, 204):
  79. return "Received status code %d" % r.status_code
  80. except requests.exceptions.Timeout:
  81. # Well, we tried
  82. return "Connection timed out"
  83. except requests.exceptions.ConnectionError:
  84. return "Connection failed"
  85. @classmethod
  86. def get(cls, url, **kwargs):
  87. # Make 3 attempts--
  88. for x in range(0, 3):
  89. error = cls._request("get", url, **kwargs)
  90. if error is None:
  91. break
  92. return error
  93. @classmethod
  94. def post(cls, url, **kwargs):
  95. # Make 3 attempts--
  96. for x in range(0, 3):
  97. error = cls._request("post", url, **kwargs)
  98. if error is None:
  99. break
  100. return error
  101. @classmethod
  102. def put(cls, url, **kwargs):
  103. # Make 3 attempts--
  104. for x in range(0, 3):
  105. error = cls._request("put", url, **kwargs)
  106. if error is None:
  107. break
  108. return error
  109. class Webhook(HttpTransport):
  110. def prepare(self, template, check, urlencode=False):
  111. """ Replace variables with actual values.
  112. There should be no bad translations if users use $ symbol in
  113. check's name or tags, because $ gets urlencoded to %24
  114. """
  115. def safe(s):
  116. return quote(s) if urlencode else s
  117. result = template
  118. if "$CODE" in result:
  119. result = result.replace("$CODE", str(check.code))
  120. if "$STATUS" in result:
  121. result = result.replace("$STATUS", check.status)
  122. if "$NOW" in result:
  123. s = timezone.now().replace(microsecond=0).isoformat()
  124. result = result.replace("$NOW", safe(s))
  125. if "$NAME" in result:
  126. result = result.replace("$NAME", safe(check.name))
  127. if "$TAGS" in result:
  128. result = result.replace("$TAGS", safe(check.tags))
  129. if "$TAG" in result:
  130. for i, tag in enumerate(check.tags_list()):
  131. placeholder = "$TAG%d" % (i + 1)
  132. result = result.replace(placeholder, safe(tag))
  133. return result
  134. def is_noop(self, check):
  135. if check.status == "down" and not self.channel.url_down:
  136. return True
  137. if check.status == "up" and not self.channel.url_up:
  138. return True
  139. return False
  140. def notify(self, check):
  141. spec = self.channel.webhook_spec(check.status)
  142. assert spec["url"]
  143. url = self.prepare(spec["url"], check, urlencode=True)
  144. headers = {}
  145. for key, value in spec["headers"].items():
  146. headers[key] = self.prepare(value, check)
  147. body = spec["body"]
  148. if body:
  149. body = self.prepare(body, check)
  150. if spec["method"] == "GET":
  151. return self.get(url, headers=headers)
  152. elif spec["method"] == "POST":
  153. return self.post(url, data=body.encode(), headers=headers)
  154. elif spec["method"] == "PUT":
  155. return self.put(url, data=body.encode(), headers=headers)
  156. class Slack(HttpTransport):
  157. def notify(self, check):
  158. text = tmpl("slack_message.json", check=check)
  159. payload = json.loads(text)
  160. return self.post(self.channel.slack_webhook_url, json=payload)
  161. class HipChat(HttpTransport):
  162. def is_noop(self, check):
  163. return True
  164. class OpsGenie(HttpTransport):
  165. def notify(self, check):
  166. headers = {
  167. "Conent-Type": "application/json",
  168. "Authorization": "GenieKey %s" % self.channel.value,
  169. }
  170. payload = {"alias": str(check.code), "source": settings.SITE_NAME}
  171. if check.status == "down":
  172. payload["tags"] = check.tags_list()
  173. payload["message"] = tmpl("opsgenie_message.html", check=check)
  174. payload["note"] = tmpl("opsgenie_note.html", check=check)
  175. payload["description"] = tmpl("opsgenie_description.html", check=check)
  176. url = "https://api.opsgenie.com/v2/alerts"
  177. if check.status == "up":
  178. url += "/%s/close?identifierType=alias" % check.code
  179. return self.post(url, json=payload, headers=headers)
  180. class PagerDuty(HttpTransport):
  181. URL = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
  182. def notify(self, check):
  183. description = tmpl("pd_description.html", check=check)
  184. payload = {
  185. "vendor": settings.PD_VENDOR_KEY,
  186. "service_key": self.channel.pd_service_key,
  187. "incident_key": str(check.code),
  188. "event_type": "trigger" if check.status == "down" else "resolve",
  189. "description": description,
  190. "client": settings.SITE_NAME,
  191. "client_url": settings.SITE_ROOT,
  192. }
  193. return self.post(self.URL, json=payload)
  194. class PagerTree(HttpTransport):
  195. def notify(self, check):
  196. url = self.channel.value
  197. headers = {"Conent-Type": "application/json"}
  198. payload = {
  199. "incident_key": str(check.code),
  200. "event_type": "trigger" if check.status == "down" else "resolve",
  201. "title": tmpl("pagertree_title.html", check=check),
  202. "description": tmpl("pagertree_description.html", check=check),
  203. "client": settings.SITE_NAME,
  204. "client_url": settings.SITE_ROOT,
  205. "tags": ",".join(check.tags_list()),
  206. }
  207. return self.post(url, json=payload, headers=headers)
  208. class PagerTeam(HttpTransport):
  209. def notify(self, check):
  210. url = self.channel.value
  211. headers = {"Content-Type": "application/json"}
  212. payload = {
  213. "incident_key": str(check.code),
  214. "event_type": "trigger" if check.status == "down" else "resolve",
  215. "title": tmpl("pagerteam_title.html", check=check),
  216. "description": tmpl("pagerteam_description.html", check=check),
  217. "client": settings.SITE_NAME,
  218. "client_url": settings.SITE_ROOT,
  219. "tags": ",".join(check.tags_list()),
  220. }
  221. return self.post(url, json=payload, headers=headers)
  222. class Pushbullet(HttpTransport):
  223. def notify(self, check):
  224. text = tmpl("pushbullet_message.html", check=check)
  225. url = "https://api.pushbullet.com/v2/pushes"
  226. headers = {
  227. "Access-Token": self.channel.value,
  228. "Conent-Type": "application/json",
  229. }
  230. payload = {"type": "note", "title": settings.SITE_NAME, "body": text}
  231. return self.post(url, json=payload, headers=headers)
  232. class Pushover(HttpTransport):
  233. URL = "https://api.pushover.net/1/messages.json"
  234. def notify(self, check):
  235. others = self.checks().filter(status="down").exclude(code=check.code)
  236. # list() executes the query, to avoid DB access while
  237. # rendering a template
  238. ctx = {"check": check, "down_checks": list(others)}
  239. text = tmpl("pushover_message.html", **ctx)
  240. title = tmpl("pushover_title.html", **ctx)
  241. pieces = self.channel.value.split("|")
  242. user_key, prio = pieces[0], pieces[1]
  243. # The third element, if present, is the priority for "up" events
  244. if len(pieces) == 3 and check.status == "up":
  245. prio = pieces[2]
  246. payload = {
  247. "token": settings.PUSHOVER_API_TOKEN,
  248. "user": user_key,
  249. "message": text,
  250. "title": title,
  251. "html": 1,
  252. "priority": int(prio),
  253. }
  254. # Emergency notification
  255. if prio == "2":
  256. payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
  257. payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
  258. return self.post(self.URL, data=payload)
  259. class VictorOps(HttpTransport):
  260. def notify(self, check):
  261. description = tmpl("victorops_description.html", check=check)
  262. mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
  263. payload = {
  264. "entity_id": str(check.code),
  265. "message_type": mtype,
  266. "entity_display_name": check.name_then_code(),
  267. "state_message": description,
  268. "monitoring_tool": settings.SITE_NAME,
  269. }
  270. return self.post(self.channel.value, json=payload)
  271. class Matrix(HttpTransport):
  272. def get_url(self):
  273. s = quote(self.channel.value)
  274. url = settings.MATRIX_HOMESERVER
  275. url += "/_matrix/client/r0/rooms/%s/send/m.room.message?" % s
  276. url += urlencode({"access_token": settings.MATRIX_ACCESS_TOKEN})
  277. return url
  278. def notify(self, check):
  279. plain = tmpl("matrix_description.html", check=check)
  280. formatted = tmpl("matrix_description_formatted.html", check=check)
  281. payload = {
  282. "msgtype": "m.text",
  283. "body": plain,
  284. "format": "org.matrix.custom.html",
  285. "formatted_body": formatted,
  286. }
  287. return self.post(self.get_url(), json=payload)
  288. class Discord(HttpTransport):
  289. def notify(self, check):
  290. text = tmpl("slack_message.json", check=check)
  291. payload = json.loads(text)
  292. url = self.channel.discord_webhook_url + "/slack"
  293. return self.post(url, json=payload)
  294. class Telegram(HttpTransport):
  295. SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
  296. @classmethod
  297. def send(cls, chat_id, text):
  298. return cls.post(
  299. cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
  300. )
  301. def notify(self, check):
  302. text = tmpl("telegram_message.html", check=check)
  303. return self.send(self.channel.telegram_id, text)
  304. class Sms(HttpTransport):
  305. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  306. def is_noop(self, check):
  307. return check.status != "down"
  308. def notify(self, check):
  309. profile = Profile.objects.for_user(self.channel.project.owner)
  310. if not profile.authorize_sms():
  311. return "Monthly SMS limit exceeded"
  312. url = self.URL % settings.TWILIO_ACCOUNT
  313. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  314. text = tmpl("sms_message.html", check=check, site_name=settings.SITE_NAME)
  315. data = {
  316. "From": settings.TWILIO_FROM,
  317. "To": self.channel.sms_number,
  318. "Body": text,
  319. }
  320. return self.post(url, data=data, auth=auth)
  321. class WhatsApp(HttpTransport):
  322. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  323. def is_noop(self, check):
  324. if check.status == "down":
  325. return not self.channel.whatsapp_notify_down
  326. else:
  327. return not self.channel.whatsapp_notify_up
  328. def notify(self, check):
  329. profile = Profile.objects.for_user(self.channel.project.owner)
  330. if not profile.authorize_sms():
  331. return "Monthly message limit exceeded"
  332. url = self.URL % settings.TWILIO_ACCOUNT
  333. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  334. text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME)
  335. data = {
  336. "From": "whatsapp:%s" % settings.TWILIO_FROM,
  337. "To": "whatsapp:%s" % self.channel.sms_number,
  338. "Body": text,
  339. }
  340. return self.post(url, data=data, auth=auth)
  341. class Trello(HttpTransport):
  342. URL = "https://api.trello.com/1/cards"
  343. def is_noop(self, check):
  344. return check.status != "down"
  345. def notify(self, check):
  346. params = {
  347. "idList": self.channel.trello_list_id,
  348. "name": tmpl("trello_name.html", check=check),
  349. "desc": tmpl("trello_desc.html", check=check),
  350. "key": settings.TRELLO_APP_KEY,
  351. "token": self.channel.trello_token,
  352. }
  353. return self.post(self.URL, params=params)
  354. class Apprise(HttpTransport):
  355. def notify(self, check):
  356. if not settings.APPRISE_ENABLED:
  357. # Not supported and/or enabled
  358. return "Apprise is disabled and/or not installed."
  359. a = apprise.Apprise()
  360. title = tmpl("apprise_title.html", check=check)
  361. body = tmpl("apprise_description.html", check=check)
  362. a.add(self.channel.value)
  363. notify_type = apprise.NotifyType.SUCCESS \
  364. if check.status == "up" else apprise.NotifyType.FAILURE
  365. return "Failed" if not \
  366. a.notify(body=body, title=title, notify_type=notify_type) else None