Skip to content

Commit 533a81c

Browse files
committed
Merge branch 'feature/inline-keyboards'
2 parents 954a119 + 597fd55 commit 533a81c

File tree

22 files changed

+1619
-214
lines changed

22 files changed

+1619
-214
lines changed

botogram/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .runner import run
3535
from .objects import *
3636
from .utils import usernames_in
37+
from .callbacks import Buttons, ButtonsRow
3738

3839

3940
# This code will simulate the Windows' multiprocessing behavior if the

botogram/api.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,7 @@ def file_content(self, path):
182182
response = requests.get(url)
183183

184184
return response.content
185+
186+
@property
187+
def token(self):
188+
return self._api_key

botogram/bot.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import requests.exceptions
2626

2727
from . import api
28+
from . import callbacks
2829
from . import objects
2930
from . import runner
3031
from . import defaults
@@ -54,6 +55,8 @@ def __init__(self, api_connection):
5455

5556
self.process_backlog = False
5657

58+
self.validate_callback_signatures = True
59+
5760
self._lang = ""
5861
self._lang_inst = None
5962

@@ -87,6 +90,7 @@ def __init__(self, api_connection):
8790
messages.process_channel_post)
8891
self.register_update_processor("edited_channel_post",
8992
messages.process_channel_post_edited)
93+
self.register_update_processor("callback_query", callbacks.process)
9094

9195
self._bot_id = str(uuid.uuid4())
9296

@@ -117,6 +121,13 @@ def __reduce__(self):
117121
return object.__reduce__(self)
118122

119123
def __setattr__(self, name, value):
124+
# Warn about disabled callback validation
125+
if name == "validate_callback_signatures" and not value:
126+
self.logger.warn("Your code disabled signature validation for "
127+
"callbacks!")
128+
self.logger.warn("This can cause security issues. Please enable "
129+
"it again.")
130+
120131
# Use the standard __setattr__
121132
return object.__setattr__(self, name, value)
122133

@@ -178,6 +189,13 @@ def __(func):
178189
return func
179190
return __
180191

192+
def callback(self, name):
193+
"""Register a new callback"""
194+
def __(func):
195+
self._main_component.add_callback(name, func)
196+
return func
197+
return __
198+
181199
def timer(self, interval):
182200
"""Register a new timer"""
183201
def __(func):
@@ -234,8 +252,10 @@ def run(self, workers=2):
234252
def register_update_processor(self, kind, processor):
235253
"""Register a new update processor"""
236254
if kind in self._update_processors:
237-
raise NameError("An update processor for \"%s\" updates is "
238-
"already registered" % kind)
255+
self.logger.warn("Your code replaced the default update processor "
256+
"for '%s'!" % kind)
257+
self.logger.warn("If you want botogram to handle those updates "
258+
"natively remove your processor.")
239259

240260
self._update_processors[kind] = processor
241261

@@ -247,6 +267,7 @@ def freeze(self):
247267
return frozenbot.FrozenBot(self.api, self.about, self.owner,
248268
self._hide_commands, self.before_help,
249269
self.after_help, self.link_preview_in_help,
270+
self.validate_callback_signatures,
250271
self.process_backlog, self.lang,
251272
self.itself, self._commands_re,
252273
self._commands, chains, self._scheduler,

botogram/callbacks.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright (c) 2015-2017 The Botogram Authors (see AUTHORS)
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19+
20+
import base64
21+
import binascii
22+
import hashlib
23+
24+
from . import crypto
25+
from .context import ctx
26+
27+
28+
DIGEST = hashlib.md5
29+
DIGEST_LEN = 16
30+
31+
32+
class ButtonsRow:
33+
"""A row of an inline keyboard"""
34+
35+
def __init__(self):
36+
self._content = []
37+
38+
def url(self, label, url):
39+
"""Open an URL when the button is pressed"""
40+
self._content.append({"text": label, "url": url})
41+
42+
def callback(self, label, callback, data=None):
43+
"""Trigger a callback when the button is pressed"""
44+
def generate_callback_data():
45+
c = ctx()
46+
47+
name = "%s:%s" % (c.component_name(), callback)
48+
return get_callback_data(c.bot, c.chat(), name, data)
49+
50+
self._content.append({
51+
"text": label,
52+
"callback_data": generate_callback_data,
53+
})
54+
55+
def switch_inline_query(self, label, query="", current_chat=False):
56+
"""Switch the user to this bot's inline query"""
57+
if current_chat:
58+
self._content.append({
59+
"text": label,
60+
"switch_inline_query_current_chat": query,
61+
})
62+
else:
63+
self._content.append({
64+
"text": label,
65+
"switch_inline_query": query,
66+
})
67+
68+
def _get_content(self):
69+
"""Get the content of this row"""
70+
for item in self._content:
71+
new = item.copy()
72+
73+
# Replace any callable with its value
74+
# This allows to dynamically generate field values
75+
for key, value in new.items():
76+
if callable(value):
77+
new[key] = value()
78+
79+
yield new
80+
81+
82+
class Buttons:
83+
"""Factory for inline keyboards"""
84+
85+
def __init__(self):
86+
self._rows = {}
87+
88+
def __getitem__(self, index):
89+
if index not in self._rows:
90+
self._rows[index] = ButtonsRow()
91+
return self._rows[index]
92+
93+
def _serialize_attachment(self):
94+
rows = [
95+
list(row._get_content()) for i, row in sorted(
96+
tuple(self._rows.items()), key=lambda i: i[0]
97+
)
98+
]
99+
100+
return {"inline_keyboard": rows}
101+
102+
103+
def parse_callback_data(bot, chat, raw):
104+
"""Parse the callback data generated by botogram and return it"""
105+
raw = raw.encode("utf-8")
106+
107+
if len(raw) < 32:
108+
raise crypto.TamperedMessageError
109+
110+
try:
111+
prelude = base64.b64decode(raw[:32])
112+
except binascii.Error:
113+
raise crypto.TamperedMessageError
114+
115+
signature = prelude[:16]
116+
name = prelude[16:]
117+
data = raw[32:]
118+
119+
# Don't check the signature if the user explicitly disabled the check
120+
if bot.validate_callback_signatures:
121+
correct = get_signature(bot, chat, name, data)
122+
if not crypto.compare(correct, signature):
123+
raise crypto.TamperedMessageError
124+
125+
if data:
126+
return name, data.decode("utf-8")
127+
else:
128+
return name, None
129+
130+
131+
def get_callback_data(bot, chat, name, data=None):
132+
"""Get the callback data for the provided name and data"""
133+
name = hashed_callback_name(name)
134+
135+
if data is None:
136+
data = ""
137+
data = data.encode("utf-8")
138+
139+
if len(data) > 32:
140+
raise ValueError(
141+
"The provided data is too big (%s bytes), try to reduce it to "
142+
"32 bytes" % len(data)
143+
)
144+
145+
# Get the signature of the hook name and data
146+
signature = get_signature(bot, chat, name, data)
147+
148+
# Base64 the signature and the hook name together to save space
149+
return (base64.b64encode(signature + name) + data).decode("utf-8")
150+
151+
152+
def get_signature(bot, chat, name, data):
153+
"""Generate a signature for the provided information"""
154+
chat_id = str(chat.id).encode("utf-8")
155+
return crypto.get_hmac(bot, name + b'\0' + chat_id + b'\0' + data)
156+
157+
158+
def hashed_callback_name(name):
159+
"""Get the hashed name of a callback"""
160+
# Get only the first 8 bytes of the hash to fit it into the payload
161+
return DIGEST(name.encode("utf-8")).digest()[:8]
162+
163+
164+
def process(bot, chains, update):
165+
"""Process a callback sent to the bot"""
166+
chat = update.callback_query.message.chat
167+
raw = update.callback_query._data
168+
169+
try:
170+
name, data = parse_callback_data(bot, chat, raw)
171+
except crypto.TamperedMessageError:
172+
bot.logger.warn(
173+
"The user tampered with the #%s update's data. Skipped it."
174+
% update.update_id
175+
)
176+
return
177+
178+
for hook in chains["callbacks"]:
179+
bot.logger.debug("Processing update #%s with the hook %s" %
180+
(update.update_id, hook.name))
181+
182+
result = hook.call(bot, update, name, data)
183+
if result is True:
184+
bot.logger.debug("Update #%s was just processed by the %s hook" %
185+
(update.update_id, hook.name))
186+
return
187+
188+
bot.logger.debug("No hook actually processed the #%s update." %
189+
update.update_id)

botogram/components.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __new__(cls, *args, **kwargs):
3737
self = super(Component, cls).__new__(cls)
3838

3939
self.__commands = {}
40+
self.__callbacks = {}
4041
self.__processors = []
4142
self.__no_commands = []
4243
self.__before_processors = []
@@ -132,6 +133,19 @@ def add_command(self, name, func, hidden=False, order=0, _from_main=False):
132133
command = commands.Command(hook)
133134
self.__commands[name] = command
134135

136+
def add_callback(self, name, func):
137+
"""Add a new callback"""
138+
if name in self.__callbacks:
139+
raise NameError("The callback %s already exists" % name)
140+
141+
if not callable(func):
142+
raise ValueError("A callback must be callable")
143+
144+
hook = hooks.CallbackHook(func, self, {
145+
"name": name,
146+
})
147+
self.__callbacks[name] = hook
148+
135149
def add_timer(self, interval, func):
136150
"""Register a new timer"""
137151
if not callable(func):
@@ -212,7 +226,11 @@ def _get_chains(self):
212226
"chat_unavalable_hooks": [self.__chat_unavailable_hooks],
213227
"messages_edited": [self.__messages_edited_hooks],
214228
"channel_post": [self.__channel_post_hooks],
215-
"channel_post_edited": [self.__channel_post_edited_hooks]
229+
"channel_post_edited": [self.__channel_post_edited_hooks],
230+
"callbacks": [[
231+
self.__callbacks[name]
232+
for name in sorted(self.__callbacks.keys())
233+
]],
216234
}
217235

218236
def _get_commands(self):

botogram/context.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright (c) 2015-2017 The Botogram Authors (see AUTHORS)
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19+
# DEALINGS IN THE SOFTWARE.
20+
21+
import threading
22+
23+
24+
_local = threading.local()
25+
_local._botogram_context = []
26+
27+
28+
class Context:
29+
"""Context of an hook call"""
30+
31+
def __init__(self, bot, hook, update):
32+
self.bot = bot
33+
self.hook = hook
34+
self.update = update
35+
36+
def __enter__(self):
37+
_local._botogram_context.append(self)
38+
39+
def __exit__(self, *_):
40+
_local._botogram_context.pop()
41+
42+
def bot_username(self):
43+
"""Get the username of the bot"""
44+
return self.bot.itself.username
45+
46+
def component_name(self):
47+
"""Get the name of the current component"""
48+
return self.hook.component.component_name
49+
50+
def chat(self):
51+
"""Get the current chat"""
52+
if self.update:
53+
return self.update.chat()
54+
55+
56+
def ctx():
57+
"""Get the current context"""
58+
if _local._botogram_context:
59+
return _local._botogram_context[-1]

0 commit comments

Comments
 (0)