4f3fccb68d
Заменяет единый cfg.token + extra_config['bot_token'] на модель MattermostBot (роль, username, user_id, is_active, token как EncryptedCharField — токены шифруются в БД). - MattermostService(role='provisioning') — синхронизация сотрудников (system-bot) - pharmacy_channel_service -> role='channels' (neo) — операции с каналами - bot_service + feed -> role='tasks' (link) — уведомления задач и посты ленты - Админка MattermostBot; команда create_mattermost_bot пишет в MattermostBot по --role Токены засеваются в БД (не в git). Смена темы Матрица -> Кремниевая долина в display_name (Гилфойл/Динеш/Ричард), код от имён не зависит. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""Публикация новостей портала в Mattermost (кнопка «Опублікувати в чат»)."""
|
|
import logging
|
|
import mimetypes
|
|
import re
|
|
from html import unescape
|
|
|
|
from django.conf import settings
|
|
from django.core.files.storage import default_storage
|
|
from django.utils import timezone
|
|
from django.utils.html import strip_tags
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Команди Mattermost, які можна вибрати чекбоксами на формі створення/редагування —
|
|
# {team_name: підпис для чекбокса}. Порядок — порядок відображення на формі.
|
|
TARGET_TEAMS = {
|
|
'akc': 'Офіс',
|
|
'pharmacys': 'Аптеки',
|
|
'warehouse': 'Склад',
|
|
}
|
|
|
|
CATEGORY_EMOJI = {
|
|
'general': '📰',
|
|
'hr': '🧑💼',
|
|
'it': '💻',
|
|
'network': '🏥',
|
|
}
|
|
|
|
# Mattermost за замовчуванням ріже повідомлення довші за 16383 символи —
|
|
# лишаємо запас під заголовок/посилання і не намагаємось влізти впритул.
|
|
MAX_BODY_LENGTH = 15000
|
|
# Mattermost за замовчуванням дозволяє не більше 10 файлів на пост (FileSettings.MaxFileCount)
|
|
MAX_IMAGES = 10
|
|
|
|
_BLOCK_BREAK_RE = re.compile(r'</(p|div|li|h[1-6]|tr)>|<br\s*/?>', re.IGNORECASE)
|
|
_BLANK_LINES_RE = re.compile(r'\n{3,}')
|
|
_IMG_SRC_RE = re.compile(r'<img[^>]+src="([^"]+)"', re.IGNORECASE)
|
|
|
|
|
|
def _extract_local_image_paths(html: str) -> list[str]:
|
|
"""Шляхи (відносно MEDIA_ROOT) картинок з тіла поста, завантажених через
|
|
news_upload_image_view (TinyMCE). Зовнішні URL — ігноруємо, їх нема на диску."""
|
|
media_url = settings.MEDIA_URL
|
|
paths = []
|
|
for src in _IMG_SRC_RE.findall(html):
|
|
idx = src.find(media_url)
|
|
if idx == -1:
|
|
continue
|
|
path = src[idx + len(media_url):]
|
|
if path and path not in paths:
|
|
paths.append(path)
|
|
return paths[:MAX_IMAGES]
|
|
|
|
|
|
def _upload_images_for_channel(channel_id: str, image_paths: list[str]) -> list[str]:
|
|
"""Залити картинки поста в конкретний канал (файли Mattermost прив'язані
|
|
до каналу завантаження) і повернути file_ids для create_post/patch_post."""
|
|
from apps.mattermost_connector.services.bot_service import upload_channel_file
|
|
|
|
file_ids = []
|
|
for path in image_paths:
|
|
if not default_storage.exists(path):
|
|
continue
|
|
try:
|
|
with default_storage.open(path, 'rb') as f:
|
|
content = f.read()
|
|
except Exception as e:
|
|
logger.warning(f'Не удалось прочитать файл {path} для публикации в Mattermost: {e}')
|
|
continue
|
|
|
|
filename = path.rsplit('/', 1)[-1]
|
|
content_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
|
file_id = upload_channel_file(channel_id, filename, content, content_type)
|
|
if file_id:
|
|
file_ids.append(file_id)
|
|
return file_ids
|
|
|
|
|
|
def _html_to_text(html: str) -> str:
|
|
"""Повний текст новини для чату: зберігаємо розбиття на абзаци/рядки
|
|
(інакше strip_tags склеює весь HTML в суцільний текст без пробілів)."""
|
|
text = _BLOCK_BREAK_RE.sub('\n', html)
|
|
text = unescape(strip_tags(text))
|
|
text = _BLANK_LINES_RE.sub('\n\n', text)
|
|
return text.strip()
|
|
|
|
|
|
def _build_message(post) -> str:
|
|
url = f'{settings.PORTAL_BASE_URL.rstrip("/")}/news/{post.pk}/'
|
|
body = _html_to_text(post.body)
|
|
if len(body) > MAX_BODY_LENGTH:
|
|
body = body[:MAX_BODY_LENGTH].rstrip() + f'…\n\n[Читати повністю]({url})'
|
|
emoji = CATEGORY_EMOJI.get(post.category, '📰')
|
|
important = ' 🔥 Важливо!' if post.is_important else ''
|
|
return f'{emoji} **{post.title}**{important}\n\n{body}\n\n[Відкрити на порталі]({url})'
|
|
|
|
|
|
def publish_post_to_mattermost(post) -> dict:
|
|
"""Постить новину в town-square команд, обраних чекбоксами на формі
|
|
(post.mattermost_targets — список кодів команд з TARGET_TEAMS), разом із
|
|
картинками з тіла поста (заливаються в Mattermost як файли — працює навіть
|
|
якщо портал не публічний, на відміну від посилань на media).
|
|
Якщо повідомлення в цій команді вже публікувалось раніше (є id в
|
|
mattermost_post_ids) — редагує його замість того, щоб плодити дублі;
|
|
якщо редагування не вдалось (напр. повідомлення видалили в Mattermost) —
|
|
публікує нове і оновлює id. Команди, які прибрали з targets при редагуванні,
|
|
НЕ видаляються автоматично — старе повідомлення просто перестає оновлюватись.
|
|
Повертає {team_name: True/False} — успіх публікації в кожній команді."""
|
|
from apps.mattermost_connector.models import MattermostChannel
|
|
from apps.mattermost_connector.services.bot_service import create_channel_post, update_channel_post
|
|
|
|
team_names = post.mattermost_targets or []
|
|
if not team_names:
|
|
return {}
|
|
message = _build_message(post)
|
|
image_paths = _extract_local_image_paths(post.body)
|
|
post_ids = dict(post.mattermost_post_ids or {})
|
|
|
|
results = {}
|
|
for team_name in team_names:
|
|
channel = MattermostChannel.objects.filter(
|
|
team__name=team_name, name='town-square',
|
|
).first()
|
|
if not channel:
|
|
results[team_name] = False
|
|
continue
|
|
|
|
file_ids = _upload_images_for_channel(channel.channel_id, image_paths) if image_paths else []
|
|
|
|
existing_id = post_ids.get(team_name)
|
|
if existing_id and update_channel_post(existing_id, message, file_ids=file_ids or None):
|
|
results[team_name] = True
|
|
continue
|
|
|
|
new_id = create_channel_post(channel.channel_id, message, file_ids=file_ids or None)
|
|
if new_id:
|
|
post_ids[team_name] = new_id
|
|
results[team_name] = bool(new_id)
|
|
|
|
if any(results.values()):
|
|
post.mattermost_post_ids = post_ids
|
|
post.mattermost_posted_at = timezone.now()
|
|
post.save(update_fields=['mattermost_post_ids', 'mattermost_posted_at'])
|
|
|
|
return results
|
|
|
|
|
|
def delete_post_from_mattermost(post) -> None:
|
|
"""Видалити опубліковані повідомлення в усіх каналах — викликається перед
|
|
Post.delete() з порталу. Mattermost сам видаляє репліки (коментарі) разом
|
|
з кореневим повідомленням, окремо чистити mattermost_reply_ids не треба."""
|
|
if not post.mattermost_post_ids:
|
|
return
|
|
|
|
from apps.mattermost_connector.services.bot_service import delete_channel_post
|
|
|
|
for post_id in post.mattermost_post_ids.values():
|
|
delete_channel_post(post_id)
|
|
|
|
|
|
def push_comment_to_mattermost_thread(comment) -> None:
|
|
"""Відповідь на коментар з порталу — в тред під новиною в кожній команді,
|
|
куди новина була опублікована. Викликається сигналом при створенні
|
|
PostComment (apps/feed/signals.py) — ТІЛЬКИ для коментарів з порталу,
|
|
не для тих, що самі підтягнуті з Mattermost (mattermost_source_id)."""
|
|
from apps.mattermost_connector.models import MattermostChannel
|
|
from apps.mattermost_connector.services.bot_service import create_channel_post
|
|
|
|
post = comment.post
|
|
if not post.mattermost_post_ids:
|
|
return
|
|
|
|
author_name = comment.author.get_full_name() if comment.author else 'Хтось на порталі'
|
|
message = f'💬 **{author_name}** (з порталу):\n{comment.text}'
|
|
|
|
reply_ids = {}
|
|
for team_name, root_id in post.mattermost_post_ids.items():
|
|
channel = MattermostChannel.objects.filter(team__name=team_name, name='town-square').first()
|
|
if not channel:
|
|
continue
|
|
reply_id = create_channel_post(channel.channel_id, message, root_id=root_id)
|
|
if reply_id:
|
|
reply_ids[team_name] = reply_id
|
|
|
|
if reply_ids:
|
|
comment.mattermost_reply_ids = reply_ids
|
|
comment.save(update_fields=['mattermost_reply_ids'])
|
|
|
|
|
|
def sync_comments_from_mattermost(post) -> int:
|
|
"""Підтягнути нові відповіді з тредів Mattermost як PostComment.
|
|
Викликається при кожному відкритті news_detail — без Celery/webhook.
|
|
Повертає кількість імпортованих коментарів."""
|
|
if not post.mattermost_post_ids:
|
|
return 0
|
|
|
|
from apps.employees.models.base import Employee
|
|
from apps.feed.models import PostComment
|
|
from apps.mattermost_connector.services.bot_service import get_thread_replies
|
|
from apps.mattermost_connector.models import MattermostBot
|
|
|
|
bot = MattermostBot.get_active(MattermostBot.ROLE_TASKS)
|
|
bot_user_id = bot.user_id if bot else None
|
|
|
|
known_ids = set()
|
|
for c in post.comments.all():
|
|
known_ids.update((c.mattermost_reply_ids or {}).values())
|
|
if c.mattermost_source_id:
|
|
known_ids.add(c.mattermost_source_id)
|
|
|
|
imported = 0
|
|
for root_id in post.mattermost_post_ids.values():
|
|
for reply in get_thread_replies(root_id):
|
|
reply_id = reply['id']
|
|
if reply_id in known_ids:
|
|
continue
|
|
if bot_user_id and reply.get('user_id') == bot_user_id:
|
|
continue # власні службові повідомлення бота — не коментар
|
|
|
|
employee = Employee.objects.filter(mattermost_user_id=reply.get('user_id')).first()
|
|
if not employee:
|
|
continue # не змогли зіставити автора — пропускаємо, а не пишемо "невідомо"
|
|
|
|
text = (reply.get('message') or '').strip()
|
|
if not text:
|
|
continue
|
|
|
|
new_comment = PostComment.objects.create(
|
|
post=post, author=employee, text=text, mattermost_source_id=reply_id,
|
|
)
|
|
create_at_ms = reply.get('create_at')
|
|
if create_at_ms:
|
|
from datetime import datetime, timezone as dt_timezone
|
|
actual_dt = datetime.fromtimestamp(create_at_ms / 1000, tz=dt_timezone.utc)
|
|
PostComment.objects.filter(pk=new_comment.pk).update(created_at=actual_dt)
|
|
|
|
known_ids.add(reply_id)
|
|
imported += 1
|
|
|
|
return imported
|