chat/apps/chat/integrations/mattermost/client.py
2026-07-21 16:08:37 +03:30

226 lines
8.3 KiB
Python

import secrets
import string
from urllib.parse import urlparse
from uuid import UUID
from django.conf import settings
from mattermostdriver import Driver
from mattermostdriver.exceptions import ResourceNotFound
from apps.chat.integrations.mattermost.exceptions import MattermostError
class MattermostClient:
"""
Thin adapter around the Mattermost API.
All calls use a single service-level admin token — no per-user tokens.
Mattermost internal IDs (user IDs, channel IDs, post IDs) are opaque
strings from the perspective of callers; they must never appear in public
API responses or service-layer interfaces.
"""
def __init__(self):
url = getattr(settings, "MATTERMOST_URL", None)
token = getattr(settings, "MATTERMOST_TOKEN", None)
if not url:
raise MattermostError("MATTERMOST_URL is not configured")
if not token:
raise MattermostError("MATTERMOST_TOKEN is not configured")
parsed = urlparse(url)
scheme = parsed.scheme or "https"
hostname = parsed.hostname or url
port = parsed.port or (443 if scheme == "https" else 80)
self._team_id: str | None = getattr(settings, "MATTERMOST_TEAM_ID", None) or None
self._driver = Driver(
{
"url": hostname,
"token": token,
"scheme": scheme,
"port": port,
"verify": True,
"debug": False,
}
)
self._driver.login()
# ------------------------------------------------------------------
# Internal helpers — username and email are deterministic from UUID
# so the same user always maps to the same Mattermost account.
# ------------------------------------------------------------------
def _username_for(self, user_id: UUID) -> str:
return f"chat-{UUID(str(user_id)).hex}"
def _email_for(self, user_id: UUID) -> str:
return f"chat-{UUID(str(user_id)).hex}@mm.internal"
@staticmethod
def _random_password() -> str:
alphabet = string.ascii_letters + string.digits + "!@#$%"
return "".join(secrets.choice(alphabet) for _ in range(32))
# ------------------------------------------------------------------
# Public API — return only opaque strings, never raw Mattermost dicts
# with identifying fields surfaced beyond this layer.
# ------------------------------------------------------------------
def _ensure_team_member(self, mm_user_id: str) -> None:
if not self._team_id:
return
try:
self._driver.teams.add_user_to_team(
self._team_id, options={"team_id": self._team_id, "user_id": mm_user_id}
)
except Exception:
pass # already a member or team not configured — not fatal
def get_or_create_user(self, user_id: UUID) -> str:
"""
Ensure a Mattermost account exists for the given external UUID.
Returns the opaque mattermost_user_id string.
"""
username = self._username_for(user_id)
try:
user = self._driver.users.get_user_by_username(username)
mm_user_id = user["id"]
self._ensure_team_member(mm_user_id)
return mm_user_id
except ResourceNotFound:
pass
except Exception as exc:
raise MattermostError(f"User lookup failed: {exc}") from exc
try:
user = self._driver.users.create_user(
options={
"username": username,
"email": self._email_for(user_id),
"password": self._random_password(),
}
)
mm_user_id = user["id"]
self._ensure_team_member(mm_user_id)
return mm_user_id
except Exception as exc:
# Guard against a concurrent request having created the account
# between our lookup and our create attempt.
try:
user = self._driver.users.get_user_by_username(username)
mm_user_id = user["id"]
self._ensure_team_member(mm_user_id)
return mm_user_id
except Exception:
pass
raise MattermostError(f"User creation failed: {exc}") from exc
def create_private_channel(self, mm_user_ids: list[str]) -> str:
"""
Create a new private Mattermost channel and add all members.
Each call always creates a brand-new channel.
Returns the opaque mattermost_channel_id string.
"""
if not self._team_id:
raise MattermostError("MATTERMOST_TEAM_ID is not configured")
channel_name = f"conv-{secrets.token_hex(8)}"
try:
channel = self._driver.channels.create_channel(
options={
"team_id": self._team_id,
"name": channel_name,
"display_name": "Conversation",
"type": "P", # P = private channel (not a DM)
}
)
except Exception as exc:
raise MattermostError(f"Channel creation failed: {exc}") from exc
channel_id: str = channel["id"]
for mm_user_id in mm_user_ids:
try:
self._driver.channels.add_user(
channel_id, options={"user_id": mm_user_id}
)
except Exception as exc:
raise MattermostError(
f"Adding user {mm_user_id} to channel failed: {exc}"
) from exc
return channel_id
def post_message(self, channel_id: str, message: str, sender_uuid: UUID) -> str:
"""
Post a message to the given channel using the admin token.
Every post is authored by the single service account, not the real
sender — there are no per-user tokens. `sender_uuid` is stamped into
the post's `props` so it can be recovered later in `get_posts()`;
Mattermost's own `user_id` on the post is always the service account.
Returns the opaque mattermost_post_id string.
"""
try:
post = self._driver.posts.create_post(
options={
"channel_id": channel_id,
"message": message,
"props": {"sender_uuid": str(sender_uuid)},
}
)
return post["id"]
except Exception as exc:
raise MattermostError(f"Post creation failed: {exc}") from exc
def get_posts(
self,
channel_id: str,
page: int = 0,
per_page: int = 20,
since: int | None = None,
) -> list[dict]:
"""
Fetch posts from a channel and return them in chronological order.
Includes Mattermost system posts (channel membership changes, etc.)
as-is, with their raw `type` intact — callers filter if they need to.
`since` is a Unix timestamp in **milliseconds**. When provided,
`page` is ignored and all posts after that timestamp are returned.
"""
params: dict = {"per_page": per_page}
if since is not None:
params["since"] = since
else:
params["page"] = page
try:
data = self._driver.posts.get_posts_for_channel(channel_id, params=params)
except Exception as exc:
raise MattermostError(f"Fetching posts failed: {exc}") from exc
order: list[str] = data.get("order", [])
posts_map: dict = data.get("posts", {})
# Mattermost returns newest-first; reverse to get chronological order.
return [posts_map[pid] for pid in reversed(order) if pid in posts_map]
def get_latest_post_id(self, channel_id: str) -> str | None:
"""
Return the ID of the most recent post in the channel, or None if empty.
Used to derive `has_unread` against a stored last_read_mattermost_post_id.
"""
try:
data = self._driver.posts.get_posts_for_channel(
channel_id, params={"page": 0, "per_page": 1}
)
except Exception as exc:
raise MattermostError(f"Fetching latest post failed: {exc}") from exc
order: list[str] = data.get("order", [])
return order[0] if order else None