ForcePilot/backend/package/yuxi/channels/adapters/zalo_user/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

719 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
from collections.abc import AsyncIterator
from typing import Any, ClassVar
import httpx
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelConnectionError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelAccountSnapshot,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
EventType,
HealthStatus,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from .backoff import BackoffManager
from .bridge import BridgeClient
from .constants import (
DEFAULT_BRIDGE_URL,
DEFAULT_CIRCUIT_BREAKER_RECOVERY,
DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
DEFAULT_MAX_SEND_PER_MINUTE,
)
from .credentials import CredentialManager
from .dedup import MessageDedup
from .directory import get_self_info, list_group_members, list_groups, list_peers
from .exec_approval import create_exec_approval
from .group_cache import group_context_cache
from .group_sync import sync_group_list
from .allow_from import resolve_allow_from_entries
from .interactive_dispatch import (
default_approval_handler,
default_model_selector_handler,
default_pagination_handler,
)
from .login import LoginFlow
from .message_sid import MessageIdTracker
from .monitor import MessageMonitor
from .normalize import (
normalize_inbound,
)
from .probe import check_bridge_health
from .rate_limiter import RateLimiter
from .reaction import is_delete_reaction, normalize_reaction_icon
from .security_audit import collect_security_audit_findings
from .send import OutboundSequencer, _build_send_payload, send_delivered_event, send_seen_event, send_with_retry
from .send_cache import SendCache
from .session import (
check_group_policy,
check_mention_required,
resolve_agent_route,
)
from .sticker_handler import augment_sticker_message
from .status_issues import collect_status_issues
from .text_styles import (
has_markdown_syntax,
markdown_to_zalo_styles,
styled_message_to_send_payload,
)
from .vision import augment_message_with_vision
def _env_fallback(env_keys: str | list[str], config: dict[str, Any], config_key: str, default: Any) -> Any:
if isinstance(env_keys, str):
env_keys = [env_keys]
if not config:
for key in env_keys:
if key in os.environ:
return os.environ[key]
return default
val = config.get(config_key)
if val is not None:
return val
for key in env_keys:
env_val = os.environ.get(key)
if env_val is not None:
return env_val
return default
@register_builtin_adapter
class ZaloUserAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "zalo_user"
channel_type: ClassVar[ChannelType] = ChannelType.ZALO_USER
text_chunk_limit: ClassVar[int] = 2000
supports_markdown: ClassVar[bool] = True
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "partial", "block"]
max_media_size_mb: ClassVar[int] = 50
webhook_path: ClassVar[str | None] = None
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
reply=True, # Bridge supports reply via quote_message_id (differs from zca-js native capabilities)
media=True,
reactions=True,
edit=True, # Bridge supports message editing (differs from zca-js native capabilities)
unsend=True, # Bridge supports message deletion (differs from zca-js native capabilities)
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "partial", "block"],
text_chunk_limit=2000,
max_media_size_mb=50,
polls=False,
threads=False,
group_management=False,
native_commands=False,
)
meta = ChannelMeta(
id="zalo_user",
label="Zalo User",
blurb="通过自建 Bridge 服务间接集成 zca-js支持 QR/Cookie 认证、文本/媒体消息收发、反应系统、流式输出",
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._bridge: BridgeClient | None = None
self._login_flow: LoginFlow | None = None
self._credential_mgr = CredentialManager(config=self._config, profile=self._profile)
self._monitor: MessageMonitor | None = None
self._circuit_breaker = CircuitBreaker(
failure_threshold=DEFAULT_CIRCUIT_BREAKER_THRESHOLD,
recovery_timeout=DEFAULT_CIRCUIT_BREAKER_RECOVERY,
)
self._rate_limiter: RateLimiter | None = None
self._account_info: dict[str, Any] = {}
self._group_cache: list[dict[str, Any]] = []
self._paired_users: set[str] = set()
self._friends_cache: list[dict[str, Any]] = []
self._reconnect_attempts = 0
self._last_error: str | None = None
self._last_connected_at: float | None = None
self._last_message_at: float | None = None
self._last_transport_activity_at: float | None = None
self._config = config or {}
self._profile = _env_fallback(
["ZALOUSER_PROFILE", "ZCA_PROFILE", "ZALO_PROFILE"], self._config, "profile", "default"
)
self._enabled = self._config.get("enabled", True)
self._msg_id_tracker = MessageIdTracker()
self._outbound_seq = OutboundSequencer()
self._backoff = BackoffManager(base_delay=1.0, max_delay=60.0)
self._exec_approval = create_exec_approval(timeout=self._config.get("exec_approval_timeout", 30.0))
self._history_limit = self._config.get("history_limit", 100)
self._dedup = MessageDedup()
self._send_cache = SendCache()
def _resolve_bridge_url(self) -> str:
return _env_fallback("ZALO_BRIDGE_URL", self._config, "bridge_url", DEFAULT_BRIDGE_URL)
def _resolve_auth_type(self) -> str:
return _env_fallback("ZALO_AUTH_TYPE", self._config, "auth_type", "qr")
async def reload_config(self, new_config: dict[str, Any]) -> None:
old_bridge_url = self._resolve_bridge_url()
self._config = new_config or {}
self._profile = _env_fallback(
["ZALOUSER_PROFILE", "ZCA_PROFILE", "ZALO_PROFILE"], self._config, "profile", "default"
)
self._enabled = self._config.get("enabled", True)
self._history_limit = self._config.get("history_limit", 100)
new_bridge_url = self._resolve_bridge_url()
if old_bridge_url != new_bridge_url and self._bridge is not None:
logger.info("[ZaloUser] Bridge URL changed, reconnecting")
was_connected = self._status == ChannelStatus.CONNECTED
await self.disconnect()
if was_connected:
await self.connect()
self._circuit_breaker = CircuitBreaker(
failure_threshold=self._config.get("circuit_breaker_threshold", DEFAULT_CIRCUIT_BREAKER_THRESHOLD),
recovery_timeout=self._config.get("circuit_breaker_recovery", DEFAULT_CIRCUIT_BREAKER_RECOVERY),
)
max_per_minute = self._config.get("max_send_per_minute", DEFAULT_MAX_SEND_PER_MINUTE)
self._rate_limiter = RateLimiter(max_per_minute)
@property
def account_id(self) -> str:
return self._account_info.get("user_id", "")
@property
def account_name(self) -> str:
return self._account_info.get("display_name", "unknown")
@property
def config(self) -> dict[str, Any]:
return self._config
@config.setter
def config(self, value: dict[str, Any] | None) -> None:
self._config = value or {}
@property
def dm_policy(self) -> str:
return self._config.get("dm_policy", "pairing")
@property
def group_policy(self) -> str:
return self._config.get("group_policy", "allowlist")
async def pre_connect(self) -> dict:
bridge_url = self._resolve_bridge_url()
timeout = self._config.get("network", {}).get("connect_timeout", 10)
self_listen = self._config.get("bridge_self_listen", True)
bridge = BridgeClient(bridge_url, timeout, self_listen=self_listen)
await bridge.start()
self._bridge = bridge
login_flow = LoginFlow(bridge, self._config, self._credential_mgr)
self._login_flow = login_flow
auth_type = self._resolve_auth_type()
if auth_type == "cookie":
try:
await login_flow.login_with_cookie()
return {"status": "ok", "mode": "cookie"}
except ChannelAuthenticationError as e:
self._last_error = str(e)
return {"status": "error", "message": str(e)}
try:
result = await login_flow.start_qr_login()
return {"status": "pending", "qr_url": result["qr_url"], "qr_id": result["qr_id"]}
except Exception as e:
self._last_error = str(e)
return {"status": "error", "message": str(e)}
async def connect(self) -> None:
if self._status == ChannelStatus.CONNECTED:
return
self._status = ChannelStatus.CONNECTING
logger.info(f"[ZaloUser] Starting channel '{self.channel_id}'")
if self._bridge is None or not self._bridge.is_started:
bridge_url = self._resolve_bridge_url()
timeout = self._config.get("network", {}).get("connect_timeout", 10)
self_listen = self._config.get("bridge_self_listen", True)
self._bridge = BridgeClient(bridge_url, timeout, self_listen=self_listen)
await self._bridge.start()
if self._login_flow is None:
self._login_flow = LoginFlow(self._bridge, self._config, self._credential_mgr)
bridge = self._bridge
try:
health = await check_bridge_health(bridge)
if health.status == "unhealthy":
raise ChannelConnectionError(f"Bridge service unhealthy at {bridge.base_url}")
except httpx.ConnectError:
raise ChannelConnectionError(f"Cannot connect to bridge at {bridge.base_url}")
login_status = await bridge.check_login_status()
if not login_status.get("logged_in"):
if self._credential_mgr.load_from_file():
try:
verify = await bridge.check_login_status()
if verify.get("logged_in"):
logger.info("[ZaloUser] Session restored from persisted credentials")
else:
logger.info("[ZaloUser] Persisted credentials invalid, re-authenticating")
self._credential_mgr.mark_logged_out()
except Exception:
pass
if not self._credential_mgr.is_authenticated:
auth_type = self._resolve_auth_type()
if auth_type == "cookie":
await self._login_flow.login_with_cookie()
else:
try:
await self._login_flow.wait_for_login(
poll_interval=self._config.get("login_poll_interval", 2.0),
timeout=self._config.get("login_timeout", 120.0),
)
except ChannelAuthenticationError as e:
self._last_error = str(e)
self._status = ChannelStatus.ERROR
raise
try:
account_resp = await bridge.get_account_info()
self._account_info = account_resp
self._credential_mgr.mark_connected(account_resp)
except Exception:
self._account_info = {"display_name": "unknown", "user_id": "unknown"}
self._group_cache = await sync_group_list(bridge)
try:
self._friends_cache = await list_peers(bridge)
except Exception:
self._friends_cache = []
group_context_cache.set_profile(self._profile)
max_per_minute = self._config.get("max_send_per_minute", DEFAULT_MAX_SEND_PER_MINUTE)
self._rate_limiter = RateLimiter(max_per_minute)
import time
self._last_connected_at = time.time()
monitor = MessageMonitor(
channel_id=self.channel_id,
ws_url=bridge.get_websocket_url(),
on_message=self._handle_message,
profile=self._profile,
)
self._monitor = monitor
await monitor.start()
# Security audit integration
security_issues = collect_security_audit_findings(self._config)
if security_issues:
logger.warning(f"[ZaloUser] Security audit post-connect findings: {security_issues}")
self._status = ChannelStatus.CONNECTED
self._reconnect_attempts = 0
logger.info(f"[ZaloUser] Channel started. Account: {self._account_info.get('display_name', 'unknown')}")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[ZaloUser] Stopping channel '{self.channel_id}'")
if self._monitor:
try:
await self._monitor.stop()
except Exception:
logger.exception("[ZaloUser] Error stopping message monitor")
finally:
self._monitor = None
group_context_cache.clear()
if self._bridge:
try:
await self._bridge.stop()
except Exception:
logger.exception("[ZaloUser] Error stopping bridge client")
finally:
self._bridge = None
self._credential_mgr.mark_logged_out()
self._status = ChannelStatus.DISCONNECTED
logger.info(f"[ZaloUser] Channel '{self.channel_id}' stopped")
async def logout(self) -> dict[str, Any]:
logger.info(f"[ZaloUser] Logging out channel '{self.channel_id}'")
if self._login_flow:
await self._login_flow.logout()
await self.disconnect()
return {"status": "logged_out"}
async def send(self, response: ChannelResponse) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
seq = self._outbound_seq.next()
if not self._send_cache.check_and_set(response.identity.channel_chat_id, response.content, seq):
return DeliveryResult(success=True, message_id=f"cached:{seq}")
async def _do_send():
return await send_with_retry(self._bridge, response, self._config, self._rate_limiter)
try:
import time
self._last_message_at = time.time()
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except httpx.ConnectError as e:
return DeliveryResult(success=False, error=f"Connection error: {e}")
except httpx.TimeoutException as e:
return DeliveryResult(success=False, error=f"Request timeout: {e}")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def receive(self) -> AsyncIterator[ChannelMessage]:
if False:
yield
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
return normalize_inbound(self.channel_id, raw)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
markdown_enabled = self._config.get("markdown", True)
if markdown_enabled and has_markdown_syntax(response.content):
styled = markdown_to_zalo_styles(response.content)
payload = _build_send_payload(response, self._config)
payload["styled_paragraphs"] = styled_message_to_send_payload(styled, response.identity.channel_chat_id)[
"styled_paragraphs"
]
return payload
return _build_send_payload(response, self._config)
async def health_check(self) -> HealthStatus:
if not self._bridge:
return HealthStatus(status="unhealthy", last_error="Bridge not initialized")
health = await check_bridge_health(self._bridge)
health.metadata["adapter_status"] = self._status.value
health.metadata["credential_stage"] = self._credential_mgr.stage.value
health.metadata["reconnect_attempts"] = self._reconnect_attempts
health.metadata["profile"] = self._profile
if self._account_info:
health.metadata["account"] = self._account_info.get("display_name", "unknown")
health.metadata["account_id"] = self._account_info.get("user_id", "unknown")
security_findings = collect_security_audit_findings(self._config)
if security_findings:
health.metadata["security_audit"] = security_findings
status_issues = collect_status_issues(
credential_stage=self._credential_mgr.stage.value,
adapter_status=self._status.value,
dm_policy=self.dm_policy,
group_policy=self.group_policy,
last_error=self._last_error,
reconnect_attempts=self._reconnect_attempts,
bridge_url=self._resolve_bridge_url(),
)
if status_issues:
health.metadata["status_issues"] = status_issues
health.last_connected_at = utc_now_naive()
return health
def get_status_snapshot(self) -> ChannelAccountSnapshot:
return ChannelAccountSnapshot(
account_id=self.account_id,
name=self.account_name,
configured=bool(self._bridge),
enabled=True,
linked=self._credential_mgr.is_authenticated,
running=self._status == ChannelStatus.CONNECTED,
connected=self._status == ChannelStatus.CONNECTED,
status_state=self._status.value,
health_state=self._status.value,
last_connected_at_s=self._last_connected_at,
last_message_at=self._last_message_at,
last_error=self._last_error,
reconnect_attempts=self._reconnect_attempts,
dm_policy=self.dm_policy,
group_policy=self.group_policy,
allow_from_count=len(self._config.get("allow_from", [])),
profile=self._account_info if self._account_info else None,
)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
try:
self._outbound_seq.next(chat_id)
resp = await self._bridge.edit_message(chat_id, content)
return DeliveryResult(success=True, message_id=resp.get("message_id"))
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
try:
return await self._bridge.delete_message(chat_id, msg_id)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
normalized = normalize_reaction_icon(emoji)
if is_delete_reaction(emoji):
return await self.remove_reaction(chat_id, msg_id)
try:
return await self._bridge.send_reaction(chat_id, msg_id, normalized)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
try:
return await self._bridge.send_reaction(chat_id, msg_id, "")
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if not self._bridge or self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="Not connected")
identity = self._build_stream_identity(chat_id, "")
response = ChannelResponse(
identity=identity,
message_type=MessageType(media_type) if media_type in MessageType else MessageType.FILE,
content="",
)
if media_type == "image":
response.message_type = MessageType.IMAGE
elif media_type == "video":
response.message_type = MessageType.VIDEO
elif media_type == "audio":
response.message_type = MessageType.AUDIO
if isinstance(data, str) and data.startswith("http"):
response.attachments = [Attachment(type=media_type, url=data)]
elif isinstance(data, bytes):
response.content = "media"
else:
response.attachments = [Attachment(type=media_type, url=str(data))]
return await self.send(response)
async def download_media(self, file_id: str) -> bytes:
if not self._bridge:
raise ChannelConnectionError("Bridge not initialized")
return await self._bridge.download_media(file_id)
def _send_pairing_approval(self, chat_id: str) -> None:
import asyncio
async def _do():
try:
if self._bridge and self._status == ChannelStatus.CONNECTED:
await self._bridge.send_message(
{
"conversation_id": chat_id,
"message_type": "text",
"text": "Your pairing request has been approved.",
}
)
except Exception:
logger.exception("[ZaloUser] Failed to send pairing approval")
asyncio.create_task(_do())
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if not self._bridge:
return {"id": channel_user_id, "display_name": channel_user_id}
try:
return await self._bridge.get_user_info(channel_user_id)
except Exception:
return {"id": channel_user_id, "display_name": channel_user_id}
async def get_account_info(self) -> dict[str, Any]:
if not self._bridge:
return self._account_info or {"display_name": "unknown", "user_id": "unknown"}
try:
account = await get_self_info(self._bridge)
self._account_info = account
return account
except Exception:
return self._account_info or {"display_name": "unknown", "user_id": "unknown"}
async def list_friends(self, query: str = "") -> list[dict[str, Any]]:
if not self._bridge:
return self._friends_cache
try:
friends = await list_peers(self._bridge, query)
self._friends_cache = friends
return friends
except Exception:
return self._friends_cache
async def list_groups(self, query: str = "") -> list[dict[str, Any]]:
if not self._bridge:
return self._group_cache
try:
return await list_groups(self._bridge, query)
except Exception:
return self._group_cache
async def list_group_members(self, group_id: str) -> list[dict[str, Any]]:
if not self._bridge:
return []
return await list_group_members(self._bridge, group_id)
async def _handle_message(self, message: ChannelMessage) -> None:
import time
self._last_transport_activity_at = time.time()
msg_id = message.identity.channel_message_id
user_id = message.identity.channel_user_id
chat_id = message.identity.channel_chat_id
if msg_id:
if self._dedup.check_and_mark(msg_id):
logger.debug(f"[ZaloUser] Duplicate message ignored: {msg_id}")
return
self._msg_id_tracker.track(msg_id, chat_id)
if self._bridge and self._status == ChannelStatus.CONNECTED:
try:
await send_delivered_event(self._bridge, chat_id, msg_id)
await send_seen_event(self._bridge, chat_id, msg_id)
except Exception:
logger.debug("[ZaloUser] Failed to send delivered/seen events")
event_type = message.event_type
if event_type in (EventType.REACTION_ADDED, EventType.REACTION_REMOVED):
self._msg_id_tracker.track(message.metadata.get("reaction_message_id", ""), chat_id)
elif event_type == EventType.CARD_ACTION:
await self._handle_callback_query(message)
return
message = augment_sticker_message(message)
message = await augment_message_with_vision(self._bridge, message, self._config)
chat_type = message.metadata.get("zalo_chat_type", "direct")
content = message.content
if chat_type == "direct":
paired_key = f"zalo:{user_id}"
is_new_pair = paired_key not in self._paired_users
self._paired_users.add(paired_key)
dm_policy = self._config.get("dm_policy", "pairing")
if dm_policy == "pairing":
if is_new_pair:
self._send_pairing_approval(chat_id)
elif dm_policy == "disabled":
logger.warning(f"[ZaloUser] DM blocked by policy: user={user_id}")
return
elif dm_policy == "allowlist":
allow_from = self._config.get("allow_from", [])
resolved = resolve_allow_from_entries(allow_from, self._friends_cache)
if paired_key not in allow_from and user_id not in resolved:
logger.warning(f"[ZaloUser] DM blocked (not in allowlist): user={user_id}")
return
elif dm_policy != "open":
logger.warning(f"[ZaloUser] DM blocked by policy: user={user_id}")
return
elif chat_type == "group":
if not check_group_policy(chat_id, user_id, self._config):
logger.warning(f"[ZaloUser] Group message blocked by policy: chat={chat_id}, user={user_id}")
return
bot_name = self._account_info.get("display_name", "")
bot_names = [bot_name] if bot_name else []
if not check_mention_required(chat_id, content, self._config, bot_names):
logger.debug(f"[ZaloUser] Group message ignored: @mention required for chat={chat_id}")
return
route = resolve_agent_route(self.channel_id, chat_id, user_id, chat_type, self._config)
message.metadata["agent_route"] = route
if self._message_handler:
await self._message_handler(message)
async def _handle_callback_query(self, message: ChannelMessage) -> None:
from .interactive_dispatch import InteractiveDispatch
callback_data = message.metadata.get("callback_data", "")
logger.info(f"[ZaloUser] Callback query received: data={callback_data}")
dispatch = InteractiveDispatch()
dispatch.register_prefix("approve_", default_approval_handler)
dispatch.register_prefix("reject_", default_approval_handler)
dispatch.register_prefix("model_", default_model_selector_handler)
dispatch.register_prefix("page_", default_pagination_handler)
ctx = {"bridge": self._bridge, "adapter": self, "exec_approval": getattr(self, "_exec_approval", None)}
result = await dispatch.dispatch(message, ctx)
if result.get("handled") and self._bridge and self._status == ChannelStatus.CONNECTED:
action = result.get("action", "")
chat_id = message.identity.channel_chat_id
if action == "approved":
await self._bridge.send_message(
{
"conversation_id": chat_id,
"message_type": "text",
"text": "Approved.",
}
)
elif action == "rejected":
await self._bridge.send_message(
{
"conversation_id": chat_id,
"message_type": "text",
"text": "Rejected.",
}
)