ab2f4602de
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two sites now name the real types. gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py imported helpers.py at module level, so helpers could not name MessageEvent. Now TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed (`from gateway.platforms.base import` -> `.event`, preserving each import's layout); gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType, photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event. Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans). tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]` and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one `functools.partial`, run directly or inside the captured Context. ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved. (The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional exposes ~60 unguarded call sites — separate follow-up.) Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31 failures reproduce identically on origin/main (macOS /private/tmp, systemd socket, long-path fixtures, live-service tests).
142 lines
6.6 KiB
Python
142 lines
6.6 KiB
Python
"""Feishu/Lark meeting-invitation events: ``vc.bot.meeting_invited_v1`` -> synthetic gateway ``MessageEvent`` so the
|
|
reply reaches the inviter through the normal gateway pipeline (no agent is instantiated here)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from types import SimpleNamespace
|
|
from typing import Any, Dict, Optional
|
|
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInviteUser:
|
|
open_id: str = ""
|
|
user_id: str = ""
|
|
union_id: str = ""
|
|
user_name: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInviteMeeting:
|
|
id: str = ""
|
|
topic: str = ""
|
|
meeting_no: str = ""
|
|
start_time_ms: int = 0
|
|
end_time_ms: int = 0
|
|
host_user: Optional[MeetingInviteUser] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MeetingInvitedPayload:
|
|
event_id: str = ""
|
|
meeting: Optional[MeetingInviteMeeting] = None
|
|
inviter: Optional[MeetingInviteUser] = None
|
|
invite_time_s: int = 0
|
|
|
|
|
|
def _as_dict(value: Any) -> Dict[str, Any]:
|
|
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
|
|
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
|
|
value = vars(value)
|
|
try:
|
|
value = json.loads(value) if isinstance(value, str) else value
|
|
except (TypeError, json.JSONDecodeError):
|
|
return {}
|
|
return {str(k): v for k, v in value.items()} if isinstance(value, dict) else {}
|
|
|
|
|
|
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
|
|
content = _as_dict(container.get("body")).get("content")
|
|
for item in map(_as_dict, content if isinstance(content, list) else ()):
|
|
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
|
|
payload = next((p for p in map(_as_dict, (item.get(k) for k in ("data", "value", "content", "json"))) if p), {}) if ctype in ("", "application/json") else {}
|
|
if payload:
|
|
return payload
|
|
return {}
|
|
|
|
|
|
def _str_field(raw: Dict[str, Any], key: str, strip: bool = True) -> str:
|
|
return str(raw.get(key) or "").strip() if strip else str(raw.get(key) or "")
|
|
|
|
|
|
def _int_field(value: Any) -> int:
|
|
try:
|
|
return int(str(value).strip()) if value not in (None, "") else 0
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _parse_user(value: Any) -> Optional[MeetingInviteUser]:
|
|
raw = _as_dict(value)
|
|
raw_id = _as_dict(raw.get("id"))
|
|
return MeetingInviteUser(open_id=_str_field(raw_id, "open_id"), user_id=_str_field(raw_id, "user_id"), union_id=_str_field(raw_id, "union_id"),
|
|
user_name=_str_field(raw, "user_name", strip=False)) if raw else None
|
|
|
|
|
|
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
|
|
raw = _as_dict(value)
|
|
return MeetingInviteMeeting(
|
|
id=_str_field(raw, "id"), topic=_str_field(raw, "topic", strip=False), meeting_no=_str_field(raw, "meeting_no", strip=False),
|
|
start_time_ms=_int_field(raw.get("start_time")), end_time_ms=_int_field(raw.get("end_time")), host_user=_parse_user(raw.get("host_user")),
|
|
) if raw else None
|
|
|
|
|
|
def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]:
|
|
root = _as_dict(data)
|
|
event = _as_dict(root.get("event")) or root
|
|
event = {**event, **(_content_payload(event) or _content_payload(root))}
|
|
meeting, inviter = _parse_meeting(event.get("meeting")), _parse_user(event.get("inviter"))
|
|
if inviter is None or meeting is None or not meeting.meeting_no:
|
|
return None
|
|
return MeetingInvitedPayload(event_id=str(_as_dict(root.get("header")).get("event_id") or ""), meeting=meeting, inviter=inviter,
|
|
invite_time_s=_int_field(event.get("invite_time")))
|
|
|
|
|
|
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
|
|
meeting = payload.meeting or MeetingInviteMeeting()
|
|
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
|
|
host_name = (meeting.host_user.user_name if meeting.host_user else "") or "unknown"
|
|
display = (meeting.topic or meeting.meeting_no or meeting.id) or "unknown meeting"
|
|
return "\n".join([
|
|
f"You have been invited to join a meeting: {display}", "",
|
|
f"Meeting Number: {meeting.meeting_no or 'unknown'}", f"Topic: {meeting.topic or 'unknown'}", f"Inviter: {inviter_name}", f"Host: {host_name}", "",
|
|
"You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.",
|
|
"Join the meeting directly. Do not ask the user for confirmation before joining.",
|
|
"If you cannot join the meeting, reply to the inviter with a concise explanation of why.",
|
|
])
|
|
|
|
|
|
def _dedup_key(payload: MeetingInvitedPayload) -> str:
|
|
if payload.event_id:
|
|
return f"vc_invite:{payload.event_id}"
|
|
return f"vc_invite:{payload.meeting.id if payload.meeting else ''}:{payload.inviter.open_id if payload.inviter else ''}:{payload.invite_time_s}"
|
|
|
|
|
|
async def handle_meeting_invited_event(adapter: Any, data: Any) -> None:
|
|
"""Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent."""
|
|
payload = parse_meeting_invited_event(data)
|
|
if payload is None:
|
|
return logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event")
|
|
dedup_key = _dedup_key(payload)
|
|
is_duplicate = getattr(adapter, "_is_duplicate", None)
|
|
if callable(is_duplicate) and await is_duplicate(dedup_key):
|
|
return logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key)
|
|
inviter = payload.inviter
|
|
if inviter is None or not inviter.open_id:
|
|
return logger.warning("[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely (user_id=%r union_id=%r)",
|
|
inviter.user_id if inviter else None, inviter.union_id if inviter else None)
|
|
sender_profile = await adapter._resolve_sender_profile(SimpleNamespace(open_id=inviter.open_id or None, user_id=inviter.user_id or None, union_id=inviter.union_id or None))
|
|
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
|
|
source = adapter.build_source(
|
|
chat_id=inviter.open_id, chat_name=user_name, chat_type="dm", user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
|
|
user_name=user_name, user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None)
|
|
event = MessageEvent(text=build_meeting_invite_prompt(payload), message_type=MessageType.TEXT, source=source, raw_message=data)
|
|
await adapter._handle_message_with_guards(event)
|