text:
@base         <https://rubygems.org/gems/json-ld> .
@prefix dc:   <http://purl.org/dc/terms/> .
@prefix earl: <http://www.w3.org/ns/earl#> .
@prefix doap: <http://usefulinc.com/ns/doap#> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .
@prefix rdf:  <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .

<> a doap:Project;
   doap:name "JSON::LD"^^xsd:string;
   doap:shortdesc "JSON-LD support for Ruby."@en;
   doap:description "JSON::LD parses and serializes JSON-LD into RDF and implements expansion, compaction and framing API interfaces for the Ruby RDF.rb library suite."@en;
   dc:creator <https://greggkellogg.net/foaf#me>;
   doap:blog <https://greggkellogg.net/>;
   doap:bug-database <https://github.com/ruby-rdf/json-ld/issues>;
   doap:created "2011-05-07"^^xsd:date;
   doap:developer <https://greggkellogg.net/foaf#me>;
   doap:documenter <https://greggkellogg.net/foaf#me>;
   doap:homepage <https://github.com/ruby-rdf/json-ld/>;
   doap:implements <https://www.w3.org/TR/json-ld11/>,
     <https://www.w3.org/TR/json-ld11-api/>,
     <https://www.w3.org/TR/json-ld11-framing/>;
   doap:license <https://unlicense.org/1.0/>;
   doap:maintainer <https://greggkellogg.net/foaf#me>;
   doap:programming-language "Ruby";
   foaf:maker <https://greggkellogg.net/foaf#me> .

<https://greggkellogg.net/foaf#me> a foaf:Person;
   rdfs:isDefinedBy <https://greggkellogg.net/foaf>;
   foaf:made <>;
   foaf:mbox <mailto:gregg@greggkellogg.net>;
   foaf:name "Gregg Kellogg"^^xsd:string .

repo_name: ruby-rdf/json-ld

repo_language: HTML

mime_type: text/plain


text:
<https://greggkellogg.net/foaf#me> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://xmlns.com/foaf/0.1/Person> .
<https://greggkellogg.net/foaf#me> <http://www.w3.org/2000/01/rdf-schema#isDefinedBy> <https://greggkellogg.net/foaf> .
<https://greggkellogg.net/foaf#me> <http://xmlns.com/foaf/0.1/made> <https://rubygems.org/gems/json-ld> .
<https://greggkellogg.net/foaf#me> <http://xmlns.com/foaf/0.1/mbox> <mailto:gregg@greggkellogg.net> .
<https://greggkellogg.net/foaf#me> <http://xmlns.com/foaf/0.1/name> "Gregg Kellogg" .
<https://rubygems.org/gems/json-ld> <http://purl.org/dc/terms/creator> <https://greggkellogg.net/foaf#me> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#blog> <https://greggkellogg.net/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#bug-database> <https://github.com/ruby-rdf/json-ld/issues> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#created> "2011-05-07"^^<http://www.w3.org/2001/XMLSchema#date> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#description> "RDF.rb extension for parsing/serializing JSON-LD data."@en .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#developer> <https://greggkellogg.net/foaf#me> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#documenter> <https://greggkellogg.net/foaf#me> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#homepage> <https://github.com/ruby-rdf/json-ld/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#implements> <https://www.w3.org/TR/json-ld11-api/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#implements> <https://www.w3.org/TR/json-ld11-framing/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#implements> <https://www.w3.org/TR/json-ld11/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#license> <https://unlicense.org/1.0/> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#maintainer> <https://greggkellogg.net/foaf#me> .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#name> "JSON::LD" .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#programming-language> "Ruby" .
<https://rubygems.org/gems/json-ld> <http://usefulinc.com/ns/doap#shortdesc> "JSON-LD support for RDF.rb."@en .
<https://rubygems.org/gems/json-ld> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://usefulinc.com/ns/doap#Project> .
<https://rubygems.org/gems/json-ld> <http://xmlns.com/foaf/0.1/maker> <https://greggkellogg.net/foaf#me> .


text: /usr/lib/python3.10/Tools/i18n/pygettext.py -d chinese -o locales/olgram.pot olgram/ server/

repo_name: civsocit/olgram

repo_language: Python

mime_type: text/x-po


text:
import asyncio
from olgram.migrations.custom import migrate

if __name__ == '__main__':
    asyncio.get_event_loop().run_until_complete(migrate())


text:
# Пример сложной конфигурации сервера: реверс-прокси, автоматическое обновление github
version: '3'
services:
  postgres:
    image: postgres:13.4
    restart: unless-stopped
    env_file:
      - release.env
    volumes:
      - database:/var/lib/postgresql/data
    networks:
      - traefik
    labels:
      - 'com.centurylinklabs.watchtower.enable="false"'
  redis:
    image: 'bitnami/redis:6.2.7'
    restart: unless-stopped
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - redis-db:/bitnami/redis/data
    env_file:
      - release.env
    networks:
      - traefik
    labels:
      - 'com.centurylinklabs.watchtower.enable="false"'
  olgram:
    image: ghcr.io/civsocit/olgram/bot:stable
    restart: unless-stopped
    networks:
      - traefik
    labels:
      - 'com.centurylinklabs.watchtower.enable="true"'
      - "traefik.enable=true"
      - "traefik.http.routers.static.rule=Host(`bot.civsoc.com`)"
      - "traefik.http.routers.static.tls=true"
      - "traefik.http.routers.static.tls.certresolver=le"
      - "traefik.http.routers.static.entrypoints=websecure"
      - "traefik.docker.network=traefik"
    env_file:
      - release.env
    depends_on:
      - postgres
      - redis
  watchtower:
    image: containrrr/watchtower
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./config.json:/config.json
    command: --interval 30
    networks:
      - traefik
  traefik:
    image: traefik:v2.4
    container_name: olgram_traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    networks:
      - traefik
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./acme:/acme
    command:
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --providers.docker=true
      - --providers.docker.endpoint=unix:///var/run/docker.sock
      - --providers.docker.exposedByDefault=false
      - --providers.docker.network=traefik
      - --certificatesresolvers.le.acme.email=feedback@civsoc.com
      - --certificatesresolvers.le.acme.storage=/acme/acme.json
      - --certificatesresolvers.le.acme.tlschallenge=false
      - --certificatesresolvers.le.acme.httpchallenge=true
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    labels:
      - 'com.centurylinklabs.watchtower.enable="false"'

volumes:
  database:
  redis-db:

networks:
  traefik:
    driver: bridge


text:
# Минимальная конфигурация сервера, код собирается из текущей директории
version: '3'
services:
  postgres:
    image: postgres:13.4
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - database:/var/lib/postgresql/data
  redis:
    image: 'bitnami/redis:latest'
    restart: unless-stopped
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - redis-db:/bitnami/redis/data
    env_file:
      - .env
  olgram:
    build: .
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - olgram-cert:/cert
    ports:
      - "${WEBHOOK_PORT}:80"
    depends_on:
      - postgres
      - redis

volumes:
  database:
  redis-db:
  olgram-cert:


text:
[tool.poetry]
name = "olgram"
version = "0.2.0"
description = ""
authors = ["Civ Soc <feedback@civsoc.it>"]
license = "CC0"

[tool.poetry.dependencies]
python = "^3.8"
aiogram = "2.13"
python-dotenv = "^0.19.2"
aiocache = "^0.11.1"
aiohttp = "^3.8.1"
pycrypto = "^2.6.1"
aioredis = "1.3"
aerich = "0.5.x"
tortoise-orm = {extras = ["asyncpg"], version = "^0.18.1"}
python-gettext = "^4.0"

[tool.poetry.dev-dependencies]
flake8 = "^4.0.1"

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"


text:
# example: 123456789:AAAA-abc123_AbcdEFghijKLMnopqrstu12 (without quotes!)
BOT_TOKEN=YOUR_BOT_TOKEN_HERE

POSTGRES_USER=olgram

# example: x2y0n27ihiez93kmzj82 (without quotes!)
POSTGRES_PASSWORD=SOME_RANDOM_PASSWORD_HERE

POSTGRES_DB=olgram
POSTGRES_HOST=postgres

# example: i7flci0mx4z5patxnl6m (without quotes!)
TOKEN_ENCRYPTION_KEY=SOME_RANDOM_PASSWORD_HERE

# use your user id or group chat id to restrict access to the bot
# ADMIN_ID=223453418

# use your user id or group chat id to give selected users access to the bot's general statistics (/info command)
# SUPERVISOR_ID=223453419

# example: 11.143.142.140 or my_domain.com (without quotes, without 'https://' prefix!)
WEBHOOK_HOST=YOUR_HOST_HERE

# allowed: 80, 443, 8080, 8443
WEBHOOK_PORT=8443
# use that if you don't set up your own domain and let's encrypt certificate
CUSTOM_CERT=true

REDIS_PATH=redis://redis

# Set log level, can be CRITICAL, ERROR, WARNING, INFO, DEBUG. By default it set to WARNING.
LOGLEVEL=

# Uncomment this to switch bot language to English
# O_LANG=en


text:
#!/bin/bash
set -e

if [ ! -z "${CUSTOM_CERT}" ]; then
  echo "Use custom certificate"
  if [ ! -f /cert/private.key ]; then
    echo "Generate new certificate"
    openssl req -newkey rsa:2048 -sha256 -nodes -keyout /cert/private.key -x509 -days 10000 -out /cert/public.pem -subj "/C=US/ST=Berlin/L=Berlin/O=my_org/CN=${WEBHOOK_HOST}"
  fi
fi

sleep 10
aerich upgrade
python migrate.py
python main.py $@


text:
# Минимальная конфигурация сервера
version: '3'
services:
  postgres:
    image: postgres
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - database:/var/lib/postgresql/data
  redis:
    image: 'bitnami/redis:6.2.7'
    restart: unless-stopped
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - redis-db:/bitnami/redis/data
    env_file:
      - .env
  olgram:
    image: ghcr.io/civsocit/olgram/bot:stable
    restart: unless-stopped
    env_file:
      - .env
    volumes:
      - olgram-cert:/cert
    ports:
      - "${WEBHOOK_PORT}:80"
    depends_on:
      - postgres
      - redis

volumes:
  database:
  redis-db:
  olgram-cert:


text:
import asyncio
import argparse
from tortoise import Tortoise

from olgram.router import dp
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
import olgram.commands.start  # noqa: F401
import olgram.commands.menu  # noqa: F401
import olgram.commands.bot_actions  # noqa: F401
import olgram.commands.info  # noqa: F401
import olgram.commands.promo  # noqa: F401
import olgram.commands.admin  # noqa: F401
from locales.locale import _

from server.server import main as server_main

async def init_database():
    await Tortoise.init(config=TORTOISE_ORM)

async def init_olgram():
    from olgram.router import bot, dp
    dp.setup_middleware(AccessMiddleware(OlgramSettings.admin_ids()))
    from aiogram.types import BotCommand
    await bot.set_my_commands(
        [
            BotCommand("start", _("Запустить бота")),
            BotCommand("addbot", _("Добавить бот")),
            BotCommand("mybots", _("Управление ботами")),
            BotCommand("help", _("Справка"))
        ]
    )

async def initialization():
    await init_database()
    await init_redis()
    await init_olgram()

def main():
    parser = argparse.ArgumentParser("Olgram bot and feedback server")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--noserver", help="Не запускать сервер обратной связи, только сам Olgram", action="store_true")
    group.add_argument("--onlyserver", help="Запустить только сервер обратной связи, без Olgram", action="store_true")
    args = parser.parse_args()

    loop = asyncio.get_event_loop()
    loop.run_until_complete(initialization())

    if not args.onlyserver:
        print("Run olgram polling")
        loop.create_task(dp.start_polling())
    if not args.noserver:
        print("Run olgram server")
        loop.create_task(server_main().start())

    loop.run_forever()

if __name__ == '__main__':
    main()


text:
FROM python:3.8-buster

ENV PYTHONUNBUFFERED=1 \
    POETRY_VERSION=1.1.12 \
    POETRY_VIRTUALENVS_CREATE="false"

RUN apt-get update && \
    apt-get install -y gettext build-essential && \
    apt-get clean && rm -rf /var/cache/apt/* && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/*

RUN pip install "poetry==$POETRY_VERSION"

WORKDIR /app

COPY pyproject.toml poetry.lock docker-entrypoint.sh ./
RUN poetry install --no-interaction --no-ansi --no-dev

COPY . /app

RUN msgfmt locales/zh/LC_MESSAGES/olgram.po -o locales/zh/LC_MESSAGES/olgram.mo --use-fuzzy
RUN msgfmt locales/uk/LC_MESSAGES/olgram.po -o locales/uk/LC_MESSAGES/olgram.mo --use-fuzzy
RUN msgfmt locales/en/LC_MESSAGES/olgram.po -o locales/en/LC_MESSAGES/olgram.mo --use-fuzzy

EXPOSE 80

ENTRYPOINT ["./docker-entrypoint.sh"]


text:
[aerich]
tortoise_orm = olgram.settings.TORTOISE_ORM
location = ./olgram/migrations
src_folder = .


text:
# Конфигурация, удобная для разработки в PyCharm: бот запускается без docker, порты postgres и redis открыты на localhost
# Не используйте её в production!
version: '3'
services:
  postgres:
    image: postgres:14
    environment:
      - POSTGRES_USER=test_user
      - POSTGRES_PASSWORD=test_passwd
      - POSTGRES_DB=olgram
    ports:
      - '5400:5432'
    volumes:
      - database:/var/lib/postgresql/data
  redis:
    image: 'bitnami/redis:6.2.7'
    environment:
      - ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - redis-db:/bitnami/redis/data
    ports:
      - '6370:6379'

volumes:
  database:
  redis-db:


text:
# OLGram

[![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)

[@OlgramBot](https://t.me/olgrambot) - конструктор ботов обратной связи в Telegram

Документация: https://olgram.readthedocs.io

**Olgram** [@OlgramBot](https://t.me/olgrambot) это конструктор, который позволяет создавать боты обратной связи
в Telegram. После подключения к Olgram пользователи вашего бота смогут писать сообщения, которые будут
пересылаться вам в чат, где вы сможете на них ответить.

Такие боты могут вам пригодиться, например:

   *Пример 1.* Вы администрируете Telegram-канал и хотите дать своим подписчикам возможность связаться с вами,
   но не хотите оставлять свои личные контакты. Тогда вы можете создать бота обратной связи: подписчики будут писать
   боту, вы будете отвечать через бота анонимно.

   *Пример 2.* Вы организуете небольшой call-центр в Telegram или группу технической поддержки. С помощью бота обратной
   связи вы можете принимать заявки от пользователей в общий чат ваших специалистов, обсуждать эти заявки и отвечать
   пользователям прямо из этого чата.

Читайте больше: https://olgram.readthedocs.io


text:
@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


text:
# 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)


text:
Добро пожаловать в документацию Olgram
===================================

**Olgram** `@olgrambot <https://t.me/olgrambot>`_ это конструктор, который позволяет создавать боты обратной связи
в Telegram. После подключения к Olgram пользователи вашего бота смогут писать сообщения, которые будут
пересылаться вам в чат, где вы сможете на них ответить. Читайте больше о проекте в главе :doc:`about`.

Откройте главу :doc:`quick_start` чтобы приступить к созданию своего первого бота!

Оглавление
--------

.. toctree::

   about
   quick_start
   templates
   options
   developer
   additional

.. note::

   Если у вас возникли вопросы по использованию бота, или вы нашли ошибку - напишите
   нам `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.


text:
Шаблоны ответов
=============

Иногда в поддержке приходится отвечать на однотипные вопросы однотипными ответами. Например:

   Q. ``Здравствуйте! Когда будет доставлен мой заказ?``

   A. ``Добрый день. Ваш заказ принят в обработку. Среднее время доставки 2-4 дня. Мы уведомим вас об изменении статуса заказа``

Чтобы не печатать каждый раз одинаковые тексты, в Olgram можно задать список шаблонных ответов. Тогда диалог с
пользователем может выглядеть так:

.. image:: ../images/inline.gif
   :width: 300

Заметьте, чтобы увидеть список вариантов ответов, нужно упомянуть вашего feedback бота и нажать пробел

Как настроить шаблоны
---------------------

Шаблоны можно задать в меню Olgram бота Текст -> Автоответчик -> Шаблоны ответов.

.. image:: ../images/settemplates.jpg
   :width: 300

Обязательно включите inline mode в вашем feedback боте. Для этого отправьте @BotFather команду ``/setinline``
и следуйте инструкциям

.. note::

   Может пройти несколько минут, прежде чем добавленные в OlgramBot шаблоны появятся в списке вашего feedback бота


text:
# Configuration file for the Sphinx documentation builder.

# -- Project information

project = 'Olgram'
copyright = '2023, 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'


text:
Опции
=============

.. _threads:

Потоки сообщений
----------------

Olgram пересылает сообщения так, чтобы сообщения от одного и того же пользователя оставались в одном и том же
потоке сообщений. Тогда по кнопке View Replies можно увидеть диалог с этим пользователем, а все остальные сообщения из
чата скрываются:

.. image:: ../images/thread.gif
   :width: 300

**Как настроить потоки сообщений**

Привяжите вашего feedback бота к групповому чату :doc:`quick_start`. В настройках группового чата откройте историю
чата для новых участников чата ("Chat history for new members -> Visible"). Изменение этой настройки превращает чат в
`супергруппу <https://telegram.org/blog/supergroups5k>`_: потоки сообщений работают только в таких группах

Включите потоки в настройках бота Olgram Опции->Потоки сообщений

.. _user_info:

Данные пользователя
-------------------

При получении входящего сообщения Olgram может пересылать дополнительную информацию об отправителе. Имя, username и
идентификатор пользователя. Например так:

.. image:: ../images/user_info.jpg
   :width: 300

Эта функция может быть полезной, чтобы отличить одного пользователя от другого. Имя и username можно сменить, но
идентификатор #id остаётся неизменным для одного и того же аккаунта.

Включить эту функцию можно в настройках бота Olgram Опции->Данные пользователя

.. note::

   Включение этой опции меняет текст политики конфиденциальности вашего feedback бота (команда /security_policy)
   и может отпугнуть некоторых пользователей. Не включайте эту опцию без необходимости.

.. _antiflood:

Защита от флуда
---------------

При включении этой опции пользователю запрещается отправлять больше одного сообщения в минуту. Используйте её, если
не успеваете обрабатывать входящие сообщения.


text:
Дополнительно
=============

Донаты
----------------

На рекламу проекта, аренду сервера и пиццу

Bitcoin:
    ``bc1qlq7cm5chc8flr3fy8ewk967aknq3dwmxtwn9hl``

Litecoin:
    ``LTC1QXAJSVZ0LW44AA5NYTUCH8CP2G8X7A4CDASE4Y7``

Как убрать "Этот бот создан с помощью ..."
----------------
Напишите нам на `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.

История изменений
----------------

- `2022-08-01` Защита от флуда
- `2022-07-23` Автоответчик не пишет сообщение лишний раз
- `2022-07-04` Поддержка двух ботов в одном чате
- `2022-06-25` Поддержка HTML\Markdown в стартовом сообщении и автоответчике
- `2022-06-25` Пересылка отредактированных сообщений
- `2022-06-16` User info по возможности отправляется в том же сообщении, что и сообщение пользователя
- `2022-05-26` Возможность отвечать на более старые сообщения (1/2 года)
- `2022-04-11` Частичная поддержка украинского, английского языка
- `2022-04-09` 'Этот бот создан с помощью...' возможность выключать по промо
- `2022-03-17` Политика конфиденциальности
- `2022-03-17` Дополнительная информация о пользователях (имя пользователя и тд)
- `2022-02-19` Статистика использования бота
- `2022-02-16` Потоки сообщений
- `2022-02-16` Очистка Redis по timeout
- `2022-02-12` Шаблоны ответов
- `2022-01-27` Настройки логирования
- `2022-01-18` Команды /ban и /unban (возможность банить пользователей)
- `2021-12-14` Bugfix обработка изменения ID чата
- `2021-10-01` Возможность ограничивать права на бота (ADMIN_ID)
- `2021-09-26` Шифрование токенов
- `2021-09-26` Добавлен автоответчик
- `2021-09-24` Initial


text:
О проекте
===================================

Зачем нужен 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>`_.


text:
Для разработчиков
=================

.. _run:
Сборка и запуск
---------------
Вы можете развернуть 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 вы потеряете
   токены всех ботов, которые пользователи зарегистрировали в вашем боте.

Возможно, вы захотите внести изменения в проект и запустить бот с этими изменениями. Тогда:

1. Склонируйте репозиторий

.. code-block:: console

    (bash) $ git clone https://github.com/civsocit/olgram

2. Внесите в код все изменения, которые хотите внести

3. В каталоге с репозиторием (рядом с файлами .yaml) создайте файл .env и заполните его, как в инструкции выше

4. Соберите и запустите сервер:

.. code-block:: console

    (bash) $ sudo docker-compose -f docker-compose-src.yaml up -d

Дополнительно
-------------

В docker-compose.yaml приведена минимальная конфигурация. Для использования в серьёзных проектах мы советуем:

* Приобрести домен и настроить его на свой хост
* Наладить реверс-прокси и автоматическое обновление сертификатов - например, с помощью `Traefik <https://github.com/traefik/traefik>`_
* Скрыть IP сервера с помощью `Cloudflare <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.

Настройка языка
---------------

Язык по-умолчанию - русский. Поддержку другого языка можно добавлять по образцу китайского в папку locales/
(китайский - zh). Код языка указать в настройках .env

   ``O_LANG=<идентификатор языка>``


text:
Быстрый старт
=============

Как создать бота
----------------

Перейдите по ссылке `@OlgramBot <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 сможет пересылать туда сообщения.

Как блокировать и разблокировать пользователей
------------------------------------

Вы можете отправлять в бан пользователей feedback бота. Для этого есть команды /ban и /unban.
Например так:

.. image:: ../images/ban2.png
   :width: 300

Со стороны пользователя этот диалог будет выглядеть так:

.. image:: ../images/ban1.png
   :width: 300

.. note::

   Если у вас возникли вопросы по использованию бота, или вы нашли ошибку - напишите
   нам `@civsocit_feedback_bot <https://t.me/civsocit_feedback_bot>`_.


text:
name: Linter

on: push

env:
  PYTHONUNBUFFERED: 1
  POETRY_VERSION: 1.1.2
  POETRY_VIRTUALENVS_CREATE: "false"

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Set up Python
        uses: actions/setup-python@v2
        with:
          python-version: 3.8
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install poetry
          poetry install
      - name: Check flake8
        run: flake8 .


text:
name: Deploy
on: push
env:
  IMAGE_NAME: bot
  USERNAME: mihalin
jobs:
  # Push image to GitHub Packages.
  # See also https://docs.docker.com/docker-hub/builds/
  ghr_push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Build image
        run: docker build . --tag $IMAGE_NAME

      - name: Log into GitHub Container Registry
        run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u $USERNAME --password-stdin

      - name: Push poi image to GitHub Container Registry
        run: |
          IMAGE_ID=ghcr.io/${{ github.repository }}/$IMAGE_NAME

          # Change all uppercase to lowercase
          IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')

          # Strip git ref prefix from version
          VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')

          # Strip "v" prefix from tag name
          [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')

          # Use Docker `latest` tag convention
          [ "$VERSION" == "main" ] && VERSION=latest

          echo IMAGE_ID=$IMAGE_ID
          echo VERSION=$VERSION

          docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
          docker push $IMAGE_ID:$VERSION


text:
from aiogram import Dispatcher, Bot
from aiogram.contrib.fsm_storage.memory import MemoryStorage
from .settings import BotSettings

bot = Bot(BotSettings.token())
dp = Dispatcher(bot, storage=MemoryStorage())


text:
from dotenv import load_dotenv
from abc import ABC
import os
import logging
from functools import lru_cache
from datetime import timedelta
import typing as ty
from olgram.utils.crypto import Cryptor

load_dotenv()

# TODO: рефакторинг, использовать какой-нибудь lazy-config вместо своих костылей

class AbstractSettings(ABC):
    @classmethod
    def _get_env(cls, parameter: str, allow_none: bool = False) -> str:
        parameter_v = os.getenv(parameter, None)
        if not parameter_v and not allow_none:
            raise ValueError(f"{parameter} not defined in ENV")
        return parameter_v

class OlgramSettings(AbstractSettings):
    @classmethod
    def max_bots_per_user(cls) -> int:
        """
        Максимальное количество ботов у одного пользователя
        :return: int
        """
        return 10

    @classmethod
    def max_bots_per_user_promo(cls) -> int:
        """
        Максимальное количество ботов у одного пользователя с промо-доступом
        :return: int
        """
        return 25

    @classmethod
    def version(cls):
        return "0.5.0"

    @classmethod
    @lru_cache
    def admin_ids(cls):
        _ids = cls._get_env("ADMIN_ID", True)
        return set(map(int, _ids.split(","))) if _ids else None

    @classmethod
    @lru_cache
    def supervisor_id(cls):
        _id = cls._get_env("SUPERVISOR_ID", True)
        return int(_id) if _id else None

class ServerSettings(AbstractSettings):
    @classmethod
    def hook_host(cls) -> str:
        return cls._get_env("WEBHOOK_HOST")

    @classmethod
    def hook_port(cls) -> int:
        return int(cls._get_env("WEBHOOK_PORT"))

    @classmethod
    def app_port(cls) -> int:
        return 80

    @classmethod
    def redis_path(cls) -> str:
        """
        Путь до БД redis
        :return:
        """
        return cls._get_env("REDIS_PATH")

    @classmethod
    def use_custom_cert(cls) -> bool:
        use = cls._get_env("CUSTOM_CERT", allow_none=True)
        return use and "true" in use.lower()

    @classmethod
    def priv_path(cls) -> str:
        return "/cert/private.key"

    @classmethod
    def public_path(cls) -> str:
        return "/cert/public.pem"

    @classmethod
    def append_text(cls) -> str:
        return "\n\nЭтот бот создан с помощью @OlgramBot"

    @classmethod
    @lru_cache
    def redis_timeout_ms(cls) -> ty.Optional[int]:
        return int(timedelta(days=180).total_seconds() * 1000.0)

    @classmethod
    @lru_cache
    def thread_timeout_ms(cls) -> int:
        return int(timedelta(days=1).total_seconds() * 1000.0)

logging.basicConfig(level=os.environ.get("LOGLEVEL") or "WARNING",
                    format='%(asctime)s %(levelname)-8s %(message)s')

class BotSettings(AbstractSettings):
    @classmethod
    @lru_cache
    def token(cls) -> str:
        """
        Токен olgram бота
        :return:
        """
        return cls._get_env("BOT_TOKEN")

    @classmethod
    def language(cls) -> str:
        """
        Язык
        """
        lang = cls._get_env("O_LANG", allow_none=True)
        return lang.lower() if lang else "ru"

class DatabaseSettings(AbstractSettings):
    @classmethod
    def user(cls) -> str:
        return cls._get_env("POSTGRES_USER")

    @classmethod
    def password(cls) -> str:
        return cls._get_env("POSTGRES_PASSWORD")

    @classmethod
    def database_name(cls) -> str:
        return cls._get_env("POSTGRES_DB")

    @classmethod
    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()}'
                               f'@{DatabaseSettings.host()}/{DatabaseSettings.database_name()}'},
    "apps": {
        "models": {
            "models": ["olgram.models.models", "aerich.models"],
            "default_connection": "default",
        },
    },
}


text:
from tortoise.models import Model
from tortoise import fields
from uuid import uuid4
from textwrap import dedent
from olgram.settings import DatabaseSettings
from locales.locale import _

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=200, unique=True)
    owner = fields.ForeignKeyField("models.User", related_name="bots")
    name = fields.CharField(max_length=33)
    code = fields.UUIDField(default=uuid4, index=True)
    start_text = fields.TextField(default=dedent(_("""
    Здравствуйте!
    Напишите ваш вопрос и мы ответим вам в ближайшее время.
    """)))
    second_text = fields.TextField(null=True, default=None)

    group_chats = fields.ManyToManyField("models.GroupChat", related_name="bots", on_delete=fields.relational.CASCADE,
                                         null=True)
    group_chat = fields.ForeignKeyField("models.GroupChat", related_name="active_bots",
                                        on_delete=fields.relational.CASCADE,
                                        null=True)

    incoming_messages_count = fields.BigIntField(default=0)
    outgoing_messages_count = fields.BigIntField(default=0)

    enable_threads = fields.BooleanField(default=False)
    enable_additional_info = fields.BooleanField(default=False)
    enable_olgram_text = fields.BooleanField(default=True)
    enable_antiflood = fields.BooleanField(default=False)

    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:
            return group_chat.chat_id
        return (await self.owner).telegram_id

    async def is_promo(self):
        await self.fetch_related("owner")
        return await self.owner.is_promo()

    class Meta:
        table = 'bot'

class User(Model):
    id = fields.IntField(pk=True)
    telegram_id = fields.BigIntField(index=True, unique=True)

    async def is_promo(self):
        await self.fetch_related("promo")
        return bool(self.promo)

    class Meta:
        table = 'user'

class GroupChat(Model):
    id = fields.IntField(pk=True)
    chat_id = fields.BigIntField(index=True, unique=True)
    name = fields.CharField(max_length=255)

    class Meta:
        table = 'group_chat'

class BannedUser(Model):
    id = fields.BigIntField(pk=True)
    telegram_id = fields.BigIntField(index=True)
    username = fields.CharField(max_length=100, default=None, null=True)

    bot = fields.ForeignKeyField("models.Bot", related_name="banned_users", on_delete=fields.relational.CASCADE)

    class Meta:
        table = "bot_banned_user"

class DefaultAnswer(Model):
    id = fields.BigIntField(pk=True)
    bot = fields.ForeignKeyField("models.Bot", related_name="answers", on_delete=fields.relational.CASCADE)
    text = fields.TextField()

class Promo(Model):
    id = fields.BigIntField(pk=True)
    code = fields.UUIDField(default=uuid4, index=True)
    date = fields.DatetimeField(auto_now_add=True)

    owner = fields.ForeignKeyField("models.User", related_name="promo", on_delete=fields.relational.SET_NULL,
                                   null=True, default=None)


text:
"""
Здесь промокоды
"""

from aiogram import types
from aiogram.dispatcher import FSMContext
from olgram.models import models
from uuid import UUID

from olgram.router import dp
from olgram.settings import OlgramSettings
from locales.locale import _

@dp.message_handler(commands=["newpromo"], state="*")
async def new_promo(message: types.Message, state: FSMContext):
    """
    Команда /newpromo
    """

    if message.chat.id != OlgramSettings.supervisor_id():
        await message.answer(_("Недостаточно прав"))
        return

    promo = await models.Promo()
    await message.answer(_("Новый промокод\n```{0}```").format(promo.code), parse_mode="Markdown")

    await promo.save()

@dp.message_handler(commands=["delpromo"], state="*")
async def del_promo(message: types.Message, state: FSMContext):
    """
    Команда /delpromo
    """

    if message.chat.id != OlgramSettings.supervisor_id():
        await message.answer(_("Недостаточно прав"))
        return

    try:
        uuid = UUID(message.get_args().strip())
        promo = await models.Promo.get_or_none(code=uuid)
    except ValueError:
        return await message.answer(_("Неправильный токен"))

    if not promo:
        return await message.answer(_("Такого кода не существует"))

    user = await models.User.filter(promo=promo)
    bots = await user.bots()
    for bot in bots:
        bot.enable_olgram_text = True
        await bot.save(update_fields=["enable_olgram_text"])

    await promo.delete()

    await message.answer(_("Промокод отозван"))

@dp.message_handler(commands=["setpromo"], state="*")
async def setpromo(message: types.Message, state: FSMContext):
    """
    Команда /setpromo
    """

    arg = message.get_args()
    if not arg:
        return await message.answer(_("Укажите аргумент: промокод. Например: <pre>/setpromo my-promo-code</pre>"),
                                    parse_mode="HTML")

    arg = arg.strip()

    try:
        UUID(arg)
    except ValueError:
        return await message.answer(_("Промокод не найден"))

    promo = await models.Promo.get_or_none(code=arg)
    if not promo:
        return await message.answer(_("Промокод не найден"))

    if promo.owner:
        return await message.answer(_("Промокод уже использован"))

    user, created = await models.User.get_or_create(telegram_id=message.from_user.id)
    promo.owner = user
    await promo.save(update_fields=["owner_id"])

    await message.answer(_("Промокод активирован! Спасибо 🙌"))


text:
"""
Здесь простые команды на первом уровне вложенности: /start /help
"""

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 locales.locale import _

from olgram.router import dp

@dp.message_handler(commands=["start"], state="*")
@public()
async def start(message: types.Message, state: FSMContext):
    """
    Команда /start
    """
    await state.reset_state()

    await message.answer(dedent(_("""
    Olgram Bot — это конструктор ботов обратной связи в Telegram. Подробнее \
читайте здесь. Следите за обновлениями \
здесь.

    Используйте эти команды, чтобы управлять этим ботом:

    /addbot - добавить бот
    /mybots - управление ботами

    /help - помощь
    """)), parse_mode="html", disable_web_page_preview=True)

@dp.message_handler(commands=["help"], state="*")
@public()
async def help(message: types.Message, state: FSMContext):
    """
    Команда /help
    """
    await message.answer(dedent(_("""
    Читайте инструкции на нашем сайте https://olgram.readthedocs.io
    Техническая поддержка: @civsocit_feedback_bot
    Версия {0}
    """)).format(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)


text:
"""
Здесь работа с ботами на первом уровне вложенности: список ботов, добавление ботов
"""
from aiogram import types, Bot as AioBot
from aiogram.dispatcher import FSMContext
from aiogram.utils.exceptions import Unauthorized, TelegramAPIError
from tortoise.exceptions import IntegrityError
import re
from textwrap import dedent

from olgram.models.models import Bot, User
from olgram.settings import OlgramSettings, BotSettings
from olgram.commands.menu import send_bots_menu
from server.server import register_token
from locales.locale import _

from olgram.router import dp

import logging

logger = logging.getLogger(__name__)

token_pattern = r'[0-9]{8,10}:[a-zA-Z0-9_-]{35}'

@dp.message_handler(commands=["mybots"], state="*")
async def my_bots(message: types.Message, state: FSMContext):
    """
    Команда /mybots (список ботов)
    """
    return await send_bots_menu(message.chat.id, message.from_user.id)

@dp.message_handler(commands=["addbot"], state="*")
async def add_bot(message: types.Message, state: FSMContext):
    """
    Команда /addbot (добавить бота)
    """
    user = await User.get_or_none(telegram_id=message.from_user.id)
    max_bot_count = OlgramSettings.max_bots_per_user()
    if user and await user.is_promo():
        max_bot_count = OlgramSettings.max_bots_per_user_promo()
    bot_count = await Bot.filter(owner__telegram_id=message.from_user.id).count()
    if bot_count >= max_bot_count:
        await message.answer(_("У вас уже слишком много ботов. Удалите какой-нибудь свой бот из Olgram"
                               "(/mybots -> (Выбрать бота) -> Удалить бот)"))
        return

    await message.answer(dedent(_("""
    Чтобы подключить бот, вам нужно выполнить три действия:

    1. Перейдите в бот @BotFather, нажмите START и отправьте команду /newbot
    2. Введите название бота, а потом username бота.
    3. После создания бота перешлите ответное сообщение в этот бот или скопируйте и пришлите token бота.

    Важно: не подключайте боты, которые используются в других сервисах (Manybot, Chatfuel, Livegram и других).
    """)))
    await state.set_state("add_bot")

@dp.message_handler(state="add_bot", content_types="text", regexp="^[^/].+")  # Not command
async def bot_added(message: types.Message, state: FSMContext):
    """
    Пользователь добавляет бота и мы ждём от него токен
    """
    token = re.findall(token_pattern, message.text)

    async def on_invalid_token():
        await message.answer(dedent(_("""
        Это не токен бота.

        Токен выглядит вот так: 123456789:AAAA-abc123_AbcdEFghijKLMnopqrstu12
        """)))

    async def on_dummy_token():
        await message.answer(dedent(_("""
        Не удалось запустить этого бота: неверный токен
        """)))

    async def on_unknown_error():
        await message.answer(dedent(_("""
        Не удалось запустить этого бота: непредвиденная ошибка
        """)))

    async def on_duplication_bot():
        await message.answer(dedent(_("""
        Такой бот уже есть в базе данных
        """)))

    if not token:
        return await on_invalid_token()

    token = token[0]

    try:
        test_bot = AioBot(token)
        test_bot_info = await test_bot.get_me()
        await test_bot.session.close()
    except ValueError:
        return await on_invalid_token()
    except Unauthorized:
        return await on_dummy_token()
    except TelegramAPIError:
        return await on_unknown_error()

    if token == BotSettings.token():
        return await on_duplication_bot()

    user, created = await User.get_or_create(telegram_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:
        return await on_duplication_bot()

    if not await register_token(bot):
        await bot.delete()
        return await on_unknown_error()

    await message.answer(_("Бот добавлен! Список ваших ботов: /mybots"))
    await state.reset_state()


text:
"""
Здесь метрики
"""

from aiogram import types
from aiogram.dispatcher import FSMContext
from olgram.models import models

from olgram.router import dp
from olgram.settings import OlgramSettings
from locales.locale import _

@dp.message_handler(commands=["info"], state="*")
async def info(message: types.Message, state: FSMContext):
    """
    Команда /info
    """

    if message.chat.id != OlgramSettings.supervisor_id():
        await message.answer(_("Недостаточно прав"))
        return

    bots = await models.Bot.all()
    bots_count = len(bots)
    user_count = len(await models.User.all())
    templates_count = len(await models.DefaultAnswer.all())
    promo_count = len(await models.Promo.all())
    olgram_text_disabled = len(await models.Bot.filter(enable_olgram_text=False))

    income_messages = sum([bot.incoming_messages_count for bot in bots])
    outgoing_messages = sum([bot.outgoing_messages_count for bot in bots])

    await message.answer(_("Количество ботов: {0}\n").format(bots_count) +
                         _("Количество пользователей (у конструктора): {0}\n").format(user_count) +
                         _("Шаблонов ответов: {0}\n").format(templates_count) +
                         _("Входящих сообщений у всех ботов: {0}\n").format(income_messages) +
                         _("Исходящих сообщений у всех ботов: {0}\n").format(outgoing_messages) +
                         _("Промо-кодов выдано: {0}\n").format(promo_count) +
                         _("Рекламную плашку выключили: {0}\n".format(olgram_text_disabled)))


text:
"""
Здесь некоторые команды администратора
"""

from aiogram import types
from aiogram.dispatcher import FSMContext
from olgram.models import models

from olgram.router import dp
from olgram.settings import OlgramSettings
from locales.locale import _

@dp.message_handler(commands=["notifyowner"], state="*")
async def notify(message: types.Message, state: FSMContext):
    """
    Команда /notify-owner
    """

    if message.chat.id != OlgramSettings.supervisor_id():
        await message.answer(_("Недостаточно прав"))
        return

    bot_name = message.get_args()

    if not bot_name:
        await message.answer(_("Нужно указать имя бота"))
        return

    bot = await models.Bot.filter(name=bot_name.replace("@", "")).first()

    if not bot:
        await message.answer(_("Такого бота нет в системе"))
        return

    await state.set_state("wait_owner_notify_message")
    await state.update_data({"notify_to_bot": bot.id})

    markup = types.ReplyKeyboardMarkup([[types.KeyboardButton(text=_("Пропустить"))]],
                                       resize_keyboard=True)

    await message.answer(_("Введите текст, который будет отправлен владельцу бота {0}. "
                           "Напишите 'Пропустить' чтобы отменить").format(bot_name), reply_markup=markup)

@dp.message_handler(state="wait_owner_notify_message")
async def on_notify_text(message: types.Message, state: FSMContext):
    if not message.text:
        await state.reset_state(with_data=True)
        await message.answer(_("Поддерживается только текст"), reply_markup=types.ReplyKeyboardRemove())
        return

    if message.text == _("Пропустить"):
        await state.reset_state(with_data=True)
        await message.answer(_("Отменено"), reply_markup=types.ReplyKeyboardRemove())
        return

    await state.update_data({"notify_text": message.text})
    await state.set_state("wait_owner_notify_message_confirm")

    markup = types.ReplyKeyboardMarkup([[types.KeyboardButton(text=_("Отправить")),
                                         types.KeyboardButton(text=_("Отменить"))]], resize_keyboard=True)

    await message.answer("Точно отправить?", reply_markup=markup)

@dp.message_handler(state="wait_owner_notify_message_confirm")
async def on_notify_message_confirm(message: types.Message, state: FSMContext):
    if not message.text or (message.text != _("Отправить")):
        await state.reset_state(with_data=True)
        await message.answer(_("Отменено"), reply_markup=types.ReplyKeyboardRemove())
        return

    data = await state.get_data()
    bot = await models.Bot.get(pk=data["notify_to_bot"])
    text = data["notify_text"]
    chat_id = (await bot.owner).telegram_id

    await state.reset_state(with_data=True)
    await message.bot.send_message(chat_id, text=text)
    await message.answer(_("Отправлено"), reply_markup=types.ReplyKeyboardRemove())


text:
"""
Здесь работа с конкретным ботом
"""
from aiogram import types
from aiogram.utils.exceptions import TelegramAPIError, Unauthorized
from aiogram import Bot as AioBot
from olgram.models.models import Bot
from server.server import unregister_token
from locales.locale import _

async def delete_bot(bot: Bot, call: types.CallbackQuery):
    """
    Пользователь решил удалить бота
    """
    try:
        await unregister_token(bot.decrypted_token())
    except Unauthorized:
        # Вероятно пользователь сбросил токен или удалил бот, это уже не наши проблемы
        pass
    await bot.delete()
    await call.answer(_("Бот удалён"))
    try:
        await call.message.delete()
    except TelegramAPIError:
        pass

async def reset_bot_text(bot: Bot, call: types.CallbackQuery):
    """
    Пользователь решил сбросить текст бота к default
    :param bot:
    :param call:
    :return:
    """
    bot.start_text = bot._meta.fields_map['start_text'].default
    await bot.save()
    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):
    """
    Пользователь выбрал чат, в который хочет получать сообщения от бота
    :param bot:
    :param call:
    :param chat:
    :return:
    """
    if chat == "personal":
        bot.group_chat = None
        await bot.save()
        await call.answer(_("Выбран личный чат"))
        return
    if chat == "leave":
        bot.group_chat = None
        await bot.save()
        chats = await bot.group_chats.all()
        a_bot = AioBot(bot.decrypted_token())
        for chat in chats:
            try:
                await chat.delete()
                await a_bot.leave_chat(chat.chat_id)
            except TelegramAPIError:
                pass
        await call.answer(_("Бот вышел из чатов"))
        await a_bot.session.close()
        return

    chat_obj = await bot.group_chats.filter(id=chat).first()
    if not chat_obj:
        await call.answer(_("Нельзя привязать бота к этому чату"))
        return
    bot.group_chat = chat_obj
    await bot.save()
    await call.answer(_("Выбран чат {0}").format(chat_obj.name))

async def threads(bot: Bot, call: types.CallbackQuery):
    bot.enable_threads = not bot.enable_threads
    await bot.save(update_fields=["enable_threads"])

async def additional_info(bot: Bot, call: types.CallbackQuery):
    bot.enable_additional_info = not bot.enable_additional_info
    await bot.save(update_fields=["enable_additional_info"])

async def olgram_text(bot: Bot, call: types.CallbackQuery):
    if await bot.is_promo():
        bot.enable_olgram_text = not bot.enable_olgram_text
        await bot.save(update_fields=["enable_olgram_text"])

async def antiflood(bot: Bot, call: types.CallbackQuery):
    bot.enable_antiflood = not bot.enable_antiflood
    await bot.save(update_fields=["enable_antiflood"])


text:
from olgram.router import dp

from aiogram import types, Bot as AioBot
from olgram.models.models import Bot, User, DefaultAnswer
from aiogram.dispatcher import FSMContext
from aiogram.utils.callback_data import CallbackData
from textwrap import dedent
from olgram.utils.mix import edit_or_create, button_text_limit, wrap
from olgram.commands import bot_actions
from locales.locale import _

import typing as ty

menu_callback = CallbackData('menu', 'level', 'bot_id', 'operation', 'chat')

empty = "0"

async def send_bots_menu(chat_id: int, user_id: int, call=None):
    """
    Отправить пользователю список ботов
    :return:
    """
    if call:
        await call.answer()

    user = await User.get_or_none(telegram_id=user_id)
    bots = await Bot.filter(owner=user)
    if not bots:
        await AioBot.get_current().send_message(chat_id, dedent(_("""
        У вас нет добавленных ботов.

        Отправьте команду /addbot, чтобы добавить бот.
        """)))
        return

    keyboard = types.InlineKeyboardMarkup(row_width=2)
    for bot in bots:
        keyboard.insert(
            types.InlineKeyboardButton(text="@" + bot.name,
                                       callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty,
                                                                       chat=empty))
        )

    text = _("Ваши боты")
    if call:
        await edit_or_create(call, text, keyboard)
    else:
        await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard)

async def send_chats_menu(bot: Bot, call: types.CallbackQuery):
    await call.answer()
    keyboard = types.InlineKeyboardMarkup(row_width=2)

    chats = await bot.group_chats.all()

    for chat in chats:
        keyboard.insert(
            types.InlineKeyboardButton(text=button_text_limit(chat.name),
                                       callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="chat",
                                                                       chat=chat.id))
        )
    if chats:
        keyboard.insert(
            types.InlineKeyboardButton(text=_("Личные сообщения"),
                                       callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="chat",
                                                                       chat="personal"))
        )
        keyboard.insert(
            types.InlineKeyboardButton(text=_("❗ Выйти из всех чатов"),
                                       callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="chat",
                                                                       chat="leave"))
        )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Назад"),
                                   callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty,
                                                                   chat=empty))
    )

    if not chats:
        text = dedent(_("""
        Этот бот не добавлен в чаты, поэтому все сообщения будут приходить вам в бот.
        Чтобы подключить чат — добавьте бот @{0} в чат, откройте это меню ещё раз и выберите добавленный чат.
        Если ваш бот состоял в групповом чате до того, как его добавили в Olgram - удалите бота из чата и добавьте
        снова.
        """)).format(bot.name)
    else:
        text = dedent(_("""
        В этом разделе вы можете привязать бота @{0} к чату.
        Выберите чат, куда бот будет пересылать сообщения.
        """)).format(bot.name)

    await edit_or_create(call, text, keyboard)

async def send_bot_menu(bot: Bot, call: types.CallbackQuery):
    await call.answer()
    keyboard = types.InlineKeyboardMarkup(row_width=2)
    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=2, bot_id=bot.id, operation="chat",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Удалить бот"),
                                   callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="delete",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Статистика"),
                                   callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="stat",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Назад"),
                                   callback_data=menu_callback.new(level=0, bot_id=empty, operation=empty, chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Опции"),
                                   callback_data=menu_callback.new(level=2, bot_id=bot.id, operation="settings",
                                                                   chat=empty))
    )

    await edit_or_create(call, dedent(_("""
    Управление ботом @{0}.

    Если у вас возникли вопросы по настройке бота, то посмотрите нашу справку /help или напишите нам
    @civsocit_feedback_bot
    """)).format(bot.name), reply_markup=keyboard)

async def send_bot_delete_menu(bot: Bot, call: 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="delete_yes",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Назад"),
                                   callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty))
    )

    await edit_or_create(call, dedent(_("""
    Вы уверены, что хотите удалить бота @{0}?
    """)).format(bot.name), reply_markup=keyboard)

async def send_bot_settings_menu(bot: Bot, call: 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="threads",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Данные пользователя"),
                                   callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="additional_info",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Антифлуд"),
                                   callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="antiflood",
                                                                   chat=empty))
    )
    is_promo = await bot.is_promo()
    if is_promo:
        keyboard.insert(
            types.InlineKeyboardButton(text=_("Olgram подпись"),
                                       callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="olgram_text",
                                                                       chat=empty))
        )

    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Назад"),
                                   callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty,
                                                                   chat=empty))
    )

    thread_turn = _("включены") if bot.enable_threads else _("выключены")
    info_turn = _("включены") if bot.enable_additional_info else _("выключены")
    antiflood_turn = _("включен") if bot.enable_antiflood else _("выключен")
    text = dedent(_("""
    Потоки сообщений: {0}
    Данные пользователя: {1}
    Антифлуд: {2}
    """)).format(thread_turn, info_turn, antiflood_turn)

    if is_promo:
        olgram_turn = _("включена") if bot.enable_olgram_text else _("выключена")
        text += _("Olgram подпись: {0}").format(olgram_turn)

    await edit_or_create(call, text, reply_markup=keyboard, parse_mode="HTML")

async def send_bot_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=3, bot_id=bot.id, operation="next_text",
                                                                   chat=empty))
    )
    keyboard.insert(
        types.InlineKeyboardButton(text=_("Сбросить текст"),
                                   callback_data=menu_callback.new(level=3, bot_id=bot.id, operation="reset_text",
                                                                   chat=empty))
    )

    text = dedent(_("""
    Сейчас вы редактируете текст, который отправляется после того, как пользователь отправит вашему боту @{0}
    команду /start

    Текущий текст:

    {1}

    Отправьте сообщение, чтобы изменить текст.
    """))
    text = text.format(bot.name, bot.start_text)
    if call:
        await edit_or_create(call, text, keyboard, parse_mode="HTML")
    else:
        await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML")

async def send_bot_statistic_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None,
                                  chat_id: ty.Optional[int] = None):
    if call:
        await call.answer()
    keyboard = types.InlineKeyboardMarkup(row_width=2)
    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Назад"),
                                   callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty))
    )

    text = dedent(_("""
    Статистика по боту @{0}

    Входящих сообщений: {1}
    Ответных сообщений: {2}
    Шаблоны ответов: {3}
    Забанено пользователей: {4}
    """)).format(bot.name, bot.incoming_messages_count, bot.outgoing_messages_count, len(await bot.answers),
                 len(await bot.banned_users))
    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")

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="templates",
                                                                   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} \
автоматически. По умолчанию оно отключено.

    Текущий текст:

    {1}

    Отправьте сообщение, чтобы изменить текст.
    """))
    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")

async def send_bot_templates_menu(bot: Bot, call: ty.Optional[types.CallbackQuery] = None,
                                  chat_id: ty.Optional[int] = None):
    if call:
        await call.answer()
    keyboard = types.InlineKeyboardMarkup(row_width=2)
    keyboard.insert(
        types.InlineKeyboardButton(text=_("<< Завершить редактирование"),
                                   callback_data=menu_callback.new(level=1, bot_id=bot.id, operation=empty, chat=empty))
    )

    text = dedent(_("""
    Сейчас вы редактируете шаблоны ответов для @{0}. Текущие шаблоны:

    {1}

    Отправьте какую-нибудь фразу (например: "Ваш заказ готов, ожидайте!"), чтобы добавить её в шаблон.
    Чтобы удалить шаблон из списка, отправьте его номер в списке (например, 4)
    """))

    templates = await bot.answers

    total_text_len = sum(len(t.text) for t in templates) + len(text)  # примерная длина текста
    max_len = 1000
    if total_text_len > 4000:
        max_len = 100

    templates_text = "\n".join(f"{n}. {wrap(template.text, max_len)}" for n, template in enumerate(templates))
    if not templates_text:
        templates_text = _("(нет шаблонов)")
    text = text.format(bot.name, templates_text)
    if call:
        await edit_or_create(call, text, keyboard, parse_mode="HTML")
    else:
        await AioBot.get_current().send_message(chat_id, text, reply_markup=keyboard, parse_mode="HTML")

@dp.message_handler(state="wait_start_text", content_types="text", regexp="^[^/].+")  # Not command
async def start_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.start_text = message.html_text
    await bot.save()
    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.html_text
    await bot.save()
    await send_bot_second_text_menu(bot, chat_id=message.chat.id)

@dp.message_handler(state="wait_template", content_types="text", regexp="^[^/](.+)?")  # Not command
async def template_received(message: types.Message, state: FSMContext):
    async with state.proxy() as proxy:
        bot_id = proxy.get("bot_id")
    bot = await Bot.get_or_none(pk=bot_id)

    if message.text.isdigit():
        # Delete template
        number = int(message.text)
        templates = await bot.answers
        if not templates:
            await message.answer(_("У вас нет шаблонов, чтобы их удалять"))
        if number < 0 or number >= len(templates):
            await message.answer(_("Неправильное число. Чтобы удалить шаблон, введите число от 0 до {0}").format(
                len(templates)))
            return
        await templates[number].delete()
    else:
        # Add template
        total_templates = len(await bot.answers)
        if total_templates > 30:
            await message.answer(_("У вашего бота уже слишком много шаблонов"))
        else:
            answers = await bot.answers.filter(text=message.text)
            if answers:
                await message.answer(_("Такой текст уже есть в списке шаблонов"))
            else:
                template = DefaultAnswer(text=message.text, bot=bot)
                await template.save()

    await send_bot_templates_menu(bot, chat_id=message.chat.id)

@dp.callback_query_handler(menu_callback.filter(), state="*")
async def callback(call: types.CallbackQuery, callback_data: dict, state: FSMContext):
    level = callback_data.get("level")

    if level == "0":
        return await send_bots_menu(call.message.chat.id, call.from_user.id, call)

    bot_id = callback_data.get("bot_id")
    bot = await Bot.get_or_none(id=bot_id)
    if not bot or (await bot.owner).telegram_id != call.from_user.id:
        await call.answer(_("У вас нет прав на этого бота"), show_alert=True)
        return

    if level == "1":
        await state.reset_state()
        return await send_bot_menu(bot, call)

    operation = callback_data.get("operation")
    if level == "2":
        await state.reset_state()
        if operation == "chat":
            return await send_chats_menu(bot, call)
        if operation == "delete":
            return await send_bot_delete_menu(bot, call)
        if operation == "stat":
            return await send_bot_statistic_menu(bot, call)
        if operation == "settings":
            return await send_bot_settings_menu(bot, call)
        if operation == "text":
            await state.set_state("wait_start_text")
            async with state.proxy() as proxy:
                proxy["bot_id"] = bot.id
            return await send_bot_text_menu(bot, call)

    if level == "3":
        if operation == "delete_yes":
            return await bot_actions.delete_bot(bot, call)
        if operation == "chat":
            return await bot_actions.select_chat(bot, call, callback_data.get("chat"))
        if operation == "threads":
            await bot_actions.threads(bot, call)
            return await send_bot_settings_menu(bot, call)
        if operation == "antiflood":
            await bot_actions.antiflood(bot, call)
            return await send_bot_settings_menu(bot, call)
        if operation == "additional_info":
            await bot_actions.additional_info(bot, call)
            return await send_bot_settings_menu(bot, call)
        if operation == "olgram_text":
            await bot_actions.olgram_text(bot, call)
            return await send_bot_settings_menu(bot, call)
        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)
        if operation == "templates":
            await state.set_state("wait_template")
            async with state.proxy() as proxy:
                proxy["bot_id"] = bot.id
            return await send_bot_templates_menu(bot, call)


text:
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup
from aiogram.utils.exceptions import TelegramAPIError

from typing import Optional

async def try_delete_message(message: Message):
    try:
        await message.delete()
    except TelegramAPIError:
        pass

async def edit_or_create(call: CallbackQuery, message: str,
                         reply_markup: Optional[InlineKeyboardMarkup] = None,
                         parse_mode: Optional[str] = None):
    try:
        await call.message.edit_text(message, parse_mode=parse_mode)
        await call.message.edit_reply_markup(reply_markup)
    except TelegramAPIError:  # кнопка устарела
        await call.bot.send_message(call.message.chat.id, text=message, reply_markup=reply_markup,
                                    parse_mode=parse_mode)

def wrap(data: str, max_len: int) -> str:
    if len(data) > max_len:
        data = data[:max_len-4] + "..."
    return data

def button_text_limit(data: str) -> str:
    return wrap(data, 30)


text:
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()


text:
import aiogram.types as types
from aiogram.dispatcher.handler import CancelHandler, current_handler
from aiogram.dispatcher.middlewares import BaseMiddleware
import typing as ty
from locales.locale import _

def public():
    """
    Хендлеры с этим декоратором будут обрабатываться даже если пользователь не является владельцем бота
    (например, команда /help)
    :return:
    """

    def decorator(func):
        setattr(func, "access_public", True)
        return func

    return decorator

class AccessMiddleware(BaseMiddleware):
    def __init__(self, access_chat_ids: ty.Iterable[int]):
        self._access_chat_ids = access_chat_ids
        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_ids = self._access_chat_ids
        if not admin_ids:
            return  # Администраторы бота вообще не указаны

        if self._is_public_command():  # Эта команда разрешена всем пользователям
            return

        if message.chat.id not in admin_ids:
            await message.answer(_("Владелец бота ограничил доступ к этому функционалу 😞"))
            raise CancelHandler()

    async def on_process_callback_query(self, call: types.CallbackQuery, data: dict):
        admin_ids = self._access_chat_ids
        if not admin_ids:
            return  # Администраторы бота вообще не указаны

        if self._is_public_command():  # Эта команда разрешена всем пользователям
            return

        if call.message.chat.id not in admin_ids:
            await call.answer(_("Владелец бота ограничил доступ к этому функционалу😞"))
            raise CancelHandler()


text:
"""Наши собственные миграции, которые нельзя описать на языке SQL и с которыми не справится TortoiseORM/Aerich"""

import aioredis
from tortoise import transactions, Tortoise
from olgram.settings import TORTOISE_ORM, ServerSettings
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")

async def upgrade_2():
    """Отменяем малый TTL для старых сообщений"""
    meta_info = await MetaInfo.first()
    if meta_info.version != 1:
        logging.info("skip")
        return

    con = await aioredis.create_connection(ServerSettings.redis_path())
    client = aioredis.Redis(con)

    i, keys = await client.scan()
    for key in keys:
        if not key.startswith(b"thread"):
            await client.pexpire(key, ServerSettings.redis_timeout_ms())

    meta_info.version = 2
    await meta_info.save()
    logging.info("done")

async def upgrade_3():
    """start_text и second_text должны быть валидными HTML"""
    import html

    meta_info = await MetaInfo.first()
    if meta_info.version != 2:
        logging.info("skip")
        return

    async with transactions.in_transaction():
        bots = await Bot.all()
        for bot in bots:
            if bot.start_text:
                bot.start_text = html.escape(bot.start_text)
            if bot.second_text:
                bot.second_text = html.escape(bot.second_text)
            await bot.save(update_fields=["start_text", "second_text"])
        meta_info.version = 3
        await meta_info.save()
    logging.info("done")

# Не забудь добавить миграцию в этот лист!
_migrations = [upgrade_1, upgrade_2, upgrade_3]

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


text:
-- upgrade --
ALTER TABLE "bot" ADD "enable_additional_info" BOOL NOT NULL  DEFAULT False;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "enable_additional_info";


text:
-- upgrade --
ALTER TABLE "bot" ADD "outgoing_messages_count" BIGINT NOT NULL  DEFAULT 0;
ALTER TABLE "bot" ADD "incoming_messages_count" BIGINT NOT NULL  DEFAULT 0;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "outgoing_messages_count";
ALTER TABLE "bot" DROP COLUMN "incoming_messages_count";


text:
-- upgrade --
CREATE TABLE IF NOT EXISTS "defaultanswer" (
    "id" BIGSERIAL NOT NULL PRIMARY KEY,
    "bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE CASCADE
);
-- downgrade --
DROP TABLE IF EXISTS "defaultanswer";


text:
-- upgrade --
CREATE TABLE IF NOT EXISTS "group_chat" (
    "id" SERIAL NOT NULL PRIMARY KEY,
    "chat_id" INT NOT NULL UNIQUE,
    "name" VARCHAR(50) NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_group_chat_chat_id_5da32d" ON "group_chat" ("chat_id");
CREATE TABLE IF NOT EXISTS "user" (
    "id" SERIAL NOT NULL PRIMARY KEY,
    "telegram_id" INT NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
CREATE TABLE IF NOT EXISTS "bot" (
    "id" SERIAL NOT NULL PRIMARY KEY,
    "token" VARCHAR(50) NOT NULL UNIQUE,
    "name" VARCHAR(33) NOT NULL,
    "code" UUID NOT NULL,
    "start_text" TEXT NOT NULL,
    "group_chat_id" INT REFERENCES "group_chat" ("id") ON DELETE CASCADE,
    "owner_id" INT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_bot_code_a43015" ON "bot" ("code");
CREATE TABLE IF NOT EXISTS "aerich" (
    "id" SERIAL NOT NULL PRIMARY KEY,
    "version" VARCHAR(255) NOT NULL,
    "app" VARCHAR(20) NOT NULL,
    "content" JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS "bot_group_chat" (
    "bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE CASCADE,
    "groupchat_id" INT NOT NULL REFERENCES "group_chat" ("id") ON DELETE CASCADE
);


text:
-- upgrade --
ALTER TABLE "group_chat" ALTER COLUMN "name" TYPE VARCHAR(255) USING "name"::VARCHAR(255);
-- downgrade --
ALTER TABLE "group_chat" ALTER COLUMN "name" TYPE VARCHAR(50) USING "name"::VARCHAR(50);


text:
-- 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";


text:
-- upgrade --
CREATE TABLE IF NOT EXISTS "promo" (
    "id" BIGSERIAL NOT NULL PRIMARY KEY,
    "code" UUID NOT NULL,
    "date" TIMESTAMPTZ NOT NULL  DEFAULT CURRENT_TIMESTAMP,
    "owner_id" INT REFERENCES "user" ("id") ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS "idx_promo_code_9b981a" ON "promo" ("code");
-- downgrade --
DROP TABLE IF EXISTS "promo";


text:
-- upgrade --
CREATE TABLE IF NOT EXISTS "bot_banned_user" (
    "id" BIGSERIAL NOT NULL PRIMARY KEY,
    "telegram_id" BIGINT NOT NULL,
    "username" VARCHAR(100),
    "bot_id" INT NOT NULL REFERENCES "bot" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_bot_banned__telegra_915aca" ON "bot_banned_user" ("telegram_id");
-- downgrade --
DROP TABLE IF EXISTS "bot_banned_user";
DROP INDEX IF EXISTS "idx_bot_banned__telegra_915aca";


text:
-- upgrade --
ALTER TABLE "bot" ADD "second_text" TEXT;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "second_text";


text:
-- upgrade --
ALTER TABLE "defaultanswer" ADD "text" TEXT NOT NULL;
-- downgrade --
ALTER TABLE "defaultanswer" DROP COLUMN "text";


text:
-- upgrade --
ALTER TABLE "bot" ADD "enable_threads" BOOL NOT NULL  DEFAULT False;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "enable_threads";


text:
-- upgrade --
ALTER TABLE "bot" ADD "enable_olgram_text" BOOL NOT NULL  DEFAULT True;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "enable_olgram_text";


text:
-- upgrade --
ALTER TABLE "group_chat" ALTER COLUMN "chat_id" TYPE BIGINT USING "chat_id"::BIGINT;
ALTER TABLE "user" ALTER COLUMN "telegram_id" TYPE BIGINT USING "telegram_id"::BIGINT;
-- downgrade --
ALTER TABLE "user" ALTER COLUMN "telegram_id" TYPE INT USING "telegram_id"::INT;
ALTER TABLE "group_chat" ALTER COLUMN "chat_id" TYPE INT USING "chat_id"::INT;


text:
-- upgrade --
ALTER TABLE "bot" ADD "enable_antiflood" BOOL NOT NULL  DEFAULT False;
-- downgrade --
ALTER TABLE "bot" DROP COLUMN "enable_antiflood";


text:
from aiogram import Bot as AioBot
from aiogram.types import BotCommand
from olgram.models.models import Bot
from aiohttp import web
from asyncio import get_event_loop
import ssl
from olgram.settings import ServerSettings
from locales.locale import _
from .custom import CustomRequestHandler

import logging

logger = logging.getLogger(__name__)

def path_for_bot(bot: Bot) -> str:
    return "/" + str(bot.code)

def url_for_bot(bot: Bot) -> str:
    return f"https://{ServerSettings.hook_host()}:{ServerSettings.hook_port()}" + path_for_bot(bot)

async def register_token(bot: Bot) -> bool:
    """
    Зарегистрировать токен
    :param bot: Бот
    :return: получилось ли
    """
    await unregister_token(bot.decrypted_token())

    a_bot = AioBot(bot.decrypted_token())
    certificate = None
    if ServerSettings.use_custom_cert():
        certificate = open(ServerSettings.public_path(), 'rb')

    res = await a_bot.set_webhook(url_for_bot(bot), certificate=certificate, drop_pending_updates=True,
                                  max_connections=10)
    await a_bot.set_my_commands([
        BotCommand("/start", _("(Пере)запустить бота")),
        BotCommand("/security_policy", _("Политика конфиденциальности"))
    ])

    await a_bot.session.close()
    del a_bot
    return res

async def unregister_token(token: str):
    """
    Удалить токен
    :param token: токен
    :return:
    """
    bot = AioBot(token)
    await bot.delete_webhook(drop_pending_updates=True)
    await bot.session.close()
    del bot

def main():
    loop = get_event_loop()

    app = web.Application()
    app.router.add_route('*', r"/{name}", CustomRequestHandler, name='webhook_handler')

    context = None
    if ServerSettings.use_custom_cert():
        context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
        context.load_cert_chain(ServerSettings.public_path(), ServerSettings.priv_path())

    runner = web.AppRunner(app)
    loop.run_until_complete(runner.setup())
    logger.info("Server initialization done")
    site = web.TCPSite(runner, port=ServerSettings.app_port(), ssl_context=context)
    return site


text:
from aiocache import cached
import hashlib
from aiogram.types import InlineQuery, InputTextMessageContent, InlineQueryResultArticle
from aiogram.bot import Bot as AioBot

from olgram.models.models import Bot
import typing as ty

@cached(ttl=60)
async def get_phrases(bot: Bot) -> ty.List:
    objects = await bot.answers
    return [obj.text for obj in objects]

async def check_chat_member(chat_id: int, user_id: int, bot: AioBot) -> bool:
    member = await bot.get_chat_member(chat_id, user_id)
    return member.is_chat_member()

@cached(ttl=60)
async def check_permissions(inline_query: InlineQuery, bot: Bot):
    user_id = inline_query.from_user.id
    super_chat_id = await bot.super_chat_id()

    if super_chat_id == user_id:
        return True

    if super_chat_id < 0:  # Group chat
        is_member = await check_chat_member(super_chat_id, user_id, inline_query.bot)
        return is_member

    return False

async def inline_handler(inline_query: InlineQuery, bot: Bot):
    # Check permissions at first
    allow = await check_permissions(inline_query, bot)
    if not allow:
        return await inline_query.answer([])  # forbidden

    all_phrases = await get_phrases(bot)
    phrases = [phrase for phrase in all_phrases if inline_query.query.lower() in phrase.lower()]
    items = []
    for phrase in phrases:

        input_content = InputTextMessageContent(phrase)
        result_id: str = hashlib.md5(phrase.encode()).hexdigest()
        item = InlineQueryResultArticle(
            id=result_id,
            title=phrase,
            input_message_content=input_content,
        )
        items.append(item)

    await inline_query.answer(results=items)