mirror of
https://github.com/civsocit/olgram.git
synced 2023-07-22 01:29:12 +03:00
Merge branch 'main' into stable
This commit is contained in:
commit
4c22563974
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
BIN
docs/images/inline.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/settemplates.jpg
Normal file
BIN
docs/images/settemplates.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 175 KiB |
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
about
|
about
|
||||||
quick_start
|
quick_start
|
||||||
|
templates
|
||||||
developer
|
developer
|
||||||
additional
|
additional
|
||||||
|
|
||||||
|
@ -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
32
docs/source/templates.rst
Normal 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 бота
|
||||||
|
|
@ -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=
|
||||||
|
@ -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)
|
||||||
|
7
olgram/migrations/models/7_20220210194635_update.sql
Normal file
7
olgram/migrations/models/7_20220210194635_update.sql
Normal 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";
|
4
olgram/migrations/models/8_20220210201740_update.sql
Normal file
4
olgram/migrations/models/8_20220210201740_update.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- upgrade --
|
||||||
|
ALTER TABLE "defaultanswer" ADD "text" TEXT NOT NULL;
|
||||||
|
-- downgrade --
|
||||||
|
ALTER TABLE "defaultanswer" DROP COLUMN "text";
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
56
server/inlines.py
Normal 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)
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user