diff --git a/.gitignore b/.gitignore index 7dace7a..79f3fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ docs/build ad.md release.env test.py +backup + diff --git a/docs/images/inline.gif b/docs/images/inline.gif new file mode 100644 index 0000000..308ef13 Binary files /dev/null and b/docs/images/inline.gif differ diff --git a/docs/images/settemplates.jpg b/docs/images/settemplates.jpg new file mode 100644 index 0000000..d6bed2b Binary files /dev/null and b/docs/images/settemplates.jpg differ diff --git a/docs/source/index.rst b/docs/source/index.rst index 360b55f..c2c3bd9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ about quick_start + templates developer additional diff --git a/docs/source/quick_start.rst b/docs/source/quick_start.rst index 3969b9a..f3d9132 100644 --- a/docs/source/quick_start.rst +++ b/docs/source/quick_start.rst @@ -4,7 +4,7 @@ Как создать бота ---------------- -Перейдите по ссылке `@Olgram `_ и нажмите Запустить: +Перейдите по ссылке `@OlgramBot `_ и нажмите Запустить: .. image:: ../images/start.jpg diff --git a/docs/source/templates.rst b/docs/source/templates.rst new file mode 100644 index 0000000..765ad2c --- /dev/null +++ b/docs/source/templates.rst @@ -0,0 +1,32 @@ +Шаблоны ответов +============= + +Иногда в поддержке приходится отвечать на однотипные вопросы однотипными ответами. Например: + + Q. ``Здравствуйте! Когда будет доставлен мой заказ?`` + + A. ``Добрый день. Ваш заказ принят в обработку. Среднее время доставки 2-4 дня. Мы уведомим вас об изменении статуса заказа`` + +Чтобы не печатать каждый раз одинаковые тексты, в Olgram можно задать список шаблонных ответов. Тогда диалог с +пользователем может выглядеть так: + +.. image:: ../images/inline.gif + :width: 300 + +Заметьте, чтобы увидеть список вариантов ответов, нужно упомянуть вашего feedback бота и нажать пробел + +Как настроить шаблоны +--------------------- + +Шаблоны можно задать в меню Olgram бота Текст -> Автоответчик -> Шаблоны ответов. + +.. image:: ../images/settemplates.jpg + :width: 300 + +Обязательно включите inline mode в вашем feedback боте. Для этого отправьте @BotFather команду ``/setinline`` +и следуйте инструкциям + +.. note:: + + Может пройти несколько минут, прежде чем добавленные в OlgramBot шаблоны появятся в списке вашего feedback бота + diff --git a/example.env b/example.env index d62510a..02b44f2 100644 --- a/example.env +++ b/example.env @@ -24,3 +24,6 @@ WEBHOOK_PORT=8443 CUSTOM_CERT=true REDIS_PATH=redis://redis + +# Set log level, can be CRITICAL, ERROR, WARNING, INFO, DEBUG. By default it set to INFO. +LOGLEVEL= diff --git a/olgram/commands/menu.py b/olgram/commands/menu.py index 3eddf09..79029cb 100644 --- a/olgram/commands/menu.py +++ b/olgram/commands/menu.py @@ -1,11 +1,11 @@ from olgram.router import dp from aiogram import types, Bot as AioBot -from olgram.models.models import Bot, User +from olgram.models.models import Bot, User, DefaultAnswer from aiogram.dispatcher import FSMContext from aiogram.utils.callback_data import CallbackData from textwrap import dedent -from olgram.utils.mix import edit_or_create, button_text_limit +from olgram.utils.mix import edit_or_create, button_text_limit, wrap from olgram.commands import bot_actions import typing as ty @@ -158,7 +158,7 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = ) text = dedent(""" - Сейчас вы редактируете текст, который отправляется после того, как пользователь отправит вашему боту {0} + Сейчас вы редактируете текст, который отправляется после того, как пользователь отправит вашему боту @{0} команду /start Текущий текст: @@ -188,6 +188,11 @@ async def send_bot_second_text_menu(bot: Bot, call: ty.Optional[types.CallbackQu callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="text", chat=empty)) ) + keyboard.insert( + types.InlineKeyboardButton(text="Шаблоны ответов...", + callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="templates", + chat=empty)) + ) keyboard.insert( types.InlineKeyboardButton(text="Сбросить текст", callback_data=menu_callback.new(level=3, bot_id=bot.id, @@ -211,6 +216,43 @@ async def send_bot_second_text_menu(bot: Bot, call: ty.Optional[types.CallbackQu await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML") +async def send_bot_templates_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None, + chat_id: ty.Optional[int] = None): + if call: + await call.answer() + keyboard = types.InlineKeyboardMarkup(row_width=2) + keyboard.insert( + types.InlineKeyboardButton(text="<< Завершить редактирование", + callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty)) + ) + + text = dedent(""" + Сейчас вы редактируете шаблоны ответов для @{0}. Текущие шаблоны: + +
+    {1}
+    
+ Отправьте какую-нибудь фразу (например: "Ваш заказ готов, ожидайте!"), чтобы добавить её в шаблон. + Чтобы удалить шаблон из списка, отправьте его номер в списке (например, 4) + """) + + templates = await bot.answers + + total_text_len = sum(len(t.text) for t in templates) + len(text) # примерная длина текста + max_len = 1000 + if total_text_len > 4000: + max_len = 100 + + templates_text = "\n".join(f"{n}. {wrap(template.text, max_len)}" for n, template in enumerate(templates)) + if not templates_text: + templates_text = "(нет шаблонов)" + text = text.format(bot.name, templates_text) + if call: + await edit_or_create(call, text, keyboard, parse_mode="HTML") + else: + await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML") + + @dp.message_handler(state="wait_start_text", content_types="text", regexp="^[^/].+") # Not command async def start_text_received(message: types.Message, state: FSMContext): async with state.proxy() as proxy: @@ -231,6 +273,38 @@ async def second_text_received(message: types.Message, state: FSMContext): await send_bot_second_text_menu(bot, chat_id=message.chat.id) +@dp.message_handler(state="wait_template", content_types="text", regexp="^[^/](.+)?") # Not command +async def template_received(message: types.Message, state: FSMContext): + async with state.proxy() as proxy: + bot_id = proxy.get("bot_id") + bot = await Bot.get_or_none(pk=bot_id) + + if message.text.isdigit(): + # Delete template + number = int(message.text) + templates = await bot.answers + if not templates: + await message.answer("У вас нет шаблонов, чтобы их удалять") + if number < 0 or number >= len(templates): + await message.answer(f"Неправильное число. Чтобы удалить шаблон, введите число от 0 до {len(templates)}") + return + await templates[number].delete() + else: + # Add template + total_templates = len(await bot.answers) + if total_templates > 30: + await message.answer("У вашего бота уже слишком много шаблонов") + else: + answers = await bot.answers.filter(text=message.text) + if answers: + await message.answer("Такой текст уже есть в списке шаблонов") + else: + template = DefaultAnswer(text=message.text, bot=bot) + await template.save() + + await send_bot_templates_menu(bot, chat_id=message.chat.id) + + @dp.callback_query_handler(menu_callback.filter(), state="*") async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext): level = callback_data.get("level") @@ -245,6 +319,7 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon return if level == "1": + await state.reset_state() return await send_bot_menu(bot, call) operation = callback_data.get("operation") @@ -276,3 +351,8 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon if operation == "reset_second_text": await bot_actions.reset_bot_second_text(bot, call) return await send_bot_second_text_menu(bot, call) + if operation == "templates": + await state.set_state("wait_template") + async with state.proxy() as proxy: + proxy["bot_id"] = bot.id + return await send_bot_templates_menu(bot, call) diff --git a/olgram/migrations/models/7_20220210194635_update.sql b/olgram/migrations/models/7_20220210194635_update.sql new file mode 100644 index 0000000..f1f9392 --- /dev/null +++ b/olgram/migrations/models/7_20220210194635_update.sql @@ -0,0 +1,7 @@ +-- upgrade -- +CREATE TABLE IF NOT EXISTS "defaultanswer" ( + "id" BIGSERIAL NOT NULL PRIMARY KEY, + "bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE CASCADE +); +-- downgrade -- +DROP TABLE IF EXISTS "defaultanswer"; diff --git a/olgram/migrations/models/8_20220210201740_update.sql b/olgram/migrations/models/8_20220210201740_update.sql new file mode 100644 index 0000000..6f91d13 --- /dev/null +++ b/olgram/migrations/models/8_20220210201740_update.sql @@ -0,0 +1,4 @@ +-- upgrade -- +ALTER TABLE "defaultanswer" ADD "text" TEXT NOT NULL; +-- downgrade -- +ALTER TABLE "defaultanswer" DROP COLUMN "text"; diff --git a/olgram/models/models.py b/olgram/models/models.py index 42c5ed8..20f2e6d 100644 --- a/olgram/models/models.py +++ b/olgram/models/models.py @@ -83,3 +83,9 @@ class BannedUser(Model): class Meta: table = "bot_banned_user" + + +class DefaultAnswer(Model): + id = fields.BigIntField(pk=True) + bot = fields.ForeignKeyField("models.Bot", related_name="answers", on_delete=fields.relational.CASCADE) + text = fields.TextField() diff --git a/olgram/settings.py b/olgram/settings.py index ad08cda..53761f4 100644 --- a/olgram/settings.py +++ b/olgram/settings.py @@ -1,6 +1,7 @@ from dotenv import load_dotenv from abc import ABC import os +import logging from olgram.utils.crypto import Cryptor from functools import lru_cache @@ -30,7 +31,7 @@ class OlgramSettings(AbstractSettings): @classmethod def version(cls): - return "0.2.0" + return "0.3.0" @classmethod @lru_cache @@ -54,10 +55,6 @@ class ServerSettings(AbstractSettings): def hook_port(cls) -> int: return int(cls._get_env("WEBHOOK_PORT")) - @classmethod - def app_host(cls) -> str: - return "olgram" - @classmethod def app_port(cls) -> int: return 80 @@ -87,6 +84,8 @@ class ServerSettings(AbstractSettings): def append_text(cls) -> str: return "\n\nЭтот бот создан с помощью @OlgramBot" + logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) + class BotSettings(AbstractSettings): @classmethod diff --git a/olgram/utils/mix.py b/olgram/utils/mix.py index 0130a56..a5aa382 100644 --- a/olgram/utils/mix.py +++ b/olgram/utils/mix.py @@ -22,8 +22,11 @@ async def edit_or_create(call: CallbackQuery, message: str, parse_mode=parse_mode) -def button_text_limit(data: str) -> str: - max_len = 30 +def wrap(data: str, max_len: int) -> str: if len(data) > max_len: data = data[:max_len-4] + "..." return data + + +def button_text_limit(data: str) -> str: + return wrap(data, 30) diff --git a/server/custom.py b/server/custom.py index 520c102..540014b 100644 --- a/server/custom.py +++ b/server/custom.py @@ -11,7 +11,7 @@ import logging import typing as ty from olgram.settings import ServerSettings from olgram.models.models import Bot, GroupChat, BannedUser - +from server.inlines import inline_handler _logger = logging.getLogger(__name__) _logger.setLevel(logging.INFO) @@ -90,8 +90,8 @@ async def message_handler(message: types.Message, *args, **kwargs): await message.reply("Невозможно переслать сообщение (автор заблокировал бота?)", parse_mode="HTML") return - else: - # в супер-чате кто-то пишет сообщение сам себе + elif super_chat_id > 0: + # в супер-чате кто-то пишет сообщение сам себе, только для личных сообщений await message.forward(super_chat_id) # И отправить пользователю специальный текст, если он указан if bot.second_text: @@ -136,6 +136,12 @@ async def receive_left(message: types.Message): await bot.save() +async def receive_inline(inline_query): + _logger.info("inline handler") + bot = db_bot_instance.get() + return await inline_handler(inline_query, bot) + + async def receive_migrate(message: types.Message): bot = db_bot_instance.get() from_id = message.chat.id @@ -175,6 +181,7 @@ class CustomRequestHandler(WebhookRequestHandler): dp.register_message_handler(receive_left, content_types=[types.ContentType.LEFT_CHAT_MEMBER]) dp.register_message_handler(receive_migrate, content_types=[types.ContentType.MIGRATE_TO_CHAT_ID]) dp.register_message_handler(receive_group_create, content_types=[types.ContentType.GROUP_CHAT_CREATED]) + dp.register_inline_handler(receive_inline) return dp diff --git a/server/inlines.py b/server/inlines.py new file mode 100644 index 0000000..a020f4c --- /dev/null +++ b/server/inlines.py @@ -0,0 +1,56 @@ +from aiocache import cached +import hashlib +from aiogram.types import InlineQuery, InputTextMessageContent, InlineQueryResultArticle +from aiogram.bot import Bot as AioBot + +from olgram.models.models import Bot +import typing as ty + + +@cached(ttl=60) +async def get_phrases(bot: Bot) -> ty.List: + objects = await bot.answers + return [obj.text for obj in objects] + + +async def check_chat_member(chat_id: int, user_id: int, bot: AioBot) -> bool: + member = await bot.get_chat_member(chat_id, user_id) + return member.is_chat_member() + + +@cached(ttl=60) +async def check_permissions(inline_query: InlineQuery, bot: Bot): + user_id = inline_query.from_user.id + super_chat_id = await bot.super_chat_id() + + if super_chat_id == user_id: + return True + + if super_chat_id < 0: # Group chat + is_member = await check_chat_member(super_chat_id, user_id, inline_query.bot) + return is_member + + return False + + +async def inline_handler(inline_query: InlineQuery, bot: Bot): + # Check permissions at first + allow = await check_permissions(inline_query, bot) + if not allow: + return await inline_query.answer([]) # forbidden + + all_phrases = await get_phrases(bot) + phrases = [phrase for phrase in all_phrases if inline_query.query.lower() in phrase.lower()] + items = [] + for phrase in phrases: + + input_content = InputTextMessageContent(phrase) + result_id: str = hashlib.md5(phrase.encode()).hexdigest() + item = InlineQueryResultArticle( + id=result_id, + title=phrase, + input_message_content=input_content, + ) + items.append(item) + + await inline_query.answer(results=items) diff --git a/server/server.py b/server/server.py index 97bbb49..727aa0f 100644 --- a/server/server.py +++ b/server/server.py @@ -65,5 +65,5 @@ def main(): runner = web.AppRunner(app) loop.run_until_complete(runner.setup()) logger.info("Server initialization done") - site = web.TCPSite(runner, host=ServerSettings.app_host(), port=ServerSettings.app_port(), ssl_context=context) + site = web.TCPSite(runner, port=ServerSettings.app_port(), ssl_context=context) return site