新增 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: 类型定义
279 lines
7.5 KiB
Python
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
|