Files
ACORE/apps/feed/services.py
T
lokses 4f3fccb68d Mattermost: мульти-бот по ролям (provisioning/channels/tasks)
Заменяет единый 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>
2026-07-23 15:31:51 +03:00

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