1
0
mirror of https://github.com/civsocit/olgram.git synced 2025-12-17 01:16:18 +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 venv
config.json config.json
*.yaml *.yaml
docs/

3
.gitignore vendored
View File

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

View File

@@ -1,11 +1,12 @@
# OLGram # 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) [![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) [![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 ## Возможности и преимущества Olgram Bot
@@ -25,9 +26,10 @@
### Для разработчиков: сборка и запуск проекта ### Для разработчиков: сборка и запуск проекта
Вам потребуется собственный VPS или любой хост со статическим адресом или доменом. Вам потребуется собственный VPS или любой хост со статическим адресом или доменом.
* Создайте файл .env и заполните его по образцу example.env. Вам нужно заполнить переменные: * Создайте файл .env по образцу example.env. Вам нужно заполнить переменные:
* BOT_TOKEN - токен нового бота, получить у [@botfather](https://t.me/botfather) * BOT_TOKEN - токен нового бота, получить у [@botfather](https://t.me/botfather)
* POSTGRES_PASSWORD - любой случайный пароль * POSTGRES_PASSWORD - любой случайный пароль
* TOKEN_ENCRYPTION_KEY - любой случайный пароль, отличный от POSTGRES_PASSWORD
* WEBHOOK_HOST - IP адрес или доменное имя сервера, на котором запускается проект * WEBHOOK_HOST - IP адрес или доменное имя сервера, на котором запускается проект
* Сохраните файл docker-compose.yaml и соберите его: * Сохраните файл docker-compose.yaml и соберите его:
``` ```

View File

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

View File

@@ -11,4 +11,5 @@ fi
sleep 10 sleep 10
aerich upgrade aerich upgrade
python migrate.py
python main.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_DB=olgram
POSTGRES_HOST=postgres 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_HOST=YOUR_HOST_HERE # example: 11.143.142.140 or my_domain.com
WEBHOOK_PORT=8443 # allowed: 80, 443, 8080, 8443 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 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 asyncio
import argparse
from tortoise import Tortoise from tortoise import Tortoise
from olgram.router import dp 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 from server.custom import init_redis
import olgram.commands.bots # noqa: F401 import olgram.commands.bots # noqa: F401
@@ -21,7 +23,8 @@ async def init_database():
async def init_olgram(): 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 from aiogram.types import BotCommand
await bot.set_my_commands( await bot.set_my_commands(
[ [
@@ -43,11 +46,17 @@ def main():
""" """
Classic polling 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 = asyncio.get_event_loop()
loop.run_until_complete(initialization()) loop.run_until_complete(initialization())
loop.create_task(dp.start_polling()) 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() 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 import types
from aiogram.utils.exceptions import TelegramAPIError from aiogram.utils.exceptions import TelegramAPIError, Unauthorized
from olgram.models.models import Bot from olgram.models.models import Bot
from server.server import unregister_token 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 bot.delete()
await call.answer("Бот удалён") await call.answer("Бот удалён")
try: try:
@@ -32,6 +36,18 @@ async def reset_bot_text(bot: Bot, call: types.CallbackQuery):
await call.answer("Текст сброшен") 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): 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() return await on_unknown_error()
user, _ = await User.get_or_create(telegram_id=message.from_user.id) 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: try:
await bot.save() await bot.save()
except IntegrityError: except IntegrityError:

View File

@@ -76,7 +76,9 @@ async def send_chats_menu(bot: Bot, call: types.CallbackQuery):
if not chats: if not chats:
text = dedent(f""" text = dedent(f"""
Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот. Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот.
Чтобы подключить чат — просто добавьте бот @{bot.name} в чат. Чтобы подключить чат — добавьте бот @{bot.name} в чат, откройте это меню ещё раз и выберите добавленный чат.
Если ваш бот состоял в групповом чате до того, как его добавили в Olgram - удалите бота из чата и добавьте
снова.
""") """)
else: else:
text = dedent(f""" text = dedent(f"""
@@ -141,13 +143,18 @@ async def send_bot_text_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] =
await call.answer() await call.answer()
keyboard = types.InlineKeyboardMarkup(row_width=2) keyboard = types.InlineKeyboardMarkup(row_width=2)
keyboard.insert( keyboard.insert(
types.InlineKeyboardButton(text="Сбросить текст", types.InlineKeyboardButton(text="<< Завершить редактирование",
callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="reset_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)) chat=empty))
) )
keyboard.insert( keyboard.insert(
types.InlineKeyboardButton(text="<< Завершить редактирование", types.InlineKeyboardButton(text="Сбросить текст",
callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty)) callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="reset_text",
chat=empty))
) )
text = dedent(""" 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") 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 @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:
@@ -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) 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="*") @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")
@@ -214,3 +268,11 @@ async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMCon
if operation == "reset_text": if operation == "reset_text":
await bot_actions.reset_bot_text(bot, call) await bot_actions.reset_bot_text(bot, call)
return await send_bot_text_menu(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 aiogram.dispatcher import FSMContext
from textwrap import dedent from textwrap import dedent
from olgram.settings import OlgramSettings from olgram.settings import OlgramSettings
from olgram.utils.permissions import public
from olgram.router import dp from olgram.router import dp
@dp.message_handler(commands=["start"], state="*") @dp.message_handler(commands=["start"], state="*")
@public()
async def start(message: types.Message, state: FSMContext): async def start(message: types.Message, state: FSMContext):
""" """
Команда /start Команда /start
@@ -20,7 +22,8 @@ async def start(message: types.Message, state: FSMContext):
# TODO: locale # TODO: locale
await message.answer(dedent(""" 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 - управление ботами /mybots - управление ботами
/help - помощь /help - помощь
""")) """), parse_mode="html")
@dp.message_handler(commands=["help"], state="*") @dp.message_handler(commands=["help"], state="*")
@public()
async def help(message: types.Message, state: FSMContext): async def help(message: types.Message, state: FSMContext):
""" """
Команда /help Команда /help
""" """
await message.answer(dedent(f""" await message.answer(dedent(f"""
О проекте https://telegra.ph/Olgram-09-15 Читайте инструкции на нашем сайте https://olgram.readthedocs.io
Техническая поддержка: @civsocit_feedback_bot
Репозиторий https://github.com/civsocit/olgram
Поддержка: @civsocit_feedback_bot
Версия {OlgramSettings.version()} Версия {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 tortoise import fields
from uuid import uuid4 from uuid import uuid4
from textwrap import dedent 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): class Bot(Model):
id = fields.IntField(pk=True) 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") owner = fields.ForeignKeyField("models.User", related_name="bots")
name = fields.CharField(max_length=33) name = fields.CharField(max_length=33)
code = fields.UUIDField(default=uuid4, index=True) 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, group_chats = fields.ManyToManyField("models.GroupChat", related_name="bots", on_delete=fields.relational.CASCADE,
null=True) null=True)
@@ -21,6 +38,15 @@ class Bot(Model):
on_delete=fields.relational.CASCADE, on_delete=fields.relational.CASCADE,
null=True) 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): async def super_chat_id(self):
group_chat = await self.group_chat group_chat = await self.group_chat
if group_chat: if group_chat:

View File

@@ -1,18 +1,22 @@
from dotenv import load_dotenv from dotenv import load_dotenv
from abc import ABC from abc import ABC
import os import os
from olgram.utils.crypto import Cryptor
from functools import lru_cache
load_dotenv() load_dotenv()
# TODO: рефакторинг, использовать какой-нибудь lazy-config вместо своих костылей
class AbstractSettings(ABC): class AbstractSettings(ABC):
@classmethod @classmethod
def _get_env(cls, parameter: str, allow_none: bool = False) -> str: def _get_env(cls, parameter: str, allow_none: bool = False) -> str:
parameter = os.getenv(parameter, None) parameter_v = os.getenv(parameter, None)
if not parameter and not allow_none: if not parameter_v and not allow_none:
raise ValueError(f"{parameter} not defined in ENV") raise ValueError(f"{parameter} not defined in ENV")
return parameter return parameter_v
class OlgramSettings(AbstractSettings): class OlgramSettings(AbstractSettings):
@@ -22,11 +26,16 @@ class OlgramSettings(AbstractSettings):
Максимальное количество ботов у одного пользователя Максимальное количество ботов у одного пользователя
:return: int :return: int
""" """
return 5 return 10
@classmethod @classmethod
def version(cls): 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): class ServerSettings(AbstractSettings):
@@ -99,6 +108,12 @@ class DatabaseSettings(AbstractSettings):
def host(cls) -> str: def host(cls) -> str:
return cls._get_env("POSTGRES_HOST") 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 = { TORTOISE_ORM = {
"connections": {"default": f'postgres://{DatabaseSettings.user()}:{DatabaseSettings.password()}' "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 python-dotenv~=0.17.1
aioredis==1.3.1 aioredis==1.3.1
aiocache 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) new_message = await message.forward(super_chat_id)
await _redis.set(_message_unique_id(bot.pk, new_message.message_id), message.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: else:
# Это супер-чат # Это супер-чат
if message.reply_to_message: if message.reply_to_message:
# Ответ из супер-чата переслать тому пользователю, # В супер-чате кто-то ответил на сообщение пользователя, нужно переслать тому пользователю
chat_id = await _redis.get(_message_unique_id(bot.pk, message.reply_to_message.message_id)) chat_id = await _redis.get(_message_unique_id(bot.pk, message.reply_to_message.message_id))
if not chat_id: if not chat_id:
chat_id = message.reply_to_message.forward_from_chat chat_id = message.reply_to_message.forward_from_chat
@@ -64,7 +68,11 @@ async def message_handler(message, *args, **kwargs):
parse_mode="HTML") parse_mode="HTML")
return return
else: else:
# в супер-чате кто-то пишет сообщение сам себе
await message.forward(super_chat_id) 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): async def receive_invite(message: types.Message):
@@ -106,7 +114,7 @@ class CustomRequestHandler(WebhookRequestHandler):
if not bot: if not bot:
return None return None
db_bot_instance.set(bot) 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, dp.register_message_handler(message_handler, content_types=[types.ContentType.TEXT,
types.ContentType.CONTACT, types.ContentType.CONTACT,

View File

@@ -26,9 +26,9 @@ async def register_token(bot: Bot) -> bool:
:param bot: Бот :param bot: Бот
:return: получилось ли :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 certificate = None
if ServerSettings.use_custom_cert(): if ServerSettings.use_custom_cert():
certificate = open(ServerSettings.public_path(), 'rb') certificate = open(ServerSettings.public_path(), 'rb')