forked from GithubBackups/healthchecks
The initial implementation was just calling signal-cli directly using `subprocess.run`. Going with DBus makes it easier to shield signal-cli from the rest of the system. It also makes sure the signal-cli daemon is running in the background and receiving messages. This is important when a recipient does the "Reset secure connection" from the app. We must receive their new keys, otherwise our future messages will appear as "bad encrypted message" for them.
700 lines
22 KiB
Python
700 lines
22 KiB
Python
import os
|
|
|
|
from django.conf import settings
|
|
from django.template.loader import render_to_string
|
|
from django.utils import timezone
|
|
from django.utils.html import escape
|
|
import json
|
|
import requests
|
|
from urllib.parse import quote, urlencode
|
|
|
|
from hc.accounts.models import Profile
|
|
from hc.lib import emails
|
|
from hc.lib.string import replace
|
|
|
|
try:
|
|
import apprise
|
|
except ImportError:
|
|
# Enforce
|
|
settings.APPRISE_ENABLED = False
|
|
|
|
try:
|
|
import dbus
|
|
except ImportError:
|
|
# Enforce
|
|
settings.SIGNAL_CLI_ENABLED = False
|
|
|
|
|
|
def tmpl(template_name, **ctx):
|
|
template_path = "integrations/%s" % template_name
|
|
# \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
|
|
# and cost twice the money.
|
|
return render_to_string(template_path, ctx).strip().replace("\xa0", " ")
|
|
|
|
|
|
class Transport(object):
|
|
def __init__(self, channel):
|
|
self.channel = channel
|
|
|
|
def notify(self, check):
|
|
""" Send notification about current status of the check.
|
|
|
|
This method returns None on success, and error message
|
|
on error.
|
|
|
|
"""
|
|
|
|
raise NotImplementedError()
|
|
|
|
def is_noop(self, check):
|
|
""" Return True if transport will ignore check's current status.
|
|
|
|
This method is overridden in Webhook subclass where the user can
|
|
configure webhook urls for "up" and "down" events, and both are
|
|
optional.
|
|
|
|
"""
|
|
|
|
return False
|
|
|
|
def checks(self):
|
|
return self.channel.project.check_set.order_by("created")
|
|
|
|
|
|
class Email(Transport):
|
|
def notify(self, check):
|
|
if not self.channel.email_verified:
|
|
return "Email not verified"
|
|
|
|
unsub_link = self.channel.get_unsub_link()
|
|
|
|
headers = {
|
|
"X-Status-Url": check.status_url,
|
|
"List-Unsubscribe": "<%s>" % unsub_link,
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
}
|
|
|
|
from hc.accounts.models import Profile
|
|
|
|
# If this email address has an associated account, include
|
|
# a summary of projects the account has access to
|
|
try:
|
|
profile = Profile.objects.get(user__email=self.channel.email_value)
|
|
projects = list(profile.projects())
|
|
except Profile.DoesNotExist:
|
|
projects = None
|
|
|
|
ctx = {
|
|
"check": check,
|
|
"ping": check.ping_set.order_by("created").last(),
|
|
"projects": projects,
|
|
"unsub_link": unsub_link,
|
|
}
|
|
|
|
emails.alert(self.channel.email_value, ctx, headers)
|
|
|
|
def is_noop(self, check):
|
|
if check.status == "down":
|
|
return not self.channel.email_notify_down
|
|
else:
|
|
return not self.channel.email_notify_up
|
|
|
|
|
|
class Shell(Transport):
|
|
def prepare(self, template, check):
|
|
""" Replace placeholders with actual values. """
|
|
|
|
ctx = {
|
|
"$CODE": str(check.code),
|
|
"$STATUS": check.status,
|
|
"$NOW": timezone.now().replace(microsecond=0).isoformat(),
|
|
"$NAME": check.name,
|
|
"$TAGS": check.tags,
|
|
}
|
|
|
|
for i, tag in enumerate(check.tags_list()):
|
|
ctx["$TAG%d" % (i + 1)] = tag
|
|
|
|
return replace(template, ctx)
|
|
|
|
def is_noop(self, check):
|
|
if check.status == "down" and not self.channel.cmd_down:
|
|
return True
|
|
|
|
if check.status == "up" and not self.channel.cmd_up:
|
|
return True
|
|
|
|
return False
|
|
|
|
def notify(self, check):
|
|
if not settings.SHELL_ENABLED:
|
|
return "Shell commands are not enabled"
|
|
|
|
if check.status == "up":
|
|
cmd = self.channel.cmd_up
|
|
elif check.status == "down":
|
|
cmd = self.channel.cmd_down
|
|
|
|
cmd = self.prepare(cmd, check)
|
|
code = os.system(cmd)
|
|
|
|
if code != 0:
|
|
return "Command returned exit code %d" % code
|
|
|
|
|
|
class HttpTransport(Transport):
|
|
@classmethod
|
|
def get_error(cls, response):
|
|
# Override in subclasses: look for a specific error message in the
|
|
# response and return it.
|
|
return None
|
|
|
|
@classmethod
|
|
def _request(cls, method, url, **kwargs):
|
|
try:
|
|
options = dict(kwargs)
|
|
options["timeout"] = 5
|
|
if "headers" not in options:
|
|
options["headers"] = {}
|
|
if "User-Agent" not in options["headers"]:
|
|
options["headers"]["User-Agent"] = "healthchecks.io"
|
|
|
|
r = requests.request(method, url, **options)
|
|
if r.status_code not in (200, 201, 202, 204):
|
|
m = cls.get_error(r)
|
|
if m:
|
|
return f'Received status code {r.status_code} with a message: "{m}"'
|
|
|
|
return f"Received status code {r.status_code}"
|
|
|
|
except requests.exceptions.Timeout:
|
|
# Well, we tried
|
|
return "Connection timed out"
|
|
except requests.exceptions.ConnectionError:
|
|
return "Connection failed"
|
|
|
|
@classmethod
|
|
def get(cls, url, num_tries=3, **kwargs):
|
|
for x in range(0, num_tries):
|
|
error = cls._request("get", url, **kwargs)
|
|
if error is None:
|
|
break
|
|
|
|
return error
|
|
|
|
@classmethod
|
|
def post(cls, url, num_tries=3, **kwargs):
|
|
for x in range(0, num_tries):
|
|
error = cls._request("post", url, **kwargs)
|
|
if error is None:
|
|
break
|
|
|
|
return error
|
|
|
|
@classmethod
|
|
def put(cls, url, num_tries=3, **kwargs):
|
|
for x in range(0, num_tries):
|
|
error = cls._request("put", url, **kwargs)
|
|
if error is None:
|
|
break
|
|
|
|
return error
|
|
|
|
|
|
class Webhook(HttpTransport):
|
|
def prepare(self, template, check, urlencode=False):
|
|
""" Replace variables with actual values. """
|
|
|
|
def safe(s):
|
|
return quote(s) if urlencode else s
|
|
|
|
ctx = {
|
|
"$CODE": str(check.code),
|
|
"$STATUS": check.status,
|
|
"$NOW": safe(timezone.now().replace(microsecond=0).isoformat()),
|
|
"$NAME": safe(check.name),
|
|
"$TAGS": safe(check.tags),
|
|
}
|
|
|
|
for i, tag in enumerate(check.tags_list()):
|
|
ctx["$TAG%d" % (i + 1)] = safe(tag)
|
|
|
|
return replace(template, ctx)
|
|
|
|
def is_noop(self, check):
|
|
if check.status == "down" and not self.channel.url_down:
|
|
return True
|
|
|
|
if check.status == "up" and not self.channel.url_up:
|
|
return True
|
|
|
|
return False
|
|
|
|
def notify(self, check):
|
|
spec = self.channel.webhook_spec(check.status)
|
|
if not spec["url"]:
|
|
return "Empty webhook URL"
|
|
|
|
url = self.prepare(spec["url"], check, urlencode=True)
|
|
headers = {}
|
|
for key, value in spec["headers"].items():
|
|
headers[key] = self.prepare(value, check)
|
|
|
|
body = spec["body"]
|
|
if body:
|
|
body = self.prepare(body, check).encode()
|
|
|
|
num_tries = 3
|
|
if getattr(check, "is_test"):
|
|
# When sending a test notification, don't retry on failures.
|
|
num_tries = 1
|
|
|
|
if spec["method"] == "GET":
|
|
return self.get(url, num_tries=num_tries, headers=headers)
|
|
elif spec["method"] == "POST":
|
|
return self.post(url, num_tries=num_tries, data=body, headers=headers)
|
|
elif spec["method"] == "PUT":
|
|
return self.put(url, num_tries=num_tries, data=body, headers=headers)
|
|
|
|
|
|
class Slack(HttpTransport):
|
|
def notify(self, check):
|
|
text = tmpl("slack_message.json", check=check)
|
|
payload = json.loads(text)
|
|
return self.post(self.channel.slack_webhook_url, json=payload)
|
|
|
|
|
|
class HipChat(HttpTransport):
|
|
def is_noop(self, check):
|
|
return True
|
|
|
|
|
|
class OpsGenie(HttpTransport):
|
|
@classmethod
|
|
def get_error(cls, response):
|
|
try:
|
|
return response.json().get("message")
|
|
except ValueError:
|
|
pass
|
|
|
|
def notify(self, check):
|
|
headers = {
|
|
"Conent-Type": "application/json",
|
|
"Authorization": "GenieKey %s" % self.channel.opsgenie_key,
|
|
}
|
|
|
|
payload = {"alias": str(check.code), "source": settings.SITE_NAME}
|
|
|
|
if check.status == "down":
|
|
payload["tags"] = check.tags_list()
|
|
payload["message"] = tmpl("opsgenie_message.html", check=check)
|
|
payload["note"] = tmpl("opsgenie_note.html", check=check)
|
|
payload["description"] = tmpl("opsgenie_description.html", check=check)
|
|
|
|
url = "https://api.opsgenie.com/v2/alerts"
|
|
if self.channel.opsgenie_region == "eu":
|
|
url = "https://api.eu.opsgenie.com/v2/alerts"
|
|
|
|
if check.status == "up":
|
|
url += "/%s/close?identifierType=alias" % check.code
|
|
|
|
return self.post(url, json=payload, headers=headers)
|
|
|
|
|
|
class PagerDuty(HttpTransport):
|
|
URL = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
|
|
|
|
def notify(self, check):
|
|
description = tmpl("pd_description.html", check=check)
|
|
payload = {
|
|
"service_key": self.channel.pd_service_key,
|
|
"incident_key": str(check.code),
|
|
"event_type": "trigger" if check.status == "down" else "resolve",
|
|
"description": description,
|
|
"client": settings.SITE_NAME,
|
|
"client_url": check.details_url(),
|
|
}
|
|
|
|
return self.post(self.URL, json=payload)
|
|
|
|
|
|
class PagerTree(HttpTransport):
|
|
def notify(self, check):
|
|
url = self.channel.value
|
|
headers = {"Conent-Type": "application/json"}
|
|
payload = {
|
|
"incident_key": str(check.code),
|
|
"event_type": "trigger" if check.status == "down" else "resolve",
|
|
"title": tmpl("pagertree_title.html", check=check),
|
|
"description": tmpl("pagertree_description.html", check=check),
|
|
"client": settings.SITE_NAME,
|
|
"client_url": settings.SITE_ROOT,
|
|
"tags": ",".join(check.tags_list()),
|
|
}
|
|
|
|
return self.post(url, json=payload, headers=headers)
|
|
|
|
|
|
class PagerTeam(HttpTransport):
|
|
def is_noop(self, check):
|
|
return True
|
|
|
|
|
|
class Pushbullet(HttpTransport):
|
|
def notify(self, check):
|
|
text = tmpl("pushbullet_message.html", check=check)
|
|
url = "https://api.pushbullet.com/v2/pushes"
|
|
headers = {
|
|
"Access-Token": self.channel.value,
|
|
"Conent-Type": "application/json",
|
|
}
|
|
payload = {"type": "note", "title": settings.SITE_NAME, "body": text}
|
|
|
|
return self.post(url, json=payload, headers=headers)
|
|
|
|
|
|
class Pushover(HttpTransport):
|
|
URL = "https://api.pushover.net/1/messages.json"
|
|
|
|
def notify(self, check):
|
|
others = self.checks().filter(status="down").exclude(code=check.code)
|
|
|
|
# list() executes the query, to avoid DB access while
|
|
# rendering a template
|
|
ctx = {"check": check, "down_checks": list(others)}
|
|
text = tmpl("pushover_message.html", **ctx)
|
|
title = tmpl("pushover_title.html", **ctx)
|
|
|
|
pieces = self.channel.value.split("|")
|
|
user_key, prio = pieces[0], pieces[1]
|
|
# The third element, if present, is the priority for "up" events
|
|
if len(pieces) == 3 and check.status == "up":
|
|
prio = pieces[2]
|
|
|
|
payload = {
|
|
"token": settings.PUSHOVER_API_TOKEN,
|
|
"user": user_key,
|
|
"message": text,
|
|
"title": title,
|
|
"html": 1,
|
|
"priority": int(prio),
|
|
}
|
|
|
|
# Emergency notification
|
|
if prio == "2":
|
|
payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
|
|
payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
|
|
|
|
return self.post(self.URL, data=payload)
|
|
|
|
|
|
class VictorOps(HttpTransport):
|
|
def notify(self, check):
|
|
description = tmpl("victorops_description.html", check=check)
|
|
mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
|
|
payload = {
|
|
"entity_id": str(check.code),
|
|
"message_type": mtype,
|
|
"entity_display_name": check.name_then_code(),
|
|
"state_message": description,
|
|
"monitoring_tool": settings.SITE_NAME,
|
|
}
|
|
|
|
return self.post(self.channel.value, json=payload)
|
|
|
|
|
|
class Matrix(HttpTransport):
|
|
def get_url(self):
|
|
s = quote(self.channel.value)
|
|
|
|
url = settings.MATRIX_HOMESERVER
|
|
url += "/_matrix/client/r0/rooms/%s/send/m.room.message?" % s
|
|
url += urlencode({"access_token": settings.MATRIX_ACCESS_TOKEN})
|
|
return url
|
|
|
|
def notify(self, check):
|
|
plain = tmpl("matrix_description.html", check=check)
|
|
formatted = tmpl("matrix_description_formatted.html", check=check)
|
|
payload = {
|
|
"msgtype": "m.text",
|
|
"body": plain,
|
|
"format": "org.matrix.custom.html",
|
|
"formatted_body": formatted,
|
|
}
|
|
|
|
return self.post(self.get_url(), json=payload)
|
|
|
|
|
|
class Discord(HttpTransport):
|
|
def notify(self, check):
|
|
text = tmpl("slack_message.json", check=check)
|
|
payload = json.loads(text)
|
|
url = self.channel.discord_webhook_url + "/slack"
|
|
return self.post(url, json=payload)
|
|
|
|
|
|
class Telegram(HttpTransport):
|
|
SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
|
|
|
|
@classmethod
|
|
def get_error(cls, response):
|
|
try:
|
|
return response.json().get("description")
|
|
except ValueError:
|
|
pass
|
|
|
|
@classmethod
|
|
def send(cls, chat_id, text):
|
|
# Telegram.send is a separate method because it is also used in
|
|
# hc.front.views.telegram_bot to send invite links.
|
|
return cls.post(
|
|
cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
|
|
)
|
|
|
|
def notify(self, check):
|
|
from hc.api.models import TokenBucket
|
|
|
|
if not TokenBucket.authorize_telegram(self.channel.telegram_id):
|
|
return "Rate limit exceeded"
|
|
|
|
text = tmpl("telegram_message.html", check=check)
|
|
return self.send(self.channel.telegram_id, text)
|
|
|
|
|
|
class Sms(HttpTransport):
|
|
URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
|
|
|
|
def is_noop(self, check):
|
|
return check.status != "down"
|
|
|
|
def notify(self, check):
|
|
profile = Profile.objects.for_user(self.channel.project.owner)
|
|
if not profile.authorize_sms():
|
|
profile.send_sms_limit_notice("SMS")
|
|
return "Monthly SMS limit exceeded"
|
|
|
|
url = self.URL % settings.TWILIO_ACCOUNT
|
|
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
|
|
text = tmpl("sms_message.html", check=check, site_name=settings.SITE_NAME)
|
|
|
|
data = {
|
|
"From": settings.TWILIO_FROM,
|
|
"To": self.channel.phone_number,
|
|
"Body": text,
|
|
"StatusCallback": check.status_url,
|
|
}
|
|
|
|
return self.post(url, data=data, auth=auth)
|
|
|
|
|
|
class Call(HttpTransport):
|
|
URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Calls.json"
|
|
|
|
def is_noop(self, check):
|
|
return check.status != "down"
|
|
|
|
def notify(self, check):
|
|
profile = Profile.objects.for_user(self.channel.project.owner)
|
|
if not profile.authorize_call():
|
|
profile.send_call_limit_notice()
|
|
return "Monthly phone call limit exceeded"
|
|
|
|
url = self.URL % settings.TWILIO_ACCOUNT
|
|
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
|
|
twiml = tmpl("call_message.html", check=check, site_name=settings.SITE_NAME)
|
|
|
|
data = {
|
|
"From": settings.TWILIO_FROM,
|
|
"To": self.channel.phone_number,
|
|
"Twiml": twiml,
|
|
"StatusCallback": check.status_url,
|
|
}
|
|
|
|
return self.post(url, data=data, auth=auth)
|
|
|
|
|
|
class WhatsApp(HttpTransport):
|
|
URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
|
|
|
|
def is_noop(self, check):
|
|
if check.status == "down":
|
|
return not self.channel.whatsapp_notify_down
|
|
else:
|
|
return not self.channel.whatsapp_notify_up
|
|
|
|
def notify(self, check):
|
|
profile = Profile.objects.for_user(self.channel.project.owner)
|
|
if not profile.authorize_sms():
|
|
profile.send_sms_limit_notice("WhatsApp")
|
|
return "Monthly message limit exceeded"
|
|
|
|
url = self.URL % settings.TWILIO_ACCOUNT
|
|
auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
|
|
text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME)
|
|
|
|
data = {
|
|
"From": "whatsapp:%s" % settings.TWILIO_FROM,
|
|
"To": "whatsapp:%s" % self.channel.phone_number,
|
|
"Body": text,
|
|
"StatusCallback": check.status_url,
|
|
}
|
|
|
|
return self.post(url, data=data, auth=auth)
|
|
|
|
|
|
class Trello(HttpTransport):
|
|
URL = "https://api.trello.com/1/cards"
|
|
|
|
def is_noop(self, check):
|
|
return check.status != "down"
|
|
|
|
def notify(self, check):
|
|
params = {
|
|
"idList": self.channel.trello_list_id,
|
|
"name": tmpl("trello_name.html", check=check),
|
|
"desc": tmpl("trello_desc.html", check=check),
|
|
"key": settings.TRELLO_APP_KEY,
|
|
"token": self.channel.trello_token,
|
|
}
|
|
|
|
return self.post(self.URL, params=params)
|
|
|
|
|
|
class Apprise(HttpTransport):
|
|
def notify(self, check):
|
|
|
|
if not settings.APPRISE_ENABLED:
|
|
# Not supported and/or enabled
|
|
return "Apprise is disabled and/or not installed"
|
|
|
|
a = apprise.Apprise()
|
|
title = tmpl("apprise_title.html", check=check)
|
|
body = tmpl("apprise_description.html", check=check)
|
|
|
|
a.add(self.channel.value)
|
|
|
|
notify_type = (
|
|
apprise.NotifyType.SUCCESS
|
|
if check.status == "up"
|
|
else apprise.NotifyType.FAILURE
|
|
)
|
|
|
|
return (
|
|
"Failed"
|
|
if not a.notify(body=body, title=title, notify_type=notify_type)
|
|
else None
|
|
)
|
|
|
|
|
|
class MsTeams(HttpTransport):
|
|
def escape_md(self, s):
|
|
# Escape special HTML characters
|
|
s = escape(s)
|
|
# Escape characters that have special meaning in Markdown
|
|
for c in r"\`*_{}[]()#+-.!|":
|
|
s = s.replace(c, "\\" + c)
|
|
return s
|
|
|
|
def notify(self, check):
|
|
text = tmpl("msteams_message.json", check=check)
|
|
payload = json.loads(text)
|
|
|
|
# MS Teams escapes HTML special characters in the summary field.
|
|
# It does not interpret summary content as Markdown.
|
|
name = check.name_then_code()
|
|
payload["summary"] = f"“{name}” is {check.status.upper()}."
|
|
|
|
# MS teams *strips* HTML special characters from the title field.
|
|
# To avoid that, we use escape().
|
|
# It does not interpret title as Markdown.
|
|
safe_name = escape(name)
|
|
payload["title"] = f"“{safe_name}” is {check.status.upper()}."
|
|
|
|
# MS teams allows some HTML in the section text.
|
|
# It also interprets the section text as Markdown.
|
|
# We want to display the raw content, angle brackets and all,
|
|
# so we run escape() and then additionally escape Markdown:
|
|
payload["sections"][0]["text"] = self.escape_md(check.desc)
|
|
|
|
return self.post(self.channel.value, json=payload)
|
|
|
|
|
|
class Zulip(HttpTransport):
|
|
@classmethod
|
|
def get_error(cls, response):
|
|
try:
|
|
return response.json().get("msg")
|
|
except ValueError:
|
|
pass
|
|
|
|
def notify(self, check):
|
|
_, domain = self.channel.zulip_bot_email.split("@")
|
|
url = "https://%s/api/v1/messages" % domain
|
|
auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
|
|
data = {
|
|
"type": self.channel.zulip_type,
|
|
"to": self.channel.zulip_to,
|
|
"topic": tmpl("zulip_topic.html", check=check),
|
|
"content": tmpl("zulip_content.html", check=check),
|
|
}
|
|
|
|
return self.post(url, data=data, auth=auth)
|
|
|
|
|
|
class Spike(HttpTransport):
|
|
def notify(self, check):
|
|
url = self.channel.value
|
|
headers = {"Conent-Type": "application/json"}
|
|
payload = {
|
|
"check_id": str(check.code),
|
|
"title": tmpl("spike_title.html", check=check),
|
|
"message": tmpl("spike_description.html", check=check),
|
|
"status": check.status,
|
|
}
|
|
|
|
return self.post(url, json=payload, headers=headers)
|
|
|
|
|
|
class LineNotify(HttpTransport):
|
|
URL = "https://notify-api.line.me/api/notify"
|
|
|
|
def notify(self, check):
|
|
headers = {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Authorization": "Bearer %s" % self.channel.linenotify_token,
|
|
}
|
|
payload = {"message": tmpl("linenotify_message.html", check=check)}
|
|
return self.post(self.URL, headers=headers, params=payload)
|
|
|
|
|
|
class Signal(Transport):
|
|
def is_noop(self, check):
|
|
if check.status == "down":
|
|
return not self.channel.signal_notify_down
|
|
else:
|
|
return not self.channel.signal_notify_up
|
|
|
|
def get_service(self):
|
|
bus = dbus.SystemBus()
|
|
signal_object = bus.get_object("org.asamk.Signal", "/org/asamk/Signal")
|
|
return dbus.Interface(signal_object, "org.asamk.Signal")
|
|
|
|
def notify(self, check):
|
|
if not settings.SIGNAL_CLI_ENABLED:
|
|
return "Signal notifications are not enabled"
|
|
|
|
from hc.api.models import TokenBucket
|
|
|
|
if not TokenBucket.authorize_signal(self.channel.phone_number):
|
|
return "Rate limit exceeded"
|
|
|
|
text = tmpl("signal_message.html", check=check, site_name=settings.SITE_NAME)
|
|
|
|
try:
|
|
self.get_service().sendMessage(text, [], [self.channel.phone_number])
|
|
except dbus.exceptions.DBusException as e:
|
|
if "NotFoundException" in str(e):
|
|
return "Recipient not found"
|
|
|
|
return "signal-cli call failed"
|