156 lines
4.7 KiB
Python
156 lines
4.7 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import ipaddress
|
||
|
|
import logging
|
||
|
|
import secrets
|
||
|
|
from urllib.parse import urljoin, urlparse
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.format import convert_markdown_tables_to_ascii
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.normalize import strip_nextcloud_talk_target_prefix
|
||
|
|
from yuxi.channel.extensions.nextcloud_talk.signature import generate_nextcloud_talk_signature
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
OCS_MESSAGE_PATH = "/ocs/v2.php/apps/spreed/api/v1/bot/{token}/message"
|
||
|
|
OCS_REACTION_PATH = "/ocs/v2.php/apps/spreed/api/v1/bot/{token}/reaction/{message_id}"
|
||
|
|
|
||
|
|
|
||
|
|
class NextcloudTalkSendError(Exception):
|
||
|
|
def __init__(self, message: str, status_code: int | None = None):
|
||
|
|
super().__init__(message)
|
||
|
|
self.status_code = status_code
|
||
|
|
|
||
|
|
|
||
|
|
def _check_ssrf(base_url: str, dangerously_allow_private_network: bool = False) -> None:
|
||
|
|
if dangerously_allow_private_network:
|
||
|
|
return
|
||
|
|
|
||
|
|
try:
|
||
|
|
parsed = urlparse(base_url)
|
||
|
|
except ValueError:
|
||
|
|
return
|
||
|
|
|
||
|
|
hostname = parsed.hostname
|
||
|
|
if not hostname:
|
||
|
|
return
|
||
|
|
|
||
|
|
from yuxi.channel.security.ssrf_guard import SsrfGuard
|
||
|
|
|
||
|
|
guard = SsrfGuard(default_deny=False)
|
||
|
|
|
||
|
|
try:
|
||
|
|
ip_str = str(ipaddress.ip_address(hostname))
|
||
|
|
except ValueError:
|
||
|
|
return
|
||
|
|
|
||
|
|
ip_result = guard.check_ip(ip_str)
|
||
|
|
if not ip_result.safe:
|
||
|
|
raise NextcloudTalkSendError(
|
||
|
|
f"SSRF blocked: {hostname} — {ip_result.reason}. Set dangerously_allow_private_network=true to allow."
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_message_nextcloud_talk(
|
||
|
|
to: str,
|
||
|
|
text: str,
|
||
|
|
base_url: str,
|
||
|
|
secret: str,
|
||
|
|
*,
|
||
|
|
reply_to: str | None = None,
|
||
|
|
silent: bool = False,
|
||
|
|
reference_id: str | None = None,
|
||
|
|
timeout: float = 30.0,
|
||
|
|
dangerously_allow_private_network: bool = False,
|
||
|
|
) -> dict:
|
||
|
|
_check_ssrf(base_url, dangerously_allow_private_network)
|
||
|
|
|
||
|
|
room_token = strip_nextcloud_talk_target_prefix(to)
|
||
|
|
|
||
|
|
if not text.strip():
|
||
|
|
raise NextcloudTalkSendError("Message must be non-empty")
|
||
|
|
|
||
|
|
text = convert_markdown_tables_to_ascii(text)
|
||
|
|
|
||
|
|
body = {"message": text}
|
||
|
|
if reply_to:
|
||
|
|
body["replyTo"] = reply_to
|
||
|
|
if silent:
|
||
|
|
body["silent"] = True
|
||
|
|
if reference_id is None:
|
||
|
|
reference_id = secrets.token_hex(16)
|
||
|
|
body["referenceId"] = reference_id
|
||
|
|
|
||
|
|
random, signature = generate_nextcloud_talk_signature(text, secret)
|
||
|
|
|
||
|
|
url = urljoin(base_url.rstrip("/") + "/", OCS_MESSAGE_PATH.format(token=room_token))
|
||
|
|
|
||
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
|
|
resp = await client.post(
|
||
|
|
url,
|
||
|
|
json=body,
|
||
|
|
headers={
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"OCS-APIRequest": "true",
|
||
|
|
"X-Nextcloud-Talk-Bot-Random": random,
|
||
|
|
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||
|
|
"Accept": "application/json",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
if resp.status_code == 200:
|
||
|
|
data = resp.json()
|
||
|
|
ocs_data = data.get("ocs", {}).get("data", {})
|
||
|
|
return {
|
||
|
|
"message_id": str(ocs_data.get("id", "unknown")),
|
||
|
|
"room_token": room_token,
|
||
|
|
"timestamp": ocs_data.get("timestamp"),
|
||
|
|
"reference_id": reference_id,
|
||
|
|
}
|
||
|
|
|
||
|
|
error_body = resp.text[:500]
|
||
|
|
status_map = {
|
||
|
|
400: f"Nextcloud Talk: bad request - {error_body}",
|
||
|
|
401: "Nextcloud Talk: authentication failed - check bot secret",
|
||
|
|
403: "Nextcloud Talk: forbidden - bot may not have permission in this room",
|
||
|
|
404: f"Nextcloud Talk: room not found (token={room_token})",
|
||
|
|
}
|
||
|
|
msg = status_map.get(resp.status_code, f"Nextcloud Talk send failed: {error_body}")
|
||
|
|
raise NextcloudTalkSendError(msg, resp.status_code)
|
||
|
|
|
||
|
|
|
||
|
|
async def send_reaction_nextcloud_talk(
|
||
|
|
room_token: str,
|
||
|
|
message_id: str,
|
||
|
|
reaction: str,
|
||
|
|
base_url: str,
|
||
|
|
secret: str,
|
||
|
|
*,
|
||
|
|
timeout: float = 30.0,
|
||
|
|
dangerously_allow_private_network: bool = False,
|
||
|
|
) -> bool:
|
||
|
|
_check_ssrf(base_url, dangerously_allow_private_network)
|
||
|
|
|
||
|
|
random, signature = generate_nextcloud_talk_signature(reaction, secret)
|
||
|
|
|
||
|
|
url = urljoin(
|
||
|
|
base_url.rstrip("/") + "/",
|
||
|
|
OCS_REACTION_PATH.format(token=room_token, message_id=message_id),
|
||
|
|
)
|
||
|
|
|
||
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||
|
|
resp = await client.post(
|
||
|
|
url,
|
||
|
|
json={"reaction": reaction},
|
||
|
|
headers={
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"OCS-APIRequest": "true",
|
||
|
|
"X-Nextcloud-Talk-Bot-Random": random,
|
||
|
|
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||
|
|
"Accept": "application/json",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
return resp.status_code == 200
|