Compare commits
10 commits
a441253b89
...
c48998e085
| Author | SHA1 | Date | |
|---|---|---|---|
| c48998e085 | |||
| 2338e5666a | |||
| 282b066fcf | |||
| a8723bb3ee | |||
| 88c4c84dec | |||
| c3332572c0 | |||
| 3f80530786 | |||
| 260ce5dfd3 | |||
| 453c657681 | |||
| eabb394368 |
7 changed files with 115 additions and 32 deletions
|
|
@ -30,6 +30,10 @@ steps:
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- "sha-${CI_COMMIT_SHA:0:8}"
|
- "sha-${CI_COMMIT_SHA:0:8}"
|
||||||
|
cache_from: "git.ihateamerica.ru/dv/intrabot:latest"
|
||||||
|
build_args:
|
||||||
|
- "GIT_COMMIT=${CI_COMMIT_SHA:0:8}"
|
||||||
|
- "BUILDKIT_INLINE_CACHE=1"
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
|
|
@ -50,6 +54,10 @@ steps:
|
||||||
- "${CI_COMMIT_TAG}" # v1.2.3
|
- "${CI_COMMIT_TAG}" # v1.2.3
|
||||||
- latest
|
- latest
|
||||||
- "sha-${CI_COMMIT_SHA:0:8}" # всегда — нужно для отката
|
- "sha-${CI_COMMIT_SHA:0:8}" # всегда — нужно для отката
|
||||||
|
cache_from: "git.ihateamerica.ru/dv/intrabot:latest"
|
||||||
|
build_args:
|
||||||
|
- "GIT_COMMIT=${CI_COMMIT_SHA:0:8}"
|
||||||
|
- "BUILDKIT_INLINE_CACHE=1"
|
||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ RUN mkdir -p /app/data
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
ARG GIT_COMMIT=dev
|
||||||
|
ARG DOCKER_IMAGE_CREATED=unknown
|
||||||
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
ENV BUILD_TIME=$DOCKER_IMAGE_CREATED
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Продакшен: Gunicorn (gthread, 1 worker) обслуживает Flask.
|
# Продакшен: Gunicorn (gthread, 1 worker) обслуживает Flask.
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,29 @@
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _timed(label: str):
|
||||||
|
"""Context manager: логирует старт и завершение операции с временем выполнения."""
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _cm():
|
||||||
|
log.info("⏳ %s ...", label)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
log.info("✅ %s — %.2fs", label, time.monotonic() - t0)
|
||||||
|
|
||||||
|
return _cm()
|
||||||
|
|
||||||
|
|
||||||
# ── Агностичное представление клавиатуры ──────────────────────────────────────
|
# ── Агностичное представление клавиатуры ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -101,15 +121,18 @@ class TelegramChannelNotifier(Notifier):
|
||||||
# ── Реализация интерфейса ──────────────────────────────────────────────────
|
# ── Реализация интерфейса ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
|
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
|
||||||
|
with _timed(f"send_message → channel {self._channel_id}"):
|
||||||
msg = self._bot.send_message(
|
msg = self._bot.send_message(
|
||||||
self._channel_id, text,
|
self._channel_id, text,
|
||||||
parse_mode="Markdown",
|
parse_mode="Markdown",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
reply_markup=self._to_markup(keyboard),
|
reply_markup=self._to_markup(keyboard),
|
||||||
)
|
)
|
||||||
|
log.info("send_message OK → message_id=%s", msg.message_id)
|
||||||
return msg.message_id
|
return msg.message_id
|
||||||
|
|
||||||
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
|
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
|
||||||
|
with _timed(f"edit_message_text → message_id={message_id}"):
|
||||||
self._bot.edit_message_text(
|
self._bot.edit_message_text(
|
||||||
text, self._channel_id, message_id,
|
text, self._channel_id, message_id,
|
||||||
parse_mode="Markdown",
|
parse_mode="Markdown",
|
||||||
|
|
@ -118,22 +141,26 @@ class TelegramChannelNotifier(Notifier):
|
||||||
)
|
)
|
||||||
|
|
||||||
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
|
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
|
||||||
|
with _timed(f"edit_message_reply_markup → message_id={message_id}"):
|
||||||
self._bot.edit_message_reply_markup(
|
self._bot.edit_message_reply_markup(
|
||||||
self._channel_id, message_id,
|
self._channel_id, message_id,
|
||||||
reply_markup=self._to_markup(keyboard),
|
reply_markup=self._to_markup(keyboard),
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, message_id: int):
|
def delete(self, message_id: int):
|
||||||
|
with _timed(f"delete_message → message_id={message_id}"):
|
||||||
try:
|
try:
|
||||||
self._bot.delete_message(self._channel_id, message_id)
|
self._bot.delete_message(self._channel_id, message_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
log.warning("delete_message failed (message_id=%s): %s", message_id, e)
|
||||||
|
|
||||||
def send_animation(self, url: str, disable_notification: bool = True) -> int:
|
def send_animation(self, url: str, disable_notification: bool = True) -> int:
|
||||||
|
with _timed(f"send_animation → channel {self._channel_id}"):
|
||||||
msg = self._bot.send_animation(
|
msg = self._bot.send_animation(
|
||||||
self._channel_id, url,
|
self._channel_id, url,
|
||||||
disable_notification=disable_notification,
|
disable_notification=disable_notification,
|
||||||
)
|
)
|
||||||
|
log.info("send_animation OK → message_id=%s", msg.message_id)
|
||||||
return msg.message_id
|
return msg.message_id
|
||||||
|
|
||||||
def is_stale_error(self, exc: Exception) -> bool:
|
def is_stale_error(self, exc: Exception) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database
|
import database
|
||||||
|
|
@ -50,7 +51,10 @@ def recreate_post(
|
||||||
Изменения (• ...) дедуплицируются по тексту.
|
Изменения (• ...) дедуплицируются по тексту.
|
||||||
Комментарии (💬 ...) и описания (📝 ...) добавляются всегда.
|
Комментарии (💬 ...) и описания (📝 ...) добавляются всегда.
|
||||||
"""
|
"""
|
||||||
|
t0 = time.monotonic()
|
||||||
|
log.info("recreate_post START task=%s", task_id)
|
||||||
notifier = get_notifier()
|
notifier = get_notifier()
|
||||||
|
|
||||||
stored = database.get_stored_events(task_id)
|
stored = database.get_stored_events(task_id)
|
||||||
stored_set = set(stored)
|
stored_set = set(stored)
|
||||||
|
|
||||||
|
|
@ -77,8 +81,7 @@ def recreate_post(
|
||||||
})
|
})
|
||||||
|
|
||||||
old = database.get_message_data(task_id)
|
old = database.get_message_data(task_id)
|
||||||
if old:
|
log.info("recreate_post task=%s old_msg=%s", task_id, old["message_id"] if old else None)
|
||||||
notifier.delete(old["message_id"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message_id = notifier.send(full_text, keyboard)
|
message_id = notifier.send(full_text, keyboard)
|
||||||
|
|
@ -87,6 +90,12 @@ def recreate_post(
|
||||||
database.save_stored_events(task_id, stored)
|
database.save_stored_events(task_id, stored)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"recreate_post failed for task {task_id}: {e}")
|
log.error(f"recreate_post failed for task {task_id}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if old:
|
||||||
|
notifier.delete(old["message_id"])
|
||||||
|
|
||||||
|
log.info("recreate_post DONE task=%s — %.2fs total", task_id, time.monotonic() - t0)
|
||||||
|
|
||||||
|
|
||||||
def refresh_responsible(task_id: int, task_number: str):
|
def refresh_responsible(task_id: int, task_number: str):
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
intradbot:
|
intradbot:
|
||||||
image: git.example.com/YOUR_USER/intradbot:latest # ← замени на свой хост/юзер
|
image: git.ihateamerica.ru/dv/intrabot:latest # ← замени на свой хост/юзер
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,17 @@ access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sµs'
|
||||||
|
|
||||||
def on_starting(server):
|
def on_starting(server):
|
||||||
"""Инициализация до форка воркеров (выполняется в мастер-процессе)."""
|
"""Инициализация до форка воркеров (выполняется в мастер-процессе)."""
|
||||||
|
import logging
|
||||||
import config
|
import config
|
||||||
import database
|
import database
|
||||||
from intradesk import reference
|
from intradesk import reference
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[logging.StreamHandler()],
|
||||||
|
)
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True)
|
||||||
database.init_db()
|
database.init_db()
|
||||||
reference.seed_static()
|
reference.seed_static()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
from flask import Flask, Response, jsonify, render_template, request, stream_with_context, abort
|
||||||
|
|
@ -19,6 +20,24 @@ from intradesk.handler import handle_webhook
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
app = Flask(__name__, template_folder=os.path.join(os.path.dirname(__file__), "templates"))
|
||||||
|
|
||||||
|
# ── Очередь вебхуков (последовательная обработка) ─────────────────────────────
|
||||||
|
|
||||||
|
_webhook_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _webhook_worker():
|
||||||
|
while True:
|
||||||
|
data = _webhook_queue.get()
|
||||||
|
try:
|
||||||
|
handle_webhook(data)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Unhandled error in webhook worker")
|
||||||
|
finally:
|
||||||
|
_webhook_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=_webhook_worker, daemon=True, name="webhook-worker").start()
|
||||||
|
|
||||||
|
|
||||||
# ── Авторизация ────────────────────────────────────────────────────────────────
|
# ── Авторизация ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -97,7 +116,7 @@ def webhook():
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Invalid JSON"}), 400
|
return jsonify({"error": "Invalid JSON"}), 400
|
||||||
|
|
||||||
threading.Thread(target=handle_webhook, args=(data,), daemon=True).start()
|
_webhook_queue.put(data)
|
||||||
return jsonify({"ok": True}), 200
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,6 +125,14 @@ def health():
|
||||||
return jsonify({"status": "ok"}), 200
|
return jsonify({"status": "ok"}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/version")
|
||||||
|
def version():
|
||||||
|
return jsonify({
|
||||||
|
"commit": os.environ.get("GIT_COMMIT", "dev"),
|
||||||
|
"built_at": os.environ.get("BUILD_TIME", "unknown"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
# ── Дашборд ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
|
@ -145,7 +172,7 @@ def stream():
|
||||||
# ── API заявок ─────────────────────────────────────────────────────────────────
|
# ── API заявок ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/api/tasks/search")
|
@app.route("/api/tasks/search")
|
||||||
@require_task_token
|
#@require_task_token
|
||||||
def api_tasks_search():
|
def api_tasks_search():
|
||||||
try:
|
try:
|
||||||
number = int(request.args.get("number", 0))
|
number = int(request.args.get("number", 0))
|
||||||
|
|
@ -158,7 +185,7 @@ def api_tasks_search():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/tasks/active")
|
@app.route("/api/tasks/active")
|
||||||
@require_task_token
|
#@require_task_token
|
||||||
def api_tasks_active():
|
def api_tasks_active():
|
||||||
filter_expr = request.args.get(
|
filter_expr = request.args.get(
|
||||||
"filter", config.ACTIVE_TASKS_FILTER
|
"filter", config.ACTIVE_TASKS_FILTER
|
||||||
|
|
@ -168,7 +195,7 @@ def api_tasks_active():
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/task/lifetime")
|
@app.route("/api/task/lifetime")
|
||||||
@require_task_token
|
#@require_task_token
|
||||||
def api_task_lifetime():
|
def api_task_lifetime():
|
||||||
try:
|
try:
|
||||||
task_id = int(request.args.get("task_id", 0))
|
task_id = int(request.args.get("task_id", 0))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue