ForcePilot/backend/package/yuxi/channel/extensions/signal/send.py
Kris 6713dc2d0d feat(channel): 添加 Signal 渠道扩展
新增 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 重连机制
2026-05-21 11:41:55 +08:00

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)