Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bdae028c3 | ||
|
|
942862f171 | ||
|
|
e95f21d413 | ||
|
|
1aeec0c9d8 | ||
|
|
5fcb5b8900 | ||
|
|
f0237ecb0b | ||
|
|
4790a21f60 | ||
|
|
ea8d251142 | ||
|
|
2e61640f5a | ||
|
|
188b58d8e2 | ||
|
|
0f84b67b49 | ||
|
|
8013c8c8e4 | ||
|
|
11f8004c55 | ||
|
|
0487838942 | ||
|
|
ddea5ba06c | ||
|
|
5c7ced1549 | ||
|
|
118b24df8f | ||
|
|
0d8081be35 | ||
|
|
c59fc4ebc1 | ||
|
|
64538aa17f | ||
|
|
7e3bc13f14 | ||
|
|
e4ec20a5c4 | ||
|
|
cbc3b586a7 | ||
|
|
5cf69e3ea9 | ||
|
|
04a3efd3fb | ||
|
|
29d7118833 | ||
|
|
7c6abd5558 | ||
|
|
d9d37f4a5f | ||
|
|
71e905b0be |
@@ -2,3 +2,4 @@
|
||||
venv
|
||||
config.json
|
||||
*.yaml
|
||||
docs/
|
||||
|
||||
1
.gitignore
vendored
@@ -6,3 +6,4 @@ __pycache__
|
||||
*.pyc
|
||||
config.json
|
||||
docker-compose-release.yaml
|
||||
docs/build
|
||||
|
||||
10
README.md
@@ -1,11 +1,12 @@
|
||||
# OLGram
|
||||
|
||||
[@olgram](https://t.me/olgrambot) - конструктор ботов обратной связи в Telegram
|
||||
|
||||
[](https://github.com/civsocit/olgram/actions?workflow=Linter)
|
||||
[](https://github.com/civsocit/olgram/actions?workflow=Deploy)
|
||||
[](https://olgram.readthedocs.io)
|
||||
|
||||

|
||||
[@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 и соберите его:
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,4 +11,5 @@ fi
|
||||
|
||||
sleep 10
|
||||
aerich upgrade
|
||||
python migrate.py
|
||||
python main.py
|
||||
|
||||
20
docs/Makefile
Normal 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
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/images/added.jpg
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/images/botfather.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/images/botfathernew.png
Normal file
|
After Width: | Height: | Size: 560 KiB |
BIN
docs/images/botfathertoken.png
Normal file
|
After Width: | Height: | Size: 636 KiB |
BIN
docs/images/chat1.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/images/chat2.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
BIN
docs/images/logo1_big.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
BIN
docs/images/start.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
docs/images/test.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/images/test2.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/images/text1.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/images/text2.jpg
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/images/text3.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
35
docs/make.bat
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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>`_.
|
||||
96
docs/source/quick_start.rst
Normal 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>`_.
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 202 KiB |
9
migrate.py
Normal 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())
|
||||
@@ -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):
|
||||
"""
|
||||
Пользователь выбрал чат, в который хочет получать сообщения от бота
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
olgram/migrations/custom.py
Normal 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()
|
||||
4
olgram/migrations/models/4_20210926165918_update.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- upgrade --
|
||||
ALTER TABLE "bot" ADD "second_text" TEXT;
|
||||
-- downgrade --
|
||||
ALTER TABLE "bot" DROP COLUMN "second_text";
|
||||
10
olgram/migrations/models/5_20210926185420_update.sql
Normal 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";
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
52
olgram/utils/permissions.py
Normal 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
@@ -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"]
|
||||
@@ -5,3 +5,4 @@ python-dotenv~=0.17.1
|
||||
aioredis==1.3.1
|
||||
aiocache
|
||||
aiohttp
|
||||
pycrypto
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||