195 lines
6.6 KiB
Python
195 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""
|
|
.. module::SMS
|
|
:synopsis: SMS client script (requires Twilio account)
|
|
"""
|
|
|
|
from time import time
|
|
from math import floor
|
|
from pyircbot.modulebase import ModuleBase, regex
|
|
from pyircbot.modules.ModInfo import info
|
|
import cherrypy
|
|
from threading import Thread
|
|
from twilio.rest import Client
|
|
|
|
|
|
class Api(object):
|
|
def __init__(self, mod):
|
|
self.mod = mod
|
|
|
|
@cherrypy.expose
|
|
def gotsms(self, *args, **kwargs):
|
|
"""
|
|
Twilio webhook listener
|
|
"""
|
|
|
|
"""
|
|
Example payload:
|
|
{'To': '+11234567890',
|
|
'ToCity': 'ALBERTVILLE',
|
|
'ToState': 'AL',
|
|
'ToZip': '35951',
|
|
'ToCountry': 'US,
|
|
'NumMedia': '1',
|
|
'MediaContentType0': 'image/jpeg',
|
|
'MediaUrl0': 'https://api.twilio.com/xxx',
|
|
'From': '+11234567890',
|
|
'FromCity': 'ROCHESTER',
|
|
'FromState': 'NY',
|
|
'FromZip': '14622',
|
|
'FromCountry': 'US',
|
|
'Body': 'Lol',
|
|
'NumSegments': '1',
|
|
'SmsStatus': 'received',
|
|
'SmsSid': 'xxx',
|
|
'SmsMessageSid': 'xxx',
|
|
'MessageSid': 'xxx',
|
|
'AccountSid': 'xxx',
|
|
'MessagingServiceSid': 'xxx',
|
|
'ApiVersion': '2010-04-01'}
|
|
"""
|
|
attachments = []
|
|
medias = int(kwargs["NumMedia"])
|
|
while medias > 0:
|
|
medias -= 1
|
|
attachments.append((kwargs["MediaContentType{}".format(medias)], kwargs["MediaUrl{}".format(medias)], ))
|
|
|
|
self.mod.got_text(kwargs["From"], kwargs["Body"], attachments=attachments)
|
|
yield ''
|
|
|
|
|
|
class SMS(ModuleBase):
|
|
def __init__(self, bot, moduleName):
|
|
ModuleBase.__init__(self, bot, moduleName)
|
|
self.apithread = None
|
|
self.twilio = Client(self.config["account_sid"], self.config["auth_token"])
|
|
|
|
# limit-related vars
|
|
# How many messages can be bursted
|
|
self.bucket_max = int(self.config["limit"]["max"])
|
|
# burst bucket, initial value is 1 or half the max, whichever is more
|
|
self.bucket = max(1, self.bucket_max / 2)
|
|
# how often the bucket has 1 item added
|
|
self.bucket_period = int(self.config["limit"]["period"])
|
|
# last time the burst bucket was filled
|
|
self.bucket_lastfill = int(time())
|
|
|
|
def check_rate_limit(self):
|
|
"""
|
|
Rate limiting via a 'burst bucket'. This method is called before sending and returns true or false depending on
|
|
if the action is allowed.
|
|
"""
|
|
|
|
# First, update the bucket
|
|
# Check if $period time has passed since the bucket was filled
|
|
since_fill = int(time()) - self.bucket_lastfill
|
|
if since_fill > self.bucket_period:
|
|
# How many complete points are credited
|
|
fills = floor(since_fill / self.bucket_period)
|
|
self.bucket += fills
|
|
if self.bucket > self.bucket_max:
|
|
self.bucket = self.bucket_max
|
|
# Advance the lastfill time appropriately
|
|
self.bucket_lastfill += self.bucket_period * fills
|
|
|
|
if self.bucket >= 1:
|
|
self.bucket -= 1
|
|
return True
|
|
return False
|
|
|
|
def api(self):
|
|
"""
|
|
Run the webhook listener and block
|
|
"""
|
|
api = Api(self)
|
|
cherrypy.config.update({
|
|
# 'sessionFilter.on': True,
|
|
'tools.sessions.on': False,
|
|
'tools.sessions.locking': 'explicit',
|
|
# 'tools.sessions.timeout': 525600,
|
|
'request.show_tracebacks': True,
|
|
'server.socket_port': self.config.get("api_port"),
|
|
'server.thread_pool': 1,
|
|
'server.socket_host': '0.0.0.0',
|
|
'server.show_tracebacks': True,
|
|
'server.socket_timeout': 10,
|
|
'log.screen': False,
|
|
'engine.autoreload.on': False})
|
|
cherrypy.tree.mount(api, '/app/', {})
|
|
cherrypy.engine.start()
|
|
cherrypy.engine.block()
|
|
|
|
def onenable(self):
|
|
"""
|
|
If needed, create an API and run it
|
|
"""
|
|
if self.apithread is None and self.config.get("api_port") > 0:
|
|
self.apithread = Thread(target=self.api, daemon=True)
|
|
self.apithread.start()
|
|
|
|
def ondisable(self):
|
|
"""
|
|
Shut down the api
|
|
"""
|
|
cherrypy.engine.exit()
|
|
|
|
@info("text-<name>", "text somebody on the VIP list", cmds=["text"])
|
|
@regex(r'(?:^\.text\-([a-zA-Z0-9]+)(?:\s+(.+))?)', types=['PRIVMSG'])
|
|
def cmd_text(self, msg, match):
|
|
"""
|
|
Text somebody
|
|
"""
|
|
contact, message = match.groups()
|
|
contact = contact.lower()
|
|
|
|
if msg.args[0].lower() != self.config["channel"].lower():
|
|
return # invalid channel
|
|
if message is None:
|
|
return # TODO help text
|
|
if contact not in self.config["contacts"].keys():
|
|
return # TODO invalid contact
|
|
|
|
if self.config["limit"]["enable"]:
|
|
if not self.check_rate_limit():
|
|
self.bot.act_PRIVMSG(msg.args[0], "Sorry, try again later")
|
|
return
|
|
|
|
try:
|
|
self.twilio.api.account.messages.create(to=self.config["contacts"][contact],
|
|
from_=self.config["number"],
|
|
body="{} <{}>: {}".format(msg.args[0],
|
|
msg.prefix.nick,
|
|
msg.trailing[7 + len(contact):].strip()))
|
|
except Exception as e:
|
|
self.bot.act_PRIVMSG(msg.args[0], "Could not send message: {}".format(repr(e)))
|
|
else:
|
|
self.bot.act_PRIVMSG(msg.args[0], "Message sent.")
|
|
|
|
def got_text(self, sender, body, attachments=None):
|
|
"""
|
|
Webhook callback to react to a message
|
|
|
|
:param sender: number that sent the message, like +10000000000
|
|
:type sender: str
|
|
:param body: body text of the sms/mms
|
|
:type body: str
|
|
:param attachments: if mms, any attachments as a list of (mime, url) tuples
|
|
:type attachments: list
|
|
"""
|
|
name = None
|
|
for contact, number in self.config["contacts"].items():
|
|
if number == sender:
|
|
name = contact
|
|
|
|
if name is None:
|
|
name = sender
|
|
|
|
body = body.strip()
|
|
if body:
|
|
self.bot.act_PRIVMSG(self.config["channel"], "SMS from {}: {}".format(name, body))
|
|
|
|
if attachments:
|
|
for mime, url in attachments[0:3]:
|
|
self.bot.act_PRIVMSG(self.config["channel"], "MMS from {}: {} ({})".format(name, url, mime))
|