This commit is contained in:
dv 2026-03-15 21:36:03 +03:00
parent e94e1a3faa
commit 02143a9b48
15 changed files with 1045 additions and 427 deletions

3
.gitignore vendored
View file

@ -18,3 +18,6 @@
*.secret *.secret
secrets.json secrets.json
config.local.* config.local.*
#
intradbot-docker.zip

88
.woodpecker.yml Normal file
View 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

View file

@ -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
View 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
View 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
View 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
View 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
View 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}")

View file

@ -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
View 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"

View file

@ -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
View 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.")

View file

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

View file

@ -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
View 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"