ForcePilot/backend/package/yuxi/channels/adapters/whatsapp/adapter.py
Kris 5c3611ff19 refactor(whatsapp): 整理WhatsApp适配器代码结构并修复多线程安全问题
主要变更:
1. 重构导入顺序,统一模块导入规范
2. 提取通用方法到session模块,减少代码重复
3. 为缓存类添加线程/异步锁,修复并发安全问题
4. 新增入站处理器和发送管理器模块,拆分业务逻辑
5. 优化凭证队列,改为异步实现
6. 移除废弃的SSE_POLLING能力标识
7. 修复轮询投票解析逻辑
8. 优化Markdown转换规则,避免格式冲突
9. 完善连接控制器的异常处理
10. 新增发送静默消息的API支持
2026-05-13 16:17:30 +08:00

693 lines
28 KiB
Python

from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from typing import Any
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities
from yuxi.channels.exceptions import ChannelNotConnectedError
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
from yuxi.channels.meta import ChannelMeta as DisplayMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
)
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 .bridge import BaileysBridge
from .channel_meta import ChannelMeta
from .connection_controller import ConnectionController, ConnectionState
from .credential_queue import CredentialQueue
from .debounce import MessageDebouncer
from .dedupe import ButtonDeduplicator, MessageDeduplicator
from .directory import PerGroupConfig
from .directory_peers import ContactDirectory
from .echo_filter import EchoFilter
from .error_policy import ErrorPolicyConfig
from .format import format_outbound as _format_outbound
from .format import normalize_inbound as _normalize_inbound
from .heartbeat import HeartbeatManager
from .inbound_cache import InboundMessageCache
from .inbound_processor import InboundProcessor
from .inbound_pipeline import InboundPipeline, PipelineAction
from .monitor import WhatsAppMonitor
from .pairing import PairingManager
from .per_dm_config import PerDmConfig
from .reactions.ack_reaction import AckReactionManager
from .reactions.reaction_level import ReactionLevelController
from .security import WhatsAppSecurityPolicy
from .send_manager import SendManager
from .sent_message_cache import SentMessageCache
from .session import jid_to_thread_key, normalize_phone, resolve_session_scope
from .stream.lane_delivery import LaneDelivery
from .vision.sticker_vision import StickerVision
from .watchdog import Watchdog
@register_builtin_adapter
class WhatsAppAdapter(BaseChannelAdapter):
channel_id = "whatsapp"
channel_type = ChannelType.WHATSAPP
meta = DisplayMeta(
id="whatsapp",
label="WhatsApp",
selection_label="WhatsApp",
docs_path="docs/channels/whatsapp",
docs_label="WhatsApp 文档",
blurb="WhatsApp Bridge 适配器,通过 Baileys 协议连接 WhatsApp",
order=9,
aliases=["wa"],
system_image="whatsapp",
markdown_capable=True,
exposure="public",
)
channel_meta = ChannelMeta()
text_chunk_limit = 4000
supports_markdown = True
supports_streaming = True
streaming_modes = ["off", "typing_indicator"]
max_media_size_mb = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
polls=True,
reactions=True,
unsend=True,
reply=True,
media=True,
supports_markdown=True,
supports_streaming=True,
supports_broadcast=False,
streaming_modes=["off", "typing_indicator"],
text_chunk_limit=4000,
max_media_size_mb=100,
block_streaming=True,
tts=TTSCapabilities(
voice=TTSVoiceCapabilities(
synthesis_target="voice-note",
transcodes_audio=True,
enabled=False,
)
),
)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
cfg = config or {}
self._status = ChannelStatus.DISCONNECTED
self._bridge = BaileysBridge(cfg)
self._monitor = WhatsAppMonitor(cfg, self._bridge)
self._connection_ctrl = ConnectionController()
self._heartbeat = HeartbeatManager(self._bridge)
self._watchdog = Watchdog(timeout_seconds=cfg.get("watchdogTimeout", 30.0))
self._security = WhatsAppSecurityPolicy(cfg)
self._pairing = PairingManager()
self._reaction_level = ReactionLevelController(cfg)
self._ack_reaction = AckReactionManager(cfg)
self._per_group_config = PerGroupConfig.from_config(cfg)
self._per_dm_config = PerDmConfig.from_config(cfg)
self._error_policy = ErrorPolicyConfig.from_config(cfg)
self._block_streaming = cfg.get("blockStreaming", False)
self._chunk_mode = cfg.get("chunkMode", "length")
self._send_read_receipts = cfg.get("sendReadReceipts", False)
self._reply_to_mode = cfg.get("replyToMode", "first")
self._lane_delivery = LaneDelivery(cfg)
self._sticker_vision = StickerVision(cfg)
self._self_chat_mode = cfg.get("selfChatMode", False)
self._default_to = cfg.get("defaultTo")
self._contact_directory = ContactDirectory.from_config(cfg)
self._401_retry_pending = False
self._sent_message_cache = SentMessageCache(
max_size=500,
ttl_seconds=cfg.get("sentMessageCacheTtl", 3600),
)
self._inbound_cache = InboundMessageCache(
max_size=500,
ttl_seconds=600,
)
self._deduplicator = MessageDeduplicator(ttl_seconds=cfg.get("dedupeTtl", 300))
self._button_deduplicator = ButtonDeduplicator(ttl_seconds=cfg.get("buttonDedupeTtl", 5.0))
self._echo_filter = EchoFilter(ttl_seconds=cfg.get("echoTtl", 10.0))
self._debouncer = MessageDebouncer(
window_seconds=cfg.get("debounceWindow", 2.0),
max_calls=cfg.get("debounceMaxCalls", 3),
)
self._creds_queue: CredentialQueue | None = None
self._self_jid: str | None = None
self._on_connection_change: Callable[[dict[str, Any]], Awaitable[None]] | None = None
self._recent_messages: dict[str, dict[str, Any]] = {}
self._last_inbound_at: float | None = None
self._last_message_at: float | None = None
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="whatsapp")
self._stream_buffers: dict[str, str] = {}
self._stream_chunk_counters: dict[str, int] = {}
self._stream_status_msg_ids: dict[str, str] = {}
self._last_message_tracker: list[float | None] = [None]
self._send_mgr = SendManager(
bridge=self._bridge,
circuit_breaker=self._circuit_breaker,
sent_message_cache=self._sent_message_cache,
echo_filter=self._echo_filter,
debouncer=self._debouncer,
reaction_level=self._reaction_level,
error_policy=self._error_policy,
lane_delivery=self._lane_delivery,
config=cfg,
stream_buffers=self._stream_buffers,
stream_chunk_counters=self._stream_chunk_counters,
stream_status_msg_ids=self._stream_status_msg_ids,
recent_messages=self._recent_messages,
last_message_tracker=self._last_message_tracker,
)
self._inbound_processor = InboundProcessor(
echo_filter=self._echo_filter,
deduplicator=self._deduplicator,
button_deduplicator=self._button_deduplicator,
security=self._security,
self_chat_mode=self._self_chat_mode,
per_group_config=self._per_group_config,
per_dm_config=self._per_dm_config,
pairing=self._pairing,
self_jid=None,
)
self._inbound_pipeline = self._inbound_processor.build_pipeline()
async def connect(self) -> None:
if self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING):
return
self._status = ChannelStatus.CONNECTING
self._connection_ctrl.transition(ConnectionState.CONNECTING)
logger.info(f"[WhatsApp] Starting channel '{self.config.get('name', self.channel_id)}'")
try:
await self._bridge.start()
except Exception as e:
self._status = ChannelStatus.ERROR
self._connection_ctrl.transition(ConnectionState.DISCONNECTED)
logger.error(f"[WhatsApp] Bridge start failed: {e}")
raise ChannelNotConnectedError() from e
health = await self._bridge.health_check()
if health.status == "healthy":
self._self_jid = health.metadata.get("jid", "")
logger.info(f"[WhatsApp] Connected as {self._self_jid}")
self._connection_ctrl.transition(ConnectionState.ACTIVE)
self._setup_monitor_handler()
self._heartbeat.on_unhealthy(self._on_bridge_unhealthy)
self._watchdog.on_timeout(self._on_watchdog_timeout)
await self._monitor.start()
await self._heartbeat.start()
await self._watchdog.start()
self._status = ChannelStatus.CONNECTED
return
self._status = ChannelStatus.CONNECTING
self._connection_ctrl.transition(ConnectionState.QR_PENDING)
logger.info("[WhatsApp] Waiting for QR login (call pre_connect first)")
async def disconnect(self) -> None:
if self._status == ChannelStatus.DISCONNECTED:
return
logger.info(f"[WhatsApp] Stopping channel '{self.config.get('name', self.channel_id)}'")
await self._watchdog.stop()
await self._heartbeat.stop()
await self._monitor.stop()
await self._bridge.stop()
self._status = ChannelStatus.DISCONNECTED
self._connection_ctrl.transition(ConnectionState.DISCONNECTED)
self._self_jid = None
async def pre_connect(self, force: bool = False) -> dict:
if not force:
health = await self._bridge.health_check()
if health.status == "healthy":
self._self_jid = health.metadata.get("jid", "")
self._connection_ctrl.transition(ConnectionState.ACTIVE)
return {"status": "logged_in", "jid": self._self_jid}
qr_result = await self._bridge.get_qr()
if qr_data := qr_result.get("qr"):
self._connection_ctrl.transition(ConnectionState.QR_PENDING)
return {"status": "pending_scan", "qr_base64": qr_data, "qr_type": "png_base64"}
return {"status": "error", "error": "QR generation failed"}
async def send(self, response: ChannelResponse, silent: bool = False) -> DeliveryResult:
return await self._send_mgr.send(response, silent)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
return await self._send_mgr.send_media(chat_id, media_type, data)
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
return await self._send_mgr.send_reaction(chat_id, msg_id, emoji)
async def remove_reaction(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._send_mgr.remove_reaction(chat_id, msg_id)
async def send_reaction_from_action(self, action: dict[str, Any]) -> DeliveryResult:
return await self._send_mgr.send_reaction_from_action(action)
async def download_media(self, file_id: str) -> bytes:
return await self._send_mgr.download_media(file_id)
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
return await self._send_mgr.send_stream_chunk(chat_id, msg_id, chunk, finished)
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
return await self._send_mgr.edit_message(chat_id, msg_id, content)
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
return await self._send_mgr.delete_message(chat_id, msg_id)
async def send_poll(self, chat_id: str, name: str, options: list[str], selectable_count: int = 1) -> DeliveryResult:
return await self._send_mgr.send_poll(chat_id, name, options, selectable_count)
async def send_location(
self,
chat_id: str,
latitude: float,
longitude: float,
name: str = "",
address: str = "",
) -> DeliveryResult:
return await self._send_mgr.send_location(chat_id, latitude, longitude, name, address)
async def send_contact(
self,
chat_id: str,
contacts: list[dict[str, str]],
) -> DeliveryResult:
return await self._send_mgr.send_contact(chat_id, contacts)
async def send_sticker(
self,
chat_id: str,
sticker_path: str,
reply_to: str | None = None,
) -> DeliveryResult:
return await self._send_mgr.send_sticker(chat_id, sticker_path, reply_to)
async def send_buttons(
self,
chat_id: str,
text: str,
buttons: list[dict[str, str]],
title: str = "",
footer: str = "",
) -> DeliveryResult:
return await self._send_mgr.send_buttons(chat_id, text, buttons, title, footer)
async def send_list_message(
self,
chat_id: str,
text: str,
sections: list[dict[str, Any]],
title: str = "",
footer: str = "",
button_text: str = "Select",
) -> DeliveryResult:
return await self._send_mgr.send_list_message(chat_id, text, sections, title, footer, button_text)
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
return _normalize_inbound(raw, self.channel_id)
def format_outbound(self, response: ChannelResponse) -> Any:
return _format_outbound(response)
async def health_check(self) -> HealthStatus:
bridge_health = await self._bridge.health_check()
if self._status != ChannelStatus.CONNECTED:
return HealthStatus(
status="degraded",
metadata={
"adapter_status": self._status.value,
"connection_state": self._connection_ctrl.state.value,
"bridge": bridge_health.metadata,
},
)
return HealthStatus(
status=bridge_health.status,
latency_ms=bridge_health.latency_ms,
last_error=bridge_health.last_error,
last_connected_at=utc_now_naive() if bridge_health.status == "healthy" else None,
metadata={
"jid": self._self_jid or "",
"adapter_status": self._status.value,
"connection_state": self._connection_ctrl.state.value,
"watchdog_alive": self._watchdog.is_alive(),
"last_inbound_at": self._last_inbound_at,
"last_message_at": self._last_message_at,
**bridge_health.metadata,
},
)
def on_connection_change(self, handler: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
self._on_connection_change = handler
async def _handle_sse_event(self, raw_payload: dict[str, Any]) -> None:
self._watchdog.feed()
event_type = raw_payload.get("type", "")
if event_type == "connection":
status = raw_payload.get("status", "")
reason = raw_payload.get("reason", "")
if status == "close" and reason in ("logged_out_401", "logged_out"):
logger.warning("[WhatsApp] Bridge reported logged out (401)")
if not self._401_retry_pending:
self._401_retry_pending = True
logger.info("[WhatsApp] 401 detected, checking if transient (wait 5s)...")
await asyncio.sleep(5.0)
health = await self._bridge.health_check()
if health.status == "healthy":
self._401_retry_pending = False
self._connection_ctrl.transition(ConnectionState.ACTIVE)
self._self_jid = health.metadata.get("jid", self._self_jid)
logger.info("[WhatsApp] 401 was transient, connection recovered")
return
logger.warning("[WhatsApp] 401 confirmed as permanent, cleaning up")
self._401_retry_pending = False
self._connection_ctrl.transition(ConnectionState.LOGGED_OUT)
from .auth_resolve import resolve_auth_dir
from .logout_security import perform_logout_cleanup
auth_dir = resolve_auth_dir(self.channel_id, self.config)
perform_logout_cleanup(auth_dir)
if self._on_connection_change:
await self._on_connection_change(
{"status": "logged_out", "reason": reason, "code": raw_payload.get("statusCode")}
)
return
self._connection_ctrl.transition(
ConnectionState.ACTIVE if raw_payload.get("status") == "open" else ConnectionState.DISCONNECTED
)
if self._on_connection_change:
await self._on_connection_change(raw_payload)
if self._connection_ctrl.state == ConnectionState.ACTIVE:
logger.info("[WhatsApp] Connection opened")
if self._status != ChannelStatus.CONNECTED:
health = await self._bridge.health_check()
self._self_jid = health.metadata.get("jid", self._self_jid)
self._status = ChannelStatus.CONNECTED
return
if event_type == "qr":
self._connection_ctrl.transition(ConnectionState.QR_PENDING)
logger.info("[WhatsApp] QR code event received")
return
if self._message_handler is None:
return
result = await self._inbound_pipeline.process(raw_payload)
if result.action != PipelineAction.ACCEPT:
return
self._last_inbound_at = time.monotonic()
is_from_me = raw_payload.get("key", {}).get("fromMe", False)
if is_from_me and not self._self_chat_mode:
return
msg_id = raw_payload.get("key", {}).get("id", "")
if is_from_me and self._self_chat_mode:
modified = raw_payload.copy()
modified["key"] = {**raw_payload.get("key", {}), "fromMe": False}
channel_msg = self.normalize_inbound(modified)
channel_msg.metadata["from_me"] = True
else:
channel_msg = self.normalize_inbound(raw_payload)
chat_id = channel_msg.identity.channel_chat_id
is_group = "@g.us" in chat_id
if msg_id and channel_msg.attachments:
self._recent_messages[msg_id] = raw_payload
if len(self._recent_messages) > 200:
oldest = next(iter(self._recent_messages))
del self._recent_messages[oldest]
if not is_from_me and msg_id:
msg_content = raw_payload.get("message", {})
text = (
msg_content.get("conversation", "")
or (msg_content.get("extendedTextMessage") or {}).get("text", "")
or (msg_content.get("imageMessage") or {}).get("caption", "")
or (msg_content.get("videoMessage") or {}).get("caption", "")
or (msg_content.get("documentMessage") or {}).get("caption", "")
)
self._inbound_cache.put(msg_id, chat_id, text)
if not is_from_me and self._ack_reaction.can_ack(chat_id, is_group):
asyncio.ensure_future(self._send_ack_reaction(chat_id, msg_id))
if not is_from_me and self._send_read_receipts and msg_id:
asyncio.ensure_future(self._send_read_receipt(chat_id, msg_id))
if self._sticker_vision.enabled and channel_msg.attachments:
asyncio.ensure_future(self._enrich_with_vision(channel_msg, raw_payload))
await self._message_handler(channel_msg)
async def _send_ack_reaction(self, chat_id: str, msg_id: str) -> None:
if not msg_id:
return
try:
await self._bridge.send_reaction(
jid=chat_id,
message_id=msg_id,
emoji=self._ack_reaction.emoji,
)
self._ack_reaction.record_ack(chat_id)
except Exception:
logger.debug(f"[WhatsApp] ACK reaction failed for {msg_id}", exc_info=True)
async def _send_read_receipt(self, chat_id: str, msg_id: str) -> None:
if not msg_id:
return
try:
await self._bridge.send_read_receipt(jid=chat_id, message_ids=[msg_id])
except Exception:
logger.debug(f"[WhatsApp] Read receipt failed for {msg_id}", exc_info=True)
async def _enrich_with_vision(self, channel_msg: ChannelMessage, raw_payload: dict) -> None:
try:
for attachment in channel_msg.attachments:
if attachment.type in ("image", "sticker") and attachment.url:
description = await self._sticker_vision.describe_sticker(attachment.url)
if description:
channel_msg.metadata["vision_description"] = description
logger.debug(f"[WhatsApp] Vision enriched: {description[:100]}")
break
elif attachment.type == "audio" and attachment.url:
transcription = await self._sticker_vision.transcribe_audio(attachment.url)
if transcription:
channel_msg.metadata["audio_transcription"] = transcription
logger.debug(f"[WhatsApp] Audio transcription: {transcription[:100]}")
break
except Exception:
logger.debug("[WhatsApp] Vision enrichment failed", exc_info=True)
def _build_inbound_pipeline(self) -> InboundPipeline:
self._inbound_processor._self_jid = self._self_jid
return self._inbound_processor.build_pipeline()
def _setup_monitor_handler(self) -> None:
self._monitor.on_raw_message(self._handle_sse_event)
async def _on_bridge_unhealthy(self, error: str) -> None:
logger.warning(f"[WhatsApp] Bridge unhealthy for extended period: {error}, attempting recovery")
self._status = ChannelStatus.ERROR
self._connection_ctrl.transition(ConnectionState.RECONNECTING)
if self._on_connection_change:
await self._on_connection_change(
{
"status": "error",
"error": f"Bridge unhealthy: {error}",
}
)
async def _on_watchdog_timeout(self) -> None:
logger.warning("[WhatsApp] Watchdog timeout, bridge may be unresponsive")
self._status = ChannelStatus.ERROR
self._connection_ctrl.transition(ConnectionState.RECONNECTING)
if self._on_connection_change:
await self._on_connection_change(
{
"status": "error",
"error": "Watchdog timeout - bridge unresponsive",
}
)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
phone = normalize_phone(channel_user_id)
jid = f"{phone}@s.whatsapp.net"
try:
avatar_url = await self._bridge.get_profile_picture(jid)
except Exception:
avatar_url = None
return {
"phone": phone,
"jid": jid,
"avatar_url": avatar_url,
}
async def get_groups(self) -> dict[str, Any]:
return await self._bridge.get_groups()
async def get_group_info(self, group_jid: str) -> dict[str, Any]:
return await self._bridge.get_group_info(group_jid)
async def wait_scan(self, timeout: float = 120.0) -> dict[str, Any]:
return await self._bridge.wait_scan(timeout)
async def detect_whatsapp_linked(self) -> bool:
from .auth_resolve import resolve_auth_dir
from .logout_security import validate_credential_freshness
auth_dir = resolve_auth_dir(self.channel_id, self.config)
if not validate_credential_freshness(auth_dir):
return False
try:
health = await self._bridge.health_check()
return health.status == "healthy"
except Exception:
return False
async def get_qr_status(self) -> dict[str, Any]:
return await self._bridge.get_qr_status()
async def logout(self) -> dict[str, Any]:
from .auth_resolve import resolve_auth_dir
from .logout_security import perform_logout_cleanup
result = await self._bridge.logout()
auth_dir = resolve_auth_dir(self.channel_id, self.config)
perform_logout_cleanup(auth_dir)
self._connection_ctrl.transition(ConnectionState.LOGGED_OUT)
return result
async def get_message_history(self, jid: str, limit: int = 50, before: str | None = None) -> dict[str, Any]:
return await self._bridge.get_message_history(jid, limit=limit, before=before)
def thread_key_for(self, jid: str) -> str:
scope = resolve_session_scope(self.config)
return jid_to_thread_key(self.channel_id, jid, scope)
def pair_user(self, phone_number: str, code: str, timeout: float = 300) -> None:
self._pairing.create_pair_request(phone_number, code, timeout)
def confirm_pair(self, phone_number: str) -> bool:
return self._pairing.confirm_pair(phone_number)
def is_paired(self, phone_number: str) -> bool:
return self._pairing.is_paired(phone_number)
def add_to_allowlist(self, phone_number: str) -> None:
self._security.add_to_allow_list(phone_number)
def remove_from_allowlist(self, phone_number: str) -> bool:
return self._security.remove_from_allow_list(phone_number)
@property
def security_policy(self) -> WhatsAppSecurityPolicy:
return self._security
@property
def pairing_manager(self) -> PairingManager:
return self._pairing
@property
def connection_controller(self) -> ConnectionController:
return self._connection_ctrl
@property
def last_inbound_at(self) -> float | None:
return self._last_inbound_at
@property
def last_message_at(self) -> float | None:
return self._last_message_at
def resolve_system_prompt(self, chat_id: str) -> str | None:
base = None
if "@g.us" in chat_id:
base = self._per_group_config.system_prompt(chat_id)
else:
sender = chat_id.split("@")[0] if "@" in chat_id else chat_id
base = self._per_dm_config.system_prompt(sender)
return self._inject_reaction_guidance(base)
def resolve_system_prompt_for_group(self, group_jid: str) -> str | None:
base = self._per_group_config.system_prompt(group_jid)
return self._inject_reaction_guidance(base)
def resolve_system_prompt_for_direct(self, phone: str) -> str | None:
base = self._per_dm_config.system_prompt(phone)
return self._inject_reaction_guidance(base)
def _inject_reaction_guidance(self, base: str | None) -> str | None:
guidance = self.config.get("agentReactionGuidance")
if not guidance:
return base
guidance_text = f"\n\nYou may use WhatsApp reactions to respond with emoji when appropriate. {guidance}"
if base:
return base + guidance_text
return guidance_text.strip()
def resolve_quoted_message_key(self, chat_id: str, quoted_msg_id: str | None = None) -> dict | None:
return self._inbound_cache.resolve_quoted_message_key(chat_id, quoted_msg_id)
@property
def inbound_cache(self) -> InboundMessageCache:
return self._inbound_cache
@property
def contact_directory(self) -> ContactDirectory:
return self._contact_directory
@property
def self_chat_mode(self) -> bool:
return self._self_chat_mode
@property
def default_target(self) -> str | None:
return self._default_to
@property
def agent_reaction_guidance(self) -> str | None:
return self.config.get("agentReactionGuidance")
def resolve_inbound_debounce_ms(self, account_id: str | None = None) -> float:
accounts_cfg = self.config.get("accounts", {})
if account_id and isinstance(accounts_cfg, dict):
acct = accounts_cfg.get(account_id, {})
if isinstance(acct, dict) and "debounceWindow" in acct:
return float(acct["debounceWindow"]) * 1000
return self.config.get("debounceWindow", 2.0) * 1000
@property
def has_capability(self):
return self.channel_meta.has_capability