1
0
mirror of https://github.com/civsocit/olgram.git synced 2025-12-16 15:56:17 +00:00

29 Commits

Author SHA1 Message Date
mihalin
3bdae028c3 version bump 2021-10-01 22:48:35 +03:00
mihalin
942862f171 update docker-compose-full example 2021-10-01 20:08:42 +03:00
mihalin
e95f21d413 ADMIN_ID документация 2021-10-01 20:06:09 +03:00
mihalin
1aeec0c9d8 Возможность ограничивать права на бота 2021-10-01 19:48:23 +03:00
mihalin
5fcb5b8900 запуск в режиме отладки 2021-09-28 01:43:57 +03:00
mihalin
f0237ecb0b добавил про шифрование в документацию 2021-09-27 05:01:28 +03:00
mihalin
4790a21f60 version increment 2021-09-26 20:42:30 +03:00
mihalin
ea8d251142 flake8 fix, server back 2021-09-26 20:37:51 +03:00
mihalin
2e61640f5a Шифрование токенов 2021-09-26 20:36:05 +03:00
mihalin
188b58d8e2 Добавлен второй текст бота 2021-09-26 19:06:03 +03:00
mihalin
0f84b67b49 Добавлен второй текст бота 2021-09-26 18:15:46 +03:00
mihalin
8013c8c8e4 мелкая правка по докам 2021-09-24 15:11:24 +03:00
mihalin
11f8004c55 убран tab в команде start 2021-09-24 14:38:15 +03:00
mihalin
0487838942 опечатка 2021-09-24 14:29:25 +03:00
mihalin
ddea5ba06c больше ботов на одного пользователя 2021-09-24 14:09:01 +03:00
mihalin
5c7ced1549 dot 2021-09-24 03:07:49 +03:00
mihalin
118b24df8f flake8 fixes 2021-09-24 02:59:14 +03:00
mihalin
0d8081be35 docks in start message 2021-09-24 02:57:57 +03:00
mihalin
c59fc4ebc1 minor documentation changes 2021-09-24 02:55:21 +03:00
mihalin
64538aa17f minor вщс changes 2021-09-24 02:02:13 +03:00
mihalin
7e3bc13f14 minor changes 2021-09-22 21:41:28 +03:00
mihalin
e4ec20a5c4 minor changes 2021-09-22 21:38:22 +03:00
mihalin
cbc3b586a7 minor changes 2021-09-22 21:34:15 +03:00
mihalin
5cf69e3ea9 minor changes 2021-09-22 21:17:26 +03:00
mihalin
04a3efd3fb quick start 2021-09-22 21:00:50 +03:00
mihalin
29d7118833 documentation first iteration 2021-09-22 19:19:11 +03:00
mihalin
7c6abd5558 flake8 2021-09-17 21:26:52 +03:00
mihalin
d9d37f4a5f version bump 2021-09-17 21:20:20 +03:00
mihalin
71e905b0be chat menu more description 2021-09-17 21:20:09 +03:00
47 changed files with 632 additions and 36 deletions

View File

@@ -2,3 +2,4 @@
venv
config.json
*.yaml
docs/

3
.gitignore vendored
View File

@@ -5,4 +5,5 @@ venv
__pycache__
*.pyc
config.json
docker-compose-release.yaml
docker-compose-release.yaml
docs/build

View File

@@ -1,11 +1,12 @@
# OLGram
[@olgram](https://t.me/olgrambot) - конструктор ботов обратной связи в Telegram
[![Static Analysis Status](https://github.com/civsocit/olgram/workflows/Linter/badge.svg)](https://github.com/civsocit/olgram/actions?workflow=Linter)
[![Deploy Status](https://github.com/civsocit/olgram/workflows/Deploy/badge.svg)](https://github.com/civsocit/olgram/actions?workflow=Deploy)
[![Documentation](https://readthedocs.org/projects/olgram/badge/?version=latest)](https://olgram.readthedocs.io)
![Logo](media/logo1_big.png)
[@olgram](https://t.me/olgrambot) - конструктор ботов обратной связи в Telegram
Документация: https://olgram.readthedocs.io
## Возможности и преимущества Olgram Bot
@@ -25,9 +26,10 @@
### Для разработчиков: сборка и запуск проекта
Вам потребуется собственный VPS или любой хост со статическим адресом или доменом.
* Создайте файл .env и заполните его по образцу example.env. Вам нужно заполнить переменные:
* Создайте файл .env по образцу example.env. Вам нужно заполнить переменные:
* BOT_TOKEN - токен нового бота, получить у [@botfather](https://t.me/botfather)
* POSTGRES_PASSWORD - любой случайный пароль
* TOKEN_ENCRYPTION_KEY - любой случайный пароль, отличный от POSTGRES_PASSWORD
* WEBHOOK_HOST - IP адрес или доменное имя сервера, на котором запускается проект
* Сохраните файл docker-compose.yaml и соберите его:
```

View File

@@ -1,7 +1,7 @@
version: '3'
services:
postgres:
image: postgres
image: postgres:13.4
restart: unless-stopped
env_file:
- release.env
@@ -9,6 +9,8 @@ services:
- database:/var/lib/postgresql/data
networks:
- traefik
labels:
- 'com.centurylinklabs.watchtower.enable="false"'
redis:
image: 'bitnami/redis:latest'
restart: unless-stopped
@@ -20,6 +22,8 @@ services:
- release.env
networks:
- traefik
labels:
- 'com.centurylinklabs.watchtower.enable="false"'
olgram:
image: ghcr.io/civsocit/olgram/bot:stable
restart: unless-stopped
@@ -71,6 +75,8 @@ services:
- --certificatesresolvers.le.acme.tlschallenge=false
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
labels:
- 'com.centurylinklabs.watchtower.enable="false"'
volumes:
database:

View File

@@ -11,4 +11,5 @@ fi
sleep 10
aerich upgrade
python migrate.py
python main.py

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

BIN
docs/images/addbot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/images/added.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
docs/images/botfather.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

BIN
docs/images/chat1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
docs/images/chat2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/images/logo1_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/images/start.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
docs/images/test.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
docs/images/test2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
docs/images/text1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

BIN
docs/images/text2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

BIN
docs/images/text3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

40
docs/source/about.rst Normal file
View File

@@ -0,0 +1,40 @@
О проекте
===================================
Зачем нужен Olgram
------------
Olgram - это конструктор ботов обратной связи. Такие боты могут вам пригодиться, например:
*Пример 1.* Вы администрируете Telegram-канал и хотите дать своим подписчикам возможность связаться с вами,
но не хотите оставлять свои личные контакты. Тогда вы можете создать бота обратной связи: подписчики будут писать
боту, вы будете отвечать через бота анонимно.
*Пример 2.* Вы организуете небольшой call-центр в Telegram или группу технической поддержки. С помощью бота обратной
связи вы можете принимать заявки от пользователей в общий чат ваших специалистов, обсуждать эти заявки и отвечать
пользователям прямо из этого чата.
.. note::
Olgram - молодой развивающийся проект. Мы готовы расширить функционал бота под ваш сценарий использования, если он
не является слишком узкоспециализированным и пригодится другим пользователям. Напишите нам по этому вопросу
`@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.
Почему не Livegram?
------------
Наше принципиальное отличие от `Livegram <https://t.me/LivegramBot>`_ это открытость и безопасность.
* Olgram не хранит сообщения, которые вы пересылаете через него
* Код нашего проекта `полностью открыт <https://github.com/civsocit/olgram>`_
* Вы можете развернуть Olgram на своём собственном сервере (читайте :doc:`developer`)
* Наши сервера находятся в Германии, мы не подконтрольны российскому или белорусскому правительству
Всё это позволяет вам использовать Olgram (в отличие от Livegram) в политических и других серьёзных проектах.
Чтобы приступить к созданию своего первого бота, откройте главу :doc:`quick_start`
.. note::
Если у вас возникли вопросы по использованию бота, или вы нашли ошибку - напишите
нам `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.

35
docs/source/conf.py Normal file
View File

@@ -0,0 +1,35 @@
# Configuration file for the Sphinx documentation builder.
# -- Project information
project = 'Olgram'
copyright = '2021, Civsocit'
author = 'civsocit'
release = '0.1'
version = '0.1.0'
# -- General configuration
extensions = [
'sphinx.ext.duration',
'sphinx.ext.doctest',
'sphinx.ext.autodoc',
'sphinx.ext.autosummary',
'sphinx.ext.intersphinx',
]
intersphinx_mapping = {
'python': ('https://docs.python.org/3/', None),
'sphinx': ('https://www.sphinx-doc.org/en/master/', None),
}
intersphinx_disabled_domains = ['std']
templates_path = ['_templates']
# -- Options for HTML output
html_theme = 'sphinx_rtd_theme'
# -- Options for EPUB output
epub_show_urls = 'footnote'

52
docs/source/developer.rst Normal file
View File

@@ -0,0 +1,52 @@
Для разработчиков
=================
Сборка и запуск
---------------
Вы можете развернуть Olgram на своём сервере. Вам потребуется собственный VPS или любой хост со статическим адресом
или доменом.
1. Создайте файл .env и заполните его по образцу `example.env <https://github.com/civsocit/olgram/blob/main/example.env>`_
Вам нужно заполнить переменные:
* ``BOT_TOKEN`` - токен нового бота, получить у `@botfather <https://t.me/botfather>`_
* ``POSTGRES_PASSWORD`` - любой случайный пароль
* ``TOKEN_ENCRYPTION_KEY`` - любой случайный пароль, отличный от POSTGRES_PASSWORD
* ``WEBHOOK_HOST`` - IP адрес или доменное имя сервера, на котором запускается проект
2. Рядом с файлом .env сохраните файл
`docker-compose.yaml <https://github.com/civsocit/olgram/blob/main/docker-compose.yaml>`_ и соберите его:
.. code-block:: console
(bash) $ sudo docker-compose up -d
Готово, ваш собственный Olgram запущен!
.. warning::
Не потеряйте TOKEN_ENCRYPTION_KEY! Его нельзя восстановить. В случае утери TOKEN_ENCRYPTION_KEY вы потеряете
токены всех ботов, которые пользователи зарегистрировали в вашем боте.
Дополнительно
-------------
В docker-compose.yaml приведена минимальная конфигурация. Для использования в серьёзных проектах мы советуем:
* Приобрести домен и настроить его на свой хост
* Наладить реверс-прокси и автоматическое обновление сертификатов - например, с помощью `Traefik <https://github.com/traefik/traefik>`_
* Скрыть IP сервера с помощью `Cloudflire <https://www.cloudflare.com>`_, чтобы пользователи ботов не могли найти IP адрес хоста по Webhook бота.
Пример более сложной конфигурации есть в файле `docker-compose-full.yaml <https://github.com/civsocit/olgram/blob/main/docker-compose-full.yaml>`_
Как ограничить доступ к своему боту
-----------------------------------
По-умолчанию все пользователи Telegram могут писать в ваш Olgram и регистрировать там своих ботов. Чтобы ограничить
доступ к боту, укажите в переменных окружения (файл .env):
``ADMIN_ID=<идентификатор чата>``
Идентификатор чата это либо ваш Telegram ID, либо ID группового чата Telegram. Идентификатор можно посмотреть
командой /chatid.

22
docs/source/index.rst Normal file
View File

@@ -0,0 +1,22 @@
Добро пожаловать в документацию Olgram
===================================
**Olgram** `@olgrambot <https://t.me/olgrambot>`_ это конструктор, который позволяет создавать боты обратной связи
в Telegram. После подключения к Olgram пользователи вашего бота смогут писать сообщения, которые будут
пересылаться вам в чат, где вы сможете на них ответить. Читайте больше о проекте в главе :doc:`about`.
Откройте главу :doc:`quick_start` чтобы приступить к созданию своего первого бота!
Оглавление
--------
.. toctree::
about
quick_start
developer
.. note::
Если у вас возникли вопросы по использованию бота, или вы нашли ошибку - напишите
нам `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.

View File

@@ -0,0 +1,96 @@
Быстрый старт
=============
Как создать бота
----------------
Перейдите по ссылке `@Olgram <https://t.me/olgrambot>`_ и нажмите Запустить:
.. image:: ../images/start.jpg
:width: 300
Нажмите ссылку "addbot":
.. image:: ../images/addbot.jpg
:width: 300
И перейдите по ссылке создания бота:
.. image:: ../images/botfather.jpg
:width: 300
BotFather - это официальный бот Telegram, создающий другие боты, которые и будут помогать вам управлять каналами.
Запустите его как и Olgram bot и кликните по ссылке "/newbot":
.. image:: ../images/botfathernew.png
:width: 300
В диалоге прописываете, как вы хотите что бы назывался бот, и название ссылки, ведущей к этому боту. В итоге вы
получаете токен:
.. image:: ../images/botfathertoken.png
:width: 300
Скопируйте этот токен и отправьте в Olgram:
.. image:: ../images/added.jpg
:width: 300
Готово! Бот добавлен в Olgram. Теперь для человека,желающего что-то спросить, бот будет выглядеть примерно так:
.. image:: ../images/test.jpg
:width: 300
Для вас же это будет выглядеть так:
.. image:: ../images/test2.jpg
:width: 300
Как изменить текст приветствия
------------------------------
По-умолчанию ваш бот после запуска отправляет приветственное сообщение:
Здравствуйте! Напишите свой вопрос, и мы ответим вам в ближайшее время
Вы можете настроить этот текст. Для этого откройте список ботов командой /mybots и выберите нужного бота:
.. image:: ../images/text1.jpg
:width: 300
В появившемся меню выберите "Текст"
.. image:: ../images/text2.jpg
:width: 300
Теперь просто отправьте новый текст приветствия.
Как привязать бота к групповому чату
------------------------------------
По-умолчанию ваш бот пересылает сообщения от пользователей вам в личные сообщения. Бота можно привязать к групповому
чату. Для этого добавьте его в групповой чат. Затем откройте список ботов, как в примере выше, выберите нужного бота
и нажмите кнопку "Чат":
.. image:: ../images/chat1.jpg
:width: 300
Затем выберите в списке тот чат, в который добавили бота
.. image:: ../images/chat2.jpg
:width: 300
Готово. Теперь сообщения от пользователей будут пересылаться в групповой чат.
.. note::
Нужно сначала зарегистрировать своего бота в Olgram, и только потом добавить в групповой чат. Если бот уже
добавлен в групповой чат, удалите его оттуда и добавьте заново - тогда Olgram сможет пересылать туда сообщения.
.. note::
Если у вас возникли вопросы по использованию бота, или вы нашли ошибку - напишите
нам `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.

View File

@@ -5,6 +5,10 @@ POSTGRES_PASSWORD=SOME_RANDOM_PASSWORD_HERE # example: x2y0n27ihiez93kmzj82
POSTGRES_DB=olgram
POSTGRES_HOST=postgres
TOKEN_ENCRYPTION_KEY=SOME_RANDOM_PASSWORD_HERE # example: i7flci0mx4z5patxnl6m
# ADMIN_ID=223453418 # use your user id or group chat id to restrict access to the bot
WEBHOOK_HOST=YOUR_HOST_HERE # example: 11.143.142.140 or my_domain.com
WEBHOOK_PORT=8443 # allowed: 80, 443, 8080, 8443
CUSTOM_CERT=true # use that if you don't set up your own domain and let's encrypt certificate

15
main.py
View File

@@ -1,8 +1,10 @@
import asyncio
import argparse
from tortoise import Tortoise
from olgram.router import dp
from olgram.settings import TORTOISE_ORM
from olgram.settings import TORTOISE_ORM, OlgramSettings
from olgram.utils.permissions import AccessMiddleware
from server.custom import init_redis
import olgram.commands.bots # noqa: F401
@@ -21,7 +23,8 @@ async def init_database():
async def init_olgram():
from olgram.router import bot
from olgram.router import bot, dp
dp.setup_middleware(AccessMiddleware(OlgramSettings.admin_id()))
from aiogram.types import BotCommand
await bot.set_my_commands(
[
@@ -43,11 +46,17 @@ def main():
"""
Classic polling
"""
parser = argparse.ArgumentParser("Olgram server")
parser.add_argument("--noserver", help="Не запускать сервер обратной связи, только сам Olgram (режим для "
"разработки)", action="store_true")
args = parser.parse_args()
loop = asyncio.get_event_loop()
loop.run_until_complete(initialization())
loop.create_task(dp.start_polling())
loop.create_task(server_main().start())
if not args.noserver:
loop.create_task(server_main().start())
loop.run_forever()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

9
migrate.py Normal file
View File

@@ -0,0 +1,9 @@
import asyncio
import logging
from olgram.migrations.custom import migrate
logging.basicConfig(level=logging.INFO)
if __name__ == '__main__':
asyncio.get_event_loop().run_until_complete(migrate())

View File

@@ -2,7 +2,7 @@
Здесь работа с конкретным ботом
"""
from aiogram import types
from aiogram.utils.exceptions import TelegramAPIError
from aiogram.utils.exceptions import TelegramAPIError, Unauthorized
from olgram.models.models import Bot
from server.server import unregister_token
@@ -11,7 +11,11 @@ async def delete_bot(bot: Bot, call: types.CallbackQuery):
"""
Пользователь решил удалить бота
"""
await unregister_token(bot.token)
try:
await unregister_token(bot.decrypted_token())
except Unauthorized:
# Вероятно пользователь сбросил токен или удалил бот, это уже не наши проблемы
pass
await bot.delete()
await call.answer("Бот удалён")
try:
@@ -32,6 +36,18 @@ async def reset_bot_text(bot: Bot, call: types.CallbackQuery):
await call.answer("Текст сброшен")
async def reset_bot_second_text(bot: Bot, call: types.CallbackQuery):
"""
Пользователь решил сбросить second text бота
:param bot:
:param call:
:return:
"""
bot.second_text = bot._meta.fields_map['second_text'].default
await bot.save()
await call.answer("Текст сброшен")
async def select_chat(bot: Bot, call: types.CallbackQuery, chat: str):
"""
Пользователь выбрал чат, в который хочет получать сообщения от бота

View File

@@ -99,7 +99,8 @@ async def bot_added(message: types.Message, state: FSMContext):
return await on_unknown_error()
user, _ = await User.get_or_create(telegram_id=message.from_user.id)
bot = Bot(token=token, owner=user, name=test_bot_info.username, super_chat_id=message.from_user.id)
bot = Bot(token=Bot.encrypted_token(token), owner=user, name=test_bot_info.username,
super_chat_id=message.from_user.id)
try:
await bot.save()
except IntegrityError:

View File

@@ -76,7 +76,9 @@ async def send_chats_menu(bot: Bot, call: types.CallbackQuery):
if not chats:
text = dedent(f"""
Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот.
Чтобы подключить чат — просто добавьте бот @{bot.name} в чат.
Чтобы подключить чат — добавьте бот @{bot.name} в чат, откройте это меню ещё раз и выберите добавленный чат.
Если ваш бот состоял в групповом чате до того, как его добавили в Olgram - удалите бота из чата и добавьте
снова.
""")
else:
text = dedent(f"""
@@ -141,13 +143,18 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] =
await call.answer()
keyboard = types.InlineKeyboardMarkup(row_width=2)
keyboard.insert(
types.InlineKeyboardButton(text="Сбросить текст",
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="reset_text",
types.InlineKeyboardButton(text="<< Завершить редактирование",
callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty))
)
keyboard.insert(
types.InlineKeyboardButton(text="Следующий текст",
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="next_text",
chat=empty))
)
keyboard.insert(
types.InlineKeyboardButton(text="<< Завершить редактирование",
callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty))
types.InlineKeyboardButton(text="Сбросить текст",
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="reset_text",
chat=empty))
)
text = dedent("""
@@ -167,6 +174,43 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] =
await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML")
async def send_bot_second_text_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))
)
keyboard.insert(
types.InlineKeyboardButton(text="Предыдущий текст",
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="reset_second_text", chat=empty))
)
text = dedent("""
Сейчас вы редактируете текст автоответчика. Это сообщение отправляется в ответ на все входящие сообщения @{0} \
автоматически. По умолчанию оно отключено.
Текущий текст:
<pre>
{1}
</pre>
Отправьте сообщение, чтобы изменить текст.
""")
text = text.format(bot.name, bot.second_text if bot.second_text else "(отключено)")
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:
@@ -177,6 +221,16 @@ async def start_text_received(message: types.Message, state: FSMContext):
await send_bot_text_menu(bot, chat_id=message.chat.id)
@dp.message_handler(state="wait_second_text", content_types="text", regexp="^[^/].+") # Not command
async def second_text_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)
bot.second_text = message.text
await bot.save()
await send_bot_second_text_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")
@@ -214,3 +268,11 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
if operation == "reset_text":
await bot_actions.reset_bot_text(bot, call)
return await send_bot_text_menu(bot, call)
if operation == "next_text":
await state.set_state("wait_second_text")
async with state.proxy() as proxy:
proxy["bot_id"] = bot.id
return await send_bot_second_text_menu(bot, call)
if operation == "reset_second_text":
await bot_actions.reset_bot_second_text(bot, call)
return await send_bot_second_text_menu(bot, call)

View File

@@ -6,11 +6,13 @@ from aiogram import types
from aiogram.dispatcher import FSMContext
from textwrap import dedent
from olgram.settings import OlgramSettings
from olgram.utils.permissions import public
from olgram.router import dp
@dp.message_handler(commands=["start"], state="*")
@public()
async def start(message: types.Message, state: FSMContext):
"""
Команда /start
@@ -20,7 +22,8 @@ async def start(message: types.Message, state: FSMContext):
# TODO: locale
await message.answer(dedent("""
Olgram Bot — это конструктор ботов обратной связи в Telegram.
Olgram Bot — это конструктор ботов обратной связи в Telegram. Подробнее \
<a href="https://olgram.readthedocs.io">читайте здесь</a>.
Используйте эти команды, чтобы управлять этим ботом:
@@ -28,20 +31,26 @@ async def start(message: types.Message, state: FSMContext):
/mybots - управление ботами
/help - помощь
"""))
"""), parse_mode="html")
@dp.message_handler(commands=["help"], state="*")
@public()
async def help(message: types.Message, state: FSMContext):
"""
Команда /help
"""
await message.answer(dedent(f"""
О проекте https://telegra.ph/Olgram-09-15
Репозиторий https://github.com/civsocit/olgram
Поддержка: @civsocit_feedback_bot
Читайте инструкции на нашем сайте https://olgram.readthedocs.io
Техническая поддержка: @civsocit_feedback_bot
Версия {OlgramSettings.version()}
"""))
@dp.message_handler(commands=["chatid"], state="*")
@public()
async def chat_id(message: types.Message, state: FSMContext):
"""
Команда /chatid
"""
await message.answer(message.chat.id)

View File

@@ -0,0 +1,35 @@
"""Наши собственные миграции, которые нельзя описать на языке SQL и с которыми не справится TortoiseORM/Aerich"""
from tortoise import transactions, Tortoise
from olgram.settings import TORTOISE_ORM
from olgram.models.models import MetaInfo, Bot
import logging
async def upgrade_1():
"""Шифруем токены"""
meta_info = await MetaInfo.first()
if meta_info.version != 0:
logging.info("skip")
return
async with transactions.in_transaction():
bots = await Bot.all()
for bot in bots:
bot.token = bot.encrypted_token(bot.token)
await bot.save()
meta_info.version = 1
await meta_info.save()
logging.info("done")
# Не забудь добавить миграцию в этот лист!
_migrations = [upgrade_1]
async def migrate():
logging.info("Run custom migrations...")
await Tortoise.init(config=TORTOISE_ORM)
for migration in _migrations:
logging.info(f"Migration {migration.__name__}...")
await migration()

View File

@@ -0,0 +1,4 @@
-- upgrade --
ALTER TABLE "bot" ADD "second_text" TEXT;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "second_text";

View File

@@ -0,0 +1,10 @@
-- upgrade --
ALTER TABLE "bot" ALTER COLUMN "token" TYPE VARCHAR(200) USING "token"::VARCHAR(200);
CREATE TABLE IF NOT EXISTS "_custom_meta_info" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" INT NOT NULL DEFAULT 0
);;
INSERT INTO _custom_meta_info (id, version) VALUES (0, 0) ON CONFLICT DO NOTHING;
-- downgrade --
ALTER TABLE "bot" ALTER COLUMN "token" TYPE VARCHAR(50) USING "token"::VARCHAR(50);
DROP TABLE IF EXISTS "_custom_meta_info";

View File

@@ -2,11 +2,27 @@ from tortoise.models import Model
from tortoise import fields
from uuid import uuid4
from textwrap import dedent
from olgram.settings import DatabaseSettings
class MetaInfo(Model):
id = fields.IntField(pk=True)
version = fields.IntField(default=0)
def __init__(self, **kwargs):
# Кажется это единственный способ сделать single-instance модель в TortoiseORM :(
if "id" in kwargs:
kwargs["id"] = 0
self.id = 0
super(MetaInfo, self).__init__(**kwargs)
class Meta:
table = '_custom_meta_info'
class Bot(Model):
id = fields.IntField(pk=True)
token = fields.CharField(max_length=50, unique=True)
token = fields.CharField(max_length=200, unique=True)
owner = fields.ForeignKeyField("models.User", related_name="bots")
name = fields.CharField(max_length=33)
code = fields.UUIDField(default=uuid4, index=True)
@@ -14,6 +30,7 @@ class Bot(Model):
Здравствуйте!
Напишите ваш вопрос и мы ответим вам в ближайшее время.
"""))
second_text = fields.TextField(null=True, default=None)
group_chats = fields.ManyToManyField("models.GroupChat", related_name="bots", on_delete=fields.relational.CASCADE,
null=True)
@@ -21,6 +38,15 @@ class Bot(Model):
on_delete=fields.relational.CASCADE,
null=True)
def decrypted_token(self):
cryptor = DatabaseSettings.cryptor()
return cryptor.decrypt(self.token)
@classmethod
def encrypted_token(cls, token: str):
cryptor = DatabaseSettings.cryptor()
return cryptor.encrypt(token)
async def super_chat_id(self):
group_chat = await self.group_chat
if group_chat:

View File

@@ -1,18 +1,22 @@
from dotenv import load_dotenv
from abc import ABC
import os
from olgram.utils.crypto import Cryptor
from functools import lru_cache
load_dotenv()
# TODO: рефакторинг, использовать какой-нибудь lazy-config вместо своих костылей
class AbstractSettings(ABC):
@classmethod
def _get_env(cls, parameter: str, allow_none: bool = False) -> str:
parameter = os.getenv(parameter, None)
if not parameter and not allow_none:
parameter_v = os.getenv(parameter, None)
if not parameter_v and not allow_none:
raise ValueError(f"{parameter} not defined in ENV")
return parameter
return parameter_v
class OlgramSettings(AbstractSettings):
@@ -22,11 +26,16 @@ class OlgramSettings(AbstractSettings):
Максимальное количество ботов у одного пользователя
:return: int
"""
return 5
return 10
@classmethod
def version(cls):
return "0.0.3"
return "0.1.1"
@classmethod
@lru_cache
def admin_id(cls):
return cls._get_env("ADMIN_ID", True)
class ServerSettings(AbstractSettings):
@@ -99,6 +108,12 @@ class DatabaseSettings(AbstractSettings):
def host(cls) -> str:
return cls._get_env("POSTGRES_HOST")
@classmethod
@lru_cache
def cryptor(cls) -> Cryptor:
password = cls._get_env("TOKEN_ENCRYPTION_KEY")
return Cryptor(password)
TORTOISE_ORM = {
"connections": {"default": f'postgres://{DatabaseSettings.user()}:{DatabaseSettings.password()}'

16
olgram/utils/crypto.py Normal file
View File

@@ -0,0 +1,16 @@
import base64
from Crypto.Cipher import AES
class Cryptor:
def __init__(self, password: str):
password = password.rjust(32)[:32]
self._cipher = AES.new(password.encode("utf-8"), AES.MODE_ECB)
def encrypt(self, data: str) -> str:
if data.startswith(" "):
raise ValueError("Data should not start with space!")
return base64.b64encode(self._cipher.encrypt(data.encode("utf-8").rjust(64))).decode("utf-8")
def decrypt(self, data: str) -> str:
return self._cipher.decrypt(base64.b64decode(data.encode("utf-8"))).decode("utf-8").lstrip()

View File

@@ -0,0 +1,52 @@
import aiogram.types as types
from aiogram.dispatcher.handler import CancelHandler, current_handler
from aiogram.dispatcher.middlewares import BaseMiddleware
def public():
"""
Хендлеры с этим декоратором будут обрабатываться даже если пользователь не является владельцем бота
(например, команда /help)
:return:
"""
def decorator(func):
setattr(func, "access_public", True)
return func
return decorator
class AccessMiddleware(BaseMiddleware):
def __init__(self, access_chat_id: int):
self._access_chat_id = access_chat_id
super(AccessMiddleware, self).__init__()
@classmethod
def _is_public_command(cls) -> bool:
handler = current_handler.get()
return handler and getattr(handler, "access_public", False)
async def on_process_message(self, message: types.Message, data: dict):
admin_id = self._access_chat_id
if not admin_id:
return # Администратор бота вообще не указан
if self._is_public_command(): # Эта команда разрешена всем пользователям
return
if message.chat.id != admin_id:
await message.answer("Владелец бота ограничил доступ к этому функционалу 😞")
raise CancelHandler()
async def on_process_callback_query(self, call: types.CallbackQuery, data: dict):
admin_id = self._access_chat_id
if not admin_id:
return # Администратор бота вообще не указан
if self._is_public_command(): # Эта команда разрешена всем пользователям
return
if call.message.chat.id != admin_id:
await call.answer("Владелец бота ограничил доступ к этому функционалу😞")
raise CancelHandler()

8
pyproject.toml Normal file
View File

@@ -0,0 +1,8 @@
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[project]
name = "olgram"
authors = [{name = "civsocit", email = "feedback@civsoc.it"}]
dynamic = ["version", "description"]

View File

@@ -4,4 +4,5 @@ aerich==0.5.4
python-dotenv~=0.17.1
aioredis==1.3.1
aiocache
aiohttp
aiohttp
pycrypto

View File

@@ -45,10 +45,14 @@ async def message_handler(message, *args, **kwargs):
# Это обычный чат: сообщение нужно переслать в супер-чат
new_message = await message.forward(super_chat_id)
await _redis.set(_message_unique_id(bot.pk, new_message.message_id), message.chat.id)
# И отправить пользователю специальный текст, если он указан
if bot.second_text:
return SendMessage(chat_id=message.chat.id, text=bot.second_text)
else:
# Это супер-чат
if message.reply_to_message:
# Ответ из супер-чата переслать тому пользователю,
# В супер-чате кто-то ответил на сообщение пользователя, нужно переслать тому пользователю
chat_id = await _redis.get(_message_unique_id(bot.pk, message.reply_to_message.message_id))
if not chat_id:
chat_id = message.reply_to_message.forward_from_chat
@@ -64,7 +68,11 @@ async def message_handler(message, *args, **kwargs):
parse_mode="HTML")
return
else:
# в супер-чате кто-то пишет сообщение сам себе
await message.forward(super_chat_id)
# И отправить пользователю специальный текст, если он указан
if bot.second_text:
return SendMessage(chat_id=message.chat.id, text=bot.second_text)
async def receive_invite(message: types.Message):
@@ -106,7 +114,7 @@ class CustomRequestHandler(WebhookRequestHandler):
if not bot:
return None
db_bot_instance.set(bot)
dp = Dispatcher(AioBot(bot.token))
dp = Dispatcher(AioBot(bot.decrypted_token()))
dp.register_message_handler(message_handler, content_types=[types.ContentType.TEXT,
types.ContentType.CONTACT,

View File

@@ -26,9 +26,9 @@ async def register_token(bot: Bot) -> bool:
:param bot: Бот
:return: получилось ли
"""
await unregister_token(bot.token)
await unregister_token(bot.decrypted_token())
a_bot = AioBot(bot.token)
a_bot = AioBot(bot.decrypted_token())
certificate = None
if ServerSettings.use_custom_cert():
certificate = open(ServerSettings.public_path(), 'rb')