ForcePilot/backend/package/yuxi/channel/extensions/mattermost/interactions.py
Kris ebab14660a feat(channel): 添加 Mattermost 渠道扩展
新增 Mattermost 渠道扩展,支持在 Yuxi 平台中集成 Mattermost 团队协作平台。

包含以下功能模块:
- client: Mattermost API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- interactions: 交互处理
- slash_commands: 斜杠指令
- actions: 动作处理
- approval: 审批流程
- delivery: 消息送达确认
- directory: 目录管理
- threading: 线程管理
- gating: 门控管理
- reconnect: 重连机制
- reactions: 表情反应
- media: 媒体资源处理
- model_picker: 模型选择
- types: 类型定义
2026-05-21 11:22:43 +08:00

279 lines
7.5 KiB
Python

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