From cc7b78880d28912a6aabe66bf520cf0ced569ba7 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 9 Aug 2018 00:33:54 +0200 Subject: [PATCH 1/5] Add params thumb and add class sendAnimation --- .DS_Store | Bin 0 -> 6148 bytes telepot/.DS_Store | Bin 0 -> 6148 bytes telepot/__init__.py | 109 ++++++++++++++++++++++++++++++++------------ telepot/api.py | 2 +- 4 files changed, 80 insertions(+), 31 deletions(-) create mode 100644 .DS_Store create mode 100644 telepot/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c5318f72342cf0c323fc496ce807ea0fbb603c4e GIT binary patch literal 6148 zcmeHK%Wl&^6ur|J>X;(3KnhE5kXS@fB0{@BNNJh~BJmPNumIHBwUt_O?I?Cg+Ypou z{{TOL#Gmj7du2J*NBAqlfeaSf^tOm;G2(^D9w^^$#`H2aRP@L?`59y&)xN=NkXJ z#&-}w9})EskB9n#NQh_sc_HgEJp*%>y7Xv`2hVxY!=@au6f{Oub(B*rDxT{ie-qqD z#6LvN+FM{e=ijh{Nj_A2?$=(b>-+jeZ{s`JJ>$|hdn7vsF+k6!WK zQ@=P0RQq|BCBNE!@FI>T`<3f^Sz7pU8jXZ=97dqLei^4>HtA&JG|a_1o}94lvR&S< zRHxI{otoQhZO>}%bZ4hgb6dB!XS1?hz1h6iKR7-;o4%X9|A>rW3}=S0YYNZd6B?Q( zZ@p2PWa$7i+{+S*2Y_vbbBP#%G3g4Fu1sAqn7TPI>5lof;?E^YcVgnnSVujXsT&GY z7Z2tNIU!M{Qc`E@N^<m5;x%n9E1bF`?f)oWMElQZww{zOC zEmLy?C{!OIrPRlBkCF{40J-(}B#77l0q+RaBYPQ5T5jukl!hH!cLJd6W5>&j^yOjJKT-QYN7r`)gA z=kxZ1hTCfIEgJ58f4|vq+xPbti?UO{(>gdhK0UvfzgxWjsG=l+olV)U!C&wR1*5^+ z-Z+kAe1aPG-i9&>wBcQFO+`^hdsf9N1vI1x=>)74n8*6C#<~u_Ga4XIz`-hD?eciv zH+Zk%9jS4~D8(2xho{#1b7rkeG{Wo#^d{Gv*F5W8hBwEQARp&3lnPl!3EmP4%>1&Q z>Xn(Xov2m7DsWi}@c!T;F?I|tHL9%xnYsc1i>OwHGXGg%j%%=EaH$b3FrleHO%>*f zAv7KJuJP*_Tx!&G66W$D%*evrP=p#C{kzJYL`S2|tpZkoRRs$AwZ`ZF<3Hd3S4sBG zDqt1(uM`l)!`@*RA(^vvBRD>5U8G|qHuhU;R1{?9I93Hdif$eqRp2KT*73&x literal 0 HcmV?d00001 diff --git a/telepot/__init__.py b/telepot/__init__.py index 30cb9d4..9749940 100644 --- a/telepot/__init__.py +++ b/telepot/__init__.py @@ -490,13 +490,14 @@ def handle(self, 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} + def _api_request_with_file(self, method, params, files_key, files, **kwargs): + if _isstring(files[files_key]): + params[files_key] = files[files_key] + del files[files_key] + if files != {}: return self._api_request(method, _rectify(params), files, **kwargs) + else: + return self._api_request(method, _rectify(params), **kwargs) def getMe(self): """ See: https://core.telegram.org/bots/api#getme """ @@ -536,9 +537,12 @@ def sendPhoto(self, chat_id, photo, filename is a unicode string. """ p = _strip(locals(), more=['photo']) - return self._api_request_with_file('sendPhoto', _rectify(p), 'photo', photo) + files = {'photo': photo} + files_key = 'photo' + return self._api_request_with_file('sendPhoto', _rectify(p), files_key, files) def sendAudio(self, chat_id, audio, + thumb=None, caption=None, parse_mode=None, duration=None, @@ -552,24 +556,32 @@ def sendAudio(self, chat_id, audio, :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) + p = _strip(locals(), more=['thumb']) + files_key = 'audio' + files = {files_key: audio} + if thumb != None: files['thumb'] = thumb + return self._api_request_with_file('sendAudio', _rectify(p), files_key, files) def sendDocument(self, chat_id, document, - caption=None, - parse_mode=None, - disable_notification=None, - reply_to_message_id=None, - reply_markup=None): + thumb=None, + 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) + p = _strip(locals(), more=['thumb']) + files_key = 'document' + files = {files_key: document} + if thumb != None: files['thumb'] = thumb + return self._api_request_with_file('sendDocument', _rectify(p), files_key, files) def sendVideo(self, chat_id, video, + thumb=None, duration=None, width=None, height=None, @@ -584,8 +596,32 @@ def sendVideo(self, chat_id, video, :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) + p = _strip(locals(), more=['thumb']) + files_key = 'video' + files = {files_key: video} + if thumb != None: files['thumb'] = thumb + return self._api_request_with_file('sendVideo', _rectify(p), files_key, files) + + def sendAnimation(self, chat_id, animation, + thumb=None, + duration=None, + width=None, + height=None, + caption=None, + parse_mode=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=['thumb']) + files_key = 'animation' + files = {files_key: animation} + if thumb != None: files['thumb'] = thumb + return self._api_request_with_file('sendAnimation', _rectify(p), files_key, files) def sendVoice(self, chat_id, voice, caption=None, @@ -600,14 +636,16 @@ def sendVoice(self, chat_id, voice, :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) + files = {'voice': voice} + return self._api_request_with_file('sendVoice', _rectify(p), files_key, files) def sendVideoNote(self, chat_id, video_note, - duration=None, - length=None, - disable_notification=None, - reply_to_message_id=None, - reply_markup=None): + thumb=None, + duration=None, + length=None, + disable_notification=None, + reply_to_message_id=None, + reply_markup=None): """ See: https://core.telegram.org/bots/api#sendvideonote @@ -618,8 +656,10 @@ def sendVideoNote(self, chat_id, video_note, 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) + p = _strip(locals(), more=['thumb']) + files = {'video_note': video_note} + if thumb != None: files['thumb'] = thumb + return self._api_request_with_file('sendVideoNote', _rectify(p), files_key, files) def sendMediaGroup(self, chat_id, media, disable_notification=None, @@ -784,6 +824,7 @@ def exportChatInviteLink(self, chat_id): def setChatPhoto(self, chat_id, photo): """ See: https://core.telegram.org/bots/api#setchatphoto """ p = _strip(locals(), more=['photo']) + files = {'photo': photo} return self._api_request_with_file('setChatPhoto', _rectify(p), 'photo', photo) def deleteChatPhoto(self, chat_id): @@ -933,7 +974,9 @@ def sendSticker(self, chat_id, sticker, :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) + files_key = 'sticker' + files = {files_key: sticker} + return self._api_request_with_file('sendSticker', _rectify(p), files_key, files) def getStickerSet(self, name): """ @@ -947,7 +990,9 @@ 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) + files_key = 'png_sticker' + files = {files_key: png_sticker} + return self._api_request_with_file('uploadStickerFile', _rectify(p), files_key, files) def createNewStickerSet(self, user_id, name, title, png_sticker, emojis, contains_masks=None, @@ -956,7 +1001,9 @@ def createNewStickerSet(self, user_id, name, title, png_sticker, emojis, 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) + files_key = 'png_sticker' + files = {files_key: png_sticker} + return self._api_request_with_file('createNewStickerSet', _rectify(p), files_key, files) def addStickerToSet(self, user_id, name, png_sticker, emojis, mask_position=None): @@ -964,7 +1011,9 @@ def addStickerToSet(self, user_id, name, png_sticker, emojis, 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) + files_key = 'png_sticker' + files = {files_key: png_sticker} + return self._api_request_with_file('addStickerToSet', _rectify(p), files_key, files) def setStickerPositionInSet(self, sticker, position): """ diff --git a/telepot/api.py b/telepot/api.py index 3ffa788..80b7904 100644 --- a/telepot/api.py +++ b/telepot/api.py @@ -125,7 +125,7 @@ def _transform(req, **user_kw): pool = _create_onetime_pool() else: pool = _pools[name] - + return pool.request_encode_body, ('POST', url, fields), kwargs def _parse(response): From 175b3b3c45de47a9a975fc79c1a98db1ede709dd Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 9 Aug 2018 00:35:57 +0200 Subject: [PATCH 2/5] Add params thumb and add class sendAnimation --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2e2d2c5..baa73c3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ todo.txt .remote-sync.json .pypirc bugs/ +*.DS_Store From e028a89d6295687ed9783a555005c112fab061cd Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 9 Aug 2018 00:37:41 +0200 Subject: [PATCH 3/5] add thumb params and add sendAnimation class --- .DS_Store | Bin 6148 -> 6148 bytes .gitignore | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.DS_Store b/.DS_Store index c5318f72342cf0c323fc496ce807ea0fbb603c4e..bfd5e62b3dda62bfefb3d7cbfe1b4d950a321559 100644 GIT binary patch delta 62 zcmZoMXfc@J&&aniU^g=(-((&ZZFUw0J%)6K%*m%%Z;C?M3?*g3MR_^-dFc!c42+vi NSZ*_IX6N|J4*&os5V8OO delta 31 ncmZoMXfc@J&&abeU^g=(&tx7J?alhEH<%_i{MgLS@s}R}o>U5l diff --git a/.gitignore b/.gitignore index baa73c3..4ec993b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ todo.txt .remote-sync.json .pypirc bugs/ -*.DS_Store +.DS_Store From 0731a186e231a84c647ea5d0567d08e1e41f4f7f Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 9 Aug 2018 00:41:22 +0200 Subject: [PATCH 4/5] add thumb params and add sendAnimation class --- .DS_Store | Bin 6148 -> 0 bytes telepot/.DS_Store | Bin 6148 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store delete mode 100644 telepot/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index bfd5e62b3dda62bfefb3d7cbfe1b4d950a321559..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%Wl&^6ur|J>X=s+NMY#>5{oEGL}(WXDNP$eBwoS@7Jyp2wo*&39mNizgrIEr z2lxRb{)9i^L-+tV^JtVzTGSOnWv(=H=5g-$&KZx#Lqx1I3Y$a~BC?S^Oog_8@1pP4FJWR5b6zmc2Wp>x z4+cq`7mdabwpOw(TwJ$p$9AqbZ@hzS>=k}7$~*q>6|X(@i-SOwpJ!PzFZ;oZI2!L( zu5V{);m2t-6wYxNf%5ugoQB!Bla10a7vp%c!?w$IdACxXOj>tpZnL#9t+|uU%|^{_ z-QJi^%Xal<^ImW7@aX;I!}Q~4xeN^7DPh+X9>W(D=JR{&4bvn``)K2Q=U5+T)Tfj( z8d9Wb^$om5tuE6xJ)kXmO!uiv59tZ8PKOjO`?0F#SE3N(?`y128q21L-jR>-29%(j zYy6)Y-$4XDMASt*9`X|+A->vQ7goDW&%oTG9eQ+%2cPqzhfO(RDQJYK>PSagRD7|65&r;l*4{qG=llouIeiB;O|&=B5{l(3TJiN>)C$!znA!{Wr8961#VBAD_`ek3 z-v<|oV_V@&qWE+m6ITGBg=S$Wi(h}B%Nu}gg)@n0fidX{l&(x&F_^kJFzJr|w&Kqu zN_S%7$rwjHnW-BJQx^~B3Ocd2M3Wl@i~`FFlw`HS&;Rb}_y1)lQ!@$}1&srC04~ZM+%_IsJWa>B;1%4FYKoW*NmluF-g)@n0fteoxA%n?`0)JJ3 E-+hP_IRF3v diff --git a/telepot/.DS_Store b/telepot/.DS_Store deleted file mode 100644 index d4445d2526030fbd6288cdd3ce22c6b5128c3194..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKL2eT<6#Z^OGf61T0x2wK0TPP{B_gy7R4EE&(H&9{EC8i5nb3@wjz`Iav<*So za0j>m5;x%n9E1bF`?f)oWMElQZww{zOC zEmLy?C{!OIrPRlBkCF{40J-(}B#77l0q+RaBYPQ5T5jukl!hH!cLJd6W5>&j^yOjJKT-QYN7r`)gA z=kxZ1hTCfIEgJ58f4|vq+xPbti?UO{(>gdhK0UvfzgxWjsG=l+olV)U!C&wR1*5^+ z-Z+kAe1aPG-i9&>wBcQFO+`^hdsf9N1vI1x=>)74n8*6C#<~u_Ga4XIz`-hD?eciv zH+Zk%9jS4~D8(2xho{#1b7rkeG{Wo#^d{Gv*F5W8hBwEQARp&3lnPl!3EmP4%>1&Q z>Xn(Xov2m7DsWi}@c!T;F?I|tHL9%xnYsc1i>OwHGXGg%j%%=EaH$b3FrleHO%>*f zAv7KJuJP*_Tx!&G66W$D%*evrP=p#C{kzJYL`S2|tpZkoRRs$AwZ`ZF<3Hd3S4sBG zDqt1(uM`l)!`@*RA(^vvBRD>5U8G|qHuhU;R1{?9I93Hdif$eqRp2KT*73&x From 41907edfe67757b7e3418773fa1921f6af4c0427 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 11 Aug 2018 15:08:18 +0200 Subject: [PATCH 5/5] Add params thumb in aio/__init__.py and fix some problems --- build/lib/telepot/__init__.py | 1445 +++++++++++++++++++++++++++++ build/lib/telepot/aio/__init__.py | 961 +++++++++++++++++++ build/lib/telepot/aio/api.py | 168 ++++ build/lib/telepot/aio/delegate.py | 106 +++ build/lib/telepot/aio/hack.py | 36 + build/lib/telepot/aio/helper.py | 372 ++++++++ build/lib/telepot/aio/loop.py | 205 ++++ build/lib/telepot/aio/routing.py | 46 + build/lib/telepot/api.py | 164 ++++ build/lib/telepot/delegate.py | 420 +++++++++ build/lib/telepot/exception.py | 111 +++ build/lib/telepot/filtering.py | 34 + build/lib/telepot/hack.py | 16 + build/lib/telepot/helper.py | 1170 +++++++++++++++++++++++ build/lib/telepot/loop.py | 313 +++++++ build/lib/telepot/namedtuple.py | 865 +++++++++++++++++ build/lib/telepot/routing.py | 223 +++++ build/lib/telepot/text.py | 88 ++ telepot/__init__.py | 75 +- telepot/aio/__init__.py | 63 +- telepot/api.py | 4 +- 21 files changed, 6827 insertions(+), 58 deletions(-) create mode 100644 build/lib/telepot/__init__.py create mode 100644 build/lib/telepot/aio/__init__.py create mode 100644 build/lib/telepot/aio/api.py create mode 100644 build/lib/telepot/aio/delegate.py create mode 100644 build/lib/telepot/aio/hack.py create mode 100644 build/lib/telepot/aio/helper.py create mode 100644 build/lib/telepot/aio/loop.py create mode 100644 build/lib/telepot/aio/routing.py create mode 100644 build/lib/telepot/api.py create mode 100644 build/lib/telepot/delegate.py create mode 100644 build/lib/telepot/exception.py create mode 100644 build/lib/telepot/filtering.py create mode 100644 build/lib/telepot/hack.py create mode 100644 build/lib/telepot/helper.py create mode 100644 build/lib/telepot/loop.py create mode 100644 build/lib/telepot/namedtuple.py create mode 100644 build/lib/telepot/routing.py create mode 100644 build/lib/telepot/text.py diff --git a/build/lib/telepot/__init__.py b/build/lib/telepot/__init__.py new file mode 100644 index 0000000..709d53c --- /dev/null +++ b/build/lib/telepot/__init__.py @@ -0,0 +1,1445 @@ +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 `_ 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 `_ object): + + - regardless: (``msg['id']``, ``msg['from']['id']``, ``msg['data']``) + + When ``flavor`` is ``inline_query`` + (``msg`` being a `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 `_ object): + + - regardless: (``msg['result_id']``, ``msg['from']['id']``, ``msg['query']``) + + When ``flavor`` is ``shipping_query`` + (``msg`` being a `ShippingQuery `_ object): + + - regardless: (``msg['id']``, ``msg['from']['id']``, ``msg['invoice_payload']``) + + When ``flavor`` is ``pre_checkout_query`` + (``msg`` being a `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, files_value, **kwargs): + if _isstring(files_value[file_key]): + params[file_key] = files_value[file_key] + del files_value[file_key] + if files_value != {}: + return self._api_request(method, _rectify(params), files_value, **kwargs) + else: + return self._api_request(method, _rectify(params), **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']) + photo = {'photo': photo} + return self._api_request_with_file('sendPhoto', _rectify(p), file_key, photo) + + def sendAudio(self, chat_id, audio, + thumb=None, + 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=['thumb']) + files_value = {'audio': audio} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendAudio', _rectify(p), 'audio', files_value) + + def sendDocument(self, chat_id, document, + thumb=None, + 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=['thumb']) + files_value = {'document': document} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendDocument', _rectify(p), 'document', files_value) + + def sendVideo(self, chat_id, video, + thumb=None, + 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=['thumb']) + files_value = {'video': video} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendVideo', _rectify(p), 'video', files_value) + + def sendAnimation(self, chat_id, animation, + thumb=None, + duration=None, + width=None, + height=None, + caption=None, + parse_mode=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=['thumb']) + files_value = {'animation': animation} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendAnimation', _rectify(p), 'animation', files_value) + + 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']) + voice = {'voice': voice} + return self._api_request_with_file('sendVoice', _rectify(p), 'voice', voice) + + def sendVideoNote(self, chat_id, video_note, + thumb=None, + 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=['thumb']) + files_value = {'video_note': video_note} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', files_value) + + 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 `_ 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']) + files = {'photo': 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']) + files = {'sticker': sticker} + return self._api_request_with_file('sendSticker', _rectify(p), 'sticker', files) + + 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']) + png_sticker = {'png_sticker': 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']) + png_sticker = {'png_sticker': 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']) + png_sticker = {'png_sticker': 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 `_ 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/build/lib/telepot/aio/__init__.py b/build/lib/telepot/aio/__init__.py new file mode 100644 index 0000000..96fdeb5 --- /dev/null +++ b/build/lib/telepot/aio/__init__.py @@ -0,0 +1,961 @@ +import io +import json +import time +import asyncio +import traceback +import collections +from concurrent.futures._base import CancelledError +from . import helper, api +from .. import ( + _BotBase, flavor, _find_first_key, _isstring, _strip, _rectify, + _dismantle_message_identifier, _split_input_media_array +) + +# Patch aiohttp for sending unicode filename +from . import hack + +from .. import exception + + +def flavor_router(routing_table): + router = helper.Router(flavor, routing_table) + return router.route + + +class Bot(_BotBase): + class Scheduler(object): + def __init__(self, loop): + self._loop = loop + self._callback = None + + def on_event(self, callback): + self._callback = callback + + def event_at(self, when, data): + delay = when - time.time() + return self._loop.call_later(delay, self._callback, data) + # call_at() uses event loop time, not unix time. + # May as well use call_later here. + + def event_later(self, delay, data): + return self._loop.call_later(delay, self._callback, data) + + def event_now(self, data): + return self._loop.call_soon(self._callback, data) + + def cancel(self, event): + return event.cancel() + + def __init__(self, token, loop=None): + super(Bot, self).__init__(token) + + self._loop = loop or asyncio.get_event_loop() + api._loop = self._loop # sync loop with api module + + self._scheduler = self.Scheduler(self._loop) + + self._router = helper.Router(flavor, {'chat': helper._create_invoker(self, 'on_chat_message'), + 'callback_query': helper._create_invoker(self, 'on_callback_query'), + 'inline_query': helper._create_invoker(self, 'on_inline_query'), + 'chosen_inline_result': helper._create_invoker(self, 'on_chosen_inline_result')}) + + @property + def loop(self): + return self._loop + + @property + def scheduler(self): + return self._scheduler + + @property + def router(self): + return self._router + + async def handle(self, msg): + await self._router.route(msg) + + async def _api_request(self, method, params=None, files=None, **kwargs): + return await api.request((self._token, method, params, files), **kwargs) + + async def _api_request_with_file(self, method, params, file_key, file_value, **kwargs): + if _isstring(files[files_key]): + params[files_key] = files[files_key] + del files[files_key] + if files != {}: + return self._api_request(method, _rectify(params), files, **kwargs) + else: + return self._api_request(method, _rectify(params), **kwargs) + async def getMe(self): + """ See: https://core.telegram.org/bots/api#getme """ + return await self._api_request('getMe') + + async 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 await self._api_request('sendMessage', _rectify(p)) + + async 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 await self._api_request('forwardMessage', _rectify(p)) + + async 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']) + photo = {'photo': photo} + return await self._api_request_with_file('sendPhoto', _rectify(p), 'photo', photo) + + async 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.aio.Bot.sendPhoto` + """ + p = _strip(locals(), more=['thumb']) + files_value = {'audio': audio} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendAudio', _rectify(p), 'audio', files_value) + + async 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.aio.Bot.sendPhoto` + """ + p = _strip(locals(), more=['thumb']) + files_value = {'document': document} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendDocument', _rectify(p), 'document', files_value) + + async 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.aio.Bot.sendPhoto` + """ + p = _strip(locals(), more=['thumb']) + files_value = {'video': video} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendVideo', _rectify(p), 'video', files_value) + + async def sendAnimation(self, chat_id, animation, + thumb=None, + duration=None, + width=None, + height=None, + caption=None, + parse_mode=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=['thumb']) + files_value = {'animation': animation} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendAnimation', _rectify(p), 'animation', files_value) + + async 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.aio.Bot.sendPhoto` + """ + p = _strip(locals(), more=['voice']) + voice = {'voice': voice} + return await self._api_request_with_file('sendVoice', _rectify(p), 'voice', voice) + + async 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 voice: Same as ``photo`` in :meth:`telepot.aio.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=['thumb']) + files_value = {'video_note': video_note} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', files_value) + + async 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 `_ 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 await self._api_request('sendMediaGroup', _rectify(p), files_to_attach) + + async 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 await self._api_request('sendLocation', _rectify(p)) + + async 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 await self._api_request('editMessageLiveLocation', _rectify(p)) + + async 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 await self._api_request('stopMessageLiveLocation', _rectify(p)) + + async 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 await self._api_request('sendVenue', _rectify(p)) + + async 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 await self._api_request('sendContact', _rectify(p)) + + async 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 await self._api_request('sendGame', _rectify(p)) + + async 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 await self._api_request('sendInvoice', _rectify(p)) + + async def sendChatAction(self, chat_id, action): + """ See: https://core.telegram.org/bots/api#sendchataction """ + p = _strip(locals()) + return await self._api_request('sendChatAction', _rectify(p)) + + async def getUserProfilePhotos(self, user_id, + offset=None, + limit=None): + """ See: https://core.telegram.org/bots/api#getuserprofilephotos """ + p = _strip(locals()) + return await self._api_request('getUserProfilePhotos', _rectify(p)) + + async def getFile(self, file_id): + """ See: https://core.telegram.org/bots/api#getfile """ + p = _strip(locals()) + return await self._api_request('getFile', _rectify(p)) + + async def kickChatMember(self, chat_id, user_id, + until_date=None): + """ See: https://core.telegram.org/bots/api#kickchatmember """ + p = _strip(locals()) + return await self._api_request('kickChatMember', _rectify(p)) + + async def unbanChatMember(self, chat_id, user_id): + """ See: https://core.telegram.org/bots/api#unbanchatmember """ + p = _strip(locals()) + return await self._api_request('unbanChatMember', _rectify(p)) + + async 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 await self._api_request('restrictChatMember', _rectify(p)) + + async 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 await self._api_request('promoteChatMember', _rectify(p)) + + async def exportChatInviteLink(self, chat_id): + """ See: https://core.telegram.org/bots/api#exportchatinvitelink """ + p = _strip(locals()) + return await self._api_request('exportChatInviteLink', _rectify(p)) + + async def setChatPhoto(self, chat_id, photo): + """ See: https://core.telegram.org/bots/api#setchatphoto """ + p = _strip(locals(), more=['photo']) + photo = {'photo': photo} + return await self._api_request_with_file('setChatPhoto', _rectify(p), 'photo', photo) + + async def deleteChatPhoto(self, chat_id): + """ See: https://core.telegram.org/bots/api#deletechatphoto """ + p = _strip(locals()) + return await self._api_request('deleteChatPhoto', _rectify(p)) + + async def setChatTitle(self, chat_id, title): + """ See: https://core.telegram.org/bots/api#setchattitle """ + p = _strip(locals()) + return await self._api_request('setChatTitle', _rectify(p)) + + async def setChatDescription(self, chat_id, + description=None): + """ See: https://core.telegram.org/bots/api#setchatdescription """ + p = _strip(locals()) + return await self._api_request('setChatDescription', _rectify(p)) + + async def pinChatMessage(self, chat_id, message_id, + disable_notification=None): + """ See: https://core.telegram.org/bots/api#pinchatmessage """ + p = _strip(locals()) + return await self._api_request('pinChatMessage', _rectify(p)) + + async def unpinChatMessage(self, chat_id): + """ See: https://core.telegram.org/bots/api#unpinchatmessage """ + p = _strip(locals()) + return await self._api_request('unpinChatMessage', _rectify(p)) + + async def leaveChat(self, chat_id): + """ See: https://core.telegram.org/bots/api#leavechat """ + p = _strip(locals()) + return await self._api_request('leaveChat', _rectify(p)) + + async def getChat(self, chat_id): + """ See: https://core.telegram.org/bots/api#getchat """ + p = _strip(locals()) + return await self._api_request('getChat', _rectify(p)) + + async def getChatAdministrators(self, chat_id): + """ See: https://core.telegram.org/bots/api#getchatadministrators """ + p = _strip(locals()) + return await self._api_request('getChatAdministrators', _rectify(p)) + + async def getChatMembersCount(self, chat_id): + """ See: https://core.telegram.org/bots/api#getchatmemberscount """ + p = _strip(locals()) + return await self._api_request('getChatMembersCount', _rectify(p)) + + async def getChatMember(self, chat_id, user_id): + """ See: https://core.telegram.org/bots/api#getchatmember """ + p = _strip(locals()) + return await self._api_request('getChatMember', _rectify(p)) + + async def setChatStickerSet(self, chat_id, sticker_set_name): + """ See: https://core.telegram.org/bots/api#setchatstickerset """ + p = _strip(locals()) + return await self._api_request('setChatStickerSet', _rectify(p)) + + async def deleteChatStickerSet(self, chat_id): + """ See: https://core.telegram.org/bots/api#deletechatstickerset """ + p = _strip(locals()) + return await self._api_request('deleteChatStickerSet', _rectify(p)) + + async 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 await self._api_request('answerCallbackQuery', _rectify(p)) + + async 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 await self._api_request('answerShippingQuery', _rectify(p)) + + async def answerPreCheckoutQuery(self, pre_checkout_query_id, ok, + error_message=None): + """ See: https://core.telegram.org/bots/api#answerprecheckoutquery """ + p = _strip(locals()) + return await self._api_request('answerPreCheckoutQuery', _rectify(p)) + + async 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 await self._api_request('editMessageText', _rectify(p)) + + async 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.aio.Bot.editMessageText` + """ + p = _strip(locals(), more=['msg_identifier']) + p.update(_dismantle_message_identifier(msg_identifier)) + return await self._api_request('editMessageCaption', _rectify(p)) + + async 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.aio.Bot.editMessageText` + """ + p = _strip(locals(), more=['msg_identifier']) + p.update(_dismantle_message_identifier(msg_identifier)) + return await self._api_request('editMessageReplyMarkup', _rectify(p)) + + async def deleteMessage(self, msg_identifier): + """ + See: https://core.telegram.org/bots/api#deletemessage + + :param msg_identifier: + Same as ``msg_identifier`` in :meth:`telepot.aio.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 await self._api_request('deleteMessage', _rectify(p)) + + async 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.aio.Bot.sendPhoto` + """ + p = _strip(locals(), more=['sticker']) + sticker = {'sticker': sticker} + return await self._api_request_with_file('sendSticker', _rectify(p), 'sticker', sticker) + + async def getStickerSet(self, name): + """ + See: https://core.telegram.org/bots/api#getstickerset + """ + p = _strip(locals()) + return await self._api_request('getStickerSet', _rectify(p)) + + async def uploadStickerFile(self, user_id, png_sticker): + """ + See: https://core.telegram.org/bots/api#uploadstickerfile + """ + p = _strip(locals(), more=['png_sticker']) + png_sticker = {'png_sticker': png_sticker} + return await self._api_request_with_file('uploadStickerFile', _rectify(p), 'png_sticker', png_sticker) + + async 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']) + png_sticker = {'png_sticker': png_sticker} + return await self._api_request_with_file('createNewStickerSet', _rectify(p), 'png_sticker', png_sticker) + + async 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']) + png_sticker = {'png_sticker': png_sticker} + return await self._api_request_with_file('addStickerToSet', _rectify(p), 'png_sticker', png_sticker) + + async def setStickerPositionInSet(self, sticker, position): + """ + See: https://core.telegram.org/bots/api#setstickerpositioninset + """ + p = _strip(locals()) + return await self._api_request('setStickerPositionInSet', _rectify(p)) + + async def deleteStickerFromSet(self, sticker): + """ + See: https://core.telegram.org/bots/api#deletestickerfromset + """ + p = _strip(locals()) + return await self._api_request('deleteStickerFromSet', _rectify(p)) + + async 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 await self._api_request('answerInlineQuery', _rectify(p)) + + async def getUpdates(self, + offset=None, + limit=None, + timeout=None, + allowed_updates=None): + """ See: https://core.telegram.org/bots/api#getupdates """ + p = _strip(locals()) + return await self._api_request('getUpdates', _rectify(p)) + + async 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 await self._api_request('setWebhook', _rectify(p), files) + else: + return await self._api_request('setWebhook', _rectify(p)) + + async def deleteWebhook(self): + """ See: https://core.telegram.org/bots/api#deletewebhook """ + return await self._api_request('deleteWebhook') + + async def getWebhookInfo(self): + """ See: https://core.telegram.org/bots/api#getwebhookinfo """ + return await self._api_request('getWebhookInfo') + + async def setGameScore(self, user_id, score, game_message_identifier, + force=None, + disable_edit_message=None): + """ See: https://core.telegram.org/bots/api#setgamescore """ + p = _strip(locals(), more=['game_message_identifier']) + p.update(_dismantle_message_identifier(game_message_identifier)) + return await self._api_request('setGameScore', _rectify(p)) + + async def getGameHighScores(self, user_id, game_message_identifier): + """ See: https://core.telegram.org/bots/api#getgamehighscores """ + p = _strip(locals(), more=['game_message_identifier']) + p.update(_dismantle_message_identifier(game_message_identifier)) + return await self._api_request('getGameHighScores', _rectify(p)) + + async def download_file(self, file_id, dest): + """ + Download a file to local disk. + + :param dest: a path or a ``file`` object + """ + f = await self.getFile(file_id) + + try: + d = dest if isinstance(dest, io.IOBase) else open(dest, 'wb') + + session, request = api.download((self._token, f['file_path'])) + + async with session: + async with request as r: + while 1: + chunk = await r.content.read(self._file_chunk_size) + if not chunk: + break + d.write(chunk) + d.flush() + finally: + if not isinstance(dest, io.IOBase) and 'd' in locals(): + d.close() + + async def message_loop(self, handler=None, relax=0.1, + timeout=20, allowed_updates=None, + source=None, ordered=True, maxhold=3): + """ + Return a task to constantly ``getUpdates`` or pull updates from a queue. + Apply ``handler`` to every message received. + + :param handler: + 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 ``asyncio.Queue``, 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`` or ``bytes`` (decoded using UTF-8) + representing a JSON-serialized `Update `_ object. + - a ``dict`` representing an Update object. + + When ``source`` is a queue, these parameters are meaningful: + + :type ordered: bool + :param ordered: + If ``True``, ensure in-order delivery of messages to ``handler`` + (i.e. updates with a smaller ``update_id`` always come before those with + a larger ``update_id``). + If ``False``, no re-ordering is done. ``handler`` 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 ``handler`` + 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. + + :type timeout: int + :param timeout: + ``timeout`` parameter supplied to :meth:`telepot.aio.Bot.getUpdates`, + controlling how long to poll in seconds. + + :type allowed_updates: array of string + :param allowed_updates: + ``allowed_updates`` parameter supplied to :meth:`telepot.aio.Bot.getUpdates`, + controlling which types of updates to receive. + """ + if handler is None: + handler = self.handle + elif isinstance(handler, dict): + handler = flavor_router(handler) + + def create_task_for(msg): + self.loop.create_task(handler(msg)) + + if asyncio.iscoroutinefunction(handler): + callback = create_task_for + else: + callback = handler + + def handle(update): + try: + 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']) + + callback(update[key]) + except: + # Localize the error so message thread can keep going. + traceback.print_exc() + finally: + return update['update_id'] + + async def get_from_telegram_server(): + offset = None # running offset + allowed_upd = allowed_updates + while 1: + try: + result = await 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([handle(update) for update in result]) + 1 + except CancelledError: + raise + except exception.BadHTTPResponse as e: + traceback.print_exc() + + # Servers probably down. Wait longer. + if e.status == 502: + await asyncio.sleep(30) + except: + traceback.print_exc() + await asyncio.sleep(relax) + else: + await asyncio.sleep(relax) + + def dictify(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() + + async def get_from_queue_unordered(qu): + while 1: + try: + data = await qu.get() + update = dictify(data) + handle(update) + except: + traceback.print_exc() + + async def get_from_queue(qu): + # 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 = await asyncio.wait_for(qu.get(), qwait) + update = dictify(data) + + 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 asyncio.TimeoutError: + # 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) + + self._scheduler._callback = callback + + if source is None: + await get_from_telegram_server() + elif isinstance(source, asyncio.Queue): + if ordered: + await get_from_queue(source) + else: + await get_from_queue_unordered(source) + else: + raise ValueError('Invalid source') + + +class SpeakerBot(Bot): + def __init__(self, token, loop=None): + super(SpeakerBot, self).__init__(token, loop) + self._mic = helper.Microphone() + + @property + def mic(self): + return self._mic + + def create_listener(self): + q = asyncio.Queue() + self._mic.add(q) + ln = helper.Listener(self._mic, q) + return ln + + +class DelegatorBot(SpeakerBot): + def __init__(self, token, delegation_patterns, loop=None): + """ + :param delegation_patterns: a list of (seeder, delegator) tuples. + """ + super(DelegatorBot, self).__init__(token, loop) + self._delegate_records = [p+({},) for p in delegation_patterns] + + def handle(self, msg): + self._mic.send(msg) + + for calculate_seed, make_coroutine_obj, 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 dict[id].done(): + c = make_coroutine_obj((self, msg, id)) + + if not asyncio.iscoroutine(c): + raise RuntimeError('You must produce a coroutine *object* as delegate.') + + dict[id] = self._loop.create_task(c) + else: + c = make_coroutine_obj((self, msg, id)) + self._loop.create_task(c) diff --git a/build/lib/telepot/aio/api.py b/build/lib/telepot/aio/api.py new file mode 100644 index 0000000..8454363 --- /dev/null +++ b/build/lib/telepot/aio/api.py @@ -0,0 +1,168 @@ +import asyncio +import aiohttp +import async_timeout +import atexit +import re +import json +from .. import exception +from ..api import _methodurl, _which_pool, _fileurl, _guess_filename + +_loop = asyncio.get_event_loop() + +_pools = { + 'default': aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=10), + loop=_loop) +} + +_timeout = 30 +_proxy = None # (url, (username, password)) + +def set_proxy(url, basic_auth=None): + global _proxy + if not url: + _proxy = None + else: + _proxy = (url, basic_auth) if basic_auth else (url,) + +def _proxy_kwargs(): + if _proxy is None or len(_proxy) == 0: + return {} + elif len(_proxy) == 1: + return {'proxy': _proxy[0]} + elif len(_proxy) == 2: + return {'proxy': _proxy[0], 'proxy_auth': aiohttp.BasicAuth(*_proxy[1])} + else: + raise RuntimeError("_proxy has invalid length") + +async def _close_pools(): + global _pools + for s in _pools.values(): + await s.close() + +atexit.register(lambda: _loop.create_task(_close_pools())) # have to wrap async function + +def _create_onetime_pool(): + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=1, force_close=True), + loop=_loop) + +def _default_timeout(req, **user_kw): + return _timeout + +def _compose_timeout(req, **user_kw): + token, method, params, files = req + + if method == 'getUpdates' and params and 'timeout' in params: + # Ensure HTTP timeout is longer than getUpdates timeout + return 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. + return None + else: + return _default_timeout(req, **user_kw) + +def _compose_data(req, **user_kw): + token, method, params, files = req + + data = aiohttp.FormData() + + if params: + for key,value in params.items(): + data.add_field(key, str(value)) + + if files: + for key,f in files.items(): + if isinstance(f, tuple): + if len(f) == 2: + filename, fileobj = f + else: + raise ValueError('Tuple must have exactly 2 elements: filename, fileobj') + else: + filename, fileobj = _guess_filename(f) or key, f + + data.add_field(key, fileobj, filename=filename) + + return data + +def _transform(req, **user_kw): + timeout = _compose_timeout(req, **user_kw) + + data = _compose_data(req, **user_kw) + + url = _methodurl(req, **user_kw) + + name = _which_pool(req, **user_kw) + + if name is None: + session = _create_onetime_pool() + cleanup = session.close # one-time session: remember to close + else: + session = _pools[name] + cleanup = None # reuse: do not close + + kwargs = {'data':data} + kwargs.update(user_kw) + + return session.post, (url,), kwargs, timeout, cleanup + +async def _parse(response): + try: + data = await response.json() + if data is None: + raise ValueError() + except (ValueError, json.JSONDecodeError, aiohttp.ClientResponseError): + text = await response.text() + 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) + +async def request(req, **user_kw): + fn, args, kwargs, timeout, cleanup = _transform(req, **user_kw) + + kwargs.update(_proxy_kwargs()) + try: + if timeout is None: + async with fn(*args, **kwargs) as r: + return await _parse(r) + else: + try: + with async_timeout.timeout(timeout): + async with fn(*args, **kwargs) as r: + return await _parse(r) + + except asyncio.TimeoutError: + raise exception.TelegramError('Response timeout', 504, {}) + + except aiohttp.ClientConnectionError: + raise exception.TelegramError('Connection Error', 400, {}) + + finally: + if cleanup: # e.g. closing one-time session + if asyncio.iscoroutinefunction(cleanup): + await cleanup() + else: + cleanup() + +def download(req): + session = _create_onetime_pool() + + kwargs = {} + kwargs.update(_proxy_kwargs()) + + return session, session.get(_fileurl(req), timeout=_timeout, **kwargs) + # Caller should close session after download is complete diff --git a/build/lib/telepot/aio/delegate.py b/build/lib/telepot/aio/delegate.py new file mode 100644 index 0000000..d316c5c --- /dev/null +++ b/build/lib/telepot/aio/delegate.py @@ -0,0 +1,106 @@ +""" +Like :mod:`telepot.delegate`, this module has a bunch of seeder factories +and delegator factories. + +.. autofunction:: per_chat_id +.. autofunction:: per_chat_id_in +.. autofunction:: per_chat_id_except +.. autofunction:: per_from_id +.. autofunction:: per_from_id_in +.. autofunction:: per_from_id_except +.. autofunction:: per_inline_from_id +.. autofunction:: per_inline_from_id_in +.. autofunction:: per_inline_from_id_except +.. autofunction:: per_application +.. autofunction:: per_message +.. autofunction:: per_event_source_id +.. autofunction:: per_callback_query_chat_id +.. autofunction:: per_callback_query_origin +.. autofunction:: per_invoice_payload +.. autofunction:: until +.. autofunction:: chain +.. autofunction:: pair +.. autofunction:: pave_event_space +.. autofunction:: include_callback_query_chat_id +.. autofunction:: intercept_callback_query_origin +""" + +import asyncio +import traceback +from .. import exception +from . import helper + +# Mirror traditional version to avoid having to import one more module +from ..delegate import ( + per_chat_id, per_chat_id_in, per_chat_id_except, + per_from_id, per_from_id_in, per_from_id_except, + per_inline_from_id, per_inline_from_id_in, per_inline_from_id_except, + per_application, per_message, per_event_source_id, + per_callback_query_chat_id, per_callback_query_origin, per_invoice_payload, + until, chain, pair, pave_event_space, + include_callback_query_chat_id, intercept_callback_query_origin +) + +def _ensure_coroutine_function(fn): + return fn if asyncio.iscoroutinefunction(fn) else asyncio.coroutine(fn) + +def call(corofunc, *args, **kwargs): + """ + :return: + a delegator function that returns a coroutine object by calling + ``corofunc(seed_tuple, *args, **kwargs)``. + """ + corofunc = _ensure_coroutine_function(corofunc) + def f(seed_tuple): + return corofunc(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 + a coroutine object by calling the object's ``run`` method, which should be + a coroutine function. + """ + def f(seed_tuple): + j = cls(seed_tuple, *args, **kwargs) + return _ensure_coroutine_function(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 coroutine object that uses the object's ``listener`` to wait for + messages and invokes instance method ``open``, ``on_message``, and ``on_close`` + accordingly. + """ + def f(seed_tuple): + j = cls(seed_tuple, *args, **kwargs) + + async def wait_loop(): + bot, msg, seed = seed_tuple + try: + handled = await helper._invoke(j.open, msg, seed) + if not handled: + await helper._invoke(j.on_message, msg) + + while 1: + msg = await j.listener.wait() + await helper._invoke(j.on_message, msg) + + # These exceptions are "normal" exits. + except (exception.IdleTerminate, exception.StopListening) as e: + await helper._invoke(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() + await helper._invoke(j.on_close, e) + + return wait_loop() + return f diff --git a/build/lib/telepot/aio/hack.py b/build/lib/telepot/aio/hack.py new file mode 100644 index 0000000..4a5deb2 --- /dev/null +++ b/build/lib/telepot/aio/hack.py @@ -0,0 +1,36 @@ +try: + import aiohttp + from urllib.parse import quote + + def content_disposition_header(disptype, quote_fields=True, **params): + if not disptype or not (aiohttp.helpers.TOKEN > set(disptype)): + raise ValueError('bad content disposition type {!r}' + ''.format(disptype)) + + value = disptype + if params: + lparams = [] + for key, val in params.items(): + if not key or not (aiohttp.helpers.TOKEN > set(key)): + raise ValueError('bad content disposition parameter' + ' {!r}={!r}'.format(key, val)) + + ###### Do not encode filename + if key == 'filename': + qval = val + else: + qval = quote(val, '') if quote_fields else val + + lparams.append((key, '"%s"' % qval)) + + sparams = '; '.join('='.join(pair) for pair in lparams) + value = '; '.join((value, sparams)) + return value + + # Override original version + aiohttp.payload.content_disposition_header = content_disposition_header + +# In case aiohttp changes and this hack no longer works, I don't want it to +# bog down the entire library. +except (ImportError, AttributeError): + pass diff --git a/build/lib/telepot/aio/helper.py b/build/lib/telepot/aio/helper.py new file mode 100644 index 0000000..3ab598f --- /dev/null +++ b/build/lib/telepot/aio/helper.py @@ -0,0 +1,372 @@ +import asyncio +import traceback +from .. import filtering, helper, exception +from .. import ( + flavor, chat_flavors, inline_flavors, is_event, + message_identifier, origin_identifier) + +# Mirror traditional version +from ..helper import ( + Sender, Administrator, Editor, openable, + StandardEventScheduler, StandardEventMixin) + + +async def _invoke(fn, *args, **kwargs): + if asyncio.iscoroutinefunction(fn): + return await fn(*args, **kwargs) + else: + return fn(*args, **kwargs) + + +def _create_invoker(obj, method_name): + async def d(*a, **kw): + method = getattr(obj, method_name) + return await _invoke(method, *a, **kw) + return d + + +class Microphone(object): + def __init__(self): + self._queues = set() + + def add(self, q): + self._queues.add(q) + + def remove(self, q): + self._queues.remove(q) + + def send(self, msg): + for q in self._queues: + try: + q.put_nowait(msg) + except asyncio.QueueFull: + traceback.print_exc() + pass + + +class Listener(helper.Listener): + async def wait(self): + """ + Block until a matched message appears. + """ + if not self._patterns: + raise RuntimeError('Listener has nothing to capture') + + while 1: + msg = await self._queue.get() + + if any(map(lambda p: filtering.match_all(msg, p), self._patterns)): + return msg + + +from concurrent.futures._base import CancelledError + +class Answerer(object): + """ + When processing inline queries, ensures **at most one active task** per user id. + """ + + def __init__(self, bot, loop=None): + self._bot = bot + self._loop = loop if loop is not None else asyncio.get_event_loop() + self._working_tasks = {} + + def answer(self, inline_query, compute_fn, *compute_args, **compute_kwargs): + """ + Create a task 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 task is already working for a user, that task is cancelled, + thus ensuring at most one active task 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 function whose returned value is given to :meth:`.Bot.answerInlineQuery` to send. + May return: + + - a *list* of `InlineQueryResult `_ + - a *tuple* whose first element is a list of `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'] + + async def compute_and_answer(): + try: + query_id = inline_query['id'] + + ans = await _invoke(compute_fn, *compute_args, **compute_kwargs) + + if isinstance(ans, list): + await self._bot.answerInlineQuery(query_id, ans) + elif isinstance(ans, tuple): + await self._bot.answerInlineQuery(query_id, *ans) + elif isinstance(ans, dict): + await self._bot.answerInlineQuery(query_id, **ans) + else: + raise ValueError('Invalid answer format') + except CancelledError: + # Cancelled. Record has been occupied by new task. Don't touch. + raise + except: + # Die accidentally. Remove myself from record. + del self._working_tasks[from_id] + raise + else: + # Die naturally. Remove myself from record. + del self._working_tasks[from_id] + + if from_id in self._working_tasks: + self._working_tasks[from_id].cancel() + + t = self._loop.create_task(compute_and_answer()) + self._working_tasks[from_id] = t + + +class AnswererMixin(helper.AnswererMixin): + Answerer = Answerer # use async Answerer class + + +class CallbackQueryCoordinator(helper.CallbackQueryCoordinator): + def augment_send(self, send_func): + async def augmented(*aa, **kw): + sent = await 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): + async def augmented(msg_identifier, *aa, **kw): + edited = await 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): + async def augmented(msg_identifier, *aa, **kw): + deleted = await 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): + async 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 await _invoke(handler, msg) + return augmented + + +class InterceptCallbackQueryMixin(helper.InterceptCallbackQueryMixin): + CallbackQueryCoordinator = CallbackQueryCoordinator + + +class IdleEventCoordinator(helper.IdleEventCoordinator): + def augment_on_message(self, handler): + async def augmented(msg): + # Reset timer if this is an external message + is_event(msg) or self.refresh() + return await _invoke(handler, msg) + return augmented + + def augment_on_close(self, handler): + async 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 await _invoke(handler, ex) + return augmented + + +class IdleTerminateMixin(helper.IdleTerminateMixin): + IdleEventCoordinator = IdleEventCoordinator + + +class Router(helper.Router): + async 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 nesting. + 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 await _invoke(fn, msg, *args, **kwargs) + + +class DefaultRouterMixin(object): + def __init__(self, *args, **kwargs): + self._router = Router(flavor, {'chat': _create_invoker(self, 'on_chat_message'), + 'callback_query': _create_invoker(self, 'on_callback_query'), + 'inline_query': _create_invoker(self, 'on_inline_query'), + 'chosen_inline_result': _create_invoker(self, 'on_chosen_inline_result'), + 'shipping_query': _create_invoker(self, 'on_shipping_query'), + 'pre_checkout_query': _create_invoker(self, 'on_pre_checkout_query'), + '_idle': _create_invoker(self, 'on__idle')}) + + super(DefaultRouterMixin, self).__init__(*args, **kwargs) + + @property + def router(self): + """ See :class:`.helper.Router` """ + return self._router + + async def on_message(self, msg): + """ + Called when a message is received. + By default, call :meth:`Router.route` to handle the message. + """ + await self._router.route(msg) + + +@openable +class Monitor(helper.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:`telepot.aio.delegate.per_application`. + + :param capture: a list of patterns for ``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(helper.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(helper.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(helper.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(helper.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/build/lib/telepot/aio/loop.py b/build/lib/telepot/aio/loop.py new file mode 100644 index 0000000..46f4b4e --- /dev/null +++ b/build/lib/telepot/aio/loop.py @@ -0,0 +1,205 @@ +import asyncio +import time +import traceback +import collections +from concurrent.futures._base import CancelledError + +from . import flavor_router + +from ..loop import _extract_message, _dictify +from .. import exception + + +class GetUpdatesLoop(object): + def __init__(self, bot, on_update): + self._bot = bot + self._update_handler = on_update + + async 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 = await 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 CancelledError: + break + except exception.BadHTTPResponse as e: + traceback.print_exc() + + # Servers probably down. Wait longer. + if e.status == 502: + await asyncio.sleep(30) + except: + traceback.print_exc() + await asyncio.sleep(relax) + else: + await asyncio.sleep(relax) + + +def _infer_handler_function(bot, h): + if h is None: + handler = bot.handle + elif isinstance(h, dict): + handler = flavor_router(h) + else: + handler = h + + def create_task_for(msg): + bot.loop.create_task(handler(msg)) + + if asyncio.iscoroutinefunction(handler): + return create_task_for + else: + return handler + + +class MessageLoop(object): + def __init__(self, bot, handle=None): + self._bot = bot + self._handle = _infer_handler_function(bot, handle) + self._task = None + + async def run_forever(self, *args, **kwargs): + updatesloop = GetUpdatesLoop(self._bot, + lambda update: + self._handle(_extract_message(update)[1])) + + self._task = self._bot.loop.create_task(updatesloop.run_forever(*args, **kwargs)) + + self._bot.scheduler.on_event(self._handle) + + def cancel(self): + self._task.cancel() + + +class Webhook(object): + def __init__(self, bot, handle=None): + self._bot = bot + self._handle = _infer_handler_function(bot, handle) + + async def run_forever(self): + self._bot.scheduler.on_event(self._handle) + + def feed(self, data): + update = _dictify(data) + self._handle(_extract_message(update)[1]) + + +class OrderedWebhook(object): + def __init__(self, bot, handle=None): + self._bot = bot + self._handle = _infer_handler_function(bot, handle) + self._update_queue = asyncio.Queue(loop=bot.loop) + + async def run_forever(self, maxhold=3): + self._bot.scheduler.on_event(self._handle) + + def extract_handle(update): + try: + self._handle(_extract_message(update)[1]) + except: + # Localize the error so message thread can keep going. + traceback.print_exc() + finally: + 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 = await asyncio.wait_for(self._update_queue.get(), qwait) + + if max_id is None: + # First message received, handle regardless. + max_id = extract_handle(update) + + elif update['update_id'] == max_id + 1: + # No update_id skipped, handle naturally. + max_id = extract_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 = extract_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 asyncio.TimeoutError: + # 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 = extract_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) + + def feed(self, data): + update = _dictify(data) + self._update_queue.put_nowait(update) diff --git a/build/lib/telepot/aio/routing.py b/build/lib/telepot/aio/routing.py new file mode 100644 index 0000000..a1af9ca --- /dev/null +++ b/build/lib/telepot/aio/routing.py @@ -0,0 +1,46 @@ +from .helper import _create_invoker +from .. import all_content_types + +# Mirror traditional version to avoid having to import one more module +from ..routing import ( + by_content_type, by_command, by_chat_command, by_text, by_data, by_regex, + process_key, lower_key, upper_key +) + +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], _create_invoker(obj, prefix+k[0]) + else: + raise ValueError() + else: + return k, _create_invoker(obj, prefix+k) + + 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/build/lib/telepot/api.py b/build/lib/telepot/api.py new file mode 100644 index 0000000..235cc57 --- /dev/null +++ b/build/lib/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 \ No newline at end of file diff --git a/build/lib/telepot/delegate.py b/build/lib/telepot/delegate.py new file mode 100644 index 0000000..91b981e --- /dev/null +++ b/build/lib/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/build/lib/telepot/exception.py b/build/lib/telepot/exception.py new file mode 100644 index 0000000..820a522 --- /dev/null +++ b/build/lib/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/build/lib/telepot/filtering.py b/build/lib/telepot/filtering.py new file mode 100644 index 0000000..52ed749 --- /dev/null +++ b/build/lib/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/build/lib/telepot/hack.py b/build/lib/telepot/hack.py new file mode 100644 index 0000000..029bd19 --- /dev/null +++ b/build/lib/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/build/lib/telepot/helper.py b/build/lib/telepot/helper.py new file mode 100644 index 0000000..1b57b84 --- /dev/null +++ b/build/lib/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 `_ + - a *tuple* whose first element is a list of `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/build/lib/telepot/loop.py b/build/lib/telepot/loop.py new file mode 100644 index 0000000..4b1331b --- /dev/null +++ b/build/lib/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 `_ object. + - a ``dict`` representing an Update object. + """ + update = _dictify(data) + self._orderer.input_queue.put(update) diff --git a/build/lib/telepot/namedtuple.py b/build/lib/telepot/namedtuple.py new file mode 100644 index 0000000..a5c570b --- /dev/null +++ b/build/lib/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/build/lib/telepot/routing.py b/build/lib/telepot/routing.py new file mode 100644 index 0000000..41a141c --- /dev/null +++ b/build/lib/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/build/lib/telepot/text.py b/build/lib/telepot/text.py new file mode 100644 index 0000000..d4b3cb7 --- /dev/null +++ b/build/lib/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 `_ 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 `_ objects + """ + escapes = {'<': '<', + '>': '>', + '&': '&',} + + formatters = {'bold': lambda s,e: ''+s+'', + 'italic': lambda s,e: ''+s+'', + 'text_link': lambda s,e: ''+s+'', + 'text_mention': lambda s,e: ''+s+'', + 'code': lambda s,e: ''+s+'', + 'pre': lambda s,e: '
'+s+'
'} + + return _apply_entities(text, entities, escapes, formatters) diff --git a/telepot/__init__.py b/telepot/__init__.py index 9749940..709d53c 100644 --- a/telepot/__init__.py +++ b/telepot/__init__.py @@ -490,12 +490,12 @@ def handle(self, 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, files_key, files, **kwargs): - if _isstring(files[files_key]): - params[files_key] = files[files_key] - del files[files_key] - if files != {}: - return self._api_request(method, _rectify(params), files, **kwargs) + def _api_request_with_file(self, method, params, file_key, files_value, **kwargs): + if _isstring(files_value[file_key]): + params[file_key] = files_value[file_key] + del files_value[file_key] + if files_value != {}: + return self._api_request(method, _rectify(params), files_value, **kwargs) else: return self._api_request(method, _rectify(params), **kwargs) @@ -537,9 +537,8 @@ def sendPhoto(self, chat_id, photo, filename is a unicode string. """ p = _strip(locals(), more=['photo']) - files = {'photo': photo} - files_key = 'photo' - return self._api_request_with_file('sendPhoto', _rectify(p), files_key, files) + photo = {'photo': photo} + return self._api_request_with_file('sendPhoto', _rectify(p), file_key, photo) def sendAudio(self, chat_id, audio, thumb=None, @@ -557,10 +556,9 @@ def sendAudio(self, chat_id, audio, :param audio: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['thumb']) - files_key = 'audio' - files = {files_key: audio} - if thumb != None: files['thumb'] = thumb - return self._api_request_with_file('sendAudio', _rectify(p), files_key, files) + files_value = {'audio': audio} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendAudio', _rectify(p), 'audio', files_value) def sendDocument(self, chat_id, document, thumb=None, @@ -575,10 +573,9 @@ def sendDocument(self, chat_id, document, :param document: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['thumb']) - files_key = 'document' - files = {files_key: document} - if thumb != None: files['thumb'] = thumb - return self._api_request_with_file('sendDocument', _rectify(p), files_key, files) + files_value = {'document': document} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendDocument', _rectify(p), 'document', files_value) def sendVideo(self, chat_id, video, thumb=None, @@ -597,10 +594,9 @@ def sendVideo(self, chat_id, video, :param video: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['thumb']) - files_key = 'video' - files = {files_key: video} - if thumb != None: files['thumb'] = thumb - return self._api_request_with_file('sendVideo', _rectify(p), files_key, files) + files_value = {'video': video} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendVideo', _rectify(p), 'video', files_value) def sendAnimation(self, chat_id, animation, thumb=None, @@ -618,10 +614,9 @@ def sendAnimation(self, chat_id, animation, :param video: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['thumb']) - files_key = 'animation' - files = {files_key: animation} - if thumb != None: files['thumb'] = thumb - return self._api_request_with_file('sendAnimation', _rectify(p), files_key, files) + files_value = {'animation': animation} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendAnimation', _rectify(p), 'animation', files_value) def sendVoice(self, chat_id, voice, caption=None, @@ -636,8 +631,8 @@ def sendVoice(self, chat_id, voice, :param voice: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['voice']) - files = {'voice': voice} - return self._api_request_with_file('sendVoice', _rectify(p), files_key, files) + voice = {'voice': voice} + return self._api_request_with_file('sendVoice', _rectify(p), 'voice', voice) def sendVideoNote(self, chat_id, video_note, thumb=None, @@ -657,9 +652,9 @@ def sendVideoNote(self, chat_id, video_note, on the video note's display size. """ p = _strip(locals(), more=['thumb']) - files = {'video_note': video_note} - if thumb != None: files['thumb'] = thumb - return self._api_request_with_file('sendVideoNote', _rectify(p), files_key, files) + files_value = {'video_note': video_note} + if thumb != None: files_value['thumb'] = thumb + return self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', files_value) def sendMediaGroup(self, chat_id, media, disable_notification=None, @@ -974,9 +969,8 @@ def sendSticker(self, chat_id, sticker, :param sticker: Same as ``photo`` in :meth:`telepot.Bot.sendPhoto` """ p = _strip(locals(), more=['sticker']) - files_key = 'sticker' - files = {files_key: sticker} - return self._api_request_with_file('sendSticker', _rectify(p), files_key, files) + files = {'sticker': sticker} + return self._api_request_with_file('sendSticker', _rectify(p), 'sticker', files) def getStickerSet(self, name): """ @@ -990,9 +984,8 @@ def uploadStickerFile(self, user_id, png_sticker): See: https://core.telegram.org/bots/api#uploadstickerfile """ p = _strip(locals(), more=['png_sticker']) - files_key = 'png_sticker' - files = {files_key: png_sticker} - return self._api_request_with_file('uploadStickerFile', _rectify(p), files_key, files) + png_sticker = {'png_sticker': 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, @@ -1001,9 +994,8 @@ def createNewStickerSet(self, user_id, name, title, png_sticker, emojis, See: https://core.telegram.org/bots/api#createnewstickerset """ p = _strip(locals(), more=['png_sticker']) - files_key = 'png_sticker' - files = {files_key: png_sticker} - return self._api_request_with_file('createNewStickerSet', _rectify(p), files_key, files) + png_sticker = {'png_sticker': 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): @@ -1011,9 +1003,8 @@ def addStickerToSet(self, user_id, name, png_sticker, emojis, See: https://core.telegram.org/bots/api#addstickertoset """ p = _strip(locals(), more=['png_sticker']) - files_key = 'png_sticker' - files = {files_key: png_sticker} - return self._api_request_with_file('addStickerToSet', _rectify(p), files_key, files) + png_sticker = {'png_sticker': png_sticker} + return self._api_request_with_file('addStickerToSet', _rectify(p), 'png_sticker', png_sticker) def setStickerPositionInSet(self, sticker, position): """ diff --git a/telepot/aio/__init__.py b/telepot/aio/__init__.py index 8e688fa..96fdeb5 100644 --- a/telepot/aio/__init__.py +++ b/telepot/aio/__init__.py @@ -78,13 +78,13 @@ async def _api_request(self, method, params=None, files=None, **kwargs): return await api.request((self._token, method, params, files), **kwargs) async def _api_request_with_file(self, method, params, file_key, file_value, **kwargs): - if _isstring(file_value): - params[file_key] = file_value - return await self._api_request(method, _rectify(params), **kwargs) + if _isstring(files[files_key]): + params[files_key] = files[files_key] + del files[files_key] + if files != {}: + return self._api_request(method, _rectify(params), files, **kwargs) else: - files = {file_key: file_value} - return await self._api_request(method, _rectify(params), files, **kwargs) - + return self._api_request(method, _rectify(params), **kwargs) async def getMe(self): """ See: https://core.telegram.org/bots/api#getme """ return await self._api_request('getMe') @@ -123,6 +123,7 @@ async def sendPhoto(self, chat_id, photo, filename is a unicode string. """ p = _strip(locals(), more=['photo']) + photo = {'photo': photo} return await self._api_request_with_file('sendPhoto', _rectify(p), 'photo', photo) async def sendAudio(self, chat_id, audio, @@ -139,8 +140,10 @@ async def sendAudio(self, chat_id, audio, :param audio: Same as ``photo`` in :meth:`telepot.aio.Bot.sendPhoto` """ - p = _strip(locals(), more=['audio']) - return await self._api_request_with_file('sendAudio', _rectify(p), 'audio', audio) + p = _strip(locals(), more=['thumb']) + files_value = {'audio': audio} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendAudio', _rectify(p), 'audio', files_value) async def sendDocument(self, chat_id, document, caption=None, @@ -153,8 +156,10 @@ async def sendDocument(self, chat_id, document, :param document: Same as ``photo`` in :meth:`telepot.aio.Bot.sendPhoto` """ - p = _strip(locals(), more=['document']) - return await self._api_request_with_file('sendDocument', _rectify(p), 'document', document) + p = _strip(locals(), more=['thumb']) + files_value = {'document': document} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendDocument', _rectify(p), 'document', files_value) async def sendVideo(self, chat_id, video, duration=None, @@ -171,8 +176,30 @@ async def sendVideo(self, chat_id, video, :param video: Same as ``photo`` in :meth:`telepot.aio.Bot.sendPhoto` """ - p = _strip(locals(), more=['video']) - return await self._api_request_with_file('sendVideo', _rectify(p), 'video', video) + p = _strip(locals(), more=['thumb']) + files_value = {'video': video} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendVideo', _rectify(p), 'video', files_value) + + async def sendAnimation(self, chat_id, animation, + thumb=None, + duration=None, + width=None, + height=None, + caption=None, + parse_mode=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=['thumb']) + files_value = {'animation': animation} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendAnimation', _rectify(p), 'animation', files_value) async def sendVoice(self, chat_id, voice, caption=None, @@ -187,6 +214,7 @@ async def sendVoice(self, chat_id, voice, :param voice: Same as ``photo`` in :meth:`telepot.aio.Bot.sendPhoto` """ p = _strip(locals(), more=['voice']) + voice = {'voice': voice} return await self._api_request_with_file('sendVoice', _rectify(p), 'voice', voice) async def sendVideoNote(self, chat_id, video_note, @@ -205,8 +233,10 @@ async def sendVideoNote(self, chat_id, video_note, 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 await self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', video_note) + p = _strip(locals(), more=['thumb']) + files_value = {'video_note': video_note} + if thumb != None: files_value['thumb'] = thumb + return await self._api_request_with_file('sendVideoNote', _rectify(p), 'video_note', files_value) async def sendMediaGroup(self, chat_id, media, disable_notification=None, @@ -371,6 +401,7 @@ async def exportChatInviteLink(self, chat_id): async def setChatPhoto(self, chat_id, photo): """ See: https://core.telegram.org/bots/api#setchatphoto """ p = _strip(locals(), more=['photo']) + photo = {'photo': photo} return await self._api_request_with_file('setChatPhoto', _rectify(p), 'photo', photo) async def deleteChatPhoto(self, chat_id): @@ -520,6 +551,7 @@ async def sendSticker(self, chat_id, sticker, :param sticker: Same as ``photo`` in :meth:`telepot.aio.Bot.sendPhoto` """ p = _strip(locals(), more=['sticker']) + sticker = {'sticker': sticker} return await self._api_request_with_file('sendSticker', _rectify(p), 'sticker', sticker) async def getStickerSet(self, name): @@ -534,6 +566,7 @@ async def uploadStickerFile(self, user_id, png_sticker): See: https://core.telegram.org/bots/api#uploadstickerfile """ p = _strip(locals(), more=['png_sticker']) + png_sticker = {'png_sticker': png_sticker} return await self._api_request_with_file('uploadStickerFile', _rectify(p), 'png_sticker', png_sticker) async def createNewStickerSet(self, user_id, name, title, png_sticker, emojis, @@ -543,6 +576,7 @@ async def createNewStickerSet(self, user_id, name, title, png_sticker, emojis, See: https://core.telegram.org/bots/api#createnewstickerset """ p = _strip(locals(), more=['png_sticker']) + png_sticker = {'png_sticker': png_sticker} return await self._api_request_with_file('createNewStickerSet', _rectify(p), 'png_sticker', png_sticker) async def addStickerToSet(self, user_id, name, png_sticker, emojis, @@ -551,6 +585,7 @@ async def addStickerToSet(self, user_id, name, png_sticker, emojis, See: https://core.telegram.org/bots/api#addstickertoset """ p = _strip(locals(), more=['png_sticker']) + png_sticker = {'png_sticker': png_sticker} return await self._api_request_with_file('addStickerToSet', _rectify(p), 'png_sticker', png_sticker) async def setStickerPositionInSet(self, sticker, position): diff --git a/telepot/api.py b/telepot/api.py index 80b7904..235cc57 100644 --- a/telepot/api.py +++ b/telepot/api.py @@ -125,7 +125,7 @@ def _transform(req, **user_kw): pool = _create_onetime_pool() else: pool = _pools[name] - + return pool.request_encode_body, ('POST', url, fields), kwargs def _parse(response): @@ -161,4 +161,4 @@ def _fileurl(req): def download(req, **user_kw): pool = _create_onetime_pool() r = pool.request('GET', _fileurl(req), **user_kw) - return r + return r \ No newline at end of file