matplobbot-shared 0.1.48__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: matplobbot-shared
3
+ Version: 0.1.48
4
+ Summary: Shared library for the Matplobbot ecosystem (database, services, i18n).
5
+ Author: Ackrome
6
+ Author-email: ivansergeyevich@gmail.com
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: asyncpg
9
+ Requires-Dist: aiohttp
10
+ Requires-Dist: certifi
11
+ Requires-Dist: redis
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: requires-dist
15
+ Dynamic: requires-python
16
+ Dynamic: summary
@@ -0,0 +1,302 @@
1
+ <div align="center" style="border: none; padding: 0; margin: 0;">
2
+ <img src="image/logo/thelogo.png" alt="Matplobbot Logo" width="400" style="border: none; outline: none;">
3
+ <h1>Matplobbot & Stats Dashboard</h1>
4
+ <strong>A comprehensive solution: An Aiogram 3 Telegram bot for advanced code interaction and a FastAPI dashboard for real-time analytics.</strong>
5
+ <br>
6
+ </br>
7
+ <p align="center">
8
+ <img src="https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white" alt="Python">
9
+ <img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker">
10
+ <img src="https://img.shields.io/badge/FastAPI-009688?style=for-the-badge&logo=fastapi&logoColor=white" alt="FastAPI">
11
+ <img src="https://img.shields.io/badge/Aiogram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Aiogram">
12
+ <img src="https://img.shields.io/badge/PostgreSQL-4169E1?style=for-the-badge&logo=postgresql&logoColor=white" alt="PostgreSQL">
13
+ <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black" alt="JavaScript">
14
+ <img src="https://img.shields.io/badge/Pandoc-5A5A5A?style=for-the-badge&logo=pandoc&logoColor=white" alt="Pandoc">
15
+ <img src="https://img.shields.io/badge/LaTeX-008080?style=for-the-badge&logo=latex&logoColor=white" alt="LaTeX">
16
+ <img src ="https://img.shields.io/badge/Tailscale-000000?style=for-the-badge&logo=tailscale&logoColor=white">
17
+ <img src="https://img.shields.io/badge/Jenkins-D24939?style=for-the-badge&logo=jenkins&logoColor=white">
18
+ <img src="https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white">
19
+ <img src="https://img.shields.io/badge/markdown-%23000000.svg?style=for-the-badge&logo=markdown&logoColor=white" alt="Markdown">
20
+ <img src="https://img.shields.io/badge/Pydantic-E92063?style=for-the-badge&logo=Pydantic&logoColor=white" alt="Pydantic">
21
+ <img src="https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=github-actions&logoColor=white" alt="GithubActions">
22
+ <img src="https://img.shields.io/badge/redis-%23DD0031.svg?&style=for-the-badge&logo=redis&logoColor=white" alt="Redis">
23
+ </p>
24
+ </div>
25
+
26
+ ---
27
+
28
+ ## 🚀 Project Overview
29
+
30
+ This project is a powerful, dual-component system designed for advanced interaction with programming content and real-time monitoring, all containerized with Docker for seamless deployment.
31
+
32
+ 1. **Matplobbot (Telegram Bot)**: A sophisticated asynchronous bot built on `aiogram 3`. It serves as an intelligent gateway to programming libraries and educational materials. Its core features include interactive library browsing, full-text search, and a powerful on-demand rendering engine for LaTeX equations and Mermaid diagrams. All user interactions are meticulously logged to a shared SQLite database.
33
+
34
+ 2. **Stats Dashboard (FastAPI Web App)**: A real-time monitoring dashboard powered by `FastAPI`. It features a clean, responsive frontend built with vanilla JavaScript and `Chart.js`. The dashboard provides deep insights into bot usage statistics by querying the shared **PostgreSQL** database and streams live log events directly from the bot's log file via WebSockets.
35
+
36
+ The entire ecosystem is orchestrated by Docker Compose, utilizing shared volumes for the database and logs, which ensures data consistency and perfect integration between the two services.
37
+
38
+ ## ✨ Key Features
39
+
40
+ ### 🤖 Telegram Bot
41
+
42
+ The bot provides a rich, interactive experience for developers, students, and researchers.
43
+
44
+ #### Content Interaction
45
+ - **Library Browsing**: Interactively navigate the `matplobblib` library by modules and topics (`/matp_all`).
46
+ - **GitHub Repository Browsing**: Explore user-configured GitHub repositories file by file (`/lec_all`).
47
+ - **Full-Text Search**: Perform deep searches within the `matplobblib` source code (`/matp_search`) and across Markdown files in your linked GitHub repositories (`/lec_search`).
48
+
49
+ #### 🔬 Dynamic On-Demand Rendering
50
+ - **LaTeX Rendering**: Convert LaTeX equations into crisp, high-quality PNG images using the `/latex` command. Results are cached in the database for instant retrieval on subsequent requests.
51
+ - **Mermaid.js Rendering**: Transform Mermaid diagram syntax into PNG images via the `/mermaid` command, utilizing a headless Chrome instance managed by Puppeteer.
52
+
53
+ #### 📄 Advanced Markdown Processing
54
+ The bot features a sophisticated pipeline for displaying `.md` files from GitHub. It uses **Pandoc** augmented with **custom Lua and Python filters** to correctly process and render complex documents containing embedded LaTeX and Mermaid code.
55
+
56
+ | Display Mode | Description |
57
+ | :--- | :--- |
58
+ | 🖼 **Text + Images** | Renders the document directly into the chat, splitting it into a series of text messages and generated images for equations and diagrams. |
59
+ | 📄 **HTML File** | Generates a fully self-contained `.html` file, bundling all necessary CSS and JS. Mermaid diagrams are interactive. |
60
+ | ⚫ **MD File** | Sends the original, raw `.md` file without any processing. |
61
+
62
+ #### ⚙️ Personalization & User Management
63
+ - **Favorites (`/favorites`)**: Bookmark useful code examples from your searches for quick access later.
64
+ - **Settings (`/settings`)**: A comprehensive inline menu allows users to:
65
+ - Toggle the display of code docstrings.
66
+ - Select their preferred Markdown display mode.
67
+ - Fine-tune LaTeX rendering quality (DPI and padding).
68
+ - Manage their personal list of GitHub repositories.
69
+
70
+ #### 👑 Administration
71
+ - **Live Library Updates (`/update`)**: (Admin-only) Fetches the latest version of the `matplobblib` library from PyPI and dynamically reloads the module without bot downtime.
72
+ - **Cache Management (`/clear_cache`)**: (Admin-only) Instantly purges all application caches, including in-memory `TTLCache` for API calls and the persistent LaTeX cache in the database.
73
+
74
+ ### 📊 Web Dashboard
75
+
76
+ The dashboard provides a live, data-rich view of the bot's health and user engagement.
77
+
78
+ <div align="center">
79
+ <img src="https://github.com/Ackrome/matplobbot/blob/main/image/notes/Dashboard.png" alt="Dashboard Screenshot" width="800">
80
+ </div>
81
+
82
+ - **Real-time Updates**: All statistical charts and counters update instantly via **WebSocket** connections, providing a true live monitoring experience.
83
+ - **Rich Data Visualization**:
84
+ - Total user actions counter.
85
+ - Leaderboard of the most active users, complete with their Telegram avatars.
86
+ - Bar charts for the most frequently used commands and text messages.
87
+ - A pie chart visualizing the distribution of action types (e.g., command vs. callback query).
88
+ - A line chart illustrating user activity over time.
89
+ - **Live Log Streaming**: A live feed of the `bot.log` file is streamed directly to the web UI, enabling real-time operational monitoring.
90
+ - **Modern UI**: A clean, responsive interface with automatic **light and dark theme** support.
91
+
92
+ ## 🛠️ Architecture & Tech Stack
93
+
94
+
95
+
96
+ The project is built on modern, asynchronous frameworks with a strong emphasis on modularity and separation of concerns.
97
+
98
+ | Category | Technology & Key Libraries |
99
+ | :--- | :--- |
100
+ | **Backend** | Python 3.11+ |
101
+ | **Bot Framework** | **Aiogram 3** (utilizing `Router` for modular handlers) |
102
+ | **Web Framework** | **FastAPI**, Uvicorn |
103
+ | **Database** | **PostgreSQL** (accessed asynchronously via `asyncpg`) |
104
+ | **Frontend** | HTML5, CSS3, Vanilla JavaScript, **Chart.js** |
105
+ | **Containerization** | **Docker, Docker Compose** |
106
+ | **Rendering Pipeline** | **Pandoc** with custom Lua & Python filters, **TeX Live**, dvipng, **Mermaid-CLI**, Puppeteer |
107
+ | **Key Libraries** | `aiohttp`, `cachetools`, `python-dotenv` |
108
+
109
+ ### Architectural Highlights
110
+ - **Decoupled Services**: The bot and the web dashboard run in separate Docker containers but communicate through a shared database and log volume, creating a robust, microservice-like architecture.
111
+ - **Modular Handlers**: The bot's logic is cleanly organized into feature-specific modules (`admin`, `rendering`, `settings`, etc.), each with its own `aiogram.Router`.
112
+ - **Service Layer**: Complex business logic, such as rendering documents and interacting with the GitHub API, is abstracted into a dedicated `services` package.
113
+ - **Asynchronous Everywhere**: From database calls (`asyncpg`) to external API requests (`aiohttp`), the entire stack is asynchronous to ensure high performance and scalability.
114
+ - **Intelligent Caching**: In-memory `TTLCache` is used extensively to cache GitHub API responses, reducing rate-limiting and speeding up user-facing operations.
115
+
116
+ ## ⚙️ Installation & Setup
117
+
118
+ The project is fully containerized, enabling a simple and reproducible setup.
119
+
120
+ ### 1. Prerequisites
121
+ - **Docker** and **Docker Compose** must be installed on your system.
122
+
123
+ ### 2. Environment Variables
124
+
125
+ Create a `.env` file in the project's root directory. Fill it out using the template below.
126
+
127
+ ```env
128
+ # Get this from @BotFather on Telegram
129
+ BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
130
+
131
+ # Comma-separated list of Telegram User IDs for admin command access
132
+ ADMIN_USER_IDS=123456789,987654321
133
+
134
+ # GitHub Personal Access Token with 'repo' scope for reading repositories
135
+ # Required for /lec_search, /lec_all, and uploading rendered LaTeX images
136
+ GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
137
+
138
+ # --- PostgreSQL Credentials ---
139
+ POSTGRES_USER=user
140
+ POSTGRES_PASSWORD=password
141
+ POSTGRES_DB=matplobbot_db
142
+ POSTGRES_HOST=postgres # The service name in docker-compose
143
+ POSTGRES_PORT=5432
144
+ ```
145
+
146
+ ### 3. Running with Docker Compose
147
+
148
+ This is the recommended method for running the project.
149
+
150
+ 1. **Clone the repository:**
151
+ ```bash
152
+ git clone https://github.com/Ackrome/matplobbot.git
153
+ cd matplobbot
154
+ ```
155
+
156
+ 2. **Ensure your `.env` file is created and configured** as described above.
157
+
158
+ 3. **Build and run the services in detached mode:**
159
+ ```bash
160
+ docker compose up --build -d
161
+ ```
162
+
163
+ ### 4. Accessing the Services
164
+
165
+ - **Telegram Bot**: Will be active and available on Telegram.
166
+ - **Web Dashboard**: Open `http://localhost:9583` in your browser.
167
+
168
+ ### 5. Stopping the Services
169
+
170
+ - To stop all running containers, execute:
171
+ ```bash
172
+ docker compose down
173
+ ```
174
+ - Your database and log files will persist in named volumes. To remove all data, run `docker-compose down -v`.
175
+
176
+ ## 📚 Bot Command Reference
177
+
178
+ | Command | Description | Usage |
179
+ | :--- | :--- | :--- |
180
+ | **General** | | |
181
+ | `/start` | Initializes the bot and displays the main command keyboard. | Send to begin or reset your session. |
182
+ | `/help` | Shows an interactive inline menu with descriptions of all available commands. | Send to get a quick overview of the bot's features. |
183
+ | `/cancel` | Aborts any ongoing operation or conversation state. | Use if you get stuck waiting for input or want to return to the main menu. |
184
+ | **Content Browsing & Search** | | |
185
+ | `/matp_all` | Interactively browse the `matplobblib` library by modules and topics. | Send the command and navigate the library structure using inline buttons. |
186
+ | `/matp_search` | Performs a full-text search for code examples within `matplobblib`. | Send the command, then type your search query (e.g., "line plot"). |
187
+ | `/lec_all` | Interactively browse files in your configured GitHub repositories. | Send the command. If you have multiple repos, you'll be asked to choose one. |
188
+ | `/lec_search` | Performs a full-text search within `.md` files in a chosen GitHub repository. | Send the command, choose a repository, then enter your search query. |
189
+ | **Dynamic Rendering** | | |
190
+ | `/latex` | Renders a LaTeX formula into a high-quality PNG image. | Send the command, then provide the LaTeX code (e.g., `\frac{a}{b}`). |
191
+ | `/mermaid` | Renders a Mermaid.js diagram into a PNG image. | Send the command, then provide the Mermaid diagram code (e.g., `graph TD; A-->B;`). |
192
+ | **Personalization** | | |
193
+ | `/favorites` | View, manage, and access your saved favorite code examples. | Send the command to see your list. You can add items from search results or library browsing. |
194
+ | `/settings` | Access and modify your personal settings. | Configure docstring visibility, Markdown display format, LaTeX quality, and manage your GitHub repositories. |
195
+ | **Admin Commands** | | |
196
+ | `/update` | Updates the `matplobblib` library to the latest version from PyPI. | *(Admin-only)* Send the command to perform a live update. |
197
+ | `/clear_cache` | Clears all application caches (in-memory and database). | *(Admin-only)* Useful for forcing the bot to fetch fresh data. |
198
+
199
+ ### On-boarding users
200
+ ```mermaid
201
+ graph TD
202
+ subgraph "User's First Interaction"
203
+ A[User sends /start] --> B{Is onboarding_completed == false?};
204
+ end
205
+
206
+ B -- Yes --> C[Onboarding Starts: Show Welcome Message & 'Next' button];
207
+ B -- No --> Z[Show Regular Welcome Message];
208
+
209
+ C --> D{User clicks 'Next'};
210
+ D --> E[Show GitHub Feature & 'Add Repository' button];
211
+
212
+ subgraph "Feature Interaction 1: GitHub"
213
+ E --> F{User clicks 'Add Repository'};
214
+ F --> G[User interacts with Repo Management];
215
+ G --> H{User clicks 'Back to Tour'};
216
+ end
217
+
218
+ E --> H;
219
+
220
+ H --> I[Show Library Feature & 'Next' button];
221
+ I --> J[Show Rendering Feature & 'Try LaTeX' button];
222
+
223
+ subgraph "Feature Interaction 2: Rendering"
224
+ J --> K{User can try LaTeX};
225
+ end
226
+
227
+ J --> L{User clicks 'Next'};
228
+ I --> L;
229
+
230
+ L --> M[Show Final Message & 'Finish Tour' button]
231
+
232
+ M --> N{User clicks 'Finish Tour'};
233
+ N --> O[Set onboarding_completed = true];
234
+ O --> P[Show Main Menu Keyboard];
235
+
236
+ style Z fill:#f9f,stroke:#333,stroke-width:2px
237
+ style P fill:#ccf,stroke:#333,stroke-width:2px
238
+ ```
239
+
240
+ ### 🚀 CI/CD Pipeline
241
+
242
+ The project is built around a modern, secure, and fully automated CI/CD pipeline that handles everything from code validation to production deployment. This pipeline leverages a hybrid approach, using a self-hosted GitHub Runner to securely bridge the public cloud (GitHub) with a private deployment environment (Proxmox/Tailscale).
243
+
244
+ **Key Technologies:**
245
+ * **Continuous Integration**: GitHub Actions
246
+ * **Continuous Deployment**: Jenkins
247
+ * **Containerization**: Docker & Docker Compose
248
+ * **Secure Networking**: Tailscale
249
+
250
+ #### The Workflow
251
+
252
+ The entire process is event-driven, starting with a simple `git push` and ending with the new version of the application running live, with no manual intervention required.
253
+
254
+ ```mermaid
255
+ graph TD
256
+ subgraph "GitHub (Public Cloud)"
257
+ A[Developer pushes to main branch] --> B{GitHub Actions Trigger};
258
+ end
259
+
260
+ subgraph "Your Proxmox Network (Private & Secure)"
261
+ direction LR
262
+ C(Self-Hosted GitHub Runner VM)
263
+ D(Jenkins VM)
264
+ E(Application VM)
265
+
266
+ C -- "Executes Job Locally" --> F{Test, Build & Push};
267
+ F -- "Pushes images" --> G[GitHub Container Registry];
268
+ F -- "Triggers Deploy via Tailscale IP" --> D;
269
+ D -- "Executes Deploy via SSH" --> E;
270
+ end
271
+
272
+ B -- "Assigns Job" --> C;
273
+ G -- "Images are pulled by App VM" --> E;
274
+
275
+ subgraph "Deployment Steps on Application VM"
276
+ direction TB
277
+ E --> H{1. Jenkins creates .env file from secrets};
278
+ H --> I{2. Pull new Docker images};
279
+ I --> J{3. docker-compose up -d};
280
+ J --> K[🚀 New version is live!];
281
+ end
282
+ ```
283
+
284
+ #### Step-by-Step Breakdown:
285
+
286
+ 1. **Commit & Push**: A developer pushes new code to the `main` branch on the GitHub repository.
287
+ 2. **Job Assignment**: GitHub Actions detects the push and assigns a new workflow job to the registered **self-hosted runner**.
288
+ 3. **CI on Self-Hosted Runner**: The runner, running on a dedicated VM within your private network, picks up the job. It performs the **Continuous Integration** steps locally:
289
+ * Checks out the source code.
290
+ * Sets up the Docker Buildx environment.
291
+ * Builds the `matplobbot-bot` and `matplobbot-api` Docker images.
292
+ * Pushes the newly tagged images to the GitHub Container Registry (GHCR).
293
+ 4. **Secure Trigger for CD**: Upon successful completion of the CI stage, a subsequent step in the same workflow on the self-hosted runner sends a secure webhook (`cURL` request) to the Jenkins server. This communication is safe because the runner and Jenkins are on the same private Tailscale network.
294
+ 5. **Deployment Orchestration**: Jenkins receives the webhook and triggers the `matplobbot-deploy` pipeline. This **Continuous Deployment** pipeline performs the final steps:
295
+ * It securely loads the application's production secrets (like `BOT_TOKEN`) from its encrypted credentials store.
296
+ * It connects to the dedicated **Application VM** via SSH.
297
+ * It dynamically writes the secrets into a `.env` file on the Application VM.
298
+ * It executes the `deploy.sh` script on the Application VM.
299
+ 6. **Final Rollout**: The `deploy.sh` script orchestrates the final rollout by:
300
+ * Pulling the new Docker images from GHCR.
301
+ * Running `docker-compose up -d` to gracefully restart the services with the updated images and configuration.
302
+ * Running `docker compose up -d` to gracefully restart the services with the updated images and configuration.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: matplobbot-shared
3
+ Version: 0.1.48
4
+ Summary: Shared library for the Matplobbot ecosystem (database, services, i18n).
5
+ Author: Ackrome
6
+ Author-email: ivansergeyevich@gmail.com
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: asyncpg
9
+ Requires-Dist: aiohttp
10
+ Requires-Dist: certifi
11
+ Requires-Dist: redis
12
+ Dynamic: author
13
+ Dynamic: author-email
14
+ Dynamic: requires-dist
15
+ Dynamic: requires-python
16
+ Dynamic: summary
@@ -0,0 +1,14 @@
1
+ README.md
2
+ setup.py
3
+ matplobbot_shared.egg-info/PKG-INFO
4
+ matplobbot_shared.egg-info/SOURCES.txt
5
+ matplobbot_shared.egg-info/dependency_links.txt
6
+ matplobbot_shared.egg-info/requires.txt
7
+ matplobbot_shared.egg-info/top_level.txt
8
+ shared_lib/__init__.py
9
+ shared_lib/database.py
10
+ shared_lib/i18n.py
11
+ shared_lib/redis_client.py
12
+ shared_lib/services/__init__.py
13
+ shared_lib/services/schedule_service.py
14
+ shared_lib/services/university_api.py
@@ -0,0 +1,4 @@
1
+ asyncpg
2
+ aiohttp
3
+ certifi
4
+ redis
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="matplobbot-shared",
5
+ version="0.1.48", # Let's use the version from your requirements.txt
6
+ packages=find_packages(include=['shared_lib', 'shared_lib.*']),
7
+ description="Shared library for the Matplobbot ecosystem (database, services, i18n).",
8
+ author="Ackrome",
9
+ author_email="ivansergeyevich@gmail.com",
10
+ # Declare dependencies for this library
11
+ install_requires=[
12
+ "asyncpg",
13
+ "aiohttp", # Specify versions as needed
14
+ "certifi",
15
+ "redis"
16
+ ],
17
+ # This tells setuptools that the package data (like .json files) should be included
18
+ package_data={
19
+ 'shared_lib.locales': ['*.json'],
20
+ },
21
+ include_package_data=True,
22
+ python_requires='>=3.11',
23
+ )
@@ -0,0 +1 @@
1
+ # This file makes shared_lib a Python package
@@ -0,0 +1,608 @@
1
+ import asyncpg
2
+ import datetime
3
+ import logging
4
+ import json
5
+ import os
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # --- PostgreSQL Database Configuration ---
10
+ # The DATABASE_URL should be a complete connection string.
11
+ # Example for Docker: postgresql://user:password@postgres:5432/matplobbot_db
12
+ # Example for local: postgresql://user:password@localhost:5432/matplobbot_db
13
+ DATABASE_URL = os.getenv("DATABASE_URL")
14
+
15
+ # Global connection pool
16
+ pool = None
17
+
18
+ async def init_db_pool():
19
+ global pool
20
+ if pool is None:
21
+ try:
22
+ if not DATABASE_URL:
23
+ logger.critical("DATABASE_URL environment variable is not set. Cannot initialize database pool.")
24
+ raise ValueError("DATABASE_URL is not set.")
25
+ pool = await asyncpg.create_pool(DATABASE_URL, min_size=5, max_size=20)
26
+ logger.info("Shared DB Pool: Database connection pool created successfully.")
27
+ except Exception as e:
28
+ logger.error(f"Failed to create database connection pool: {e}", exc_info=True)
29
+ raise
30
+
31
+ async def close_db_pool():
32
+ global pool
33
+ if pool:
34
+ await pool.close()
35
+ logger.info("Shared DB Pool: Database connection pool closed.")
36
+
37
+ def get_db_connection_obj():
38
+ if pool is None:
39
+ # In FastAPI context, this would be an HTTPException
40
+ raise ConnectionError("Database connection pool is not initialized.")
41
+ return pool.acquire()
42
+
43
+ # --- User Settings Defaults ---
44
+ DEFAULT_SETTINGS = {
45
+ 'show_docstring': True,
46
+ 'latex_padding': 15,
47
+ 'md_display_mode': 'md_file',
48
+ 'latex_dpi': 300,
49
+ 'language': 'en',
50
+ }
51
+
52
+ async def init_db():
53
+ """Initializes the database and creates tables if they don't exist."""
54
+ if pool is None:
55
+ await init_db_pool()
56
+
57
+ async with pool.acquire() as connection:
58
+ async with connection.transaction():
59
+ await connection.execute('''
60
+ CREATE TABLE IF NOT EXISTS users (
61
+ user_id BIGINT PRIMARY KEY,
62
+ username TEXT,
63
+ full_name TEXT NOT NULL,
64
+ avatar_pic_url TEXT,
65
+ settings JSONB DEFAULT '{}'::jsonb,
66
+ onboarding_completed BOOLEAN DEFAULT FALSE
67
+ )
68
+ ''')
69
+ await connection.execute('''
70
+ CREATE TABLE IF NOT EXISTS user_actions (
71
+ id SERIAL PRIMARY KEY,
72
+ user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
73
+ action_type TEXT NOT NULL,
74
+ action_details TEXT,
75
+ timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
76
+ )
77
+ ''')
78
+ await connection.execute('''
79
+ CREATE TABLE IF NOT EXISTS user_favorites (
80
+ id SERIAL PRIMARY KEY,
81
+ user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
82
+ code_path TEXT NOT NULL,
83
+ added_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
84
+ UNIQUE(user_id, code_path)
85
+ )
86
+ ''')
87
+ await connection.execute('''
88
+ CREATE TABLE IF NOT EXISTS latex_cache (
89
+ formula_hash TEXT PRIMARY KEY,
90
+ image_url TEXT NOT NULL,
91
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
92
+ )
93
+ ''')
94
+ await connection.execute('''
95
+ CREATE TABLE IF NOT EXISTS user_github_repos (
96
+ id SERIAL PRIMARY KEY,
97
+ user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
98
+ repo_path TEXT NOT NULL,
99
+ added_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
100
+ UNIQUE(user_id, repo_path)
101
+ )
102
+ ''')
103
+ await connection.execute('''
104
+ CREATE TABLE IF NOT EXISTS user_schedule_subscriptions (
105
+ id SERIAL PRIMARY KEY,
106
+ user_id BIGINT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
107
+ chat_id BIGINT NOT NULL,
108
+ entity_type TEXT NOT NULL,
109
+ entity_id TEXT NOT NULL,
110
+ entity_name TEXT NOT NULL,
111
+ notification_time TIME NOT NULL,
112
+ is_active BOOLEAN DEFAULT TRUE,
113
+ last_schedule_hash TEXT,
114
+ deactivated_at TIMESTAMPTZ DEFAULT NULL,
115
+ message_thread_id BIGINT DEFAULT NULL,
116
+ UNIQUE(chat_id, entity_type, entity_id, notification_time)
117
+ )
118
+ ''')
119
+ await connection.execute('''
120
+ CREATE TABLE IF NOT EXISTS chat_settings (
121
+ chat_id BIGINT PRIMARY KEY,
122
+ settings JSONB DEFAULT '{}'::jsonb
123
+ )
124
+ ''')
125
+
126
+ logger.info("Database tables initialized.")
127
+
128
+ async def log_user_action(user_id: int, username: str | None, full_name: str, avatar_pic_url: str | None, action_type: str, action_details: str | None):
129
+ async with pool.acquire() as connection:
130
+ try:
131
+ await connection.execute('''
132
+ INSERT INTO users (user_id, username, full_name, avatar_pic_url)
133
+ VALUES ($1, $2, $3, $4)
134
+ ON CONFLICT(user_id) DO UPDATE SET
135
+ username = EXCLUDED.username,
136
+ full_name = EXCLUDED.full_name,
137
+ avatar_pic_url = EXCLUDED.avatar_pic_url;
138
+ ''', user_id, username, full_name, avatar_pic_url)
139
+ await connection.execute('''
140
+ INSERT INTO user_actions (user_id, action_type, action_details)
141
+ VALUES ($1, $2, $3);
142
+ ''', user_id, action_type, action_details)
143
+ except Exception as e:
144
+ logger.error(f"Error logging user action to DB: {e}", exc_info=True)
145
+
146
+ async def get_user_settings(user_id: int) -> dict:
147
+ async with pool.acquire() as connection:
148
+ settings_json = await connection.fetchval("SELECT settings FROM users WHERE user_id = $1", user_id)
149
+ db_settings = json.loads(settings_json) if settings_json else {}
150
+ merged_settings = DEFAULT_SETTINGS.copy()
151
+ merged_settings.update(db_settings)
152
+ return merged_settings
153
+
154
+ async def get_chat_settings(chat_id: int) -> dict:
155
+ """Fetches settings for a specific chat, creating a default record if none exists."""
156
+ async with pool.acquire() as connection:
157
+ # Upsert to ensure a row exists for the chat.
158
+ await connection.execute("""
159
+ INSERT INTO chat_settings (chat_id) VALUES ($1)
160
+ ON CONFLICT (chat_id) DO NOTHING;
161
+ """, chat_id)
162
+ settings_json = await connection.fetchval("SELECT settings FROM chat_settings WHERE chat_id = $1", chat_id)
163
+ db_settings = json.loads(settings_json) if settings_json else {}
164
+ # Merge with defaults to ensure all keys are present.
165
+ merged_settings = DEFAULT_SETTINGS.copy()
166
+ merged_settings.update(db_settings)
167
+ return merged_settings
168
+
169
+ async def update_user_settings_db(user_id: int, settings: dict):
170
+ async with pool.acquire() as connection:
171
+ await connection.execute("UPDATE users SET settings = $1 WHERE user_id = $2", json.dumps(settings), user_id)
172
+
173
+ async def update_chat_settings_db(chat_id: int, settings: dict):
174
+ async with pool.acquire() as connection:
175
+ await connection.execute("UPDATE chat_settings SET settings = $1 WHERE chat_id = $2", json.dumps(settings), chat_id)
176
+
177
+ # --- Favorites ---
178
+ async def add_favorite(user_id: int, code_path: str):
179
+ async with pool.acquire() as connection:
180
+ try:
181
+ await connection.execute("INSERT INTO user_favorites (user_id, code_path) VALUES ($1, $2)", user_id, code_path)
182
+ return True
183
+ except asyncpg.UniqueViolationError:
184
+ return False
185
+
186
+ async def remove_favorite(user_id: int, code_path: str):
187
+ async with pool.acquire() as connection:
188
+ await connection.execute("DELETE FROM user_favorites WHERE user_id = $1 AND code_path = $2", user_id, code_path)
189
+
190
+ async def get_favorites(user_id: int) -> list:
191
+ async with pool.acquire() as connection:
192
+ rows = await connection.fetch("SELECT code_path FROM user_favorites WHERE user_id = $1", user_id)
193
+ return [row['code_path'] for row in rows]
194
+
195
+ # --- LaTeX Cache ---
196
+ async def clear_latex_cache():
197
+ async with pool.acquire() as connection:
198
+ await connection.execute("TRUNCATE TABLE latex_cache")
199
+
200
+ # --- GitHub Repos ---
201
+ async def add_user_repo(user_id: int, repo_path: str) -> bool:
202
+ async with pool.acquire() as connection:
203
+ try:
204
+ await connection.execute("INSERT INTO user_github_repos (user_id, repo_path) VALUES ($1, $2)", user_id, repo_path)
205
+ return True
206
+ except asyncpg.UniqueViolationError:
207
+ return False
208
+
209
+ async def get_user_repos(user_id: int) -> list[str]:
210
+ async with pool.acquire() as connection:
211
+ rows = await connection.fetch("SELECT repo_path FROM user_github_repos WHERE user_id = $1 ORDER BY added_at ASC", user_id)
212
+ return [row['repo_path'] for row in rows]
213
+
214
+ async def remove_user_repo(user_id: int, repo_path: str):
215
+ async with pool.acquire() as connection:
216
+ await connection.execute("DELETE FROM user_github_repos WHERE user_id = $1 AND repo_path = $2", user_id, repo_path)
217
+
218
+ async def update_user_repo(user_id: int, old_repo_path: str, new_repo_path: str):
219
+ async with pool.acquire() as connection:
220
+ await connection.execute("UPDATE user_github_repos SET repo_path = $1 WHERE user_id = $2 AND repo_path = $3", new_repo_path, user_id, old_repo_path)
221
+
222
+ # --- Onboarding ---
223
+ async def is_onboarding_completed(user_id: int) -> bool:
224
+ async with pool.acquire() as connection:
225
+ completed = await connection.fetchval("SELECT onboarding_completed FROM users WHERE user_id = $1", user_id)
226
+ return completed or False
227
+
228
+ async def set_onboarding_completed(user_id: int):
229
+ async with pool.acquire() as connection:
230
+ await connection.execute("UPDATE users SET onboarding_completed = TRUE WHERE user_id = $1", user_id)
231
+
232
+ # --- Schedule Subscriptions ---
233
+ async def add_schedule_subscription(user_id: int, chat_id: int, message_thread_id: int | None, entity_type: str, entity_id: str, entity_name: str, notification_time: datetime.time) -> bool:
234
+ async with pool.acquire() as connection:
235
+ try:
236
+ await connection.execute('''
237
+ INSERT INTO user_schedule_subscriptions (user_id, chat_id, message_thread_id, entity_type, entity_id, entity_name, notification_time)
238
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
239
+ ON CONFLICT (chat_id, entity_type, entity_id, notification_time) DO UPDATE SET
240
+ entity_name = EXCLUDED.entity_name,
241
+ -- When the same time is re-added, ensure it's active
242
+ is_active = TRUE,
243
+ -- Also update the user who initiated it, in case it changes
244
+ user_id = EXCLUDED.user_id;
245
+ ''', user_id, chat_id, message_thread_id, entity_type, entity_id, entity_name, notification_time)
246
+ return True
247
+ except Exception as e:
248
+ logger.error(f"Failed to add schedule subscription for user {user_id}: {e}", exc_info=True)
249
+ return False
250
+
251
+ async def get_user_subscriptions(user_id: int, page: int = 0, page_size: int = 5) -> tuple[list, int]:
252
+ """
253
+ Gets a paginated list of active schedule subscriptions for a specific user.
254
+ Returns a tuple: (list of subscriptions, total count).
255
+ """
256
+ if not pool:
257
+ raise ConnectionError("Database pool is not initialized.")
258
+ async with pool.acquire() as connection:
259
+ # Query to get the paginated list
260
+ offset = page * page_size
261
+ rows = await connection.fetch("""
262
+ SELECT id, user_id, chat_id, entity_type, entity_id, entity_name, TO_CHAR(notification_time, 'HH24:MI') as notification_time, is_active
263
+ FROM user_schedule_subscriptions
264
+ WHERE user_id = $1
265
+ ORDER BY entity_name, notification_time
266
+ LIMIT $2 OFFSET $3
267
+ """, user_id, page_size, offset)
268
+
269
+ # Query to get the total count for pagination
270
+ total_count = await connection.fetchval("SELECT COUNT(*) FROM user_schedule_subscriptions WHERE user_id = $1", user_id)
271
+
272
+ return [dict(row) for row in rows], total_count or 0
273
+
274
+ async def get_chat_subscriptions(chat_id: int, page: int = 0, page_size: int = 5) -> tuple[list, int]:
275
+ """
276
+ Gets a paginated list of active schedule subscriptions for a specific chat.
277
+ Returns a tuple: (list of subscriptions, total count).
278
+ """
279
+ if not pool:
280
+ raise ConnectionError("Database pool is not initialized.")
281
+ async with pool.acquire() as connection:
282
+ offset = page * page_size
283
+ rows = await connection.fetch("""
284
+ SELECT id, user_id, chat_id, entity_type, entity_id, entity_name, TO_CHAR(notification_time, 'HH24:MI') as notification_time, is_active
285
+ FROM user_schedule_subscriptions
286
+ WHERE chat_id = $1
287
+ ORDER BY entity_name, notification_time
288
+ LIMIT $2 OFFSET $3
289
+ """, chat_id, page_size, offset)
290
+
291
+ total_count = await connection.fetchval("SELECT COUNT(*) FROM user_schedule_subscriptions WHERE chat_id = $1", chat_id)
292
+
293
+ return [dict(row) for row in rows], total_count or 0
294
+
295
+ async def toggle_subscription_status(subscription_id: int, user_id: int, is_chat_admin: bool = False) -> tuple[bool, str] | None:
296
+ """
297
+ Toggles the is_active status of a subscription, checking for ownership or admin rights.
298
+ Returns a tuple of (new_status, entity_name) on success, otherwise None.
299
+ """
300
+ if not pool:
301
+ raise ConnectionError("Database pool is not initialized.")
302
+ async with pool.acquire() as connection:
303
+ # First, verify the user has permission if they are not a chat admin
304
+ if not is_chat_admin:
305
+ owner_id = await connection.fetchval("SELECT user_id FROM user_schedule_subscriptions WHERE id = $1", subscription_id)
306
+ if owner_id != user_id:
307
+ logger.warning(f"User {user_id} attempted to toggle subscription {subscription_id} without permission.")
308
+ return None
309
+
310
+ # If permission is granted, toggle the status and return the new state
311
+ result = await connection.fetchrow(
312
+ """
313
+ UPDATE user_schedule_subscriptions
314
+ SET is_active = NOT is_active,
315
+ deactivated_at = CASE WHEN is_active THEN NOW() ELSE NULL END
316
+ WHERE id = $1
317
+ RETURNING is_active, entity_name
318
+ """,
319
+ subscription_id
320
+ )
321
+ return (result['is_active'], result['entity_name']) if result else None
322
+
323
+ async def remove_schedule_subscription(subscription_id: int, user_id: int, is_chat_admin: bool = False) -> str | None:
324
+ """
325
+ Removes a specific schedule subscription, checking for ownership or admin rights.
326
+ - If is_chat_admin is False, it only deletes if user_id matches the creator.
327
+ - If is_chat_admin is True, it deletes regardless of the creator.
328
+ Returns the entity_name of the deleted subscription on success, otherwise None.
329
+ """
330
+ if not pool:
331
+ raise ConnectionError("Database pool is not initialized.")
332
+ async with pool.acquire() as connection:
333
+ if is_chat_admin:
334
+ # Admin can delete any subscription in their chat.
335
+ deleted_name = await connection.fetchval(
336
+ "DELETE FROM user_schedule_subscriptions WHERE id = $1 RETURNING entity_name",
337
+ subscription_id)
338
+ else:
339
+ # Regular user can only delete their own subscriptions.
340
+ deleted_name = await connection.fetchval(
341
+ "DELETE FROM user_schedule_subscriptions WHERE id = $1 AND user_id = $2 RETURNING entity_name",
342
+ subscription_id, user_id)
343
+ return deleted_name
344
+
345
+ async def update_subscription_notification_time(subscription_id: int, new_time: datetime.time, user_id: int, is_chat_admin: bool = False) -> str | None:
346
+ """
347
+ Updates the notification time for a specific subscription, checking for ownership or admin rights.
348
+ Returns the entity_name of the updated subscription on success, otherwise None.
349
+ """
350
+ if not pool:
351
+ raise ConnectionError("Database pool is not initialized.")
352
+ async with pool.acquire() as connection:
353
+ if is_chat_admin:
354
+ # Admin can update any subscription in their chat.
355
+ updated_name = await connection.fetchval(
356
+ "UPDATE user_schedule_subscriptions SET notification_time = $1 WHERE id = $2 RETURNING entity_name",
357
+ new_time, subscription_id
358
+ )
359
+ else:
360
+ # Regular user can only update their own subscriptions.
361
+ updated_name = await connection.fetchval(
362
+ "UPDATE user_schedule_subscriptions SET notification_time = $1 WHERE id = $2 AND user_id = $3 RETURNING entity_name",
363
+ new_time, subscription_id, user_id
364
+ )
365
+ return updated_name
366
+
367
+
368
+ async def delete_old_inactive_subscriptions(days_inactive: int = 30):
369
+ """
370
+ Deletes subscriptions that have been inactive for more than a specified number of days.
371
+ Returns the number of deleted subscriptions.
372
+ """
373
+ if not pool:
374
+ raise ConnectionError("Database pool is not initialized.")
375
+ async with pool.acquire() as connection:
376
+ result = await connection.execute(
377
+ "DELETE FROM user_schedule_subscriptions WHERE is_active = FALSE AND deactivated_at < NOW() - INTERVAL '$1 days'",
378
+ str(days_inactive)
379
+ )
380
+ return int(result.split(' ')[-1]) # Returns "DELETE N", we want N
381
+
382
+ async def get_subscriptions_for_notification(notification_time: str) -> list:
383
+ async with pool.acquire() as connection:
384
+ # Modified to select the subscription ID and the last hash
385
+ # Also select chat_id to know where to send the message
386
+ rows = await connection.fetch("""
387
+ SELECT id, user_id, chat_id, message_thread_id, entity_type, entity_id, entity_name, last_schedule_hash
388
+ FROM user_schedule_subscriptions
389
+ WHERE is_active = TRUE AND TO_CHAR(notification_time, 'HH24:MI') = $1
390
+ """, notification_time)
391
+ return [dict(row) for row in rows]
392
+
393
+ async def get_all_active_subscriptions() -> list:
394
+ """Fetches all active schedule subscriptions from the database."""
395
+ if not pool:
396
+ raise ConnectionError("Database pool is not initialized.")
397
+ async with pool.acquire() as connection:
398
+ rows = await connection.fetch("""
399
+ SELECT id, user_id, chat_id, message_thread_id, entity_type, entity_id, entity_name, last_schedule_hash
400
+ FROM user_schedule_subscriptions WHERE is_active = TRUE
401
+ """)
402
+ return [dict(row) for row in rows]
403
+
404
+ async def update_subscription_hash(subscription_id: int, new_hash: str):
405
+ """Updates the schedule hash for a specific subscription."""
406
+ if not pool:
407
+ raise ConnectionError("Database pool is not initialized.")
408
+ async with pool.acquire() as connection:
409
+ await connection.execute("UPDATE user_schedule_subscriptions SET last_schedule_hash = $1 WHERE id = $2", new_hash, subscription_id)
410
+
411
+ # --- FastAPI Specific Queries ---
412
+ async def get_leaderboard_data_from_db(db_conn):
413
+ # The timestamp is stored in UTC (TIMESTAMPTZ). We convert it to Moscow time for display.
414
+ query = """
415
+ SELECT
416
+ u.user_id,
417
+ u.full_name,
418
+ COALESCE(u.username, 'N/A') AS username,
419
+ u.avatar_pic_url,
420
+ COUNT(ua.id)::int AS actions_count,
421
+ TO_CHAR(MAX(ua.timestamp AT TIME ZONE 'Europe/Moscow'), 'YYYY-MM-DD HH24:MI:SS') AS last_action_time
422
+ FROM users u
423
+ JOIN user_actions ua ON u.user_id = ua.user_id
424
+ GROUP BY u.user_id, u.full_name, u.username, u.avatar_pic_url
425
+ ORDER BY actions_count DESC LIMIT 100;
426
+ """
427
+ rows = await db_conn.fetch(query)
428
+ return [dict(row) for row in rows]
429
+
430
+ async def get_popular_commands_data_from_db(db_conn):
431
+ query = """
432
+ SELECT action_details as command, COUNT(id) as command_count FROM user_actions
433
+ WHERE action_type = 'command' GROUP BY action_details ORDER BY command_count DESC LIMIT 10;
434
+ """
435
+ rows = await db_conn.fetch(query)
436
+ return [{"command": row['command'], "count": row['command_count']} for row in rows]
437
+
438
+ async def get_popular_messages_data_from_db(db_conn):
439
+ query = """
440
+ SELECT CASE WHEN LENGTH(action_details) > 30 THEN SUBSTR(action_details, 1, 27) || '...' ELSE action_details END as message_snippet,
441
+ COUNT(id) as message_count FROM user_actions
442
+ WHERE action_type = 'text_message' AND action_details IS NOT NULL AND action_details != ''
443
+ GROUP BY message_snippet ORDER BY message_count DESC LIMIT 10;
444
+ """
445
+ rows = await db_conn.fetch(query)
446
+ return [{"message": row['message_snippet'], "count": row['message_count']} for row in rows]
447
+
448
+ async def get_action_types_distribution_from_db(db_conn):
449
+ query = "SELECT action_type, COUNT(id) as type_count FROM user_actions GROUP BY action_type ORDER BY type_count DESC;"
450
+ rows = await db_conn.fetch(query)
451
+ return [{"action_type": row['action_type'], "count": row['type_count']} for row in rows]
452
+
453
+ async def get_activity_over_time_data_from_db(db_conn, period='day'):
454
+ date_format = {'day': 'YYYY-MM-DD', 'week': 'IYYY-IW', 'month': 'YYYY-MM'}.get(period, 'YYYY-MM-DD')
455
+ # Convert timestamp to Moscow time before grouping
456
+ query = f"""
457
+ SELECT TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', '{date_format}') as period_start, COUNT(id) as actions_count
458
+ FROM user_actions GROUP BY period_start ORDER BY period_start ASC;
459
+ """
460
+ rows = await db_conn.fetch(query)
461
+ return [{"period": row['period_start'], "count": row['actions_count']} for row in rows]
462
+
463
+
464
+ async def get_user_profile_data_from_db(
465
+ db_conn,
466
+ user_id: int,
467
+ page: int = 1,
468
+ page_size: int = 50,
469
+ sort_by: str = 'timestamp',
470
+ sort_order: str = 'desc'
471
+ ):
472
+ """Извлекает детали профиля пользователя и пагинированный список его действий."""
473
+ # --- Безопасная сортировка ---
474
+ allowed_sort_columns = ['id', 'action_type', 'action_details', 'timestamp'] # These are ua columns
475
+ if sort_by not in allowed_sort_columns:
476
+ sort_by = 'timestamp' # Значение по умолчанию
477
+ sort_order = 'ASC' if sort_order.lower() == 'asc' else 'DESC' # Безопасное определение порядка
478
+
479
+ # --- Единый запрос для получения всех данных ---
480
+ # Используем CTE и оконные функции для эффективности.
481
+ # 1. Выбираем все действия пользователя.
482
+ # 2. С помощью оконной функции COUNT(*) OVER () получаем общее количество действий без дополнительного запроса.
483
+ # 3. Присоединяем информацию о пользователе.
484
+ # 4. Применяем пагинацию и сортировку.
485
+ query = f"""
486
+ WITH UserActions AS (
487
+ SELECT
488
+ id,
489
+ action_type,
490
+ action_details,
491
+ TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', 'YYYY-MM-DD HH24:MI:SS') AS timestamp
492
+ FROM user_actions
493
+ WHERE user_id = $1
494
+ )
495
+ SELECT
496
+ u.user_id,
497
+ u.full_name,
498
+ COALESCE(u.username, 'Нет username') AS username,
499
+ u.avatar_pic_url,
500
+ (SELECT COUNT(*) FROM UserActions) as total_actions,
501
+ ua.id as action_id,
502
+ ua.action_type,
503
+ ua.action_details,
504
+ ua.timestamp
505
+ FROM users u
506
+ LEFT JOIN UserActions ua ON 1=1
507
+ WHERE u.user_id = $2
508
+ ORDER BY ua.{sort_by} {sort_order}
509
+ LIMIT $3 OFFSET $4;
510
+ """
511
+ offset = (page - 1) * page_size
512
+ rows = await db_conn.fetch(query, user_id, user_id, page_size, offset)
513
+ if not rows:
514
+ # If user exists but has no actions, we might get no rows. Check user existence separately.
515
+ user_exists = await db_conn.fetchrow("SELECT 1 FROM users WHERE user_id = $1", user_id)
516
+ if not user_exists:
517
+ return None # User not found
518
+ # User exists but has no actions, return empty actions list
519
+ user_details_row = await db_conn.fetchrow("SELECT user_id, full_name, COALESCE(username, 'Нет username') AS username, avatar_pic_url FROM users WHERE user_id = $1", user_id)
520
+ return {
521
+ "user_details": dict(user_details_row),
522
+ "actions": [],
523
+ "total_actions": 0
524
+ }
525
+
526
+ first_row = dict(rows[0])
527
+ user_details = {
528
+ "user_id": first_row["user_id"],
529
+ "full_name": first_row["full_name"],
530
+ "username": first_row["username"],
531
+ "avatar_pic_url": first_row["avatar_pic_url"]
532
+ }
533
+ total_actions = first_row["total_actions"]
534
+
535
+ # Собираем действия, если они есть (может быть пользователь без действий)
536
+ actions = []
537
+ for row in rows:
538
+ row_dict = dict(row)
539
+ if row_dict["action_id"] is not None: # action_id не NULL
540
+ actions.append({
541
+ "id": row_dict["action_id"],
542
+ "action_type": row_dict["action_type"],
543
+ "action_details": row_dict["action_details"],
544
+ "timestamp": row_dict["timestamp"]
545
+ })
546
+
547
+ return {
548
+ "user_details": user_details,
549
+ "actions": actions,
550
+ "total_actions": total_actions
551
+ }
552
+
553
+ async def get_users_for_action(db_conn, action_type: str, action_details: str, page: int = 1, page_size: int = 15, sort_by: str = 'full_name', sort_order: str = 'asc'):
554
+ """Извлекает пагинированный список уникальных пользователей, совершивших определенное действие."""
555
+ # Note: action_type for messages is 'text_message' in the DB
556
+ db_action_type = 'text_message' if action_type == 'message' else action_type
557
+ offset = (page - 1) * page_size
558
+
559
+ # --- Безопасная сортировка ---
560
+ allowed_sort_columns = ['user_id', 'full_name', 'username'] # These are u columns
561
+ if sort_by not in allowed_sort_columns:
562
+ sort_by = 'full_name' # Значение по умолчанию
563
+ sort_order = 'DESC' if sort_order.lower() == 'desc' else 'ASC' # Безопасное определение порядка
564
+ order_by_clause = f"ORDER BY u.{sort_by} {sort_order}"
565
+
566
+ # Query for total count of distinct users
567
+ count_query = """
568
+ SELECT COUNT(DISTINCT u.user_id)
569
+ FROM users u
570
+ JOIN user_actions ua ON u.user_id = ua.user_id
571
+ WHERE ua.action_type = $1 AND ua.action_details = $2;
572
+ """
573
+ total_users = await db_conn.fetchval(count_query, db_action_type, action_details)
574
+
575
+ # Query for the paginated list of users
576
+ users_query = f"""
577
+ SELECT DISTINCT
578
+ u.user_id,
579
+ u.full_name,
580
+ COALESCE(u.username, 'Нет username') AS username
581
+ FROM users u
582
+ JOIN user_actions ua ON u.user_id = ua.user_id
583
+ WHERE ua.action_type = $1 AND ua.action_details = $2
584
+ {order_by_clause}
585
+ LIMIT $3 OFFSET $4;
586
+ """
587
+ rows = await db_conn.fetch(users_query, db_action_type, action_details, page_size, offset)
588
+ users = [dict(row) for row in rows]
589
+
590
+ return {
591
+ "users": users,
592
+ "total_users": total_users
593
+ }
594
+
595
+ async def get_all_user_actions(db_conn, user_id: int):
596
+ """Извлекает ВСЕ действия для указанного пользователя без пагинации."""
597
+ query = """
598
+ SELECT
599
+ id,
600
+ action_type,
601
+ action_details,
602
+ TO_CHAR(timestamp AT TIME ZONE 'Europe/Moscow', 'YYYY-MM-DD HH24:MI:SS') AS timestamp
603
+ FROM user_actions
604
+ WHERE user_id = $1
605
+ ORDER BY timestamp DESC;
606
+ """
607
+ rows = await db_conn.fetch(query, user_id)
608
+ return [dict(row) for row in rows]
@@ -0,0 +1,56 @@
1
+ import json
2
+ from pathlib import Path
3
+ import logging
4
+
5
+ from .database import get_user_settings, get_chat_settings
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class Translator:
11
+ def __init__(self, locales_dir: Path, default_lang: str = "en"):
12
+ self.locales_dir = locales_dir
13
+ self.default_lang = default_lang
14
+ self.translations = {}
15
+ self._load_translations()
16
+
17
+ def _load_translations(self):
18
+ """Loads all .json language files from the locales directory."""
19
+ for lang_file in self.locales_dir.glob("*.json"):
20
+ lang_code = lang_file.stem
21
+ try:
22
+ with open(lang_file, 'r', encoding='utf-8') as f:
23
+ self.translations[lang_code] = json.load(f)
24
+ logger.info(f"Successfully loaded language: {lang_code}")
25
+ except (json.JSONDecodeError, IOError) as e:
26
+ logger.error(f"Failed to load language file {lang_file}: {e}")
27
+
28
+ async def get_language(self, user_id: int, chat_id: int | None = None) -> str:
29
+ """
30
+ Fetches the appropriate language.
31
+ - If chat_id is provided and it's a group chat, it uses the group's setting.
32
+ - Otherwise, it falls back to the user's personal setting.
33
+ """
34
+ # If it's a group chat (negative chat_id), check for a group-specific setting.
35
+ if chat_id and chat_id < 0:
36
+ chat_settings = await get_chat_settings(chat_id)
37
+ return chat_settings.get('language', self.default_lang)
38
+
39
+ user_settings = await get_user_settings(user_id)
40
+ return user_settings.get('language', self.default_lang)
41
+
42
+ def gettext(self, lang: str, key: str, **kwargs) -> str:
43
+ """
44
+ Gets a translated string for a given key and language.
45
+ Falls back to the default language if the key is not found.
46
+ """
47
+ text = self.translations.get(lang, {}).get(key)
48
+ if text is None:
49
+ # Fallback to default language
50
+ text = self.translations.get(self.default_lang, {}).get(key, f"_{key}_")
51
+
52
+ return text.format(**kwargs)
53
+
54
+
55
+ # Create a single instance of the translator
56
+ translator = Translator(locales_dir=Path(__file__).parent / "locales")
@@ -0,0 +1,46 @@
1
+ import redis.asyncio as redis
2
+ import json
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ # TTL для кэша в секундах (например, 1 час)
8
+ CACHE_TTL = 3600
9
+
10
+ class RedisClient:
11
+ def __init__(self, host='localhost', port=6379):
12
+ # Используем connection_pool для более эффективного управления соединениями
13
+ self.pool = redis.ConnectionPool(host=host, port=port, db=0, decode_responses=True)
14
+ self.client = redis.Redis(connection_pool=self.pool)
15
+
16
+ async def set_user_cache(self, user_id: int, key: str, data: dict, ttl: int = CACHE_TTL):
17
+ """Сохраняет данные в кэш для конкретного пользователя."""
18
+ try:
19
+ redis_key = f"user_cache:{user_id}:{key}"
20
+ await self.client.set(redis_key, json.dumps(data), ex=ttl)
21
+ except Exception as e:
22
+ logger.error(f"Ошибка при записи в Redis для user_id={user_id}, key={key}: {e}")
23
+
24
+ async def get_user_cache(self, user_id: int, key: str) -> dict | None:
25
+ """Получает данные из кэша для конкретного пользователя."""
26
+ try:
27
+ redis_key = f"user_cache:{user_id}:{key}"
28
+ data = await self.client.get(redis_key)
29
+ if data:
30
+ return json.loads(data)
31
+ return None
32
+ except Exception as e:
33
+ logger.error(f"Ошибка при чтении из Redis для user_id={user_id}, key={key}: {e}")
34
+ return None
35
+
36
+ async def clear_all_user_cache(self):
37
+ """Очищает весь пользовательский кэш (ключи, начинающиеся с 'user_cache:')."""
38
+ try:
39
+ async for key in self.client.scan_iter("user_cache:*"):
40
+ await self.client.delete(key)
41
+ logger.info("Весь пользовательский кэш в Redis очищен.")
42
+ except Exception as e:
43
+ logger.error(f"Ошибка при очистке кэша Redis: {e}")
44
+
45
+ # Создаем единственный экземпляр клиента
46
+ redis_client = RedisClient(host='redis')
@@ -0,0 +1,154 @@
1
+ # bot/services/schedule_service.py
2
+
3
+ from typing import List, Dict, Any
4
+ from datetime import datetime, date
5
+ from collections import defaultdict
6
+ from ics import Calendar, Event
7
+ from zoneinfo import ZoneInfo
8
+ from aiogram.utils.markdown import hcode
9
+
10
+ from shared_lib.i18n import translator
11
+
12
+ names_shorter = defaultdict(lambda: 'Unknown')
13
+ to_add = {
14
+ 'Практические (семинарские) занятия': 'Семинар',
15
+ 'Лекции': 'Лекция',
16
+ 'Консультации текущие': 'Консультация',
17
+ 'Повторная промежуточная аттестация (экзамен)':'Пересдача'
18
+ }
19
+ names_shorter.update(to_add)
20
+
21
+ def _format_lesson_for_diff(lesson: Dict[str, Any], lang: str) -> str:
22
+ """Formats a single lesson for display in a diff message."""
23
+ date_obj = datetime.strptime(lesson['date'], "%Y-%m-%d").date()
24
+ day_header = f"<b>{date_obj.strftime('%A, %d.%m.%Y')}</b>"
25
+ details = [
26
+ hcode(f"{lesson['beginLesson']} - {lesson['endLesson']} | {lesson['auditorium']}"),
27
+ f"{lesson['discipline']} ({names_shorter[lesson['kindOfWork']]})",
28
+ f"<i>{translator.gettext(lang, 'lecturer_prefix')}: {lesson.get('lecturer_title', 'N/A').replace('_', ' ')}</i>"
29
+ ]
30
+ return f"{day_header}\n" + "\n".join(details)
31
+
32
+ def diff_schedules(old_data: List[Dict[str, Any]], new_data: List[Dict[str, Any]], lang: str) -> str | None:
33
+ """Compares two schedule datasets and returns a human-readable diff."""
34
+ old_lessons = {lesson['lessonOid']: lesson for lesson in old_data}
35
+ new_lessons = {lesson['lessonOid']: lesson for lesson in new_data}
36
+
37
+ added = [lesson for oid, lesson in new_lessons.items() if oid not in old_lessons]
38
+ removed = [lesson for oid, lesson in old_lessons.items() if oid not in new_lessons]
39
+ modified = []
40
+
41
+ # Fields to check for modifications
42
+ fields_to_check = ['beginLesson', 'endLesson', 'auditorium', 'lecturer_title']
43
+
44
+ for oid, old_lesson in old_lessons.items():
45
+ if oid in new_lessons:
46
+ new_lesson = new_lessons[oid]
47
+ changes = {}
48
+ for field in fields_to_check:
49
+ if old_lesson.get(field) != new_lesson.get(field):
50
+ changes[field] = (old_lesson.get(field), new_lesson.get(field))
51
+ if changes:
52
+ modified.append({'new': new_lesson, 'changes': changes})
53
+
54
+ if not added and not removed and not modified:
55
+ return None
56
+
57
+ diff_parts = []
58
+ if added: diff_parts.append(f"<b>{translator.gettext(lang, 'schedule_change_added')}:</b>\n" + "\n\n".join([_format_lesson_for_diff(l, lang) for l in added]))
59
+ if removed: diff_parts.append(f"<b>{translator.gettext(lang, 'schedule_change_removed')}:</b>\n" + "\n\n".join([_format_lesson_for_diff(l, lang) for l in removed]))
60
+ if modified:
61
+ modified_texts = []
62
+ for mod in modified:
63
+ change_descs = [f"<i>{translator.gettext(lang, f'field_{f}')}: {hcode(v[0])} → {hcode(v[1])}</i>" for f, v in mod['changes'].items()]
64
+ modified_texts.append(f"{_format_lesson_for_diff(mod['new'], lang)}\n" + "\n".join(change_descs))
65
+ diff_parts.append(f"<b>{translator.gettext(lang, 'schedule_change_modified')}:</b>\n" + "\n\n".join(modified_texts))
66
+
67
+ return "\n\n---\n\n".join(diff_parts)
68
+
69
+
70
+ def format_schedule(schedule_data: List[Dict[str, Any]], lang: str, entity_name: str, entity_type: str, start_date: date, is_week_view: bool = False) -> str:
71
+ """Formats a list of lessons into a readable daily schedule."""
72
+ if not schedule_data:
73
+ # Different message for single day vs week
74
+ no_lessons_key = "schedule_no_lessons_week" if is_week_view else "schedule_no_lessons_day" # This was Russian text
75
+ return translator.gettext(lang, "schedule_header_for", entity_name=entity_name) + f"\n\n{translator.gettext(lang, no_lessons_key)}"
76
+
77
+ # Group lessons by date
78
+ days = defaultdict(list)
79
+ for lesson in schedule_data:
80
+ days[lesson['date']].append(lesson)
81
+
82
+ formatted_days = []
83
+ # Iterate through sorted dates to build the full schedule string
84
+ for date_str, lessons in sorted(days.items()):
85
+ date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
86
+
87
+ # --- LOCALIZATION FIX ---
88
+ day_of_week = translator.gettext(lang, f"day_{date_obj.weekday()}") # e.g., day_0 for Monday
89
+ month_name = translator.gettext(lang, f"month_{date_obj.month-1}_gen") # Genitive case for dates
90
+ day_header = f"<b>{day_of_week}, {date_obj.day} {month_name} {date_obj.year}</b>"
91
+
92
+ formatted_lessons = []
93
+ for lesson in sorted(lessons, key=lambda x: x['beginLesson']):
94
+ lesson_details = [
95
+ hcode(f"{lesson['beginLesson']} - {lesson['endLesson']} | {lesson['auditorium']}"),
96
+ f"{lesson['discipline']} | {names_shorter[lesson['kindOfWork']]}"
97
+ ]
98
+
99
+ if entity_type == 'group':
100
+ lecturer_info = [lesson['lecturer_title'].replace('_',' ')]
101
+ if lesson.get('lecturerEmail'):
102
+ lecturer_info.append(lesson['lecturerEmail'])
103
+ lesson_details.append("\n".join(lecturer_info))
104
+ elif entity_type == 'person': # Lecturer
105
+ lesson_details.append(f" {lesson.get('group', 'Группа не указана')}")
106
+ elif entity_type == 'auditorium':
107
+ lecturer_info = [f"{lesson.get('group', 'Группа не указана')} | {lesson['lecturer_title'].replace('_',' ')}"]
108
+ if lesson.get('lecturerEmail'):
109
+ lecturer_info.append(lesson['lecturerEmail'])
110
+ lesson_details.append("\n".join(lecturer_info))
111
+ else: # Fallback to a generic format
112
+ lesson_details.append(f"{lesson['lecturer_title'].replace('_',' ')}")
113
+
114
+ formatted_lessons.append("\n".join(lesson_details))
115
+
116
+ formatted_days.append(f"{day_header}\n" + "\n\n".join(formatted_lessons))
117
+
118
+ main_header = translator.gettext(lang, "schedule_header_for", entity_name=entity_name)
119
+ return f"{main_header}\n\n" + "\n\n---\n\n".join(formatted_days)
120
+
121
+ def generate_ical_from_schedule(schedule_data: List[Dict[str, Any]], entity_name: str) -> str:
122
+ """
123
+ Generates an iCalendar (.ics) file string from schedule data.
124
+ """
125
+ cal = Calendar()
126
+ moscow_tz = ZoneInfo("Europe/Moscow")
127
+
128
+ if not schedule_data:
129
+ return cal.serialize()
130
+
131
+ for lesson in schedule_data:
132
+ try:
133
+ event = Event()
134
+ event.name = f"{lesson['discipline']} ({names_shorter[lesson['kindOfWork']]})"
135
+
136
+ lesson_date = datetime.strptime(lesson['date'], "%Y-%m-%d").date()
137
+ start_time = time.fromisoformat(lesson['beginLesson'])
138
+ end_time = time.fromisoformat(lesson['endLesson'])
139
+
140
+ event.begin = datetime.combine(lesson_date, start_time, tzinfo=moscow_tz)
141
+ event.end = datetime.combine(lesson_date, end_time, tzinfo=moscow_tz)
142
+
143
+ event.location = f"{lesson['auditorium']}, {lesson['building']}"
144
+
145
+ description_parts = [f"Преподаватель: {lesson['lecturer_title'].replace('_',' ')}"]
146
+ if 'group' in lesson: description_parts.append(f"Группа: {lesson['group']}")
147
+ event.description = "\n".join(description_parts)
148
+
149
+ cal.events.add(event)
150
+ except (ValueError, KeyError) as e:
151
+ logging.warning(f"Skipping lesson due to parsing error: {e}. Lesson data: {lesson}")
152
+ continue
153
+
154
+ return cal.serialize()
@@ -0,0 +1,48 @@
1
+ import aiohttp
2
+ from datetime import datetime
3
+ from typing import List, Dict, Any
4
+ import ssl
5
+ import certifi
6
+
7
+ class RuzAPIError(Exception):
8
+ """Custom exception for RUZ API errors."""
9
+ pass
10
+
11
+ class RuzAPIClient:
12
+ """Asynchronous API client for ruz.fa.ru."""
13
+ def __init__(self, session: aiohttp.ClientSession):
14
+ self.HOST = "https://ruz.fa.ru"
15
+ self.session = session
16
+
17
+ async def _request(self, sub_url: str) -> Dict[str, Any] | List[Dict[str, Any]]:
18
+ """Performs an asynchronous request to the RUZ API."""
19
+ full_url = self.HOST + sub_url
20
+ # Create an SSL context that uses the certifi bundle for verification.
21
+ # This is more reliable than the system's default trust store, especially in Docker.
22
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
23
+
24
+ async with self.session.get(full_url, ssl=ssl_context) as response:
25
+ if response.status == 200:
26
+ json_response = await response.json()
27
+ # The search API can return an empty object {} instead of an empty list [].
28
+ # We normalize this to always return a list for list-based endpoints.
29
+ return json_response if isinstance(json_response, list) else []
30
+ error_text = await response.text()
31
+ raise RuzAPIError(
32
+ f"RUZ API Error: Status {response.status} for URL {full_url}. Response: {error_text}"
33
+ )
34
+
35
+ async def search(self, term: str, search_type: str) -> List[Dict[str, Any]]:
36
+ """Generic search function."""
37
+ return await self._request(f"/api/search?term={term}&type={search_type}")
38
+
39
+ async def get_schedule(self, entity_type: str, entity_id: str, start: str, finish: str) -> List[Dict[str, Any]]:
40
+ """Generic function to get a schedule."""
41
+ return await self._request(f"/api/schedule/{entity_type}/{entity_id}?start={start}&finish={finish}&lng=1")
42
+
43
+ def create_ruz_api_client(session: aiohttp.ClientSession) -> RuzAPIClient:
44
+ """
45
+ Creates and returns a RuzAPIClient instance.
46
+ This function is intended to be called once during application startup.
47
+ """
48
+ return RuzAPIClient(session)