Compare commits
6 commits
master-bac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b1643f53 | |||
| 585cc374ed | |||
| 43607bf9d0 | |||
| 570e6a3fed | |||
| 858acfbf5c | |||
| 5770829021 |
10 changed files with 373 additions and 17 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -18,3 +18,6 @@
|
||||||
*.secret
|
*.secret
|
||||||
secrets.json
|
secrets.json
|
||||||
config.local.*
|
config.local.*
|
||||||
|
|
||||||
|
#
|
||||||
|
intradbot-docker.zip
|
||||||
96
.woodpecker.yml
Normal file
96
.woodpecker.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Woodpecker CI Pipeline
|
||||||
|
#
|
||||||
|
# Шаги:
|
||||||
|
# 1. docker-build — сборка и пуш образа (push в main)
|
||||||
|
# 2. docker-build-tag — сборка и пуш образа с semver-тегом (git tag v*)
|
||||||
|
# 3. deploy — деплой на сервер (выполняется на агенте role=work_woodpecker)
|
||||||
|
#
|
||||||
|
# Секреты (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}"
|
||||||
|
cache_from: "git.ihateamerica.ru/dv/intrabot:latest"
|
||||||
|
build_args:
|
||||||
|
- "GIT_COMMIT=${CI_COMMIT_SHA:0:8}"
|
||||||
|
- "BUILDKIT_INLINE_CACHE=1"
|
||||||
|
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}" # всегда — нужно для отката
|
||||||
|
cache_from: "git.ihateamerica.ru/dv/intrabot:latest"
|
||||||
|
build_args:
|
||||||
|
- "GIT_COMMIT=${CI_COMMIT_SHA:0:8}"
|
||||||
|
- "BUILDKIT_INLINE_CACHE=1"
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
# ── 2. Деплой на продакшн-сервер ──────────────────────────────────────────
|
||||||
|
# Выполняется на Woodpecker-агенте с меткой role=work_woodpecker.
|
||||||
|
# Требует: репозиторий помечен «trusted» в настройках Woodpecker-сервера
|
||||||
|
# (иначе монтирование volumes запрещено).
|
||||||
|
- name: deploy
|
||||||
|
image: docker:cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /opt/intrabot:/deploy
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set -e
|
||||||
|
cd /deploy
|
||||||
|
|
||||||
|
# Сохраняем ID текущего контейнерного образа (для отката)
|
||||||
|
CONTAINER=$(docker compose ps -q intrabot 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 -p intrabot pull
|
||||||
|
docker compose -p intrabot up -d --remove-orphans
|
||||||
|
|
||||||
|
echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})"
|
||||||
|
labels:
|
||||||
|
role: aeza_woodpecker
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: main
|
||||||
|
- event: tag
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -7,9 +7,14 @@ RUN mkdir -p /app/data
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
ARG GIT_COMMIT=dev
|
||||||
|
ARG DOCKER_IMAGE_CREATED=unknown
|
||||||
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
ENV BUILD_TIME=$DOCKER_IMAGE_CREATED
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Gunicorn запускает 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()"]
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,10 @@ def _build_full_text(
|
||||||
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
|
result += "\n\n🆕 *Новое:*\n" + "\n".join(newest)
|
||||||
|
|
||||||
if responsible:
|
if responsible:
|
||||||
names = ", ".join(r["user_name"] for r in responsible)
|
names = ", ".join(
|
||||||
|
formatter.escape_markdown(r["user_name"]) or r["user_name"]
|
||||||
|
for r in responsible
|
||||||
|
)
|
||||||
result += _RESP_SEP + f"\n🙋 В работе: {names}"
|
result += _RESP_SEP + f"\n🙋 В работе: {names}"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -166,11 +169,35 @@ def recreate_post(
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
|
log.info(f"recreate_post sent for task {task_id}, message_id={msg.message_id}")
|
||||||
# save_message_id сначала создаёт/обновляет строку, потом сохраняем события
|
# save_message_id сначала создаёт/обновляет строку, потом сохраняем события
|
||||||
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
|
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
|
||||||
if added:
|
if added:
|
||||||
database.save_stored_events(task_id, stored)
|
database.save_stored_events(task_id, stored)
|
||||||
except ApiTelegramException as e:
|
except ApiTelegramException as e:
|
||||||
|
err_text = str(e).lower()
|
||||||
|
is_markdown_error = any(x in err_text for x in (
|
||||||
|
"can't parse entities",
|
||||||
|
"can't find end of the entity",
|
||||||
|
"entity starting at byte offset",
|
||||||
|
))
|
||||||
|
if is_markdown_error:
|
||||||
|
log.error(f"recreate_post markdown failed for task {task_id}, fallback to plain text: {e}")
|
||||||
|
try:
|
||||||
|
msg = bot.send_message(
|
||||||
|
config.CHANNEL_ID,
|
||||||
|
full_text,
|
||||||
|
parse_mode=None,
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=markup,
|
||||||
|
)
|
||||||
|
database.save_message_id(task_id, msg.message_id, task_number, group_id, base_text)
|
||||||
|
if added:
|
||||||
|
database.save_stored_events(task_id, stored)
|
||||||
|
log.warning(f"recreate_post sent without markdown for task {task_id}, message_id={msg.message_id}")
|
||||||
|
except ApiTelegramException as e2:
|
||||||
|
log.error(f"recreate_post fallback failed for task {task_id}: {e2}")
|
||||||
|
else:
|
||||||
log.error(f"recreate_post failed for task {task_id}: {e}")
|
log.error(f"recreate_post failed for task {task_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -335,7 +362,18 @@ def _is_stale_error(e: Exception) -> bool:
|
||||||
"message to edit not found",
|
"message to edit not found",
|
||||||
"message is not modified",
|
"message is not modified",
|
||||||
"message can't be edited",
|
"message can't be edited",
|
||||||
"bad request",
|
"message to delete not found",
|
||||||
|
"message_id_invalid",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_message_error(e: Exception) -> bool:
|
||||||
|
"""True for Telegram errors when target message no longer exists."""
|
||||||
|
s = str(e).lower()
|
||||||
|
return any(x in s for x in (
|
||||||
|
"message to edit not found",
|
||||||
|
"message to delete not found",
|
||||||
|
"message_id_invalid",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -373,6 +411,18 @@ def _refresh_responsible(call, task_id: int, task_number: str):
|
||||||
reply_markup=markup,
|
reply_markup=markup,
|
||||||
)
|
)
|
||||||
except ApiTelegramException as e:
|
except ApiTelegramException as e:
|
||||||
|
if _is_missing_message_error(e):
|
||||||
|
log.warning(
|
||||||
|
f"_refresh_responsible: message missing for task {task_id}, recreating post"
|
||||||
|
)
|
||||||
|
recreate_post(
|
||||||
|
task_id=task_id,
|
||||||
|
task_number=task_number,
|
||||||
|
base_text=data["base_text"],
|
||||||
|
group_id=data.get("group_id") or 0,
|
||||||
|
new_event_lines=None,
|
||||||
|
)
|
||||||
|
return
|
||||||
if not _is_stale_error(e):
|
if not _is_stale_error(e):
|
||||||
log.warning(f"_refresh_responsible failed: {e}")
|
log.warning(f"_refresh_responsible failed: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
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.ihateamerica.ru/dv/intrabot: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
|
||||||
78
gunicorn.conf.py
Normal file
78
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""
|
||||||
|
Конфигурация 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 logging
|
||||||
|
import config
|
||||||
|
import database
|
||||||
|
from intradesk import reference
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
handlers=[logging.StreamHandler()],
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True)
|
||||||
|
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,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Точка входа IntradBot.
|
Точка входа IntraBot.
|
||||||
|
|
||||||
Запускает три компонента:
|
Запускает три компонента:
|
||||||
1. Telegram бот (polling) — в отдельном потоке
|
1. Telegram бот (polling) — в отдельном потоке
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,15 @@ def _shorten_name(name: str | None) -> str | None:
|
||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def escape_markdown(text: str | None) -> str | None:
|
||||||
|
"""Escape user-provided text for Telegram legacy Markdown parse mode."""
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
escaped = str(text).replace("\\", "\\\\")
|
||||||
|
for ch in ("_", "*", "`", "["):
|
||||||
|
escaped = escaped.replace(ch, f"\\{ch}")
|
||||||
|
return escaped
|
||||||
|
|
||||||
def _strip_quote(text: str) -> tuple[str, bool]:
|
def _strip_quote(text: str) -> tuple[str, bool]:
|
||||||
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
|
"""Убирает блоки цитирования. Возвращает (cleaned_text, had_quote)."""
|
||||||
cleaned, n = _QUOTE_RE.subn("", text)
|
cleaned, n = _QUOTE_RE.subn("", text)
|
||||||
|
|
@ -146,6 +155,7 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
etype = event.get("type")
|
etype = event.get("type")
|
||||||
blockname = event.get("blockname", "")
|
blockname = event.get("blockname", "")
|
||||||
short_user = _shorten_name(username) or "Неизвестный"
|
short_user = _shorten_name(username) or "Неизвестный"
|
||||||
|
short_user = escape_markdown(short_user) or short_user
|
||||||
|
|
||||||
# ── Комментарии ──────────────────────────────────────────────────────────
|
# ── Комментарии ──────────────────────────────────────────────────────────
|
||||||
if etype in (50, 55):
|
if etype in (50, 55):
|
||||||
|
|
@ -155,6 +165,7 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
text, had_quote = _strip_quote(text)
|
text, had_quote = _strip_quote(text)
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
text = escape_markdown(text) or ""
|
||||||
prefix = "Ответ на прошлое: " if had_quote else ""
|
prefix = "Ответ на прошлое: " if had_quote else ""
|
||||||
lock = " 🔒" if etype == 55 else ""
|
lock = " 🔒" if etype == 55 else ""
|
||||||
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
|
return ParsedEvent("comment", f"*{short_user}*{lock}: {prefix}{text}")
|
||||||
|
|
@ -187,8 +198,8 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
if entity_data:
|
if entity_data:
|
||||||
val_new = entity_data.get("valuenew") or {}
|
val_new = entity_data.get("valuenew") or {}
|
||||||
val_old = entity_data.get("valueold")
|
val_old = entity_data.get("valueold")
|
||||||
new_str = _extract_name(val_new, blockname)
|
new_str = escape_markdown(_extract_name(val_new, blockname))
|
||||||
old_str = _extract_name(val_old, blockname) if val_old else None
|
old_str = escape_markdown(_extract_name(val_old, blockname)) if val_old else None
|
||||||
if not new_str and not old_str:
|
if not new_str and not old_str:
|
||||||
return None
|
return None
|
||||||
if new_str and old_str:
|
if new_str and old_str:
|
||||||
|
|
@ -205,11 +216,14 @@ def _parse_single_event(event: dict, username: str = "") -> ParsedEvent | None:
|
||||||
if isinstance(attach, dict):
|
if isinstance(attach, dict):
|
||||||
fname = attach.get("Name", "")
|
fname = attach.get("Name", "")
|
||||||
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
|
uploader = _shorten_name(attach.get("UploadedBy", "").strip() or None) or "?"
|
||||||
|
fname = escape_markdown(fname) or ""
|
||||||
|
uploader = escape_markdown(uploader) or "?"
|
||||||
return ParsedEvent("change", f"• 📎 {uploader} прикрепил: {fname}")
|
return ParsedEvent("change", f"• 📎 {uploader} прикрепил: {fname}")
|
||||||
m = _FILE_EXT_RE.search(str_val.strip())
|
m = _FILE_EXT_RE.search(str_val.strip())
|
||||||
ext = m.group(1).upper() if m else "файл"
|
ext = m.group(1).upper() if m else "файл"
|
||||||
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
|
return ParsedEvent("change", f"• 📎 Добавлен файл .{ext}")
|
||||||
truncated = str_val[:80] + ("…" if len(str_val) > 80 else "")
|
truncated = str_val[:80] + ("…" if len(str_val) > 80 else "")
|
||||||
|
truncated = escape_markdown(truncated) or ""
|
||||||
return ParsedEvent("change", f"• {label}: *{truncated}*")
|
return ParsedEvent("change", f"• {label}: *{truncated}*")
|
||||||
|
|
||||||
# ── Событие без значения ──────────────────────────────────────────────────
|
# ── Событие без значения ──────────────────────────────────────────────────
|
||||||
|
|
@ -230,6 +244,7 @@ def get_description_event(fields: dict) -> str | None:
|
||||||
if not description:
|
if not description:
|
||||||
return None
|
return None
|
||||||
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
truncated = description[:100] + ('…' if len(description) > 100 else '')
|
||||||
|
truncated = escape_markdown(truncated) or ""
|
||||||
return f"📝 {truncated}"
|
return f"📝 {truncated}"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,12 +389,12 @@ def format_notification(payload: dict) -> str:
|
||||||
op_type = payload.get("Event", {}).get("OperationType", "")
|
op_type = payload.get("Event", {}).get("OperationType", "")
|
||||||
fields = payload.get("Fields", {})
|
fields = payload.get("Fields", {})
|
||||||
number = fields.get("Number", "?")
|
number = fields.get("Number", "?")
|
||||||
name = _scalar(fields.get("name")) or "—"
|
name = escape_markdown(_scalar(fields.get("name")) or "—") or "-"
|
||||||
applicant = get_initiator(fields)
|
applicant = escape_markdown(get_initiator(fields))
|
||||||
executors = get_all_executors(fields)
|
executors = [escape_markdown(x) or "" for x in get_all_executors(fields)]
|
||||||
obj_name = get_object_name(fields)
|
obj_name = escape_markdown(get_object_name(fields))
|
||||||
to_flag = is_to(fields)
|
to_flag = is_to(fields)
|
||||||
status = _resolve_field(fields, "status", "statuses")
|
status = escape_markdown(_resolve_field(fields, "status", "statuses"))
|
||||||
|
|
||||||
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
header = f"🆕 *Новая #{number}*" if op_type == "Created" \
|
||||||
else f"🔄 *#{number} обновлена*"
|
else f"🔄 *#{number} обновлена*"
|
||||||
|
|
|
||||||
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