Lev
2 years ago
commit
015196d164
9 changed files with 459 additions and 0 deletions
@ -0,0 +1,218 @@ |
|||||||
|
import telebot |
||||||
|
import pymongo |
||||||
|
from functools import wraps |
||||||
|
from user import User |
||||||
|
from templates import TemplateProvider |
||||||
|
import emoji_data_python as edp |
||||||
|
from threading import Thread |
||||||
|
import utils |
||||||
|
import typing |
||||||
|
import traceback |
||||||
|
import time |
||||||
|
|
||||||
|
|
||||||
|
def locale_from_ietf(ietf_locale: str) -> str: |
||||||
|
if not ietf_locale: |
||||||
|
return 'en' |
||||||
|
ietf_locale = ietf_locale.split('-')[0] |
||||||
|
return 'ru' if ietf_locale in ['ru', 'be', 'uk'] else 'en' |
||||||
|
|
||||||
|
|
||||||
|
class Bot(telebot.TeleBot): |
||||||
|
def __init__(self, config: dict): |
||||||
|
self.name = config['name'] |
||||||
|
super().__init__(config['token']) |
||||||
|
self.me: telebot.types.User = self.get_me() |
||||||
|
self.db = getattr(pymongo.MongoClient(config['mongo']), self.name) |
||||||
|
self.db.users.create_index([('user_id', pymongo.ASCENDING)], unique=True) |
||||||
|
self.config = config |
||||||
|
self.templates = TemplateProvider() |
||||||
|
for template_file in config.get('template_files', []): |
||||||
|
self.templates.load_file(template_file) |
||||||
|
self.main_keyboard = None |
||||||
|
if config.get('main_keyboard') is not None: |
||||||
|
kb = config.get('main_keyboard') |
||||||
|
self.main_keyboard = {key: utils.create_keyboard(kb[key]) for key in kb.keys()} |
||||||
|
|
||||||
|
def render_template(self, template_name: str, locale: str = 'ru', **kwargs) -> \ |
||||||
|
typing.Tuple[str, typing.Union[telebot.types.ReplyKeyboardMarkup, telebot.types.InlineKeyboardMarkup]]: |
||||||
|
text: str = self.templates.render_template(template_name, locale, bot=self, **kwargs) |
||||||
|
text, keyboard = self.templates.separate_text_and_keyboards(text) |
||||||
|
keyboard = utils.create_keyboard(keyboard) \ |
||||||
|
if keyboard is not None \ |
||||||
|
else self.main_keyboard.get(locale, self.main_keyboard.get('ru')) |
||||||
|
return text, keyboard |
||||||
|
|
||||||
|
def render_message_for_user(self, user: User, template_name: str, **kwargs) -> \ |
||||||
|
typing.Tuple[str, typing.Union[telebot.types.ReplyKeyboardMarkup, telebot.types.InlineKeyboardMarkup]]: |
||||||
|
text: str = self.templates.render_template(template_name, user.locale, user=user, **kwargs) |
||||||
|
text, keyboard = self.templates.separate_text_and_keyboards(text) |
||||||
|
keyboard = utils.create_keyboard(keyboard) if keyboard is not None \ |
||||||
|
else self.main_keyboard.get(user.locale, self.main_keyboard.get('ru')) |
||||||
|
return text, keyboard |
||||||
|
|
||||||
|
def edit_message_with_template(self, query: telebot.types.CallbackQuery, template: str, **kwargs): |
||||||
|
msg = query.message |
||||||
|
text, keyboard = self.render_message_for_user(self.get_user_by_msg(msg), template, |
||||||
|
tg_user=msg.from_user, |
||||||
|
**kwargs) |
||||||
|
self.edit_message_text(text, msg.chat.id, msg.id, reply_markup=keyboard, parse_mode='HTML') |
||||||
|
self.answer_callback_query(query) |
||||||
|
|
||||||
|
def reply_with_template(self, msg: typing.Union[telebot.types.Message, telebot.types.CallbackQuery], template: str, |
||||||
|
as_reply: bool = False, locale_overwrite: typing.Optional[str] = None, **kwargs): |
||||||
|
if isinstance(msg, telebot.types.CallbackQuery): |
||||||
|
msg = msg.message |
||||||
|
text, keyboard = self.render_message_for_user(self.get_user_by_msg(msg), template, |
||||||
|
tg_user=msg.from_user, locale_overwrite=locale_overwrite, |
||||||
|
**kwargs) |
||||||
|
if as_reply: |
||||||
|
self.reply_to(msg, text, reply_markup=keyboard) |
||||||
|
else: |
||||||
|
self.send_message(msg.chat.id, text, reply_markup=keyboard, parse_mode='HTML') |
||||||
|
|
||||||
|
def get_user_from_db(self, request: dict) -> typing.Optional[User]: |
||||||
|
data = self.db.users.find_one(request) |
||||||
|
if data is None: |
||||||
|
return None |
||||||
|
return User.from_dict(data) |
||||||
|
|
||||||
|
def get_user_by_msg(self, msg: typing.Union[telebot.types.Message, telebot.types.InlineQuery]) -> User: |
||||||
|
tg_user: telebot.types.User = msg.from_user |
||||||
|
if tg_user.id == self.me.id and msg.chat.id > 0: |
||||||
|
user = User.by_id(msg.chat.id, self) or User.by_id(msg.from_user.id, self) |
||||||
|
else: |
||||||
|
user = User.by_id(msg.from_user.id, self) |
||||||
|
if user is not None: |
||||||
|
if tg_user.language_code: |
||||||
|
user.locale = locale_from_ietf(tg_user.language_code) |
||||||
|
return user |
||||||
|
else: |
||||||
|
user = User(msg.from_user.id, msg.from_user.id) |
||||||
|
user.locale = locale_from_ietf(tg_user.language_code) |
||||||
|
self.db.users.insert_one(user.dict()) |
||||||
|
return user |
||||||
|
|
||||||
|
def save(self, user: User): |
||||||
|
self.db.users.replace_one({'user_id': user.user_id}, user.dict()) |
||||||
|
|
||||||
|
def handle_commands(self, cmds: typing.List[str]): |
||||||
|
cmds = [edp.replace_colons(cmd) for cmd in cmds] |
||||||
|
|
||||||
|
def wrapper(func): |
||||||
|
@wraps(func) |
||||||
|
def func_wrapped(msg): |
||||||
|
user = self.get_user_by_msg(msg) |
||||||
|
args: str = msg.text |
||||||
|
for cmd in cmds: |
||||||
|
if args.startswith(cmd): |
||||||
|
if len(args.split(cmd)) <= 1: |
||||||
|
args = '' |
||||||
|
break |
||||||
|
args = cmd.join(args.split(cmd)[1:]).strip() |
||||||
|
break |
||||||
|
func(msg, user, args) |
||||||
|
|
||||||
|
if cmds[0].startswith('/'): |
||||||
|
self.message_handler(commands=[cmd[1:] for cmd in cmds])(func_wrapped) |
||||||
|
else: |
||||||
|
self.message_handler(func=lambda msg: any(msg.text.startswith(cmd) for cmd in cmds))(func_wrapped) |
||||||
|
return wrapper |
||||||
|
|
||||||
|
def handle_callback(self, name: str): |
||||||
|
def wrapper(func): |
||||||
|
@wraps(func) |
||||||
|
def func_wrapped(query): |
||||||
|
user = self.get_user_by_msg(query) |
||||||
|
args = query.data.strip()[len(name) + 1:] |
||||||
|
try: |
||||||
|
return func(query, user, args.strip()) |
||||||
|
except Exception: |
||||||
|
print(f'An exception occured: {traceback.format_exc()}') |
||||||
|
|
||||||
|
self.callback_query_handler( |
||||||
|
lambda query: query.data.strip().startswith(name + '_') or query.data.strip() == name)(func_wrapped) |
||||||
|
return func_wrapped |
||||||
|
|
||||||
|
return wrapper |
||||||
|
|
||||||
|
def inline_handler_(self, filter_func, **kwargs): |
||||||
|
def wrapper(func): |
||||||
|
@wraps(func) |
||||||
|
def handler(*args, **kwargs_2): |
||||||
|
def func_2(): |
||||||
|
try: |
||||||
|
func(*args, **kwargs_2) |
||||||
|
except Exception as e: |
||||||
|
print('An exception occured:', e) |
||||||
|
|
||||||
|
Thread(target=func_2).start() |
||||||
|
|
||||||
|
self.inline_handler(filter_func, **kwargs)(func) |
||||||
|
return handler |
||||||
|
|
||||||
|
return wrapper |
||||||
|
|
||||||
|
def iter_users(self) -> typing.Iterable[User]: |
||||||
|
for user_data in self.db.users.find(): |
||||||
|
yield User.from_dict(user_data) |
||||||
|
|
||||||
|
def register_next_step_handler(self, message, callback, *args, **kwargs): |
||||||
|
if isinstance(message, telebot.types.CallbackQuery): |
||||||
|
message = message.message |
||||||
|
|
||||||
|
def new_callback(new_message, *new_args, **new_kwargs): |
||||||
|
try: |
||||||
|
if new_message.text in ['🛑 Cancel', '🛑 Отменить']: |
||||||
|
# self.clear_step_handler_by_chat_id(new_message.chat.id) |
||||||
|
self.reply_with_template(new_message, 'canceled') |
||||||
|
return callback(new_message, *new_args, **new_kwargs) |
||||||
|
except: |
||||||
|
print(traceback.format_exc()) |
||||||
|
|
||||||
|
super().register_next_step_handler(message, new_callback, *args, **kwargs) |
||||||
|
|
||||||
|
def send_all_copy(self, message: telebot.types.Message): |
||||||
|
num = 0 |
||||||
|
failures = 0 |
||||||
|
for user in self.iter_users(): |
||||||
|
try: |
||||||
|
self.copy_message(user.user_id, message.chat.id, message.id) |
||||||
|
num += 1 |
||||||
|
except: |
||||||
|
print(traceback.format_exc()) |
||||||
|
failures += 1 |
||||||
|
print(f'Sent to {num} users. Failures: {failures}') |
||||||
|
|
||||||
|
def user_growth(self) -> typing.List[int]: |
||||||
|
timestamp = next(iter(self.db.users.find({}).sort('start_timestamp'))) |
||||||
|
data = list() |
||||||
|
while timestamp < time.time(): |
||||||
|
data.append(self.db.users.find({'start_timestamp': {'$lt': timestamp}}).count_documents()) |
||||||
|
timestamp += 24 * 3600 |
||||||
|
return data |
||||||
|
|
||||||
|
def stats(self) -> typing.Dict[str, int]: |
||||||
|
total_users = self.db.users.count_documents({}) |
||||||
|
new_users_today = self.db.users.count_documents( |
||||||
|
{'start_timestamp': {'$gt': int(time.time() - 24 * 3600)}} |
||||||
|
) |
||||||
|
new_users_month = self.db.users.count_documents( |
||||||
|
{'start_timestamp': {'$gt': int(time.time() - 24 * 3600 * 30)}} |
||||||
|
) |
||||||
|
active_users_today = self.db.users.count_documents( |
||||||
|
{'last_action_timestamp': {'$gt': int(time.time() - 24 * 3600)}} |
||||||
|
) |
||||||
|
active_users_month = self.db.users.count_documents( |
||||||
|
{'last_action_timestamp': {'$gt': int(time.time() - 24 * 3600 * 30)}} |
||||||
|
) |
||||||
|
return { |
||||||
|
'Total users': total_users, |
||||||
|
'New users in the last 24 hours': new_users_today, |
||||||
|
'New users last 30 days': new_users_month, |
||||||
|
'Active users in the last 24 hours': active_users_today, |
||||||
|
'Active users in the last 30 days': active_users_month |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,5 @@ |
|||||||
|
pyTelegramBotAPI |
||||||
|
pymongo |
||||||
|
attrs |
||||||
|
jinja2 |
||||||
|
emoji_data_python |
@ -0,0 +1,72 @@ |
|||||||
|
import attr |
||||||
|
import typing |
||||||
|
import jinja2 |
||||||
|
import emoji_data_python as edp |
||||||
|
|
||||||
|
|
||||||
|
""" |
||||||
|
Template format: |
||||||
|
|
||||||
|
||<| template_name |>|| |
||||||
|
//| locale_1 |// |
||||||
|
Text for locale 1 |
||||||
|
>>| Reply Keyboard: row 1, button 1;; Row 1, button 2 |<< |
||||||
|
>>| Row 2, button 1 :-: callback_data_for_inline_keyboard |<< |
||||||
|
//| locale_2 |// |
||||||
|
Text for locale 2 |
||||||
|
""" |
||||||
|
|
||||||
|
|
||||||
|
KeyboardMarkup = typing.List[typing.List[typing.Union[str, typing.Tuple[str, str]]]] |
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True) |
||||||
|
class TemplateProvider: |
||||||
|
# {template: {locale: jinja template}} |
||||||
|
templates: typing.Dict[str, typing.Dict[str, jinja2.Template]] = attr.Factory(dict) |
||||||
|
locales: typing.Tuple[str] = ('', 'both', 'en', 'ru') |
||||||
|
|
||||||
|
def get_template(self, template_name: str, locale: str) -> jinja2.Template: |
||||||
|
locales = [locale] + list(self.locales) |
||||||
|
template = self.templates[template_name] |
||||||
|
for locale in locales: |
||||||
|
if locale in template.keys(): |
||||||
|
return template[locale] |
||||||
|
return next(template.values()) |
||||||
|
|
||||||
|
def render_template(self, template_name: str, locale: str, **kwargs) -> str: |
||||||
|
return edp.replace_colons( |
||||||
|
self.get_template(template_name, locale) |
||||||
|
.render(**kwargs, **__builtins__)).strip() |
||||||
|
|
||||||
|
def add_template(self, template_string: str): |
||||||
|
template_name = template_string.split('||<|')[1].split('|>||')[0].strip() |
||||||
|
template: typing.Dict[str, jinja2.Template] = dict() |
||||||
|
for locale_string in template_string.split('//|')[1:]: |
||||||
|
locale_name: str = locale_string.split('|//')[0].strip() |
||||||
|
locale_text: str = '|//'.join(locale_string.split('|//')[1:]).strip() |
||||||
|
template[locale_name] = jinja2.Environment(loader=jinja2.FileSystemLoader('./')).from_string(locale_text) |
||||||
|
self.templates[template_name] = template |
||||||
|
|
||||||
|
def load_file(self, filename: str): |
||||||
|
file_content = open(filename).read() |
||||||
|
for template_string in file_content.split('||<|')[1:]: |
||||||
|
template_string = '||<|' + template_string |
||||||
|
self.add_template(edp.replace_colons(template_string)) |
||||||
|
|
||||||
|
@staticmethod |
||||||
|
def separate_text_and_keyboards(text: str) -> typing.Tuple[str, typing.Optional[KeyboardMarkup]]: |
||||||
|
pure_text = text.split('>>|')[0].strip() |
||||||
|
if len(text.split('>>|')) <= 1: |
||||||
|
return pure_text, None |
||||||
|
keyboard_rows = [row.split('|<<')[0].strip() for row in text.split('>>|')[1:]] |
||||||
|
keyboard: KeyboardMarkup = list() |
||||||
|
for row_raw in keyboard_rows: |
||||||
|
row = list() |
||||||
|
for btn in row_raw.split(';;'): |
||||||
|
if ':-:' in btn: |
||||||
|
row.append(tuple(btn.split(':-:'))) |
||||||
|
else: |
||||||
|
row.append(btn) |
||||||
|
keyboard.append(row) |
||||||
|
return pure_text, keyboard |
@ -0,0 +1,24 @@ |
|||||||
|
||<| start |>|| |
||||||
|
//| ru |// |
||||||
|
Здравствуйте, <b>{{ tg_user.first_name }}</b>! |
||||||
|
//| en |// |
||||||
|
Hello, <b>{{ tg_user.first_name }}</b>! |
||||||
|
|
||||||
|
||<| help |>|| |
||||||
|
//| en |// |
||||||
|
/help - Help |
||||||
|
//| ru |// |
||||||
|
/help - помощь |
||||||
|
|
||||||
|
||<| admin_stats |>|| |
||||||
|
//| |// |
||||||
|
<b>Stats:</b> |
||||||
|
{% for key in stats.keys() %}<i>{{ key }}:</i> {{ stats[key] }} |
||||||
|
{% endfor %} |
||||||
|
|
||||||
|
||<| canceled |>|| |
||||||
|
//| en |// |
||||||
|
Canceled |
||||||
|
//| ru |// |
||||||
|
Отменено |
||||||
|
|
@ -0,0 +1,40 @@ |
|||||||
|
from __future__ import annotations |
||||||
|
from dataclasses import dataclass, field, asdict |
||||||
|
import attr |
||||||
|
import typing |
||||||
|
import time |
||||||
|
|
||||||
|
|
||||||
|
@dataclass |
||||||
|
class User: |
||||||
|
user_id: int |
||||||
|
chat_id: int = 0 |
||||||
|
locale: str = '' |
||||||
|
start_timestamp: int = field(default_factory=lambda: int(time.time())) |
||||||
|
last_action_timestamp: int = field(default_factory=lambda: int(time.time())) |
||||||
|
|
||||||
|
def dict(self) -> dict: |
||||||
|
data = asdict(self) |
||||||
|
data['last_action_timestamp'] = int(time.time()) |
||||||
|
return data |
||||||
|
|
||||||
|
@classmethod |
||||||
|
def from_dict(cls, data: dict) -> User: |
||||||
|
data = getattr(data, '__dict__', data) |
||||||
|
data_ = {key: data[key] for key in data.keys() if key in ['user_id', 'chat_id', 'locale']} |
||||||
|
self = cls(**data_) |
||||||
|
return self |
||||||
|
|
||||||
|
@classmethod |
||||||
|
def by_id(cls, user_id: int, bot) -> typing.Optional[User]: |
||||||
|
data = bot.get_user_from_db({'user_id': user_id}) |
||||||
|
if data is None: |
||||||
|
return None |
||||||
|
return data |
||||||
|
|
||||||
|
@property |
||||||
|
def id(self): |
||||||
|
return self._id |
||||||
|
|
||||||
|
def is_admin(self) -> bool: |
||||||
|
return self.user_id in [218952152] |
@ -0,0 +1,42 @@ |
|||||||
|
import telebot |
||||||
|
import typing |
||||||
|
import random |
||||||
|
import string |
||||||
|
|
||||||
|
|
||||||
|
def generate_id() -> str: |
||||||
|
return ''.join(random.choice(string.digits + string.ascii_lowercase) for _ in range(8)) |
||||||
|
|
||||||
|
|
||||||
|
def isint(st: str) -> bool: |
||||||
|
try: |
||||||
|
int(st) |
||||||
|
return True |
||||||
|
except ValueError: |
||||||
|
return False |
||||||
|
|
||||||
|
|
||||||
|
def create_inline_button(text: str, callback_data: str) -> telebot.types.InlineKeyboardButton: |
||||||
|
if callback_data.strip().startswith('https://') or callback_data.startswith('tg://'): |
||||||
|
return telebot.types.InlineKeyboardButton(text, url=callback_data) |
||||||
|
if callback_data.strip().startswith('inline://'): |
||||||
|
telebot.types.InlineKeyboardButton(text, |
||||||
|
switch_inline_query_current_chat=callback_data.replace('inline://', '')) |
||||||
|
if callback_data.strip().startswith('inline_other://'): |
||||||
|
telebot.types.InlineKeyboardButton(text, switch_inline_query=callback_data.replace('inline_other://', '')) |
||||||
|
return telebot.types.InlineKeyboardButton(text, callback_data=callback_data) |
||||||
|
|
||||||
|
|
||||||
|
def create_keyboard(rows: typing.List[typing.List[str]]): |
||||||
|
is_inline = not isinstance(rows[0][0], str) |
||||||
|
markup = telebot.types.InlineKeyboardMarkup() if is_inline else telebot.types.ReplyKeyboardMarkup( |
||||||
|
resize_keyboard=True) |
||||||
|
for row in rows: |
||||||
|
if is_inline: |
||||||
|
markup.row(*( |
||||||
|
create_inline_button(text, callback_data) |
||||||
|
for text, callback_data in row |
||||||
|
)) |
||||||
|
else: |
||||||
|
markup.row(*(telebot.types.KeyboardButton(text) for text in row)) |
||||||
|
return markup |
Loading…
Reference in new issue