from __future__ import annotations import hashlib import hmac import json import logging from typing import Any logger = logging.getLogger(__name__) def derive_interaction_secret(bot_token: str) -> str: return hmac.new( key=b"openclaw-mattermost-interactions", msg=bot_token.encode(), digestmod=hashlib.sha256, ).hexdigest() def generate_interaction_token(context: dict, account_id: str | None = None) -> str: secret = derive_interaction_secret(_get_interaction_secret(account_id)) canonical = canonicalize_interaction_context(context) payload = json.dumps(canonical, sort_keys=True) return hmac.new( key=secret.encode(), msg=payload.encode(), digestmod=hashlib.sha256, ).hexdigest() def verify_interaction_token(context: dict, token: str, account_id: str | None = None) -> bool: expected = generate_interaction_token(context, account_id) return hmac.compare_digest(expected, token) def canonicalize_interaction_context(value) -> object: if isinstance(value, list): return [canonicalize_interaction_context(v) for v in value] if isinstance(value, dict): return { k: canonicalize_interaction_context(v) for k, v in sorted(value.items()) if v is not None } return value def _get_interaction_secret(account_id: str | None) -> str: import os if account_id: key = f"MATTERMOST_INTERACTION_SECRET_{account_id.upper()}" return os.environ.get(key, "") or os.environ.get("MATTERMOST_BOT_TOKEN", "") return os.environ.get("MATTERMOST_BOT_TOKEN", "") def sanitize_action_id(action_id: str) -> str: return action_id.replace("-", "").replace("_", "") def build_button_props( callback_url: str, account_id: str | None, channel_id: str, buttons: list[dict], text: str | None = None, ) -> dict | None: flattened = _flatten_buttons(buttons) if not flattened: return None mattermost_buttons = [] for btn in flattened: btn_id = btn.get("id") or btn.get("callback_data") btn_name = btn.get("text") or btn.get("name") or btn.get("label") if not btn_id or not btn_name: continue context = (btn.get("context") or {}).copy() context["__openclaw_channel_id"] = channel_id mattermost_buttons.append({ "id": sanitize_action_id(btn_id), "name": btn_name, "type": "button", "style": btn.get("style", "default"), "integration": { "url": f"{callback_url}/{account_id}", "context": { **context, "_token": generate_interaction_token(context, account_id), }, }, }) if not mattermost_buttons: return None return { "attachments": [{ "text": text or "", "actions": mattermost_buttons, }], } def _flatten_buttons(buttons: list[dict]) -> list[dict]: flattened: list[dict] = [] for item in buttons: if isinstance(item, dict) and "buttons" in item: flattened.extend(item["buttons"]) elif isinstance(item, list): flattened.extend(item) else: flattened.append(item) return flattened async def handle_interaction_callback( body: dict, account_id: str, allowed_source_ips: list[str], ) -> dict: context = body.get("context", {}) token = context.pop("_token", None) if not token: return {"status": "error", "reason": "missing_token"} if not verify_interaction_token(context, token, account_id): return {"status": "error", "reason": "invalid_token"} action_id = body.get("action_id", "") channel_id = context.get("__openclaw_channel_id", "") user_id = body.get("user_id", "") post_id = body.get("post_id", "") return { "status": "ok", "action_id": action_id, "channel_id": channel_id, "user_id": user_id, "post_id": post_id, "context": context, } def build_dialog( trigger_id: str, url: str, title: str, *, introduction_text: str = "", elements: list[dict] | None = None, submit_label: str = "提交", notify_on_cancel: bool = False, callback_id: str = "openclaw_dialog", ) -> dict: return { "trigger_id": trigger_id, "url": url, "dialog": { "callback_id": callback_id, "title": title, "introduction_text": introduction_text, "elements": elements or [], "submit_label": submit_label, "notify_on_cancel": notify_on_cancel, }, } async def open_dialog(client, dialog_payload: dict) -> dict: return await client._request("POST", "/actions/dialogs/open", body=dialog_payload) def build_select_element( name: str, label: str, options: list[dict[str, str]], *, optional: bool = False, default: str = "", help_text: str = "", ) -> dict: return { "display_name": label, "name": name, "type": "select", "options": [ {"text": opt["text"], "value": opt["value"]} for opt in options ], "optional": optional, "default": default, "help_text": help_text, } def build_text_element( name: str, label: str, *, subtype: str = "text", optional: bool = False, default: str = "", placeholder: str = "", help_text: str = "", min_length: int = 0, max_length: int = 0, ) -> dict: element: dict[str, Any] = { "display_name": label, "name": name, "type": "text", "subtype": subtype, "optional": optional, "default": default, "placeholder": placeholder, "help_text": help_text, } if min_length > 0: element["min_length"] = min_length if max_length > 0: element["max_length"] = max_length return element def build_attachment( *, fallback: str = "", color: str = "", pretext: str = "", author_name: str = "", author_icon: str = "", author_link: str = "", title: str = "", title_link: str = "", text: str = "", fields: list[dict[str, Any]] | None = None, image_url: str = "", thumb_url: str = "", footer: str = "", footer_icon: str = "", actions: list[dict] | None = None, ) -> dict: attachment: dict[str, Any] = {} if fallback: attachment["fallback"] = fallback if color: attachment["color"] = color if pretext: attachment["pretext"] = pretext if author_name: attachment["author_name"] = author_name if author_icon: attachment["author_icon"] = author_icon if author_link: attachment["author_link"] = author_link if title: attachment["title"] = title if title_link: attachment["title_link"] = title_link if text: attachment["text"] = text if fields: attachment["fields"] = [ {"title": f.get("title", ""), "value": f.get("value", ""), "short": f.get("short", True)} for f in fields ] if image_url: attachment["image_url"] = image_url if thumb_url: attachment["thumb_url"] = thumb_url if footer: attachment["footer"] = footer if footer_icon: attachment["footer_icon"] = footer_icon if actions: attachment["actions"] = actions return attachment