init3
This commit is contained in:
parent
e94e1a3faa
commit
02143a9b48
15 changed files with 1045 additions and 427 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -18,3 +18,6 @@
|
||||||
*.secret
|
*.secret
|
||||||
secrets.json
|
secrets.json
|
||||||
config.local.*
|
config.local.*
|
||||||
|
|
||||||
|
#
|
||||||
|
intradbot-docker.zip
|
||||||
88
.woodpecker.yml
Normal file
88
.woodpecker.yml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Woodpecker CI Pipeline
|
||||||
|
#
|
||||||
|
# Шаги:
|
||||||
|
# 1. docker-build — сборка и пуш образа (push в main)
|
||||||
|
# 2. docker-build-tag — сборка и пуш образа с semver-тегом (git tag v*)
|
||||||
|
# 3. deploy — деплой на сервер (выполняется на агенте role=production)
|
||||||
|
#
|
||||||
|
# Секреты (Settings → Secrets репозитория):
|
||||||
|
# forgejo_host — хост реестра, например git.example.com
|
||||||
|
# forgejo_image — полный путь образа, например git.example.com/user/intradbot
|
||||||
|
# forgejo_username — логин (обычно = gitea.actor)
|
||||||
|
# forgejo_token — токен с правом package:write
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
# ── 1а. Сборка при push в main ─────────────────────────────────────────────
|
||||||
|
- name: docker-build
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
registry:
|
||||||
|
from_secret: forgejo_host
|
||||||
|
repo:
|
||||||
|
from_secret: forgejo_image
|
||||||
|
username:
|
||||||
|
from_secret: forgejo_username
|
||||||
|
password:
|
||||||
|
from_secret: forgejo_token
|
||||||
|
tags:
|
||||||
|
- latest
|
||||||
|
- "sha-${CI_COMMIT_SHA:0:8}"
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
|
||||||
|
# ── 1б. Сборка при git tag v* (semver) ────────────────────────────────────
|
||||||
|
- name: docker-build-tag
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
settings:
|
||||||
|
registry:
|
||||||
|
from_secret: forgejo_host
|
||||||
|
repo:
|
||||||
|
from_secret: forgejo_image
|
||||||
|
username:
|
||||||
|
from_secret: forgejo_username
|
||||||
|
password:
|
||||||
|
from_secret: forgejo_token
|
||||||
|
tags:
|
||||||
|
- "${CI_COMMIT_TAG}" # v1.2.3
|
||||||
|
- latest
|
||||||
|
- "sha-${CI_COMMIT_SHA:0:8}" # всегда — нужно для отката
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
# ── 2. Деплой на продакшн-сервер ──────────────────────────────────────────
|
||||||
|
# Выполняется на Woodpecker-агенте с меткой role=production.
|
||||||
|
# Требует: репозиторий помечен «trusted» в настройках Woodpecker-сервера
|
||||||
|
# (иначе монтирование volumes запрещено).
|
||||||
|
- name: deploy
|
||||||
|
image: docker:cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/intradbot:/deploy
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
cd /deploy
|
||||||
|
|
||||||
|
# Сохраняем ID текущего контейнерного образа (для отката)
|
||||||
|
CONTAINER=$(docker compose ps -q intradbot 2>/dev/null | head -1)
|
||||||
|
if [ -n "$CONTAINER" ]; then
|
||||||
|
OLD_IMAGE=$(docker inspect --format='{{.Image}}' "$CONTAINER")
|
||||||
|
printf '%s' "$OLD_IMAGE" > .previous_image
|
||||||
|
echo "Snapshot for rollback: $OLD_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Обновляем образ и перезапускаем
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
|
||||||
|
labels:
|
||||||
|
role: production
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: tag
|
||||||
|
|
@ -9,7 +9,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Gunicorn запускает Flask в продакшене.
|
# Продакшен: Gunicorn (gthread, 1 worker) обслуживает Flask.
|
||||||
# intradbot.py сам запускает бота и шедулер в отдельных потоках,
|
# Telegram-бот и шедулер стартуют внутри воркера через gunicorn.conf.py (post_fork).
|
||||||
# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки.
|
# Локальная разработка: python intradbot.py
|
||||||
CMD ["python", "intradbot.py"]
|
CMD ["gunicorn", "-c", "gunicorn.conf.py", "web.server:create_app()"]
|
||||||
|
|
|
||||||
195
bot/callbacks.py
Normal file
195
bot/callbacks.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""
|
||||||
|
Реестр callback-обработчиков.
|
||||||
|
|
||||||
|
Добавить новый callback:
|
||||||
|
@callback("my_prefix")
|
||||||
|
def _cb_my_action(call, bot):
|
||||||
|
...
|
||||||
|
|
||||||
|
Диспетчер telegram.py вызывает dispatch(call, bot), который находит
|
||||||
|
нужный обработчик по префиксу callback_data.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import database
|
||||||
|
from intradesk import api as intradesk_api
|
||||||
|
from intradesk import reference
|
||||||
|
from bot.keyboards import (
|
||||||
|
build_executor_keyboard,
|
||||||
|
build_status_keyboard,
|
||||||
|
build_task_keyboard,
|
||||||
|
)
|
||||||
|
from bot import poster
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_registry: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def callback(prefix: str):
|
||||||
|
"""Декоратор: регистрирует функцию как обработчик callback с данным префиксом."""
|
||||||
|
def decorator(fn: Callable) -> Callable:
|
||||||
|
_registry[prefix] = fn
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(call, bot) -> None:
|
||||||
|
"""Роутер: разбирает префикс из callback_data и вызывает нужный обработчик."""
|
||||||
|
prefix = call.data.split(":")[0]
|
||||||
|
handler = _registry.get(prefix)
|
||||||
|
if handler:
|
||||||
|
handler(call, bot)
|
||||||
|
else:
|
||||||
|
log.debug(f"Unhandled callback prefix: {prefix!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Вспомогательные функции ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse2(s: str) -> tuple[str, int]:
|
||||||
|
"""Парсит '{task_number}:{group_id}' → (task_number, group_id)."""
|
||||||
|
parts = s.split(":", 1)
|
||||||
|
return parts[0], int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_message_id(task_number: str, call) -> int:
|
||||||
|
"""Возвращает актуальный message_id поста из БД, иначе — из callback."""
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if task_id:
|
||||||
|
data = database.get_message_data(task_id)
|
||||||
|
if data:
|
||||||
|
return data["message_id"]
|
||||||
|
return call.message.message_id
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_keyboard(bot, call, task_number: str, group_id: int):
|
||||||
|
"""Восстанавливает главную клавиатуру задачи на актуальном посте."""
|
||||||
|
notifier = poster.get_notifier()
|
||||||
|
msg_id = _get_message_id(task_number, call)
|
||||||
|
try:
|
||||||
|
notifier.edit_keyboard(msg_id, build_task_keyboard({
|
||||||
|
"task_number": task_number,
|
||||||
|
"group_id": group_id,
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
if not notifier.is_stale_error(e):
|
||||||
|
log.warning(f"restore_keyboard failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _answer_stale_or_error(bot, call, exc: Exception):
|
||||||
|
notifier = poster.get_notifier()
|
||||||
|
if notifier.is_stale_error(exc):
|
||||||
|
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, str(exc), show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Обработчики ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@callback("st")
|
||||||
|
def _cb_show_statuses(call, bot):
|
||||||
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
|
notifier = poster.get_notifier()
|
||||||
|
msg_id = _get_message_id(task_number, call)
|
||||||
|
try:
|
||||||
|
notifier.edit_keyboard(msg_id, build_status_keyboard(task_number, group_id))
|
||||||
|
bot.answer_callback_query(call.id)
|
||||||
|
except Exception as e:
|
||||||
|
_answer_stale_or_error(bot, call, e)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("ss")
|
||||||
|
def _cb_set_status(call, bot):
|
||||||
|
# ss:{task_number}:{group_id}:{status_id}
|
||||||
|
parts = call.data.split(":")
|
||||||
|
task_number = parts[1]
|
||||||
|
group_id = int(parts[2])
|
||||||
|
status_id = int(parts[3])
|
||||||
|
status_name = reference.STATUSES.get(status_id, f"#{status_id}")
|
||||||
|
|
||||||
|
if intradesk_api.change_status(int(task_number), status_id):
|
||||||
|
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
|
||||||
|
_restore_keyboard(bot, call, task_number, group_id)
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("ex")
|
||||||
|
def _cb_show_executors(call, bot):
|
||||||
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
|
notifier = poster.get_notifier()
|
||||||
|
msg_id = _get_message_id(task_number, call)
|
||||||
|
try:
|
||||||
|
notifier.edit_keyboard(msg_id, build_executor_keyboard(task_number, group_id))
|
||||||
|
bot.answer_callback_query(call.id)
|
||||||
|
except Exception as e:
|
||||||
|
_answer_stale_or_error(bot, call, e)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("se")
|
||||||
|
def _cb_set_executor(call, bot):
|
||||||
|
# se:{task_number}:{group_id}:{emp_id}
|
||||||
|
parts = call.data.split(":")
|
||||||
|
task_number = parts[1]
|
||||||
|
group_id = int(parts[2])
|
||||||
|
emp_id = int(parts[3])
|
||||||
|
emp_name = database.resolve("employees", emp_id) or f"ID {emp_id}"
|
||||||
|
|
||||||
|
if intradesk_api.assign_executor(int(task_number), user_id=emp_id, group_id=group_id or None):
|
||||||
|
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
|
||||||
|
_restore_keyboard(bot, call, task_number, group_id)
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("su")
|
||||||
|
def _cb_unset_executor(call, bot):
|
||||||
|
"""Убрать конкретного исполнителя, оставить только группу."""
|
||||||
|
task_number, group_id = _parse2(call.data[3:])
|
||||||
|
if not group_id:
|
||||||
|
bot.answer_callback_query(call.id, "Группа не определена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if intradesk_api.assign_executor(int(task_number), group_id=group_id):
|
||||||
|
bot.answer_callback_query(call.id, "✅ Исполнитель снят, группа сохранена")
|
||||||
|
_restore_keyboard(bot, call, task_number, group_id)
|
||||||
|
else:
|
||||||
|
bot.answer_callback_query(call.id, "❌ Ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("take")
|
||||||
|
def _cb_take(call, bot):
|
||||||
|
task_number = call.data[5:]
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if not task_id:
|
||||||
|
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_id = call.from_user.id
|
||||||
|
user_name = (call.from_user.full_name or call.from_user.username or str(user_id)).strip()
|
||||||
|
database.add_responsible(task_id, user_id, user_name)
|
||||||
|
bot.answer_callback_query(call.id, f"🙋 Вы взяли заявку #{task_number} в работу")
|
||||||
|
poster.refresh_responsible(task_id, task_number)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("drop")
|
||||||
|
def _cb_drop(call, bot):
|
||||||
|
task_number = call.data[5:]
|
||||||
|
task_id = database.get_task_id_by_number(task_number)
|
||||||
|
if not task_id:
|
||||||
|
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
database.remove_responsible(task_id, call.from_user.id)
|
||||||
|
bot.answer_callback_query(call.id, f"✅ Вы сняты с заявки #{task_number}")
|
||||||
|
poster.refresh_responsible(task_id, task_number)
|
||||||
|
|
||||||
|
|
||||||
|
@callback("back")
|
||||||
|
def _cb_back(call, bot):
|
||||||
|
task_number, group_id = _parse2(call.data[5:])
|
||||||
|
bot.answer_callback_query(call.id)
|
||||||
|
_restore_keyboard(bot, call, task_number, group_id)
|
||||||
163
bot/keyboards.py
Normal file
163
bot/keyboards.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Описание кнопок как данных.
|
||||||
|
|
||||||
|
Чтобы добавить или убрать кнопку — редактируй TASK_BUTTONS.
|
||||||
|
Чтобы добавить новый тип клавиатуры — добавь список ButtonSpec + функцию build_*_keyboard().
|
||||||
|
|
||||||
|
Keyboard (агностичный тип) конвертируется в нативный формат
|
||||||
|
в адаптере (см. bot/notifier.py).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import config
|
||||||
|
from bot.notifier import Keyboard, KeyboardButton
|
||||||
|
|
||||||
|
|
||||||
|
# ── Спецификация кнопки ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ButtonSpec:
|
||||||
|
"""
|
||||||
|
Описание одной кнопки главной клавиатуры задачи.
|
||||||
|
|
||||||
|
url_factory — функция(ctx) → str, для URL-кнопок
|
||||||
|
callback_prefix — строковый префикс callback_data, для inline-кнопок
|
||||||
|
condition — функция(ctx) → bool, показывать ли кнопку (по умолчанию: всегда)
|
||||||
|
"""
|
||||||
|
label: str
|
||||||
|
url_factory: Callable[[dict], str] | None = None
|
||||||
|
callback_prefix: str | None = None
|
||||||
|
condition: Callable[[dict], bool] = field(
|
||||||
|
default_factory=lambda: (lambda _ctx: True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Вспомогательная функция: подписанная ссылка на Mini App ───────────────────
|
||||||
|
|
||||||
|
def _sign_task_url(task_number: str) -> str:
|
||||||
|
"""Генерирует HMAC-подписанную ссылку на Mini App (TTL 30 дней)."""
|
||||||
|
expires = int(time.time()) + 30 * 24 * 3600
|
||||||
|
msg = f"{task_number}:{expires}".encode()
|
||||||
|
sig = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
||||||
|
return f"{config.PUBLIC_URL}/miniapp?task={task_number}&exp={expires}&sig={sig}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Главная клавиатура задачи ─────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Добавить кнопку → добавь ButtonSpec в TASK_BUTTONS
|
||||||
|
# Убрать кнопку → удали строку из TASK_BUTTONS
|
||||||
|
# Временно скрыть → раскомментируй condition или закомментируй весь ButtonSpec
|
||||||
|
#
|
||||||
|
TASK_BUTTONS: list[ButtonSpec] = [
|
||||||
|
ButtonSpec(
|
||||||
|
label="📄 Предпросмотр",
|
||||||
|
url_factory=lambda ctx: _sign_task_url(ctx["task_number"]),
|
||||||
|
condition=lambda ctx: bool(config.PUBLIC_URL),
|
||||||
|
),
|
||||||
|
ButtonSpec(
|
||||||
|
label="🔗 Открыть в IntraDesk",
|
||||||
|
url_factory=lambda ctx: config.BASE_TASK_URL + ctx["task_number"],
|
||||||
|
),
|
||||||
|
# ── Управляющие кнопки (раскомментируй для включения) ─────────────────────
|
||||||
|
# ButtonSpec(
|
||||||
|
# label="📋 Статус",
|
||||||
|
# callback_prefix="st",
|
||||||
|
# condition=lambda ctx: not ctx.get("closed", False),
|
||||||
|
# ),
|
||||||
|
# ButtonSpec(
|
||||||
|
# label="👤 Исполнитель",
|
||||||
|
# callback_prefix="ex",
|
||||||
|
# condition=lambda ctx: not ctx.get("closed", False),
|
||||||
|
# ),
|
||||||
|
# ButtonSpec(
|
||||||
|
# label="🙋 Взять",
|
||||||
|
# callback_prefix="take",
|
||||||
|
# condition=lambda ctx: not ctx.get("closed", False),
|
||||||
|
# ),
|
||||||
|
# ButtonSpec(
|
||||||
|
# label="❌ Снять",
|
||||||
|
# callback_prefix="drop",
|
||||||
|
# condition=lambda ctx: not ctx.get("closed", False),
|
||||||
|
# ),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_callback_data(spec: ButtonSpec, ctx: dict) -> str:
|
||||||
|
task_number = ctx["task_number"]
|
||||||
|
group_id = ctx.get("group_id", 0)
|
||||||
|
# take/drop — без group_id
|
||||||
|
if spec.callback_prefix in ("take", "drop"):
|
||||||
|
return f"{spec.callback_prefix}:{task_number}"
|
||||||
|
return f"{spec.callback_prefix}:{task_number}:{group_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_task_keyboard(ctx: dict) -> Keyboard:
|
||||||
|
"""
|
||||||
|
Строит главную клавиатуру задачи по TASK_BUTTONS.
|
||||||
|
|
||||||
|
ctx ожидает:
|
||||||
|
task_number : str
|
||||||
|
group_id : int (опционально)
|
||||||
|
closed : bool (опционально)
|
||||||
|
|
||||||
|
Раскладка: по 2 кнопки в ряд.
|
||||||
|
"""
|
||||||
|
buttons: list[KeyboardButton] = []
|
||||||
|
for spec in TASK_BUTTONS:
|
||||||
|
if spec.condition(ctx):
|
||||||
|
if spec.url_factory:
|
||||||
|
buttons.append(KeyboardButton(
|
||||||
|
label=spec.label,
|
||||||
|
url=spec.url_factory(ctx),
|
||||||
|
))
|
||||||
|
elif spec.callback_prefix:
|
||||||
|
buttons.append(KeyboardButton(
|
||||||
|
label=spec.label,
|
||||||
|
callback_data=_make_callback_data(spec, ctx),
|
||||||
|
))
|
||||||
|
return [buttons[i:i + 2] for i in range(0, len(buttons), 2)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Клавиатура выбора статуса ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_status_keyboard(task_number: str, group_id: int) -> Keyboard:
|
||||||
|
from intradesk import reference
|
||||||
|
rows: Keyboard = []
|
||||||
|
for sid, name in reference.STATUSES.items():
|
||||||
|
rows.append([KeyboardButton(
|
||||||
|
label=name,
|
||||||
|
callback_data=f"ss:{task_number}:{group_id}:{sid}",
|
||||||
|
)])
|
||||||
|
rows.append([KeyboardButton(
|
||||||
|
label="↩ Назад",
|
||||||
|
callback_data=f"back:{task_number}:{group_id}",
|
||||||
|
)])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ── Клавиатура выбора исполнителя ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_executor_keyboard(task_number: str, group_id: int) -> Keyboard:
|
||||||
|
import database
|
||||||
|
rows: Keyboard = []
|
||||||
|
for uid in config.EXECUTOR_USER_IDS:
|
||||||
|
name = database.resolve("employees", uid) or f"ID {uid}"
|
||||||
|
rows.append([KeyboardButton(
|
||||||
|
label=name,
|
||||||
|
callback_data=f"se:{task_number}:{group_id}:{uid}",
|
||||||
|
)])
|
||||||
|
rows.append([KeyboardButton(
|
||||||
|
label="➖ Только группа",
|
||||||
|
callback_data=f"su:{task_number}:{group_id}",
|
||||||
|
)])
|
||||||
|
rows.append([KeyboardButton(
|
||||||
|
label="↩ Назад",
|
||||||
|
callback_data=f"back:{task_number}:{group_id}",
|
||||||
|
)])
|
||||||
|
return rows
|
||||||
44
bot/messages.py
Normal file
44
bot/messages.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""
|
||||||
|
Построение текста постов.
|
||||||
|
|
||||||
|
Только текстовая логика — без отправки, без Telegram API, без зависимостей.
|
||||||
|
Легко тестируется изолированно.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_RESP_SEP = "\n\n─────────────────────"
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_text(
|
||||||
|
base_text: str,
|
||||||
|
responsible: list[dict],
|
||||||
|
events_lines: list[str] | None = None,
|
||||||
|
new_count: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Собирает итоговый текст поста:
|
||||||
|
1. Основной текст (base_text)
|
||||||
|
2. «📋 Последние события» — все события кроме последних new_count
|
||||||
|
3. «🆕 Новое» — последние new_count событий (из текущего вебхука)
|
||||||
|
4. Разделитель + «В работе» — всегда самый последний блок
|
||||||
|
"""
|
||||||
|
clean = base_text.split(_RESP_SEP)[0]
|
||||||
|
result = clean
|
||||||
|
|
||||||
|
if events_lines:
|
||||||
|
if new_count > 0:
|
||||||
|
history = events_lines[:-new_count]
|
||||||
|
newest = events_lines[-new_count:]
|
||||||
|
else:
|
||||||
|
history = events_lines
|
||||||
|
newest = []
|
||||||
|
history = history[-6:]
|
||||||
|
if history:
|
||||||
|
result += "\n\n📋 *Последние события:*\n" + "\n".join(history)
|
||||||
|
if newest:
|
||||||
|
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
|
||||||
|
|
||||||
|
if responsible:
|
||||||
|
names = ", ".join(r["user_name"] for r in responsible)
|
||||||
|
result += _RESP_SEP + f"\n🙋 В работе: {names}"
|
||||||
|
|
||||||
|
return result
|
||||||
146
bot/notifier.py
Normal file
146
bot/notifier.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""
|
||||||
|
Абстракция канала уведомлений.
|
||||||
|
|
||||||
|
Паттерн «Адаптер»: весь код бота работает с интерфейсом Notifier,
|
||||||
|
не зная о конкретном мессенджере.
|
||||||
|
|
||||||
|
Чтобы добавить новый канал (Slack, Email, Teams и т.д.) — создай класс,
|
||||||
|
реализующий Notifier, и зарегистрируй его через poster.set_notifier().
|
||||||
|
|
||||||
|
Структура клавиатуры (Keyboard) тоже агностична — адаптеры конвертируют
|
||||||
|
её в нативный формат своего мессенджера.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Агностичное представление клавиатуры ──────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KeyboardButton:
|
||||||
|
"""Одна кнопка: либо URL-ссылка, либо callback."""
|
||||||
|
label: str
|
||||||
|
url: str | None = None
|
||||||
|
callback_data: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Клавиатура — список строк, каждая строка — список кнопок
|
||||||
|
Keyboard = list[list[KeyboardButton]]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Интерфейс отправителя ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Notifier(ABC):
|
||||||
|
"""
|
||||||
|
Абстрактный адаптер канала уведомлений.
|
||||||
|
|
||||||
|
Реализации:
|
||||||
|
- TelegramChannelNotifier (этот файл)
|
||||||
|
- SlackNotifier (создай bot/adapters/slack.py)
|
||||||
|
- EmailNotifier (создай bot/adapters/email.py)
|
||||||
|
- ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
|
||||||
|
"""Отправить сообщение. Возвращает идентификатор сообщения."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
|
||||||
|
"""Редактировать текст существующего сообщения."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
|
||||||
|
"""Заменить только клавиатуру существующего сообщения."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, message_id: int):
|
||||||
|
"""Удалить сообщение (ошибки поглощаются — сообщение могло быть удалено)."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_animation(self, url: str, disable_notification: bool = True) -> int:
|
||||||
|
"""Отправить анимацию/GIF. Возвращает идентификатор сообщения."""
|
||||||
|
|
||||||
|
def is_stale_error(self, exc: Exception) -> bool:
|
||||||
|
"""
|
||||||
|
True если ошибка означает устаревшее/уже удалённое сообщение.
|
||||||
|
Адаптеры переопределяют под свою платформу.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Telegram-адаптер ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TelegramChannelNotifier(Notifier):
|
||||||
|
"""Адаптер для отправки уведомлений в Telegram-канал через pytelegrambotapi."""
|
||||||
|
|
||||||
|
def __init__(self, bot, channel_id: int):
|
||||||
|
self._bot = bot
|
||||||
|
self._channel_id = channel_id
|
||||||
|
|
||||||
|
# ── Конвертация агностичной Keyboard → telebot InlineKeyboardMarkup ────────
|
||||||
|
|
||||||
|
def _to_markup(self, keyboard: Keyboard | None):
|
||||||
|
from telebot import types
|
||||||
|
if not keyboard:
|
||||||
|
return None
|
||||||
|
markup = types.InlineKeyboardMarkup()
|
||||||
|
for row in keyboard:
|
||||||
|
tg_row = []
|
||||||
|
for btn in row:
|
||||||
|
if btn.url:
|
||||||
|
tg_row.append(types.InlineKeyboardButton(btn.label, url=btn.url))
|
||||||
|
elif btn.callback_data:
|
||||||
|
tg_row.append(types.InlineKeyboardButton(btn.label, callback_data=btn.callback_data))
|
||||||
|
if tg_row:
|
||||||
|
markup.row(*tg_row)
|
||||||
|
return markup
|
||||||
|
|
||||||
|
# ── Реализация интерфейса ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def send(self, text: str, keyboard: Keyboard | None = None) -> int:
|
||||||
|
msg = self._bot.send_message(
|
||||||
|
self._channel_id, text,
|
||||||
|
parse_mode="Markdown",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=self._to_markup(keyboard),
|
||||||
|
)
|
||||||
|
return msg.message_id
|
||||||
|
|
||||||
|
def edit_text(self, message_id: int, text: str, keyboard: Keyboard | None = None):
|
||||||
|
self._bot.edit_message_text(
|
||||||
|
text, self._channel_id, message_id,
|
||||||
|
parse_mode="Markdown",
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=self._to_markup(keyboard),
|
||||||
|
)
|
||||||
|
|
||||||
|
def edit_keyboard(self, message_id: int, keyboard: Keyboard | None):
|
||||||
|
self._bot.edit_message_reply_markup(
|
||||||
|
self._channel_id, message_id,
|
||||||
|
reply_markup=self._to_markup(keyboard),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, message_id: int):
|
||||||
|
try:
|
||||||
|
self._bot.delete_message(self._channel_id, message_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_animation(self, url: str, disable_notification: bool = True) -> int:
|
||||||
|
msg = self._bot.send_animation(
|
||||||
|
self._channel_id, url,
|
||||||
|
disable_notification=disable_notification,
|
||||||
|
)
|
||||||
|
return msg.message_id
|
||||||
|
|
||||||
|
def is_stale_error(self, exc: Exception) -> bool:
|
||||||
|
s = str(exc).lower()
|
||||||
|
return any(x in s for x in (
|
||||||
|
"message to edit not found",
|
||||||
|
"message is not modified",
|
||||||
|
"message can't be edited",
|
||||||
|
"bad request",
|
||||||
|
))
|
||||||
134
bot/poster.py
Normal file
134
bot/poster.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""
|
||||||
|
Управление постами в канале.
|
||||||
|
|
||||||
|
Использует интерфейс Notifier — не привязан к Telegram или любому другому
|
||||||
|
мессенджеру. Инициализируется один раз при старте бота через set_notifier().
|
||||||
|
|
||||||
|
Чтобы сменить канал: вызови set_notifier() с другим адаптером.
|
||||||
|
Чтобы отправлять в несколько каналов: используй MultiNotifier (создай адаптер).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import config
|
||||||
|
import database
|
||||||
|
from bot.keyboards import build_task_keyboard
|
||||||
|
from bot.messages import build_full_text
|
||||||
|
from bot.notifier import Notifier
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_notifier: Notifier | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_notifier(notifier: Notifier) -> None:
|
||||||
|
"""Регистрирует активный адаптер уведомлений. Вызывается при старте бота."""
|
||||||
|
global _notifier
|
||||||
|
_notifier = notifier
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifier() -> Notifier:
|
||||||
|
if _notifier is None:
|
||||||
|
raise RuntimeError("Notifier not initialized. Call set_notifier() first.")
|
||||||
|
return _notifier
|
||||||
|
|
||||||
|
|
||||||
|
# ── Публичные операции с постами ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def recreate_post(
|
||||||
|
task_id: int,
|
||||||
|
task_number: str,
|
||||||
|
base_text: str,
|
||||||
|
group_id: int = 0,
|
||||||
|
closed: bool = False,
|
||||||
|
new_event_lines: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Удаляет старый пост задачи и создаёт новый, накапливая события.
|
||||||
|
|
||||||
|
Изменения (• ...) дедуплицируются по тексту.
|
||||||
|
Комментарии (💬 ...) и описания (📝 ...) добавляются всегда.
|
||||||
|
"""
|
||||||
|
notifier = get_notifier()
|
||||||
|
stored = database.get_stored_events(task_id)
|
||||||
|
stored_set = set(stored)
|
||||||
|
|
||||||
|
if new_event_lines:
|
||||||
|
added = []
|
||||||
|
for line in new_event_lines:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
is_change = line.startswith("•")
|
||||||
|
if is_change and line in stored_set:
|
||||||
|
continue
|
||||||
|
added.append(line)
|
||||||
|
stored_set.add(line)
|
||||||
|
stored = stored + added
|
||||||
|
else:
|
||||||
|
added = []
|
||||||
|
|
||||||
|
responsible = database.get_responsible(task_id)
|
||||||
|
full_text = build_full_text(base_text, responsible, stored, new_count=len(added))
|
||||||
|
keyboard = build_task_keyboard({
|
||||||
|
"task_number": task_number,
|
||||||
|
"group_id": group_id,
|
||||||
|
"closed": closed,
|
||||||
|
})
|
||||||
|
|
||||||
|
old = database.get_message_data(task_id)
|
||||||
|
if old:
|
||||||
|
notifier.delete(old["message_id"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_id = notifier.send(full_text, keyboard)
|
||||||
|
database.save_message_id(task_id, message_id, task_number, group_id, base_text)
|
||||||
|
if added:
|
||||||
|
database.save_stored_events(task_id, stored)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"recreate_post failed for task {task_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_responsible(task_id: int, task_number: str):
|
||||||
|
"""Редактирует текст поста — обновляет блок «В работе»."""
|
||||||
|
notifier = get_notifier()
|
||||||
|
data = database.get_message_data(task_id)
|
||||||
|
if not data or not data.get("base_text"):
|
||||||
|
return
|
||||||
|
|
||||||
|
responsible = database.get_responsible(task_id)
|
||||||
|
events_lines = database.get_stored_events(task_id)
|
||||||
|
full_text = build_full_text(data["base_text"], responsible, events_lines, new_count=0)
|
||||||
|
keyboard = build_task_keyboard({
|
||||||
|
"task_number": task_number,
|
||||||
|
"group_id": data.get("group_id") or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
notifier.edit_text(data["message_id"], full_text, keyboard)
|
||||||
|
except Exception as e:
|
||||||
|
if not notifier.is_stale_error(e):
|
||||||
|
log.warning(f"refresh_responsible failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def send_gif_for_to(task_id: int):
|
||||||
|
"""
|
||||||
|
Отправляет GIF для заявок с ТО.
|
||||||
|
Предварительно удаляет предыдущую гифку если была.
|
||||||
|
Если GIF_URL пустой — ничего не делает.
|
||||||
|
"""
|
||||||
|
if not config.GIF_URL:
|
||||||
|
return
|
||||||
|
|
||||||
|
notifier = get_notifier()
|
||||||
|
old_gif_id = database.get_gif_message_id(task_id)
|
||||||
|
if old_gif_id:
|
||||||
|
notifier.delete(old_gif_id)
|
||||||
|
database.delete_gif_message_id(task_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_id = notifier.send_animation(config.GIF_URL, disable_notification=True)
|
||||||
|
database.save_gif_message_id(task_id, message_id)
|
||||||
|
log.info(f"GIF sent for task {task_id}, message_id={message_id}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to send GIF for task {task_id}: {e}")
|
||||||
425
bot/telegram.py
425
bot/telegram.py
|
|
@ -1,420 +1,35 @@
|
||||||
import hashlib
|
"""
|
||||||
import hmac
|
Точка запуска Telegram-бота.
|
||||||
|
|
||||||
|
Создаёт экземпляр бота, инициализирует адаптер уведомлений (TelegramChannelNotifier),
|
||||||
|
регистрирует единый callback-диспетчер.
|
||||||
|
|
||||||
|
Вся логика кнопок → bot/keyboards.py
|
||||||
|
Вся логика текста → bot/messages.py
|
||||||
|
Отправка/удаление → bot/poster.py (через Notifier-адаптер)
|
||||||
|
Callback-обработчики → bot/callbacks.py
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
|
|
||||||
import telebot
|
import telebot
|
||||||
from telebot import types
|
|
||||||
from telebot.apihelper import ApiTelegramException
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
import database
|
from bot import callbacks
|
||||||
from intradesk import reference
|
from bot.notifier import TelegramChannelNotifier
|
||||||
from intradesk import api as intradesk_api
|
from bot import poster
|
||||||
from intradesk import formatter
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
bot = telebot.TeleBot(config.TOKEN, parse_mode="Markdown")
|
||||||
|
|
||||||
# Сотрудники, доступные для назначения исполнителем (ID из Intradesk)
|
|
||||||
EXECUTOR_USER_IDS = config.EXECUTOR_USER_IDS
|
|
||||||
|
|
||||||
# Разделитель перед секцией "В работе" в тексте поста
|
@bot.callback_query_handler(func=lambda c: True)
|
||||||
_RESP_SEP = "\n\n─────────────────────"
|
def _handle_callback(call):
|
||||||
|
callbacks.dispatch(call, bot)
|
||||||
|
|
||||||
|
|
||||||
# ── Текст с футером "В работе" ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_full_text(
|
|
||||||
base_text: str,
|
|
||||||
responsible: list[dict],
|
|
||||||
events_lines: list[str] | None = None,
|
|
||||||
new_count: int = 0,
|
|
||||||
) -> str:
|
|
||||||
"""
|
|
||||||
Собирает итоговый текст поста:
|
|
||||||
1. Основной текст (base_text)
|
|
||||||
2. «📋 Последние события» — все события кроме последних new_count
|
|
||||||
3. «🆕 Новое» — последние new_count событий (все новые из текущего вебхука)
|
|
||||||
4. Разделитель + «В работе» — всегда самый последний блок
|
|
||||||
"""
|
|
||||||
clean = base_text.split(_RESP_SEP)[0]
|
|
||||||
result = clean
|
|
||||||
|
|
||||||
if events_lines:
|
|
||||||
if new_count > 0:
|
|
||||||
history = events_lines[:-new_count]
|
|
||||||
newest = events_lines[-new_count:]
|
|
||||||
else:
|
|
||||||
history = events_lines
|
|
||||||
newest = []
|
|
||||||
# Показываем последние 6 из истории
|
|
||||||
history = history[-6:]
|
|
||||||
if history:
|
|
||||||
result += "\n\n📋 *Последние события:*\n" + "\n".join(history)
|
|
||||||
if newest:
|
|
||||||
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
|
|
||||||
|
|
||||||
if responsible:
|
|
||||||
names = ", ".join(r["user_name"] for r in responsible)
|
|
||||||
result += _RESP_SEP + f"\n🙋 В работе: {names}"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ── Клавиатуры ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def sign_task_url(task_number: str) -> str:
|
|
||||||
"""Генерирует подписанную ссылку на Mini App с токеном (TTL 30 дней)."""
|
|
||||||
expires = int(time.time()) + 30 * 24 * 3600
|
|
||||||
msg = f"{task_number}:{expires}".encode()
|
|
||||||
sig = hmac.new(config.MINIAPP_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:16]
|
|
||||||
return f"{config.PUBLIC_URL}/miniapp?task={task_number}&exp={expires}&sig={sig}"
|
|
||||||
|
|
||||||
|
|
||||||
def make_task_buttons(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
|
||||||
gid = group_id or 0
|
|
||||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
|
||||||
#markup.add(
|
|
||||||
# types.InlineKeyboardButton("📋 Статус", callback_data=f"st:{task_number}:{gid}"),
|
|
||||||
# types.InlineKeyboardButton("👤 Исполнитель", callback_data=f"ex:{task_number}:{gid}"),
|
|
||||||
# types.InlineKeyboardButton("🙋 Взять", callback_data=f"take:{task_number}"),
|
|
||||||
# types.InlineKeyboardButton("❌ Снять", callback_data=f"drop:{task_number}"),
|
|
||||||
#)
|
|
||||||
# Кнопка Mini App — полная карточка заявки (только если PUBLIC_URL задан)
|
|
||||||
if config.PUBLIC_URL:
|
|
||||||
markup.add(
|
|
||||||
types.InlineKeyboardButton("📄 Предпросмотр", url=sign_task_url(task_number)),
|
|
||||||
types.InlineKeyboardButton("🔗 Открыть в IntraDesk", url=config.BASE_TASK_URL + task_number),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
markup.add(
|
|
||||||
types.InlineKeyboardButton("🔗 Открыть заявку", url=config.BASE_TASK_URL + task_number),
|
|
||||||
)
|
|
||||||
return markup
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _make_status_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
|
||||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
|
||||||
for sid, name in reference.STATUSES.items():
|
|
||||||
markup.add(types.InlineKeyboardButton(
|
|
||||||
name, callback_data=f"ss:{task_number}:{group_id}:{sid}"
|
|
||||||
))
|
|
||||||
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
|
|
||||||
return markup
|
|
||||||
|
|
||||||
|
|
||||||
def _make_executor_keyboard(task_number: str, group_id: int) -> types.InlineKeyboardMarkup:
|
|
||||||
markup = types.InlineKeyboardMarkup(row_width=1)
|
|
||||||
for uid in EXECUTOR_USER_IDS:
|
|
||||||
name = database.resolve("employees", uid) or f"ID {uid}"
|
|
||||||
markup.add(types.InlineKeyboardButton(
|
|
||||||
name, callback_data=f"se:{task_number}:{group_id}:{uid}"
|
|
||||||
))
|
|
||||||
markup.add(types.InlineKeyboardButton(
|
|
||||||
"➖ Только группа", callback_data=f"su:{task_number}:{group_id}"
|
|
||||||
))
|
|
||||||
markup.add(types.InlineKeyboardButton("↩ Назад", callback_data=f"back:{task_number}:{group_id}"))
|
|
||||||
return markup
|
|
||||||
|
|
||||||
|
|
||||||
# ── Публикация / пересоздание поста ──────────────────────────────────────────
|
|
||||||
|
|
||||||
def recreate_post(
|
|
||||||
task_id: int,
|
|
||||||
task_number: str,
|
|
||||||
base_text: str,
|
|
||||||
group_id: int = 0,
|
|
||||||
closed: bool = False,
|
|
||||||
new_event_lines: list[str] | None = None,
|
|
||||||
):
|
|
||||||
"""Удаляет старый пост и создаёт новый, накапливая события и сохраняя ответственных."""
|
|
||||||
# Накапливаем события:
|
|
||||||
# - изменения (• ...) дедуплицируем по тексту — одно и то же изменение не дублируем
|
|
||||||
# - комментарии (💬 ...) и описание (📝 ...) всегда добавляем, даже если текст совпадает
|
|
||||||
stored = database.get_stored_events(task_id)
|
|
||||||
stored_set = set(stored)
|
|
||||||
if new_event_lines:
|
|
||||||
added = []
|
|
||||||
for line in new_event_lines:
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
is_change = line.startswith("•")
|
|
||||||
if is_change and line in stored_set:
|
|
||||||
continue # дедупликация только для изменений
|
|
||||||
added.append(line)
|
|
||||||
stored_set.add(line) # обновляем set чтобы не дублировать внутри одного вебхука
|
|
||||||
stored = stored + added
|
|
||||||
else:
|
|
||||||
added = []
|
|
||||||
|
|
||||||
responsible = database.get_responsible(task_id)
|
|
||||||
full_text = _build_full_text(base_text, responsible, stored, new_count=len(added))
|
|
||||||
markup = make_task_buttons(task_number, group_id)
|
|
||||||
|
|
||||||
# Удаляем старый пост
|
|
||||||
old = database.get_message_data(task_id)
|
|
||||||
if old:
|
|
||||||
_delete_message(config.CHANNEL_ID, old["message_id"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = bot.send_message(
|
|
||||||
config.CHANNEL_ID,
|
|
||||||
full_text,
|
|
||||||
parse_mode="Markdown",
|
|
||||||
disable_web_page_preview=True,
|
|
||||||
reply_markup=markup,
|
|
||||||
)
|
|
||||||
# save_message_id сначала создаёт/обновляет строку, потом сохраняем события
|
|
||||||
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
|
|
||||||
if added:
|
|
||||||
database.save_stored_events(task_id, stored)
|
|
||||||
except ApiTelegramException as e:
|
|
||||||
log.error(f"recreate_post failed for task {task_id}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Callbacks: смена статуса ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("st:"))
|
|
||||||
def cb_show_statuses(call):
|
|
||||||
task_number, group_id = _parse2(call.data[3:])
|
|
||||||
chat_id, msg_id = _current_msg(task_number, call)
|
|
||||||
try:
|
|
||||||
bot.edit_message_reply_markup(
|
|
||||||
chat_id, msg_id,
|
|
||||||
reply_markup=_make_status_keyboard(task_number, group_id),
|
|
||||||
)
|
|
||||||
bot.answer_callback_query(call.id)
|
|
||||||
except Exception as e:
|
|
||||||
if _is_stale_error(e):
|
|
||||||
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
|
|
||||||
else:
|
|
||||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ss:"))
|
|
||||||
def cb_set_status(call):
|
|
||||||
# ss:{task_number}:{group_id}:{status_id}
|
|
||||||
parts = call.data.split(":")
|
|
||||||
task_number = parts[1]
|
|
||||||
group_id = int(parts[2])
|
|
||||||
status_id = int(parts[3])
|
|
||||||
status_name = reference.STATUSES.get(status_id, f"#{status_id}")
|
|
||||||
|
|
||||||
success = intradesk_api.change_status(int(task_number), status_id)
|
|
||||||
if success:
|
|
||||||
bot.answer_callback_query(call.id, f"✅ Статус → {status_name}")
|
|
||||||
_restore_keyboard(call, task_number, group_id)
|
|
||||||
else:
|
|
||||||
bot.answer_callback_query(call.id, "❌ Ошибка изменения статуса", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Callbacks: назначение исполнителя ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("ex:"))
|
|
||||||
def cb_show_executors(call):
|
|
||||||
task_number, group_id = _parse2(call.data[3:])
|
|
||||||
chat_id, msg_id = _current_msg(task_number, call)
|
|
||||||
try:
|
|
||||||
bot.edit_message_reply_markup(
|
|
||||||
chat_id, msg_id,
|
|
||||||
reply_markup=_make_executor_keyboard(task_number, group_id),
|
|
||||||
)
|
|
||||||
bot.answer_callback_query(call.id)
|
|
||||||
except Exception as e:
|
|
||||||
if _is_stale_error(e):
|
|
||||||
bot.answer_callback_query(call.id, "⚠️ Пост обновился, попробуйте ещё раз")
|
|
||||||
else:
|
|
||||||
bot.answer_callback_query(call.id, str(e), show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("se:"))
|
|
||||||
def cb_set_executor(call):
|
|
||||||
# se:{task_number}:{group_id}:{emp_id}
|
|
||||||
parts = call.data.split(":")
|
|
||||||
task_number = parts[1]
|
|
||||||
group_id = int(parts[2])
|
|
||||||
emp_id = int(parts[3])
|
|
||||||
emp_name = database.resolve("employees", emp_id) or f"ID {emp_id}"
|
|
||||||
|
|
||||||
# Сохраняем текущую группу, меняем только исполнителя
|
|
||||||
success = intradesk_api.assign_executor(
|
|
||||||
int(task_number), user_id=emp_id, group_id=group_id or None
|
|
||||||
)
|
|
||||||
if success:
|
|
||||||
bot.answer_callback_query(call.id, f"✅ Исполнитель → {emp_name}")
|
|
||||||
_restore_keyboard(call, task_number, group_id)
|
|
||||||
else:
|
|
||||||
bot.answer_callback_query(call.id, "❌ Ошибка при назначении", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("su:"))
|
|
||||||
def cb_unset_executor(call):
|
|
||||||
"""Убрать конкретного исполнителя, оставить только группу."""
|
|
||||||
task_number, group_id = _parse2(call.data[3:])
|
|
||||||
if not group_id:
|
|
||||||
bot.answer_callback_query(call.id, "Группа не определена", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
success = intradesk_api.assign_executor(int(task_number), group_id=group_id)
|
|
||||||
if success:
|
|
||||||
bot.answer_callback_query(call.id, "✅ Исполнитель снят, группа сохранена")
|
|
||||||
_restore_keyboard(call, task_number, group_id)
|
|
||||||
else:
|
|
||||||
bot.answer_callback_query(call.id, "❌ Ошибка", show_alert=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Callbacks: "Взять в работу" / "Снять меня" ────────────────────────────────
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("take:"))
|
|
||||||
def cb_take(call):
|
|
||||||
task_number = call.data[5:]
|
|
||||||
task_id = database.get_task_id_by_number(task_number)
|
|
||||||
if not task_id:
|
|
||||||
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
user_id = call.from_user.id
|
|
||||||
user_name = (call.from_user.full_name or call.from_user.username or str(user_id)).strip()
|
|
||||||
|
|
||||||
database.add_responsible(task_id, user_id, user_name)
|
|
||||||
bot.answer_callback_query(call.id, f"🙋 Вы взяли заявку #{task_number} в работу")
|
|
||||||
_refresh_responsible(call, task_id, task_number)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("drop:"))
|
|
||||||
def cb_drop(call):
|
|
||||||
task_number = call.data[5:]
|
|
||||||
task_id = database.get_task_id_by_number(task_number)
|
|
||||||
if not task_id:
|
|
||||||
bot.answer_callback_query(call.id, "Заявка не найдена", show_alert=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
database.remove_responsible(task_id, call.from_user.id)
|
|
||||||
bot.answer_callback_query(call.id, f"✅ Вы сняты с заявки #{task_number}")
|
|
||||||
_refresh_responsible(call, task_id, task_number)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Callback: назад (отмена выбора) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
@bot.callback_query_handler(func=lambda c: c.data.startswith("back:"))
|
|
||||||
def cb_back(call):
|
|
||||||
task_number, group_id = _parse2(call.data[5:])
|
|
||||||
bot.answer_callback_query(call.id)
|
|
||||||
_restore_keyboard(call, task_number, group_id)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Вспомогательные функции ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _parse2(s: str) -> tuple[str, int]:
|
|
||||||
"""Парсит '{task_number}:{group_id}' → (task_number, group_id)."""
|
|
||||||
parts = s.split(":", 1)
|
|
||||||
return parts[0], int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
|
|
||||||
|
|
||||||
|
|
||||||
def _current_msg(task_number: str, call) -> tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Возвращает (chat_id, message_id) актуального поста из БД.
|
|
||||||
Если поста нет в БД — использует данные из callback (может быть устаревшим).
|
|
||||||
"""
|
|
||||||
task_id = database.get_task_id_by_number(task_number)
|
|
||||||
if task_id:
|
|
||||||
data = database.get_message_data(task_id)
|
|
||||||
if data:
|
|
||||||
return config.CHANNEL_ID, data["message_id"]
|
|
||||||
return call.message.chat.id, call.message.message_id
|
|
||||||
|
|
||||||
|
|
||||||
def _is_stale_error(e: Exception) -> bool:
|
|
||||||
"""Возвращает True если ошибка означает что сообщение уже удалено или не изменилось."""
|
|
||||||
s = str(e).lower()
|
|
||||||
return any(x in s for x in (
|
|
||||||
"message to edit not found",
|
|
||||||
"message is not modified",
|
|
||||||
"message can't be edited",
|
|
||||||
"bad request",
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
def _restore_keyboard(call, task_number: str, group_id: int):
|
|
||||||
"""Восстанавливает основные кнопки на актуальном посте задачи."""
|
|
||||||
chat_id, msg_id = _current_msg(task_number, call)
|
|
||||||
try:
|
|
||||||
bot.edit_message_reply_markup(
|
|
||||||
chat_id, msg_id,
|
|
||||||
reply_markup=make_task_buttons(task_number, group_id),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
if not _is_stale_error(e):
|
|
||||||
log.warning(f"restore_keyboard failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_responsible(call, task_id: int, task_number: str):
|
|
||||||
"""Редактирует текст актуального поста из БД — обновляет список ответственных."""
|
|
||||||
data = database.get_message_data(task_id)
|
|
||||||
if not data or not data.get("base_text"):
|
|
||||||
return
|
|
||||||
|
|
||||||
responsible = database.get_responsible(task_id)
|
|
||||||
events_lines = database.get_stored_events(task_id)
|
|
||||||
full_text = _build_full_text(data["base_text"], responsible, events_lines, new_count=0)
|
|
||||||
markup = make_task_buttons(task_number, data.get("group_id") or 0)
|
|
||||||
|
|
||||||
try:
|
|
||||||
bot.edit_message_text(
|
|
||||||
full_text,
|
|
||||||
config.CHANNEL_ID,
|
|
||||||
data["message_id"],
|
|
||||||
parse_mode="Markdown",
|
|
||||||
disable_web_page_preview=True,
|
|
||||||
reply_markup=markup,
|
|
||||||
)
|
|
||||||
except ApiTelegramException as e:
|
|
||||||
if not _is_stale_error(e):
|
|
||||||
log.warning(f"_refresh_responsible failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_message(chat_id, message_id):
|
|
||||||
try:
|
|
||||||
bot.delete_message(chat_id, message_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── Гифка для заявок с ТО ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def send_gif_for_to(task_id: int):
|
|
||||||
"""
|
|
||||||
Отправляет гифку для заявки с ТО.
|
|
||||||
Предварительно удаляет предыдущую гифку если была.
|
|
||||||
Если GIF_URL пустой — ничего не делает.
|
|
||||||
"""
|
|
||||||
if not config.GIF_URL:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Удаляем старую гифку
|
|
||||||
old_gif_id = database.get_gif_message_id(task_id)
|
|
||||||
if old_gif_id:
|
|
||||||
_delete_message(config.CHANNEL_ID, old_gif_id)
|
|
||||||
database.delete_gif_message_id(task_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = bot.send_animation(
|
|
||||||
config.CHANNEL_ID,
|
|
||||||
config.GIF_URL,
|
|
||||||
disable_notification=True,
|
|
||||||
)
|
|
||||||
database.save_gif_message_id(task_id, msg.message_id)
|
|
||||||
log.info(f"GIF sent for task {task_id}, message_id={msg.message_id}")
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"Failed to send GIF for task {task_id}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Запуск бота ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def start_bot():
|
def start_bot():
|
||||||
log.info("Telegram bot started (polling)...")
|
log.info("Telegram bot started (polling)...")
|
||||||
|
notifier = TelegramChannelNotifier(bot, config.CHANNEL_ID)
|
||||||
|
poster.set_notifier(notifier)
|
||||||
bot.infinity_polling(timeout=60)
|
bot.infinity_polling(timeout=60)
|
||||||
|
|
|
||||||
44
deploy/docker-compose.yml
Normal file
44
deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# ПРОДАКШН docker-compose.yml — размещать на сервере в /opt/intradbot/
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Первоначальный запуск:
|
||||||
|
# mkdir -p /opt/intradbot/{data,ssl}
|
||||||
|
# cp deploy/docker-compose.yml /opt/intradbot/docker-compose.yml
|
||||||
|
# cp .env.example /opt/intradbot/.env && nano /opt/intradbot/.env
|
||||||
|
# cp /path/to/cert.pem /opt/intradbot/ssl/
|
||||||
|
# cp /path/to/key.pem /opt/intradbot/ssl/
|
||||||
|
# docker login git.example.com
|
||||||
|
# cd /opt/intradbot && docker compose up -d
|
||||||
|
#
|
||||||
|
# Обновление (вручную, если нет CI):
|
||||||
|
# cd /opt/intradbot && docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# Полезные команды:
|
||||||
|
# docker compose logs -f — логи в реальном времени
|
||||||
|
# docker compose ps — статус
|
||||||
|
# docker compose exec intradbot bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
services:
|
||||||
|
intradbot:
|
||||||
|
image: git.example.com/YOUR_USER/intradbot:latest # ← замени на свой хост/юзер
|
||||||
|
restart: always
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # SQLite БД
|
||||||
|
- ./ssl:/app/ssl:ro # cert.pem + key.pem
|
||||||
|
pull_policy: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
|
@ -8,3 +8,9 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./ssl:/app/ssl:ro # ← реальные файлы без симлинков
|
- ./ssl:/app/ssl:ro # ← реальные файлы без симлинков
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
71
gunicorn.conf.py
Normal file
71
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""
|
||||||
|
Конфигурация Gunicorn для продакшена.
|
||||||
|
|
||||||
|
Архитектура:
|
||||||
|
- 1 воркер (workers=1) — обязательно, т.к.:
|
||||||
|
* SQLite не поддерживает несколько независимых процессов без WAL
|
||||||
|
* SSE event bus (events.py) хранит состояние в памяти одного процесса
|
||||||
|
* Notifier (poster.py) инициализируется один раз в памяти воркера
|
||||||
|
- gthread: несколько потоков внутри воркера — позволяет обслуживать
|
||||||
|
одновременно SSE-поток дашборда + вебхуки + API-запросы
|
||||||
|
- post_fork: стартует Telegram-бот и шедулер внутри воркера (не в мастере)
|
||||||
|
|
||||||
|
Локальная разработка: python intradbot.py (Flask dev server)
|
||||||
|
Продакшен (Docker): gunicorn -c gunicorn.conf.py "web.server:create_app()"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# ── Сервер ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bind = f"0.0.0.0:{os.environ.get('PORT', '8080')}"
|
||||||
|
workers = 1 # строго 1 — см. комментарий выше
|
||||||
|
worker_class = "gthread" # многопоточный воркер для SSE + вебхуков одновременно
|
||||||
|
threads = 4 # потоков: SSE-клиенты + API-запросы + запас
|
||||||
|
timeout = 120 # достаточно для долгих SSE-соединений
|
||||||
|
keepalive = 5
|
||||||
|
|
||||||
|
# ── Логирование ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
accesslog = "-" # stdout
|
||||||
|
errorlog = "-" # stderr
|
||||||
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sµs'
|
||||||
|
|
||||||
|
# ── Хуки жизненного цикла ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_starting(server):
|
||||||
|
"""Инициализация до форка воркеров (выполняется в мастер-процессе)."""
|
||||||
|
import config
|
||||||
|
import database
|
||||||
|
from intradesk import reference
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True)
|
||||||
|
database.init_db()
|
||||||
|
reference.seed_static()
|
||||||
|
reference.load_all()
|
||||||
|
server.log.info("Database and reference data initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
"""
|
||||||
|
Выполняется в каждом воркер-процессе после fork.
|
||||||
|
Запускает Telegram-бот и шедулер как daemon-потоки внутри воркера.
|
||||||
|
"""
|
||||||
|
import schedule
|
||||||
|
from intradesk import reference
|
||||||
|
from bot.telegram import start_bot
|
||||||
|
|
||||||
|
threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start()
|
||||||
|
|
||||||
|
schedule.every().monday.at("10:00").do(reference.load_all)
|
||||||
|
|
||||||
|
def _run_scheduler():
|
||||||
|
while True:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
threading.Thread(target=_run_scheduler, daemon=True, name="scheduler").start()
|
||||||
|
worker.log.info("Telegram bot and scheduler started in worker.")
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
"""
|
"""
|
||||||
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
Парсинг и форматирование данных из вебхуков и API Intradesk.
|
||||||
|
|
||||||
|
Добавить парсер нового типа события:
|
||||||
|
@event_type_handler(ваш_тип)
|
||||||
|
def _parse_my_event(event: dict, username: str) -> ParsedEvent | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
Добавить новое boolean доп. поле (ТО, Созвон и т.д.):
|
||||||
|
register_bool_field("addfield_xxxxBoolean", "Название поля")
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Callable, Literal
|
||||||
|
|
||||||
import database
|
import database
|
||||||
from intradesk import reference
|
from intradesk import reference
|
||||||
|
|
@ -20,6 +28,16 @@ _ADDFIELD_BOOL_LABELS: dict[str, str] = {
|
||||||
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
|
"addfield_2684bdczamena_oborudovaniiaBoolean": "Замена оборудования",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_bool_field(blockname: str, label: str) -> None:
|
||||||
|
"""
|
||||||
|
Регистрирует новое boolean доп. поле Intradesk.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
register_bool_field("addfield_abcdef123Boolean", "Срочный выезд")
|
||||||
|
"""
|
||||||
|
_ADDFIELD_BOOL_LABELS[blockname] = label
|
||||||
|
|
||||||
BLOCK_TO_ENTITY: dict[str, str] = {
|
BLOCK_TO_ENTITY: dict[str, str] = {
|
||||||
"status": "statuses",
|
"status": "statuses",
|
||||||
"priority": "priorities",
|
"priority": "priorities",
|
||||||
|
|
@ -134,6 +152,44 @@ def _extract_name(v: dict | None, blockname: str = "") -> str | None:
|
||||||
return str(item_id) if item_id else None
|
return str(item_id) if item_id else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Реестр парсеров специальных типов событий ─────────────────────────────────
|
||||||
|
|
||||||
|
_ETYPE_HANDLERS: dict[int, Callable] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def event_type_handler(*etypes: int):
|
||||||
|
"""
|
||||||
|
Декоратор: регистрирует функцию как парсер событий указанных типов.
|
||||||
|
|
||||||
|
Пример — добавить обработчик типа 99:
|
||||||
|
@event_type_handler(99)
|
||||||
|
def _parse_my_event(event: dict, username: str) -> ParsedEvent | None:
|
||||||
|
return ParsedEvent("change", f"• Мой тип: {event.get('stringvalue')}")
|
||||||
|
"""
|
||||||
|
def decorator(fn: Callable) -> Callable:
|
||||||
|
for etype in etypes:
|
||||||
|
_ETYPE_HANDLERS[etype] = fn
|
||||||
|
return fn
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
# ── Встроенные парсеры специальных типов ──────────────────────────────────────
|
||||||
|
|
||||||
|
@event_type_handler(50, 55)
|
||||||
|
def _parse_comment(event: dict, username: str) -> ParsedEvent | None:
|
||||||
|
"""Комментарий (50) и приватный комментарий (55)."""
|
||||||
|
text = event.get("stringvalue") or ""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
text, had_quote = _strip_quote(text)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
short_user = _shorten_name(username) or "Неизвестный"
|
||||||
|
prefix = "Ответ на прошлое: " if had_quote else ""
|
||||||
|
lock = " 🔒" if event.get("type") == 55 else ""
|
||||||
|
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
|
||||||
|
|
||||||
|
|
||||||
# ── Ядро: разбор одного события ───────────────────────────────────────────────
|
# ── Ядро: разбор одного события ───────────────────────────────────────────────
|
||||||
|
|
||||||
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
|
|
@ -142,22 +198,16 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
|
|
||||||
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
Используется как в parse_lifetime() (вебхук), так и в format_lifetime_api_events()
|
||||||
(прямой API). Единственный источник правды для логики разбора событий.
|
(прямой API). Единственный источник правды для логики разбора событий.
|
||||||
|
|
||||||
|
Специальные типы событий (50, 55, ...) обрабатываются через реестр
|
||||||
|
_ETYPE_HANDLERS. Для добавления нового типа используй @event_type_handler.
|
||||||
"""
|
"""
|
||||||
etype = event.get("type")
|
etype = event.get("type")
|
||||||
blockname = event.get("blockname", "")
|
blockname = event.get("blockname", "")
|
||||||
short_user = _shorten_name(username) or "Неизвестный"
|
|
||||||
|
|
||||||
# ── Комментарии ──────────────────────────────────────────────────────────
|
# ── Специальные обработчики по типу события ───────────────────────────────
|
||||||
if etype in (50, 55):
|
if etype in _ETYPE_HANDLERS:
|
||||||
text = event.get("stringvalue") or ""
|
return _ETYPE_HANDLERS[etype](event, username)
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
text, had_quote = _strip_quote(text)
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
prefix = "Ответ на прошлое: " if had_quote else ""
|
|
||||||
lock = " 🔒" if etype == 55 else ""
|
|
||||||
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
|
|
||||||
|
|
||||||
if blockname in IGNORED_FIELDS:
|
if blockname in IGNORED_FIELDS:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import datetime
|
||||||
|
|
||||||
import database
|
import database
|
||||||
import events
|
import events
|
||||||
from bot import telegram as tg
|
from bot import poster
|
||||||
from intradesk import formatter
|
from intradesk import formatter
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
@ -54,7 +54,7 @@ def handle_webhook(payload: dict):
|
||||||
"formatted": base_text,
|
"formatted": base_text,
|
||||||
})
|
})
|
||||||
|
|
||||||
tg.recreate_post(
|
poster.recreate_post(
|
||||||
task_id, str(number), base_text, group_id,
|
task_id, str(number), base_text, group_id,
|
||||||
closed=closed, new_event_lines=new_event_lines,
|
closed=closed, new_event_lines=new_event_lines,
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +62,7 @@ def handle_webhook(payload: dict):
|
||||||
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
# ── Гифка для ТО ──────────────────────────────────────────────────────
|
||||||
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
# Отправляем (и удаляем предыдущую) при любом вебхуке если ТО = да
|
||||||
if is_to_now:
|
if is_to_now:
|
||||||
tg.send_gif_for_to(task_id)
|
poster.send_gif_for_to(task_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Error while handling webhook")
|
log.exception("Error while handling webhook")
|
||||||
|
|
|
||||||
59
scripts/rollback.sh
Normal file
59
scripts/rollback.sh
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Откат к предыдущему образу
|
||||||
|
#
|
||||||
|
# Использование (на сервере):
|
||||||
|
# bash /opt/intradbot/scripts/rollback.sh
|
||||||
|
#
|
||||||
|
# Как работает:
|
||||||
|
# Перед каждым деплоем Woodpecker сохраняет ID текущего образа
|
||||||
|
# в /opt/intradbot/.previous_image.
|
||||||
|
# Этот скрипт перетегирует сохранённый образ как :latest и перезапускает
|
||||||
|
# контейнер без обращения к registry.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DEPLOY_DIR="/opt/intradbot"
|
||||||
|
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml"
|
||||||
|
PREV_FILE="$DEPLOY_DIR/.previous_image"
|
||||||
|
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$PREV_FILE" ]; then
|
||||||
|
echo "ERROR: файл $PREV_FILE не найден — предыдущий деплой не был записан"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREV_IMAGE=$(cat "$PREV_FILE" | tr -d '[:space:]')
|
||||||
|
if [ -z "$PREV_IMAGE" ]; then
|
||||||
|
echo "ERROR: $PREV_FILE пустой"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем, что образ ещё есть локально (не был удалён docker prune)
|
||||||
|
if ! docker image inspect "$PREV_IMAGE" > /dev/null 2>&1; then
|
||||||
|
echo "ERROR: образ $PREV_IMAGE не найден локально — используй ручной откат:"
|
||||||
|
echo " docker compose pull # затем подставь нужный sha-тег в docker-compose.yml"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Определяем полное имя образа из docker-compose.yml
|
||||||
|
TARGET_IMAGE=$(grep 'image:' "$COMPOSE_FILE" | awk '{print $2}' | head -1)
|
||||||
|
if [ -z "$TARGET_IMAGE" ]; then
|
||||||
|
echo "ERROR: не удалось извлечь image из $COMPOSE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Откат:"
|
||||||
|
echo " образ сейчас : $(docker compose ps -q intradbot 2>/dev/null | xargs -r docker inspect --format='{{.Image}}' | head -1 || echo 'нет контейнера')"
|
||||||
|
echo " откатываемся : $PREV_IMAGE"
|
||||||
|
|
||||||
|
# Перетегируем старый образ как :latest локально
|
||||||
|
docker tag "$PREV_IMAGE" "$TARGET_IMAGE"
|
||||||
|
|
||||||
|
# Перезапускаем без pull (используем локальный образ)
|
||||||
|
docker compose up -d --no-build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Откат выполнен. Текущий образ: $TARGET_IMAGE"
|
||||||
|
echo "Для проверки: docker compose ps"
|
||||||
Loading…
Add table
Reference in a new issue