226 lines
8.3 KiB
Python
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
|