feat(imessage): 新增投票管理、审计日志功能并优化多项细节
1. 新增poll命令支持创建、关闭、列出投票 2. 新增审计日志记录功能 3. 优化远程附件URL安全校验逻辑 4. 修复表格匹配正则,支持包含竖线的表格分隔行 5. 新增媒体AI处理能力,支持图片描述和音频转录 6. 完善配置校验和错误处理 7. 重构文本发送逻辑,增加重试机制 8. 新增投票投票处理逻辑,支持数字快捷投票
This commit is contained in:
parent
87fc26cd15
commit
8f352d91bb
@ -31,16 +31,19 @@ from yuxi.utils.datetime_utils import utc_now_naive
|
|||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
from .accounts import list_enabled_accounts
|
from .accounts import list_enabled_accounts
|
||||||
|
from .audit import AuditLogger
|
||||||
from .bridge_client import BlueBubblesClient
|
from .bridge_client import BlueBubblesClient
|
||||||
from .commands import is_authorized_for_commands, parse_command
|
from .commands import is_authorized_for_commands, parse_command
|
||||||
|
from .config_schema import validate_config
|
||||||
from .contact_resolver import resolve_contact
|
from .contact_resolver import resolve_contact
|
||||||
from .echo_cache import EchoCache
|
from .echo_cache import EchoCache
|
||||||
from .envelope import format_inbound_envelope
|
|
||||||
from .formatter import convert_markdown_tables, sanitize_imessage_text
|
from .formatter import convert_markdown_tables, sanitize_imessage_text
|
||||||
|
from .media_ai import describe_image, transcribe_audio
|
||||||
from .media_security import validate_attachment_path
|
from .media_security import validate_attachment_path
|
||||||
from .normalize import parse_forwarded
|
|
||||||
from .monitor import IMessageMonitor
|
from .monitor import IMessageMonitor
|
||||||
|
from .normalize import parse_forwarded
|
||||||
from .pairing import IMessagePairingManager
|
from .pairing import IMessagePairingManager
|
||||||
|
from .polls import IMessagePollManager
|
||||||
from .probe import run_doctor_diagnosis
|
from .probe import run_doctor_diagnosis
|
||||||
from .rate_limiter import LoopRateLimiter
|
from .rate_limiter import LoopRateLimiter
|
||||||
from .reflection_guard import ReflectionGuard
|
from .reflection_guard import ReflectionGuard
|
||||||
@ -77,7 +80,7 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
edit=True,
|
edit=True,
|
||||||
unsend=True,
|
unsend=True,
|
||||||
media=True,
|
media=True,
|
||||||
polls=False,
|
polls=True,
|
||||||
threads=False,
|
threads=False,
|
||||||
pin=False,
|
pin=False,
|
||||||
unpin=False,
|
unpin=False,
|
||||||
@ -102,6 +105,11 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
self.config.setdefault("password", os.getenv("IMESSAGE_PASSWORD", ""))
|
self.config.setdefault("password", os.getenv("IMESSAGE_PASSWORD", ""))
|
||||||
self._account_id = self.config.get("accountId", self.config.get("account_id", "default"))
|
self._account_id = self.config.get("accountId", self.config.get("account_id", "default"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_config(self.config)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"iMessage configuration validation failed: {e}") from e
|
||||||
|
|
||||||
super().__init__(self.config)
|
super().__init__(self.config)
|
||||||
|
|
||||||
self._block_streaming = self.config.get("blockStreaming", self.config.get("block_streaming", False))
|
self._block_streaming = self.config.get("blockStreaming", self.config.get("block_streaming", False))
|
||||||
@ -151,6 +159,8 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
self._pairing = IMessagePairingManager(
|
self._pairing = IMessagePairingManager(
|
||||||
config_writes=self.config.get("configWrites", self.config.get("config_writes", True))
|
config_writes=self.config.get("configWrites", self.config.get("config_writes", True))
|
||||||
)
|
)
|
||||||
|
self._audit_logger = AuditLogger(log_dir=self.config.get("auditDir", self.config.get("audit_dir", None)))
|
||||||
|
self._poll_manager = IMessagePollManager(adapter=self)
|
||||||
self._echo_cache = EchoCache()
|
self._echo_cache = EchoCache()
|
||||||
self._sticker_cache = StickerCache()
|
self._sticker_cache = StickerCache()
|
||||||
self._last_inbound: dict[str, tuple[float, str]] = {}
|
self._last_inbound: dict[str, tuple[float, str]] = {}
|
||||||
@ -164,6 +174,19 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
self._merge_buffers: dict[str, list[ChannelMessage]] = {}
|
self._merge_buffers: dict[str, list[ChannelMessage]] = {}
|
||||||
self._merge_tasks: dict[str, asyncio.Task] = {}
|
self._merge_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
media_ai_cfg = self.config.get("mediaAi", self.config.get("media_ai", {}))
|
||||||
|
self._media_ai_enabled = media_ai_cfg.get("enabled", False)
|
||||||
|
self._media_ai_config = {
|
||||||
|
"vision_api_url": media_ai_cfg.get("visionApiUrl", media_ai_cfg.get("vision_api_url", "")),
|
||||||
|
"vision_api_key": media_ai_cfg.get("visionApiKey", media_ai_cfg.get("vision_api_key", "")),
|
||||||
|
"transcription_api_url": media_ai_cfg.get(
|
||||||
|
"transcriptionApiUrl", media_ai_cfg.get("transcription_api_url", "")
|
||||||
|
),
|
||||||
|
"transcription_api_key": media_ai_cfg.get(
|
||||||
|
"transcriptionApiKey", media_ai_cfg.get("transcription_api_key", "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def _build_account_config(self, acct) -> dict[str, Any]:
|
def _build_account_config(self, acct) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"server_url": acct.server_url,
|
"server_url": acct.server_url,
|
||||||
@ -256,6 +279,7 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
await monitor.stop()
|
await monitor.stop()
|
||||||
for client in self._clients.values():
|
for client in self._clients.values():
|
||||||
await client.close()
|
await client.close()
|
||||||
|
await self._audit_logger.flush()
|
||||||
self._status = ChannelStatus.DISCONNECTED
|
self._status = ChannelStatus.DISCONNECTED
|
||||||
self._pending_messages.clear()
|
self._pending_messages.clear()
|
||||||
|
|
||||||
@ -277,28 +301,67 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
disable_notification=disable_notification,
|
disable_notification=disable_notification,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
result = await self._send_chunked(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content=content,
|
||||||
|
reply_to=reply_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.success and result.message_id:
|
||||||
|
self._pending_messages[result.message_id] = content
|
||||||
|
self._echo_cache.record_sent(result.message_id, content, chat_guid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _send_chunked(
|
||||||
|
self,
|
||||||
|
chat_guid: str,
|
||||||
|
content: str,
|
||||||
|
reply_to: str | None = None,
|
||||||
|
max_retries: int = 2,
|
||||||
|
) -> DeliveryResult:
|
||||||
chunks = _chunk_text(content, self.text_chunk_limit)
|
chunks = _chunk_text(content, self.text_chunk_limit)
|
||||||
last_result = DeliveryResult(success=True)
|
results: list[DeliveryResult] = []
|
||||||
|
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
chunk_reply_to = reply_to if i == 0 else None
|
chunk_reply_to = reply_to if i == 0 else None
|
||||||
|
|
||||||
|
chunk_result = None
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
chunk_result = await self._get_client().send_text_message(
|
chunk_result = await self._get_client().send_text_message(
|
||||||
chat_guid=chat_guid,
|
chat_guid=chat_guid,
|
||||||
content=chunk,
|
content=chunk,
|
||||||
reply_to=chunk_reply_to,
|
reply_to=chunk_reply_to,
|
||||||
is_html=self.supports_markdown,
|
is_html=self.supports_markdown,
|
||||||
)
|
)
|
||||||
|
if chunk_result.success:
|
||||||
|
break
|
||||||
|
if attempt < max_retries:
|
||||||
|
logger.warning(
|
||||||
|
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed "
|
||||||
|
f"(attempt {attempt + 1}/{max_retries + 1}): {chunk_result.error}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1 * (attempt + 1))
|
||||||
|
|
||||||
|
results.append(chunk_result)
|
||||||
|
|
||||||
if not chunk_result.success:
|
if not chunk_result.success:
|
||||||
if i == 0:
|
if i == 0:
|
||||||
return chunk_result
|
return chunk_result
|
||||||
last_result = chunk_result
|
logger.error(
|
||||||
else:
|
f"[iMessage] Chunk {i + 1}/{len(chunks)} send failed after "
|
||||||
last_result = chunk_result
|
f"{max_retries + 1} attempts: {chunk_result.error}"
|
||||||
result = last_result
|
)
|
||||||
|
|
||||||
if result.success and result.message_id:
|
all_success = all(r.success for r in results)
|
||||||
self._pending_messages[result.message_id] = content
|
if all_success:
|
||||||
self._echo_cache.record_sent(result.message_id, content, chat_guid)
|
return results[-1]
|
||||||
return result
|
|
||||||
|
failed_indices = [i + 1 for i, r in enumerate(results) if not r.success]
|
||||||
|
return DeliveryResult(
|
||||||
|
success=False,
|
||||||
|
error=f"Chunks {failed_indices} failed to send",
|
||||||
|
metadata={"failed_chunks": failed_indices, "total_chunks": len(chunks)},
|
||||||
|
)
|
||||||
|
|
||||||
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
|
||||||
suffix_map = {"image": ".jpg", "video": ".mp4", "audio": ".caf", "file": ""}
|
suffix_map = {"image": ".jpg", "video": ".mp4", "audio": ".caf", "file": ""}
|
||||||
@ -354,6 +417,9 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def send_sticker(self, chat_guid: str, sticker_id: str) -> DeliveryResult:
|
async def send_sticker(self, chat_guid: str, sticker_id: str) -> DeliveryResult:
|
||||||
|
# 注意: BlueBubbles 当前不提供原生 sticker API。
|
||||||
|
# Sticker 以 URL 文本消息形式发送,而非使用 iMessage 原生贴纸协议。
|
||||||
|
# 待 BlueBubbles 支持原生 sticker API 后可升级此实现。
|
||||||
sticker_entry = self._sticker_cache.get(sticker_id)
|
sticker_entry = self._sticker_cache.get(sticker_id)
|
||||||
if not sticker_entry:
|
if not sticker_entry:
|
||||||
return DeliveryResult(success=False, error=f"Sticker not found: {sticker_id}")
|
return DeliveryResult(success=False, error=f"Sticker not found: {sticker_id}")
|
||||||
@ -531,21 +597,13 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
|
|
||||||
content, msg_type, attachments = self._extract_content(data, include_attachments=self._include_attachments)
|
content, msg_type, attachments = self._extract_content(data, include_attachments=self._include_attachments)
|
||||||
mentions = self._parse_mentions(data)
|
mentions = self._parse_mentions(data)
|
||||||
reply_context = self._parse_reply_context(data)
|
|
||||||
|
|
||||||
sender_name = await resolve_contact(self._get_client(), sender)
|
if self._media_ai_enabled and attachments:
|
||||||
envelope = format_inbound_envelope(
|
ai_descriptions = await self._run_media_ai(attachments)
|
||||||
identity=ChannelIdentity(
|
if ai_descriptions:
|
||||||
channel_id=self.channel_id,
|
content = content + "\n" + "\n".join(ai_descriptions)
|
||||||
channel_type=self.channel_type,
|
|
||||||
channel_user_id=self._clean_handle(sender),
|
reply_context = self._parse_reply_context(data)
|
||||||
channel_chat_id=chat_guid,
|
|
||||||
channel_message_id=msg_guid,
|
|
||||||
),
|
|
||||||
sender_name=sender_name or "",
|
|
||||||
chat_name=chat_guid,
|
|
||||||
reply_context=reply_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if reply_context:
|
if reply_context:
|
||||||
reply_tag = format_reply_context(
|
reply_tag = format_reply_context(
|
||||||
@ -590,6 +648,11 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
if chat_type == ChatType.DIRECT:
|
if chat_type == ChatType.DIRECT:
|
||||||
access = self._security.check_dm_access(sender)
|
access = self._security.check_dm_access(sender)
|
||||||
if not access.allowed:
|
if not access.allowed:
|
||||||
|
self._audit_logger.record_dm_access(
|
||||||
|
sender=sender,
|
||||||
|
allowed=False,
|
||||||
|
reason=access.reject_reason or "unknown",
|
||||||
|
)
|
||||||
logger.info(f"[iMessage] DM blocked: sender={sender}, reason={access.reject_reason}")
|
logger.info(f"[iMessage] DM blocked: sender={sender}, reason={access.reject_reason}")
|
||||||
if access.reply:
|
if access.reply:
|
||||||
asyncio.ensure_future(
|
asyncio.ensure_future(
|
||||||
@ -617,6 +680,8 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
)
|
)
|
||||||
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
||||||
|
|
||||||
|
self._audit_logger.record_dm_access(sender=sender, allowed=True)
|
||||||
|
|
||||||
if not self._pairing.is_approved(self._clean_handle(sender)):
|
if not self._pairing.is_approved(self._clean_handle(sender)):
|
||||||
if self._security.dm_policy == "pairing":
|
if self._security.dm_policy == "pairing":
|
||||||
pairing_code = self._pairing.request_pairing(
|
pairing_code = self._pairing.request_pairing(
|
||||||
@ -640,6 +705,11 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
elif chat_type == ChatType.GROUP:
|
elif chat_type == ChatType.GROUP:
|
||||||
access = self._security.check_group_access(chat_guid)
|
access = self._security.check_group_access(chat_guid)
|
||||||
if not access.allowed:
|
if not access.allowed:
|
||||||
|
self._audit_logger.record_group_access(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
allowed=False,
|
||||||
|
reason=access.reject_reason or "unknown",
|
||||||
|
)
|
||||||
logger.info(f"[iMessage] Group blocked: chat={chat_guid}, reason={access.reject_reason}")
|
logger.info(f"[iMessage] Group blocked: chat={chat_guid}, reason={access.reject_reason}")
|
||||||
if access.reply:
|
if access.reply:
|
||||||
asyncio.ensure_future(
|
asyncio.ensure_future(
|
||||||
@ -650,11 +720,16 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
)
|
)
|
||||||
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
||||||
|
|
||||||
|
self._audit_logger.record_group_access(chat_guid=chat_guid, allowed=True)
|
||||||
|
|
||||||
mention_check = self._security.check_mention_required(chat_guid, mentions, self._self_handle or "")
|
mention_check = self._security.check_mention_required(chat_guid, mentions, self._self_handle or "")
|
||||||
if not mention_check.allowed:
|
if not mention_check.allowed:
|
||||||
logger.debug(f"[iMessage] Mention required but bot not mentioned in {chat_guid}")
|
logger.debug(f"[iMessage] Mention required but bot not mentioned in {chat_guid}")
|
||||||
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
return self._make_passthrough_event(chat_guid, msg_guid, sender, event_type)
|
||||||
|
|
||||||
|
if content and content.strip().isdigit():
|
||||||
|
await self._handle_poll_vote(chat_guid, sender, content.strip())
|
||||||
|
|
||||||
return ChannelMessage(
|
return ChannelMessage(
|
||||||
identity=ChannelIdentity(
|
identity=ChannelIdentity(
|
||||||
channel_id=self.channel_id,
|
channel_id=self.channel_id,
|
||||||
@ -897,6 +972,65 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if action == "poll":
|
||||||
|
sub_action = args[0].lower() if args else ""
|
||||||
|
if sub_action == "create":
|
||||||
|
remaining = " ".join(args[1:]) if len(args) > 1 else ""
|
||||||
|
parts = _parse_quoted_args(remaining)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
question = parts[0]
|
||||||
|
option_labels = parts[1:]
|
||||||
|
poll = await self._poll_manager.create_poll(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
question=question,
|
||||||
|
option_labels=option_labels,
|
||||||
|
expires_in_s=300.0,
|
||||||
|
)
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content=f"Poll created! ID: {poll.poll_id}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content='Usage: /poll create "Question" "Option1" "Option2" ...',
|
||||||
|
)
|
||||||
|
elif sub_action == "close":
|
||||||
|
poll_id = args[1] if len(args) > 1 else ""
|
||||||
|
if poll_id:
|
||||||
|
result = await self._poll_manager.close_poll(poll_id)
|
||||||
|
if result:
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="Poll closed.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="Usage: /poll close <poll_id>",
|
||||||
|
)
|
||||||
|
elif sub_action == "list":
|
||||||
|
active = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
|
||||||
|
if active:
|
||||||
|
lines = ["Active polls:"]
|
||||||
|
for p in active:
|
||||||
|
lines.append(f" {p.poll_id}: {p.question}")
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="\n".join(lines),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="No active polls in this chat.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="/poll create|close|list",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
if action == "help":
|
if action == "help":
|
||||||
await self._get_client().send_text_message(
|
await self._get_client().send_text_message(
|
||||||
chat_guid=chat_guid,
|
chat_guid=chat_guid,
|
||||||
@ -906,6 +1040,9 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
"/pairing — List pending pairing requests\n"
|
"/pairing — List pending pairing requests\n"
|
||||||
"/approve <code> — Approve a pairing\n"
|
"/approve <code> — Approve a pairing\n"
|
||||||
"/reject <code> — Reject a pairing\n"
|
"/reject <code> — Reject a pairing\n"
|
||||||
|
'/poll create "Q" "A" "B" — Create a poll\n'
|
||||||
|
"/poll close <id> — Close a poll\n"
|
||||||
|
"/poll list — List active polls\n"
|
||||||
"/help — Show this help",
|
"/help — Show this help",
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
@ -924,6 +1061,101 @@ class IMessageAdapter(BaseChannelAdapter):
|
|||||||
def is_paired(self, channel_user_id: str) -> bool:
|
def is_paired(self, channel_user_id: str) -> bool:
|
||||||
return self._pairing.is_paired(channel_user_id)
|
return self._pairing.is_paired(channel_user_id)
|
||||||
|
|
||||||
|
async def create_poll(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
question: str,
|
||||||
|
options: list[str],
|
||||||
|
duration_minutes: int = 0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
poll = await self._poll_manager.create_poll(
|
||||||
|
chat_guid=chat_id,
|
||||||
|
question=question,
|
||||||
|
option_labels=options,
|
||||||
|
expires_in_s=duration_minutes * 60 if duration_minutes > 0 else 300.0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"poll_id": poll.poll_id,
|
||||||
|
"chat_guid": poll.chat_guid,
|
||||||
|
"question": poll.question,
|
||||||
|
"options": [{"index": o.index + 1, "label": o.label} for o in poll.options],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def close_poll(self, chat_id: str, poll_id: str) -> dict[str, Any]:
|
||||||
|
result = await self._poll_manager.close_poll(poll_id)
|
||||||
|
if result is None:
|
||||||
|
return {"error": f"Poll not found: {poll_id}"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def vote_poll(self, poll_id: str, user_id: str, option_index: int) -> dict[str, Any]:
|
||||||
|
result = await self._poll_manager.vote(poll_id, user_id, option_index)
|
||||||
|
if result is None:
|
||||||
|
return {"error": f"Poll not found: {poll_id}"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_poll(self, poll_id: str) -> dict[str, Any] | None:
|
||||||
|
poll = self._poll_manager.get_poll(poll_id)
|
||||||
|
if poll is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"poll_id": poll.poll_id,
|
||||||
|
"chat_guid": poll.chat_guid,
|
||||||
|
"question": poll.question,
|
||||||
|
"closed": poll.closed,
|
||||||
|
"options": [{"index": o.index + 1, "label": o.label, "vote_count": o.vote_count} for o in poll.options],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _handle_poll_vote(self, chat_guid: str, sender: str, digit_str: str) -> None:
|
||||||
|
active_polls = [p for p in self._poll_manager._polls.values() if p.chat_guid == chat_guid and not p.closed]
|
||||||
|
if not active_polls:
|
||||||
|
return
|
||||||
|
|
||||||
|
option_index = int(digit_str) - 1
|
||||||
|
poll = active_polls[-1]
|
||||||
|
|
||||||
|
result = await self._poll_manager.vote(poll.poll_id, sender, option_index)
|
||||||
|
if result and result.get("success"):
|
||||||
|
logger.info(f"[iMessage/Poll] Vote recorded: user={sender}, poll={poll.poll_id}, option={option_index + 1}")
|
||||||
|
elif result and result.get("poll_closed"):
|
||||||
|
await self._get_client().send_text_message(
|
||||||
|
chat_guid=chat_guid,
|
||||||
|
content="This poll is already closed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_media_ai(self, attachments: list[Attachment]) -> list[str]:
|
||||||
|
descriptions: list[str] = []
|
||||||
|
cfg = self._media_ai_config
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(10):
|
||||||
|
if att.type in (MessageType.IMAGE.value, "image"):
|
||||||
|
if att.url and cfg["vision_api_url"]:
|
||||||
|
image_bytes = await self._get_client().download_attachment(att.url)
|
||||||
|
desc = await describe_image(
|
||||||
|
image_bytes,
|
||||||
|
vision_api_url=cfg["vision_api_url"],
|
||||||
|
vision_api_key=cfg["vision_api_key"],
|
||||||
|
)
|
||||||
|
if desc:
|
||||||
|
descriptions.append(f"[图片描述: {desc}]")
|
||||||
|
elif att.type in (MessageType.AUDIO.value, "audio"):
|
||||||
|
if att.url and cfg["transcription_api_url"]:
|
||||||
|
audio_bytes = await self._get_client().download_attachment(att.url)
|
||||||
|
transcript = await transcribe_audio(
|
||||||
|
audio_bytes,
|
||||||
|
transcription_api_url=cfg["transcription_api_url"],
|
||||||
|
transcription_api_key=cfg["transcription_api_key"],
|
||||||
|
)
|
||||||
|
if transcript:
|
||||||
|
descriptions.append(f"[音频转录: {transcript}]")
|
||||||
|
except TimeoutError:
|
||||||
|
logger.warning(f"[iMessage/AI] Media AI timed out for {att.url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[iMessage/AI] Media AI failed for {att.url}: {e}")
|
||||||
|
|
||||||
|
return descriptions
|
||||||
|
|
||||||
def get_security_warnings(self) -> list[str]:
|
def get_security_warnings(self) -> list[str]:
|
||||||
return self._security.collect_warnings()
|
return self._security.collect_warnings()
|
||||||
|
|
||||||
@ -1046,3 +1278,12 @@ def _msg_type_to_media_str(msg_type) -> str:
|
|||||||
MessageType.FILE: "file",
|
MessageType.FILE: "file",
|
||||||
}
|
}
|
||||||
return mapping.get(msg_type, "file")
|
return mapping.get(msg_type, "file")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_quoted_args(text: str) -> list[str]:
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
try:
|
||||||
|
return shlex.split(text)
|
||||||
|
except ValueError:
|
||||||
|
return text.split()
|
||||||
|
|||||||
@ -68,6 +68,68 @@ def describe_imessage_security_status_tool() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe_imessage_poll_tool() -> dict:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "imessage_manage_polls",
|
||||||
|
"description": "管理 iMessage 投票:创建、关闭、查看投票",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["create", "close", "list", "get"],
|
||||||
|
"description": "操作类型",
|
||||||
|
},
|
||||||
|
"chat_guid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "聊天 ID(创建/列出时需要)",
|
||||||
|
},
|
||||||
|
"question": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "投票问题(创建时需要)",
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "选项列表(创建时需要)",
|
||||||
|
},
|
||||||
|
"poll_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "投票 ID(关闭/查看时需要)",
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "投票持续时间(分钟),默认 5 分钟",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["action"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe_imessage_audit_tool() -> dict:
|
||||||
|
return {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "imessage_audit_log",
|
||||||
|
"description": "查询 iMessage 渠道审计日志",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "返回记录数上限,默认 20",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class IMessageAgentToolFactory:
|
class IMessageAgentToolFactory:
|
||||||
def __init__(self, adapter: Any):
|
def __init__(self, adapter: Any):
|
||||||
self._adapter = adapter
|
self._adapter = adapter
|
||||||
@ -77,6 +139,8 @@ class IMessageAgentToolFactory:
|
|||||||
describe_imessage_pairing_tool(),
|
describe_imessage_pairing_tool(),
|
||||||
describe_imessage_allowlist_tool(),
|
describe_imessage_allowlist_tool(),
|
||||||
describe_imessage_security_status_tool(),
|
describe_imessage_security_status_tool(),
|
||||||
|
describe_imessage_poll_tool(),
|
||||||
|
describe_imessage_audit_tool(),
|
||||||
]
|
]
|
||||||
|
|
||||||
async def execute_tool(self, tool_name: str, params: dict) -> Any:
|
async def execute_tool(self, tool_name: str, params: dict) -> Any:
|
||||||
@ -86,6 +150,10 @@ class IMessageAgentToolFactory:
|
|||||||
return await self._manage_allowlist(params)
|
return await self._manage_allowlist(params)
|
||||||
if tool_name == "imessage_security_status":
|
if tool_name == "imessage_security_status":
|
||||||
return self._security_status()
|
return self._security_status()
|
||||||
|
if tool_name == "imessage_manage_polls":
|
||||||
|
return await self._manage_polls(params)
|
||||||
|
if tool_name == "imessage_audit_log":
|
||||||
|
return self._audit_log(params)
|
||||||
raise ValueError(f"Unknown tool: {tool_name}")
|
raise ValueError(f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
async def _manage_pairing(self, params: dict) -> dict:
|
async def _manage_pairing(self, params: dict) -> dict:
|
||||||
@ -149,3 +217,57 @@ class IMessageAgentToolFactory:
|
|||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
"paired_count": len(self._adapter._pairing.get_paired_handles()),
|
"paired_count": len(self._adapter._pairing.get_paired_handles()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _manage_polls(self, params: dict) -> dict:
|
||||||
|
action = params.get("action", "list")
|
||||||
|
|
||||||
|
if action == "list":
|
||||||
|
active = [
|
||||||
|
{
|
||||||
|
"poll_id": p.poll_id,
|
||||||
|
"chat_guid": p.chat_guid,
|
||||||
|
"question": p.question,
|
||||||
|
"closed": p.closed,
|
||||||
|
}
|
||||||
|
for p in self._adapter._poll_manager._polls.values()
|
||||||
|
if not p.closed
|
||||||
|
]
|
||||||
|
return {"active_polls": active}
|
||||||
|
|
||||||
|
if action == "create":
|
||||||
|
chat_guid = params.get("chat_guid", "")
|
||||||
|
question = params.get("question", "")
|
||||||
|
options = params.get("options", [])
|
||||||
|
duration_minutes = params.get("duration_minutes", 5)
|
||||||
|
if not chat_guid or not question or not options:
|
||||||
|
return {"success": False, "error": "chat_guid, question, and options are required"}
|
||||||
|
result = await self._adapter.create_poll(
|
||||||
|
chat_id=chat_guid,
|
||||||
|
question=question,
|
||||||
|
options=options,
|
||||||
|
duration_minutes=duration_minutes,
|
||||||
|
)
|
||||||
|
return {"success": True, "poll": result}
|
||||||
|
|
||||||
|
if action == "close":
|
||||||
|
poll_id = params.get("poll_id", "")
|
||||||
|
if not poll_id:
|
||||||
|
return {"success": False, "error": "poll_id is required"}
|
||||||
|
result = await self._adapter.close_poll("", poll_id)
|
||||||
|
return {"success": True, "result": result}
|
||||||
|
|
||||||
|
if action == "get":
|
||||||
|
poll_id = params.get("poll_id", "")
|
||||||
|
if not poll_id:
|
||||||
|
return {"success": False, "error": "poll_id is required"}
|
||||||
|
result = self._adapter.get_poll(poll_id)
|
||||||
|
if result is None:
|
||||||
|
return {"success": False, "error": f"Poll not found: {poll_id}"}
|
||||||
|
return {"success": True, "poll": result}
|
||||||
|
|
||||||
|
return {"success": False, "error": f"Unknown action: {action}"}
|
||||||
|
|
||||||
|
def _audit_log(self, params: dict) -> dict:
|
||||||
|
limit = params.get("limit", 20)
|
||||||
|
records = self._adapter._audit_logger.get_recent(limit=limit)
|
||||||
|
return {"records": records, "count": len(records)}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ AVAILABLE_COMMANDS = {
|
|||||||
"approve": {"description": "Approve a pending approval request", "requires_owner": True},
|
"approve": {"description": "Approve a pending approval request", "requires_owner": True},
|
||||||
"reject": {"description": "Reject a pending approval request", "requires_owner": True},
|
"reject": {"description": "Reject a pending approval request", "requires_owner": True},
|
||||||
"allowlist": {"description": "Manage allowlist entries", "requires_owner": True},
|
"allowlist": {"description": "Manage allowlist entries", "requires_owner": True},
|
||||||
|
"poll": {"description": "Manage polls: create, close, list", "requires_owner": False},
|
||||||
"help": {"description": "Show this help message", "requires_owner": False},
|
"help": {"description": "Show this help message", "requires_owner": False},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
TABLE_ROW_RE = re.compile(r"^\|(.+)\|$")
|
TABLE_ROW_RE = re.compile(r"^\|(.+)\|$")
|
||||||
TABLE_SEP_RE = re.compile(r"^\|[\s\-:]+\|$")
|
TABLE_SEP_RE = re.compile(r"^\|[\s\-:\|]+\|$")
|
||||||
DIRECTIVE_RE = re.compile(r"<\s*/?directive[^>]*>", re.IGNORECASE)
|
DIRECTIVE_RE = re.compile(r"<\s*/?directive[^>]*>", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -60,12 +60,19 @@ def validate_remote_attachment_url(
|
|||||||
server_url: str = "",
|
server_url: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""校验远程附件 URL 是否安全。"""
|
"""校验远程附件 URL 是否安全。"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
url_host = parsed_url.hostname or ""
|
||||||
|
|
||||||
if allowed_roots:
|
if allowed_roots:
|
||||||
for root in allowed_roots:
|
for root in allowed_roots:
|
||||||
if url.startswith(root):
|
root_parsed = urlparse(root)
|
||||||
|
root_host = root_parsed.hostname or ""
|
||||||
|
if url_host == root_host and url.startswith(root):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import aiohttp
|
|||||||
|
|
||||||
from yuxi.utils.logging_config import logger
|
from yuxi.utils.logging_config import logger
|
||||||
|
|
||||||
|
|
||||||
_MAX_ERROR_MSG_LEN = 200
|
_MAX_ERROR_MSG_LEN = 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user