ForcePilot/backend/package/yuxi/channel/extensions/zalouser/outbound.py
Kris d5e36d33b7 feat(channel): 添加 Zalo OA、Zoom Chat 和 Zulip 渠道扩展
新增 Zalo OA、Zoom Chat、Zulip 三个渠道扩展。

Zalo OA 渠道扩展主要模块:sidecar_client, config, gateway, outbound, streaming, pairing, security, auth, dedupe, directory, monitor, status, session, reactions, tools

Zoom Chat 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, crypto, dedupe, actions, media, mentions, monitor, status, session, reactions, threading

Zulip 渠道扩展主要模块:client, config, gateway, outbound, streaming, pairing, security, monitor, status
2026-05-21 12:06:26 +08:00

261 lines
8.1 KiB
Python

from __future__ import annotations
import logging
from typing import Any
from yuxi.channel.extensions.zalouser.format import chunk_text_for_outbound, format_for_zalouser_markdown
from yuxi.channel.extensions.zalouser.sidecar_client import ZcaSidecarClient
from yuxi.channel.protocols import OutboundDeliveryMode
logger = logging.getLogger(__name__)
DEFAULT_CHUNK_LIMIT = 2000
class ZaloUserOutboundAdapter:
delivery_mode = OutboundDeliveryMode.DIRECT
chunker_mode = "markdown"
text_chunk_limit = DEFAULT_CHUNK_LIMIT
poll_max_options = 10
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = True
def __init__(self):
self._client: ZcaSidecarClient | None = None
def set_client(self, client: ZcaSidecarClient) -> None:
self._client = client
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return chunk_text_for_outbound(text, limit)
def sanitize_text(self, text: str, payload: object) -> str:
return format_for_zalouser_markdown(text)
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return True
def normalize_payload(self, payload: object, config: dict, account_id: str | None = None) -> object | None:
return payload
def resolve_effective_text_chunk_limit(
self, config: dict, account_id: str | None = None, fallback_limit: int | None = None
) -> int | None:
return config.get("text_chunk_limit", DEFAULT_CHUNK_LIMIT)
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
is_group = thread_id == "Group"
quote = None
if reply_to_id:
parts = reply_to_id.split(":", 1)
quote = {"msgId": parts[0], "cliMsgId": parts[1] if len(parts) > 1 else parts[0]}
await self._client.send_message(target_id, content, is_group=is_group, quote=quote)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
is_group = thread_id == "Group"
if media_type == "image":
await self._client.send_image(target_id, media_url, is_group=is_group)
elif media_type in ("voice", "audio"):
await self._client.send_voice(target_id, media_url, is_group=is_group)
else:
await self._client.send_attachment(target_id, media_url, media_type, is_group=is_group)
async def send_link(
self,
target_id: str,
url: str,
caption: str = "",
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
await self._client.send_link(target_id, url, caption)
async def send_reaction(
self,
target_id: str,
message_id: str,
icon: str,
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
parts = message_id.split(":", 1)
msg_id = parts[0]
cli_msg_id = parts[1] if len(parts) > 1 else msg_id
await self._client.send_reaction(target_id, msg_id, cli_msg_id, icon)
async def remove_reaction(
self,
target_id: str,
message_id: str,
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
parts = message_id.split(":", 1)
msg_id = parts[0]
cli_msg_id = parts[1] if len(parts) > 1 else msg_id
await self._client.remove_reaction(target_id, msg_id, cli_msg_id)
async def delete_message(
self,
target_id: str,
message_id: str,
account_id: str | None = None,
) -> None:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
parts = message_id.split(":", 1)
msg_id = parts[0]
cli_msg_id = parts[1] if len(parts) > 1 else msg_id
await self._client.delete_message(target_id, msg_id, cli_msg_id)
async def send_delivered(
self,
target_id: str,
message_id: str,
) -> None:
if not self._client:
return
msg_id = message_id.split(":", 1)[0]
await self._client.send_delivered(target_id, msg_id)
async def send_seen(
self,
target_id: str,
message_id: str,
) -> None:
if not self._client:
return
msg_id = message_id.split(":", 1)[0]
await self._client.send_seen(target_id, msg_id)
async def send_typing(self, target_id: str, account_id: str | None = None) -> None:
if not self._client:
return
await self._client.send_typing(target_id)
async def clear_typing(self, target_id: str, account_id: str | None = None) -> None:
pass
async def send_poll(
self,
target_id: str,
question: str,
options: list[str],
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.create_poll(target_id, question, options)
async def end_poll(
self,
target_id: str,
poll_id: str,
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.lock_poll(poll_id, target_id)
async def send_sticker(
self,
target_id: str,
sticker_obj: dict,
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.send_sticker(target_id, sticker_obj)
async def send_card(
self,
target_id: str,
user_id: str,
phone: str,
name: str,
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.send_card(target_id, user_id, phone, name)
async def create_note(
self,
target_id: str,
title: str,
content: str,
color: int = 0,
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.create_note(target_id, title, content, color)
async def edit_note(
self,
target_id: str,
note_id: str,
title: str,
content: str,
color: int = 0,
account_id: str | None = None,
) -> dict:
if not self._client:
raise RuntimeError("ZcaSidecarClient not set on ZaloUserOutboundAdapter")
return await self._client.edit_note(target_id, note_id, title, content, color)
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "Target is required"
from yuxi.channel.extensions.zalouser.session import ZaloUserSessionAdapter
return True, ZaloUserSessionAdapter.normalize_target(to)
def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool:
return True