summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--requirements.txt21
-rw-r--r--taulubot.py10
-rw-r--r--telepot/__init__.py1405
-rw-r--r--telepot/api.py164
-rw-r--r--telepot/delegate.py420
-rw-r--r--telepot/exception.py111
-rw-r--r--telepot/filtering.py34
-rw-r--r--telepot/hack.py16
-rw-r--r--telepot/helper.py1170
-rw-r--r--telepot/loop.py313
-rw-r--r--telepot/namedtuple.py865
-rw-r--r--telepot/routing.py223
-rw-r--r--telepot/text.py88
13 files changed, 4818 insertions, 22 deletions
diff --git a/requirements.txt b/requirements.txt
index de427cd..a57210c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,19 +1,2 @@
-aiohttp==3.8.5
-aiosignal==1.3.1
-anyio==4.0.0
-async-timeout==4.0.3
-attrs==23.1.0
-certifi==2023.7.22
-charset-normalizer==3.2.0
-frozenlist==1.4.0
-h11==0.14.0
-httpcore==0.17.3
-httpx==0.24.1
-idna==3.4
-multidict==6.0.4
-Pillow==10.0.1
-requests==2.31.0
-sniffio==1.3.0
-telepot==12.7
-urllib3==2.0.5
-yarl==1.9.2
+pillow==11.3.0
+urllib3==2.5.0
diff --git a/taulubot.py b/taulubot.py
index 6b9c1a2..27f3b1b 100644
--- a/taulubot.py
+++ b/taulubot.py
@@ -1,7 +1,7 @@
import random
import time
from pprint import pprint
-import telepot
+from telepot import Bot
from telepot.loop import MessageLoop
import time
from PIL import Image
@@ -24,7 +24,7 @@ START_TIME = calendar.timegm(time.gmtime())
wooden_im.thumbnail(img_size)
golden_im.thumbnail(img_size)
-bot = telepot.Bot(BOT_TOKEN)
+bot = Bot(BOT_TOKEN)
logging.warning("Connected with token {}".format(BOT_TOKEN))
last_user_by_chat_id = {}
@@ -136,6 +136,10 @@ def handle(msg):
logging.warning("Listening...")
-MessageLoop(bot, handle).run_forever(
+MessageLoop(bot, handle).run_as_thread(
allowed_updates=["message"],
)
+
+# Run forever
+while True:
+ time.sleep(1)
diff --git a/telepot/__init__.py b/telepot/__init__.py
new file mode 100644
index 0000000..30cb9d4
--- /dev/null
+++ b/telepot/__init__.py
@@ -0,0 +1,1405 @@
+import sys
+import io
+import time
+import json
+import threading
+import traceback
+import collections
+import bisect
+
+try:
+ import Queue as queue
+except ImportError:
+ import queue
+
+# Patch urllib3 for sending unicode filename
+from . import hack
+
+from . import exception
+
+
+__version_info__ = (12, 7)
+__version__ = '.'.join(map(str, __version_info__))
+
+
+def flavor(msg):
+ """
+ Return flavor of message or event.
+
+ A message's flavor may be one of these:
+
+ - ``chat``
+ - ``callback_query``
+ - ``inline_query``
+ - ``chosen_inline_result``
+ - ``shipping_query``
+ - ``pre_checkout_query``
+
+ An event's flavor is determined by the single top-level key.
+ """
+ if 'message_id' in msg:
+ return 'chat'
+ elif 'id' in msg and 'chat_instance' in msg:
+ return 'callback_query'
+ elif 'id' in msg and 'query' in msg:
+ return 'inline_query'
+ elif 'result_id' in msg:
+ return 'chosen_inline_result'
+ elif 'id' in msg and 'shipping_address' in msg:
+ return 'shipping_query'
+ elif 'id' in msg and 'total_amount' in msg:
+ return 'pre_checkout_query'
+ else:
+ top_keys = list(msg.keys())
+ if len(top_keys) == 1:
+ return top_keys[0]
+
+ raise exception.BadFlavor(msg)
+
+
+chat_flavors = ['chat']
+inline_flavors = ['inline_query', 'chosen_inline_result']
+
+
+def _find_first_key(d, keys):
+ for k in keys:
+ if k in d:
+ return k
+ raise KeyError('No suggested keys %s in %s' % (str(keys), str(d)))
+
+
+all_content_types = [
+ 'text', 'audio', 'document', 'game', 'photo', 'sticker', 'video', 'voice', 'video_note',
+ 'contact', 'location', 'venue', 'new_chat_member', 'left_chat_member', 'new_chat_title',
+ 'new_chat_photo', 'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created',
+ 'channel_chat_created', 'migrate_to_chat_id', 'migrate_from_chat_id', 'pinned_message',
+ 'new_chat_members', 'invoice', 'successful_payment'
+]
+
+def glance(msg, flavor='chat', long=False):
+ """
+ Extract "headline" info about a message.
+ Use parameter ``long`` to control whether a short or long tuple is returned.
+
+ When ``flavor`` is ``chat``
+ (``msg`` being a `Message <https://core.telegram.org/bots/api#message>`_ object):
+
+ - short: (content_type, ``msg['chat']['type']``, ``msg['chat']['id']``)
+ - long: (content_type, ``msg['chat']['type']``, ``msg['chat']['id']``, ``msg['date']``, ``msg['message_id']``)
+
+ *content_type* can be: ``text``, ``audio``, ``document``, ``game``, ``photo``, ``sticker``, ``video``, ``voice``,
+ ``video_note``, ``contact``, ``location``, ``venue``, ``new_chat_member``, ``left_chat_member``, ``new_chat_title``,
+ ``new_chat_photo``, ``delete_chat_photo``, ``group_chat_created``, ``supergroup_chat_created``,
+ ``channel_chat_created``, ``migrate_to_chat_id``, ``migrate_from_chat_id``, ``pinned_message``,
+ ``new_chat_members``, ``invoice``, ``successful_payment``.
+
+ When ``flavor`` is ``callback_query``
+ (``msg`` being a `CallbackQuery <https://core.telegram.org/bots/api#callbackquery>`_ object):
+
+ - regardless: (``msg['id']``, ``msg['from']['id']``, ``msg['data']``)
+
+ When ``flavor`` is ``inline_query``
+ (``msg`` being a `InlineQuery <https://core.telegram.org/bots/api#inlinequery>`_ object):
+
+ - short: (``msg['id']``, ``msg['from']['id']``, ``msg['query']``)
+ - long: (``msg['id']``, ``msg['from']['id']``, ``msg['query']``, ``msg['offset']``)
+
+ When ``flavor`` is ``chosen_inline_result``
+ (``msg`` being a `ChosenInlineResult <https://core.telegram.org/bots/api#choseninlineresult>`_ object):
+
+ - regardless: (``msg['result_id']``, ``msg['from']['id']``, ``msg['query']``)
+
+ When ``flavor`` is ``shipping_query``
+ (``msg`` being a `ShippingQuery <https://core.telegram.org/bots/api#shippingquery>`_ object):
+
+ - regardless: (``msg['id']``, ``msg['from']['id']``, ``msg['invoice_payload']``)
+
+ When ``flavor`` is ``pre_checkout_query``
+ (``msg`` being a `PreCheckoutQuery <https://core.telegram.org/bots/api#precheckoutquery>`_ object):
+
+ - short: (``msg['id']``, ``msg['from']['id']``, ``msg['invoice_payload']``)
+ - long: (``msg['id']``, ``msg['from']['id']``, ``msg['invoice_payload']``, ``msg['currency']``, ``msg['total_amount']``)
+ """
+ def gl_chat():
+ content_type = _find_first_key(msg, all_content_types)
+
+ if long:
+ return content_type, msg['chat']['type'], msg['chat']['id'], msg['date'], msg['message_id']
+ else:
+ return content_type, msg['chat']['type'], msg['chat']['id']
+
+ def gl_callback_query():
+ return msg['id'], msg['from']['id'], msg['data']
+
+ def gl_inline_query():
+ if long:
+ return msg['id'], msg['from']['id'], msg['query'], msg['offset']
+ else:
+ return msg['id'], msg['from']['id'], msg['query']
+
+ def gl_chosen_inline_result():
+ return msg['result_id'], msg['from']['id'], msg['query']
+
+ def gl_shipping_query():
+ return msg['id'], msg['from']['id'], msg['invoice_payload']
+
+ def gl_pre_checkout_query():
+ if long:
+ return msg['id'], msg['from']['id'], msg['invoice_payload'], msg['currency'], msg['total_amount']
+ else:
+ return msg['id'], msg['from']['id'], msg['invoice_payload']
+
+ try:
+ fn = {'chat': gl_chat,
+ 'callback_query': gl_callback_query,
+ 'inline_query': gl_inline_query,
+ 'chosen_inline_result': gl_chosen_inline_result,
+ 'shipping_query': gl_shipping_query,
+ 'pre_checkout_query': gl_pre_checkout_query}[flavor]
+ except KeyError:
+ raise exception.BadFlavor(flavor)
+
+ return fn()
+
+
+def flance(msg, long=False):
+ """
+ A combination of :meth:`telepot.flavor` and :meth:`telepot.glance`,
+ return a 2-tuple (flavor, headline_info), where *headline_info* is whatever extracted by
+ :meth:`telepot.glance` depending on the message flavor and the ``long`` parameter.
+ """
+ f = flavor(msg)
+ g = glance(msg, flavor=f, long=long)
+ return f,g
+
+
+def peel(event):
+ """
+ Remove an event's top-level skin (where its flavor is determined), and return
+ the core content.
+ """
+ return list(event.values())[0]
+
+
+def fleece(event):
+ """
+ A combination of :meth:`telepot.flavor` and :meth:`telepot.peel`,
+ return a 2-tuple (flavor, content) of an event.
+ """
+ return flavor(event), peel(event)
+
+
+def is_event(msg):
+ """
+ Return whether the message looks like an event. That is, whether it has a flavor
+ that starts with an underscore.
+ """
+ return flavor(msg).startswith('_')
+
+
+def origin_identifier(msg):
+ """
+ Extract the message identifier of a callback query's origin. Returned value
+ is guaranteed to be a tuple.
+
+ ``msg`` is expected to be ``callback_query``.
+ """
+ if 'message' in msg:
+ return msg['message']['chat']['id'], msg['message']['message_id']
+ elif 'inline_message_id' in msg:
+ return msg['inline_message_id'],
+ else:
+ raise ValueError()
+
+def message_identifier(msg):
+ """
+ Extract an identifier for message editing. Useful with :meth:`telepot.Bot.editMessageText`
+ and similar methods. Returned value is guaranteed to be a tuple.
+
+ ``msg`` is expected to be ``chat`` or ``choson_inline_result``.
+ """
+ if 'chat' in msg and 'message_id' in msg:
+ return msg['chat']['id'], msg['message_id']
+ elif 'inline_message_id' in msg:
+ return msg['inline_message_id'],
+ else:
+ raise ValueError()
+
+def _dismantle_message_identifier(f):
+ if isinstance(f, tuple):
+ if len(f) == 2:
+ return {'chat_id': f[0], 'message_id': f[1]}
+ elif len(f) == 1:
+ return {'inline_message_id': f[0]}
+ else:
+ raise ValueError()
+ else:
+ return {'inline_message_id': f}
+
+def _split_input_media_array(media_array):
+ def ensure_dict(input_media):
+ if isinstance(input_media, tuple) and hasattr(input_media, '_asdict'):
+ return input_media._asdict()
+ elif isinstance(input_media, dict):
+ return input_media
+ else:
+ raise ValueError()
+
+ def given_attach_name(input_media):
+ if isinstance(input_media['media'], tuple):
+ return input_media['media'][0]
+ else:
+ return None
+
+ def attach_name_generator(used_names):
+ x = 0
+ while 1:
+ x += 1
+ name = 'media' + str(x)
+ if name in used_names:
+ continue;
+ yield name
+
+ def split_media(input_media, name_generator):
+ file_spec = input_media['media']
+
+ # file_id, URL
+ if _isstring(file_spec):
+ return (input_media, None)
+
+ # file-object
+ # (attach-name, file-object)
+ # (attach-name, (filename, file-object))
+ if isinstance(file_spec, tuple):
+ name, f = file_spec
+ else:
+ name, f = next(name_generator), file_spec
+
+ m = input_media.copy()
+ m['media'] = 'attach://' + name
+
+ return (m, (name, f))
+
+ ms = [ensure_dict(m) for m in media_array]
+
+ used_names = [given_attach_name(m) for m in ms if given_attach_name(m) is not None]
+ name_generator = attach_name_generator(used_names)
+
+ splitted = [split_media(m, name_generator) for m in ms]
+
+ legal_media, attachments = map(list, zip(*splitted))
+ files_to_attach = dict([a for a in attachments if a is not None])
+
+ return (legal_media, files_to_attach)
+
+
+PY_3 = sys.version_info.major >= 3
+_string_type = str if PY_3 else basestring
+_file_type = io.IOBase if PY_3 else file
+
+def _isstring(s):
+ return isinstance(s, _string_type)
+
+def _isfile(f):
+ return isinstance(f, _file_type)
+
+
+from . import helper
+
+def flavor_router(routing_table):
+ router = helper.Router(flavor, routing_table)
+ return router.route
+
+
+class _BotBase(object):
+ def __init__(self, token):
+ self._token = token
+ self._file_chunk_size = 65536
+
+
+def _strip(params, more=[]):
+ return {key: value for key,value in params.items() if key not in ['self']+more}
+
+def _rectify(params):
+ def make_jsonable(value):
+ if isinstance(value, list):
+ return [make_jsonable(v) for v in value]
+ elif isinstance(value, dict):
+ return {k:make_jsonable(v) for k,v in value.items() if v is not None}
+ elif isinstance(value, tuple) and hasattr(value, '_asdict'):
+ return {k:make_jsonable(v) for k,v in value._asdict().items() if v is not None}
+ else:
+ return value
+
+ def flatten(value):
+ v = make_jsonable(value)
+
+ if isinstance(v, (dict, list)):
+ return json.dumps(v, separators=(',',':'))
+ else:
+ return v
+
+ # remove None, then json-serialize if needed
+ return {k: flatten(v) for k,v in params.items() if v is not None}
+
+
+from . import api
+
+class Bot(_BotBase):
+ class Scheduler(threading.Thread):
+ # A class that is sorted by timestamp. Use `bisect` module to ensure order in event queue.
+ Event = collections.namedtuple('Event', ['timestamp', 'data'])
+ Event.__eq__ = lambda self, other: self.timestamp == other.timestamp
+ Event.__ne__ = lambda self, other: self.timestamp != other.timestamp
+ Event.__gt__ = lambda self, other: self.timestamp > other.timestamp
+ Event.__ge__ = lambda self, other: self.timestamp >= other.timestamp
+ Event.__lt__ = lambda self, other: self.timestamp < other.timestamp
+ Event.__le__ = lambda self, other: self.timestamp <= other.timestamp
+
+ def __init__(self):
+ super(Bot.Scheduler, self).__init__()
+ self._eventq = []
+ self._lock = threading.RLock() # reentrant lock to allow locked method calling locked method
+ self._event_handler = None
+
+ def _locked(fn):
+ def k(self, *args, **kwargs):
+ with self._lock:
+ return fn(self, *args, **kwargs)
+ return k
+
+ @_locked
+ def _insert_event(self, data, when):
+ ev = self.Event(when, data)
+ bisect.insort(self._eventq, ev)
+ return ev
+
+ @_locked
+ def _remove_event(self, event):
+ # Find event according to its timestamp.
+ # Index returned should be one behind.
+ i = bisect.bisect(self._eventq, event)
+
+ # Having two events with identical timestamp is unlikely but possible.
+ # I am going to move forward and compare timestamp AND object address
+ # to make sure the correct object is found.
+
+ while i > 0:
+ i -= 1
+ e = self._eventq[i]
+
+ if e.timestamp != event.timestamp:
+ raise exception.EventNotFound(event)
+ elif id(e) == id(event):
+ self._eventq.pop(i)
+ return
+
+ raise exception.EventNotFound(event)
+
+ @_locked
+ def _pop_expired_event(self):
+ if not self._eventq:
+ return None
+
+ if self._eventq[0].timestamp <= time.time():
+ return self._eventq.pop(0)
+ else:
+ return None
+
+ def event_at(self, when, data):
+ """
+ Schedule some data to emit at an absolute timestamp.
+
+ :type when: int or float
+ :type data: dictionary
+ :return: an internal Event object
+ """
+ return self._insert_event(data, when)
+
+ def event_later(self, delay, data):
+ """
+ Schedule some data to emit after a number of seconds.
+
+ :type delay: int or float
+ :type data: dictionary
+ :return: an internal Event object
+ """
+ return self._insert_event(data, time.time()+delay)
+
+ def event_now(self, data):
+ """
+ Emit some data as soon as possible.
+
+ :type data: dictionary
+ :return: an internal Event object
+ """
+ return self._insert_event(data, time.time())
+
+ def cancel(self, event):
+ """
+ Cancel an event.
+
+ :type event: an internal Event object
+ """
+ self._remove_event(event)
+
+ def run(self):
+ while 1:
+ e = self._pop_expired_event()
+ while e:
+ if callable(e.data):
+ d = e.data() # call the data-producing function
+ if d is not None:
+ self._event_handler(d)
+ else:
+ self._event_handler(e.data)
+
+ e = self._pop_expired_event()
+ time.sleep(0.1)
+
+ def run_as_thread(self):
+ self.daemon = True
+ self.start()
+
+ def on_event(self, fn):
+ self._event_handler = fn
+
+ def __init__(self, token):
+ super(Bot, self).__init__(token)
+
+ self._scheduler = self.Scheduler()
+
+ self._router = helper.Router(flavor, {'chat': lambda msg: self.on_chat_message(msg),
+ 'callback_query': lambda msg: self.on_callback_query(msg),
+ 'inline_query': lambda msg: self.on_inline_query(msg),
+ 'chosen_inline_result': lambda msg: self.on_chosen_inline_result(msg)})
+ # use lambda to delay evaluation of self.on_ZZZ to runtime because
+ # I don't want to require defining all methods right here.
+
+ @property
+ def scheduler(self):
+ return self._scheduler
+
+ @property
+ def router(self):
+ return self._router
+
+ def handle(self, msg):
+ self._router.route(msg)
+
+ def _api_request(self, method, params=None, files=None, **kwargs):
+ return api.request((self._token, method, params, files), **kwargs)
+
+ def _api_request_with_file(self, method, params, file_key, file_value, **kwargs):
+ if _isstring(file_value):
+ params[file_key] = file_value
+ return self._api_request(method, _rectify(params), **kwargs)
+ else:
+ files = {file_key: file_value}
+ return self._api_request(method, _rectify(params), files, **kwargs)
+
+ def getMe(self):
+ """ See: https://core.telegram.org/bots/api#getme """
+ return self._api_request('getMe')
+
+ def sendMessage(self, chat_id, text,
+ parse_mode=None,
+ disable_web_page_preview=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendmessage """
+ p = _strip(locals())
+ return self._api_request('sendMessage', _rectify(p))
+
+ def forwardMessage(self, chat_id, from_chat_id, message_id,
+ disable_notification=None):
+ """ See: https://core.telegram.org/bots/api#forwardmessage """
+ p = _strip(locals())
+ return self._api_request('forwardMessage', _rectify(p))
+
+ def sendPhoto(self, chat_id, photo,
+ caption=None,
+ parse_mode=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendphoto
+
+ :param photo:
+ - string: ``file_id`` for a photo existing on Telegram servers
+ - string: HTTP URL of a photo from the Internet
+ - file-like object: obtained by ``open(path, 'rb')``
+ - tuple: (filename, file-like object). If the filename contains
+ non-ASCII characters and you are using Python 2.7, make sure the
+ filename is a unicode string.
+ """
+ p = _strip(locals(), more=['photo'])
+ return self._api_request_with_file('sendPhoto', _rectify(p), 'photo', photo)
+
+ def sendAudio(self, chat_id, audio,
+ caption=None,
+ parse_mode=None,
+ duration=None,
+ performer=None,
+ title=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendaudio
+
+ :param audio: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+ """
+ p = _strip(locals(), more=['audio'])
+ return self._api_request_with_file('sendAudio', _rectify(p), 'audio', audio)
+
+ def sendDocument(self, chat_id, document,
+ caption=None,
+ parse_mode=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#senddocument
+
+ :param document: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+ """
+ p = _strip(locals(), more=['document'])
+ return self._api_request_with_file('sendDocument', _rectify(p), 'document', document)
+
+ def sendVideo(self, chat_id, video,
+ duration=None,
+ width=None,
+ height=None,
+ caption=None,
+ parse_mode=None,
+ supports_streaming=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendvideo
+
+ :param video: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+ """
+ p = _strip(locals(), more=['video'])
+ return self._api_request_with_file('sendVideo', _rectify(p), 'video', video)
+
+ def sendVoice(self, chat_id, voice,
+ caption=None,
+ parse_mode=None,
+ duration=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendvoice
+
+ :param voice: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+ """
+ p = _strip(locals(), more=['voice'])
+ return self._api_request_with_file('sendVoice', _rectify(p), 'voice', voice)
+
+ def sendVideoNote(self, chat_id, video_note,
+ duration=None,
+ length=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendvideonote
+
+ :param video_note: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+
+ :param length:
+ Although marked as optional, this method does not seem to work without
+ it being specified. Supply any integer you want. It seems to have no effect
+ on the video note's display size.
+ """
+ p = _strip(locals(), more=['video_note'])
+ return self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', video_note)
+
+ def sendMediaGroup(self, chat_id, media,
+ disable_notification=None,
+ reply_to_message_id=None):
+ """
+ See: https://core.telegram.org/bots/api#sendmediagroup
+
+ :type media: array of `InputMedia <https://core.telegram.org/bots/api#inputmedia>`_ objects
+ :param media:
+ To indicate media locations, each InputMedia object's ``media`` field
+ should be one of these:
+
+ - string: ``file_id`` for a file existing on Telegram servers
+ - string: HTTP URL of a file from the Internet
+ - file-like object: obtained by ``open(path, 'rb')``
+ - tuple: (form-data name, file-like object)
+ - tuple: (form-data name, (filename, file-like object))
+
+ In case of uploading, you may supply customized multipart/form-data
+ names for each uploaded file (as in last 2 options above). Otherwise,
+ telepot assigns unique names to each uploaded file. Names assigned by
+ telepot will not collide with user-supplied names, if any.
+ """
+ p = _strip(locals(), more=['media'])
+ legal_media, files_to_attach = _split_input_media_array(media)
+
+ p['media'] = legal_media
+ return self._api_request('sendMediaGroup', _rectify(p), files_to_attach)
+
+ def sendLocation(self, chat_id, latitude, longitude,
+ live_period=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendlocation """
+ p = _strip(locals())
+ return self._api_request('sendLocation', _rectify(p))
+
+ def editMessageLiveLocation(self, msg_identifier, latitude, longitude,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#editmessagelivelocation
+
+ :param msg_identifier: Same as in :meth:`.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('editMessageLiveLocation', _rectify(p))
+
+ def stopMessageLiveLocation(self, msg_identifier,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#stopmessagelivelocation
+
+ :param msg_identifier: Same as in :meth:`.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('stopMessageLiveLocation', _rectify(p))
+
+ def sendVenue(self, chat_id, latitude, longitude, title, address,
+ foursquare_id=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendvenue """
+ p = _strip(locals())
+ return self._api_request('sendVenue', _rectify(p))
+
+ def sendContact(self, chat_id, phone_number, first_name,
+ last_name=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendcontact """
+ p = _strip(locals())
+ return self._api_request('sendContact', _rectify(p))
+
+ def sendGame(self, chat_id, game_short_name,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendgame """
+ p = _strip(locals())
+ return self._api_request('sendGame', _rectify(p))
+
+ def sendInvoice(self, chat_id, title, description, payload,
+ provider_token, start_parameter, currency, prices,
+ provider_data=None,
+ photo_url=None,
+ photo_size=None,
+ photo_width=None,
+ photo_height=None,
+ need_name=None,
+ need_phone_number=None,
+ need_email=None,
+ need_shipping_address=None,
+ is_flexible=None,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """ See: https://core.telegram.org/bots/api#sendinvoice """
+ p = _strip(locals())
+ return self._api_request('sendInvoice', _rectify(p))
+
+ def sendChatAction(self, chat_id, action):
+ """ See: https://core.telegram.org/bots/api#sendchataction """
+ p = _strip(locals())
+ return self._api_request('sendChatAction', _rectify(p))
+
+ def getUserProfilePhotos(self, user_id,
+ offset=None,
+ limit=None):
+ """ See: https://core.telegram.org/bots/api#getuserprofilephotos """
+ p = _strip(locals())
+ return self._api_request('getUserProfilePhotos', _rectify(p))
+
+ def getFile(self, file_id):
+ """ See: https://core.telegram.org/bots/api#getfile """
+ p = _strip(locals())
+ return self._api_request('getFile', _rectify(p))
+
+ def kickChatMember(self, chat_id, user_id,
+ until_date=None):
+ """ See: https://core.telegram.org/bots/api#kickchatmember """
+ p = _strip(locals())
+ return self._api_request('kickChatMember', _rectify(p))
+
+ def unbanChatMember(self, chat_id, user_id):
+ """ See: https://core.telegram.org/bots/api#unbanchatmember """
+ p = _strip(locals())
+ return self._api_request('unbanChatMember', _rectify(p))
+
+ def restrictChatMember(self, chat_id, user_id,
+ until_date=None,
+ can_send_messages=None,
+ can_send_media_messages=None,
+ can_send_other_messages=None,
+ can_add_web_page_previews=None):
+ """ See: https://core.telegram.org/bots/api#restrictchatmember """
+ p = _strip(locals())
+ return self._api_request('restrictChatMember', _rectify(p))
+
+ def promoteChatMember(self, chat_id, user_id,
+ can_change_info=None,
+ can_post_messages=None,
+ can_edit_messages=None,
+ can_delete_messages=None,
+ can_invite_users=None,
+ can_restrict_members=None,
+ can_pin_messages=None,
+ can_promote_members=None):
+ """ See: https://core.telegram.org/bots/api#promotechatmember """
+ p = _strip(locals())
+ return self._api_request('promoteChatMember', _rectify(p))
+
+ def exportChatInviteLink(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#exportchatinvitelink """
+ p = _strip(locals())
+ return self._api_request('exportChatInviteLink', _rectify(p))
+
+ def setChatPhoto(self, chat_id, photo):
+ """ See: https://core.telegram.org/bots/api#setchatphoto """
+ p = _strip(locals(), more=['photo'])
+ return self._api_request_with_file('setChatPhoto', _rectify(p), 'photo', photo)
+
+ def deleteChatPhoto(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#deletechatphoto """
+ p = _strip(locals())
+ return self._api_request('deleteChatPhoto', _rectify(p))
+
+ def setChatTitle(self, chat_id, title):
+ """ See: https://core.telegram.org/bots/api#setchattitle """
+ p = _strip(locals())
+ return self._api_request('setChatTitle', _rectify(p))
+
+ def setChatDescription(self, chat_id,
+ description=None):
+ """ See: https://core.telegram.org/bots/api#setchatdescription """
+ p = _strip(locals())
+ return self._api_request('setChatDescription', _rectify(p))
+
+ def pinChatMessage(self, chat_id, message_id,
+ disable_notification=None):
+ """ See: https://core.telegram.org/bots/api#pinchatmessage """
+ p = _strip(locals())
+ return self._api_request('pinChatMessage', _rectify(p))
+
+ def unpinChatMessage(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#unpinchatmessage """
+ p = _strip(locals())
+ return self._api_request('unpinChatMessage', _rectify(p))
+
+ def leaveChat(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#leavechat """
+ p = _strip(locals())
+ return self._api_request('leaveChat', _rectify(p))
+
+ def getChat(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#getchat """
+ p = _strip(locals())
+ return self._api_request('getChat', _rectify(p))
+
+ def getChatAdministrators(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#getchatadministrators """
+ p = _strip(locals())
+ return self._api_request('getChatAdministrators', _rectify(p))
+
+ def getChatMembersCount(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#getchatmemberscount """
+ p = _strip(locals())
+ return self._api_request('getChatMembersCount', _rectify(p))
+
+ def getChatMember(self, chat_id, user_id):
+ """ See: https://core.telegram.org/bots/api#getchatmember """
+ p = _strip(locals())
+ return self._api_request('getChatMember', _rectify(p))
+
+ def setChatStickerSet(self, chat_id, sticker_set_name):
+ """ See: https://core.telegram.org/bots/api#setchatstickerset """
+ p = _strip(locals())
+ return self._api_request('setChatStickerSet', _rectify(p))
+
+ def deleteChatStickerSet(self, chat_id):
+ """ See: https://core.telegram.org/bots/api#deletechatstickerset """
+ p = _strip(locals())
+ return self._api_request('deleteChatStickerSet', _rectify(p))
+
+ def answerCallbackQuery(self, callback_query_id,
+ text=None,
+ show_alert=None,
+ url=None,
+ cache_time=None):
+ """ See: https://core.telegram.org/bots/api#answercallbackquery """
+ p = _strip(locals())
+ return self._api_request('answerCallbackQuery', _rectify(p))
+
+ def answerShippingQuery(self, shipping_query_id, ok,
+ shipping_options=None,
+ error_message=None):
+ """ See: https://core.telegram.org/bots/api#answershippingquery """
+ p = _strip(locals())
+ return self._api_request('answerShippingQuery', _rectify(p))
+
+ def answerPreCheckoutQuery(self, pre_checkout_query_id, ok,
+ error_message=None):
+ """ See: https://core.telegram.org/bots/api#answerprecheckoutquery """
+ p = _strip(locals())
+ return self._api_request('answerPreCheckoutQuery', _rectify(p))
+
+ def editMessageText(self, msg_identifier, text,
+ parse_mode=None,
+ disable_web_page_preview=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#editmessagetext
+
+ :param msg_identifier:
+ a 2-tuple (``chat_id``, ``message_id``),
+ a 1-tuple (``inline_message_id``),
+ or simply ``inline_message_id``.
+ You may extract this value easily with :meth:`telepot.message_identifier`
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('editMessageText', _rectify(p))
+
+ def editMessageCaption(self, msg_identifier,
+ caption=None,
+ parse_mode=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#editmessagecaption
+
+ :param msg_identifier: Same as ``msg_identifier`` in :meth:`telepot.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('editMessageCaption', _rectify(p))
+
+ def editMessageReplyMarkup(self, msg_identifier,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#editmessagereplymarkup
+
+ :param msg_identifier: Same as ``msg_identifier`` in :meth:`telepot.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('editMessageReplyMarkup', _rectify(p))
+
+ def deleteMessage(self, msg_identifier):
+ """
+ See: https://core.telegram.org/bots/api#deletemessage
+
+ :param msg_identifier:
+ Same as ``msg_identifier`` in :meth:`telepot.Bot.editMessageText`,
+ except this method does not work on inline messages.
+ """
+ p = _strip(locals(), more=['msg_identifier'])
+ p.update(_dismantle_message_identifier(msg_identifier))
+ return self._api_request('deleteMessage', _rectify(p))
+
+ def sendSticker(self, chat_id, sticker,
+ disable_notification=None,
+ reply_to_message_id=None,
+ reply_markup=None):
+ """
+ See: https://core.telegram.org/bots/api#sendsticker
+
+ :param sticker: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto`
+ """
+ p = _strip(locals(), more=['sticker'])
+ return self._api_request_with_file('sendSticker', _rectify(p), 'sticker', sticker)
+
+ def getStickerSet(self, name):
+ """
+ See: https://core.telegram.org/bots/api#getstickerset
+ """
+ p = _strip(locals())
+ return self._api_request('getStickerSet', _rectify(p))
+
+ def uploadStickerFile(self, user_id, png_sticker):
+ """
+ See: https://core.telegram.org/bots/api#uploadstickerfile
+ """
+ p = _strip(locals(), more=['png_sticker'])
+ return self._api_request_with_file('uploadStickerFile', _rectify(p), 'png_sticker', png_sticker)
+
+ def createNewStickerSet(self, user_id, name, title, png_sticker, emojis,
+ contains_masks=None,
+ mask_position=None):
+ """
+ See: https://core.telegram.org/bots/api#createnewstickerset
+ """
+ p = _strip(locals(), more=['png_sticker'])
+ return self._api_request_with_file('createNewStickerSet', _rectify(p), 'png_sticker', png_sticker)
+
+ def addStickerToSet(self, user_id, name, png_sticker, emojis,
+ mask_position=None):
+ """
+ See: https://core.telegram.org/bots/api#addstickertoset
+ """
+ p = _strip(locals(), more=['png_sticker'])
+ return self._api_request_with_file('addStickerToSet', _rectify(p), 'png_sticker', png_sticker)
+
+ def setStickerPositionInSet(self, sticker, position):
+ """
+ See: https://core.telegram.org/bots/api#setstickerpositioninset
+ """
+ p = _strip(locals())
+ return self._api_request('setStickerPositionInSet', _rectify(p))
+
+ def deleteStickerFromSet(self, sticker):
+ """
+ See: https://core.telegram.org/bots/api#deletestickerfromset
+ """
+ p = _strip(locals())
+ return self._api_request('deleteStickerFromSet', _rectify(p))
+
+ def answerInlineQuery(self, inline_query_id, results,
+ cache_time=None,
+ is_personal=None,
+ next_offset=None,
+ switch_pm_text=None,
+ switch_pm_parameter=None):
+ """ See: https://core.telegram.org/bots/api#answerinlinequery """
+ p = _strip(locals())
+ return self._api_request('answerInlineQuery', _rectify(p))
+
+ def getUpdates(self,
+ offset=None,
+ limit=None,
+ timeout=None,
+ allowed_updates=None):
+ """ See: https://core.telegram.org/bots/api#getupdates """
+ p = _strip(locals())
+ return self._api_request('getUpdates', _rectify(p))
+
+ def setWebhook(self,
+ url=None,
+ certificate=None,
+ max_connections=None,
+ allowed_updates=None):
+ """ See: https://core.telegram.org/bots/api#setwebhook """
+ p = _strip(locals(), more=['certificate'])
+
+ if certificate:
+ files = {'certificate': certificate}
+ return self._api_request('setWebhook', _rectify(p), files)
+ else:
+ return self._api_request('setWebhook', _rectify(p))
+
+ def deleteWebhook(self):
+ """ See: https://core.telegram.org/bots/api#deletewebhook """
+ return self._api_request('deleteWebhook')
+
+ def getWebhookInfo(self):
+ """ See: https://core.telegram.org/bots/api#getwebhookinfo """
+ return self._api_request('getWebhookInfo')
+
+ def setGameScore(self, user_id, score, game_message_identifier,
+ force=None,
+ disable_edit_message=None):
+ """
+ See: https://core.telegram.org/bots/api#setgamescore
+
+ :param game_message_identifier: Same as ``msg_identifier`` in :meth:`telepot.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['game_message_identifier'])
+ p.update(_dismantle_message_identifier(game_message_identifier))
+ return self._api_request('setGameScore', _rectify(p))
+
+ def getGameHighScores(self, user_id, game_message_identifier):
+ """
+ See: https://core.telegram.org/bots/api#getgamehighscores
+
+ :param game_message_identifier: Same as ``msg_identifier`` in :meth:`telepot.Bot.editMessageText`
+ """
+ p = _strip(locals(), more=['game_message_identifier'])
+ p.update(_dismantle_message_identifier(game_message_identifier))
+ return self._api_request('getGameHighScores', _rectify(p))
+
+ def download_file(self, file_id, dest):
+ """
+ Download a file to local disk.
+
+ :param dest: a path or a ``file`` object
+ """
+ f = self.getFile(file_id)
+ try:
+ d = dest if _isfile(dest) else open(dest, 'wb')
+
+ r = api.download((self._token, f['file_path']), preload_content=False)
+
+ while 1:
+ data = r.read(self._file_chunk_size)
+ if not data:
+ break
+ d.write(data)
+ finally:
+ if not _isfile(dest) and 'd' in locals():
+ d.close()
+
+ if 'r' in locals():
+ r.release_conn()
+
+ def message_loop(self, callback=None, relax=0.1,
+ timeout=20, allowed_updates=None,
+ source=None, ordered=True, maxhold=3,
+ run_forever=False):
+ """
+ :deprecated: will be removed in future. Use :class:`.MessageLoop` instead.
+
+ Spawn a thread to constantly ``getUpdates`` or pull updates from a queue.
+ Apply ``callback`` to every message received. Also starts the scheduler thread
+ for internal events.
+
+ :param callback:
+ a function that takes one argument (the message), or a routing table.
+ If ``None``, the bot's ``handle`` method is used.
+
+ A *routing table* is a dictionary of ``{flavor: function}``, mapping messages to appropriate
+ handler functions according to their flavors. It allows you to define functions specifically
+ to handle one flavor of messages. It usually looks like this: ``{'chat': fn1,
+ 'callback_query': fn2, 'inline_query': fn3, ...}``. Each handler function should take
+ one argument (the message).
+
+ :param source:
+ Source of updates.
+ If ``None``, ``getUpdates`` is used to obtain new messages from Telegram servers.
+ If it is a synchronized queue (``Queue.Queue`` in Python 2.7 or
+ ``queue.Queue`` in Python 3), new messages are pulled from the queue.
+ A web application implementing a webhook can dump updates into the queue,
+ while the bot pulls from it. This is how telepot can be integrated with webhooks.
+
+ Acceptable contents in queue:
+
+ - ``str``, ``unicode`` (Python 2.7), or ``bytes`` (Python 3, decoded using UTF-8)
+ representing a JSON-serialized `Update <https://core.telegram.org/bots/api#update>`_ object.
+ - a ``dict`` representing an Update object.
+
+ When ``source`` is ``None``, these parameters are meaningful:
+
+ :type relax: float
+ :param relax: seconds between each ``getUpdates``
+
+ :type timeout: int
+ :param timeout:
+ ``timeout`` parameter supplied to :meth:`telepot.Bot.getUpdates`,
+ controlling how long to poll.
+
+ :type allowed_updates: array of string
+ :param allowed_updates:
+ ``allowed_updates`` parameter supplied to :meth:`telepot.Bot.getUpdates`,
+ controlling which types of updates to receive.
+
+ When ``source`` is a queue, these parameters are meaningful:
+
+ :type ordered: bool
+ :param ordered:
+ If ``True``, ensure in-order delivery of messages to ``callback``
+ (i.e. updates with a smaller ``update_id`` always come before those with
+ a larger ``update_id``).
+ If ``False``, no re-ordering is done. ``callback`` is applied to messages
+ as soon as they are pulled from queue.
+
+ :type maxhold: float
+ :param maxhold:
+ Applied only when ``ordered`` is ``True``. The maximum number of seconds
+ an update is held waiting for a not-yet-arrived smaller ``update_id``.
+ When this number of seconds is up, the update is delivered to ``callback``
+ even if some smaller ``update_id``\s have not yet arrived. If those smaller
+ ``update_id``\s arrive at some later time, they are discarded.
+
+ Finally, there is this parameter, meaningful always:
+
+ :type run_forever: bool or str
+ :param run_forever:
+ If ``True`` or any non-empty string, append an infinite loop at the end of
+ this method, so it never returns. Useful as the very last line in a program.
+ A non-empty string will also be printed, useful as an indication that the
+ program is listening.
+ """
+ if callback is None:
+ callback = self.handle
+ elif isinstance(callback, dict):
+ callback = flavor_router(callback)
+
+ collect_queue = queue.Queue()
+
+ def collector():
+ while 1:
+ try:
+ item = collect_queue.get(block=True)
+ callback(item)
+ except:
+ # Localize error so thread can keep going.
+ traceback.print_exc()
+
+ def relay_to_collector(update):
+ key = _find_first_key(update, ['message',
+ 'edited_message',
+ 'channel_post',
+ 'edited_channel_post',
+ 'callback_query',
+ 'inline_query',
+ 'chosen_inline_result',
+ 'shipping_query',
+ 'pre_checkout_query'])
+ collect_queue.put(update[key])
+ return update['update_id']
+
+ def get_from_telegram_server():
+ offset = None # running offset
+ allowed_upd = allowed_updates
+ while 1:
+ try:
+ result = self.getUpdates(offset=offset,
+ timeout=timeout,
+ allowed_updates=allowed_upd)
+
+ # Once passed, this parameter is no longer needed.
+ allowed_upd = None
+
+ if len(result) > 0:
+ # No sort. Trust server to give messages in correct order.
+ # Update offset to max(update_id) + 1
+ offset = max([relay_to_collector(update) for update in result]) + 1
+
+ except exception.BadHTTPResponse as e:
+ traceback.print_exc()
+
+ # Servers probably down. Wait longer.
+ if e.status == 502:
+ time.sleep(30)
+ except:
+ traceback.print_exc()
+ finally:
+ time.sleep(relax)
+
+ def dictify3(data):
+ if type(data) is bytes:
+ return json.loads(data.decode('utf-8'))
+ elif type(data) is str:
+ return json.loads(data)
+ elif type(data) is dict:
+ return data
+ else:
+ raise ValueError()
+
+ def dictify27(data):
+ if type(data) in [str, unicode]:
+ return json.loads(data)
+ elif type(data) is dict:
+ return data
+ else:
+ raise ValueError()
+
+ def get_from_queue_unordered(qu):
+ dictify = dictify3 if sys.version_info >= (3,) else dictify27
+ while 1:
+ try:
+ data = qu.get(block=True)
+ update = dictify(data)
+ relay_to_collector(update)
+ except:
+ traceback.print_exc()
+
+ def get_from_queue(qu):
+ dictify = dictify3 if sys.version_info >= (3,) else dictify27
+
+ # Here is the re-ordering mechanism, ensuring in-order delivery of updates.
+ max_id = None # max update_id passed to callback
+ buffer = collections.deque() # keep those updates which skip some update_id
+ qwait = None # how long to wait for updates,
+ # because buffer's content has to be returned in time.
+
+ while 1:
+ try:
+ data = qu.get(block=True, timeout=qwait)
+ update = dictify(data)
+
+ if max_id is None:
+ # First message received, handle regardless.
+ max_id = relay_to_collector(update)
+
+ elif update['update_id'] == max_id + 1:
+ # No update_id skipped, handle naturally.
+ max_id = relay_to_collector(update)
+
+ # clear contagious updates in buffer
+ if len(buffer) > 0:
+ buffer.popleft() # first element belongs to update just received, useless now.
+ while 1:
+ try:
+ if type(buffer[0]) is dict:
+ max_id = relay_to_collector(buffer.popleft()) # updates that arrived earlier, handle them.
+ else:
+ break # gap, no more contagious updates
+ except IndexError:
+ break # buffer empty
+
+ elif update['update_id'] > max_id + 1:
+ # Update arrives pre-maturely, insert to buffer.
+ nbuf = len(buffer)
+ if update['update_id'] <= max_id + nbuf:
+ # buffer long enough, put update at position
+ buffer[update['update_id'] - max_id - 1] = update
+ else:
+ # buffer too short, lengthen it
+ expire = time.time() + maxhold
+ for a in range(nbuf, update['update_id']-max_id-1):
+ buffer.append(expire) # put expiry time in gaps
+ buffer.append(update)
+
+ else:
+ pass # discard
+
+ except queue.Empty:
+ # debug message
+ # print('Timeout')
+
+ # some buffer contents have to be handled
+ # flush buffer until a non-expired time is encountered
+ while 1:
+ try:
+ if type(buffer[0]) is dict:
+ max_id = relay_to_collector(buffer.popleft())
+ else:
+ expire = buffer[0]
+ if expire <= time.time():
+ max_id += 1
+ buffer.popleft()
+ else:
+ break # non-expired
+ except IndexError:
+ break # buffer empty
+ except:
+ traceback.print_exc()
+ finally:
+ try:
+ # don't wait longer than next expiry time
+ qwait = buffer[0] - time.time()
+ if qwait < 0:
+ qwait = 0
+ except IndexError:
+ # buffer empty, can wait forever
+ qwait = None
+
+ # debug message
+ # print ('Buffer:', str(buffer), ', To Wait:', qwait, ', Max ID:', max_id)
+
+ collector_thread = threading.Thread(target=collector)
+ collector_thread.daemon = True
+ collector_thread.start()
+
+ if source is None:
+ message_thread = threading.Thread(target=get_from_telegram_server)
+ elif isinstance(source, queue.Queue):
+ if ordered:
+ message_thread = threading.Thread(target=get_from_queue, args=(source,))
+ else:
+ message_thread = threading.Thread(target=get_from_queue_unordered, args=(source,))
+ else:
+ raise ValueError('Invalid source')
+
+ message_thread.daemon = True # need this for main thread to be killable by Ctrl-C
+ message_thread.start()
+
+ self._scheduler.on_event(collect_queue.put)
+ self._scheduler.run_as_thread()
+
+ if run_forever:
+ if _isstring(run_forever):
+ print(run_forever)
+ while 1:
+ time.sleep(10)
+
+
+import inspect
+
+class SpeakerBot(Bot):
+ def __init__(self, token):
+ super(SpeakerBot, self).__init__(token)
+ self._mic = helper.Microphone()
+
+ @property
+ def mic(self):
+ return self._mic
+
+ def create_listener(self):
+ q = queue.Queue()
+ self._mic.add(q)
+ ln = helper.Listener(self._mic, q)
+ return ln
+
+
+class DelegatorBot(SpeakerBot):
+ def __init__(self, token, delegation_patterns):
+ """
+ :param delegation_patterns: a list of (seeder, delegator) tuples.
+ """
+ super(DelegatorBot, self).__init__(token)
+ self._delegate_records = [p+({},) for p in delegation_patterns]
+
+ def _startable(self, delegate):
+ return ((hasattr(delegate, 'start') and inspect.ismethod(delegate.start)) and
+ (hasattr(delegate, 'is_alive') and inspect.ismethod(delegate.is_alive)))
+
+ def _tuple_is_valid(self, t):
+ return len(t) == 3 and callable(t[0]) and type(t[1]) in [list, tuple] and type(t[2]) is dict
+
+ def _ensure_startable(self, delegate):
+ if self._startable(delegate):
+ return delegate
+ elif callable(delegate):
+ return threading.Thread(target=delegate)
+ elif type(delegate) is tuple and self._tuple_is_valid(delegate):
+ func, args, kwargs = delegate
+ return threading.Thread(target=func, args=args, kwargs=kwargs)
+ else:
+ raise RuntimeError('Delegate does not have the required methods, is not callable, and is not a valid tuple.')
+
+ def handle(self, msg):
+ self._mic.send(msg)
+
+ for calculate_seed, make_delegate, dict in self._delegate_records:
+ id = calculate_seed(msg)
+
+ if id is None:
+ continue
+ elif isinstance(id, collections.Hashable):
+ if id not in dict or not dict[id].is_alive():
+ d = make_delegate((self, msg, id))
+ d = self._ensure_startable(d)
+
+ dict[id] = d
+ dict[id].start()
+ else:
+ d = make_delegate((self, msg, id))
+ d = self._ensure_startable(d)
+ d.start()
diff --git a/telepot/api.py b/telepot/api.py
new file mode 100644
index 0000000..3ffa788
--- /dev/null
+++ b/telepot/api.py
@@ -0,0 +1,164 @@
+import urllib3
+import logging
+import json
+import re
+import os
+
+from . import exception, _isstring
+
+# Suppress InsecurePlatformWarning
+urllib3.disable_warnings()
+
+
+_default_pool_params = dict(num_pools=3, maxsize=10, retries=3, timeout=30)
+_onetime_pool_params = dict(num_pools=1, maxsize=1, retries=3, timeout=30)
+
+_pools = {
+ 'default': urllib3.PoolManager(**_default_pool_params),
+}
+
+_onetime_pool_spec = (urllib3.PoolManager, _onetime_pool_params)
+
+
+def set_proxy(url, basic_auth=None):
+ """
+ Access Bot API through a proxy.
+
+ :param url: proxy URL
+ :param basic_auth: 2-tuple ``('username', 'password')``
+ """
+ global _pools, _onetime_pool_spec
+ if not url:
+ _pools['default'] = urllib3.PoolManager(**_default_pool_params)
+ _onetime_pool_spec = (urllib3.PoolManager, _onetime_pool_params)
+ elif basic_auth:
+ h = urllib3.make_headers(proxy_basic_auth=':'.join(basic_auth))
+ _pools['default'] = urllib3.ProxyManager(url, proxy_headers=h, **_default_pool_params)
+ _onetime_pool_spec = (urllib3.ProxyManager, dict(proxy_url=url, proxy_headers=h, **_onetime_pool_params))
+ else:
+ _pools['default'] = urllib3.ProxyManager(url, **_default_pool_params)
+ _onetime_pool_spec = (urllib3.ProxyManager, dict(proxy_url=url, **_onetime_pool_params))
+
+def _create_onetime_pool():
+ cls, kw = _onetime_pool_spec
+ return cls(**kw)
+
+def _methodurl(req, **user_kw):
+ token, method, params, files = req
+ return 'https://api.telegram.org/bot%s/%s' % (token, method)
+
+def _which_pool(req, **user_kw):
+ token, method, params, files = req
+ return None if files else 'default'
+
+def _guess_filename(obj):
+ name = getattr(obj, 'name', None)
+ if name and _isstring(name) and name[0] != '<' and name[-1] != '>':
+ return os.path.basename(name)
+
+def _filetuple(key, f):
+ if not isinstance(f, tuple):
+ return (_guess_filename(f) or key, f.read())
+ elif len(f) == 1:
+ return (_guess_filename(f[0]) or key, f[0].read())
+ elif len(f) == 2:
+ return (f[0], f[1].read())
+ elif len(f) == 3:
+ return (f[0], f[1].read(), f[2])
+ else:
+ raise ValueError()
+
+import sys
+PY_3 = sys.version_info.major >= 3
+def _fix_type(v):
+ if isinstance(v, float if PY_3 else (long, float)):
+ return str(v)
+ else:
+ return v
+
+def _compose_fields(req, **user_kw):
+ token, method, params, files = req
+
+ fields = {k:_fix_type(v) for k,v in params.items()} if params is not None else {}
+ if files:
+ fields.update({k:_filetuple(k,v) for k,v in files.items()})
+
+ return fields
+
+def _default_timeout(req, **user_kw):
+ name = _which_pool(req, **user_kw)
+ if name is None:
+ return _onetime_pool_spec[1]['timeout']
+ else:
+ return _pools[name].connection_pool_kw['timeout']
+
+def _compose_kwargs(req, **user_kw):
+ token, method, params, files = req
+ kw = {}
+
+ if not params and not files:
+ kw['encode_multipart'] = False
+
+ if method == 'getUpdates' and params and 'timeout' in params:
+ # Ensure HTTP timeout is longer than getUpdates timeout
+ kw['timeout'] = params['timeout'] + _default_timeout(req, **user_kw)
+ elif files:
+ # Disable timeout if uploading files. For some reason, the larger the file,
+ # the longer it takes for the server to respond (after upload is finished).
+ # It is unclear how long timeout should be.
+ kw['timeout'] = None
+
+ # Let user-supplied arguments override
+ kw.update(user_kw)
+ return kw
+
+def _transform(req, **user_kw):
+ kwargs = _compose_kwargs(req, **user_kw)
+
+ fields = _compose_fields(req, **user_kw)
+
+ url = _methodurl(req, **user_kw)
+
+ name = _which_pool(req, **user_kw)
+
+ if name is None:
+ pool = _create_onetime_pool()
+ else:
+ pool = _pools[name]
+
+ return pool.request_encode_body, ('POST', url, fields), kwargs
+
+def _parse(response):
+ try:
+ text = response.data.decode('utf-8')
+ data = json.loads(text)
+ except ValueError: # No JSON object could be decoded
+ raise exception.BadHTTPResponse(response.status, text, response)
+
+ if data['ok']:
+ return data['result']
+ else:
+ description, error_code = data['description'], data['error_code']
+
+ # Look for specific error ...
+ for e in exception.TelegramError.__subclasses__():
+ n = len(e.DESCRIPTION_PATTERNS)
+ if any(map(re.search, e.DESCRIPTION_PATTERNS, n*[description], n*[re.IGNORECASE])):
+ raise e(description, error_code, data)
+
+ # ... or raise generic error
+ raise exception.TelegramError(description, error_code, data)
+
+def request(req, **user_kw):
+ fn, args, kwargs = _transform(req, **user_kw)
+ r = fn(*args, **kwargs) # `fn` must be thread-safe
+ return _parse(r)
+
+def _fileurl(req):
+ token, path = req
+ return 'https://api.telegram.org/file/bot%s/%s' % (token, path)
+
+def download(req, **user_kw):
+ pool = _create_onetime_pool()
+ r = pool.request('GET', _fileurl(req), **user_kw)
+ return r
diff --git a/telepot/delegate.py b/telepot/delegate.py
new file mode 100644
index 0000000..91b981e
--- /dev/null
+++ b/telepot/delegate.py
@@ -0,0 +1,420 @@
+import traceback
+from functools import wraps
+from . import exception
+from . import flavor, peel, is_event, chat_flavors, inline_flavors
+
+def _wrap_none(fn):
+ def w(*args, **kwargs):
+ try:
+ return fn(*args, **kwargs)
+ except (KeyError, exception.BadFlavor):
+ return None
+ return w
+
+def per_chat_id(types='all'):
+ """
+ :param types:
+ ``all`` or a list of chat types (``private``, ``group``, ``channel``)
+
+ :return:
+ a seeder function that returns the chat id only if the chat type is in ``types``.
+ """
+ return _wrap_none(lambda msg:
+ msg['chat']['id']
+ if types == 'all' or msg['chat']['type'] in types
+ else None)
+
+def per_chat_id_in(s, types='all'):
+ """
+ :param s:
+ a list or set of chat id
+
+ :param types:
+ ``all`` or a list of chat types (``private``, ``group``, ``channel``)
+
+ :return:
+ a seeder function that returns the chat id only if the chat id is in ``s``
+ and chat type is in ``types``.
+ """
+ return _wrap_none(lambda msg:
+ msg['chat']['id']
+ if (types == 'all' or msg['chat']['type'] in types) and msg['chat']['id'] in s
+ else None)
+
+def per_chat_id_except(s, types='all'):
+ """
+ :param s:
+ a list or set of chat id
+
+ :param types:
+ ``all`` or a list of chat types (``private``, ``group``, ``channel``)
+
+ :return:
+ a seeder function that returns the chat id only if the chat id is *not* in ``s``
+ and chat type is in ``types``.
+ """
+ return _wrap_none(lambda msg:
+ msg['chat']['id']
+ if (types == 'all' or msg['chat']['type'] in types) and msg['chat']['id'] not in s
+ else None)
+
+def per_from_id(flavors=chat_flavors+inline_flavors):
+ """
+ :param flavors:
+ ``all`` or a list of flavors
+
+ :return:
+ a seeder function that returns the from id only if the message flavor is
+ in ``flavors``.
+ """
+ return _wrap_none(lambda msg:
+ msg['from']['id']
+ if flavors == 'all' or flavor(msg) in flavors
+ else None)
+
+def per_from_id_in(s, flavors=chat_flavors+inline_flavors):
+ """
+ :param s:
+ a list or set of from id
+
+ :param flavors:
+ ``all`` or a list of flavors
+
+ :return:
+ a seeder function that returns the from id only if the from id is in ``s``
+ and message flavor is in ``flavors``.
+ """
+ return _wrap_none(lambda msg:
+ msg['from']['id']
+ if (flavors == 'all' or flavor(msg) in flavors) and msg['from']['id'] in s
+ else None)
+
+def per_from_id_except(s, flavors=chat_flavors+inline_flavors):
+ """
+ :param s:
+ a list or set of from id
+
+ :param flavors:
+ ``all`` or a list of flavors
+
+ :return:
+ a seeder function that returns the from id only if the from id is *not* in ``s``
+ and message flavor is in ``flavors``.
+ """
+ return _wrap_none(lambda msg:
+ msg['from']['id']
+ if (flavors == 'all' or flavor(msg) in flavors) and msg['from']['id'] not in s
+ else None)
+
+def per_inline_from_id():
+ """
+ :return:
+ a seeder function that returns the from id only if the message flavor
+ is ``inline_query`` or ``chosen_inline_result``
+ """
+ return per_from_id(flavors=inline_flavors)
+
+def per_inline_from_id_in(s):
+ """
+ :param s: a list or set of from id
+ :return:
+ a seeder function that returns the from id only if the message flavor
+ is ``inline_query`` or ``chosen_inline_result`` and the from id is in ``s``.
+ """
+ return per_from_id_in(s, flavors=inline_flavors)
+
+def per_inline_from_id_except(s):
+ """
+ :param s: a list or set of from id
+ :return:
+ a seeder function that returns the from id only if the message flavor
+ is ``inline_query`` or ``chosen_inline_result`` and the from id is *not* in ``s``.
+ """
+ return per_from_id_except(s, flavors=inline_flavors)
+
+def per_application():
+ """
+ :return:
+ a seeder function that always returns 1, ensuring at most one delegate is ever spawned
+ for the entire application.
+ """
+ return lambda msg: 1
+
+def per_message(flavors='all'):
+ """
+ :param flavors: ``all`` or a list of flavors
+ :return:
+ a seeder function that returns a non-hashable only if the message flavor
+ is in ``flavors``.
+ """
+ return _wrap_none(lambda msg: [] if flavors == 'all' or flavor(msg) in flavors else None)
+
+def per_event_source_id(event_space):
+ """
+ :return:
+ a seeder function that returns an event's source id only if that event's
+ source space equals to ``event_space``.
+ """
+ def f(event):
+ if is_event(event):
+ v = peel(event)
+ if v['source']['space'] == event_space:
+ return v['source']['id']
+ else:
+ return None
+ else:
+ return None
+ return _wrap_none(f)
+
+def per_callback_query_chat_id(types='all'):
+ """
+ :param types:
+ ``all`` or a list of chat types (``private``, ``group``, ``channel``)
+
+ :return:
+ a seeder function that returns a callback query's originating chat id
+ if the chat type is in ``types``.
+ """
+ def f(msg):
+ if (flavor(msg) == 'callback_query' and 'message' in msg
+ and (types == 'all' or msg['message']['chat']['type'] in types)):
+ return msg['message']['chat']['id']
+ else:
+ return None
+ return f
+
+def per_callback_query_origin(origins='all'):
+ """
+ :param origins:
+ ``all`` or a list of origin types (``chat``, ``inline``)
+
+ :return:
+ a seeder function that returns a callback query's origin identifier if
+ that origin type is in ``origins``. The origin identifier is guaranteed
+ to be a tuple.
+ """
+ def f(msg):
+ def origin_type_ok():
+ return (origins == 'all'
+ or ('chat' in origins and 'message' in msg)
+ or ('inline' in origins and 'inline_message_id' in msg))
+
+ if flavor(msg) == 'callback_query' and origin_type_ok():
+ if 'inline_message_id' in msg:
+ return msg['inline_message_id'],
+ else:
+ return msg['message']['chat']['id'], msg['message']['message_id']
+ else:
+ return None
+ return f
+
+def per_invoice_payload():
+ """
+ :return:
+ a seeder function that returns the invoice payload.
+ """
+ def f(msg):
+ if 'successful_payment' in msg:
+ return msg['successful_payment']['invoice_payload']
+ else:
+ return msg['invoice_payload']
+
+ return _wrap_none(f)
+
+def call(func, *args, **kwargs):
+ """
+ :return:
+ a delegator function that returns a tuple (``func``, (seed tuple,)+ ``args``, ``kwargs``).
+ That is, seed tuple is inserted before supplied positional arguments.
+ By default, a thread wrapping ``func`` and all those arguments is spawned.
+ """
+ def f(seed_tuple):
+ return func, (seed_tuple,)+args, kwargs
+ return f
+
+def create_run(cls, *args, **kwargs):
+ """
+ :return:
+ a delegator function that calls the ``cls`` constructor whose arguments being
+ a seed tuple followed by supplied ``*args`` and ``**kwargs``, then returns
+ the object's ``run`` method. By default, a thread wrapping that ``run`` method
+ is spawned.
+ """
+ def f(seed_tuple):
+ j = cls(seed_tuple, *args, **kwargs)
+ return j.run
+ return f
+
+def create_open(cls, *args, **kwargs):
+ """
+ :return:
+ a delegator function that calls the ``cls`` constructor whose arguments being
+ a seed tuple followed by supplied ``*args`` and ``**kwargs``, then returns
+ a looping function that uses the object's ``listener`` to wait for messages
+ and invokes instance method ``open``, ``on_message``, and ``on_close`` accordingly.
+ By default, a thread wrapping that looping function is spawned.
+ """
+ def f(seed_tuple):
+ j = cls(seed_tuple, *args, **kwargs)
+
+ def wait_loop():
+ bot, msg, seed = seed_tuple
+ try:
+ handled = j.open(msg, seed)
+ if not handled:
+ j.on_message(msg)
+
+ while 1:
+ msg = j.listener.wait()
+ j.on_message(msg)
+
+ # These exceptions are "normal" exits.
+ except (exception.IdleTerminate, exception.StopListening) as e:
+ j.on_close(e)
+
+ # Any other exceptions are accidents. **Print it out.**
+ # This is to prevent swallowing exceptions in the case that on_close()
+ # gets overridden but fails to account for unexpected exceptions.
+ except Exception as e:
+ traceback.print_exc()
+ j.on_close(e)
+
+ return wait_loop
+ return f
+
+def until(condition, fns):
+ """
+ Try a list of seeder functions until a condition is met.
+
+ :param condition:
+ a function that takes one argument - a seed - and returns ``True``
+ or ``False``
+
+ :param fns:
+ a list of seeder functions
+
+ :return:
+ a "composite" seeder function that calls each supplied function in turn,
+ and returns the first seed where the condition is met. If the condition
+ is never met, it returns ``None``.
+ """
+ def f(msg):
+ for fn in fns:
+ seed = fn(msg)
+ if condition(seed):
+ return seed
+ return None
+ return f
+
+def chain(*fns):
+ """
+ :return:
+ a "composite" seeder function that calls each supplied function in turn,
+ and returns the first seed that is not ``None``.
+ """
+ return until(lambda seed: seed is not None, fns)
+
+def _ensure_seeders_list(fn):
+ @wraps(fn)
+ def e(seeders, *aa, **kw):
+ return fn(seeders if isinstance(seeders, list) else [seeders], *aa, **kw)
+ return e
+
+@_ensure_seeders_list
+def pair(seeders, delegator_factory, *args, **kwargs):
+ """
+ The basic pair producer.
+
+ :return:
+ a (seeder, delegator_factory(\*args, \*\*kwargs)) tuple.
+
+ :param seeders:
+ If it is a seeder function or a list of one seeder function, it is returned
+ as the final seeder. If it is a list of more than one seeder function, they
+ are chained together before returned as the final seeder.
+ """
+ return (chain(*seeders) if len(seeders) > 1 else seeders[0],
+ delegator_factory(*args, **kwargs))
+
+def _natural_numbers():
+ x = 0
+ while 1:
+ x += 1
+ yield x
+
+_event_space = _natural_numbers()
+
+def pave_event_space(fn=pair):
+ """
+ :return:
+ a pair producer that ensures the seeder and delegator share the same event space.
+ """
+ global _event_space
+ event_space = next(_event_space)
+
+ @_ensure_seeders_list
+ def p(seeders, delegator_factory, *args, **kwargs):
+ return fn(seeders + [per_event_source_id(event_space)],
+ delegator_factory, *args, event_space=event_space, **kwargs)
+ return p
+
+def include_callback_query_chat_id(fn=pair, types='all'):
+ """
+ :return:
+ a pair producer that enables static callback query capturing
+ across seeder and delegator.
+
+ :param types:
+ ``all`` or a list of chat types (``private``, ``group``, ``channel``)
+ """
+ @_ensure_seeders_list
+ def p(seeders, delegator_factory, *args, **kwargs):
+ return fn(seeders + [per_callback_query_chat_id(types=types)],
+ delegator_factory, *args, include_callback_query=True, **kwargs)
+ return p
+
+from . import helper
+
+def intercept_callback_query_origin(fn=pair, origins='all'):
+ """
+ :return:
+ a pair producer that enables dynamic callback query origin mapping
+ across seeder and delegator.
+
+ :param origins:
+ ``all`` or a list of origin types (``chat``, ``inline``).
+ Origin mapping is only enabled for specified origin types.
+ """
+ origin_map = helper.SafeDict()
+
+ # For key functions that returns a tuple as key (e.g. per_callback_query_origin()),
+ # wrap the key in another tuple to prevent router from mistaking it as
+ # a key followed by some arguments.
+ def tuplize(fn):
+ def tp(msg):
+ return (fn(msg),)
+ return tp
+
+ router = helper.Router(tuplize(per_callback_query_origin(origins=origins)),
+ origin_map)
+
+ def modify_origin_map(origin, dest, set):
+ if set:
+ origin_map[origin] = dest
+ else:
+ try:
+ del origin_map[origin]
+ except KeyError:
+ pass
+
+ if origins == 'all':
+ intercept = modify_origin_map
+ else:
+ intercept = (modify_origin_map if 'chat' in origins else False,
+ modify_origin_map if 'inline' in origins else False)
+
+ @_ensure_seeders_list
+ def p(seeders, delegator_factory, *args, **kwargs):
+ return fn(seeders + [_wrap_none(router.map)],
+ delegator_factory, *args, intercept_callback_query=intercept, **kwargs)
+ return p
diff --git a/telepot/exception.py b/telepot/exception.py
new file mode 100644
index 0000000..820a522
--- /dev/null
+++ b/telepot/exception.py
@@ -0,0 +1,111 @@
+import sys
+
+class TelepotException(Exception):
+ """ Base class of following exceptions. """
+ pass
+
+class BadFlavor(TelepotException):
+ def __init__(self, offender):
+ super(BadFlavor, self).__init__(offender)
+
+ @property
+ def offender(self):
+ return self.args[0]
+
+PY_3 = sys.version_info.major >= 3
+
+class BadHTTPResponse(TelepotException):
+ """
+ All requests to Bot API should result in a JSON response. If non-JSON, this
+ exception is raised. While it is hard to pinpoint exactly when this might happen,
+ the following situations have been observed to give rise to it:
+
+ - an unreasonable token, e.g. ``abc``, ``123``, anything that does not even
+ remotely resemble a correct token.
+ - a bad gateway, e.g. when Telegram servers are down.
+ """
+
+ def __init__(self, status, text, response):
+ super(BadHTTPResponse, self).__init__(status, text, response)
+
+ @property
+ def status(self):
+ return self.args[0]
+
+ @property
+ def text(self):
+ return self.args[1]
+
+ @property
+ def response(self):
+ return self.args[2]
+
+class EventNotFound(TelepotException):
+ def __init__(self, event):
+ super(EventNotFound, self).__init__(event)
+
+ @property
+ def event(self):
+ return self.args[0]
+
+class WaitTooLong(TelepotException):
+ def __init__(self, seconds):
+ super(WaitTooLong, self).__init__(seconds)
+
+ @property
+ def seconds(self):
+ return self.args[0]
+
+class IdleTerminate(WaitTooLong):
+ pass
+
+class StopListening(TelepotException):
+ pass
+
+class TelegramError(TelepotException):
+ """
+ To indicate erroneous situations, Telegram returns a JSON object containing
+ an *error code* and a *description*. This will cause a ``TelegramError`` to
+ be raised. Before raising a generic ``TelegramError``, telepot looks for
+ a more specific subclass that "matches" the error. If such a class exists,
+ an exception of that specific subclass is raised. This allows you to either
+ catch specific errors or to cast a wide net (by a catch-all ``TelegramError``).
+ This also allows you to incorporate custom ``TelegramError`` easily.
+
+ Subclasses must define a class variable ``DESCRIPTION_PATTERNS`` which is a list
+ of regular expressions. If an error's *description* matches any of the regular expressions,
+ an exception of that subclass is raised.
+ """
+
+ def __init__(self, description, error_code, json):
+ super(TelegramError, self).__init__(description, error_code, json)
+
+ @property
+ def description(self):
+ return self.args[0]
+
+ @property
+ def error_code(self):
+ return self.args[1]
+
+ @property
+ def json(self):
+ return self.args[2]
+
+class UnauthorizedError(TelegramError):
+ DESCRIPTION_PATTERNS = ['unauthorized']
+
+class BotWasKickedError(TelegramError):
+ DESCRIPTION_PATTERNS = ['bot.*kicked']
+
+class BotWasBlockedError(TelegramError):
+ DESCRIPTION_PATTERNS = ['bot.*blocked']
+
+class TooManyRequestsError(TelegramError):
+ DESCRIPTION_PATTERNS = ['too *many *requests']
+
+class MigratedToSupergroupChatError(TelegramError):
+ DESCRIPTION_PATTERNS = ['migrated.*supergroup *chat']
+
+class NotEnoughRightsError(TelegramError):
+ DESCRIPTION_PATTERNS = ['not *enough *rights']
diff --git a/telepot/filtering.py b/telepot/filtering.py
new file mode 100644
index 0000000..52ed749
--- /dev/null
+++ b/telepot/filtering.py
@@ -0,0 +1,34 @@
+def pick(obj, keys):
+ def pick1(k):
+ if type(obj) is dict:
+ return obj[k]
+ else:
+ return getattr(obj, k)
+
+ if isinstance(keys, list):
+ return [pick1(k) for k in keys]
+ else:
+ return pick1(keys)
+
+def match(data, template):
+ if isinstance(template, dict) and isinstance(data, dict):
+ def pick_and_match(kv):
+ template_key, template_value = kv
+ if hasattr(template_key, 'search'): # regex
+ data_keys = list(filter(template_key.search, data.keys()))
+ if not data_keys:
+ return False
+ elif template_key in data:
+ data_keys = [template_key]
+ else:
+ return False
+ return any(map(lambda data_value: match(data_value, template_value), pick(data, data_keys)))
+
+ return all(map(pick_and_match, template.items()))
+ elif callable(template):
+ return template(data)
+ else:
+ return data == template
+
+def match_all(msg, templates):
+ return all(map(lambda t: match(msg, t), templates))
diff --git a/telepot/hack.py b/telepot/hack.py
new file mode 100644
index 0000000..029bd19
--- /dev/null
+++ b/telepot/hack.py
@@ -0,0 +1,16 @@
+try:
+ import urllib3.fields
+
+ # Do not encode unicode filename, so Telegram servers understand it.
+ def _noencode_filename(fn):
+ def w(name, value):
+ if name == 'filename':
+ return '%s="%s"' % (name, value)
+ else:
+ return fn(name, value)
+ return w
+
+ urllib3.fields.format_header_param = _noencode_filename(urllib3.fields.format_header_param)
+
+except (ImportError, AttributeError):
+ pass
diff --git a/telepot/helper.py b/telepot/helper.py
new file mode 100644
index 0000000..1b57b84
--- /dev/null
+++ b/telepot/helper.py
@@ -0,0 +1,1170 @@
+import time
+import traceback
+import threading
+import logging
+import collections
+import re
+import inspect
+from functools import partial
+from . import filtering, exception
+from . import (
+ flavor, chat_flavors, inline_flavors, is_event,
+ message_identifier, origin_identifier)
+
+try:
+ import Queue as queue
+except ImportError:
+ import queue
+
+
+class Microphone(object):
+ def __init__(self):
+ self._queues = set()
+ self._lock = threading.Lock()
+
+ def _locked(func):
+ def k(self, *args, **kwargs):
+ with self._lock:
+ return func(self, *args, **kwargs)
+ return k
+
+ @_locked
+ def add(self, q):
+ self._queues.add(q)
+
+ @_locked
+ def remove(self, q):
+ self._queues.remove(q)
+
+ @_locked
+ def send(self, msg):
+ for q in self._queues:
+ try:
+ q.put_nowait(msg)
+ except queue.Full:
+ traceback.print_exc()
+
+
+class Listener(object):
+ def __init__(self, mic, q):
+ self._mic = mic
+ self._queue = q
+ self._patterns = []
+
+ def __del__(self):
+ self._mic.remove(self._queue)
+
+ def capture(self, pattern):
+ """
+ Add a pattern to capture.
+
+ :param pattern: a list of templates.
+
+ A template may be a function that:
+ - takes one argument - a message
+ - returns ``True`` to indicate a match
+
+ A template may also be a dictionary whose:
+ - **keys** are used to *select* parts of message. Can be strings or
+ regular expressions (as obtained by ``re.compile()``)
+ - **values** are used to match against the selected parts. Can be
+ typical data or a function.
+
+ All templates must produce a match for a message to be considered a match.
+ """
+ self._patterns.append(pattern)
+
+ def wait(self):
+ """
+ Block until a matched message appears.
+ """
+ if not self._patterns:
+ raise RuntimeError('Listener has nothing to capture')
+
+ while 1:
+ msg = self._queue.get(block=True)
+
+ if any(map(lambda p: filtering.match_all(msg, p), self._patterns)):
+ return msg
+
+
+class Sender(object):
+ """
+ When you are dealing with a particular chat, it is tedious to have to supply
+ the same ``chat_id`` every time to send a message, or to send anything.
+
+ This object is a proxy to a bot's ``send*`` and ``forwardMessage`` methods,
+ automatically fills in a fixed chat id for you. Available methods have
+ identical signatures as those of the underlying bot, **except there is no need
+ to supply the aforementioned** ``chat_id``:
+
+ - :meth:`.Bot.sendMessage`
+ - :meth:`.Bot.forwardMessage`
+ - :meth:`.Bot.sendPhoto`
+ - :meth:`.Bot.sendAudio`
+ - :meth:`.Bot.sendDocument`
+ - :meth:`.Bot.sendSticker`
+ - :meth:`.Bot.sendVideo`
+ - :meth:`.Bot.sendVoice`
+ - :meth:`.Bot.sendVideoNote`
+ - :meth:`.Bot.sendMediaGroup`
+ - :meth:`.Bot.sendLocation`
+ - :meth:`.Bot.sendVenue`
+ - :meth:`.Bot.sendContact`
+ - :meth:`.Bot.sendGame`
+ - :meth:`.Bot.sendChatAction`
+ """
+
+ def __init__(self, bot, chat_id):
+ for method in ['sendMessage',
+ 'forwardMessage',
+ 'sendPhoto',
+ 'sendAudio',
+ 'sendDocument',
+ 'sendSticker',
+ 'sendVideo',
+ 'sendVoice',
+ 'sendVideoNote',
+ 'sendMediaGroup',
+ 'sendLocation',
+ 'sendVenue',
+ 'sendContact',
+ 'sendGame',
+ 'sendChatAction',]:
+ setattr(self, method, partial(getattr(bot, method), chat_id))
+ # Essentially doing:
+ # self.sendMessage = partial(bot.sendMessage, chat_id)
+
+
+class Administrator(object):
+ """
+ When you are dealing with a particular chat, it is tedious to have to supply
+ the same ``chat_id`` every time to get a chat's info or to perform administrative
+ tasks.
+
+ This object is a proxy to a bot's chat administration methods,
+ automatically fills in a fixed chat id for you. Available methods have
+ identical signatures as those of the underlying bot, **except there is no need
+ to supply the aforementioned** ``chat_id``:
+
+ - :meth:`.Bot.kickChatMember`
+ - :meth:`.Bot.unbanChatMember`
+ - :meth:`.Bot.restrictChatMember`
+ - :meth:`.Bot.promoteChatMember`
+ - :meth:`.Bot.exportChatInviteLink`
+ - :meth:`.Bot.setChatPhoto`
+ - :meth:`.Bot.deleteChatPhoto`
+ - :meth:`.Bot.setChatTitle`
+ - :meth:`.Bot.setChatDescription`
+ - :meth:`.Bot.pinChatMessage`
+ - :meth:`.Bot.unpinChatMessage`
+ - :meth:`.Bot.leaveChat`
+ - :meth:`.Bot.getChat`
+ - :meth:`.Bot.getChatAdministrators`
+ - :meth:`.Bot.getChatMembersCount`
+ - :meth:`.Bot.getChatMember`
+ - :meth:`.Bot.setChatStickerSet`
+ - :meth:`.Bot.deleteChatStickerSet`
+ """
+
+ def __init__(self, bot, chat_id):
+ for method in ['kickChatMember',
+ 'unbanChatMember',
+ 'restrictChatMember',
+ 'promoteChatMember',
+ 'exportChatInviteLink',
+ 'setChatPhoto',
+ 'deleteChatPhoto',
+ 'setChatTitle',
+ 'setChatDescription',
+ 'pinChatMessage',
+ 'unpinChatMessage',
+ 'leaveChat',
+ 'getChat',
+ 'getChatAdministrators',
+ 'getChatMembersCount',
+ 'getChatMember',
+ 'setChatStickerSet',
+ 'deleteChatStickerSet']:
+ setattr(self, method, partial(getattr(bot, method), chat_id))
+
+
+class Editor(object):
+ """
+ If you want to edit a message over and over, it is tedious to have to supply
+ the same ``msg_identifier`` every time.
+
+ This object is a proxy to a bot's message-editing methods, automatically fills
+ in a fixed message identifier for you. Available methods have
+ identical signatures as those of the underlying bot, **except there is no need
+ to supply the aforementioned** ``msg_identifier``:
+
+ - :meth:`.Bot.editMessageText`
+ - :meth:`.Bot.editMessageCaption`
+ - :meth:`.Bot.editMessageReplyMarkup`
+ - :meth:`.Bot.deleteMessage`
+ - :meth:`.Bot.editMessageLiveLocation`
+ - :meth:`.Bot.stopMessageLiveLocation`
+
+ A message's identifier can be easily extracted with :func:`telepot.message_identifier`.
+ """
+
+ def __init__(self, bot, msg_identifier):
+ """
+ :param msg_identifier:
+ a message identifier as mentioned above, or a message (whose
+ identifier will be automatically extracted).
+ """
+ # Accept dict as argument. Maybe expand this convenience to other cases in future.
+ if isinstance(msg_identifier, dict):
+ msg_identifier = message_identifier(msg_identifier)
+
+ for method in ['editMessageText',
+ 'editMessageCaption',
+ 'editMessageReplyMarkup',
+ 'deleteMessage',
+ 'editMessageLiveLocation',
+ 'stopMessageLiveLocation']:
+ setattr(self, method, partial(getattr(bot, method), msg_identifier))
+
+
+class Answerer(object):
+ """
+ When processing inline queries, ensure **at most one active thread** per user id.
+ """
+
+ def __init__(self, bot):
+ self._bot = bot
+ self._workers = {} # map: user id --> worker thread
+ self._lock = threading.Lock() # control access to `self._workers`
+
+ def answer(outerself, inline_query, compute_fn, *compute_args, **compute_kwargs):
+ """
+ Spawns a thread that calls ``compute fn`` (along with additional arguments
+ ``*compute_args`` and ``**compute_kwargs``), then applies the returned value to
+ :meth:`.Bot.answerInlineQuery` to answer the inline query.
+ If a preceding thread is already working for a user, that thread is cancelled,
+ thus ensuring at most one active thread per user id.
+
+ :param inline_query:
+ The inline query to be processed. The originating user is inferred from ``msg['from']['id']``.
+
+ :param compute_fn:
+ A **thread-safe** function whose returned value is given to :meth:`.Bot.answerInlineQuery` to send.
+ May return:
+
+ - a *list* of `InlineQueryResult <https://core.telegram.org/bots/api#inlinequeryresult>`_
+ - a *tuple* whose first element is a list of `InlineQueryResult <https://core.telegram.org/bots/api#inlinequeryresult>`_,
+ followed by positional arguments to be supplied to :meth:`.Bot.answerInlineQuery`
+ - a *dictionary* representing keyword arguments to be supplied to :meth:`.Bot.answerInlineQuery`
+
+ :param \*compute_args: positional arguments to ``compute_fn``
+ :param \*\*compute_kwargs: keyword arguments to ``compute_fn``
+ """
+
+ from_id = inline_query['from']['id']
+
+ class Worker(threading.Thread):
+ def __init__(innerself):
+ super(Worker, innerself).__init__()
+ innerself._cancelled = False
+
+ def cancel(innerself):
+ innerself._cancelled = True
+
+ def run(innerself):
+ try:
+ query_id = inline_query['id']
+
+ if innerself._cancelled:
+ return
+
+ # Important: compute function must be thread-safe.
+ ans = compute_fn(*compute_args, **compute_kwargs)
+
+ if innerself._cancelled:
+ return
+
+ if isinstance(ans, list):
+ outerself._bot.answerInlineQuery(query_id, ans)
+ elif isinstance(ans, tuple):
+ outerself._bot.answerInlineQuery(query_id, *ans)
+ elif isinstance(ans, dict):
+ outerself._bot.answerInlineQuery(query_id, **ans)
+ else:
+ raise ValueError('Invalid answer format')
+ finally:
+ with outerself._lock:
+ # Delete only if I have NOT been cancelled.
+ if not innerself._cancelled:
+ del outerself._workers[from_id]
+
+ # If I have been cancelled, that position in `outerself._workers`
+ # no longer belongs to me. I should not delete that key.
+
+ # Several threads may access `outerself._workers`. Use `outerself._lock` to protect.
+ with outerself._lock:
+ if from_id in outerself._workers:
+ outerself._workers[from_id].cancel()
+
+ outerself._workers[from_id] = Worker()
+ outerself._workers[from_id].start()
+
+
+class AnswererMixin(object):
+ """
+ Install an :class:`.Answerer` to handle inline query.
+ """
+ Answerer = Answerer # let subclass customize Answerer class
+
+ def __init__(self, *args, **kwargs):
+ self._answerer = self.Answerer(self.bot)
+ super(AnswererMixin, self).__init__(*args, **kwargs)
+
+ @property
+ def answerer(self):
+ return self._answerer
+
+
+class CallbackQueryCoordinator(object):
+ def __init__(self, id, origin_set, enable_chat, enable_inline):
+ """
+ :param origin_set:
+ Callback query whose origin belongs to this set will be captured
+
+ :param enable_chat:
+ - ``False``: Do not intercept *chat-originated* callback query
+ - ``True``: Do intercept
+ - Notifier function: Do intercept and call the notifier function
+ on adding or removing an origin
+
+ :param enable_inline:
+ Same meaning as ``enable_chat``, but apply to *inline-originated*
+ callback query
+
+ Notifier functions should have the signature ``notifier(origin, id, adding)``:
+
+ - On adding an origin, ``notifier(origin, my_id, True)`` will be called.
+ - On removing an origin, ``notifier(origin, my_id, False)`` will be called.
+ """
+ self._id = id
+ self._origin_set = origin_set
+
+ def dissolve(enable):
+ if not enable:
+ return False, None
+ elif enable is True:
+ return True, None
+ elif callable(enable):
+ return True, enable
+ else:
+ raise ValueError()
+
+ self._enable_chat, self._chat_notify = dissolve(enable_chat)
+ self._enable_inline, self._inline_notify = dissolve(enable_inline)
+
+ def configure(self, listener):
+ """
+ Configure a :class:`.Listener` to capture callback query
+ """
+ listener.capture([
+ lambda msg: flavor(msg) == 'callback_query',
+ {'message': self._chat_origin_included}
+ ])
+
+ listener.capture([
+ lambda msg: flavor(msg) == 'callback_query',
+ {'inline_message_id': self._inline_origin_included}
+ ])
+
+ def _chat_origin_included(self, msg):
+ try:
+ return (msg['chat']['id'], msg['message_id']) in self._origin_set
+ except KeyError:
+ return False
+
+ def _inline_origin_included(self, inline_message_id):
+ return (inline_message_id,) in self._origin_set
+
+ def _rectify(self, msg_identifier):
+ if isinstance(msg_identifier, tuple):
+ if len(msg_identifier) == 2:
+ return msg_identifier, self._chat_notify
+ elif len(msg_identifier) == 1:
+ return msg_identifier, self._inline_notify
+ else:
+ raise ValueError()
+ else:
+ return (msg_identifier,), self._inline_notify
+
+ def capture_origin(self, msg_identifier, notify=True):
+ msg_identifier, notifier = self._rectify(msg_identifier)
+ self._origin_set.add(msg_identifier)
+ notify and notifier and notifier(msg_identifier, self._id, True)
+
+ def uncapture_origin(self, msg_identifier, notify=True):
+ msg_identifier, notifier = self._rectify(msg_identifier)
+ self._origin_set.discard(msg_identifier)
+ notify and notifier and notifier(msg_identifier, self._id, False)
+
+ def _contains_callback_data(self, message_kw):
+ def contains(obj, key):
+ if isinstance(obj, dict):
+ return key in obj
+ else:
+ return hasattr(obj, key)
+
+ if contains(message_kw, 'reply_markup'):
+ reply_markup = filtering.pick(message_kw, 'reply_markup')
+ if contains(reply_markup, 'inline_keyboard'):
+ inline_keyboard = filtering.pick(reply_markup, 'inline_keyboard')
+ for array in inline_keyboard:
+ if any(filter(lambda button: contains(button, 'callback_data'), array)):
+ return True
+ return False
+
+ def augment_send(self, send_func):
+ """
+ :param send_func:
+ a function that sends messages, such as :meth:`.Bot.send\*`
+
+ :return:
+ a function that wraps around ``send_func`` and examines whether the
+ sent message contains an inline keyboard with callback data. If so,
+ future callback query originating from the sent message will be captured.
+ """
+ def augmented(*aa, **kw):
+ sent = send_func(*aa, **kw)
+
+ if self._enable_chat and self._contains_callback_data(kw):
+ self.capture_origin(message_identifier(sent))
+
+ return sent
+ return augmented
+
+ def augment_edit(self, edit_func):
+ """
+ :param edit_func:
+ a function that edits messages, such as :meth:`.Bot.edit*`
+
+ :return:
+ a function that wraps around ``edit_func`` and examines whether the
+ edited message contains an inline keyboard with callback data. If so,
+ future callback query originating from the edited message will be captured.
+ If not, such capturing will be stopped.
+ """
+ def augmented(msg_identifier, *aa, **kw):
+ edited = edit_func(msg_identifier, *aa, **kw)
+
+ if (edited is True and self._enable_inline) or (isinstance(edited, dict) and self._enable_chat):
+ if self._contains_callback_data(kw):
+ self.capture_origin(msg_identifier)
+ else:
+ self.uncapture_origin(msg_identifier)
+
+ return edited
+ return augmented
+
+ def augment_delete(self, delete_func):
+ """
+ :param delete_func:
+ a function that deletes messages, such as :meth:`.Bot.deleteMessage`
+
+ :return:
+ a function that wraps around ``delete_func`` and stops capturing
+ callback query originating from that deleted message.
+ """
+ def augmented(msg_identifier, *aa, **kw):
+ deleted = delete_func(msg_identifier, *aa, **kw)
+
+ if deleted is True:
+ self.uncapture_origin(msg_identifier)
+
+ return deleted
+ return augmented
+
+ def augment_on_message(self, handler):
+ """
+ :param handler:
+ an ``on_message()`` handler function
+
+ :return:
+ a function that wraps around ``handler`` and examines whether the
+ incoming message is a chosen inline result with an ``inline_message_id``
+ field. If so, future callback query originating from this chosen
+ inline result will be captured.
+ """
+ def augmented(msg):
+ if (self._enable_inline
+ and flavor(msg) == 'chosen_inline_result'
+ and 'inline_message_id' in msg):
+ inline_message_id = msg['inline_message_id']
+ self.capture_origin(inline_message_id)
+
+ return handler(msg)
+ return augmented
+
+ def augment_bot(self, bot):
+ """
+ :return:
+ a proxy to ``bot`` with these modifications:
+
+ - all ``send*`` methods augmented by :meth:`augment_send`
+ - all ``edit*`` methods augmented by :meth:`augment_edit`
+ - ``deleteMessage()`` augmented by :meth:`augment_delete`
+ - all other public methods, including properties, copied unchanged
+ """
+ # Because a plain object cannot be set attributes, we need a class.
+ class BotProxy(object):
+ pass
+
+ proxy = BotProxy()
+
+ send_methods = ['sendMessage',
+ 'forwardMessage',
+ 'sendPhoto',
+ 'sendAudio',
+ 'sendDocument',
+ 'sendSticker',
+ 'sendVideo',
+ 'sendVoice',
+ 'sendVideoNote',
+ 'sendLocation',
+ 'sendVenue',
+ 'sendContact',
+ 'sendGame',
+ 'sendInvoice',
+ 'sendChatAction',]
+
+ for method in send_methods:
+ setattr(proxy, method, self.augment_send(getattr(bot, method)))
+
+ edit_methods = ['editMessageText',
+ 'editMessageCaption',
+ 'editMessageReplyMarkup',]
+
+ for method in edit_methods:
+ setattr(proxy, method, self.augment_edit(getattr(bot, method)))
+
+ delete_methods = ['deleteMessage']
+
+ for method in delete_methods:
+ setattr(proxy, method, self.augment_delete(getattr(bot, method)))
+
+ def public_untouched(nv):
+ name, value = nv
+ return (not name.startswith('_')
+ and name not in send_methods + edit_methods + delete_methods)
+
+ for name, value in filter(public_untouched, inspect.getmembers(bot)):
+ setattr(proxy, name, value)
+
+ return proxy
+
+
+class SafeDict(dict):
+ """
+ A subclass of ``dict``, thread-safety added::
+
+ d = SafeDict() # Thread-safe operations include:
+ d['a'] = 3 # key assignment
+ d['a'] # key retrieval
+ del d['a'] # key deletion
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(SafeDict, self).__init__(*args, **kwargs)
+ self._lock = threading.Lock()
+
+ def _locked(func):
+ def k(self, *args, **kwargs):
+ with self._lock:
+ return func(self, *args, **kwargs)
+ return k
+
+ @_locked
+ def __getitem__(self, key):
+ return super(SafeDict, self).__getitem__(key)
+
+ @_locked
+ def __setitem__(self, key, value):
+ return super(SafeDict, self).__setitem__(key, value)
+
+ @_locked
+ def __delitem__(self, key):
+ return super(SafeDict, self).__delitem__(key)
+
+
+_cqc_origins = SafeDict()
+
+class InterceptCallbackQueryMixin(object):
+ """
+ Install a :class:`.CallbackQueryCoordinator` to capture callback query
+ dynamically.
+
+ Using this mixin has one consequence. The :meth:`self.bot` property no longer
+ returns the original :class:`.Bot` object. Instead, it returns an augmented
+ version of the :class:`.Bot` (augmented by :class:`.CallbackQueryCoordinator`).
+ The original :class:`.Bot` can be accessed with ``self.__bot`` (double underscore).
+ """
+ CallbackQueryCoordinator = CallbackQueryCoordinator
+
+ def __init__(self, intercept_callback_query, *args, **kwargs):
+ """
+ :param intercept_callback_query:
+ a 2-tuple (enable_chat, enable_inline) to pass to
+ :class:`.CallbackQueryCoordinator`
+ """
+ global _cqc_origins
+
+ # Restore origin set to CallbackQueryCoordinator
+ if self.id in _cqc_origins:
+ origin_set = _cqc_origins[self.id]
+ else:
+ origin_set = set()
+ _cqc_origins[self.id] = origin_set
+
+ if isinstance(intercept_callback_query, tuple):
+ cqc_enable = intercept_callback_query
+ else:
+ cqc_enable = (intercept_callback_query,) * 2
+
+ self._callback_query_coordinator = self.CallbackQueryCoordinator(self.id, origin_set, *cqc_enable)
+ cqc = self._callback_query_coordinator
+ cqc.configure(self.listener)
+
+ self.__bot = self._bot # keep original version of bot
+ self._bot = cqc.augment_bot(self._bot) # modify send* and edit* methods
+ self.on_message = cqc.augment_on_message(self.on_message) # modify on_message()
+
+ super(InterceptCallbackQueryMixin, self).__init__(*args, **kwargs)
+
+ def __del__(self):
+ global _cqc_origins
+ if self.id in _cqc_origins and not _cqc_origins[self.id]:
+ del _cqc_origins[self.id]
+ # Remove empty set from dictionary
+
+ @property
+ def callback_query_coordinator(self):
+ return self._callback_query_coordinator
+
+
+class IdleEventCoordinator(object):
+ def __init__(self, scheduler, timeout):
+ self._scheduler = scheduler
+ self._timeout_seconds = timeout
+ self._timeout_event = None
+
+ def refresh(self):
+ """ Refresh timeout timer """
+ try:
+ if self._timeout_event:
+ self._scheduler.cancel(self._timeout_event)
+
+ # Timeout event has been popped from queue prematurely
+ except exception.EventNotFound:
+ pass
+
+ # Ensure a new event is scheduled always
+ finally:
+ self._timeout_event = self._scheduler.event_later(
+ self._timeout_seconds,
+ ('_idle', {'seconds': self._timeout_seconds}))
+
+ def augment_on_message(self, handler):
+ """
+ :return:
+ a function wrapping ``handler`` to refresh timer for every
+ non-event message
+ """
+ def augmented(msg):
+ # Reset timer if this is an external message
+ is_event(msg) or self.refresh()
+
+ # Ignore timeout event that have been popped from queue prematurely
+ if flavor(msg) == '_idle' and msg is not self._timeout_event.data:
+ return
+
+ return handler(msg)
+ return augmented
+
+ def augment_on_close(self, handler):
+ """
+ :return:
+ a function wrapping ``handler`` to cancel timeout event
+ """
+ def augmented(ex):
+ try:
+ if self._timeout_event:
+ self._scheduler.cancel(self._timeout_event)
+ self._timeout_event = None
+ # This closing may have been caused by my own timeout, in which case
+ # the timeout event can no longer be found in the scheduler.
+ except exception.EventNotFound:
+ self._timeout_event = None
+ return handler(ex)
+ return augmented
+
+
+class IdleTerminateMixin(object):
+ """
+ Install an :class:`.IdleEventCoordinator` to manage idle timeout. Also define
+ instance method ``on__idle()`` to handle idle timeout events.
+ """
+ IdleEventCoordinator = IdleEventCoordinator
+
+ def __init__(self, timeout, *args, **kwargs):
+ self._idle_event_coordinator = self.IdleEventCoordinator(self.scheduler, timeout)
+ idlec = self._idle_event_coordinator
+ idlec.refresh() # start timer
+ self.on_message = idlec.augment_on_message(self.on_message)
+ self.on_close = idlec.augment_on_close(self.on_close)
+ super(IdleTerminateMixin, self).__init__(*args, **kwargs)
+
+ @property
+ def idle_event_coordinator(self):
+ return self._idle_event_coordinator
+
+ def on__idle(self, event):
+ """
+ Raise an :class:`.IdleTerminate` to close the delegate.
+ """
+ raise exception.IdleTerminate(event['_idle']['seconds'])
+
+
+class StandardEventScheduler(object):
+ """
+ A proxy to the underlying :class:`.Bot`\'s scheduler, this object implements
+ the *standard event format*. A standard event looks like this::
+
+ {'_flavor': {
+ 'source': {
+ 'space': event_space, 'id': source_id}
+ 'custom_key1': custom_value1,
+ 'custom_key2': custom_value2,
+ ... }}
+
+ - There is a single top-level key indicating the flavor, starting with an _underscore.
+ - On the second level, there is a ``source`` key indicating the event source.
+ - An event source consists of an *event space* and a *source id*.
+ - An event space is shared by all delegates in a group. Source id simply refers
+ to a delegate's id. They combine to ensure a delegate is always able to capture
+ its own events, while its own events would not be mistakenly captured by others.
+
+ Events scheduled through this object always have the second-level ``source`` key fixed,
+ while the flavor and other data may be customized.
+ """
+ def __init__(self, scheduler, event_space, source_id):
+ self._base = scheduler
+ self._event_space = event_space
+ self._source_id = source_id
+
+ @property
+ def event_space(self):
+ return self._event_space
+
+ def configure(self, listener):
+ """
+ Configure a :class:`.Listener` to capture events with this object's
+ event space and source id.
+ """
+ listener.capture([{re.compile('^_.+'): {'source': {'space': self._event_space, 'id': self._source_id}}}])
+
+ def make_event_data(self, flavor, data):
+ """
+ Marshall ``flavor`` and ``data`` into a standard event.
+ """
+ if not flavor.startswith('_'):
+ raise ValueError('Event flavor must start with _underscore')
+
+ d = {'source': {'space': self._event_space, 'id': self._source_id}}
+ d.update(data)
+ return {flavor: d}
+
+ def event_at(self, when, data_tuple):
+ """
+ Schedule an event to be emitted at a certain time.
+
+ :param when: an absolute timestamp
+ :param data_tuple: a 2-tuple (flavor, data)
+ :return: an event object, useful for cancelling.
+ """
+ return self._base.event_at(when, self.make_event_data(*data_tuple))
+
+ def event_later(self, delay, data_tuple):
+ """
+ Schedule an event to be emitted after a delay.
+
+ :param delay: number of seconds
+ :param data_tuple: a 2-tuple (flavor, data)
+ :return: an event object, useful for cancelling.
+ """
+ return self._base.event_later(delay, self.make_event_data(*data_tuple))
+
+ def event_now(self, data_tuple):
+ """
+ Schedule an event to be emitted now.
+
+ :param data_tuple: a 2-tuple (flavor, data)
+ :return: an event object, useful for cancelling.
+ """
+ return self._base.event_now(self.make_event_data(*data_tuple))
+
+ def cancel(self, event):
+ """ Cancel an event. """
+ return self._base.cancel(event)
+
+
+class StandardEventMixin(object):
+ """
+ Install a :class:`.StandardEventScheduler`.
+ """
+ StandardEventScheduler = StandardEventScheduler
+
+ def __init__(self, event_space, *args, **kwargs):
+ self._scheduler = self.StandardEventScheduler(self.bot.scheduler, event_space, self.id)
+ self._scheduler.configure(self.listener)
+ super(StandardEventMixin, self).__init__(*args, **kwargs)
+
+ @property
+ def scheduler(self):
+ return self._scheduler
+
+
+class ListenerContext(object):
+ def __init__(self, bot, context_id, *args, **kwargs):
+ # Initialize members before super() so mixin could use them.
+ self._bot = bot
+ self._id = context_id
+ self._listener = bot.create_listener()
+ super(ListenerContext, self).__init__(*args, **kwargs)
+
+ @property
+ def bot(self):
+ """
+ The underlying :class:`.Bot` or an augmented version thereof
+ """
+ return self._bot
+
+ @property
+ def id(self):
+ return self._id
+
+ @property
+ def listener(self):
+ """ See :class:`.Listener` """
+ return self._listener
+
+
+class ChatContext(ListenerContext):
+ def __init__(self, bot, context_id, *args, **kwargs):
+ super(ChatContext, self).__init__(bot, context_id, *args, **kwargs)
+ self._chat_id = context_id
+ self._sender = Sender(self.bot, self._chat_id)
+ self._administrator = Administrator(self.bot, self._chat_id)
+
+ @property
+ def chat_id(self):
+ return self._chat_id
+
+ @property
+ def sender(self):
+ """ A :class:`.Sender` for this chat """
+ return self._sender
+
+ @property
+ def administrator(self):
+ """ An :class:`.Administrator` for this chat """
+ return self._administrator
+
+
+class UserContext(ListenerContext):
+ def __init__(self, bot, context_id, *args, **kwargs):
+ super(UserContext, self).__init__(bot, context_id, *args, **kwargs)
+ self._user_id = context_id
+ self._sender = Sender(self.bot, self._user_id)
+
+ @property
+ def user_id(self):
+ return self._user_id
+
+ @property
+ def sender(self):
+ """ A :class:`.Sender` for this user """
+ return self._sender
+
+
+class CallbackQueryOriginContext(ListenerContext):
+ def __init__(self, bot, context_id, *args, **kwargs):
+ super(CallbackQueryOriginContext, self).__init__(bot, context_id, *args, **kwargs)
+ self._origin = context_id
+ self._editor = Editor(self.bot, self._origin)
+
+ @property
+ def origin(self):
+ """ Mesasge identifier of callback query's origin """
+ return self._origin
+
+ @property
+ def editor(self):
+ """ An :class:`.Editor` to the originating message """
+ return self._editor
+
+
+class InvoiceContext(ListenerContext):
+ def __init__(self, bot, context_id, *args, **kwargs):
+ super(InvoiceContext, self).__init__(bot, context_id, *args, **kwargs)
+ self._payload = context_id
+
+ @property
+ def payload(self):
+ return self._payload
+
+
+def openable(cls):
+ """
+ A class decorator to fill in certain methods and properties to ensure
+ a class can be used by :func:`.create_open`.
+
+ These instance methods and property will be added, if not defined
+ by the class:
+
+ - ``open(self, initial_msg, seed)``
+ - ``on_message(self, msg)``
+ - ``on_close(self, ex)``
+ - ``close(self, ex=None)``
+ - property ``listener``
+ """
+
+ def open(self, initial_msg, seed):
+ pass
+
+ def on_message(self, msg):
+ raise NotImplementedError()
+
+ def on_close(self, ex):
+ logging.error('on_close() called due to %s: %s', type(ex).__name__, ex)
+
+ def close(self, ex=None):
+ raise ex if ex else exception.StopListening()
+
+ @property
+ def listener(self):
+ raise NotImplementedError()
+
+ def ensure_method(name, fn):
+ if getattr(cls, name, None) is None:
+ setattr(cls, name, fn)
+
+ # set attribute if no such attribute
+ ensure_method('open', open)
+ ensure_method('on_message', on_message)
+ ensure_method('on_close', on_close)
+ ensure_method('close', close)
+ ensure_method('listener', listener)
+
+ return cls
+
+
+class Router(object):
+ """
+ Map a message to a handler function, using a **key function** and
+ a **routing table** (dictionary).
+
+ A *key function* digests a message down to a value. This value is treated
+ as a key to the *routing table* to look up a corresponding handler function.
+ """
+
+ def __init__(self, key_function, routing_table):
+ """
+ :param key_function:
+ A function that takes one argument (the message) and returns
+ one of the following:
+
+ - a key to the routing table
+ - a 1-tuple (key,)
+ - a 2-tuple (key, (positional, arguments, ...))
+ - a 3-tuple (key, (positional, arguments, ...), {keyword: arguments, ...})
+
+ Extra arguments, if returned, will be applied to the handler function
+ after using the key to look up the routing table.
+
+ :param routing_table:
+ A dictionary of ``{key: handler}``. A ``None`` key acts as a default
+ catch-all. If the key being looked up does not exist in the routing
+ table, the ``None`` key and its corresponding handler is used.
+ """
+ super(Router, self).__init__()
+ self.key_function = key_function
+ self.routing_table = routing_table
+
+ def map(self, msg):
+ """
+ Apply key function to ``msg`` to obtain a key. Return the routing table entry.
+ """
+ k = self.key_function(msg)
+ key = k[0] if isinstance(k, (tuple, list)) else k
+ return self.routing_table[key]
+
+ def route(self, msg, *aa, **kw):
+ """
+ Apply key function to ``msg`` to obtain a key, look up routing table
+ to obtain a handler function, then call the handler function with
+ positional and keyword arguments, if any is returned by the key function.
+
+ ``*aa`` and ``**kw`` are dummy placeholders for easy chaining.
+ Regardless of any number of arguments returned by the key function,
+ multi-level routing may be achieved like this::
+
+ top_router.routing_table['key1'] = sub_router1.route
+ top_router.routing_table['key2'] = sub_router2.route
+ """
+ k = self.key_function(msg)
+
+ if isinstance(k, (tuple, list)):
+ key, args, kwargs = {1: tuple(k) + ((),{}),
+ 2: tuple(k) + ({},),
+ 3: tuple(k),}[len(k)]
+ else:
+ key, args, kwargs = k, (), {}
+
+ try:
+ fn = self.routing_table[key]
+ except KeyError as e:
+ # Check for default handler, key=None
+ if None in self.routing_table:
+ fn = self.routing_table[None]
+ else:
+ raise RuntimeError('No handler for key: %s, and default handler not defined' % str(e.args))
+
+ return fn(msg, *args, **kwargs)
+
+
+class DefaultRouterMixin(object):
+ """
+ Install a default :class:`.Router` and the instance method ``on_message()``.
+ """
+ def __init__(self, *args, **kwargs):
+ self._router = Router(flavor, {'chat': lambda msg: self.on_chat_message(msg),
+ 'callback_query': lambda msg: self.on_callback_query(msg),
+ 'inline_query': lambda msg: self.on_inline_query(msg),
+ 'chosen_inline_result': lambda msg: self.on_chosen_inline_result(msg),
+ 'shipping_query': lambda msg: self.on_shipping_query(msg),
+ 'pre_checkout_query': lambda msg: self.on_pre_checkout_query(msg),
+ '_idle': lambda event: self.on__idle(event)})
+ # use lambda to delay evaluation of self.on_ZZZ to runtime because
+ # I don't want to require defining all methods right here.
+
+ super(DefaultRouterMixin, self).__init__(*args, **kwargs)
+
+ @property
+ def router(self):
+ return self._router
+
+ def on_message(self, msg):
+ """ Call :meth:`.Router.route` to handle the message. """
+ self._router.route(msg)
+
+
+@openable
+class Monitor(ListenerContext, DefaultRouterMixin):
+ def __init__(self, seed_tuple, capture, **kwargs):
+ """
+ A delegate that never times-out, probably doing some kind of background monitoring
+ in the application. Most naturally paired with :func:`.per_application`.
+
+ :param capture: a list of patterns for :class:`.Listener` to capture
+ """
+ bot, initial_msg, seed = seed_tuple
+ super(Monitor, self).__init__(bot, seed, **kwargs)
+
+ for pattern in capture:
+ self.listener.capture(pattern)
+
+
+@openable
+class ChatHandler(ChatContext,
+ DefaultRouterMixin,
+ StandardEventMixin,
+ IdleTerminateMixin):
+ def __init__(self, seed_tuple,
+ include_callback_query=False, **kwargs):
+ """
+ A delegate to handle a chat.
+ """
+ bot, initial_msg, seed = seed_tuple
+ super(ChatHandler, self).__init__(bot, seed, **kwargs)
+
+ self.listener.capture([{'chat': {'id': self.chat_id}}])
+
+ if include_callback_query:
+ self.listener.capture([{'message': {'chat': {'id': self.chat_id}}}])
+
+
+@openable
+class UserHandler(UserContext,
+ DefaultRouterMixin,
+ StandardEventMixin,
+ IdleTerminateMixin):
+ def __init__(self, seed_tuple,
+ include_callback_query=False,
+ flavors=chat_flavors+inline_flavors, **kwargs):
+ """
+ A delegate to handle a user's actions.
+
+ :param flavors:
+ A list of flavors to capture. ``all`` covers all flavors.
+ """
+ bot, initial_msg, seed = seed_tuple
+ super(UserHandler, self).__init__(bot, seed, **kwargs)
+
+ if flavors == 'all':
+ self.listener.capture([{'from': {'id': self.user_id}}])
+ else:
+ self.listener.capture([lambda msg: flavor(msg) in flavors, {'from': {'id': self.user_id}}])
+
+ if include_callback_query:
+ self.listener.capture([{'message': {'chat': {'id': self.user_id}}}])
+
+
+class InlineUserHandler(UserHandler):
+ def __init__(self, seed_tuple, **kwargs):
+ """
+ A delegate to handle a user's inline-related actions.
+ """
+ super(InlineUserHandler, self).__init__(seed_tuple, flavors=inline_flavors, **kwargs)
+
+
+@openable
+class CallbackQueryOriginHandler(CallbackQueryOriginContext,
+ DefaultRouterMixin,
+ StandardEventMixin,
+ IdleTerminateMixin):
+ def __init__(self, seed_tuple, **kwargs):
+ """
+ A delegate to handle callback query from one origin.
+ """
+ bot, initial_msg, seed = seed_tuple
+ super(CallbackQueryOriginHandler, self).__init__(bot, seed, **kwargs)
+
+ self.listener.capture([
+ lambda msg:
+ flavor(msg) == 'callback_query' and origin_identifier(msg) == self.origin
+ ])
+
+
+@openable
+class InvoiceHandler(InvoiceContext,
+ DefaultRouterMixin,
+ StandardEventMixin,
+ IdleTerminateMixin):
+ def __init__(self, seed_tuple, **kwargs):
+ """
+ A delegate to handle messages related to an invoice.
+ """
+ bot, initial_msg, seed = seed_tuple
+ super(InvoiceHandler, self).__init__(bot, seed, **kwargs)
+
+ self.listener.capture([{'invoice_payload': self.payload}])
+ self.listener.capture([{'successful_payment': {'invoice_payload': self.payload}}])
diff --git a/telepot/loop.py b/telepot/loop.py
new file mode 100644
index 0000000..4b1331b
--- /dev/null
+++ b/telepot/loop.py
@@ -0,0 +1,313 @@
+import sys
+import time
+import json
+import threading
+import traceback
+import collections
+
+try:
+ import Queue as queue
+except ImportError:
+ import queue
+
+from . import exception
+from . import _find_first_key, flavor_router
+
+
+class RunForeverAsThread(object):
+ def run_as_thread(self, *args, **kwargs):
+ t = threading.Thread(target=self.run_forever, args=args, kwargs=kwargs)
+ t.daemon = True
+ t.start()
+
+
+class CollectLoop(RunForeverAsThread):
+ def __init__(self, handle):
+ self._handle = handle
+ self._inqueue = queue.Queue()
+
+ @property
+ def input_queue(self):
+ return self._inqueue
+
+ def run_forever(self):
+ while 1:
+ try:
+ msg = self._inqueue.get(block=True)
+ self._handle(msg)
+ except:
+ traceback.print_exc()
+
+
+class GetUpdatesLoop(RunForeverAsThread):
+ def __init__(self, bot, on_update):
+ self._bot = bot
+ self._update_handler = on_update
+
+ def run_forever(self, relax=0.1, offset=None, timeout=20, allowed_updates=None):
+ """
+ Process new updates in infinity loop
+
+ :param relax: float
+ :param offset: int
+ :param timeout: int
+ :param allowed_updates: bool
+ """
+ while 1:
+ try:
+ result = self._bot.getUpdates(offset=offset,
+ timeout=timeout,
+ allowed_updates=allowed_updates)
+
+ # Once passed, this parameter is no longer needed.
+ allowed_updates = None
+
+ # No sort. Trust server to give messages in correct order.
+ for update in result:
+ self._update_handler(update)
+ offset = update['update_id'] + 1
+
+ except exception.BadHTTPResponse as e:
+ traceback.print_exc()
+
+ # Servers probably down. Wait longer.
+ if e.status == 502:
+ time.sleep(30)
+ except:
+ traceback.print_exc()
+ finally:
+ time.sleep(relax)
+
+
+def _dictify3(data):
+ if type(data) is bytes:
+ return json.loads(data.decode('utf-8'))
+ elif type(data) is str:
+ return json.loads(data)
+ elif type(data) is dict:
+ return data
+ else:
+ raise ValueError()
+
+def _dictify27(data):
+ if type(data) in [str, unicode]:
+ return json.loads(data)
+ elif type(data) is dict:
+ return data
+ else:
+ raise ValueError()
+
+_dictify = _dictify3 if sys.version_info >= (3,) else _dictify27
+
+def _extract_message(update):
+ key = _find_first_key(update, ['message',
+ 'edited_message',
+ 'channel_post',
+ 'edited_channel_post',
+ 'callback_query',
+ 'inline_query',
+ 'chosen_inline_result',
+ 'shipping_query',
+ 'pre_checkout_query'])
+ return key, update[key]
+
+def _infer_handler_function(bot, h):
+ if h is None:
+ return bot.handle
+ elif isinstance(h, dict):
+ return flavor_router(h)
+ else:
+ return h
+
+
+class MessageLoop(RunForeverAsThread):
+ def __init__(self, bot, handle=None):
+ self._bot = bot
+ self._handle = _infer_handler_function(bot, handle)
+
+ def run_forever(self, *args, **kwargs):
+ """
+ :type relax: float
+ :param relax: seconds between each :meth:`.getUpdates`
+
+ :type offset: int
+ :param offset:
+ initial ``offset`` parameter supplied to :meth:`.getUpdates`
+
+ :type timeout: int
+ :param timeout:
+ ``timeout`` parameter supplied to :meth:`.getUpdates`, controlling
+ how long to poll.
+
+ :type allowed_updates: array of string
+ :param allowed_updates:
+ ``allowed_updates`` parameter supplied to :meth:`.getUpdates`,
+ controlling which types of updates to receive.
+
+ Calling this method will block forever. Use :meth:`.run_as_thread` to
+ run it non-blockingly.
+ """
+ collectloop = CollectLoop(self._handle)
+ updatesloop = GetUpdatesLoop(self._bot,
+ lambda update:
+ collectloop.input_queue.put(_extract_message(update)[1]))
+ # feed messages to collect loop
+ # feed events to collect loop
+ self._bot.scheduler.on_event(collectloop.input_queue.put)
+ self._bot.scheduler.run_as_thread()
+
+ updatesloop.run_as_thread(*args, **kwargs)
+ collectloop.run_forever() # blocking
+
+
+class Webhook(RunForeverAsThread):
+ def __init__(self, bot, handle=None):
+ self._bot = bot
+ self._collectloop = CollectLoop(_infer_handler_function(bot, handle))
+
+ def run_forever(self):
+ # feed events to collect loop
+ self._bot.scheduler.on_event(self._collectloop.input_queue.put)
+ self._bot.scheduler.run_as_thread()
+
+ self._collectloop.run_forever()
+
+ def feed(self, data):
+ update = _dictify(data)
+ self._collectloop.input_queue.put(_extract_message(update)[1])
+
+
+class Orderer(RunForeverAsThread):
+ def __init__(self, on_ordered_update):
+ self._on_ordered_update = on_ordered_update
+ self._inqueue = queue.Queue()
+
+ @property
+ def input_queue(self):
+ return self._inqueue
+
+ def run_forever(self, maxhold=3):
+ def handle(update):
+ self._on_ordered_update(update)
+ return update['update_id']
+
+ # Here is the re-ordering mechanism, ensuring in-order delivery of updates.
+ max_id = None # max update_id passed to callback
+ buffer = collections.deque() # keep those updates which skip some update_id
+ qwait = None # how long to wait for updates,
+ # because buffer's content has to be returned in time.
+
+ while 1:
+ try:
+ update = self._inqueue.get(block=True, timeout=qwait)
+
+ if max_id is None:
+ # First message received, handle regardless.
+ max_id = handle(update)
+
+ elif update['update_id'] == max_id + 1:
+ # No update_id skipped, handle naturally.
+ max_id = handle(update)
+
+ # clear contagious updates in buffer
+ if len(buffer) > 0:
+ buffer.popleft() # first element belongs to update just received, useless now.
+ while 1:
+ try:
+ if type(buffer[0]) is dict:
+ max_id = handle(buffer.popleft()) # updates that arrived earlier, handle them.
+ else:
+ break # gap, no more contagious updates
+ except IndexError:
+ break # buffer empty
+
+ elif update['update_id'] > max_id + 1:
+ # Update arrives pre-maturely, insert to buffer.
+ nbuf = len(buffer)
+ if update['update_id'] <= max_id + nbuf:
+ # buffer long enough, put update at position
+ buffer[update['update_id'] - max_id - 1] = update
+ else:
+ # buffer too short, lengthen it
+ expire = time.time() + maxhold
+ for a in range(nbuf, update['update_id']-max_id-1):
+ buffer.append(expire) # put expiry time in gaps
+ buffer.append(update)
+
+ else:
+ pass # discard
+
+ except queue.Empty:
+ # debug message
+ # print('Timeout')
+
+ # some buffer contents have to be handled
+ # flush buffer until a non-expired time is encountered
+ while 1:
+ try:
+ if type(buffer[0]) is dict:
+ max_id = handle(buffer.popleft())
+ else:
+ expire = buffer[0]
+ if expire <= time.time():
+ max_id += 1
+ buffer.popleft()
+ else:
+ break # non-expired
+ except IndexError:
+ break # buffer empty
+ except:
+ traceback.print_exc()
+ finally:
+ try:
+ # don't wait longer than next expiry time
+ qwait = buffer[0] - time.time()
+ if qwait < 0:
+ qwait = 0
+ except IndexError:
+ # buffer empty, can wait forever
+ qwait = None
+
+ # debug message
+ # print ('Buffer:', str(buffer), ', To Wait:', qwait, ', Max ID:', max_id)
+
+
+class OrderedWebhook(RunForeverAsThread):
+ def __init__(self, bot, handle=None):
+ self._bot = bot
+ self._collectloop = CollectLoop(_infer_handler_function(bot, handle))
+ self._orderer = Orderer(lambda update:
+ self._collectloop.input_queue.put(_extract_message(update)[1]))
+ # feed messages to collect loop
+
+ def run_forever(self, *args, **kwargs):
+ """
+ :type maxhold: float
+ :param maxhold:
+ The maximum number of seconds an update is held waiting for a
+ not-yet-arrived smaller ``update_id``. When this number of seconds
+ is up, the update is delivered to the message-handling function
+ even if some smaller ``update_id``\s have not yet arrived. If those
+ smaller ``update_id``\s arrive at some later time, they are discarded.
+
+ Calling this method will block forever. Use :meth:`.run_as_thread` to
+ run it non-blockingly.
+ """
+ # feed events to collect loop
+ self._bot.scheduler.on_event(self._collectloop.input_queue.put)
+ self._bot.scheduler.run_as_thread()
+
+ self._orderer.run_as_thread(*args, **kwargs)
+ self._collectloop.run_forever()
+
+ def feed(self, data):
+ """
+ :param data:
+ One of these:
+
+ - ``str``, ``unicode`` (Python 2.7), or ``bytes`` (Python 3, decoded using UTF-8)
+ representing a JSON-serialized `Update <https://core.telegram.org/bots/api#update>`_ object.
+ - a ``dict`` representing an Update object.
+ """
+ update = _dictify(data)
+ self._orderer.input_queue.put(update)
diff --git a/telepot/namedtuple.py b/telepot/namedtuple.py
new file mode 100644
index 0000000..a5c570b
--- /dev/null
+++ b/telepot/namedtuple.py
@@ -0,0 +1,865 @@
+import collections
+import warnings
+import sys
+
+class _Field(object):
+ def __init__(self, name, constructor=None, default=None):
+ self.name = name
+ self.constructor = constructor
+ self.default = default
+
+# Function to produce namedtuple classes.
+def _create_class(typename, fields):
+ # extract field names
+ field_names = [e.name if type(e) is _Field else e for e in fields]
+
+ # Some dictionary keys are Python keywords and cannot be used as field names, e.g. `from`.
+ # Get around by appending a '_', e.g. dict['from'] => namedtuple.from_
+ keymap = [(k.rstrip('_'), k) for k in filter(lambda e: e in ['from_'], field_names)]
+
+ # extract (non-simple) fields that need conversions
+ conversions = [(e.name, e.constructor) for e in fields if type(e) is _Field and e.constructor is not None]
+
+ # extract default values
+ defaults = [e.default if type(e) is _Field else None for e in fields]
+
+ # Create the base tuple class, with defaults.
+ base = collections.namedtuple(typename, field_names)
+ base.__new__.__defaults__ = tuple(defaults)
+
+ class sub(base):
+ def __new__(cls, **kwargs):
+ # Map keys.
+ for oldkey, newkey in keymap:
+ if oldkey in kwargs:
+ kwargs[newkey] = kwargs[oldkey]
+ del kwargs[oldkey]
+
+ # Any unexpected arguments?
+ unexpected = set(kwargs.keys()) - set(super(sub, cls)._fields)
+
+ # Remove unexpected arguments and issue warning.
+ if unexpected:
+ for k in unexpected:
+ del kwargs[k]
+
+ s = ('Unexpected fields: ' + ', '.join(unexpected) + ''
+ '\nBot API seems to have added new fields to the returned data.'
+ ' This version of namedtuple is not able to capture them.'
+ '\n\nPlease upgrade telepot by:'
+ '\n sudo pip install telepot --upgrade'
+ '\n\nIf you still see this message after upgrade, that means I am still working to bring the code up-to-date.'
+ ' Please try upgrade again a few days later.'
+ ' In the meantime, you can access the new fields the old-fashioned way, through the raw dictionary.')
+
+ warnings.warn(s, UserWarning)
+
+ # Convert non-simple values to namedtuples.
+ for key, func in conversions:
+ if key in kwargs:
+ if type(kwargs[key]) is dict:
+ kwargs[key] = func(**kwargs[key])
+ elif type(kwargs[key]) is list:
+ kwargs[key] = func(kwargs[key])
+ else:
+ raise RuntimeError('Can only convert dict or list')
+
+ return super(sub, cls).__new__(cls, **kwargs)
+
+ # https://bugs.python.org/issue24931
+ # Python 3.4 bug: namedtuple subclass does not inherit __dict__ properly.
+ # Fix it manually.
+ if sys.version_info >= (3,4):
+ def _asdict(self):
+ return collections.OrderedDict(zip(self._fields, self))
+ sub._asdict = _asdict
+
+ sub.__name__ = typename
+
+ return sub
+
+"""
+Different treatments for incoming and outgoing namedtuples:
+
+- Incoming ones require type declarations for certain fields for deeper parsing.
+- Outgoing ones need no such declarations because users are expected to put the correct object in place.
+"""
+
+# Namedtuple class will reference other namedtuple classes. Due to circular
+# dependencies, it is impossible to have all class definitions ready at
+# compile time. We have to dynamically obtain class reference at runtime.
+# For example, the following function acts like a constructor for `Message`
+# so any class can reference the Message namedtuple even before the Message
+# namedtuple is defined.
+def _Message(**kwargs):
+ return getattr(sys.modules[__name__], 'Message')(**kwargs)
+
+# incoming
+User = _create_class('User', [
+ 'id',
+ 'is_bot',
+ 'first_name',
+ 'last_name',
+ 'username',
+ 'language_code'
+ ])
+
+def UserArray(data):
+ return [User(**p) for p in data]
+
+# incoming
+ChatPhoto = _create_class('ChatPhoto', [
+ 'small_file_id',
+ 'big_file_id',
+ ])
+
+# incoming
+Chat = _create_class('Chat', [
+ 'id',
+ 'type',
+ 'title',
+ 'username',
+ 'first_name',
+ 'last_name',
+ 'all_members_are_administrators',
+ _Field('photo', constructor=ChatPhoto),
+ 'description',
+ 'invite_link',
+ _Field('pinned_message', constructor=_Message),
+ 'sticker_set_name',
+ 'can_set_sticker_set',
+ ])
+
+# incoming
+PhotoSize = _create_class('PhotoSize', [
+ 'file_id',
+ 'width',
+ 'height',
+ 'file_size',
+ 'file_path', # undocumented
+ ])
+
+# incoming
+Audio = _create_class('Audio', [
+ 'file_id',
+ 'duration',
+ 'performer',
+ 'title',
+ 'mime_type',
+ 'file_size'
+ ])
+
+# incoming
+Document = _create_class('Document', [
+ 'file_id',
+ _Field('thumb', constructor=PhotoSize),
+ 'file_name',
+ 'mime_type',
+ 'file_size',
+ 'file_path', # undocumented
+ ])
+
+# incoming and outgoing
+MaskPosition = _create_class('MaskPosition', [
+ 'point',
+ 'x_shift',
+ 'y_shift',
+ 'scale',
+ ])
+
+# incoming
+Sticker = _create_class('Sticker', [
+ 'file_id',
+ 'width',
+ 'height',
+ _Field('thumb', constructor=PhotoSize),
+ 'emoji',
+ 'set_name',
+ _Field('mask_position', constructor=MaskPosition),
+ 'file_size',
+ ])
+
+def StickerArray(data):
+ return [Sticker(**p) for p in data]
+
+# incoming
+StickerSet = _create_class('StickerSet', [
+ 'name',
+ 'title',
+ 'contains_masks',
+ _Field('stickers', constructor=StickerArray),
+ ])
+
+# incoming
+Video = _create_class('Video', [
+ 'file_id',
+ 'width',
+ 'height',
+ 'duration',
+ _Field('thumb', constructor=PhotoSize),
+ 'mime_type',
+ 'file_size',
+ 'file_path', # undocumented
+ ])
+
+# incoming
+Voice = _create_class('Voice', [
+ 'file_id',
+ 'duration',
+ 'mime_type',
+ 'file_size'
+ ])
+
+# incoming
+VideoNote = _create_class('VideoNote', [
+ 'file_id',
+ 'length',
+ 'duration',
+ _Field('thumb', constructor=PhotoSize),
+ 'file_size'
+ ])
+
+# incoming
+Contact = _create_class('Contact', [
+ 'phone_number',
+ 'first_name',
+ 'last_name',
+ 'user_id'
+ ])
+
+# incoming
+Location = _create_class('Location', [
+ 'longitude',
+ 'latitude'
+ ])
+
+# incoming
+Venue = _create_class('Venue', [
+ _Field('location', constructor=Location),
+ 'title',
+ 'address',
+ 'foursquare_id',
+ ])
+
+# incoming
+File = _create_class('File', [
+ 'file_id',
+ 'file_size',
+ 'file_path'
+ ])
+
+def PhotoSizeArray(data):
+ return [PhotoSize(**p) for p in data]
+
+def PhotoSizeArrayArray(data):
+ return [[PhotoSize(**p) for p in array] for array in data]
+
+# incoming
+UserProfilePhotos = _create_class('UserProfilePhotos', [
+ 'total_count',
+ _Field('photos', constructor=PhotoSizeArrayArray)
+ ])
+
+# incoming
+ChatMember = _create_class('ChatMember', [
+ _Field('user', constructor=User),
+ 'status',
+ 'until_date',
+ 'can_be_edited',
+ 'can_change_info',
+ 'can_post_messages',
+ 'can_edit_messages',
+ 'can_delete_messages',
+ 'can_invite_users',
+ 'can_restrict_members',
+ 'can_pin_messages',
+ 'can_promote_members',
+ 'can_send_messages',
+ 'can_send_media_messages',
+ 'can_send_other_messages',
+ 'can_add_web_page_previews',
+ ])
+
+def ChatMemberArray(data):
+ return [ChatMember(**p) for p in data]
+
+# outgoing
+ReplyKeyboardMarkup = _create_class('ReplyKeyboardMarkup', [
+ 'keyboard',
+ 'resize_keyboard',
+ 'one_time_keyboard',
+ 'selective',
+ ])
+
+# outgoing
+KeyboardButton = _create_class('KeyboardButton', [
+ 'text',
+ 'request_contact',
+ 'request_location',
+ ])
+
+# outgoing
+ReplyKeyboardRemove = _create_class('ReplyKeyboardRemove', [
+ _Field('remove_keyboard', default=True),
+ 'selective',
+ ])
+
+# outgoing
+ForceReply = _create_class('ForceReply', [
+ _Field('force_reply', default=True),
+ 'selective',
+ ])
+
+# outgoing
+InlineKeyboardButton = _create_class('InlineKeyboardButton', [
+ 'text',
+ 'url',
+ 'callback_data',
+ 'switch_inline_query',
+ 'switch_inline_query_current_chat',
+ 'callback_game',
+ 'pay',
+ ])
+
+# outgoing
+InlineKeyboardMarkup = _create_class('InlineKeyboardMarkup', [
+ 'inline_keyboard',
+ ])
+
+# incoming
+MessageEntity = _create_class('MessageEntity', [
+ 'type',
+ 'offset',
+ 'length',
+ 'url',
+ _Field('user', constructor=User),
+ ])
+
+# incoming
+def MessageEntityArray(data):
+ return [MessageEntity(**p) for p in data]
+
+# incoming
+GameHighScore = _create_class('GameHighScore', [
+ 'position',
+ _Field('user', constructor=User),
+ 'score',
+ ])
+
+# incoming
+Animation = _create_class('Animation', [
+ 'file_id',
+ _Field('thumb', constructor=PhotoSize),
+ 'file_name',
+ 'mime_type',
+ 'file_size',
+ ])
+
+# incoming
+Game = _create_class('Game', [
+ 'title',
+ 'description',
+ _Field('photo', constructor=PhotoSizeArray),
+ 'text',
+ _Field('text_entities', constructor=MessageEntityArray),
+ _Field('animation', constructor=Animation),
+ ])
+
+# incoming
+Invoice = _create_class('Invoice', [
+ 'title',
+ 'description',
+ 'start_parameter',
+ 'currency',
+ 'total_amount',
+ ])
+
+# outgoing
+LabeledPrice = _create_class('LabeledPrice', [
+ 'label',
+ 'amount',
+ ])
+
+# outgoing
+ShippingOption = _create_class('ShippingOption', [
+ 'id',
+ 'title',
+ 'prices',
+ ])
+
+# incoming
+ShippingAddress = _create_class('ShippingAddress', [
+ 'country_code',
+ 'state',
+ 'city',
+ 'street_line1',
+ 'street_line2',
+ 'post_code',
+ ])
+
+# incoming
+OrderInfo = _create_class('OrderInfo', [
+ 'name',
+ 'phone_number',
+ 'email',
+ _Field('shipping_address', constructor=ShippingAddress),
+ ])
+
+# incoming
+ShippingQuery = _create_class('ShippingQuery', [
+ 'id',
+ _Field('from_', constructor=User),
+ 'invoice_payload',
+ _Field('shipping_address', constructor=ShippingAddress),
+ ])
+
+# incoming
+PreCheckoutQuery = _create_class('PreCheckoutQuery', [
+ 'id',
+ _Field('from_', constructor=User),
+ 'currency',
+ 'total_amount',
+ 'invoice_payload',
+ 'shipping_option_id',
+ _Field('order_info', constructor=OrderInfo),
+ ])
+
+# incoming
+SuccessfulPayment = _create_class('SuccessfulPayment', [
+ 'currency',
+ 'total_amount',
+ 'invoice_payload',
+ 'shipping_option_id',
+ _Field('order_info', constructor=OrderInfo),
+ 'telegram_payment_charge_id',
+ 'provider_payment_charge_id',
+ ])
+
+# incoming
+Message = _create_class('Message', [
+ 'message_id',
+ _Field('from_', constructor=User),
+ 'date',
+ _Field('chat', constructor=Chat),
+ _Field('forward_from', constructor=User),
+ _Field('forward_from_chat', constructor=Chat),
+ 'forward_from_message_id',
+ 'forward_signature',
+ 'forward_date',
+ _Field('reply_to_message', constructor=_Message),
+ 'edit_date',
+ 'author_signature',
+ 'text',
+ _Field('entities', constructor=MessageEntityArray),
+ _Field('caption_entities', constructor=MessageEntityArray),
+ _Field('audio', constructor=Audio),
+ _Field('document', constructor=Document),
+ _Field('game', constructor=Game),
+ _Field('photo', constructor=PhotoSizeArray),
+ _Field('sticker', constructor=Sticker),
+ _Field('video', constructor=Video),
+ _Field('voice', constructor=Voice),
+ _Field('video_note', constructor=VideoNote),
+ _Field('new_chat_members', constructor=UserArray),
+ 'caption',
+ _Field('contact', constructor=Contact),
+ _Field('location', constructor=Location),
+ _Field('venue', constructor=Venue),
+ _Field('new_chat_member', constructor=User),
+ _Field('left_chat_member', constructor=User),
+ 'new_chat_title',
+ _Field('new_chat_photo', constructor=PhotoSizeArray),
+ 'delete_chat_photo',
+ 'group_chat_created',
+ 'supergroup_chat_created',
+ 'channel_chat_created',
+ 'migrate_to_chat_id',
+ 'migrate_from_chat_id',
+ _Field('pinned_message', constructor=_Message),
+ _Field('invoice', constructor=Invoice),
+ _Field('successful_payment', constructor=SuccessfulPayment),
+ 'connected_website',
+ ])
+
+# incoming
+InlineQuery = _create_class('InlineQuery', [
+ 'id',
+ _Field('from_', constructor=User),
+ _Field('location', constructor=Location),
+ 'query',
+ 'offset',
+ ])
+
+# incoming
+ChosenInlineResult = _create_class('ChosenInlineResult', [
+ 'result_id',
+ _Field('from_', constructor=User),
+ _Field('location', constructor=Location),
+ 'inline_message_id',
+ 'query',
+ ])
+
+# incoming
+CallbackQuery = _create_class('CallbackQuery', [
+ 'id',
+ _Field('from_', constructor=User),
+ _Field('message', constructor=Message),
+ 'inline_message_id',
+ 'chat_instance',
+ 'data',
+ 'game_short_name',
+ ])
+
+# incoming
+Update = _create_class('Update', [
+ 'update_id',
+ _Field('message', constructor=Message),
+ _Field('edited_message', constructor=Message),
+ _Field('channel_post', constructor=Message),
+ _Field('edited_channel_post', constructor=Message),
+ _Field('inline_query', constructor=InlineQuery),
+ _Field('chosen_inline_result', constructor=ChosenInlineResult),
+ _Field('callback_query', constructor=CallbackQuery),
+ ])
+
+# incoming
+def UpdateArray(data):
+ return [Update(**u) for u in data]
+
+# incoming
+WebhookInfo = _create_class('WebhookInfo', [
+ 'url',
+ 'has_custom_certificate',
+ 'pending_update_count',
+ 'last_error_date',
+ 'last_error_message',
+ ])
+
+# outgoing
+InputTextMessageContent = _create_class('InputTextMessageContent', [
+ 'message_text',
+ 'parse_mode',
+ 'disable_web_page_preview',
+ ])
+
+# outgoing
+InputLocationMessageContent = _create_class('InputLocationMessageContent', [
+ 'latitude',
+ 'longitude',
+ 'live_period',
+ ])
+
+# outgoing
+InputVenueMessageContent = _create_class('InputVenueMessageContent', [
+ 'latitude',
+ 'longitude',
+ 'title',
+ 'address',
+ 'foursquare_id',
+ ])
+
+# outgoing
+InputContactMessageContent = _create_class('InputContactMessageContent', [
+ 'phone_number',
+ 'first_name',
+ 'last_name',
+ ])
+
+# outgoing
+InlineQueryResultArticle = _create_class('InlineQueryResultArticle', [
+ _Field('type', default='article'),
+ 'id',
+ 'title',
+ 'input_message_content',
+ 'reply_markup',
+ 'url',
+ 'hide_url',
+ 'description',
+ 'thumb_url',
+ 'thumb_width',
+ 'thumb_height',
+ ])
+
+# outgoing
+InlineQueryResultPhoto = _create_class('InlineQueryResultPhoto', [
+ _Field('type', default='photo'),
+ 'id',
+ 'photo_url',
+ 'thumb_url',
+ 'photo_width',
+ 'photo_height',
+ 'title',
+ 'description',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultGif = _create_class('InlineQueryResultGif', [
+ _Field('type', default='gif'),
+ 'id',
+ 'gif_url',
+ 'gif_width',
+ 'gif_height',
+ 'gif_duration',
+ 'thumb_url',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultMpeg4Gif = _create_class('InlineQueryResultMpeg4Gif', [
+ _Field('type', default='mpeg4_gif'),
+ 'id',
+ 'mpeg4_url',
+ 'mpeg4_width',
+ 'mpeg4_height',
+ 'mpeg4_duration',
+ 'thumb_url',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultVideo = _create_class('InlineQueryResultVideo', [
+ _Field('type', default='video'),
+ 'id',
+ 'video_url',
+ 'mime_type',
+ 'thumb_url',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'video_width',
+ 'video_height',
+ 'video_duration',
+ 'description',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultAudio = _create_class('InlineQueryResultAudio', [
+ _Field('type', default='audio'),
+ 'id',
+ 'audio_url',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'performer',
+ 'audio_duration',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultVoice = _create_class('InlineQueryResultVoice', [
+ _Field('type', default='voice'),
+ 'id',
+ 'voice_url',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'voice_duration',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultDocument = _create_class('InlineQueryResultDocument', [
+ _Field('type', default='document'),
+ 'id',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'document_url',
+ 'mime_type',
+ 'description',
+ 'reply_markup',
+ 'input_message_content',
+ 'thumb_url',
+ 'thumb_width',
+ 'thumb_height',
+ ])
+
+# outgoing
+InlineQueryResultLocation = _create_class('InlineQueryResultLocation', [
+ _Field('type', default='location'),
+ 'id',
+ 'latitude',
+ 'longitude',
+ 'title',
+ 'live_period',
+ 'reply_markup',
+ 'input_message_content',
+ 'thumb_url',
+ 'thumb_width',
+ 'thumb_height',
+ ])
+
+# outgoing
+InlineQueryResultVenue = _create_class('InlineQueryResultVenue', [
+ _Field('type', default='venue'),
+ 'id',
+ 'latitude',
+ 'longitude',
+ 'title',
+ 'address',
+ 'foursquare_id',
+ 'reply_markup',
+ 'input_message_content',
+ 'thumb_url',
+ 'thumb_width',
+ 'thumb_height',
+ ])
+
+# outgoing
+InlineQueryResultContact = _create_class('InlineQueryResultContact', [
+ _Field('type', default='contact'),
+ 'id',
+ 'phone_number',
+ 'first_name',
+ 'last_name',
+ 'reply_markup',
+ 'input_message_content',
+ 'thumb_url',
+ 'thumb_width',
+ 'thumb_height',
+ ])
+
+# outgoing
+InlineQueryResultGame = _create_class('InlineQueryResultGame', [
+ _Field('type', default='game'),
+ 'id',
+ 'game_short_name',
+ 'reply_markup',
+ ])
+
+# outgoing
+InlineQueryResultCachedPhoto = _create_class('InlineQueryResultCachedPhoto', [
+ _Field('type', default='photo'),
+ 'id',
+ 'photo_file_id',
+ 'title',
+ 'description',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedGif = _create_class('InlineQueryResultCachedGif', [
+ _Field('type', default='gif'),
+ 'id',
+ 'gif_file_id',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedMpeg4Gif = _create_class('InlineQueryResultCachedMpeg4Gif', [
+ _Field('type', default='mpeg4_gif'),
+ 'id',
+ 'mpeg4_file_id',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedSticker = _create_class('InlineQueryResultCachedSticker', [
+ _Field('type', default='sticker'),
+ 'id',
+ 'sticker_file_id',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedDocument = _create_class('InlineQueryResultCachedDocument', [
+ _Field('type', default='document'),
+ 'id',
+ 'title',
+ 'document_file_id',
+ 'description',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedVideo = _create_class('InlineQueryResultCachedVideo', [
+ _Field('type', default='video'),
+ 'id',
+ 'video_file_id',
+ 'title',
+ 'description',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedVoice = _create_class('InlineQueryResultCachedVoice', [
+ _Field('type', default='voice'),
+ 'id',
+ 'voice_file_id',
+ 'title',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InlineQueryResultCachedAudio = _create_class('InlineQueryResultCachedAudio', [
+ _Field('type', default='audio'),
+ 'id',
+ 'audio_file_id',
+ 'caption',
+ 'parse_mode',
+ 'reply_markup',
+ 'input_message_content',
+ ])
+
+# outgoing
+InputMediaPhoto = _create_class('InputMediaPhoto', [
+ _Field('type', default='photo'),
+ 'media',
+ 'caption',
+ 'parse_mode',
+ ])
+
+# outgoing
+InputMediaVideo = _create_class('InputMediaVideo', [
+ _Field('type', default='video'),
+ 'media',
+ 'caption',
+ 'parse_mode',
+ 'width',
+ 'height',
+ 'duration',
+ 'supports_streaming',
+ ])
+
+# incoming
+ResponseParameters = _create_class('ResponseParameters', [
+ 'migrate_to_chat_id',
+ 'retry_after',
+ ])
diff --git a/telepot/routing.py b/telepot/routing.py
new file mode 100644
index 0000000..41a141c
--- /dev/null
+++ b/telepot/routing.py
@@ -0,0 +1,223 @@
+"""
+This module has a bunch of key function factories and routing table factories
+to facilitate the use of :class:`.Router`.
+
+Things to remember:
+
+1. A key function takes one argument - the message, and returns a key, optionally
+ followed by positional arguments and keyword arguments.
+
+2. A routing table is just a dictionary. After obtaining one from a factory
+ function, you can customize it to your liking.
+"""
+
+import re
+from . import glance, _isstring, all_content_types
+
+def by_content_type():
+ """
+ :return:
+ A key function that returns a 2-tuple (content_type, (msg[content_type],)).
+ In plain English, it returns the message's *content type* as the key,
+ and the corresponding content as a positional argument to the handler
+ function.
+ """
+ def f(msg):
+ content_type = glance(msg, flavor='chat')[0]
+ return content_type, (msg[content_type],)
+ return f
+
+def by_command(extractor, prefix=('/',), separator=' ', pass_args=False):
+ """
+ :param extractor:
+ a function that takes one argument (the message) and returns a portion
+ of message to be interpreted. To extract the text of a chat message,
+ use ``lambda msg: msg['text']``.
+
+ :param prefix:
+ a list of special characters expected to indicate the head of a command.
+
+ :param separator:
+ a command may be followed by arguments separated by ``separator``.
+
+ :type pass_args: bool
+ :param pass_args:
+ If ``True``, arguments following a command will be passed to the handler
+ function.
+
+ :return:
+ a key function that interprets a specific part of a message and returns
+ the embedded command, optionally followed by arguments. If the text is
+ not preceded by any of the specified ``prefix``, it returns a 1-tuple
+ ``(None,)`` as the key. This is to distinguish with the special
+ ``None`` key in routing table.
+ """
+ if not isinstance(prefix, (tuple, list)):
+ prefix = (prefix,)
+
+ def f(msg):
+ text = extractor(msg)
+ for px in prefix:
+ if text.startswith(px):
+ chunks = text[len(px):].split(separator)
+ return chunks[0], (chunks[1:],) if pass_args else ()
+ return (None,), # to distinguish with `None`
+ return f
+
+def by_chat_command(prefix=('/',), separator=' ', pass_args=False):
+ """
+ :param prefix:
+ a list of special characters expected to indicate the head of a command.
+
+ :param separator:
+ a command may be followed by arguments separated by ``separator``.
+
+ :type pass_args: bool
+ :param pass_args:
+ If ``True``, arguments following a command will be passed to the handler
+ function.
+
+ :return:
+ a key function that interprets a chat message's text and returns
+ the embedded command, optionally followed by arguments. If the text is
+ not preceded by any of the specified ``prefix``, it returns a 1-tuple
+ ``(None,)`` as the key. This is to distinguish with the special
+ ``None`` key in routing table.
+ """
+ return by_command(lambda msg: msg['text'], prefix, separator, pass_args)
+
+def by_text():
+ """
+ :return:
+ a key function that returns a message's ``text`` field.
+ """
+ return lambda msg: msg['text']
+
+def by_data():
+ """
+ :return:
+ a key function that returns a message's ``data`` field.
+ """
+ return lambda msg: msg['data']
+
+def by_regex(extractor, regex, key=1):
+ """
+ :param extractor:
+ a function that takes one argument (the message) and returns a portion
+ of message to be interpreted. To extract the text of a chat message,
+ use ``lambda msg: msg['text']``.
+
+ :type regex: str or regex object
+ :param regex: the pattern to look for
+
+ :param key: the part of match object to be used as key
+
+ :return:
+ a key function that returns ``match.group(key)`` as key (where ``match``
+ is the match object) and the match object as a positional argument.
+ If no match is found, it returns a 1-tuple ``(None,)`` as the key.
+ This is to distinguish with the special ``None`` key in routing table.
+ """
+ if _isstring(regex):
+ regex = re.compile(regex)
+
+ def f(msg):
+ text = extractor(msg)
+ match = regex.search(text)
+ if match:
+ index = key if isinstance(key, tuple) else (key,)
+ return match.group(*index), (match,)
+ else:
+ return (None,), # to distinguish with `None`
+ return f
+
+def process_key(processor, fn):
+ """
+ :param processor:
+ a function to process the key returned by the supplied key function
+
+ :param fn:
+ a key function
+
+ :return:
+ a function that wraps around the supplied key function to further
+ process the key before returning.
+ """
+ def f(*aa, **kw):
+ k = fn(*aa, **kw)
+ if isinstance(k, (tuple, list)):
+ return (processor(k[0]),) + tuple(k[1:])
+ else:
+ return processor(k)
+ return f
+
+def lower_key(fn):
+ """
+ :param fn: a key function
+
+ :return:
+ a function that wraps around the supplied key function to ensure
+ the returned key is in lowercase.
+ """
+ def lower(key):
+ try:
+ return key.lower()
+ except AttributeError:
+ return key
+ return process_key(lower, fn)
+
+def upper_key(fn):
+ """
+ :param fn: a key function
+
+ :return:
+ a function that wraps around the supplied key function to ensure
+ the returned key is in uppercase.
+ """
+ def upper(key):
+ try:
+ return key.upper()
+ except AttributeError:
+ return key
+ return process_key(upper, fn)
+
+def make_routing_table(obj, keys, prefix='on_'):
+ """
+ :return:
+ a dictionary roughly equivalent to ``{'key1': obj.on_key1, 'key2': obj.on_key2, ...}``,
+ but ``obj`` does not have to define all methods. It may define the needed ones only.
+
+ :param obj: the object
+
+ :param keys: a list of keys
+
+ :param prefix: a string to be prepended to keys to make method names
+ """
+ def maptuple(k):
+ if isinstance(k, tuple):
+ if len(k) == 2:
+ return k
+ elif len(k) == 1:
+ return k[0], lambda *aa, **kw: getattr(obj, prefix+k[0])(*aa, **kw)
+ else:
+ raise ValueError()
+ else:
+ return k, lambda *aa, **kw: getattr(obj, prefix+k)(*aa, **kw)
+ # Use `lambda` to delay evaluation of `getattr`.
+ # I don't want to require definition of all methods.
+ # Let users define only the ones he needs.
+
+ return dict([maptuple(k) for k in keys])
+
+def make_content_type_routing_table(obj, prefix='on_'):
+ """
+ :return:
+ a dictionary covering all available content types, roughly equivalent to
+ ``{'text': obj.on_text, 'photo': obj.on_photo, ...}``,
+ but ``obj`` does not have to define all methods. It may define the needed ones only.
+
+ :param obj: the object
+
+ :param prefix: a string to be prepended to content types to make method names
+ """
+ return make_routing_table(obj, all_content_types, prefix)
diff --git a/telepot/text.py b/telepot/text.py
new file mode 100644
index 0000000..d4b3cb7
--- /dev/null
+++ b/telepot/text.py
@@ -0,0 +1,88 @@
+def _apply_entities(text, entities, escape_map, format_map):
+ def inside_entities(i):
+ return any(map(lambda e:
+ e['offset'] <= i < e['offset']+e['length'],
+ entities))
+
+ # Split string into char sequence and escape in-place to
+ # preserve index positions.
+ seq = list(map(lambda c,i:
+ escape_map[c] # escape special characters
+ if c in escape_map and not inside_entities(i)
+ else c,
+ list(text), # split string to char sequence
+ range(0,len(text)))) # along with each char's index
+
+ # Ensure smaller offsets come first
+ sorted_entities = sorted(entities, key=lambda e: e['offset'])
+ offset = 0
+ result = ''
+
+ for e in sorted_entities:
+ f,n,t = e['offset'], e['length'], e['type']
+
+ result += ''.join(seq[offset:f])
+
+ if t in format_map:
+ # apply format
+ result += format_map[t](''.join(seq[f:f+n]), e)
+ else:
+ result += ''.join(seq[f:f+n])
+
+ offset = f + n
+
+ result += ''.join(seq[offset:])
+ return result
+
+
+def apply_entities_as_markdown(text, entities):
+ """
+ Format text as Markdown. Also take care of escaping special characters.
+ Returned value can be passed to :meth:`.Bot.sendMessage` with appropriate
+ ``parse_mode``.
+
+ :param text:
+ plain text
+
+ :param entities:
+ a list of `MessageEntity <https://core.telegram.org/bots/api#messageentity>`_ objects
+ """
+ escapes = {'*': '\\*',
+ '_': '\\_',
+ '[': '\\[',
+ '`': '\\`',}
+
+ formatters = {'bold': lambda s,e: '*'+s+'*',
+ 'italic': lambda s,e: '_'+s+'_',
+ 'text_link': lambda s,e: '['+s+']('+e['url']+')',
+ 'text_mention': lambda s,e: '['+s+'](tg://user?id='+str(e['user']['id'])+')',
+ 'code': lambda s,e: '`'+s+'`',
+ 'pre': lambda s,e: '```text\n'+s+'```'}
+
+ return _apply_entities(text, entities, escapes, formatters)
+
+
+def apply_entities_as_html(text, entities):
+ """
+ Format text as HTML. Also take care of escaping special characters.
+ Returned value can be passed to :meth:`.Bot.sendMessage` with appropriate
+ ``parse_mode``.
+
+ :param text:
+ plain text
+
+ :param entities:
+ a list of `MessageEntity <https://core.telegram.org/bots/api#messageentity>`_ objects
+ """
+ escapes = {'<': '&lt;',
+ '>': '&gt;',
+ '&': '&amp;',}
+
+ formatters = {'bold': lambda s,e: '<b>'+s+'</b>',
+ 'italic': lambda s,e: '<i>'+s+'</i>',
+ 'text_link': lambda s,e: '<a href="'+e['url']+'">'+s+'</a>',
+ 'text_mention': lambda s,e: '<a href="tg://user?id='+str(e['user']['id'])+'">'+s+'</a>',
+ 'code': lambda s,e: '<code>'+s+'</code>',
+ 'pre': lambda s,e: '<pre>'+s+'</pre>'}
+
+ return _apply_entities(text, entities, escapes, formatters)