Merge branch 'main' into stable

This commit is contained in:
mihalin 2022-02-12 03:39:29 +03:00
commit 4c22563974
16 changed files with 215 additions and 15 deletions

2
.gitignore vendored
View File

@ -10,3 +10,5 @@ docs/build
ad.md ad.md
release.env release.env
test.py test.py
backup

BIN
docs/images/inline.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

View File

@ -14,6 +14,7 @@
about about
quick_start quick_start
templates
developer developer
additional additional

View File

@ -4,7 +4,7 @@
Как создать бота Как создать бота
---------------- ----------------
Перейдите по ссылке `@Olgram <https://t.me/olgrambot>`_ и нажмите Запустить: Перейдите по ссылке `@OlgramBot <https://t.me/olgrambot>`_ и нажмите Запустить:
.. image:: ../images/start.jpg .. image:: ../images/start.jpg

32
docs/source/templates.rst Normal file
View File

@ -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 бота

View File

@ -24,3 +24,6 @@ WEBHOOK_PORT=8443
CUSTOM_CERT=true CUSTOM_CERT=true
REDIS_PATH=redis://redis REDIS_PATH=redis://redis
# Set log level, can be CRITICAL, ERROR, WARNING, INFO, DEBUG. By default it set to INFO.
LOGLEVEL=

View File

@ -1,11 +1,11 @@
from olgram.router import dp from olgram.router import dp
from aiogram import types, Bot as AioBot 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.dispatcher import FSMContext
from aiogram.utils.callback_data import CallbackData from aiogram.utils.callback_data import CallbackData
from textwrap import dedent 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 from olgram.commands import bot_actions
import typing as ty import typing as ty
@ -158,7 +158,7 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] =
) )
text = dedent(""" text = dedent("""
Сейчас вы редактируете текст, который отправляется после того, как пользователь отправит вашему боту {0} Сейчас вы редактируете текст, который отправляется после того, как пользователь отправит вашему боту @{0}
команду /start команду /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", callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="text",
chat=empty)) chat=empty))
) )
keyboard.insert(
types.InlineKeyboardButton(text="Шаблоны ответов...",
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="templates",
chat=empty))
)
keyboard.insert( keyboard.insert(
types.InlineKeyboardButton(text="Сбросить текст", types.InlineKeyboardButton(text="Сбросить текст",
callback_data=menu_callback.new(level=3, bot_id=bot.id, 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") 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}. Текущие шаблоны:
<pre>
{1}
</pre>
Отправьте какую-нибудь фразу (например: "Ваш заказ готов, ожидайте!"), чтобы добавить её в шаблон.
Чтобы удалить шаблон из списка, отправьте его номер в списке (например, 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 @dp.message_handler(state="wait_start_text", content_types="text", regexp="^[^/].+") # Not command
async def start_text_received(message: types.Message, state: FSMContext): async def start_text_received(message: types.Message, state: FSMContext):
async with state.proxy() as proxy: 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) 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="*") @dp.callback_query_handler(menu_callback.filter(), state="*")
async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext): async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext):
level = callback_data.get("level") level = callback_data.get("level")
@ -245,6 +319,7 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
return return
if level == "1": if level == "1":
await state.reset_state()
return await send_bot_menu(bot, call) return await send_bot_menu(bot, call)
operation = callback_data.get("operation") 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": if operation == "reset_second_text":
await bot_actions.reset_bot_second_text(bot, call) await bot_actions.reset_bot_second_text(bot, call)
return await send_bot_second_text_menu(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)

View File

@ -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";

View File

@ -0,0 +1,4 @@
-- upgrade --
ALTER TABLE "defaultanswer" ADD "text" TEXT NOT NULL;
-- downgrade --
ALTER TABLE "defaultanswer" DROP COLUMN "text";

View File

@ -83,3 +83,9 @@ class BannedUser(Model):
class Meta: class Meta:
table = "bot_banned_user" 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()

View File

@ -1,6 +1,7 @@
from dotenv import load_dotenv from dotenv import load_dotenv
from abc import ABC from abc import ABC
import os import os
import logging
from olgram.utils.crypto import Cryptor from olgram.utils.crypto import Cryptor
from functools import lru_cache from functools import lru_cache
@ -30,7 +31,7 @@ class OlgramSettings(AbstractSettings):
@classmethod @classmethod
def version(cls): def version(cls):
return "0.2.0" return "0.3.0"
@classmethod @classmethod
@lru_cache @lru_cache
@ -54,10 +55,6 @@ class ServerSettings(AbstractSettings):
def hook_port(cls) -> int: def hook_port(cls) -> int:
return int(cls._get_env("WEBHOOK_PORT")) return int(cls._get_env("WEBHOOK_PORT"))
@classmethod
def app_host(cls) -> str:
return "olgram"
@classmethod @classmethod
def app_port(cls) -> int: def app_port(cls) -> int:
return 80 return 80
@ -87,6 +84,8 @@ class ServerSettings(AbstractSettings):
def append_text(cls) -> str: def append_text(cls) -> str:
return "\n\nЭтот бот создан с помощью @OlgramBot" return "\n\nЭтот бот создан с помощью @OlgramBot"
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
class BotSettings(AbstractSettings): class BotSettings(AbstractSettings):
@classmethod @classmethod

View File

@ -22,8 +22,11 @@ async def edit_or_create(call: CallbackQuery, message: str,
parse_mode=parse_mode) parse_mode=parse_mode)
def button_text_limit(data: str) -> str: def wrap(data: str, max_len: int) -> str:
max_len = 30
if len(data) > max_len: if len(data) > max_len:
data = data[:max_len-4] + "..." data = data[:max_len-4] + "..."
return data return data
def button_text_limit(data: str) -> str:
return wrap(data, 30)

View File

@ -11,7 +11,7 @@ import logging
import typing as ty import typing as ty
from olgram.settings import ServerSettings from olgram.settings import ServerSettings
from olgram.models.models import Bot, GroupChat, BannedUser from olgram.models.models import Bot, GroupChat, BannedUser
from server.inlines import inline_handler
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO) _logger.setLevel(logging.INFO)
@ -90,8 +90,8 @@ async def message_handler(message: types.Message, *args, **kwargs):
await message.reply("<i>Невозможно переслать сообщение (автор заблокировал бота?)</i>", await message.reply("<i>Невозможно переслать сообщение (автор заблокировал бота?)</i>",
parse_mode="HTML") parse_mode="HTML")
return return
else: elif super_chat_id > 0:
# в супер-чате кто-то пишет сообщение сам себе # в супер-чате кто-то пишет сообщение сам себе, только для личных сообщений
await message.forward(super_chat_id) await message.forward(super_chat_id)
# И отправить пользователю специальный текст, если он указан # И отправить пользователю специальный текст, если он указан
if bot.second_text: if bot.second_text:
@ -136,6 +136,12 @@ async def receive_left(message: types.Message):
await bot.save() 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): async def receive_migrate(message: types.Message):
bot = db_bot_instance.get() bot = db_bot_instance.get()
from_id = message.chat.id 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_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_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_message_handler(receive_group_create, content_types=[types.ContentType.GROUP_CHAT_CREATED])
dp.register_inline_handler(receive_inline)
return dp return dp

56
server/inlines.py Normal file
View File

@ -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)

View File

@ -65,5 +65,5 @@ def main():
runner = web.AppRunner(app) runner = web.AppRunner(app)
loop.run_until_complete(runner.setup()) loop.run_until_complete(runner.setup())
logger.info("Server initialization done") 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 return site