新增 Signal 渠道扩展,支持在 Yuxi 平台中集成 Signal 加密即时通讯渠道。 包含以下功能模块: - client: Signal 客户端封装 - daemon: signald 守护进程管理 - config_schema: 配置模式 - send: 消息发送 - accounts: 账户管理 - account_management: 账户综合管理 - access_policy: 访问策略 - identity: 身份管理 - profiles: 用户资料 - groups: 群组管理 - format: 消息格式转换 - normalize: 消息规范化 - dedupe: 消息去重 - monitor: 渠道状态监控 - probe: 健康探测 - sse_reconnect: SSE 重连机制
312 lines
8.0 KiB
Python
312 lines
8.0 KiB
Python
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from yuxi.channel.extensions.signal.client import SignalRpcClient
|
|
from yuxi.channel.extensions.signal.format import (
|
|
SignalTextStyle,
|
|
chunk_text,
|
|
format_text_styles_to_rpc,
|
|
markdown_to_signal_formatted_text,
|
|
)
|
|
from yuxi.channel.extensions.signal.normalize import parse_signal_target
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class SignalAttachment:
|
|
filename: str
|
|
content_type: str
|
|
width: int = 0
|
|
height: int = 0
|
|
caption: str | None = None
|
|
|
|
def to_rpc_params(self) -> dict:
|
|
p: dict = {"filename": self.filename, "contentType": self.content_type}
|
|
if self.width:
|
|
p["width"] = self.width
|
|
if self.height:
|
|
p["height"] = self.height
|
|
if self.caption:
|
|
p["caption"] = self.caption
|
|
return p
|
|
|
|
|
|
def _build_mentions(mention_uuids: list[str], text: str) -> list[dict]:
|
|
result = []
|
|
for uuid_str in mention_uuids:
|
|
idx = text.find(uuid_str)
|
|
if idx >= 0:
|
|
result.append({"uuid": uuid_str, "start": idx, "length": len(uuid_str)})
|
|
return result
|
|
|
|
|
|
async def send_signal_message(
|
|
client: SignalRpcClient,
|
|
to: str,
|
|
text: str,
|
|
*,
|
|
account: str | None = None,
|
|
attachments: list[str] | list[SignalAttachment] | None = None,
|
|
reply_to_timestamp: int | None = None,
|
|
quote_author: str | None = None,
|
|
quote_text: str | None = None,
|
|
quote_attachments: list[dict] | None = None,
|
|
quote_title: str | None = None,
|
|
mention: list[str] | None = None,
|
|
edit_timestamp: int | None = None,
|
|
expires_in_seconds: int | None = None,
|
|
preview: dict | None = None,
|
|
is_view_once: bool = False,
|
|
) -> dict | None:
|
|
parsed = parse_signal_target(to)
|
|
if not parsed:
|
|
raise ValueError(f"Invalid Signal target: {to}")
|
|
|
|
kind, target = parsed
|
|
_, styled_spans = markdown_to_signal_formatted_text(text)
|
|
text_styles = format_text_styles_to_rpc(styled_spans)
|
|
|
|
params: dict[str, Any] = {}
|
|
if account:
|
|
params["account"] = account
|
|
|
|
if kind == "groupId":
|
|
params["groupId"] = target
|
|
else:
|
|
params["recipient"] = target
|
|
|
|
if attachments:
|
|
params["attachments"] = [
|
|
a.to_rpc_params() if isinstance(a, SignalAttachment) else a
|
|
for a in attachments
|
|
]
|
|
|
|
content = text
|
|
if edit_timestamp:
|
|
params["message"] = content
|
|
params["editTimestamp"] = edit_timestamp
|
|
if text_styles:
|
|
params["text-style"] = text_styles
|
|
else:
|
|
params["message"] = content
|
|
if text_styles:
|
|
params["text-style"] = text_styles
|
|
|
|
if reply_to_timestamp:
|
|
params["quoteTimestamp"] = reply_to_timestamp
|
|
if quote_author:
|
|
params["quoteAuthor"] = quote_author
|
|
if quote_text:
|
|
params["quoteMessage"] = quote_text
|
|
if quote_attachments:
|
|
params["quoteAttachments"] = quote_attachments
|
|
if quote_title:
|
|
params["quoteTitle"] = quote_title
|
|
|
|
if mention:
|
|
params["mentions"] = _build_mentions(mention, text)
|
|
|
|
if expires_in_seconds:
|
|
params["expiresInSeconds"] = expires_in_seconds
|
|
|
|
if preview:
|
|
params["preview"] = [preview]
|
|
|
|
if is_view_once:
|
|
params["isViewOnce"] = True
|
|
|
|
try:
|
|
result = await client.call("send", params, account=account)
|
|
return result
|
|
except Exception:
|
|
logger.exception("Failed to send Signal message to %s", to)
|
|
raise
|
|
|
|
|
|
async def send_signal_message_chunked(
|
|
client: SignalRpcClient,
|
|
to: str,
|
|
text: str,
|
|
*,
|
|
account: str | None = None,
|
|
chunk_limit: int = 4000,
|
|
**kwargs,
|
|
) -> list[dict | None]:
|
|
edit_timestamp = kwargs.get("edit_timestamp")
|
|
if edit_timestamp:
|
|
result = await send_signal_message(client, to, text, account=account, **kwargs)
|
|
return [result]
|
|
|
|
chunks = chunk_text(text, chunk_limit)
|
|
results = []
|
|
for chunk in chunks:
|
|
result = await send_signal_message(client, to, chunk, account=account, **kwargs)
|
|
results.append(result)
|
|
return results
|
|
|
|
|
|
async def send_signal_remote_delete(
|
|
client: SignalRpcClient,
|
|
recipient: str,
|
|
timestamp: int,
|
|
*,
|
|
group_id: str | None = None,
|
|
account: str | None = None,
|
|
) -> None:
|
|
params: dict = {"recipient": recipient, "timestamp": timestamp}
|
|
if group_id:
|
|
params["groupId"] = group_id
|
|
if account:
|
|
params["account"] = account
|
|
await client.call("remoteDelete", params, account=account)
|
|
|
|
|
|
async def send_signal_typing(
|
|
client: SignalRpcClient,
|
|
to: str,
|
|
*,
|
|
account: str | None = None,
|
|
stop: bool = False,
|
|
) -> None:
|
|
parsed = parse_signal_target(to)
|
|
if not parsed:
|
|
raise ValueError(f"Invalid Signal target: {to}")
|
|
|
|
kind, target = parsed
|
|
params: dict[str, Any] = {}
|
|
if account:
|
|
params["account"] = account
|
|
|
|
if kind == "groupId":
|
|
params["groupId"] = target
|
|
else:
|
|
params["recipient"] = target
|
|
|
|
if stop:
|
|
params["stop"] = True
|
|
|
|
await client.call("sendTyping", params, account=account)
|
|
|
|
|
|
async def send_signal_receipt(
|
|
client: SignalRpcClient,
|
|
sender: str,
|
|
timestamp: int,
|
|
*,
|
|
account: str | None = None,
|
|
receipt_type: str = "read",
|
|
) -> None:
|
|
params: dict[str, Any] = {
|
|
"sender": sender,
|
|
"timestamp": timestamp,
|
|
"receiptType": receipt_type,
|
|
}
|
|
if account:
|
|
params["account"] = account
|
|
|
|
await client.call("sendReceipt", params, account=account)
|
|
|
|
|
|
async def send_signal_reaction(
|
|
client: SignalRpcClient,
|
|
recipient: str,
|
|
target_timestamp: int,
|
|
emoji: str,
|
|
*,
|
|
target_author: str | None = None,
|
|
target_author_uuid: str | None = None,
|
|
group_id: str | None = None,
|
|
remove: bool = False,
|
|
account: str | None = None,
|
|
) -> None:
|
|
params: dict[str, Any] = {
|
|
"recipient": recipient,
|
|
"targetTimestamp": target_timestamp,
|
|
}
|
|
if emoji:
|
|
params["reaction"] = emoji
|
|
if remove:
|
|
params["remove"] = True
|
|
if target_author:
|
|
params["targetAuthor"] = target_author
|
|
if target_author_uuid:
|
|
params["targetAuthorUuid"] = target_author_uuid
|
|
if group_id:
|
|
params["groupId"] = group_id
|
|
if account:
|
|
params["account"] = account
|
|
|
|
await client.call("sendReaction", params, account=account)
|
|
|
|
|
|
async def send_signal_sticker(
|
|
client: SignalRpcClient,
|
|
to: str,
|
|
pack_id: str,
|
|
sticker_id: int,
|
|
*,
|
|
account: str | None = None,
|
|
) -> None:
|
|
parsed = parse_signal_target(to)
|
|
if not parsed:
|
|
raise ValueError(f"Invalid Signal target: {to}")
|
|
|
|
kind, target = parsed
|
|
params: dict[str, Any] = {"sticker": {"packId": pack_id, "stickerId": sticker_id}}
|
|
if account:
|
|
params["account"] = account
|
|
if kind == "groupId":
|
|
params["groupId"] = target
|
|
else:
|
|
params["recipient"] = target
|
|
|
|
await client.call("send", params, account=account)
|
|
|
|
|
|
async def send_signal_contacts(
|
|
client: SignalRpcClient,
|
|
to: str,
|
|
contacts: list[dict],
|
|
*,
|
|
account: str | None = None,
|
|
) -> None:
|
|
parsed = parse_signal_target(to)
|
|
if not parsed:
|
|
raise ValueError(f"Invalid Signal target: {to}")
|
|
|
|
kind, target = parsed
|
|
params: dict[str, Any] = {"contacts": contacts}
|
|
if account:
|
|
params["account"] = account
|
|
if kind == "groupId":
|
|
params["groupId"] = target
|
|
else:
|
|
params["recipient"] = target
|
|
|
|
await client.call("sendContacts", params, account=account)
|
|
|
|
|
|
async def send_sync_request(
|
|
client: SignalRpcClient,
|
|
*,
|
|
account: str | None = None,
|
|
) -> None:
|
|
params: dict[str, Any] = {}
|
|
if account:
|
|
params["account"] = account
|
|
await client.call("sendSyncRequest", params, account=account)
|
|
|
|
|
|
async def send_group_v2_info(
|
|
client: SignalRpcClient,
|
|
group_id: str,
|
|
*,
|
|
account: str | None = None,
|
|
) -> None:
|
|
params: dict[str, Any] = {"groupId": group_id}
|
|
if account:
|
|
params["account"] = account
|
|
await client.call("sendGroupV2Info", params, account=account)
|