Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
dv
f5b1643f53 еще попытка фикса гцупа
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-08 11:00:31 +03:00
dv
585cc374ed фикс заявок с гцупом
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-06 15:36:26 +03:00
dv
43607bf9d0 123
All checks were successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-20 11:30:23 +03:00
dv
570e6a3fed fix deploy
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 17:44:16 +03:00
dv
858acfbf5c ???
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-18 15:38:05 +03:00
dv
5770829021 попытка отката 2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
ci/woodpecker/deployment/woodpecker Pipeline was successful
2026-03-16 00:50:30 +03:00
10 changed files with 373 additions and 17 deletions

3
.gitignore vendored
View file

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

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

View file

@ -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()"]

View file

@ -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,12 +169,36 @@ 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:
log.error(f"recreate_post failed for task {task_id}: {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}")
# ── Callbacks: смена статуса ────────────────────────────────────────────────── # ── Callbacks: смена статуса ──────────────────────────────────────────────────
@ -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
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.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"

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

78
gunicorn.conf.py Normal file
View 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.")

View file

@ -1,5 +1,5 @@
""" """
Точка входа IntradBot. Точка входа IntraBot.
Запускает три компонента: Запускает три компонента:
1. Telegram бот (polling) в отдельном потоке 1. Telegram бот (polling) в отдельном потоке

View file

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