wachter_bot
+pre-commit install
```
-## Как добавить в свой чат
+Then will be executed black against changed files in commits.
-1. Добавить в чат
-2. Написать whois (минимальная длина 20 символов)
-3. Написать боту в лс /start - бот заработает и появятся настройки чата
+### Running
-## Local Development
+1) Run `cp env.template .env`;
-1) Set `TELEGRAM_TOKEN` environment variable;
+1) Set `TELEGRAM_TOKEN` and `TELEGRAM_ERROR_CHAT_ID` in `.env`;
2) Run:
```bash
-docker-compose -f docker-compose.dev.yml up
-```
\ No newline at end of file
+docker-compose -f docker-compose.dev.yml build && docker-compose -f docker-compose.dev.yml up
+```
diff --git a/alembic.ini b/alembic.ini
index 39a5bb2..6e2163b 100644
--- a/alembic.ini
+++ b/alembic.ini
@@ -35,8 +35,7 @@ script_location = migrations
# are written from script.py.mako
# output_encoding = utf-8
-sqlalchemy.url = postgresql://localhost:5432/wachter
-
+# sqlalchemy.url = postgresql+asyncpg://user:password@wachter-db/db
# Logging configuration
[loggers]
diff --git a/app.py b/app.py
index 3e8e562..387eb9c 100644
--- a/app.py
+++ b/app.py
@@ -1,58 +1,128 @@
from telegram.ext import (
- Updater,
+ ApplicationBuilder,
CommandHandler,
- Filters,
+ filters,
MessageHandler,
CallbackQueryHandler,
+ ChatMemberHandler,
+ PicklePersistence,
)
+from ptbcontrib.ptb_jobstores.sqlalchemy import PTBSQLAlchemyJobStore
+
+import grpc
+from opentelemetry import metrics
+from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
+ OTLPMetricExporter,
+)
+from opentelemetry.sdk import metrics as sdkmetrics
+from opentelemetry.sdk.metrics import MeterProvider
+from opentelemetry.sdk.metrics.export import (
+ AggregationTemporality,
+ PeriodicExportingMetricReader,
+)
+from opentelemetry.sdk.resources import Resource
+
from src.custom_filters import filter_bot_added
from src.logging import tg_logger
from src import handlers
import os
+temporality_delta = {
+ sdkmetrics.Counter: AggregationTemporality.DELTA,
+ sdkmetrics.UpDownCounter: AggregationTemporality.DELTA,
+ sdkmetrics.Histogram: AggregationTemporality.DELTA,
+ sdkmetrics.ObservableCounter: AggregationTemporality.DELTA,
+ sdkmetrics.ObservableUpDownCounter: AggregationTemporality.DELTA,
+ sdkmetrics.ObservableGauge: AggregationTemporality.DELTA,
+}
+
+
def main():
- updater = Updater(os.environ["TELEGRAM_TOKEN"])
- dp = updater.dispatcher
+ dsn = os.environ.get("UPTRACE_DSN")
+
+ exporter = OTLPMetricExporter(
+ endpoint="otlp.uptrace.dev:4317",
+ headers=(("uptrace-dsn", dsn),),
+ timeout=5,
+ compression=grpc.Compression.Gzip,
+ preferred_temporality=temporality_delta,
+ )
+ reader = PeriodicExportingMetricReader(exporter)
+
+ resource = Resource(
+ attributes={
+ "service.name": "wachter",
+ "service.version": "1.1.0",
+ "deployment.environment": os.environ.get("DEPLOYMENT_ENVIRONMENT"),
+ }
+ )
+ provider = MeterProvider(metric_readers=[reader], resource=resource)
+ metrics.set_meter_provider(provider)
- dp.add_handler(CommandHandler("help", handlers.help_handler))
+ application = (
+ ApplicationBuilder()
+ .persistence(PicklePersistence(filepath="persistent_storage.pickle"))
+ .token(os.environ["TELEGRAM_TOKEN"])
+ .build()
+ )
+ if "PERSISTENCE_DATABASE_URL" in os.environ:
+ tg_logger.info(f"Using SQLAlchemy job store with PERSISTENCE_DATABASE_URL")
+ application.job_queue.scheduler.add_jobstore(
+ PTBSQLAlchemyJobStore(
+ application=application,
+ url=os.environ["PERSISTENCE_DATABASE_URL"],
+ )
+ )
+ else:
+ tg_logger.info("No PERSISTENCE_DATABASE_URL set, using in-memory job store")
+
+ application.add_handler(CommandHandler("help", handlers.help_handler))
+ application.add_handler(CommandHandler("listjobs", handlers.list_jobs_handler))
# group UX
- dp.add_handler(
+ application.add_handler(
+ ChatMemberHandler(
+ handlers.my_chat_member_handler,
+ ChatMemberHandler.MY_CHAT_MEMBER,
+ )
+ )
+ application.add_handler(
MessageHandler(
- Filters.entity("hashtag"),
+ filters.Entity("hashtag") & filters.ChatType.GROUPS,
handlers.on_hashtag_message,
- pass_job_queue=True,
- pass_user_data=True,
)
)
- dp.add_handler(
+ application.add_handler(
MessageHandler(
- Filters.status_update.new_chat_members & filter_bot_added,
- handlers.on_new_chat_member,
- pass_job_queue=True,
+ filters.StatusUpdate.NEW_CHAT_MEMBERS & filter_bot_added,
+ handlers.on_new_chat_members,
)
)
# admin UX
- dp.add_handler(
- CommandHandler("start", handlers.start_handler, pass_user_data=True)
- )
- dp.add_handler(CallbackQueryHandler(handlers.button_handler, pass_user_data=True))
- dp.add_handler(
- MessageHandler(
- (Filters.text | Filters.entity),
- handlers.message_handler,
- pass_user_data=True,
- pass_job_queue=True,
- )
+ application.add_handler(CommandHandler("start", handlers.start_handler))
+ application.add_handler(CallbackQueryHandler(handlers.button_handler))
+ application.add_handler(MessageHandler(filters.TEXT, handlers.message_handler))
+ application.add_error_handler(handlers.error_handler)
+
+ # Remove any existing metrics_exporter jobs to avoid duplicates
+ existing_jobs = [job for job in application.job_queue.jobs() if job.name == "metrics_exporter"]
+ if existing_jobs:
+ tg_logger.info(f"Removing {len(existing_jobs)} existing metrics_exporter job(s)")
+ for job in existing_jobs:
+ job.schedule_removal()
+
+ job = application.job_queue.run_repeating(
+ handlers.group.group_handler.db_metrics_reader_helper,
+ 3600,
+ name="metrics_exporter",
)
- dp.add_error_handler(handlers.error_handler)
+ tg_logger.info(f"Scheduled metrics_exporter job: {job.name}")
- updater.start_polling()
tg_logger.info("Bot has started successfully")
- updater.idle()
+ application.run_polling()
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f6b2274..4a84dea 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -22,6 +22,27 @@ services:
timeout: 2s
retries: 3
+ wachter-persistence-db:
+ container_name: wachter-persistence-db-dev
+ image: postgres:alpine
+ restart: always
+ ports:
+ - 5434:5432
+ environment:
+ POSTGRES_DB: db
+ POSTGRES_HOST: wachter-persistence-db
+ POSTGRES_USER: user
+ POSTGRES_PASSWORD: password
+ volumes:
+ - volume-persistence-db:/data/db
+ - postgres-persistence-data:/var/lib/postgresql/data
+ - ./share/sql-persistence:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: pg_isready -U user -d db
+ interval: 5s
+ timeout: 2s
+ retries: 3
+
wachter:
container_name: wachter-dev
build:
@@ -29,7 +50,8 @@ services:
environment:
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID}
- - DATABASE_URL=postgresql://user:password@wachter-db/db
+ - DATABASE_URL=postgresql+asyncpg://user:password@wachter-db/db
+ - PERSISTENCE_DATABASE_URL=postgresql://user:password@wachter-persistence-db/db
restart: unless-stopped
depends_on:
wachter-db:
@@ -38,6 +60,8 @@ services:
volumes:
volume-db:
postgres-data:
+ volume-persistence-db:
+ postgres-persistence-data:
networks:
default:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 2a762fc..40b1dfa 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,15 +1,66 @@
version: "3.9"
services:
- wachterbot:
+ wachterbot-prod:
container_name: wachterbot-prod
- image: ghcr.io/alexeyqu/wachter_bot/wachterbot:prod
+ image: ghcr.io/wachter-org/wachter-bot/wachterbot:prod
environment:
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
+ - UPTRACE_DSN=${UPTRACE_DSN}
- TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID}
- - DATABASE_URL=${POSTGRES_URL}
+ - DATABASE_URL=${DATABASE_URL}
+ - PERSISTENCE_DATABASE_URL=${PERSISTENCE_DATABASE_URL}
+ - DEBUG=${DEBUG}
+ - DEPLOYMENT_ENVIRONMENT=production
+ - TEAM_TELEGRAM_IDS=${TEAM_TELEGRAM_IDS}
restart: unless-stopped
+ postgres-prod:
+ image: postgres:17-alpine
+ restart: always
+ ports:
+ - 5435:5432
+ environment:
+ POSTGRES_DB: db
+ POSTGRES_HOST: postgres-prod
+ POSTGRES_USER: ${DATABASE_USER}
+ POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
+ volumes:
+ - volume-db:/data/db
+ - postgres-data:/var/lib/postgresql/data
+ - ./backup/db.sql:/docker-entrypoint-initdb.d/backupfile.sql
+ healthcheck:
+ test: pg_isready -U ${DATABASE_USER} -d db
+ interval: 5s
+ timeout: 2s
+ retries: 3
+
+ postgres-persistence-prod:
+ image: postgres:17-alpine
+ restart: always
+ ports:
+ - 5436:5432
+ environment:
+ POSTGRES_DB: persistence-db
+ POSTGRES_HOST: postgres-persistence-prod
+ POSTGRES_USER: ${PERSISTENCE_DATABASE_USER}
+ POSTGRES_PASSWORD: ${PERSISTENCE_DATABASE_PASSWORD}
+ volumes:
+ - volume-persistence-db:/data/db
+ - postgres-persistence-data:/var/lib/postgresql/data
+ - ./backup/db-persistence.sql:/docker-entrypoint-initdb.d/backupfile.sql
+ healthcheck:
+ test: pg_isready -U ${PERSISTENCE_DATABASE_USER} -d persistence-db
+ interval: 5s
+ timeout: 2s
+ retries: 3
+
+volumes:
+ volume-db:
+ postgres-data:
+ volume-persistence-db:
+ postgres-persistence-data:
+
networks:
default:
name: network-wachterbot-prod
diff --git a/docker-compose.testing.yml b/docker-compose.testing.yml
index 150a001..3f0701d 100644
--- a/docker-compose.testing.yml
+++ b/docker-compose.testing.yml
@@ -1,15 +1,66 @@
version: "3.9"
services:
- wachterbot:
+ wachterbot-testing:
container_name: wachterbot-testing
- image: ghcr.io/alexeyqu/wachter_bot/wachterbot:testing
+ image: ghcr.io/wachter-org/wachter-bot/wachterbot:testing
environment:
- TELEGRAM_TOKEN=${TELEGRAM_TOKEN}
- TELEGRAM_ERROR_CHAT_ID=${TELEGRAM_ERROR_CHAT_ID}
- - DATABASE_URL=${POSTGRES_URL}
+ - DATABASE_URL=${DATABASE_URL}
+ - UPTRACE_DSN=${UPTRACE_DSN}
+ - PERSISTENCE_DATABASE_URL=${PERSISTENCE_DATABASE_URL}
+ - DEBUG=${DEBUG}
+ - DEPLOYMENT_ENVIRONMENT=testing
+ - TEAM_TELEGRAM_IDS=${TEAM_TELEGRAM_IDS}
restart: unless-stopped
+ postgres-testing:
+ image: postgres:17-alpine
+ restart: always
+ ports:
+ - 5433:5432
+ environment:
+ POSTGRES_DB: db
+ POSTGRES_HOST: postgres-testing
+ POSTGRES_USER: ${DATABASE_USER}
+ POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
+ volumes:
+ - volume-db:/data/db
+ - postgres-data:/var/lib/postgresql/data
+ - ./backup/db.sql:/docker-entrypoint-initdb.d/backupfile.sql
+ healthcheck:
+ test: pg_isready -U ${DATABASE_USER} -d db
+ interval: 5s
+ timeout: 2s
+ retries: 3
+
+ postgres-persistence-testing:
+ image: postgres:17-alpine
+ restart: always
+ ports:
+ - 5434:5432
+ environment:
+ POSTGRES_DB: persistence-db
+ POSTGRES_HOST: postgres-persistence-testing
+ POSTGRES_USER: ${PERSISTENCE_DATABASE_USER}
+ POSTGRES_PASSWORD: ${PERSISTENCE_DATABASE_PASSWORD}
+ volumes:
+ - volume-persistence-db:/data/db
+ - postgres-persistence-data:/var/lib/postgresql/data
+ - ./backup/db-persistence.sql:/docker-entrypoint-initdb.d/backupfile.sql
+ healthcheck:
+ test: pg_isready -U ${PERSISTENCE_DATABASE_USER} -d persistence-db
+ interval: 5s
+ timeout: 2s
+ retries: 3
+
+volumes:
+ volume-db:
+ postgres-data:
+ volume-persistence-db:
+ postgres-persistence-data:
+
networks:
default:
name: network-wachterbot-testing
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 0000000..c3edb9b
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+alembic upgrade head
+exec python app.py
diff --git a/.env b/env.template
similarity index 100%
rename from .env
rename to env.template
diff --git a/migrations/env.py b/migrations/env.py
index 1a57ab5..bf1664e 100644
--- a/migrations/env.py
+++ b/migrations/env.py
@@ -1,12 +1,12 @@
-from __future__ import with_statement
-from alembic import context
-from sqlalchemy import create_engine, pool
+import asyncio
from logging.config import fileConfig
+import os
-import os, sys
-
-sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..")))
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -14,27 +14,25 @@
# Interpret the config file for Python logging.
# This line sets up loggers basically.
-fileConfig(config.config_file_name)
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
-from src import model
-
-target_metadata = model.Base.metadata
+target_metadata = None
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
+config.set_main_option('sqlalchemy.url', os.environ.get(
+ "DATABASE_URL", "postgresql+asyncpg://user:password@wachter-db/db"
+))
-def get_uri():
- return os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
-
-
-def run_migrations_offline():
+def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
@@ -46,28 +44,47 @@ def run_migrations_offline():
script output.
"""
+ url = config.get_main_option("sqlalchemy.url")
context.configure(
- url=get_uri(), target_metadata=target_metadata, literal_binds=True
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
-def run_migrations_online():
- """Run migrations in 'online' mode.
+def do_run_migrations(connection: Connection) -> None:
+ context.configure(connection=connection, target_metadata=target_metadata)
- In this scenario we need to create an Engine
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ """In this scenario we need to create an Engine
and associate a connection with the context.
"""
- connectable = create_engine(get_uri())
- with connectable.connect() as connection:
- context.configure(connection=connection, target_metadata=target_metadata)
+ connectable = async_engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode."""
- with context.begin_transaction():
- context.run_migrations()
+ asyncio.run(run_async_migrations())
if context.is_offline_mode():
diff --git a/migrations/versions/0336b796d052_create_tables_for_timeouts_and_whois_.py b/migrations/versions/0336b796d052_create_tables_for_timeouts_and_whois_.py
new file mode 100644
index 0000000..5084dd3
--- /dev/null
+++ b/migrations/versions/0336b796d052_create_tables_for_timeouts_and_whois_.py
@@ -0,0 +1,38 @@
+"""create tables for timeouts and whois length
+
+Revision ID: e34af99a19b5
+Revises: 0336b796d052
+Create Date: 2023-10-21 16:56:05.421923
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "e34af99a19b5"
+down_revision = "0336b796d052"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column(
+ "chats",
+ sa.Column("notify_timeout", sa.Integer(), nullable=False, server_default="0"),
+ )
+ op.add_column(
+ "chats",
+ sa.Column("whois_length", sa.Integer(), nullable=False, server_default="60"),
+ )
+
+
+def downgrade():
+ op.drop_column(
+ "chats",
+ sa.Column("notify_timeout", sa.Integer(), nullable=False, server_default="0"),
+ )
+ op.drop_column(
+ "chats",
+ sa.Column("whois_length", sa.Integer(), nullable=False, server_default="60"),
+ )
diff --git a/migrations/versions/296da7f6d724_remove_default_values.py b/migrations/versions/296da7f6d724_remove_default_values.py
new file mode 100644
index 0000000..8d8672a
--- /dev/null
+++ b/migrations/versions/296da7f6d724_remove_default_values.py
@@ -0,0 +1,92 @@
+"""remove default values
+
+Revision ID: 296da7f6d724
+Revises: 85798d8901da
+Create Date: 2023-10-25 15:37:35.767976
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "296da7f6d724"
+down_revision = "85798d8901da"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.batch_alter_table("chats", schema=None) as batch_op:
+ batch_op.alter_column(
+ "on_new_chat_member_message", existing_type=sa.TEXT(), server_default=None
+ )
+ batch_op.alter_column(
+ "on_known_new_chat_member_message",
+ existing_type=sa.TEXT(),
+ server_default=None,
+ )
+ batch_op.alter_column(
+ "on_introduce_message", existing_type=sa.TEXT(), server_default=None
+ )
+ batch_op.alter_column(
+ "on_kick_message", existing_type=sa.TEXT(), server_default=None
+ )
+ batch_op.alter_column(
+ "notify_message", existing_type=sa.TEXT(), server_default=None
+ )
+ batch_op.alter_column(
+ "kick_timeout", existing_type=sa.INTEGER(), server_default=None
+ )
+ batch_op.alter_column(
+ "notify_timeout", existing_type=sa.INTEGER(), server_default=None
+ )
+ batch_op.alter_column(
+ "whois_length", existing_type=sa.INTEGER(), server_default=None
+ )
+ batch_op.alter_column(
+ "on_introduce_message_update", existing_type=sa.TEXT(), server_default=None
+ )
+
+
+def downgrade():
+ with op.batch_alter_table("chats", schema=None) as batch_op:
+ batch_op.alter_column(
+ "on_new_chat_member_message",
+ existing_type=sa.TEXT(),
+ server_default="Пожалуйста, представьтесь и поздоровайтесь с сообществом.",
+ )
+ batch_op.alter_column(
+ "on_known_new_chat_member_message",
+ existing_type=sa.TEXT(),
+ server_default="Добро пожаловать. Снова",
+ )
+ batch_op.alter_column(
+ "on_introduce_message",
+ existing_type=sa.TEXT(),
+ server_default="Добро пожаловать.",
+ )
+ batch_op.alter_column(
+ "on_kick_message",
+ existing_type=sa.TEXT(),
+ server_default="%USER\_MENTION% молчит и покидает чат",
+ )
+ batch_op.alter_column(
+ "notify_message",
+ existing_type=sa.TEXT(),
+ server_default="%USER\_MENTION%, пожалуйста, представьтесь и поздоровайтесь с сообществом.",
+ )
+ batch_op.alter_column(
+ "kick_timeout", existing_type=sa.INTEGER(), server_default="0"
+ )
+ batch_op.alter_column(
+ "notify_timeout", existing_type=sa.INTEGER(), server_default="0"
+ )
+ batch_op.alter_column(
+ "whois_length", existing_type=sa.INTEGER(), server_default="60"
+ )
+ batch_op.alter_column(
+ "on_introduce_message_update",
+ existing_type=sa.TEXT(),
+ server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.",
+ )
diff --git a/migrations/versions/85798d8901da_add_on_introduce_message_update_column.py b/migrations/versions/85798d8901da_add_on_introduce_message_update_column.py
new file mode 100644
index 0000000..43aa980
--- /dev/null
+++ b/migrations/versions/85798d8901da_add_on_introduce_message_update_column.py
@@ -0,0 +1,40 @@
+"""add on_introduce_message_update column
+
+Revision ID: 85798d8901da
+Revises: e34af99a19b5
+Create Date: 2023-10-23 22:48:45.471633
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "85798d8901da"
+down_revision = "e34af99a19b5"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column(
+ "chats",
+ sa.Column(
+ "on_introduce_message_update",
+ sa.Text(),
+ nullable=False,
+ server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.",
+ ),
+ )
+
+
+def downgrade():
+ op.drop_column(
+ "chats",
+ sa.Column(
+ "on_introduce_message_update",
+ sa.Text(),
+ nullable=False,
+ server_default="Если вы хотите обновить, то добавьте тег #update к сообщению.",
+ ),
+ )
diff --git a/src/constants.py b/src/constants.py
index f64d996..649f8d7 100644
--- a/src/constants.py
+++ b/src/constants.py
@@ -1,37 +1,11 @@
from enum import IntEnum, auto
+import json, os
-# MESSAGES
-on_set_new_message = "Обновил сообщение."
-on_success_set_kick_timeout_response = "Обновил таймаут кика."
-on_failed_set_kick_timeout_response = "Таймаут должен быть целым положительным числом"
-on_failed_kick_response = "Я не справился."
-on_success_kick_response = "%USER\_MENTION% не представился и был кикнут из чата."
-on_start_command = "Выберите чат и действие:"
-skip_on_new_chat_member_message = "%SKIP%"
-help_message = """Привет. Для начала работы добавь меня в чат.
-Для настройки бота админу нужно представиться в чате (написать сообщение с #whois длинной больше 120 символов) и написать мне в личных сообщениях /start.
-По умолчанию я не кикаю непредставившихся, а лишь записываю все сообщения с тегом #whois.
-Если нужно кикать, то установи таймаут кика в значение больше нуля (в минутах).
-За 10 минут до кика я отправляю сообщение с напоминанием.
-"""
-
-get_settings_message = """
-Таймаут кика: {kick_timeout}
----
-Сообщение для нового участника чата: {on_new_chat_member_message}
----
-Сообщение при перезаходе в чат: {on_known_new_chat_member_message}
----
-Сообщение после успешного представления: {on_introduce_message}
----
-Сообщение предупреждения: {notify_message}
----
-Сообщение после кика: {on_kick_message}
-"""
-
-default_kick_timeout = 0
-notify_delta = 10
-min_whois_length = 60
+
+default_kick_timeout_m = 1440 # 24h in minutes
+default_notify_timeout_m = 1380 # 23h in minutes
+default_delete_message_timeout_m = 60 # 1h in minutes
+default_whois_length = 60
# ACTIONS
@@ -45,16 +19,21 @@ class Actions(IntEnum):
set_kick_timeout = auto()
set_on_kick_message = auto()
get_current_settings = auto()
+ set_intro_settings = auto()
+ set_kick_bans_settings = auto()
+ back_to_chats = auto()
+ set_notify_timeout = auto()
+ get_current_kick_settings = auto()
+ get_current_intro_settings = auto()
+ set_whois_length = auto()
+ set_on_introduce_message_update = auto()
+
+DEBUG = os.environ.get("DEBUG", "True") in ["True"]
+TEAM_TELEGRAM_IDS = json.loads(os.environ.get("TEAM_TELEGRAM_IDS", "[]"))
-RH_kick_messages = [
- "Хакер %USER\_MENTION% молчит и покидает чат. ⚰",
- "Хакера %USER\_MENTION% забрал роскомнадзор",
- "Хакера %USER\_MENTION% забрал Интерпол",
- "Хакер %USER\_MENTION% провалил дедлайн",
- "Хакер %USER\_MENTION% не смог выйти из VIM",
- "Хакер %USER\_MENTION% пошёл кормить рыбок",
- "Хакер %USER\_MENTION% провалил испытание",
-]
-RH_CHAT_ID = -1001147286684
+def get_uri():
+ return os.environ.get(
+ "DATABASE_URL", "postgresql+asyncpg://user:password@wachter-db/db"
+ )
diff --git a/src/custom_filters.py b/src/custom_filters.py
index 868f0ce..9ef68a3 100644
--- a/src/custom_filters.py
+++ b/src/custom_filters.py
@@ -1,7 +1,9 @@
-from telegram.ext import MessageFilter
+from telegram.ext import filters
-class FilterBotAdded(MessageFilter): # filter for message, that bot was added to group
+class FilterBotAdded(
+ filters.MessageFilter
+): # filter for message, that bot was added to group
def filter(self, message):
if message.new_chat_members[-1].is_bot:
return False
diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py
index 86595f6..ed0441d 100644
--- a/src/handlers/__init__.py
+++ b/src/handlers/__init__.py
@@ -6,4 +6,5 @@
from .error_handler import error_handler
from .admin import *
+from .debug import *
from .group import *
diff --git a/src/handlers/admin/menu_handler.py b/src/handlers/admin/menu_handler.py
index 38d7491..c27c55a 100644
--- a/src/handlers/admin/menu_handler.py
+++ b/src/handlers/admin/menu_handler.py
@@ -1,202 +1,436 @@
import json
-from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ParseMode, Update
-from telegram.ext import CallbackContext
+from telegram import InlineKeyboardMarkup, Update
+from telegram.ext import ContextTypes
+from telegram.constants import ParseMode
from datetime import datetime, timedelta
+from typing import Callable, Optional
+
+from sqlalchemy import select
from src import constants
-from src.model import Chat, User, session_scope
+from src.model import Chat, session_scope
+from src.texts import _
from src.handlers.group.group_handler import on_kick_timeout, on_notify_timeout
-from .utils import authorize_user
+from .utils import (
+ get_chats_list,
+ create_chats_list_keyboard,
+ new_button,
+ new_keyboard_layout,
+ get_chat_name,
+)
+
+from src.logging import tg_logger
+
+
+async def _job_rescheduling_helper(
+ job_func: Callable, timeout: int, context: ContextTypes.DEFAULT_TYPE, chat_id: int
+) -> None:
+ """
+ This function helps in rescheduling a job in the Telegram bot's job queue.
+
+ Args:
+ job_func (Callable): The function that is to be scheduled as a job. This is the callback function that is executed when the job runs.
+ timeout (int): The amount of time (in minutes) after which the job should be executed.
+ context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API. This provides a context containing information about the current state of the bot and the update it is handling.
+ chat_id (int): The unique identifier for the chat. This is used to query the database for chat-specific settings.
+
+ Returns:
+ None: This function does not return anything.
+ """
+ # Iterating through all the jobs currently in the job queue
+ for job in context.job_queue.jobs():
+ # If the job's name matches the name of the job function provided
+ if job.name == job_func.__name__ and chat_id == job.data.get('chat_id'):
+ # Extracting the job context and calculating the new timeout
+ job_data = job.data
+ job_creation_time = datetime.fromtimestamp(job_data.get("creation_time"))
+ new_timeout = job_creation_time + timedelta(seconds=timeout * 60)
+
+ # If the new timeout is in the past, set it to now
+ if new_timeout < datetime.now():
+ new_timeout = datetime.now()
+
+ # Schedule the current job for removal
+ job.schedule_removal()
+
+ # If the job is a notification timeout, perform additional checks
+ if job_func == on_notify_timeout:
+ # Querying the database to get the chat's kick timeout setting
+ async with session_scope() as sess:
+ result = await sess.execute(select(Chat).filter(Chat.id == chat_id))
+ chat: Optional[Chat] = result.scalars().first()
+ kick_timeout = chat.kick_timeout if chat else 0
+
+ # If the new timeout is greater than the kick timeout, skip to the next job
+ if (
+ job_creation_time + timedelta(seconds=kick_timeout * 60)
+ ) > new_timeout:
+ continue
+
+ # Update the job context with the new timeout
+ job_data["timeout"] = new_timeout
+
+ # Schedule the new job with the updated context and timeout
+ job = context.job_queue.run_once(job_func, new_timeout, data=job_data)
+
+
+async def _get_current_settings_helper(
+ chat_id: int, settings: str, chat_name: str
+) -> str:
+ """
+ Retrieve the current settings for a specific chat based on the settings category provided.
+ This function is now an asynchronous function.
+
+ Args:
+ chat_id (int): The ID of the chat for which the settings are to be retrieved.
+ settings (str): A string indicating the category of settings to retrieve.
+
+ Returns:
+ str: A formatted message string containing the current settings.
+ """
+ async with session_scope() as session:
+ result = await session.execute(select(Chat).filter(Chat.id == chat_id))
+ chat: Optional[Chat] = result.scalars().first()
+
+ if settings == constants.Actions.get_current_intro_settings:
+ print("Loading intro settings for chat_id:", chat_id)
+ return (
+ _("msg__get_intro_settings")
+ .format(chat_name=chat_name, **chat.__dict__)
+ .replace("%USER\\_MENTION%", "%USER_MENTION%")
+ )
+ else:
+ return (
+ _("msg__get_kick_settings")
+ .format(chat_name=chat_name, **chat.__dict__)
+ .replace("%USER\\_MENTION%", "%USER_MENTION%")
+ )
# todo rework into callback folder
-def button_handler(update: Update, context: CallbackContext):
+async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Handle button presses in the Telegram bot's inline keyboard.
+
+ Args:
+ update (Update): The Telegram update object.
+ context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API.
+
+ Returns:
+ None
+ """
query = update.callback_query
data = json.loads(query.data)
if data["action"] == constants.Actions.start_select_chat:
- with session_scope() as sess:
- user_id = query.from_user.id
- users = sess.query(User).filter(User.user_id == user_id)
- user_chats = [
- {
- "title": context.bot.get_chat(x.chat_id).title or x.chat_id,
- "id": x.chat_id,
- }
- for x in users
- ]
-
+ user_id = query.from_user.id
+ user_chats = await get_chats_list(user_id, context)
if len(user_chats) == 0:
- update.message.reply_text("У вас нет доступных чатов.")
+ await update.message.reply_text(_("msg__no_chats_available"))
return
+ reply_markup = InlineKeyboardMarkup(
+ await create_chats_list_keyboard(user_chats, context, user_id)
+ )
+ await context.bot.edit_message_text(
+ _("msg__start_command"),
+ reply_markup=reply_markup,
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
- keyboard = [
+ if data["action"] == constants.Actions.select_chat:
+ selected_chat_id = data["chat_id"]
+ button_configs = [
+ [{"text": _("btn__intro"), "action": constants.Actions.set_intro_settings}],
[
- InlineKeyboardButton(
- chat["title"],
- callback_data=json.dumps(
- {"chat_id": chat["id"], "action": constants.Actions.select_chat}
- ),
- )
- ]
- for chat in user_chats
- if authorize_user(context.bot, chat["id"], user_id)
+ {
+ "text": _("btn__kicks"),
+ "action": constants.Actions.set_kick_bans_settings,
+ }
+ ],
+ [
+ {
+ "text": _("btn__back_to_chats"),
+ "action": constants.Actions.back_to_chats,
+ }
+ ],
]
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- context.bot.edit_message_reply_markup(
+ reply_markup = new_keyboard_layout(button_configs, selected_chat_id)
+ chat_name = await get_chat_name(context.bot, selected_chat_id)
+ await context.bot.edit_message_text(
+ _("msg__select_chat").format(chat_name=chat_name),
reply_markup=reply_markup,
chat_id=query.message.chat_id,
message_id=query.message.message_id,
)
-
- if data["action"] == constants.Actions.select_chat:
+ elif data["action"] == constants.Actions.set_intro_settings:
selected_chat_id = data["chat_id"]
- keyboard = [
+ button_configs = [
[
- InlineKeyboardButton(
- "Изменить таймаут кика",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_kick_timeout,
- }
- ),
- )
+ {
+ "text": _("btn__current_settings"),
+ "action": constants.Actions.get_current_intro_settings,
+ }
],
[
- InlineKeyboardButton(
- "Изменить сообщение при входе в чат",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_on_new_chat_member_message_response,
- }
- ),
- )
+ {
+ "text": _("btn__change_welcome_message"),
+ "action": constants.Actions.set_on_new_chat_member_message_response,
+ }
],
[
- InlineKeyboardButton(
- "Изменить сообщение при перезаходе в чат",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_on_known_new_chat_member_message_response,
- }
- ),
- )
+ {
+ "text": _("btn__change_rewelcome_message"),
+ "action": constants.Actions.set_on_known_new_chat_member_message_response,
+ }
],
[
- InlineKeyboardButton(
- "Изменить сообщение после успешного представления",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_on_successful_introducion_response,
- }
- ),
- )
+ {
+ "text": _("btn__change_notify_message"),
+ "action": constants.Actions.set_notify_message,
+ }
],
[
- InlineKeyboardButton(
- "Изменить сообщение напоминания",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_notify_message,
- }
- ),
- )
+ {
+ "text": _("btn__change_sucess_message"),
+ "action": constants.Actions.set_on_successful_introducion_response,
+ }
],
[
- InlineKeyboardButton(
- "Изменить сообщение после кика",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.set_on_kick_message,
- }
- ),
- )
+ {
+ "text": _("btn__change_notify_timeout"),
+ "action": constants.Actions.set_notify_timeout,
+ }
],
[
- InlineKeyboardButton(
- "Получить текущие настройки",
- callback_data=json.dumps(
- {
- "chat_id": selected_chat_id,
- "action": constants.Actions.get_current_settings,
- }
- ),
- )
+ {
+ "text": _("btn__change_whois_length"),
+ "action": constants.Actions.set_whois_length,
+ }
+ ],
+ [
+ {
+ "text": _("btn__change_whois_message"),
+ "action": constants.Actions.set_on_introduce_message_update,
+ }
+ ],
+ [{"text": _("btn__back"), "action": constants.Actions.select_chat}],
+ ]
+ reply_markup = new_keyboard_layout(button_configs, selected_chat_id)
+ await context.bot.edit_message_reply_markup(
+ reply_markup=reply_markup,
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
+
+ elif data["action"] == constants.Actions.set_kick_bans_settings:
+ selected_chat_id = data["chat_id"]
+ button_configs = [
+ [
+ {
+ "text": _("btn__current_settings"),
+ "action": constants.Actions.get_current_kick_settings,
+ }
+ ],
+ [
+ {
+ "text": _("btn__change_kick_timeout"),
+ "action": constants.Actions.set_kick_timeout,
+ }
],
+ [
+ {
+ "text": _("btn__change_kick_message"),
+ "action": constants.Actions.set_on_kick_message,
+ }
+ ],
+ [{"text": _("btn__back"), "action": constants.Actions.select_chat}],
]
+ reply_markup = new_keyboard_layout(button_configs, selected_chat_id)
+ await context.bot.edit_message_reply_markup(
+ reply_markup=reply_markup,
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
- reply_markup = InlineKeyboardMarkup(keyboard)
- context.bot.edit_message_reply_markup(
+ elif data["action"] == constants.Actions.back_to_chats:
+ user_id = query.message.chat_id
+ user_chats = await get_chats_list(user_id, context)
+ reply_markup = InlineKeyboardMarkup(
+ await create_chats_list_keyboard(user_chats, context, user_id)
+ )
+ await context.bot.edit_message_text(
+ _("msg__start_command"),
reply_markup=reply_markup,
chat_id=query.message.chat_id,
message_id=query.message.message_id,
)
- elif data["action"] in [
- constants.Actions.set_on_new_chat_member_message_response,
- constants.Actions.set_kick_timeout,
- constants.Actions.set_notify_message,
- constants.Actions.set_on_known_new_chat_member_message_response,
- constants.Actions.set_on_successful_introducion_response,
- constants.Actions.set_on_kick_message,
- ]:
- context.bot.edit_message_text(
- text="Отправьте новое значение",
+ elif data["action"] == constants.Actions.set_on_new_chat_member_message_response:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_welcome_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_kick_timeout:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_kick_timout"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif (
+ data["action"]
+ == constants.Actions.set_on_known_new_chat_member_message_response
+ ):
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_rewelcome_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_notify_message:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_notify_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_on_new_chat_member_message_response:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_welcome_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_on_successful_introducion_response:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_sucess_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_whois_length:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_whois_length"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_on_kick_message:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_kick_message"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_notify_timeout:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_notify_timeout"),
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ )
+ context.user_data["chat_id"] = data["chat_id"]
+ context.user_data["action"] = data["action"]
+
+ elif data["action"] == constants.Actions.set_on_introduce_message_update:
+ await context.bot.edit_message_text(
+ text=_("msg__set_new_whois_message"),
chat_id=query.message.chat_id,
message_id=query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
)
context.user_data["chat_id"] = data["chat_id"]
context.user_data["action"] = data["action"]
- elif data["action"] == constants.Actions.get_current_settings:
+ elif data["action"] == constants.Actions.get_current_intro_settings:
keyboard = [
[
- InlineKeyboardButton(
- "К настройке чата",
- callback_data=json.dumps(
- {
- "chat_id": data["chat_id"],
- "action": constants.Actions.select_chat,
- }
- ),
- ),
- InlineKeyboardButton(
- "К списку чатов",
- callback_data=json.dumps(
- {"action": constants.Actions.start_select_chat}
- ),
- ),
- ],
+ new_button(
+ _("btn__back"),
+ data["chat_id"],
+ constants.Actions.set_intro_settings,
+ )
+ ]
]
+ chat_name = await get_chat_name(context.bot, data["chat_id"])
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ settings = await _get_current_settings_helper(
+ data["chat_id"], data["action"], chat_name
+ )
+ await context.bot.edit_message_text(
+ text=settings,
+ parse_mode=ParseMode.MARKDOWN,
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ reply_markup=reply_markup,
+ )
+
+ context.user_data["action"] = None
+ elif data["action"] == constants.Actions.get_current_kick_settings:
+ keyboard = [
+ [
+ new_button(
+ _("btn__back"),
+ data["chat_id"],
+ constants.Actions.set_kick_bans_settings,
+ )
+ ]
+ ]
reply_markup = InlineKeyboardMarkup(keyboard)
- with session_scope() as sess:
- chat = sess.query(Chat).filter(Chat.id == data["chat_id"]).first()
- context.bot.edit_message_text(
- text=constants.get_settings_message.format(**chat.__dict__),
- parse_mode=ParseMode.MARKDOWN,
- chat_id=query.message.chat_id,
- message_id=query.message.message_id,
- reply_markup=reply_markup,
- )
+ chat_name = await get_chat_name(context.bot, data["chat_id"])
+ settings = await _get_current_settings_helper(
+ data["chat_id"], data["action"], chat_name
+ )
+ await context.bot.edit_message_text(
+ text=settings,
+ parse_mode=ParseMode.MARKDOWN,
+ chat_id=query.message.chat_id,
+ message_id=query.message.message_id,
+ reply_markup=reply_markup,
+ )
context.user_data["action"] = None
-def message_handler(update: Update, context: CallbackContext):
- if not update.message:
- update.message = update.edited_message
+async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Handle text messages received by the Telegram bot.
+
+ Args:
+ update (Update): The Telegram update object.
+ context (ContextTypes.DEFAULT_TYPE): The callback context as provided by the Telegram API.
- chat_id = update.message.chat_id
+ Returns:
+ None: This function returns nothing.
+ """
+ chat_id = update.effective_message.chat_id
if chat_id > 0:
action = context.user_data.get("action")
@@ -207,64 +441,60 @@ def message_handler(update: Update, context: CallbackContext):
chat_id = context.user_data["chat_id"]
if action == constants.Actions.set_kick_timeout:
- message = update.message.text
+ message = update.effective_message.text
try:
timeout = int(message)
assert timeout >= 0
except:
- update.message.reply_text(constants.on_failed_set_kick_timeout_response)
+ await update.effective_message.reply_text(
+ _("msg__failed_set_kick_timeout_response")
+ )
return
- with session_scope() as sess:
+ async with session_scope() as sess:
chat = Chat(id=chat_id, kick_timeout=timeout)
- sess.merge(chat)
+ await sess.merge(chat)
context.user_data["action"] = None
+ await _job_rescheduling_helper(on_kick_timeout, timeout, context, chat_id)
- for job in context.job_queue.jobs():
- if job.name in [on_kick_timeout.__name__, on_notify_timeout.__name__]:
- job_context = job.context
- job_creation_time = datetime.fromtimestamp(
- job_context.get("creation_time")
+ keyboard = [
+ [
+ new_button(
+ _("btn__back"),
+ chat_id,
+ constants.Actions.set_kick_bans_settings,
)
- new_timeout = job_creation_time + timedelta(seconds=timeout * 60)
- if job.name == on_kick_timeout.__name__:
- if new_timeout < datetime.now():
- new_timeout = 0
- next_job_func = on_kick_timeout
- else:
- new_timeout = new_timeout - timedelta(
- seconds=constants.notify_delta * 60
- )
- next_job_func = on_notify_timeout
+ ]
+ ]
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await update.effective_message.reply_text(
+ _("msg__success_set_kick_timeout_response"),
+ reply_markup=reply_markup,
+ )
- job.schedule_removal()
- job_context["timeout"] = new_timeout
- job = context.job_queue.run_once(
- next_job_func, new_timeout, context=job_context
- )
+ elif action == constants.Actions.set_notify_timeout:
+ message = update.effective_message.text
+ try:
+ timeout = int(message)
+ assert timeout >= 0
+ except:
+ await update.effective_message.reply_text(_("msg__failed_kick_response"))
+ return
+ async with session_scope() as sess:
+ chat = Chat(id=chat_id, notify_timeout=timeout)
+ await sess.merge(chat)
+ context.user_data["action"] = None
+ await _job_rescheduling_helper(on_notify_timeout, timeout, context, chat_id)
keyboard = [
[
- InlineKeyboardButton(
- "К настройке чата",
- callback_data=json.dumps(
- {
- "chat_id": chat_id,
- "action": constants.Actions.select_chat,
- }
- ),
- ),
- InlineKeyboardButton(
- "К списку чатов",
- callback_data=json.dumps(
- {"action": constants.Actions.start_select_chat}
- ),
- ),
- ],
+ new_button(
+ _("btn__back"), chat_id, constants.Actions.set_intro_settings
+ )
+ ]
]
-
reply_markup = InlineKeyboardMarkup(keyboard)
- update.message.reply_text(
- constants.on_success_set_kick_timeout_response,
+ await update.effective_message.reply_text(
+ _("msg__sucess_set_notify_timeout_response"),
reply_markup=reply_markup,
)
@@ -274,9 +504,12 @@ def message_handler(update: Update, context: CallbackContext):
constants.Actions.set_on_known_new_chat_member_message_response,
constants.Actions.set_on_successful_introducion_response,
constants.Actions.set_on_kick_message,
+ constants.Actions.set_whois_length,
+ constants.Actions.set_on_introduce_message_update,
]:
- message = update.message.text_markdown
- with session_scope() as sess:
+ message = update.effective_message.text_markdown
+ reply_message = _("msg__set_new_message")
+ async with session_scope() as sess:
if action == constants.Actions.set_on_new_chat_member_message_response:
chat = Chat(id=chat_id, on_new_chat_member_message=message)
if (
@@ -290,31 +523,52 @@ def message_handler(update: Update, context: CallbackContext):
chat = Chat(id=chat_id, notify_message=message)
if action == constants.Actions.set_on_kick_message:
chat = Chat(id=chat_id, on_kick_message=message)
- sess.merge(chat)
+ if action == constants.Actions.set_whois_length:
+ try:
+ whois_length = int(message)
+ assert whois_length >= 0
+ chat = Chat(id=chat_id, whois_length=whois_length)
+ reply_message = _("msg__sucess_whois_length")
+ except:
+ await update.effective_message.reply_text(_("msg__failed_whois_response"))
+ return
- context.user_data["action"] = None
+ if action == constants.Actions.set_on_introduce_message_update:
+ if (
+ "#update"
+ not in update.effective_message.parse_entities(types=["hashtag"]).values()
+ ):
+ await update.effective_message.reply_text(
+ _("msg__need_hashtag_update_response")
+ )
+ return
+ chat = Chat(id=chat_id, on_introduce_message_update=message)
+ await sess.merge(chat)
- keyboard = [
- [
- InlineKeyboardButton(
- "К настройке чата",
- callback_data=json.dumps(
- {
- "chat_id": chat_id,
- "action": constants.Actions.select_chat,
- }
- ),
- ),
- InlineKeyboardButton(
- "К списку чатов",
- callback_data=json.dumps(
- {"action": constants.Actions.start_select_chat}
- ),
- ),
- ],
- ]
+ if action in [
+ constants.Actions.set_on_kick_message,
+ constants.Actions.set_kick_timeout,
+ ]:
+ keyboard = [
+ [
+ new_button(
+ _("btn__back"),
+ chat_id,
+ constants.Actions.set_kick_bans_settings,
+ )
+ ]
+ ]
+ else:
+ keyboard = [
+ [
+ new_button(
+ _("btn__back"),
+ chat_id,
+ constants.Actions.set_intro_settings,
+ )
+ ]
+ ]
+ context.user_data["action"] = None
reply_markup = InlineKeyboardMarkup(keyboard)
- update.message.reply_text(
- constants.on_set_new_message, reply_markup=reply_markup
- )
+ await update.effective_message.reply_text(reply_message, reply_markup=reply_markup)
diff --git a/src/handlers/admin/start_handler.py b/src/handlers/admin/start_handler.py
index 65153e5..f4c6abd 100644
--- a/src/handlers/admin/start_handler.py
+++ b/src/handlers/admin/start_handler.py
@@ -1,53 +1,41 @@
-import json
-from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update
-from telegram.ext import CallbackContext
+from telegram import InlineKeyboardMarkup, Update
+from telegram.ext import ContextTypes
-from src import constants
-from src.model import User, session_scope
-from .utils import authorize_user
+from src.handlers.utils import admin
+from src.texts import _
-# todo @admin decorator to prevent / tweak behaviour when calling from group chats
-# this will be a nice replacement for "if user_id < 0" checks
-def start_handler(update: Update, context: CallbackContext):
- user_id = update.message.chat_id
+from .utils import get_chats_list, create_chats_list_keyboard
- if user_id < 0:
- return
- with session_scope() as sess:
- users = sess.query(User).filter(User.user_id == user_id)
- user_chats = list(_get_chats(users, user_id, context.bot))
+@admin
+async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Handle the /start command in a Telegram chat.
+ Args:
+ update (Update): The update object that represents the incoming update.
+ context (ContextTypes.DEFAULT_TYPE): The context object that contains information about the current state of the bot.
+
+ Returns:
+ None
+ """
+ # Get the ID of the user who sent the message
+ user_id = update.message.chat_id
+
+ # Retrieve the list of chats where the user has administrative privileges
+ user_chats = await get_chats_list(user_id, context)
+
+ # If the user does not have administrative privileges in any chat, inform them
if len(user_chats) == 0:
- update.message.reply_text("У вас нет доступных чатов.")
+ await update.message.reply_text(_("msg__no_chats_available"))
return
- keyboard = [
- [
- InlineKeyboardButton(
- chat["title"],
- callback_data=json.dumps(
- {"chat_id": chat["id"], "action": constants.Actions.select_chat}
- ),
- )
- ]
- for chat in user_chats
- if authorize_user(context.bot, chat["id"], user_id)
- ]
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- update.message.reply_text(constants.on_start_command, reply_markup=reply_markup)
-
-
-def _get_chats(users: list, user_id: int, bot: Bot):
- for x in users:
- try:
- if authorize_user(bot, x.chat_id, user_id):
- yield {
- "title": bot.get_chat(x.chat_id).title or x.chat_id,
- "id": x.chat_id,
- }
- except Exception:
- pass
+ # Create an inline keyboard with the list of available chats
+ reply_markup = InlineKeyboardMarkup(
+ await create_chats_list_keyboard(user_chats, context, user_id)
+ )
+
+ # Send a message to the user with the inline keyboard
+ await update.message.reply_text(_("msg__start_command"), reply_markup=reply_markup)
diff --git a/src/handlers/admin/utils.py b/src/handlers/admin/utils.py
index 317a621..0437826 100644
--- a/src/handlers/admin/utils.py
+++ b/src/handlers/admin/utils.py
@@ -1,9 +1,132 @@
-from telegram import Bot
+import json
+import time
+from typing import Iterator, Dict, List
+from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup
+from telegram.ext import CallbackContext
-def authorize_user(bot: Bot, chat_id: int, user_id: int):
+from sqlalchemy import select
+
+from src.logging import tg_logger
+from src.model import User, session_scope
+from src import constants
+
+
+async def get_chat_name(bot, chat_id):
+ chat = await bot.get_chat(chat_id)
+ return chat.title or str(chat_id)
+
+
+def new_button(text: str, chat_id: int, action) -> InlineKeyboardButton:
+ """
+ Create a new InlineKeyboardButton with associated callback data.
+
+ Args:
+ text (str): The text to be displayed on the button.
+ chat_id (int): The chat ID to be included in the callback data.
+ action (str): The action to be performed, included in the callback data.
+
+ Returns:
+ InlineKeyboardButton: The created InlineKeyboardButton instance.
+ """
+ callback_data = json.dumps({"chat_id": chat_id, "action": action})
+ return InlineKeyboardButton(text, callback_data=callback_data)
+
+
+def new_keyboard_layout(
+ button_configs: List[List[Dict[str, str]]], chat_id: int
+) -> InlineKeyboardMarkup:
+ """
+ Create a new InlineKeyboardMarkup layout based on a configuration list.
+
+ Args:
+ button_configs (List[List[Dict[str, str]]]): A list of button configurations.
+ chat_id (int): The chat ID to be included in the callback data of each button.
+
+ Returns:
+ InlineKeyboardMarkup: The created InlineKeyboardMarkup instance.
+ """
+ keyboard = [
+ [new_button(button["text"], chat_id, button["action"]) for button in row]
+ for row in button_configs
+ ]
+ return InlineKeyboardMarkup(keyboard)
+
+
+async def authorize_user(bot: Bot, chat_id: int, user_id: int) -> bool:
+ """
+ Asynchronously check if a user is an administrator or the creator of a chat.
+
+ Args:
+ bot (Bot): The Telegram Bot instance.
+ chat_id (int): The ID of the chat.
+ user_id (int): The ID of the user.
+
+ Returns:
+ bool: True if the user is an administrator or creator of the chat, False otherwise.
+ """
try:
- status = bot.get_chat_member(chat_id, user_id).status
- return status in ["creator", "administrator"]
- except Exception:
+ chat_member = await bot.get_chat_member(chat_id, user_id)
+ return chat_member.status in ["creator", "administrator"]
+ except Exception as e:
+ print(f"Failed to check if user {user_id} is admin in chat {chat_id}: {e}")
return False
+
+
+async def get_chats_list(
+ user_id: int, context: CallbackContext
+) -> List[Dict[str, int]]:
+ """
+ Retrieve a list of chats where the user is an administrator or creator.
+
+ This function queries the database for User instances associated with the provided
+ user_id. For each User instance found, it checks whether the provided user_id
+ is an authorized user of the associated chat. If so, the function retrieves the
+ chat's title and id, and adds them to a list which is returned after all
+ authorized chats have been processed.
+
+ Args:
+ user_id (int): The ID of the user.
+ context (CallbackContext): The callback context as provided by the Telegram Bot API.
+
+ Returns:
+ List[Dict[str, int]]: A list of dictionaries, each containing the 'title' and 'id'
+ of a chat where the user has administrative or creator rights.
+ """
+ time_start = time.time()
+ async with session_scope() as session: # Ensure this yields an AsyncSession object.
+ result = await session.execute(select(User).filter(User.user_id == user_id))
+ users = result.scalars().all()
+ chats_list = []
+ for user in users:
+ try:
+ if await authorize_user(context.bot, user.chat_id, user_id):
+ chat_name = await get_chat_name(context.bot, user.chat_id)
+ chats_list.append({"title": chat_name, "id": user.chat_id})
+ except Exception as e:
+ context.bot.logger.exception(
+ e
+ ) # Ensure your CallbackContext has a logger configured.
+ tg_logger.info(f'get_chats_list time elapsed {time.time() - time_start}s')
+ return chats_list
+
+
+async def create_chats_list_keyboard(
+ user_chats: Iterator[Dict[str, int]], context: CallbackContext, user_id: int
+) -> List[List[InlineKeyboardButton]]:
+ """
+ Create a keyboard layout for the list of chats where the user is an administrator or creator.
+
+ Args:
+ user_chats (Iterator[Dict[str, int]]): An iterator over dictionaries containing chat information.
+ context (CallbackContext): The callback context as provided by the Telegram API.
+ user_id (int): The ID of the user.
+
+ Returns:
+ List[List[InlineKeyboardButton]]: The created keyboard layout.
+ """
+ return [
+ [new_button(chat["title"], chat["id"], constants.Actions.select_chat)]
+ for chat in user_chats
+ if await authorize_user(context.bot, chat["id"], user_id)
+ ]
diff --git a/src/handlers/debug/__init__.py b/src/handlers/debug/__init__.py
new file mode 100644
index 0000000..fbf7a5d
--- /dev/null
+++ b/src/handlers/debug/__init__.py
@@ -0,0 +1,5 @@
+"""
+Module with all the telegram handlers used for debugging.
+"""
+
+from .list_jobs_handler import list_jobs_handler
diff --git a/src/handlers/debug/list_jobs_handler.py b/src/handlers/debug/list_jobs_handler.py
new file mode 100644
index 0000000..bf14e99
--- /dev/null
+++ b/src/handlers/debug/list_jobs_handler.py
@@ -0,0 +1,24 @@
+import html
+from telegram import Update
+from telegram.constants import ParseMode
+from telegram.ext import CallbackContext
+
+from src import constants
+from src.handlers.utils import debug
+
+
+@debug
+async def list_jobs_handler(update: Update, context: CallbackContext) -> None:
+ args = update.effective_message.text.split()
+ chat_id = int(args[1]) if len(args) > 1 else None
+ jobs = [job for job in context.job_queue.jobs() if chat_id is None or job.data.get("chat_id") == chat_id]
+ await update.message.reply_text(
+ f"Jobs: {len(jobs)} items\n\n"
+ + "\n\n".join(
+ [
+ f"Job {html.escape(job.name)} ts {html.escape(str(job.next_t)) if job.next_t else 'None'}\nContext: {html.escape(str(job.data))}"
+ for job in jobs
+ ]
+ ),
+ parse_mode=ParseMode.HTML,
+ )
diff --git a/src/handlers/error_handler.py b/src/handlers/error_handler.py
index c3439b4..db75461 100644
--- a/src/handlers/error_handler.py
+++ b/src/handlers/error_handler.py
@@ -1,10 +1,66 @@
-import logging
-
from telegram import Update
from telegram.ext import CallbackContext
-
+from telegram.constants import ParseMode
+import logging
+import traceback
+import os
+import html
+import json
from src.logging import tg_logger
+logger = logging.getLogger(__name__)
+
+async def error_handler(update: Update, context: CallbackContext):
+ """
+ Log the error and send a telegram message to notify the developer.
+ Credits: https://docs.python-telegram-bot.org/en/stable/examples.errorhandlerbot.html
+ """
+ try:
+ # Safety check: ensure context and error exist
+ if not context or not context.error:
+ logger.error("Error handler called but context or context.error is None")
+ return
+
+ # Log the error before we do anything else, so we can see it even if something breaks.
+ logger.error("Exception while handling an update:", exc_info=context.error)
+
+ # traceback.format_exception returns the usual python message about an exception, but as a
+ # list of strings rather than a single string, so we have to join them together.
+ tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
+ tb_string = "".join(tb_list)
+
+ # Build the message with some markup and additional information about what happened.
+ # You might need to add some logic to deal with messages longer than the 4096 character limit.
+ update_str = update.to_dict() if isinstance(update, Update) and update else str(update)
+ message = (
+ "An exception was raised while handling an update\n"
+ f"update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
+ "\n\n"
+ f"context.chat_data = {html.escape(str(context.chat_data))}\n\n"
+ f"context.user_data = {html.escape(str(context.user_data))}\n\n"
+ f"{html.escape(tb_string)}"
+ )
+
+ # Truncate message if it's too long (Telegram has a 4096 character limit)
+ if len(message) > 4096:
+ message = message[:4000] + "\n\n... (message truncated)"
-def error_handler(update: Update, context: CallbackContext):
- tg_logger.warning(f'Update "{update}" caused error "{context.error}"')
+ # Finally, send the message (only if TELEGRAM_ERROR_CHAT_ID is set and bot is available)
+ error_chat_id = os.environ.get("TELEGRAM_ERROR_CHAT_ID")
+ if error_chat_id and context.bot:
+ try:
+ await context.bot.send_message(
+ chat_id=error_chat_id, text=message, parse_mode=ParseMode.HTML
+ )
+ except Exception as send_error:
+ # If sending the error message fails, log it but don't raise
+ logger.error(f"Failed to send error message to Telegram: {send_error}", exc_info=send_error)
+ except Exception as handler_error:
+ # If the error handler itself fails, log it but don't raise to avoid infinite recursion
+ logger.critical(f"Error handler itself raised an exception: {handler_error}", exc_info=handler_error)
+ # Fallback to simple print if logger also fails
+ try:
+ print(f"CRITICAL: Error handler failed: {handler_error}")
+ print(f"Original error: {context.error if context and context.error else 'Unknown'}")
+ except Exception:
+ pass # Last resort - if even print fails, we're in deep trouble
\ No newline at end of file
diff --git a/src/handlers/group/__init__.py b/src/handlers/group/__init__.py
index 5b53d04..d2e3dba 100644
--- a/src/handlers/group/__init__.py
+++ b/src/handlers/group/__init__.py
@@ -2,4 +2,5 @@
Module with all the telegram handlers related to group chat flow.
"""
-from .group_handler import on_hashtag_message, on_new_chat_member
+from .group_handler import on_hashtag_message, on_new_chat_members
+from .my_chat_member_handler import my_chat_member_handler
diff --git a/src/handlers/group/group_handler.py b/src/handlers/group/group_handler.py
index d1cfeb0..9e9da5c 100644
--- a/src/handlers/group/group_handler.py
+++ b/src/handlers/group/group_handler.py
@@ -1,209 +1,390 @@
from datetime import datetime, timedelta
-import logging
-import random
-from telegram import Bot, Message, ParseMode, Update
-from telegram.ext import CallbackContext
+from telegram import Bot, Message, Update
+from telegram.constants import ParseMode
+from telegram.ext import ContextTypes
+from typing import Optional
+from sqlalchemy import select, func
+
+from src.logging import tg_logger
from src import constants
+from src.texts import _
from src.model import Chat, User, session_scope
-
-
-logger = logging.getLogger(__name__)
-
-
-def on_new_chat_member(update: Update, context: CallbackContext):
+from src.handlers.utils import setup_counter, setup_histogram
+
+
+new_member_counter = setup_counter("new_member.meter", "new_member_counter")
+whois_counter = setup_counter("new_whois.meter", "new_whois_counter")
+ban_counter = setup_counter("ban.meter", "ban_counter")
+chats_histogram = setup_histogram("chats.meter", "chats_counter")
+users_histogram = setup_histogram("users.meter", "users_counter")
+unique_users_histogram = setup_histogram(
+ "unique_users.meter", "unique_users_counter"
+)
+
+async def db_metrics_reader_helper(context: ContextTypes.DEFAULT_TYPE):
+ async with session_scope() as sess:
+ # Number of chats
+ result = await sess.execute(select(func.count(Chat.id)))
+ chat_count = result.scalar()
+ chats_histogram.record(chat_count)
+ # Total number of users
+ result = await sess.execute(select(func.count()).select_from(User))
+ users_count = result.scalar()
+ users_histogram.record(users_count)
+ # Number of unique users
+ result = await sess.execute(select(func.count(func.distinct(User.user_id))))
+ unique_users_count = result.scalar()
+ unique_users_histogram.record(unique_users_count)
+
+
+async def on_new_chat_members(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """
+ Handle the event when a new member joins a chat.
+
+ Args:
+ update (Update): The update object that represents the incoming update.
+ context (CallbackContext): The context object that contains information about the current state of the bot.
+
+ Returns:
+ None
+ """
chat_id = update.message.chat_id
- user_id = update.message.new_chat_members[-1].id
-
- for job in context.job_queue.jobs():
- if (
- job.context["user_id"] == user_id
- and job.context["chat_id"] == chat_id
- and job.enabled == True
- ):
- job.enabled = False
- job.schedule_removal()
-
- with session_scope() as sess:
- user = (
- sess.query(User)
- .filter(User.chat_id == chat_id, User.user_id == user_id)
- .first()
- )
- chat = sess.query(Chat).filter(Chat.id == chat_id).first()
-
- if chat is None:
- chat = Chat(id=chat_id)
- sess.add(chat)
- sess.commit()
+ new_member_counter.add(1, {"chat_id": chat_id})
+ user_ids = [
+ new_chat_member.id for new_chat_member in update.message.new_chat_members
+ ]
- if user is not None:
- update.message.reply_text(chat.on_known_new_chat_member_message)
- return
+ for user_id in user_ids:
+ for job in context.job_queue.jobs():
+ if (
+ job.data
+ and job.data.get("user_id") == user_id
+ and job.data.get("chat_id") == chat_id
+ ):
+ job.schedule_removal()
- message = chat.on_new_chat_member_message
- timeout = chat.kick_timeout
+ async with session_scope() as sess:
+ result = await sess.execute(
+ select(User).where(User.chat_id == chat_id, User.user_id == user_id)
+ )
+ user = result.scalars().first()
+ chat_result = await sess.execute(select(Chat).where(Chat.id == chat_id))
+ chat = chat_result.scalars().first()
- if message == constants.skip_on_new_chat_member_message:
- return
+ if chat is None:
+ chat = Chat.get_new_chat(chat_id)
+ sess.add(chat)
+ await sess.commit()
+
+ if user is not None:
+ await _send_message_with_deletion(
+ context,
+ chat_id,
+ user_id,
+ chat.on_known_new_chat_member_message,
+ reply_to=update.message,
+ )
+ continue
+
+ message = chat.on_new_chat_member_message
+ kick_timeout = chat.kick_timeout
+ notify_timeout = chat.notify_timeout
+
+ if message == _("msg__skip_new_chat_member"):
+ continue
+
+ await _send_message_with_deletion(
+ context,
+ chat_id,
+ user_id,
+ message,
+ # 36 hours which is considered infinity; bots can't delete messages older than 48h
+ timeout_m=constants.default_delete_message_timeout_m * 24 * 1.5,
+ reply_to=update.message,
+ )
- message_markdown = _mention_markdown(context.bot, chat_id, user_id, message)
- msg = update.message.reply_text(message_markdown, parse_mode=ParseMode.MARKDOWN)
+ if kick_timeout != 0:
+ job = context.job_queue.run_once(
+ on_kick_timeout,
+ kick_timeout * 60,
+ chat_id=chat_id,
+ user_id=user_id,
+ data={
+ "chat_id": chat_id,
+ "user_id": user_id,
+ "creation_time": datetime.now().timestamp(),
+ },
+ )
- if timeout != 0:
- if timeout >= 10:
+ if notify_timeout != 0:
job = context.job_queue.run_once(
on_notify_timeout,
- (timeout - constants.notify_delta) * 60,
- context={
+ notify_timeout * 60,
+ chat_id=chat_id,
+ user_id=user_id,
+ data={
"chat_id": chat_id,
"user_id": user_id,
- "job_queue": context.job_queue,
"creation_time": datetime.now().timestamp(),
},
)
- job = context.job_queue.run_once(
- on_kick_timeout,
- timeout * 60,
- context={
- "chat_id": chat_id,
- "user_id": user_id,
- "message_id": msg.message_id,
- "creation_time": datetime.now().timestamp(),
- },
- )
+def is_whois(update, chat_id):
+ return (
+ "#whois" in update.effective_message.parse_entities(types=["hashtag"]).values()
+ and chat_id < 0
+ )
-def on_hashtag_message(update: Update, context: CallbackContext):
- if not update.message:
- update.message = update.edited_message
- chat_id = update.message.chat_id
+async def remove_user_jobs_from_queue(context, user_id, chat_id):
+ """
+ Remove jobs related to a specific user from the job queue.
- if (
- "#whois" in update.message.parse_entities(types=["hashtag"]).values()
- and len(update.message.text) >= constants.min_whois_length
- and chat_id < 0
- ):
- user_id = update.message.from_user.id
+ Args:
+ context (CallbackContext): The context object containing the job queue and bot instance.
+ user_id (int): The user ID for whom the jobs should be removed.
+ chat_id (int): The chat ID associated with the jobs to be removed.
+
+ Returns:
+ bool: True if at least one job was removed, False otherwise.
+ """
+ removed = False
+ for job in context.job_queue.jobs():
+ if job.data and job.data.get("user_id") == user_id and job.data.get("chat_id") == chat_id:
+ if "message_id" in job.data:
+ try:
+ await context.bot.delete_message(
+ job.data.get("chat_id"), job.data["message_id"]
+ )
+ except Exception as e:
+ tg_logger.warning(
+ f"can't delete {job.data['message_id']} from {job.data['chat_id']}",
+ exc_info=e,
+ )
+ job.schedule_removal()
+ removed = True
+ return removed
- with session_scope() as sess:
- chat = sess.query(Chat).filter(Chat.id == chat_id).first()
+async def on_hashtag_message(
+ update: Update, context: ContextTypes.DEFAULT_TYPE
+) -> None:
+ """
+ Handle messages containing #whois hashtag.
+
+ Args:
+ update (Update): The update object that represents the incoming update.
+ context (CallbackContext): The context object that contains information about the current state of the bot.
+
+ Returns:
+ None
+ """
+ chat_id = update.effective_message.chat_id
+
+ if is_whois(update, chat_id):
+ user_id = update.effective_message.from_user.id
+ whois_counter.add(1)
+
+ async with session_scope() as sess:
+ chat_result = await sess.execute(select(Chat).where(Chat.id == chat_id))
+ chat = chat_result.scalars().first()
if chat is None:
- chat = Chat(id=chat_id)
+ chat = Chat.get_new_chat(chat_id)
sess.add(chat)
- sess.commit()
+ await sess.commit()
+
+ if len(update.effective_message.text) <= chat.whois_length:
+ await _send_message_with_deletion(
+ context,
+ chat_id,
+ user_id,
+ # TODO move to chat DB
+ _("msg__short_whois").format(whois_length=chat.whois_length),
+ reply_to=update.effective_message,
+ )
+ return
message = chat.on_introduce_message
- with session_scope() as sess:
- user = User(chat_id=chat_id, user_id=user_id, whois=update.message.text)
- sess.merge(user)
+ async with session_scope() as sess:
+ user = User(
+ chat_id=chat_id, user_id=user_id, whois=update.effective_message.text
+ )
+ await sess.merge(user)
removed = False
- for job in context.job_queue.jobs():
- if (
- job.context["user_id"] == user_id
- and job.context["chat_id"] == chat_id
- and job.enabled == True
- ):
- try:
- context.bot.delete_message(
- job.context["chat_id"], job.context["message_id"]
- )
- except:
- pass
- job.enabled = False
- job.schedule_removal()
- removed = True
+ removed = await remove_user_jobs_from_queue(context, user_id, chat_id)
if removed:
- message_markdown = _mention_markdown(context.bot, chat_id, user_id, message)
- update.message.reply_text(message_markdown, parse_mode=ParseMode.MARKDOWN)
+ await _send_message_with_deletion(
+ context,
+ chat_id,
+ user_id,
+ message,
+ reply_to=update.effective_message,
+ )
-def on_notify_timeout(context: CallbackContext):
- bot, job = context.bot, context.job
- with session_scope() as sess:
- chat = sess.query(Chat).filter(Chat.id == job.context["chat_id"]).first()
+async def on_notify_timeout(context: ContextTypes.DEFAULT_TYPE):
+ """
+ Send notify message, schedule its deletion.
- message_markdown = _mention_markdown(
- bot, job.context["chat_id"], job.context["user_id"], chat.notify_message
- )
+ Args:
+ context (CallbackContext): The context object containing the job details and bot instance.
- message = bot.send_message(
- job.context["chat_id"], text=message_markdown, parse_mode=ParseMode.MARKDOWN
+ Returns:
+ None
+ """
+ bot, job = context.bot, context.job
+ async with session_scope() as sess:
+ chat_result = await sess.execute(
+ select(Chat).filter(Chat.id == job.data["chat_id"])
)
-
- job.context["job_queue"].run_once(
- _delete_message,
- constants.notify_delta * 60,
- context={
- "chat_id": job.context["chat_id"],
- "user_id": job.context["user_id"],
- "message_id": message.message_id,
- },
+ chat = chat_result.scalar_one_or_none()
+
+ await _send_message_with_deletion(
+ context,
+ job.data.get("chat_id"),
+ job.data.get("user_id"),
+ chat.notify_message,
+ timeout_m=chat.kick_timeout - chat.notify_timeout,
)
-def on_kick_timeout(context: CallbackContext):
+async def on_kick_timeout(context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Kick a user from the chat after a set amount of time and send a message about it.
+
+ Args:
+ context (CallbackContext): The context object containing the job details and bot instance.
+
+ Returns:
+ None
+ """
bot, job = context.bot, context.job
- try:
- bot.delete_message(job.context["chat_id"], job.context["message_id"])
- except:
- pass
try:
- bot.kick_chat_member(
- job.context["chat_id"],
- job.context["user_id"],
+ await bot.ban_chat_member(
+ job.data.get("chat_id"),
+ job.data.get("user_id"),
until_date=datetime.now() + timedelta(seconds=60),
)
+ ban_counter.add(1)
- with session_scope() as sess:
- chat = sess.query(Chat).filter(Chat.id == job.context["chat_id"]).first()
+ async with session_scope() as sess:
+ chat_result = await sess.execute(
+ select(Chat).where(Chat.id == job.data["chat_id"])
+ )
+ chat = chat_result.scalar_one_or_none()
if chat.on_kick_message.lower() not in ["false", "0"]:
- message_markdown = _mention_markdown(
- bot,
- job.context["chat_id"],
- job.context["user_id"],
+ await _send_message_with_deletion(
+ context,
+ job.data.get("chat_id"),
+ job.data.get("user_id"),
chat.on_kick_message,
)
- if job.context["chat_id"] == constants.RH_CHAT_ID:
- message_markdown = _mention_markdown(
- bot,
- job.context["chat_id"],
- job.context["user_id"],
- random.choice(constants.RH_kick_messages),
- )
- bot.send_message(
- job.context["chat_id"],
- text=message_markdown,
- parse_mode=ParseMode.MARKDOWN,
- )
except Exception as e:
- logger.error(e)
- bot.send_message(job.context["chat_id"], text=constants.on_failed_kick_response)
+ tg_logger.exception(
+ f"Failed to kick {job.data['user_id']} from {job.data['chat_id']}",
+ exc_info=e,
+ )
+ await _send_message_with_deletion(
+ context,
+ job.data.get("chat_id"),
+ job.data.get("user_id"),
+ _("msg__failed_kick_response"),
+ )
+
+
+async def delete_message(context: ContextTypes.DEFAULT_TYPE) -> None:
+ """
+ Delete a message from a chat.
+ Args:
+ context (CallbackContext): The context object containing the job details and bot instance.
-def _delete_message(context: CallbackContext):
+ Returns:
+ None
+ """
bot, job = context.bot, context.job
try:
- bot.delete_message(job.context["chat_id"], job.context["message_id"])
- except:
- print(f"can't delete {job.context['message_id']} from {job.context['chat_id']}")
+ await bot.delete_message(job.data["chat_id"], job.data["message_id"])
+ except Exception as e:
+ tg_logger.warning(
+ f"can't delete {job.data['message_id']} from {job.data['chat_id']}",
+ exc_info=e,
+ )
-def _mention_markdown(bot: Bot, chat_id: int, user_id: int, message: Message):
- user = bot.get_chat_member(chat_id, user_id).user
- if not user.name:
- # если пользователь удален, у него пропадает имя и markdown выглядит так: (tg://user?id=666)
- user_mention_markdown = ""
- else:
- user_mention_markdown = user.mention_markdown()
+async def _mention_markdown(bot: Bot, chat_id: int, user_id: int, message: str) -> str:
+ """
+ Format a message to include a markdown mention of a user.
+
+ Args:
+ bot (Bot): The Telegram bot instance.
+ chat_id (int): The ID of the chat.
+ user_id (int): The ID of the user to mention.
+ message (str): The message to format.
+
+ Returns:
+ str: The formatted message with the user mention.
+ """
+ chat_member = await bot.get_chat_member(chat_id, user_id)
+ user = chat_member.user
+ # if not user.name:
+ # # если пользователь удален, у него пропадает имя и markdown выглядит так: (tg://user?id=666)
+ # user_mention_markdown = ""
+ # else:
+ user_mention_markdown = user.mention_markdown()
# \ нужен из-за формата сообщений в маркдауне
- return message.replace("%USER\_MENTION%", user_mention_markdown)
+ tg_logger.warning(user_mention_markdown)
+ #user_mention_markdown = user_mention_markdown.replace("/[", "[")
+ #user_mention_markdown = user_mention_markdown.replace("]", "\]")
+ # wtf
+ message_mention = message.replace("%USER\\\\\\_MENTION%", user_mention_markdown)
+ message_mention = message_mention.replace("%USER\\\\_MENTION%", user_mention_markdown)
+ message_mention = message_mention.replace("%USER\\_MENTION%", user_mention_markdown)
+ message_mention = message_mention.replace("%USER_MENTION%", user_mention_markdown)
+ tg_logger.warning(message_mention)
+ return message_mention
+
+
+async def _send_message_with_deletion(
+ context: ContextTypes.DEFAULT_TYPE,
+ chat_id: int,
+ user_id: int,
+ message: str,
+ timeout_m: int = constants.default_delete_message_timeout_m,
+ reply_to: Optional[Message] = None,
+):
+ message_markdown = await _mention_markdown(context.bot, chat_id, user_id, message)
+
+ if reply_to is not None:
+ sent_message = await reply_to.reply_text(
+ text=message_markdown, parse_mode=ParseMode.MARKDOWN
+ )
+ else:
+ sent_message = await context.bot.send_message(
+ chat_id, text=message_markdown, parse_mode=ParseMode.MARKDOWN
+ )
+
+ # correctly handle negative timeouts
+ timeout_m = max(timeout_m, constants.default_delete_message_timeout_m)
+
+ context.job_queue.run_once(
+ delete_message,
+ timeout_m * 60,
+ chat_id=chat_id,
+ user_id=user_id,
+ data={
+ "chat_id": chat_id,
+ "user_id": user_id,
+ "message_id": sent_message.message_id,
+ },
+ )
diff --git a/src/handlers/group/my_chat_member_handler.py b/src/handlers/group/my_chat_member_handler.py
new file mode 100644
index 0000000..7a561f0
--- /dev/null
+++ b/src/handlers/group/my_chat_member_handler.py
@@ -0,0 +1,72 @@
+from telegram import ChatMember, Update
+from telegram.ext import ContextTypes
+
+from sqlalchemy import select
+
+from src import constants
+from src.model import Chat, User, session_scope
+from src.handlers.admin.utils import new_keyboard_layout
+from src.texts import _
+
+
+async def my_chat_member_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ old_status, new_status = update.my_chat_member.difference().get("status", (None, None))
+
+ if old_status == ChatMember.LEFT and new_status == ChatMember.MEMBER:
+ # which means the bot was added to the chat
+ await context.bot.send_message(
+ update.effective_chat.id, _("msg__add_bot_to_chat")
+ )
+ return
+
+ if (
+ old_status != ChatMember.ADMINISTRATOR
+ and new_status == ChatMember.ADMINISTRATOR
+ ):
+ # which means the bot is now admin and can be used
+ async with session_scope() as sess:
+ result = await sess.execute(
+ select(Chat).filter_by(id=update.effective_chat.id)
+ )
+ chat = result.scalars().first()
+
+ if chat is None:
+ chat = Chat.get_new_chat(update.effective_chat.id)
+ sess.add(chat)
+ # hack with adding an empty #whois to prevent slow /start cmd
+ # TODO after v1.0: rework the DB schema
+ user = User(
+ chat_id=update.effective_chat.id,
+ user_id=update.effective_user.id,
+ whois="",
+ )
+ await sess.merge(user)
+ # notify the admin about a new chat
+ button_configs = [
+ [
+ {
+ "text": "Приветствия",
+ "action": constants.Actions.set_intro_settings,
+ }
+ ],
+ [
+ {
+ "text": "Удаление и блокировка",
+ "action": constants.Actions.set_kick_bans_settings,
+ }
+ ],
+ [{"text": "Назад", "action": constants.Actions.back_to_chats}],
+ ]
+ reply_markup = new_keyboard_layout(
+ button_configs, update.effective_chat.id
+ )
+ await context.bot.send_message(
+ update.effective_user.id,
+ _("msg__make_admin_direct").format(
+ chat_name=update.effective_chat.title
+ ),
+ reply_markup=reply_markup,
+ )
+
+ await context.bot.send_message(update.effective_chat.id, _("msg__make_admin"))
+ return
diff --git a/src/handlers/help_handler.py b/src/handlers/help_handler.py
index 3e5ed26..f694683 100644
--- a/src/handlers/help_handler.py
+++ b/src/handlers/help_handler.py
@@ -1,7 +1,9 @@
from telegram import Update
-from telegram.ext import CallbackContext
+from telegram.constants import ParseMode
+from telegram.ext import ContextTypes
-from src import constants
+from src.texts import _
-def help_handler(update: Update, _: CallbackContext):
- update.message.reply_text(constants.help_message)
+
+async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ await update.message.reply_text(_("msg__help"), parse_mode=ParseMode.MARKDOWN)
diff --git a/src/handlers/utils.py b/src/handlers/utils.py
new file mode 100644
index 0000000..a254bb3
--- /dev/null
+++ b/src/handlers/utils.py
@@ -0,0 +1,60 @@
+from functools import wraps
+
+from telegram import Update
+from telegram.ext import CallbackContext
+from opentelemetry import metrics
+
+from src.constants import DEBUG, TEAM_TELEGRAM_IDS
+from src.model import Chat, User, session_scope
+
+
+def setup_counter(meter_name, counter_name, version="2.0.0"):
+ """
+ A helper function to remove duplication of code for counters creation.
+ """
+ meter = metrics.get_meter(meter_name, version=version)
+ return meter.create_counter(counter_name, unit="1")
+
+def setup_histogram(meter_name, histogram_name, version="2.0.0"):
+ meter = metrics.get_meter(meter_name, version=version)
+ return meter.create_histogram(name=histogram_name)
+
+
+def admin(func):
+ """
+ A decorator to ensure that a particular function is only executed in private chats,
+ and not in group chats.
+
+ Args:
+ func (Callable): The function to be wrapped by the decorator.
+
+ Returns:
+ Callable: The wrapper function which includes the functionality for checking the chat type.
+ """
+
+ @wraps(func)
+ def wrapper(update: Update, context: CallbackContext, *args, **kwargs):
+ if update.message.chat_id < 0:
+ return # Skip the execution of the function in case of group chat
+ return func(update, context, *args, **kwargs)
+
+ return wrapper
+
+
+def debug(func):
+ """
+ A decorator to ensure that a particular function is only executed for debug purposes, i.e. by someone from the team.
+
+ Args:
+ func (Callable): The function to be wrapped by the decorator.
+
+ Returns:
+ Callable: The wrapper function which includes the functionality for checking the called ID.
+ """
+
+ @wraps(func)
+ def wrapper(update: Update, context: CallbackContext, *args, **kwargs):
+ if DEBUG or update.message.chat_id in TEAM_TELEGRAM_IDS:
+ return func(update, context, *args, **kwargs)
+
+ return wrapper
diff --git a/src/logging.py b/src/logging.py
index e5880f8..823eaa8 100644
--- a/src/logging.py
+++ b/src/logging.py
@@ -2,22 +2,55 @@
from logging import config
import os
+import grpc
+from opentelemetry._logs import set_logger_provider
+from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
+ OTLPLogExporter,
+)
+from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
+from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
+from opentelemetry.sdk.resources import Resource
+
+dsn = os.environ.get("UPTRACE_DSN")
+
+resource = Resource(
+ attributes={"service.name": "wachter-bot", "service.version": "1.1.0", "deployment.environment": os.environ.get("DEPLOYMENT_ENVIRONMENT")}
+)
+logger_provider = LoggerProvider(resource=resource)
+set_logger_provider(logger_provider)
+
+exporter = OTLPLogExporter(
+ endpoint="otlp.uptrace.dev:4317",
+ headers=(("uptrace-dsn", dsn),),
+ timeout=5,
+ compression=grpc.Compression.Gzip,
+)
+logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
+
log_config = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"wachter_telegram": {
- "class": "telegram_logger.TelegramHandler",
+ "class": "telegram_handler.TelegramHandler",
"token": os.environ["TELEGRAM_TOKEN"],
- "chat_ids": [os.environ["TELEGRAM_ERROR_CHAT_ID"]],
- }
+ "chat_id": os.environ["TELEGRAM_ERROR_CHAT_ID"],
+ },
+ "wachter_oltp": {
+ "class": "opentelemetry.sdk._logs.LoggingHandler",
+ "level": logging.INFO,
+ "logger_provider": logger_provider,
+ },
},
"loggers": {
"wachter_telegram_logger": {
"level": "INFO",
- "handlers": ["wachter_telegram",]
+ "handlers": [
+ "wachter_telegram",
+ "wachter_oltp",
+ ],
}
- }
+ },
}
config.dictConfig(log_config)
diff --git a/src/model.py b/src/model.py
index 5f290ef..0fd386a 100644
--- a/src/model.py
+++ b/src/model.py
@@ -1,9 +1,12 @@
from sqlalchemy import create_engine
from sqlalchemy import Column, Integer, Text, Boolean, BigInteger
from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm.session import sessionmaker
-from contextlib import contextmanager
-import os
+from contextlib import asynccontextmanager
+
+from src import constants
+from src.texts import _
Base = declarative_base()
@@ -16,26 +19,62 @@ class Chat(Base):
on_new_chat_member_message = Column(
Text,
nullable=False,
- default="Пожалуйста, представьтесь и поздоровайтесь с сообществом.",
)
on_known_new_chat_member_message = Column(
- Text, nullable=False, default="Добро пожаловать. Снова"
+ Text,
+ nullable=False,
+ )
+ on_introduce_message = Column(
+ Text,
+ nullable=False,
)
- on_introduce_message = Column(Text, nullable=False, default="Добро пожаловать.")
on_kick_message = Column(
- Text, nullable=False, default="%USER\_MENTION% молчит и покидает чат"
+ Text,
+ nullable=False,
)
notify_message = Column(
Text,
nullable=False,
- default="%USER\_MENTION%, пожалуйста, представьтесь и поздоровайтесь с сообществом.",
)
regex_filter = Column(Text, nullable=True) # keeping that in db for now, unused
- filter_only_new_users = Column(Boolean, nullable=False, default=False) # keeping that in db for now, unused
- kick_timeout = Column(Integer, nullable=False, default=0)
+ filter_only_new_users = Column(
+ Boolean, nullable=False, default=False
+ ) # keeping that in db for now, unused
+ kick_timeout = Column(
+ Integer,
+ nullable=False,
+ )
+ notify_timeout = Column(
+ Integer,
+ nullable=False,
+ )
+ whois_length = Column(
+ Integer,
+ nullable=False,
+ )
+ on_introduce_message_update = Column(
+ Text,
+ nullable=False,
+ )
def __repr__(self):
return f""
+
+ @classmethod
+ def get_new_chat(cls, chat_id: int):
+ chat = cls(id=chat_id)
+ # write default values from texts
+ chat.on_new_chat_member_message = _("msg__new_chat_member")
+ chat.on_known_new_chat_member_message = _("msg__known_new_chat_member")
+ chat.on_introduce_message = _("msg__introduce")
+ chat.on_kick_message = _("msg__kick")
+ chat.notify_message = _("msg__notify")
+ chat.on_introduce_message_update = _("msg__introduce_update")
+
+ chat.kick_timeout = constants.default_kick_timeout_m
+ chat.notify_timeout = constants.default_notify_timeout_m
+ chat.whois_length = constants.default_whois_length
+ return chat
class User(Base):
@@ -47,25 +86,21 @@ class User(Base):
whois = Column(Text, nullable=False)
-def get_uri():
- return os.environ.get("DATABASE_URL", "postgresql://localhost:5432/wachter")
-
-
-engine = create_engine(get_uri(), echo=False)
-Session = sessionmaker(autoflush=True, bind=engine)
+engine = create_async_engine(constants.get_uri(), echo=False)
+AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
-@contextmanager
-def session_scope():
- session = Session()
- try:
- yield session
- session.commit()
- except:
- session.rollback()
- raise
- finally:
- session.close()
+@asynccontextmanager
+async def session_scope():
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ await session.commit()
+ except:
+ await session.rollback()
+ raise
+ finally:
+ await session.close()
def orm_to_dict(obj):
diff --git a/src/texts.py b/src/texts.py
new file mode 100644
index 0000000..0cbdbb8
--- /dev/null
+++ b/src/texts.py
@@ -0,0 +1,117 @@
+import re
+
+_texts = {
+ "msg__set_new_message": "Обновил сообщение.",
+ "msg__success_set_kick_timeout_response": "Обновил время до удаления.",
+ "msg__sucess_set_notify_timeout_response": "Обновил время до напоминания.",
+ "msg__failed_set_kick_timeout_response": "Время должно быть целым положительным числом.",
+ "msg__failed_kick_response": "Я не справился.",
+ "msg__start_command": "Выберите чат:",
+ "msg__select_chat": "Выбран чат {chat_name}. Теперь выберите действие:",
+ "msg__help": """Привет! Я - бот Вахтер. Я слежу, чтобы в твоем чате были только представившиеся пользователи. Для начала работы добавь меня в чат и сделай меня администратором.
+После этого для настройки бота тебе нужно написать мне в личных сообщениях /start.
+По умолчанию я не удаляю из чата непредставившихся, а лишь записываю все сообщения с хэштегом #whois.
+Если ты хочешь автоматически удалять непредставившихся, то установи время ожидания до удаления из чата в значение больше нуля (в минутах).
+По умолчанию за 10 минут до удаления я отправляю сообщение с напоминанием.""",
+ "msg__add_bot_to_chat": "Привет, я Вахтёр. Я буду следить за тем, чтобы все люди в чате были представившимися. Дайте мне админские права, чтобы я мог это делать.",
+ "msg__make_admin": "Спасибо, теперь я могу видеть сообщения. Пожалуйста, представьтесь, используя хэштег #whois.",
+ "msg__make_admin_direct": "Есть новый чат {chat_name}",
+ "msg__new_chat_member": "Добро пожаловать! Пожалуйста, представьтесь с использованием хэштега #whois и поздоровайтесь с сообществом.",
+ "msg__known_new_chat_member": "Добро пожаловать снова!",
+ "msg__introduce": "Спасибо и добро пожаловать!",
+ "msg__kick": "%USER\_MENTION% не представился и покидает чат.",
+ "msg__notify": "%USER\_MENTION%, пожалуйста, представьтесь с использованием хэштега #whois.",
+ "msg__introduce_update": "%USER\_MENTION%, если вы хотите обновить существующий #whois, пожалуйста добавьте тег #update к сообщению.",
+ "msg__no_chats_available": "У вас нет доступных чатов.",
+ "msg__sucess_whois_length": "Обновил необходимую длину #whois.",
+ "msg__failed_whois_response": "Длина должна быть целым положительным числом.",
+ "msg__need_hashtag_update_response": "Сообщение должно содержать #update.",
+ "btn__intro": "Приветствия",
+ "btn__kicks": "Удаление и блокировка",
+ "btn__back_to_chats": "Назад к списку чатов",
+ "btn__current_settings": "Посмотреть текущие настройки",
+ "btn__change_welcome_message": "Изменить сообщение при входе в чат",
+ "btn__change_rewelcome_message": "Изменить сообщение при перезаходе в чат",
+ "btn__change_notify_message": "Изменить сообщение напоминания",
+ "btn__change_sucess_message": "Изменить сообщение после представления",
+ "btn__change_notify_timeout": "Изменить время напоминания",
+ "btn__change_whois_length": "Изменить необходимую длину #whois",
+ "btn__change_whois_message": "Изменить сообщение для обновления #whois",
+ "btn__back": "Назад",
+ "btn__change_kick_timeout": "Изменить время до удаления",
+ "btn__change_kick_message": "Изменить сообщение после удаления",
+ "msg__set_new_welcome_message": "Отправьте новый текст сообщения при входе в чат. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__set_new_kick_timout": "Отправьте новое время до удаления в минутах",
+ "msg__set_new_rewelcome_message": "Отправьте новый текст сообщения при перезаходе в чат. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__set_new_notify_message": "Отправьте новый текст сообщения напоминания. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__set_new_sucess_message": "Отправьте новый текст сообщения после представления. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__set_new_whois_length": "Отправьте новую необходимую длину #whois (количество символов).",
+ "msg__set_new_kick_message": "Отправьте новый текст сообщения после удаления. Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__set_new_notify_timeout": "Отправьте новое время до напоминания в минутах.",
+ "msg__set_new_whois_message": "Отправьте новый текст сообщения для обновления #whois (должно содержать хэштег #update). Используйте `%USER_MENTION%`, чтобы тегнуть адресата.",
+ "msg__get_intro_settings": """
+Выбран чат {chat_name}.
+---
+Сообщение для нового участника чата: `{on_new_chat_member_message}`
+---
+Сообщение при перезаходе в чат: `{on_known_new_chat_member_message}`
+---
+Сообщение после успешного представления: `{on_introduce_message}`
+---
+Сообщение напоминания: `{notify_message}`
+---
+Необходимая длина представления с хэштегом #whois для новых пользователей: {whois_length}
+---
+Время до напоминания в минутах (целое положительное число): {notify_timeout}
+---
+Сообщение для обновления информации в #whois: `{on_introduce_message_update}`
+""",
+ "msg__get_kick_settings": """
+Выбран чат {chat_name}.
+---
+Время до удаления в минутах (целое положительное число): {kick_timeout}
+---
+Сообщение после удаления: `{on_kick_message}`
+""",
+ "msg__short_whois": "%USER\_MENTION%, напишите про себя побольше, хотя бы {whois_length} символов. Спасибо!",
+ "msg__skip_new_chat_member": "%SKIP%",
+}
+
+def escape_markdown(text):
+ """
+ Escapes special characters in a Markdown string to prevent Markdown rendering issues,
+ excluding text within curly brackets.
+
+ Args:
+ text (str): The input string that may contain special Markdown characters.
+
+ Returns:
+ str: A string with special Markdown characters escaped, excluding text within curly brackets.
+ """
+ # Regex to find text outside curly brackets
+ def escape_outside_braces(match):
+ text_outside = match.group(1)
+ if text_outside:
+ # Escape special characters in text outside curly brackets
+ special_characters = r"([\\`*_{}\[\]()#+\-.!|>~^])"
+ return re.sub(special_characters, r"\\\1", text_outside)
+ return match.group(0)
+
+ # Match and process text outside curly brackets
+ escaped_text = re.sub(r"([^{}]+(?=\{)|(?<=\})([^{}]+)|^[^{]+|[^}]+$)", escape_outside_braces, text)
+ return escaped_text
+
+def _(text):
+ """
+ Retrieve and escape a predefined message text based on a unique key.
+
+ Args:
+ text (str): A unique key representing the desired message.
+
+ Returns:
+ str: The escaped message text associated with the input key, or None if not found.
+ """
+ return _texts.get(text)
+ # if raw_message is not None:
+ # return escape_markdown(raw_message)
+ # return None
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..c1827b9
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,180 @@
+import sys
+import os
+import pytest, pytest_asyncio, json
+from unittest.mock import AsyncMock, MagicMock, patch
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import sessionmaker
+
+with pytest.MonkeyPatch().context() as ctx:
+
+ def mock_get_uri():
+ return "sqlite+aiosqlite:///:memory:?cache=shared"
+
+ ctx.setattr("src.constants.get_uri", mock_get_uri)
+ from src import constants
+ from src.model import engine, User, Chat
+
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
+
+
+@pytest.fixture
+def mock_update():
+ callback_query = AsyncMock()
+ callback_query.data = json.dumps({"action": constants.Actions.start_select_chat})
+
+ message_mock = MagicMock()
+ message_mock.chat_id = 12345
+ message_mock.message_id = 67890
+ message_mock.reply_text = AsyncMock()
+
+ update = AsyncMock()
+ update.callback_query = callback_query
+ update.effective_message = message_mock
+ return update
+
+
+@pytest.fixture
+def mock_context():
+ context = AsyncMock()
+ bot_mock = AsyncMock()
+ bot_mock.edit_message_text = AsyncMock()
+ context.job_queue.run_once = MagicMock()
+ context.bot = bot_mock
+ return context
+
+
+# Fixture to set up an in-memory SQLite database
+@pytest_asyncio.fixture(scope="function")
+async def async_engine():
+ async with engine.begin() as conn:
+ await conn.execute(text("DROP TABLE IF EXISTS users;"))
+ await conn.execute(text("DROP TABLE IF EXISTS chats;"))
+ await conn.execute(
+ text(
+ """
+ CREATE TABLE chats (
+ id INTEGER PRIMARY KEY,
+ on_new_chat_member_message TEXT NOT NULL,
+ on_known_new_chat_member_message TEXT NOT NULL,
+ on_introduce_message TEXT NOT NULL,
+ on_kick_message TEXT NOT NULL,
+ notify_message TEXT NOT NULL,
+ regex_filter TEXT,
+ filter_only_new_users BOOLEAN NOT NULL DEFAULT FALSE,
+ kick_timeout INTEGER NOT NULL,
+ notify_timeout INTEGER NOT NULL,
+ whois_length INTEGER NOT NULL,
+ on_introduce_message_update TEXT NOT NULL
+ );
+ """
+ )
+ )
+ await conn.execute(
+ text(
+ """
+ CREATE TABLE users (
+ user_id INTEGER,
+ chat_id INTEGER,
+ whois TEXT NOT NULL,
+ PRIMARY KEY (user_id, chat_id)
+ );
+ """
+ )
+ )
+ return engine
+
+
+# Fixture for creating a new session for each test
+@pytest_asyncio.fixture(scope="function")
+async def async_session(async_engine):
+ async_session_local = sessionmaker(
+ async_engine, class_=AsyncSession, expire_on_commit=False
+ )
+ async with async_session_local() as session:
+ yield session
+
+
+@pytest_asyncio.fixture
+async def populate_db(async_session):
+ # Define user data
+ user_data = [
+ (1, 1, "User 1 in Chat 1"),
+ (1, 2, "User 1 in Chat 2"),
+ (2, 2, "User 2 in Chat 2"),
+ (2, 3, "User 2 in Chat 3"),
+ (3, None, "User 3, no Chats"),
+ ]
+
+ # Define chat data
+ chat_data = [
+ (
+ 1,
+ "Welcome to Chat 1",
+ "Message for known members in Chat 1",
+ "Introduce in Chat 1",
+ "Kick message in Chat 1",
+ "Notify message in Chat 1",
+ None,
+ False,
+ 30,
+ 60,
+ 100,
+ "Update Introduce in Chat 1",
+ ),
+ (
+ 2,
+ "Welcome to Chat 2",
+ "Message for known members in Chat 2",
+ "Introduce in Chat 2",
+ "Kick message in Chat 2",
+ "Notify message in Chat 2",
+ None,
+ False,
+ 30,
+ 60,
+ 100,
+ "Update Introduce in Chat 2",
+ ),
+ (
+ -3,
+ "Welcome to Chat 2",
+ "Message for known members in Chat 2",
+ "Introduce in Chat 2",
+ "Kick message in Chat 2",
+ "Notify message in Chat 2",
+ None,
+ False,
+ 30,
+ 60,
+ 100,
+ "Update Introduce in Chat 2",
+ ),
+ ]
+
+ # Create objects using list comprehension
+ users = [
+ User(user_id=uid, chat_id=cid, whois=whois) for uid, cid, whois in user_data
+ ]
+
+ chats = [
+ Chat(
+ id=cid,
+ on_new_chat_member_message=welcome,
+ on_known_new_chat_member_message=known,
+ on_introduce_message=introduce,
+ on_kick_message=kick,
+ notify_message=notify,
+ regex_filter=regex,
+ filter_only_new_users=filter_new,
+ kick_timeout=kick_timeout,
+ notify_timeout=notify_timeout,
+ whois_length=whois_length,
+ on_introduce_message_update=introduce_update,
+ )
+ for cid, welcome, known, introduce, kick, notify, regex, filter_new, kick_timeout, notify_timeout, whois_length, introduce_update in chat_data
+ ]
+
+ async_session.add_all(users + chats)
+
+ await async_session.commit()
diff --git a/test/group_handler_test.py b/test/group_handler_test.py
new file mode 100644
index 0000000..37349c8
--- /dev/null
+++ b/test/group_handler_test.py
@@ -0,0 +1,108 @@
+import pytest
+from sqlalchemy import select
+from unittest.mock import patch
+import os
+
+with patch.dict(
+ "os.environ",
+ {
+ "TELEGRAM_TOKEN": "dummy_token",
+ "TELEGRAM_ERROR_CHAT_ID": "dummy_chat_id",
+ "UPTRACE_DSN": "dummy_dsn",
+ "DEPLOYMENT_ENVIRONMENT": "testing",
+ },
+):
+ from src.handlers.group.group_handler import on_hashtag_message
+ from src.model import User, Chat
+from src.texts import _
+
+from telegram.constants import ParseMode
+
+
+async def mock_mention_markdown(bot, chat_id, user_id, message):
+ # Replace %USER_MENTION% with a default string
+ return message.replace("%USER_MENTION%", "@example_user")
+
+
+@pytest.mark.asyncio
+async def test_on_hashtag_message_new_user(
+ mock_update, mock_context, async_session, populate_db, mocker
+):
+ # Simulate the Incoming Message
+ chat_id = -3 # Example group chat ID (negative for groups)
+ user_id = 3 # Example new user ID
+ mock_update.effective_message.chat_id = chat_id
+ mock_update.effective_message.from_user.id = user_id
+ mock_update.effective_message.text = "#whois I am new here dkgjldskfjglkdfjglkdfsj lgkjdsflökgjldösfjglsdfjgölkdsfjglöksdjfglöksdfjglöksdfjg"
+
+ mocker.patch("src.handlers.group.group_handler.is_whois", return_value=True)
+ mocker.patch(
+ "src.handlers.group.group_handler._mention_markdown",
+ return_value="@example_user",
+ )
+ mocker.patch(
+ "src.handlers.group.group_handler.remove_user_jobs_from_queue",
+ return_value=True,
+ )
+ mocker.patch(
+ "src.handlers.group.group_handler.whois_counter.add",
+ return_value=True,
+ )
+
+ await on_hashtag_message(mock_update, mock_context)
+
+ # Check if the reply message was sent
+ assert mock_update.effective_message.reply_text.await_count == 1
+
+ # Verify that the new user is added to the database
+ async with async_session as session:
+ result = await session.execute(
+ select(User).where(User.chat_id == chat_id, User.user_id == user_id)
+ )
+ user = result.scalars().first()
+ assert user is not None
+ assert (
+ user.whois
+ == "#whois I am new here dkgjldskfjglkdfjglkdfsj lgkjdsflökgjldösfjglsdfjgölkdsfjglöksdjfglöksdfjglöksdfjg"
+ )
+
+
+@pytest.mark.asyncio
+async def test_on_hashtag_message_short_whois(
+ mock_update, mock_context, async_session, populate_db, mocker
+):
+ # Simulate the Incoming Message
+ chat_id = -3 # Example group chat ID (negative for groups)
+ user_id = 3 # Example new user ID
+ mock_update.effective_message.chat_id = chat_id
+ mock_update.effective_message.from_user.id = user_id
+ mock_update.effective_message.text = "#whois I am new here"
+
+ mocker.patch("src.handlers.group.group_handler.is_whois", return_value=True)
+ mocker.patch(
+ "src.handlers.group.group_handler._mention_markdown",
+ side_effect=mock_mention_markdown,
+ )
+
+ await on_hashtag_message(mock_update, mock_context)
+
+ async with async_session as session:
+ try:
+ result = await session.execute(
+ select(Chat.whois_length).where(Chat.id == chat_id)
+ )
+ whois_length = result.scalar_one()
+ print(whois_length)
+ except Exception as e:
+ print(f"Error during query execution: {e}")
+ raise
+ print()
+ expected_reply = _("msg__short_whois").format(whois_length=whois_length)
+ # Fetch the actual reply text
+ actual_reply_call = mock_update.effective_message.reply_text.call_args
+ actual_reply = actual_reply_call[1]["text"] if actual_reply_call else None
+
+ # Improved assertion with detailed error message
+ assert (
+ actual_reply == expected_reply
+ ), f"Assertion failed: Expected reply text '{expected_reply}' but got '{actual_reply}'."
diff --git a/test/group_onboarding_test.py b/test/group_onboarding_test.py
new file mode 100644
index 0000000..1cb8584
--- /dev/null
+++ b/test/group_onboarding_test.py
@@ -0,0 +1,81 @@
+import pytest
+import json
+from unittest.mock import AsyncMock, call
+
+from src.handlers.group.my_chat_member_handler import my_chat_member_handler
+from src.texts import _
+from src import constants
+
+from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup
+
+
+@pytest.mark.asyncio
+async def test_add_bot_to_chat(mock_context):
+ mock_update = AsyncMock()
+ mock_update.effective_chat.id = 1000
+ mock_update.my_chat_member.difference = lambda: {
+ "status": (ChatMember.LEFT, ChatMember.MEMBER)
+ }
+ await my_chat_member_handler(mock_update, mock_context)
+ mock_context.bot.send_message.assert_awaited_once_with(
+ 1000, _("msg__add_bot_to_chat")
+ )
+
+
+@pytest.mark.asyncio
+async def test_make_bot_admin(mock_context, async_session):
+ mock_update = AsyncMock()
+ mock_update.effective_chat.id = 1000
+ mock_update.effective_chat.title = "title"
+ mock_update.effective_user.id = 1001
+ mock_update.my_chat_member.difference = lambda: {
+ "status": (ChatMember.MEMBER, ChatMember.ADMINISTRATOR)
+ }
+ await my_chat_member_handler(mock_update, mock_context)
+ mock_context.bot.send_message.assert_has_calls(
+ [
+ call(
+ 1001,
+ _("msg__make_admin_direct").format(chat_name="title"),
+ reply_markup=InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(
+ "Приветствия",
+ callback_data=json.dumps(
+ {
+ "chat_id": 1000,
+ "action": constants.Actions.set_intro_settings,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ "Удаление и блокировка",
+ callback_data=json.dumps(
+ {
+ "chat_id": 1000,
+ "action": constants.Actions.set_kick_bans_settings,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ "Назад",
+ callback_data=json.dumps(
+ {
+ "chat_id": 1000,
+ "action": constants.Actions.back_to_chats,
+ }
+ ),
+ )
+ ],
+ ]
+ ),
+ ),
+ call(1000, _("msg__make_admin")),
+ ],
+ any_order=True,
+ )
diff --git a/test/menu_handler_test.py b/test/menu_handler_test.py
new file mode 100644
index 0000000..021a133
--- /dev/null
+++ b/test/menu_handler_test.py
@@ -0,0 +1,397 @@
+import json
+import pytest, pytest_asyncio, asyncio
+
+# from conftest import function_scoped_event_loop, session_scoped_event_loop
+
+from src.handlers.admin.menu_handler import button_handler
+from src.texts import _
+from src import constants
+
+
+from unittest.mock import patch
+
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup
+
+from telegram.constants import ParseMode
+
+
+@pytest.mark.asyncio
+async def test_button_handler_no_chats(mock_update, mock_context, populate_db):
+ mock_update.callback_query.from_user.id = 3
+ await button_handler(mock_update, mock_context)
+ # Assert that the expected message was sent
+ mock_update.message.reply_text.assert_awaited_once_with(
+ _("msg__no_chats_available")
+ )
+
+
+@pytest.mark.asyncio
+async def test_button_handler_basic_start(mock_update, mock_context, mocker):
+ mock_update.callback_query.from_user.id = 1
+ mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True)
+ mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat")
+ await button_handler(mock_update, mock_context)
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ _("msg__start_command"),
+ reply_markup=InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(
+ "Chat",
+ callback_data=json.dumps(
+ {"chat_id": 1, "action": constants.Actions.select_chat}
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ "Chat",
+ callback_data=json.dumps(
+ {"chat_id": 2, "action": constants.Actions.select_chat}
+ ),
+ )
+ ],
+ ]
+ ),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_select_chat_action(mock_update, mock_context, mocker):
+ # Set the data for the select_chat action
+ action = constants.Actions.select_chat
+ selected_chat_id = 1
+ chat_name = "Chat"
+
+ # Mock the data being received from the callback_query
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+ mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat")
+
+ expected_keyboard = InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(
+ text=_("btn__intro"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.set_intro_settings,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_("btn__kicks"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.set_kick_bans_settings,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_("btn__back_to_chats"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.back_to_chats,
+ }
+ ),
+ )
+ ],
+ ]
+ )
+
+ await button_handler(mock_update, mock_context)
+ actual_call = mock_context.bot.edit_message_text.call_args[
+ 1
+ ] # This gets the kwargs of the last call
+ assert actual_call["reply_markup"] == expected_keyboard
+
+
+@pytest.mark.asyncio
+async def test_set_kick_bans_settings(mock_update, mock_context, mocker):
+ action = constants.Actions.set_kick_bans_settings
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+ await button_handler(mock_update, mock_context)
+
+ expected_keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text=_("btn__current_settings"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.get_current_kick_settings,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_("btn__change_kick_timeout"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.set_kick_timeout,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_("btn__change_kick_message"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.set_on_kick_message,
+ }
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text=_("btn__back"),
+ callback_data=json.dumps(
+ {
+ "chat_id": selected_chat_id,
+ "action": constants.Actions.select_chat,
+ }
+ ),
+ )
+ ],
+ ]
+ )
+
+ mock_context.bot.edit_message_reply_markup.assert_awaited_once_with(
+ reply_markup=expected_keyboard,
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_back_to_chats(mock_update, mock_context, mocker):
+ mock_update.callback_query.from_user.id = 1
+ mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True)
+ mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat")
+ await button_handler(mock_update, mock_context)
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ _("msg__start_command"),
+ reply_markup=InlineKeyboardMarkup(
+ [
+ [
+ InlineKeyboardButton(
+ "Chat",
+ callback_data=json.dumps(
+ {"chat_id": 1, "action": constants.Actions.select_chat}
+ ),
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ "Chat",
+ callback_data=json.dumps(
+ {"chat_id": 2, "action": constants.Actions.select_chat}
+ ),
+ )
+ ],
+ ]
+ ),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_new_chat_member_message_response(
+ mock_update, mock_context, mocker
+):
+ action = constants.Actions.set_on_new_chat_member_message_response
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_welcome_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_kick_timeout(mock_update, mock_context, mocker):
+ action = constants.Actions.set_kick_timeout
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_kick_timout"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_known_new_chat_member_message_response(mock_update, mock_context):
+ action = constants.Actions.set_on_known_new_chat_member_message_response
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_rewelcome_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_new_notify_message(mock_update, mock_context, mocker):
+ action = constants.Actions.set_notify_message
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_notify_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_new_chat_member_message_response(
+ mock_update, mock_context, mocker
+):
+ action = constants.Actions.set_on_new_chat_member_message_response
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_welcome_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_successful_introducion_response(
+ mock_update, mock_context, mocker
+):
+ action = constants.Actions.set_on_successful_introducion_response
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_sucess_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_whois_length(mock_update, mock_context, mocker):
+ action = constants.Actions.set_whois_length
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_whois_length"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_kick_message(mock_update, mock_context, mocker):
+ action = constants.Actions.set_on_kick_message
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_kick_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_notify_timeout(mock_update, mock_context, mocker):
+ action = constants.Actions.set_notify_timeout
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_notify_timeout"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ )
+
+
+@pytest.mark.asyncio
+async def test_set_on_introduce_message_update(mock_update, mock_context, mocker):
+ action = constants.Actions.set_on_introduce_message_update
+ selected_chat_id = 1
+ mock_update.callback_query.data = json.dumps(
+ {"action": action, "chat_id": selected_chat_id}
+ )
+
+ await button_handler(mock_update, mock_context)
+
+ mock_context.bot.edit_message_text.assert_awaited_once_with(
+ text=_("msg__set_new_whois_message"),
+ chat_id=mock_update.callback_query.message.chat_id,
+ message_id=mock_update.callback_query.message.message_id,
+ parse_mode=ParseMode.MARKDOWN,
+ )
diff --git a/test/start_handler_test.py b/test/start_handler_test.py
new file mode 100644
index 0000000..b412818
--- /dev/null
+++ b/test/start_handler_test.py
@@ -0,0 +1,43 @@
+import pytest
+import json
+from unittest.mock import patch
+
+from src.handlers.admin.start_handler import start_handler
+from src import constants
+from src.texts import _
+
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup
+
+
+@pytest.mark.asyncio
+async def test_start_handler_no_chats(mock_update, mock_context, populate_db):
+ mock_update.message.chat_id = 3
+ await start_handler(mock_update, mock_context)
+ mock_update.message.reply_text.assert_awaited_once_with(
+ _("msg__no_chats_available")
+ )
+
+
+@pytest.mark.asyncio
+async def test_start_handler_basic(mock_update, mock_context, mocker):
+ mock_update.message.chat_id = 1
+ mocker.patch("src.handlers.admin.utils.authorize_user", return_value=True)
+ mocker.patch("src.handlers.admin.utils.get_chat_name", return_value="Chat")
+ await start_handler(mock_update, mock_context)
+ mock_update.message.reply_text.assert_awaited_once_with(
+ _("msg__start_command"),
+ reply_markup=InlineKeyboardMarkup(
+ inline_keyboard=(
+ (
+ InlineKeyboardButton(
+ callback_data='{"chat_id": 1, "action": 2}', text="Chat"
+ ),
+ ),
+ (
+ InlineKeyboardButton(
+ callback_data='{"chat_id": 2, "action": 2}', text="Chat"
+ ),
+ ),
+ )
+ ),
+ )