mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
Execution telemetry already reported window counters. This adds one bounded, content-free chain per inbound platform event, so a single event can be followed from its source platform through routing and processing to the platform API calls it caused. - telemetry/trace.py: ContextVar trace identity with route and run scopes, and a 32-stage bound per chain. - telemetry/execution.py: keeps at most 64 chains for 120s, decides sampling from the observed outcome, and sends one payload per chain keyed by query_id = trace_id, so Space fetches a chain by primary identity. Window counters are unchanged and remain the source of coverage statistics. - botmgr / pipelinemgr / orchestrator: bind the trace at the ingress boundary, scope routing identity per dispatch, and attach the run identity, so nested lanes (event -> route -> pipeline/runner -> platform API) reuse one chain. A run reached without an ingress (WebUI debug, service API) owns its own chain. - space.execution_trace selects off | failures | sampled | all (default sampled: failures, WebUI debug runs and every N-th success). Chains carry only code-defined identifiers (adapter and runner types, route identity as type:uuid, run id); never message content, tool arguments or platform user identifiers.
2316 lines
97 KiB
Python
2316 lines
97 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import contextlib
|
||
import dataclasses
|
||
import functools
|
||
import json
|
||
import re
|
||
import time
|
||
import traceback
|
||
import typing
|
||
import uuid
|
||
import sqlalchemy
|
||
|
||
from ..core import app, entities as core_entities, taskmgr
|
||
|
||
from ..discover import engine
|
||
|
||
from ..entity.persistence import bot as persistence_bot
|
||
from ..entity.errors import platform as platform_errors
|
||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||
from ..agent.runner.host_models import (
|
||
AgentBinding,
|
||
AgentEventEnvelope,
|
||
BindingScope,
|
||
DeliveryPolicy,
|
||
StatePolicy,
|
||
)
|
||
from ..agent.runner.resource_policy import ResourcePolicyProjector
|
||
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
|
||
from ..entity.persistence import workspace as persistence_workspace
|
||
|
||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||
from ..api.http.authz import WorkspaceRequiredError
|
||
from ..workspace.errors import WorkspaceInvariantError
|
||
|
||
from .logger import EventLogger
|
||
from .adapter_names import canonical_adapter_name
|
||
|
||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||
import langbot_plugin.api.entities.events as plugin_events
|
||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||
from langbot_plugin.api.entities.builtin.runner.event import (
|
||
ActorContext,
|
||
SubjectContext,
|
||
RawEventRef,
|
||
)
|
||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
|
||
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
|
||
|
||
|
||
class RuntimeBot:
|
||
"""运行时机器人"""
|
||
|
||
ap: app.Application
|
||
|
||
bot_entity: persistence_bot.Bot
|
||
|
||
enable: bool
|
||
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||
|
||
task_wrapper: taskmgr.TaskWrapper | None
|
||
|
||
task_context: taskmgr.TaskContext
|
||
|
||
logger: EventLogger
|
||
|
||
execution_context: ExecutionContext
|
||
|
||
workspace_uuid: str
|
||
|
||
placement_generation: int
|
||
|
||
def __init__(
|
||
self,
|
||
ap: app.Application,
|
||
bot_entity: persistence_bot.Bot,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
logger: EventLogger,
|
||
execution_context: ExecutionContext,
|
||
):
|
||
if not isinstance(execution_context, ExecutionContext):
|
||
raise WorkspaceRequiredError('RuntimeBot requires an ExecutionContext')
|
||
if not execution_context.instance_uuid.strip() or not execution_context.workspace_uuid.strip():
|
||
raise WorkspaceRequiredError('RuntimeBot requires an instance and Workspace')
|
||
if execution_context.placement_generation <= 0:
|
||
raise WorkspaceRequiredError('RuntimeBot requires a positive placement generation')
|
||
entity_workspace_uuid = getattr(bot_entity, 'workspace_uuid', None)
|
||
if entity_workspace_uuid != execution_context.workspace_uuid:
|
||
raise WorkspaceRequiredError('RuntimeBot entity Workspace does not match its ExecutionContext')
|
||
if execution_context.bot_uuid not in (None, bot_entity.uuid):
|
||
raise WorkspaceRequiredError('RuntimeBot bot UUID does not match its ExecutionContext')
|
||
|
||
self.ap = ap
|
||
self.bot_entity = bot_entity
|
||
self.execution_context = dataclasses.replace(execution_context, bot_uuid=bot_entity.uuid)
|
||
self.workspace_uuid = self.execution_context.workspace_uuid
|
||
self.placement_generation = self.execution_context.placement_generation
|
||
self.enable = bot_entity.enable
|
||
self.adapter = adapter
|
||
self.task_wrapper = None
|
||
self.task_context = taskmgr.TaskContext()
|
||
self.task_wrapper = None
|
||
self.logger = logger
|
||
self._shutdown_lock = asyncio.Lock()
|
||
self._shutdown_complete = False
|
||
|
||
async def assert_execution_active(self) -> None:
|
||
"""Fail closed when this long-lived adapter belongs to a stale placement."""
|
||
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
self.workspace_uuid,
|
||
expected_generation=self.placement_generation,
|
||
)
|
||
|
||
PIPELINE_DISCARD = '__discard__'
|
||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||
EVENT_DATA_MAX_STRING_BYTES = 512
|
||
|
||
@staticmethod
|
||
def _eba_event_to_plugin_event(event: platform_events.EBAEvent) -> plugin_events.BaseEventModel | None:
|
||
"""Map a platform EBA event to a plugin EventListener event model."""
|
||
event_mapping: list[tuple[type[platform_events.EBAEvent], type[plugin_events.BaseEventModel]]] = [
|
||
(platform_events.MessageReceivedEvent, plugin_events.MessageReceived),
|
||
(platform_events.MessageEditedEvent, plugin_events.MessageEdited),
|
||
(platform_events.MessageDeletedEvent, plugin_events.MessageDeleted),
|
||
(platform_events.MessageReactionEvent, plugin_events.MessageReactionReceived),
|
||
(platform_events.FeedbackReceivedEvent, plugin_events.FeedbackReceived),
|
||
(platform_events.MemberJoinedEvent, plugin_events.GroupMemberJoined),
|
||
(platform_events.MemberLeftEvent, plugin_events.GroupMemberLeft),
|
||
(platform_events.MemberBannedEvent, plugin_events.GroupMemberBanned),
|
||
(platform_events.BotInvitedToGroupEvent, plugin_events.BotInvitedToGroup),
|
||
(platform_events.BotRemovedFromGroupEvent, plugin_events.BotRemovedFromGroup),
|
||
(platform_events.BotMutedEvent, plugin_events.BotMuted),
|
||
(platform_events.BotUnmutedEvent, plugin_events.BotUnmuted),
|
||
(platform_events.PlatformSpecificEvent, plugin_events.PlatformSpecificEventReceived),
|
||
]
|
||
|
||
for platform_event_type, plugin_event_type in event_mapping:
|
||
if isinstance(event, platform_event_type):
|
||
return plugin_event_type.from_platform_event(event)
|
||
|
||
return None
|
||
|
||
@staticmethod
|
||
def _match_event_pattern(event_type: str, pattern: str) -> bool:
|
||
if not event_type or not pattern:
|
||
return False
|
||
if pattern == '*':
|
||
return True
|
||
if pattern.endswith('.*'):
|
||
return event_type.startswith(f'{pattern[:-2]}.')
|
||
return event_type == pattern
|
||
|
||
@classmethod
|
||
def _is_message_event_type(cls, event_type: str) -> bool:
|
||
return cls._match_event_pattern(event_type, 'message.*')
|
||
|
||
@classmethod
|
||
def _agent_supports_event_type(
|
||
cls,
|
||
supported_patterns: list[str] | None,
|
||
event_type: str,
|
||
) -> bool:
|
||
patterns = supported_patterns if isinstance(supported_patterns, list) else ['*']
|
||
return any(cls._match_event_pattern(event_type, pattern) for pattern in patterns)
|
||
|
||
@staticmethod
|
||
def _get_nested_value(data: dict[str, typing.Any], path: str) -> typing.Any:
|
||
current: typing.Any = data
|
||
for key in path.split('.'):
|
||
if isinstance(current, dict):
|
||
current = current.get(key)
|
||
else:
|
||
current = getattr(current, key, None)
|
||
if current is None:
|
||
return None
|
||
return current
|
||
|
||
@classmethod
|
||
def _match_event_filter(
|
||
cls,
|
||
event_data: dict[str, typing.Any],
|
||
event_filter: dict[str, typing.Any],
|
||
) -> bool:
|
||
field = str(event_filter.get('field') or event_filter.get('path') or '').strip()
|
||
if not field:
|
||
return True
|
||
|
||
operator = str(event_filter.get('operator') or 'eq')
|
||
expected = event_filter.get('value')
|
||
actual = cls._get_nested_value(event_data, field)
|
||
|
||
if operator == 'eq':
|
||
return actual == expected
|
||
if operator == 'neq':
|
||
return actual != expected
|
||
if operator == 'contains':
|
||
if isinstance(actual, (list, tuple, set)):
|
||
return expected in actual
|
||
return str(expected) in str(actual or '')
|
||
if operator == 'not_contains':
|
||
if isinstance(actual, (list, tuple, set)):
|
||
return expected not in actual
|
||
return str(expected) not in str(actual or '')
|
||
if operator == 'starts_with':
|
||
return str(actual or '').startswith(str(expected))
|
||
if operator == 'regex':
|
||
try:
|
||
return bool(re.search(str(expected), str(actual or '')))
|
||
except re.error:
|
||
return False
|
||
return False
|
||
|
||
@classmethod
|
||
def _augment_event_data(
|
||
cls,
|
||
event_data: dict[str, typing.Any],
|
||
) -> dict[str, typing.Any]:
|
||
"""Inject virtual computed fields to simplify common filter patterns."""
|
||
message_chain = event_data.get('message_chain')
|
||
if isinstance(message_chain, list):
|
||
text_parts = [
|
||
comp.get('text', '') for comp in message_chain if isinstance(comp, dict) and comp.get('type') == 'Plain'
|
||
]
|
||
event_data['message_text'] = ''.join(text_parts)
|
||
event_data['message_element_types'] = [
|
||
comp.get('type', '') for comp in message_chain if isinstance(comp, dict)
|
||
]
|
||
if 'group' in event_data:
|
||
event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person'
|
||
return event_data
|
||
|
||
@classmethod
|
||
def _match_event_filters(
|
||
cls,
|
||
event: platform_events.EBAEvent,
|
||
filters: typing.Any,
|
||
) -> bool:
|
||
if not filters:
|
||
return True
|
||
if not isinstance(filters, list):
|
||
return False
|
||
|
||
event_data = cls._augment_event_data(cls._safe_model_dump(event))
|
||
return all(
|
||
cls._match_event_filter(event_data, event_filter)
|
||
for event_filter in filters
|
||
if isinstance(event_filter, dict)
|
||
)
|
||
|
||
@staticmethod
|
||
def _get_event_bindings_from_value(raw_bindings: typing.Any) -> list[dict[str, typing.Any]]:
|
||
raw_bindings = raw_bindings or []
|
||
if isinstance(raw_bindings, str):
|
||
try:
|
||
raw_bindings = json.loads(raw_bindings)
|
||
except json.JSONDecodeError:
|
||
raw_bindings = []
|
||
if not isinstance(raw_bindings, list):
|
||
return []
|
||
return [binding for binding in raw_bindings if isinstance(binding, dict)]
|
||
|
||
def _get_event_bindings(self) -> list[dict[str, typing.Any]]:
|
||
return self._get_event_bindings_from_value(self.bot_entity.event_bindings)
|
||
|
||
@classmethod
|
||
def _evaluate_eba_event_bindings(
|
||
cls,
|
||
bindings: list[dict[str, typing.Any]],
|
||
event: platform_events.EBAEvent,
|
||
event_type: str,
|
||
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
|
||
"""Evaluate Bot event bindings with the same precedence used at runtime."""
|
||
matched: list[tuple[int, int, dict[str, typing.Any], dict[str, typing.Any]]] = []
|
||
diagnostic_steps: list[dict[str, typing.Any]] = []
|
||
|
||
for index, binding in enumerate(bindings):
|
||
if binding.get('target_type') == 'event_processor':
|
||
continue
|
||
event_pattern = str(binding.get('event_pattern') or '')
|
||
priority = int(binding.get('priority') or 0)
|
||
order = int(binding.get('order', index))
|
||
step = {
|
||
'step': 'evaluate_binding',
|
||
'binding_id': binding.get('id'),
|
||
'event_pattern': event_pattern,
|
||
'target_type': binding.get('target_type'),
|
||
'target_uuid': binding.get('target_uuid') or '',
|
||
'enabled': binding.get('enabled', True),
|
||
'priority': priority,
|
||
'order': order,
|
||
'matched': False,
|
||
'failure_code': None,
|
||
'reason': '',
|
||
}
|
||
|
||
if not binding.get('enabled', True):
|
||
step['failure_code'] = 'binding_disabled'
|
||
step['reason'] = 'Binding is disabled'
|
||
diagnostic_steps.append(step)
|
||
continue
|
||
|
||
if not cls._match_event_pattern(event_type, event_pattern):
|
||
step['failure_code'] = 'event_pattern_mismatch'
|
||
step['reason'] = 'Event type does not match binding event_pattern'
|
||
diagnostic_steps.append(step)
|
||
continue
|
||
if not cls._match_event_filters(event, binding.get('filters')):
|
||
step['failure_code'] = 'filters_mismatch'
|
||
step['reason'] = 'Event data does not satisfy binding filters'
|
||
diagnostic_steps.append(step)
|
||
continue
|
||
|
||
step['matched'] = True
|
||
step['reason'] = 'Binding matched event pattern and filters'
|
||
diagnostic_steps.append(step)
|
||
matched.append((priority, -order, binding, step))
|
||
|
||
if not matched:
|
||
return None, diagnostic_steps
|
||
|
||
matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||
selected_binding = matched[0][2]
|
||
selected_step = matched[0][3]
|
||
selected_step['selected'] = True
|
||
selected_step['reason'] = 'Selected by priority and order'
|
||
for _, _, _, step in matched[1:]:
|
||
step['selected'] = False
|
||
step['failure_code'] = 'lower_priority'
|
||
step['reason'] = 'Another matching binding has higher priority or earlier order'
|
||
return selected_binding, diagnostic_steps
|
||
|
||
def _resolve_eba_event_binding(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
event_type: str,
|
||
) -> dict[str, typing.Any] | None:
|
||
"""Resolve the highest priority Bot event binding for a platform event."""
|
||
selected, _ = self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||
return selected
|
||
|
||
def diagnose_eba_event_binding(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
event_type: str,
|
||
) -> tuple[dict[str, typing.Any] | None, list[dict[str, typing.Any]]]:
|
||
"""Return the selected event binding plus per-binding diagnostic steps."""
|
||
return self._evaluate_eba_event_bindings(self._get_event_bindings(), event, event_type)
|
||
|
||
@staticmethod
|
||
def _route_ref(
|
||
binding: dict | None,
|
||
target_type: str | None = None,
|
||
target_uuid: str | None = None,
|
||
) -> str:
|
||
"""Code-defined route identity for telemetry; never a user-facing name."""
|
||
binding = binding or {}
|
||
kind = str(target_type or binding.get('target_type') or '').strip()
|
||
target = str(target_uuid or binding.get('target_uuid') or '').strip()
|
||
if not kind or not target:
|
||
return ''
|
||
return f'{kind}:{target}'[:160]
|
||
|
||
async def _record_event_route_trace(
|
||
self,
|
||
*,
|
||
event_type: str,
|
||
status: str,
|
||
text: str,
|
||
level: str = 'info',
|
||
binding: dict[str, typing.Any] | None = None,
|
||
target_type: str | None = None,
|
||
target_uuid: str | None = None,
|
||
failure_code: str | None = None,
|
||
reason: str | None = None,
|
||
run_id: str | None = None,
|
||
) -> dict[str, typing.Any]:
|
||
"""Record structured event routing state while preserving the human log."""
|
||
binding = binding or {}
|
||
metadata = {
|
||
'kind': 'event_route_trace',
|
||
'event_type': event_type,
|
||
'status': status,
|
||
'binding_id': binding.get('id'),
|
||
'event_pattern': binding.get('event_pattern'),
|
||
'target_type': target_type or binding.get('target_type'),
|
||
'target_uuid': target_uuid or binding.get('target_uuid') or '',
|
||
'failure_code': failure_code,
|
||
'reason': reason or text,
|
||
'run_id': run_id,
|
||
}
|
||
log_method = getattr(self.logger, level, self.logger.info)
|
||
await log_method(text, metadata=metadata)
|
||
if status in {'delivered', 'failed', 'discarded', 'not_matched'}:
|
||
from ..telemetry import trace as trace_mod
|
||
from ..telemetry.execution import record
|
||
|
||
with trace_mod.scope(route_ref=self._route_ref(binding, target_type, target_uuid)):
|
||
record(
|
||
getattr(self, 'ap', None),
|
||
getattr(self, 'execution_context', None),
|
||
family='event_route',
|
||
operation=event_type,
|
||
adapter=type(getattr(self, 'adapter', None)).__name__,
|
||
mode=target_type if target_type in {'pipeline', 'agent', 'event_processor'} else 'none',
|
||
outcome={'delivered': 'success', 'failed': 'failed'}.get(status, 'skipped'),
|
||
)
|
||
return metadata
|
||
|
||
def get_pipeline_target_for_event_type(self, event_type: str = 'message.received') -> str | None:
|
||
"""Return the first Pipeline target configured for an event type."""
|
||
matched: list[tuple[int, int, str]] = []
|
||
for index, binding in enumerate(self._get_event_bindings()):
|
||
if not binding.get('enabled', True):
|
||
continue
|
||
if binding.get('target_type') != 'pipeline':
|
||
continue
|
||
target_uuid = str(binding.get('target_uuid') or '')
|
||
if not target_uuid:
|
||
continue
|
||
if not self._match_event_pattern(event_type, str(binding.get('event_pattern') or '')):
|
||
continue
|
||
priority = int(binding.get('priority') or 0)
|
||
order = int(binding.get('order', index))
|
||
matched.append((priority, -order, target_uuid))
|
||
|
||
if not matched:
|
||
return None
|
||
|
||
matched.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||
return matched[0][2]
|
||
|
||
@staticmethod
|
||
def _safe_model_dump(model: typing.Any) -> dict[str, typing.Any]:
|
||
if model is None:
|
||
return {}
|
||
if hasattr(model, 'model_dump'):
|
||
try:
|
||
return model.model_dump(mode='json')
|
||
except TypeError:
|
||
try:
|
||
return model.model_dump()
|
||
except Exception:
|
||
return {}
|
||
except Exception:
|
||
return {}
|
||
if isinstance(model, dict):
|
||
return model
|
||
return {}
|
||
|
||
@classmethod
|
||
def _compact_event_data(cls, event: platform_events.EBAEvent) -> dict[str, typing.Any]:
|
||
raw_event_data = cls._safe_model_dump(event)
|
||
compact: dict[str, typing.Any] = {}
|
||
for key, value in raw_event_data.items():
|
||
if key == 'source_platform_object' or key.startswith('_'):
|
||
continue
|
||
if value is None or isinstance(value, (bool, int, float)):
|
||
compact[key] = value
|
||
continue
|
||
if isinstance(value, str):
|
||
if len(value.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
|
||
compact[key] = value
|
||
continue
|
||
if isinstance(value, (list, dict)):
|
||
try:
|
||
encoded = json.dumps(value, ensure_ascii=False)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if len(encoded.encode('utf-8')) <= cls.EVENT_DATA_MAX_STRING_BYTES:
|
||
compact[key] = value
|
||
return compact
|
||
|
||
async def _record_adapter_event(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> dict[str, typing.Any]:
|
||
"""Record a normalized adapter event for the platform debugging surface."""
|
||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||
metadata = {
|
||
'kind': 'adapter_event_received',
|
||
'event_type': event_type,
|
||
'event_data': self._compact_event_data(event),
|
||
'adapter': getattr(self.bot_entity, 'adapter', None) or adapter.__class__.__name__,
|
||
'bot_uuid': self.bot_entity.uuid,
|
||
}
|
||
await self.logger.info(
|
||
f'Platform adapter received {event_type}',
|
||
metadata=metadata,
|
||
)
|
||
return metadata
|
||
|
||
@staticmethod
|
||
def _get_entity_id(entity: typing.Any) -> str | None:
|
||
entity_id = getattr(entity, 'id', None)
|
||
if entity_id is None and isinstance(entity, dict):
|
||
entity_id = entity.get('id')
|
||
if entity_id is None or entity_id == '':
|
||
return None
|
||
return str(entity_id)
|
||
|
||
@staticmethod
|
||
def _get_entity_name(entity: typing.Any) -> str | None:
|
||
if entity is None:
|
||
return None
|
||
if hasattr(entity, 'get_name'):
|
||
try:
|
||
name = entity.get_name()
|
||
if name:
|
||
return str(name)
|
||
except Exception:
|
||
pass
|
||
for attr in ('nickname', 'member_name', 'name', 'display_name'):
|
||
value = getattr(entity, attr, None)
|
||
if value:
|
||
return str(value)
|
||
if isinstance(entity, dict):
|
||
for attr in ('nickname', 'member_name', 'name', 'display_name'):
|
||
value = entity.get(attr)
|
||
if value:
|
||
return str(value)
|
||
return None
|
||
|
||
@classmethod
|
||
def _infer_actor_context(cls, event: platform_events.EBAEvent) -> ActorContext | None:
|
||
actor = getattr(event, 'sender', None) or getattr(event, 'member', None) or getattr(event, 'user', None)
|
||
actor_id = cls._get_entity_id(actor)
|
||
actor_name = cls._get_entity_name(actor)
|
||
|
||
if actor_id is None:
|
||
user_id = getattr(event, 'user_id', None)
|
||
if user_id:
|
||
actor_id = str(user_id)
|
||
|
||
if actor_id is None:
|
||
return None
|
||
|
||
return ActorContext(
|
||
actor_type='user',
|
||
actor_id=actor_id,
|
||
actor_name=actor_name,
|
||
metadata={},
|
||
)
|
||
|
||
@classmethod
|
||
def _infer_subject_context(cls, event: platform_events.EBAEvent) -> SubjectContext:
|
||
group = getattr(event, 'group', None)
|
||
if group is not None:
|
||
group_id = cls._get_entity_id(group)
|
||
return SubjectContext(
|
||
subject_type='group',
|
||
subject_id=group_id,
|
||
data={'group_name': cls._get_entity_name(group)},
|
||
)
|
||
|
||
message_id = getattr(event, 'message_id', None)
|
||
if message_id:
|
||
return SubjectContext(
|
||
subject_type='message',
|
||
subject_id=str(message_id),
|
||
data={},
|
||
)
|
||
|
||
feedback_id = getattr(event, 'feedback_id', None)
|
||
if feedback_id:
|
||
return SubjectContext(
|
||
subject_type='feedback',
|
||
subject_id=str(feedback_id),
|
||
data={'message_id': getattr(event, 'message_id', None)},
|
||
)
|
||
|
||
action = getattr(event, 'action', None)
|
||
if action:
|
||
return SubjectContext(
|
||
subject_type='platform_action',
|
||
subject_id=str(action),
|
||
data={},
|
||
)
|
||
|
||
return SubjectContext(
|
||
subject_type='event',
|
||
subject_id=getattr(event, 'type', None),
|
||
data={},
|
||
)
|
||
|
||
@staticmethod
|
||
def _session_to_reply_target(session_id: str | None) -> tuple[str | None, str | None]:
|
||
if not session_id or '_' not in session_id:
|
||
return None, None
|
||
target_type, target_id = session_id.split('_', 1)
|
||
if target_type == 'person':
|
||
target_type = 'person'
|
||
elif target_type == 'group':
|
||
target_type = 'group'
|
||
else:
|
||
return None, None
|
||
return target_type, target_id or None
|
||
|
||
@classmethod
|
||
def _infer_reply_target(
|
||
cls,
|
||
event: platform_events.EBAEvent,
|
||
) -> tuple[str | None, str | None, dict[str, typing.Any]]:
|
||
metadata: dict[str, typing.Any] = {}
|
||
group = getattr(event, 'group', None)
|
||
group_id = cls._get_entity_id(group)
|
||
if group_id:
|
||
metadata['group_id'] = group_id
|
||
return 'group', group_id, metadata
|
||
|
||
chat_id = getattr(event, 'chat_id', None)
|
||
chat_type = getattr(event, 'chat_type', None)
|
||
chat_type_value = getattr(chat_type, 'value', chat_type)
|
||
if chat_id:
|
||
metadata['chat_id'] = str(chat_id)
|
||
if chat_type_value == 'group':
|
||
return 'group', str(chat_id), metadata
|
||
return 'person', str(chat_id), metadata
|
||
|
||
session_target_type, session_target_id = cls._session_to_reply_target(getattr(event, 'session_id', None))
|
||
if session_target_type and session_target_id:
|
||
return session_target_type, session_target_id, metadata
|
||
|
||
raw_data = getattr(event, 'data', None)
|
||
if isinstance(raw_data, dict):
|
||
target_type = raw_data.get('target_type') or raw_data.get('chat_type')
|
||
target_id = (
|
||
raw_data.get('target_id')
|
||
or raw_data.get('chat_id')
|
||
or raw_data.get('group_id')
|
||
or raw_data.get('user_id')
|
||
)
|
||
if target_type and target_id:
|
||
return str(target_type), str(target_id), metadata
|
||
|
||
return None, None, metadata
|
||
|
||
@staticmethod
|
||
def _extract_message_id(message_chain: platform_message.MessageChain) -> str:
|
||
for component in message_chain:
|
||
if isinstance(component, platform_message.Source):
|
||
value = getattr(component, 'id', '')
|
||
return str(value) if value is not None else ''
|
||
return ''
|
||
|
||
def _legacy_message_to_eba_event(
|
||
self,
|
||
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> platform_events.MessageReceivedEvent:
|
||
if isinstance(event, platform_events.GroupMessage):
|
||
group = platform_entities.UserGroup(
|
||
id=event.group.id,
|
||
name=event.group.name,
|
||
)
|
||
return platform_events.MessageReceivedEvent(
|
||
legacy_event=event,
|
||
message_id=self._extract_message_id(event.message_chain),
|
||
message_chain=event.message_chain,
|
||
sender=platform_entities.User(
|
||
id=event.sender.id,
|
||
nickname=event.sender.member_name,
|
||
),
|
||
chat_type=platform_entities.ChatType.GROUP,
|
||
chat_id=event.group.id,
|
||
group=group,
|
||
timestamp=event.time or time.time(),
|
||
bot_uuid=self.bot_entity.uuid,
|
||
adapter_name=adapter.__class__.__name__,
|
||
source_platform_object=event.source_platform_object,
|
||
)
|
||
|
||
return platform_events.MessageReceivedEvent(
|
||
legacy_event=event,
|
||
message_id=self._extract_message_id(event.message_chain),
|
||
message_chain=event.message_chain,
|
||
sender=platform_entities.User(
|
||
id=event.sender.id,
|
||
nickname=event.sender.nickname,
|
||
remark=event.sender.remark,
|
||
),
|
||
chat_type=platform_entities.ChatType.PRIVATE,
|
||
chat_id=event.sender.id,
|
||
timestamp=event.time or time.time(),
|
||
bot_uuid=self.bot_entity.uuid,
|
||
adapter_name=adapter.__class__.__name__,
|
||
source_platform_object=event.source_platform_object,
|
||
)
|
||
|
||
@classmethod
|
||
def _build_agent_input(cls, event: platform_events.EBAEvent) -> AgentInput:
|
||
text = None
|
||
contents: list[dict[str, typing.Any]] = []
|
||
|
||
message_chain = getattr(event, 'message_chain', None)
|
||
if message_chain:
|
||
text_parts: list[str] = []
|
||
try:
|
||
for component in message_chain:
|
||
if isinstance(component, platform_message.Plain):
|
||
text_parts.append(component.text)
|
||
elif isinstance(component, platform_message.Image):
|
||
if component.url:
|
||
contents.append({'type': 'image_url', 'image_url': {'url': component.url}})
|
||
elif component.base64:
|
||
contents.append({'type': 'image_base64', 'image_base64': component.base64})
|
||
except TypeError:
|
||
text_parts.append(str(message_chain))
|
||
text = ''.join(text_parts) or str(message_chain)
|
||
|
||
if text is None:
|
||
feedback_content = getattr(event, 'feedback_content', None)
|
||
if feedback_content:
|
||
text = str(feedback_content)
|
||
elif getattr(event, 'action', None):
|
||
text = str(getattr(event, 'action'))
|
||
else:
|
||
text = str(getattr(event, 'type', 'event'))
|
||
|
||
if text:
|
||
contents.insert(0, {'type': 'text', 'text': text})
|
||
|
||
return AgentInput(
|
||
text=text,
|
||
contents=contents,
|
||
attachments=[],
|
||
)
|
||
|
||
def _eba_event_to_agent_envelope(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> AgentEventEnvelope:
|
||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||
event_time = getattr(event, 'timestamp', None) or time.time()
|
||
event_id = (
|
||
getattr(event, 'message_id', None) or getattr(event, 'feedback_id', None) or f'{event_type}:{uuid.uuid4()}'
|
||
)
|
||
target_type, target_id, target_metadata = self._infer_reply_target(event)
|
||
supported_apis = self._get_adapter_supported_apis(adapter)
|
||
|
||
conversation_id = None
|
||
if target_type and target_id:
|
||
conversation_id = f'{target_type}_{target_id}'
|
||
elif getattr(event, 'session_id', None):
|
||
conversation_id = str(getattr(event, 'session_id'))
|
||
|
||
delivery_data: dict[str, typing.Any] = {
|
||
'surface': 'platform',
|
||
'reply_target': {
|
||
'target_type': target_type,
|
||
'target_id': target_id,
|
||
'message_id': getattr(event, 'message_id', None),
|
||
**target_metadata,
|
||
},
|
||
'supports_streaming': False,
|
||
'supports_edit': 'edit_message' in supported_apis,
|
||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||
'platform_capabilities': {
|
||
'adapter': adapter.__class__.__name__,
|
||
'event_type': event_type,
|
||
'supported_apis': supported_apis,
|
||
},
|
||
}
|
||
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
|
||
if interaction_capabilities is not None:
|
||
delivery_data['interactions'] = interaction_capabilities
|
||
|
||
return AgentEventEnvelope(
|
||
event_id=f'platform:{self.bot_entity.uuid}:{event_id}',
|
||
event_type=event_type,
|
||
event_time=int(event_time) if isinstance(event_time, (int, float)) else None,
|
||
source='platform',
|
||
source_event_type=event_type,
|
||
bot_id=self.bot_entity.uuid,
|
||
workspace_id=None,
|
||
conversation_id=conversation_id,
|
||
thread_id=None,
|
||
actor=self._infer_actor_context(event),
|
||
subject=self._infer_subject_context(event),
|
||
input=self._build_agent_input(event),
|
||
delivery=DeliveryContext.model_validate(delivery_data),
|
||
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
|
||
data=self._compact_event_data(event),
|
||
)
|
||
|
||
@staticmethod
|
||
def _get_adapter_supported_apis(
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> list[str]:
|
||
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
|
||
if not callable(get_supported_apis):
|
||
return []
|
||
try:
|
||
declared_apis = get_supported_apis()
|
||
except Exception:
|
||
return []
|
||
if not isinstance(declared_apis, (list, tuple, set)):
|
||
return []
|
||
return list(dict.fromkeys(api_name for api_name in declared_apis if isinstance(api_name, str) and api_name))
|
||
|
||
@staticmethod
|
||
def _get_adapter_interaction_capabilities(
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
supported_apis: list[str],
|
||
) -> dict[str, typing.Any] | None:
|
||
if 'interactions' not in DeliveryContext.model_fields or 'interaction.request' not in supported_apis:
|
||
return None
|
||
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
|
||
if not callable(get_capabilities):
|
||
return None
|
||
try:
|
||
capabilities = get_capabilities()
|
||
except Exception:
|
||
return None
|
||
return capabilities if isinstance(capabilities, dict) else None
|
||
|
||
@staticmethod
|
||
def _agent_product_to_binding(
|
||
agent: dict[str, typing.Any],
|
||
event_binding: dict[str, typing.Any],
|
||
event_type: str,
|
||
bot_uuid: str,
|
||
) -> AgentBinding | None:
|
||
config = agent.get('config') if isinstance(agent, dict) else None
|
||
if config is None:
|
||
return None
|
||
|
||
_, runner_id, runner_config = RunnerConfigResolver.resolve_agent_config(config)
|
||
if not runner_id:
|
||
return None
|
||
|
||
return AgentBinding(
|
||
binding_id=(
|
||
f'event_processor:{agent["uuid"]}'
|
||
if agent.get('kind') == 'event_processor'
|
||
else f'bot:{bot_uuid}:{event_binding.get("id") or uuid.uuid4()}'
|
||
),
|
||
scope=BindingScope(scope_type='bot', scope_id=bot_uuid),
|
||
event_types=[event_type],
|
||
runner_id=runner_id,
|
||
runner_config=runner_config,
|
||
resource_policy=ResourcePolicyProjector.from_runner_config(
|
||
runner_config,
|
||
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||
allowed_host_tool_names=config.get('allowed_tools'),
|
||
override_runner_tools='allowed_tools' in config,
|
||
),
|
||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||
delivery_policy=DeliveryPolicy(
|
||
enable_streaming=False,
|
||
enable_reply=False,
|
||
enable_interactions=agent.get('kind') != 'event_processor',
|
||
),
|
||
agent_id=agent.get('uuid'),
|
||
processor_type=agent.get('kind', 'agent'),
|
||
processor_id=agent.get('uuid'),
|
||
)
|
||
|
||
async def _handle_platform_event(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> None:
|
||
# One inbound event owns one execution trace; every stage recorded while
|
||
# it is handled (routing, runner, platform API calls) joins that trace.
|
||
from ..telemetry.execution import ingress
|
||
|
||
event.bot_uuid = self.bot_entity.uuid
|
||
with ingress(getattr(self, 'ap', None), 'event_done'):
|
||
await self._handle_platform_event_body(event, adapter)
|
||
|
||
async def _handle_platform_event_body(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> None:
|
||
from ..telemetry.execution import record
|
||
|
||
record(
|
||
getattr(self, 'ap', None),
|
||
getattr(self, 'execution_context', None),
|
||
family='platform_event',
|
||
operation=event.type,
|
||
adapter=adapter.__class__.__name__,
|
||
outcome='success',
|
||
)
|
||
await self._record_adapter_event(event, adapter)
|
||
|
||
primary = (
|
||
self._handle_interaction_submission(event, adapter)
|
||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted'
|
||
else self._dispatch_eba_event_to_processor(event, adapter)
|
||
)
|
||
subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', []))
|
||
seen = set()
|
||
tasks = [primary]
|
||
for subscription in subscriptions:
|
||
processor_uuid = subscription.get('processor_uuid')
|
||
if not processor_uuid or processor_uuid in seen or not subscription.get('enabled', True):
|
||
continue
|
||
seen.add(processor_uuid)
|
||
tasks.append(self._dispatch_plugin_subscription(event, adapter, processor_uuid))
|
||
# Start all deliveries together. A slow or failed subscriber cannot block
|
||
# another subscriber or the primary route from receiving the event.
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
for result in results:
|
||
if isinstance(result, BaseException):
|
||
await self.logger.error(f'Event delivery failed: {result}')
|
||
|
||
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
|
||
event_type = event.type
|
||
event_binding = {
|
||
'id': f'plugin_processor:{processor_uuid}',
|
||
'target_type': 'event_processor',
|
||
'target_uuid': processor_uuid,
|
||
'event_pattern': event_type,
|
||
}
|
||
try:
|
||
agent = await self.ap.agent_service.get_agent(self.execution_context, processor_uuid)
|
||
if not agent or agent.get('kind') != 'event_processor':
|
||
raise ValueError('Plugin processor not found')
|
||
descriptor = await self.ap.runner_registry.get(self.execution_context, agent.get('component_ref'))
|
||
if 'event' not in descriptor.usages:
|
||
raise ValueError('Runner does not support event processing')
|
||
# Resolve the installed declaration each time, including after plugin updates.
|
||
patterns = descriptor.supported_event_patterns
|
||
if not patterns or not self._agent_supports_event_type(patterns, event_type):
|
||
return
|
||
agent = {**agent, 'supported_event_patterns': patterns}
|
||
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
|
||
except Exception as exc:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='error',
|
||
binding=event_binding,
|
||
target_type='event_processor',
|
||
target_uuid=processor_uuid,
|
||
failure_code='runner_failed',
|
||
reason=str(exc),
|
||
text=f'Plugin processor {processor_uuid} failed: {exc}',
|
||
)
|
||
|
||
async def _dispatch_eba_event_to_processor(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
event_binding: dict | None = None,
|
||
agent: dict | None = None,
|
||
) -> dict[str, typing.Any]:
|
||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||
|
||
if event_binding is None:
|
||
event_binding = self._resolve_eba_event_binding(event, event_type)
|
||
if event_binding is None:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='not_matched',
|
||
failure_code='route_not_found',
|
||
reason='No event route matched',
|
||
text=f'Platform event {event_type} ignored: no event route matched',
|
||
)
|
||
|
||
target_type = event_binding.get('target_type')
|
||
await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='matched',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=event_binding.get('target_uuid'),
|
||
text=f'Event {event_type} matched route {event_binding.get("id") or ""}'.strip(),
|
||
)
|
||
if target_type == 'discard':
|
||
if isinstance(event, platform_events.MessageReceivedEvent):
|
||
from ..telemetry import trace as trace_mod
|
||
|
||
with trace_mod.scope(route_ref=self._route_ref(event_binding)):
|
||
await self._dispatch_eba_message_to_pipeline(
|
||
event,
|
||
adapter,
|
||
pipeline_uuid=self.PIPELINE_DISCARD,
|
||
routed_by_event_binding=True,
|
||
)
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='discarded',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
text=f'Event {event_type} discarded by event binding',
|
||
)
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='discarded',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
text=f'Event {event_type} discarded by event binding',
|
||
)
|
||
if target_type == 'pipeline':
|
||
if not self._is_message_event_type(event_type):
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='warning',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=event_binding.get('target_uuid'),
|
||
failure_code='processor_incompatible',
|
||
reason='Pipeline targets only support message events',
|
||
text=f'Event {event_type} ignored Pipeline target for non-message event',
|
||
)
|
||
from ..telemetry import trace as trace_mod
|
||
|
||
with trace_mod.scope(
|
||
route_ref=self._route_ref(event_binding, target_type, event_binding.get('target_uuid'))
|
||
):
|
||
await self._dispatch_eba_message_to_pipeline(
|
||
event,
|
||
adapter,
|
||
pipeline_uuid=event_binding.get('target_uuid'),
|
||
routed_by_event_binding=True,
|
||
)
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='delivered',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=event_binding.get('target_uuid'),
|
||
text=f'Event {event_type} delivered to Pipeline {event_binding.get("target_uuid") or ""}'.strip(),
|
||
)
|
||
if target_type not in {'agent', 'event_processor'}:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='warning',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=event_binding.get('target_uuid'),
|
||
failure_code='processor_incompatible',
|
||
reason=f'Unsupported event binding target type: {target_type}',
|
||
text=f'Event {event_type} ignored unsupported target type {target_type}',
|
||
)
|
||
|
||
target_uuid = event_binding.get('target_uuid')
|
||
if agent is None:
|
||
agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid)
|
||
if not agent or agent.get('kind') != target_type:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='warning',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
failure_code='processor_not_found',
|
||
reason='Agent target not found',
|
||
text=f'Event {event_type} target agent not found: {target_uuid}',
|
||
)
|
||
if not self._agent_supports_event_type(agent.get('supported_event_patterns'), event_type):
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
failure_code='processor_incompatible',
|
||
reason='Agent target does not support this event type',
|
||
text=f'Event {event_type} target agent does not support this event: {target_uuid}',
|
||
)
|
||
|
||
try:
|
||
binding = self._agent_product_to_binding(agent, event_binding, event_type, self.bot_entity.uuid)
|
||
except Exception:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='error',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
failure_code='runner_failed',
|
||
reason='Agent configuration is invalid',
|
||
text=f'Failed to build Agent binding for EBA event {event_type}: {traceback.format_exc()}',
|
||
)
|
||
if binding is None:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='warning',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
failure_code='processor_not_found',
|
||
reason='Agent target has no runner',
|
||
text=f'Event {event_type} target agent has no runner: {target_uuid}',
|
||
)
|
||
|
||
envelope = self._eba_event_to_agent_envelope(event, adapter)
|
||
if target_type == 'event_processor':
|
||
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
|
||
from ..telemetry import trace as trace_mod
|
||
|
||
try:
|
||
with trace_mod.scope(route_ref=self._route_ref(event_binding, target_type, target_uuid)):
|
||
async for _ in self.ap.agent_run_orchestrator.run(
|
||
envelope,
|
||
binding,
|
||
adapter_context={
|
||
'_delivery_adapter': adapter,
|
||
'_platform_event': event,
|
||
'_execution_context': self.execution_context,
|
||
},
|
||
):
|
||
# Results are journaled by the orchestrator; platform sends require explicit actions.
|
||
pass
|
||
except Exception:
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='failed',
|
||
level='error',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
failure_code='runner_failed',
|
||
reason='Agent runner failed',
|
||
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
|
||
)
|
||
return await self._record_event_route_trace(
|
||
event_type=event_type,
|
||
status='delivered',
|
||
binding=event_binding,
|
||
target_type=target_type,
|
||
target_uuid=target_uuid,
|
||
text=f'Event {event_type} delivered to Agent {target_uuid}',
|
||
)
|
||
|
||
def resolve_event_pipeline_uuid(
|
||
self,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
launcher_type: str,
|
||
launcher_id: str,
|
||
message_text: str,
|
||
message_element_types: list[str] | None = None,
|
||
) -> tuple[str | None, bool]:
|
||
"""Resolve a pipeline, honoring a trusted per-task adapter override."""
|
||
|
||
get_override = getattr(adapter, 'get_pipeline_uuid_override', None)
|
||
if callable(get_override):
|
||
override = get_override()
|
||
if override:
|
||
return str(override), False
|
||
return self.resolve_pipeline_uuid(
|
||
launcher_type,
|
||
launcher_id,
|
||
message_text,
|
||
message_element_types,
|
||
)
|
||
|
||
async def _record_discarded_message(
|
||
self,
|
||
launcher_type: provider_session.LauncherTypes,
|
||
launcher_id: str | int,
|
||
sender_id: str | int,
|
||
message_event: platform_events.MessageEvent,
|
||
message_chain: platform_message.MessageChain,
|
||
) -> None:
|
||
"""Record a discarded message in the monitoring system."""
|
||
try:
|
||
if hasattr(message_chain, 'model_dump'):
|
||
chain_dump = message_chain.model_dump()
|
||
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
|
||
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||
else:
|
||
message_content = str(message_chain)
|
||
|
||
sender_name = None
|
||
if hasattr(message_event, 'sender'):
|
||
if hasattr(message_event.sender, 'nickname'):
|
||
sender_name = message_event.sender.nickname
|
||
elif hasattr(message_event.sender, 'member_name'):
|
||
sender_name = message_event.sender.member_name
|
||
|
||
# Use the same session_id format as monitoring_helper.py
|
||
session_id = f'{launcher_type}_{launcher_id}'
|
||
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||
|
||
await self.ap.monitoring_service.record_message(
|
||
self.execution_context,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||
pipeline_id=self.PIPELINE_DISCARD,
|
||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||
message_content=message_content,
|
||
session_id=session_id,
|
||
status='discarded',
|
||
level='info',
|
||
platform=platform,
|
||
user_id=str(sender_id),
|
||
user_name=sender_name,
|
||
)
|
||
|
||
# Ensure the session exists so the message appears in the session monitor.
|
||
# Don't overwrite pipeline info — a session may have messages from
|
||
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||
self.execution_context,
|
||
session_id,
|
||
)
|
||
if not session_updated:
|
||
# No session yet (first message for this launcher was discarded).
|
||
await self.ap.monitoring_service.record_session_start(
|
||
self.execution_context,
|
||
session_id=session_id,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||
pipeline_id=self.PIPELINE_DISCARD,
|
||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||
platform=platform,
|
||
user_id=str(sender_id),
|
||
user_name=sender_name,
|
||
)
|
||
except Exception as e:
|
||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||
|
||
async def _handle_legacy_message_event(
|
||
self,
|
||
event: platform_events.FriendMessage | platform_events.GroupMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
pipeline_uuid_override: str | None = None,
|
||
routed_by_event_binding: bool = False,
|
||
variables: dict[str, typing.Any] | None = None,
|
||
) -> None:
|
||
await self.assert_execution_active()
|
||
is_group_message = isinstance(event, platform_events.GroupMessage)
|
||
launcher_kind = 'group' if is_group_message else 'person'
|
||
launcher_type = (
|
||
provider_session.LauncherTypes.GROUP if is_group_message else provider_session.LauncherTypes.PERSON
|
||
)
|
||
launcher_id = event.group.id if is_group_message else event.sender.id
|
||
sender_id = event.sender.id
|
||
|
||
image_components = [
|
||
component for component in event.message_chain if isinstance(component, platform_message.Image)
|
||
]
|
||
|
||
await self.logger.info(
|
||
f'{event.message_chain}',
|
||
images=image_components,
|
||
message_session_id=f'{launcher_kind}_{launcher_id}',
|
||
)
|
||
|
||
skip_pipeline = False
|
||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||
if is_group_message:
|
||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||
self.execution_context,
|
||
event,
|
||
self.bot_entity.uuid,
|
||
adapter.__class__.__name__,
|
||
)
|
||
else:
|
||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||
self.execution_context,
|
||
event,
|
||
self.bot_entity.uuid,
|
||
adapter.__class__.__name__,
|
||
)
|
||
|
||
if skip_pipeline:
|
||
await self.logger.info(f'Pipeline skipped for {launcher_kind} message due to webhook response')
|
||
return
|
||
|
||
if hasattr(adapter, 'get_launcher_id'):
|
||
custom_launcher_id = adapter.get_launcher_id(event)
|
||
if custom_launcher_id:
|
||
launcher_id = custom_launcher_id
|
||
|
||
if pipeline_uuid_override is None:
|
||
pipeline_uuid = None
|
||
routed_by_rule = False
|
||
else:
|
||
pipeline_uuid = pipeline_uuid_override
|
||
routed_by_rule = routed_by_event_binding
|
||
|
||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||
await self.logger.info(f'{launcher_kind.title()} message discarded by routing rule')
|
||
await self._record_discarded_message(
|
||
launcher_type,
|
||
launcher_id,
|
||
sender_id,
|
||
event,
|
||
event.message_chain,
|
||
)
|
||
return
|
||
|
||
await self.ap.msg_aggregator.add_message(
|
||
bot_uuid=self.bot_entity.uuid,
|
||
launcher_type=launcher_type,
|
||
launcher_id=launcher_id,
|
||
sender_id=sender_id,
|
||
message_event=event,
|
||
message_chain=event.message_chain,
|
||
adapter=adapter,
|
||
pipeline_uuid=pipeline_uuid,
|
||
routed_by_rule=routed_by_rule,
|
||
variables=variables,
|
||
execution_context=self.execution_context,
|
||
)
|
||
|
||
async def _handle_interaction_submission(
|
||
self,
|
||
event: platform_events.PlatformSpecificEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
) -> None:
|
||
"""Consume a platform interaction callback and resume its original processor."""
|
||
data = event.data if isinstance(event.data, dict) else {}
|
||
callback_token = str(data.get('callback_token') or '')
|
||
actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None
|
||
target_type = str(data.get('target_type') or '') or None
|
||
target_id = str(data.get('target_id') or data.get('chat_id') or '') or None
|
||
conversation_id = f'{target_type}_{target_id}' if target_type and target_id else None
|
||
submission = {
|
||
'interaction_id': data.get('interaction_id'),
|
||
'action_id': data.get('action_id'),
|
||
'values': data.get('values') if isinstance(data.get('values'), dict) else {},
|
||
'submitted_at': int(event.timestamp) if event.timestamp else None,
|
||
}
|
||
for ref_name in ('action_ref', 'field_ref', 'option_ref'):
|
||
if data.get(ref_name) is not None:
|
||
submission[ref_name] = data[ref_name]
|
||
|
||
record = await self.ap.agent_run_orchestrator.interaction_manager.consume_callback(
|
||
callback_token=callback_token,
|
||
submission=submission,
|
||
bot_id=self.bot_entity.uuid,
|
||
conversation_id=conversation_id,
|
||
actor_id=actor_id,
|
||
)
|
||
await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission(record, adapter)
|
||
|
||
if record['processor_type'] == 'agent':
|
||
await self._resume_agent_interaction(record, event, adapter, actor_id)
|
||
return
|
||
if record['processor_type'] != 'pipeline':
|
||
raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}')
|
||
|
||
pipeline = await self.ap.pipeline_service.get_pipeline(
|
||
self.execution_context,
|
||
record['processor_id'],
|
||
include_secret=True,
|
||
)
|
||
if not pipeline:
|
||
raise ValueError(f'Interaction target Pipeline is unavailable: {record["processor_id"]}')
|
||
current_runner_id = RunnerConfigResolver.resolve_runner_id(pipeline.get('config') or {})
|
||
if current_runner_id != record['runner_id']:
|
||
raise ValueError('Interaction target Pipeline runner changed after the request was created')
|
||
|
||
message_components: list[platform_message.MessageComponent] = []
|
||
callback_message_id = data.get('message_id')
|
||
if callback_message_id:
|
||
message_components.append(
|
||
platform_message.Source(
|
||
id=str(callback_message_id),
|
||
time=int(event.timestamp) if event.timestamp else int(time.time()),
|
||
)
|
||
)
|
||
message_components.append(
|
||
platform_message.Plain(text=str(data.get('display_text') or data.get('action_id') or 'submitted'))
|
||
)
|
||
message_chain = platform_message.MessageChain(message_components)
|
||
delivery_target = record.get('delivery_target') or {}
|
||
original_target_type = delivery_target.get('target_type')
|
||
original_target_id = delivery_target.get('target_id')
|
||
if original_target_type == 'group':
|
||
group = platform_entities.Group(
|
||
id=original_target_id,
|
||
name='',
|
||
permission=platform_entities.Permission.Member,
|
||
)
|
||
message_event: platform_events.MessageEvent = platform_events.GroupMessage(
|
||
sender=platform_entities.GroupMember(
|
||
id=actor_id or '',
|
||
member_name='',
|
||
permission=platform_entities.Permission.Member,
|
||
group=group,
|
||
),
|
||
message_chain=message_chain,
|
||
time=event.timestamp,
|
||
source_platform_object=event.source_platform_object,
|
||
)
|
||
else:
|
||
message_event = platform_events.FriendMessage(
|
||
sender=platform_entities.Friend(id=actor_id or '', nickname='', remark=''),
|
||
message_chain=message_chain,
|
||
time=event.timestamp,
|
||
source_platform_object=event.source_platform_object,
|
||
)
|
||
|
||
launcher_type = (
|
||
provider_session.LauncherTypes.GROUP
|
||
if original_target_type == 'group'
|
||
else provider_session.LauncherTypes.PERSON
|
||
)
|
||
await self.ap.msg_aggregator.add_message(
|
||
bot_uuid=self.bot_entity.uuid,
|
||
launcher_type=launcher_type,
|
||
launcher_id=original_target_id or target_id or actor_id or '',
|
||
sender_id=actor_id or '',
|
||
message_event=message_event,
|
||
message_chain=message_chain,
|
||
adapter=adapter,
|
||
pipeline_uuid=record['processor_id'],
|
||
routed_by_rule=True,
|
||
variables={'_interaction_submission': record['submission']},
|
||
execution_context=self.execution_context,
|
||
)
|
||
|
||
async def _resume_agent_interaction(
|
||
self,
|
||
record: dict[str, typing.Any],
|
||
event: platform_events.PlatformSpecificEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
actor_id: str | None,
|
||
) -> None:
|
||
"""Resume the exact independent Agent that produced an interaction."""
|
||
agent = await self.ap.agent_service.get_agent(
|
||
self.execution_context,
|
||
record['processor_id'],
|
||
)
|
||
if not agent or agent.get('kind') != 'agent':
|
||
raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}')
|
||
|
||
binding = self._agent_product_to_binding(
|
||
agent,
|
||
{'id': record['binding_id']},
|
||
'interaction.submitted',
|
||
self.bot_entity.uuid,
|
||
)
|
||
if binding is None or binding.runner_id != record['runner_id']:
|
||
raise ValueError('Interaction target Agent runner changed after the request was created')
|
||
binding.binding_id = record['binding_id']
|
||
|
||
submission = record['submission']
|
||
display_text = str(
|
||
(submission or {}).get('action_id')
|
||
or (event.data if isinstance(event.data, dict) else {}).get('display_text')
|
||
or 'submitted'
|
||
)
|
||
input_data: dict[str, typing.Any] = {
|
||
'text': display_text,
|
||
'contents': [{'type': 'text', 'text': display_text}],
|
||
'attachments': [],
|
||
}
|
||
if 'interaction' in AgentInput.model_fields:
|
||
input_data['interaction'] = submission
|
||
|
||
delivery_target = record.get('delivery_target') or {}
|
||
supported_apis = self._get_adapter_supported_apis(adapter)
|
||
delivery_data: dict[str, typing.Any] = {
|
||
'surface': 'platform',
|
||
'reply_target': delivery_target,
|
||
'supports_streaming': False,
|
||
'supports_edit': 'edit_message' in supported_apis,
|
||
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
|
||
'platform_capabilities': {
|
||
'adapter': adapter.__class__.__name__,
|
||
'event_type': 'interaction.submitted',
|
||
'supported_apis': supported_apis,
|
||
},
|
||
}
|
||
interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis)
|
||
if interaction_capabilities is not None:
|
||
delivery_data['interactions'] = interaction_capabilities
|
||
|
||
envelope = AgentEventEnvelope(
|
||
event_id=f'interaction:{record["id"]}:{uuid.uuid4()}',
|
||
event_type='interaction.submitted',
|
||
event_time=int(event.timestamp) if event.timestamp else int(time.time()),
|
||
source='platform',
|
||
source_event_type='interaction.submitted',
|
||
bot_id=self.bot_entity.uuid,
|
||
workspace_id=record.get('workspace_id'),
|
||
conversation_id=record.get('conversation_id'),
|
||
thread_id=record.get('thread_id'),
|
||
actor=ActorContext(actor_type='user', actor_id=actor_id),
|
||
subject=SubjectContext(
|
||
subject_type='interaction',
|
||
subject_id=record['interaction_id'],
|
||
data={'action_id': (submission or {}).get('action_id')},
|
||
),
|
||
input=AgentInput.model_validate(input_data),
|
||
delivery=DeliveryContext.model_validate(delivery_data),
|
||
raw_ref=RawEventRef(ref_id=f'interaction:{record["id"]}', storage_key=None),
|
||
data={'interaction': submission},
|
||
)
|
||
|
||
async for _ in self.ap.agent_run_orchestrator.run(
|
||
envelope,
|
||
binding,
|
||
adapter_context={'_delivery_adapter': adapter},
|
||
):
|
||
# Resuming an interaction follows the same explicit-action delivery policy.
|
||
pass
|
||
|
||
async def _dispatch_eba_message_to_pipeline(
|
||
self,
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
pipeline_uuid: str | None = None,
|
||
routed_by_event_binding: bool = False,
|
||
) -> None:
|
||
if not isinstance(event, platform_events.MessageReceivedEvent):
|
||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
||
await self.logger.warning(f'Event {event_type} cannot be dispatched to legacy Pipeline')
|
||
return
|
||
|
||
await self._handle_legacy_message_event(
|
||
event.to_legacy_event(),
|
||
adapter,
|
||
pipeline_uuid_override=pipeline_uuid,
|
||
routed_by_event_binding=routed_by_event_binding,
|
||
)
|
||
|
||
async def initialize(self):
|
||
from ..telemetry.platform import observe_adapter
|
||
|
||
observe_adapter(self.ap, self.execution_context, self.adapter)
|
||
|
||
def tenant_scoped_listener(listener):
|
||
@functools.wraps(listener)
|
||
async def wrapped(*args, **kwargs):
|
||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||
cloud_runtime = (
|
||
getattr(
|
||
getattr(self.ap.persistence_mgr, 'mode', None),
|
||
'value',
|
||
None,
|
||
)
|
||
== 'cloud_runtime'
|
||
)
|
||
if cloud_runtime:
|
||
if not callable(tenant_scope):
|
||
raise RuntimeError('Cloud platform callbacks require a tenant scope')
|
||
async with tenant_scope(self.workspace_uuid):
|
||
return await listener(*args, **kwargs)
|
||
return await listener(*args, **kwargs)
|
||
|
||
return wrapped
|
||
|
||
def websocket_pipeline_uuid(event, adapter):
|
||
if adapter.__class__.__name__ != 'WebSocketAdapter':
|
||
return None
|
||
value = getattr(event, '_langbot_pipeline_uuid', None)
|
||
return value if isinstance(value, str) and value else None
|
||
|
||
async def on_friend_message(
|
||
event: platform_events.FriendMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
pipeline_uuid = websocket_pipeline_uuid(event, adapter)
|
||
if pipeline_uuid:
|
||
await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid)
|
||
return
|
||
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
||
|
||
async def on_group_message(
|
||
event: platform_events.GroupMessage,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
pipeline_uuid = websocket_pipeline_uuid(event, adapter)
|
||
if pipeline_uuid:
|
||
await self._handle_legacy_message_event(event, adapter, pipeline_uuid_override=pipeline_uuid)
|
||
return
|
||
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
|
||
|
||
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
|
||
supported_events: list[str] = []
|
||
if callable(get_supported_events):
|
||
try:
|
||
supported_events = list(get_supported_events() or [])
|
||
except Exception:
|
||
supported_events = []
|
||
|
||
# EBA adapters emit MessageReceivedEvent directly. Registering both
|
||
# entry paths would process each native message twice because these
|
||
# adapters also expose legacy conversion for compatibility consumers.
|
||
if 'message.received' not in supported_events:
|
||
self.adapter.register_listener(
|
||
platform_events.FriendMessage,
|
||
tenant_scoped_listener(on_friend_message),
|
||
)
|
||
self.adapter.register_listener(
|
||
platform_events.GroupMessage,
|
||
tenant_scoped_listener(on_group_message),
|
||
)
|
||
|
||
# Register feedback listener (only effective on adapters that support it)
|
||
async def on_feedback(
|
||
event: platform_events.FeedbackEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
try:
|
||
await self.assert_execution_active()
|
||
pipeline_id = self.get_pipeline_target_for_event_type('message.received') or ''
|
||
pipeline_name = ''
|
||
if pipeline_id:
|
||
pipeline = await self.ap.pipeline_service.get_pipeline(
|
||
self.execution_context,
|
||
pipeline_id,
|
||
)
|
||
pipeline_name = pipeline.get('name', '') if pipeline else ''
|
||
|
||
await self.ap.monitoring_service.record_feedback(
|
||
self.execution_context,
|
||
feedback_id=event.feedback_id,
|
||
feedback_type=event.feedback_type,
|
||
feedback_content=event.feedback_content,
|
||
inaccurate_reasons=event.inaccurate_reasons,
|
||
bot_id=self.bot_entity.uuid,
|
||
bot_name=self.bot_entity.name,
|
||
pipeline_id=pipeline_id,
|
||
pipeline_name=pipeline_name,
|
||
session_id=event.session_id,
|
||
message_id=event.message_id,
|
||
stream_id=event.stream_id,
|
||
user_id=event.user_id,
|
||
platform=adapter.__class__.__name__,
|
||
)
|
||
await self.logger.info(
|
||
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
|
||
)
|
||
except Exception:
|
||
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||
|
||
self.adapter.register_listener(platform_events.FeedbackEvent, tenant_scoped_listener(on_feedback))
|
||
|
||
async def on_eba_event(
|
||
event: platform_events.EBAEvent,
|
||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||
):
|
||
await self._handle_platform_event(event, adapter)
|
||
|
||
self.adapter.register_listener(
|
||
platform_events.EBAEvent,
|
||
tenant_scoped_listener(on_eba_event),
|
||
)
|
||
|
||
async def run(self):
|
||
async def exception_wrapper():
|
||
try:
|
||
self.task_context.set_current_action('Running...')
|
||
await self.adapter.run_async()
|
||
self.task_context.set_current_action('Exited.')
|
||
except Exception as e:
|
||
if isinstance(e, asyncio.CancelledError):
|
||
self.task_context.set_current_action('Exited.')
|
||
return
|
||
|
||
traceback_str = traceback.format_exc()
|
||
self.task_context.set_current_action('Exited with error.')
|
||
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}')
|
||
|
||
self.task_wrapper = self.ap.task_mgr.create_task(
|
||
exception_wrapper(),
|
||
kind='platform-adapter',
|
||
name=f'platform-adapter-{self.adapter.__class__.__name__}',
|
||
context=self.task_context,
|
||
scopes=[
|
||
core_entities.LifecycleControlScope.APPLICATION,
|
||
core_entities.LifecycleControlScope.PLATFORM,
|
||
],
|
||
instance_uuid=self.execution_context.instance_uuid,
|
||
workspace_uuid=self.execution_context.workspace_uuid,
|
||
placement_generation=(self.execution_context.placement_generation),
|
||
)
|
||
|
||
async def shutdown(self):
|
||
async with self._shutdown_lock:
|
||
if self._shutdown_complete:
|
||
return
|
||
|
||
wrapper = self.task_wrapper
|
||
self.task_wrapper = None
|
||
try:
|
||
await asyncio.wait_for(self.adapter.kill(), timeout=15)
|
||
finally:
|
||
if wrapper is not None:
|
||
self.ap.task_mgr.cancel_task(wrapper.id)
|
||
if wrapper.task is not asyncio.current_task():
|
||
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
||
await asyncio.wait_for(wrapper.task, timeout=5)
|
||
self._shutdown_complete = True
|
||
|
||
|
||
# 控制QQ消息输入输出的类
|
||
class PlatformManager:
|
||
# ====== 4.0 ======
|
||
ap: app.Application = None
|
||
|
||
bots: list[RuntimeBot]
|
||
|
||
websocket_proxy_bots: dict[str, RuntimeBot]
|
||
|
||
adapter_components: list[engine.Component]
|
||
|
||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]]
|
||
|
||
def __init__(self, ap: app.Application = None):
|
||
self.ap = ap
|
||
self._bots_by_key: dict[tuple[str, str, str], RuntimeBot] = {}
|
||
self._bot_keys_by_workspace: dict[
|
||
str,
|
||
set[tuple[str, str, str]],
|
||
] = {}
|
||
self._bot_keys_by_uuid: dict[str, set[tuple[str, str, str]]] = {}
|
||
self.websocket_proxy_bots = {}
|
||
self.adapter_components = []
|
||
self.adapter_dict = {}
|
||
self._scope_generations: dict[tuple[str, str], int] = {}
|
||
self._proxy_last_accessed: dict[str, float] = {}
|
||
self._runtime_mutation_lock = asyncio.Lock()
|
||
|
||
@staticmethod
|
||
def _runtime_bot_key(bot: RuntimeBot) -> tuple[str, str, str]:
|
||
context = getattr(bot, 'execution_context', None)
|
||
instance_uuid = str(getattr(context, 'instance_uuid', '__test_instance__'))
|
||
workspace_uuid = str(
|
||
getattr(bot, 'workspace_uuid', None) or getattr(context, 'workspace_uuid', '__test_workspace__')
|
||
)
|
||
bot_uuid = str(
|
||
getattr(getattr(bot, 'bot_entity', None), 'uuid', None)
|
||
or getattr(context, 'bot_uuid', None)
|
||
or f'__runtime_{id(bot)}'
|
||
)
|
||
return instance_uuid, workspace_uuid, bot_uuid
|
||
|
||
def _register_runtime_bot(self, bot: RuntimeBot) -> RuntimeBot | None:
|
||
key = self._runtime_bot_key(bot)
|
||
previous = self._bots_by_key.get(key)
|
||
self._bots_by_key[key] = bot
|
||
self._bot_keys_by_workspace.setdefault(key[1], set()).add(key)
|
||
self._bot_keys_by_uuid.setdefault(key[2], set()).add(key)
|
||
return previous
|
||
|
||
def _pop_runtime_bot(
|
||
self,
|
||
key: tuple[str, str, str],
|
||
) -> RuntimeBot | None:
|
||
bot = self._bots_by_key.pop(key, None)
|
||
if bot is None:
|
||
return None
|
||
workspace_keys = self._bot_keys_by_workspace.get(key[1])
|
||
if workspace_keys is not None:
|
||
workspace_keys.discard(key)
|
||
if not workspace_keys:
|
||
self._bot_keys_by_workspace.pop(key[1], None)
|
||
uuid_keys = self._bot_keys_by_uuid.get(key[2])
|
||
if uuid_keys is not None:
|
||
uuid_keys.discard(key)
|
||
if not uuid_keys:
|
||
self._bot_keys_by_uuid.pop(key[2], None)
|
||
return bot
|
||
|
||
@property
|
||
def bots(self) -> list[RuntimeBot]:
|
||
"""Compatibility view over the indexed platform runtime registry."""
|
||
|
||
return list(self._bots_by_key.values())
|
||
|
||
@bots.setter
|
||
def bots(self, bots: list[RuntimeBot]) -> None:
|
||
self._bots_by_key = {}
|
||
self._bot_keys_by_workspace = {}
|
||
self._bot_keys_by_uuid = {}
|
||
for bot in bots:
|
||
self._register_runtime_bot(bot)
|
||
|
||
def _max_workspace_proxies(self) -> int:
|
||
instance_data = getattr(
|
||
getattr(self.ap, 'instance_config', None),
|
||
'data',
|
||
{},
|
||
)
|
||
value = instance_data.get('system', {}).get('websocket_retention', {}).get('max_workspace_proxies', 1024)
|
||
try:
|
||
return max(int(value), 1)
|
||
except (TypeError, ValueError):
|
||
return 1024
|
||
|
||
async def _evict_idle_websocket_proxy_unlocked(self) -> None:
|
||
"""Make room without interrupting a live socket or in-flight query."""
|
||
|
||
if len(self.websocket_proxy_bots) < self._max_workspace_proxies():
|
||
return
|
||
from .sources.websocket_manager import WebSocketScope, ws_connection_manager
|
||
|
||
for workspace_uuid in sorted(
|
||
self.websocket_proxy_bots,
|
||
key=lambda item: self._proxy_last_accessed.get(item, 0.0),
|
||
):
|
||
proxy_bot = self.websocket_proxy_bots[workspace_uuid]
|
||
listener_tasks = getattr(proxy_bot.adapter, 'inbound_listener_tasks', ())
|
||
if any(not task.done() for task in tuple(listener_tasks)):
|
||
continue
|
||
scope = WebSocketScope.from_context(proxy_bot.execution_context)
|
||
if ws_connection_manager.get_stats(scope=scope)['total_connections'] > 0:
|
||
continue
|
||
self.websocket_proxy_bots.pop(workspace_uuid, None)
|
||
self._proxy_last_accessed.pop(workspace_uuid, None)
|
||
await proxy_bot.shutdown()
|
||
if not self._bot_keys_by_workspace.get(workspace_uuid):
|
||
self._scope_generations.pop(
|
||
(proxy_bot.execution_context.instance_uuid, workspace_uuid),
|
||
None,
|
||
)
|
||
return
|
||
raise RuntimeError('WebSocket Workspace proxy capacity reached and every proxy is active')
|
||
|
||
async def _observe_execution_context(self, context: ExecutionContext) -> None:
|
||
"""Shutdown superseded Workspace adapters when placement advances."""
|
||
|
||
async with self._runtime_mutation_lock:
|
||
await self._observe_execution_context_unlocked(context)
|
||
|
||
async def _observe_execution_context_unlocked(self, context: ExecutionContext) -> None:
|
||
scope = (context.instance_uuid, context.workspace_uuid)
|
||
previous_generation = self._scope_generations.get(scope)
|
||
if previous_generation is not None and context.placement_generation < previous_generation:
|
||
raise WorkspaceInvariantError('Platform runtime placement generation rolled back')
|
||
if previous_generation == context.placement_generation:
|
||
return
|
||
if previous_generation is not None:
|
||
proxy_bot = self.websocket_proxy_bots.pop(context.workspace_uuid, None)
|
||
self._proxy_last_accessed.pop(context.workspace_uuid, None)
|
||
if proxy_bot is not None and proxy_bot.enable:
|
||
await proxy_bot.shutdown()
|
||
for key in tuple(self._bot_keys_by_workspace.get(context.workspace_uuid, ())):
|
||
bot = self._pop_runtime_bot(key)
|
||
if bot is None:
|
||
continue
|
||
if bot.enable:
|
||
await bot.shutdown()
|
||
self._scope_generations[scope] = context.placement_generation
|
||
|
||
async def initialize(self):
|
||
# delete all bot log images
|
||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||
|
||
disabled_adapters = {
|
||
canonical_adapter_name(name)
|
||
for name in (self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or [])
|
||
}
|
||
|
||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||
for component in self.adapter_components:
|
||
if component.metadata.name in disabled_adapters:
|
||
continue
|
||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||
self.adapter_dict = adapter_dict
|
||
|
||
# Filter out disabled adapters from components list (for API responses)
|
||
if disabled_adapters:
|
||
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||
|
||
await self.load_bots_from_db()
|
||
|
||
# OSS may have no persisted bots. Its singleton Workspace still needs
|
||
# a debug WebSocket proxy. SaaS creates proxies lazily from an explicit
|
||
# request/runtime context instead of guessing among Workspaces.
|
||
if not self.websocket_proxy_bots:
|
||
try:
|
||
binding = await self.ap.workspace_service.get_execution_binding()
|
||
except Exception:
|
||
pass
|
||
else:
|
||
await self.get_websocket_proxy_bot(
|
||
ExecutionContext(
|
||
instance_uuid=binding.instance_uuid,
|
||
workspace_uuid=binding.workspace_uuid,
|
||
placement_generation=binding.placement_generation,
|
||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||
)
|
||
)
|
||
|
||
@property
|
||
def websocket_proxy_bot(self) -> RuntimeBot:
|
||
"""Compatibility accessor that is safe only for a singleton Workspace."""
|
||
|
||
if len(self.websocket_proxy_bots) != 1:
|
||
raise WorkspaceRequiredError('An explicit Workspace is required for the WebSocket proxy bot')
|
||
return next(iter(self.websocket_proxy_bots.values()))
|
||
|
||
@websocket_proxy_bot.setter
|
||
def websocket_proxy_bot(self, runtime_bot: RuntimeBot) -> None:
|
||
"""Keep isolated tests that inject one proxy bot working."""
|
||
|
||
workspace_uuid = getattr(runtime_bot, 'workspace_uuid', '__test_singleton__')
|
||
self.websocket_proxy_bots = {workspace_uuid: runtime_bot}
|
||
|
||
@staticmethod
|
||
def _normalize_execution_context(
|
||
context: ExecutionContext | RequestContext,
|
||
*,
|
||
bot_uuid: str | None = None,
|
||
pipeline_uuid: str | None = None,
|
||
) -> ExecutionContext:
|
||
if isinstance(context, RequestContext):
|
||
return ExecutionContext.from_request(
|
||
context,
|
||
bot_uuid=bot_uuid,
|
||
pipeline_uuid=pipeline_uuid,
|
||
)
|
||
if not isinstance(context, ExecutionContext):
|
||
raise WorkspaceRequiredError('Runtime operations require an ExecutionContext')
|
||
if not context.instance_uuid.strip() or not context.workspace_uuid.strip():
|
||
raise WorkspaceRequiredError('Runtime operations require an instance and Workspace')
|
||
if context.placement_generation <= 0:
|
||
raise WorkspaceRequiredError('Runtime operations require a positive placement generation')
|
||
updates = {}
|
||
if bot_uuid is not None:
|
||
if context.bot_uuid not in (None, bot_uuid):
|
||
raise WorkspaceRequiredError('Runtime bot UUID does not match its ExecutionContext')
|
||
updates['bot_uuid'] = bot_uuid
|
||
if pipeline_uuid is not None:
|
||
if context.pipeline_uuid not in (None, pipeline_uuid):
|
||
raise WorkspaceRequiredError('Runtime pipeline UUID does not match its ExecutionContext')
|
||
updates['pipeline_uuid'] = pipeline_uuid
|
||
return dataclasses.replace(context, **updates) if updates else context
|
||
|
||
async def get_websocket_proxy_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
) -> RuntimeBot:
|
||
execution_context = self._normalize_execution_context(context)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
existing = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||
if existing is not None:
|
||
if existing.placement_generation != execution_context.placement_generation:
|
||
raise WorkspaceRequiredError('WebSocket proxy placement generation is stale')
|
||
self._proxy_last_accessed[execution_context.workspace_uuid] = time.monotonic()
|
||
return existing
|
||
|
||
await self._evict_idle_websocket_proxy_unlocked()
|
||
binding = await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
websocket_adapter_class = self.adapter_dict['websocket']
|
||
websocket_logger = EventLogger(
|
||
name='websocket-adapter',
|
||
ap=self.ap,
|
||
execution_context=execution_context,
|
||
owner='websocket-proxy-bot',
|
||
)
|
||
websocket_adapter_inst = websocket_adapter_class({}, websocket_logger, ap=self.ap)
|
||
proxy_context = dataclasses.replace(
|
||
execution_context,
|
||
instance_uuid=binding.instance_uuid,
|
||
bot_uuid='websocket-proxy-bot',
|
||
)
|
||
runtime_bot = RuntimeBot(
|
||
ap=self.ap,
|
||
bot_entity=persistence_bot.Bot(
|
||
uuid='websocket-proxy-bot',
|
||
workspace_uuid=binding.workspace_uuid,
|
||
name='WebSocket',
|
||
description='',
|
||
adapter='websocket',
|
||
adapter_config={},
|
||
enable=True,
|
||
),
|
||
adapter=websocket_adapter_inst,
|
||
logger=websocket_logger,
|
||
execution_context=proxy_context,
|
||
)
|
||
await runtime_bot.initialize()
|
||
self.websocket_proxy_bots[binding.workspace_uuid] = runtime_bot
|
||
self._proxy_last_accessed[binding.workspace_uuid] = time.monotonic()
|
||
return runtime_bot
|
||
|
||
def get_running_adapters(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
) -> list[abstract_platform_adapter.AbstractMessagePlatformAdapter]:
|
||
execution_context = self._normalize_execution_context(context)
|
||
return [
|
||
bot.adapter
|
||
for bot in self.bots
|
||
if bot.enable
|
||
and bot.workspace_uuid == execution_context.workspace_uuid
|
||
and bot.placement_generation == execution_context.placement_generation
|
||
]
|
||
|
||
async def load_bots_from_db(self):
|
||
self.ap.logger.info('Loading bots from db...')
|
||
|
||
async with self._runtime_mutation_lock:
|
||
old_bots = [*self.websocket_proxy_bots.values(), *self.bots]
|
||
self.websocket_proxy_bots = {}
|
||
self._proxy_last_accessed = {}
|
||
self.bots = []
|
||
self._scope_generations = {}
|
||
for bot in old_bots:
|
||
if not bot.enable:
|
||
continue
|
||
try:
|
||
await bot.shutdown()
|
||
except Exception as exc:
|
||
self.ap.logger.warning(f'Failed to stop old platform runtime during reload: {exc}')
|
||
|
||
list_bindings = getattr(
|
||
self.ap.workspace_service,
|
||
'list_active_execution_bindings',
|
||
None,
|
||
)
|
||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||
cloud_runtime = getattr(getattr(self.ap.persistence_mgr, 'mode', None), 'value', None) == 'cloud_runtime'
|
||
if cloud_runtime:
|
||
if not callable(list_bindings) or not callable(tenant_uow):
|
||
raise RuntimeError('Cloud platform loading requires explicit instance discovery and tenant UoWs')
|
||
for binding in await list_bindings():
|
||
try:
|
||
async with tenant_uow(binding.workspace_uuid):
|
||
await self._load_workspace_bots(
|
||
binding.workspace_uuid,
|
||
_binding=binding,
|
||
)
|
||
except Exception as exc:
|
||
self.ap.logger.error(
|
||
f'Failed to load Workspace bots for {binding.workspace_uuid}: {exc}\n{traceback.format_exc()}'
|
||
)
|
||
return
|
||
|
||
instance_uow = getattr(self.ap.persistence_mgr, 'instance_discovery_uow', None)
|
||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||
if callable(instance_uow) and callable(tenant_scope):
|
||
async with instance_uow(self.ap.workspace_service.instance_uuid) as discovery:
|
||
workspace_uuids = list(
|
||
(
|
||
await discovery.session.scalars(
|
||
sqlalchemy.select(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
|
||
.where(
|
||
persistence_workspace.WorkspaceExecutionState.instance_uuid
|
||
== self.ap.workspace_service.instance_uuid,
|
||
persistence_workspace.WorkspaceExecutionState.state
|
||
== persistence_workspace.WorkspaceExecutionStatus.ACTIVE.value,
|
||
persistence_workspace.WorkspaceExecutionState.write_fenced.is_(False),
|
||
)
|
||
.order_by(persistence_workspace.WorkspaceExecutionState.workspace_uuid)
|
||
)
|
||
).all()
|
||
)
|
||
else:
|
||
# Compatibility for lightweight tests and pre-tenancy managers.
|
||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_bot.Bot))
|
||
workspace_uuids = sorted({bot.workspace_uuid for bot in result.all()})
|
||
|
||
for workspace_uuid in workspace_uuids:
|
||
try:
|
||
if callable(tenant_scope):
|
||
async with tenant_scope(workspace_uuid):
|
||
await self._load_workspace_bots(workspace_uuid)
|
||
else:
|
||
await self._load_workspace_bots(workspace_uuid)
|
||
except Exception as e:
|
||
self.ap.logger.error(
|
||
f'Failed to load Workspace bots for {workspace_uuid}: {e}\n{traceback.format_exc()}'
|
||
)
|
||
|
||
async def _load_workspace_bots(
|
||
self,
|
||
workspace_uuid: str,
|
||
*,
|
||
_binding=None,
|
||
) -> None:
|
||
result = await self.ap.persistence_mgr.execute_async(
|
||
sqlalchemy.select(persistence_bot.Bot)
|
||
.where(persistence_bot.Bot.workspace_uuid == workspace_uuid)
|
||
.order_by(persistence_bot.Bot.uuid)
|
||
)
|
||
binding = _binding
|
||
for bot in result.all():
|
||
try:
|
||
if binding is None:
|
||
binding = await self.ap.workspace_service.get_execution_binding(workspace_uuid)
|
||
execution_context = ExecutionContext(
|
||
instance_uuid=binding.instance_uuid,
|
||
workspace_uuid=binding.workspace_uuid,
|
||
placement_generation=binding.placement_generation,
|
||
bot_uuid=bot.uuid,
|
||
trigger_principal=PrincipalContext(PrincipalType.SYSTEM),
|
||
)
|
||
await self.load_bot(
|
||
execution_context,
|
||
bot,
|
||
_binding_validated=True,
|
||
)
|
||
except platform_errors.AdapterNotFoundError as e:
|
||
self.ap.logger.warning(f'Adapter {e.adapter_name} not found, skipping bot {bot.uuid}')
|
||
except Exception as e:
|
||
self.ap.logger.error(f'Failed to load bot {bot.uuid}: {e}\n{traceback.format_exc()}')
|
||
|
||
async def load_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_entity: persistence_bot.Bot | sqlalchemy.Row[persistence_bot.Bot] | dict,
|
||
*,
|
||
_binding_validated: bool = False,
|
||
) -> RuntimeBot:
|
||
"""加载机器人"""
|
||
if isinstance(bot_entity, sqlalchemy.Row):
|
||
bot_entity = persistence_bot.Bot(**bot_entity._mapping)
|
||
elif isinstance(bot_entity, dict):
|
||
bot_entity = persistence_bot.Bot(**bot_entity)
|
||
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_entity.uuid)
|
||
if bot_entity.workspace_uuid != execution_context.workspace_uuid:
|
||
raise WorkspaceRequiredError('Bot entity Workspace does not match its runtime context')
|
||
if not _binding_validated:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
if not _binding_validated:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
|
||
logger = EventLogger(
|
||
name=f'platform-adapter-{bot_entity.name}',
|
||
ap=self.ap,
|
||
execution_context=execution_context,
|
||
owner=bot_entity.uuid,
|
||
)
|
||
|
||
bot_entity.adapter = canonical_adapter_name(bot_entity.adapter)
|
||
if bot_entity.adapter not in self.adapter_dict:
|
||
raise platform_errors.AdapterNotFoundError(bot_entity.adapter)
|
||
|
||
adapter_inst = self.adapter_dict[bot_entity.adapter](
|
||
bot_entity.adapter_config,
|
||
logger,
|
||
)
|
||
if hasattr(adapter_inst, 'ap'):
|
||
adapter_inst.ap = self.ap
|
||
|
||
# 如果 adapter 支持 set_bot_uuid 方法,设置 bot_uuid(用于统一 webhook)
|
||
if hasattr(adapter_inst, 'set_bot_uuid'):
|
||
adapter_inst.set_bot_uuid(bot_entity.uuid)
|
||
|
||
runtime_bot = RuntimeBot(
|
||
ap=self.ap,
|
||
bot_entity=bot_entity,
|
||
adapter=adapter_inst,
|
||
logger=logger,
|
||
execution_context=execution_context,
|
||
)
|
||
|
||
await runtime_bot.initialize()
|
||
|
||
bot_key = self._runtime_bot_key(runtime_bot)
|
||
existing_bot = self._bots_by_key.get(bot_key)
|
||
if existing_bot is not None and existing_bot is not runtime_bot:
|
||
try:
|
||
if existing_bot.enable:
|
||
await existing_bot.shutdown()
|
||
except BaseException:
|
||
if runtime_bot.enable:
|
||
await runtime_bot.shutdown()
|
||
raise
|
||
self._register_runtime_bot(runtime_bot)
|
||
|
||
return runtime_bot
|
||
|
||
async def get_bot_by_uuid(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_uuid: str,
|
||
) -> RuntimeBot | None:
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context(execution_context)
|
||
proxy_bot = self.websocket_proxy_bots.get(execution_context.workspace_uuid)
|
||
if proxy_bot and proxy_bot.bot_entity.uuid == bot_uuid:
|
||
if proxy_bot.placement_generation != execution_context.placement_generation:
|
||
return None
|
||
return proxy_bot
|
||
bot = self._bots_by_key.get(
|
||
(
|
||
execution_context.instance_uuid,
|
||
execution_context.workspace_uuid,
|
||
bot_uuid,
|
||
)
|
||
)
|
||
if bot is None or bot.placement_generation != execution_context.placement_generation:
|
||
return None
|
||
return bot
|
||
|
||
async def resolve_public_bot(self, route_key: str) -> RuntimeBot | None:
|
||
"""Resolve an opaque public bot UUID without consulting request headers."""
|
||
|
||
try:
|
||
normalized = str(uuid.UUID(route_key))
|
||
except (ValueError, AttributeError, TypeError):
|
||
return None
|
||
keys = tuple(self._bot_keys_by_uuid.get(normalized, ()))
|
||
if len(keys) != 1:
|
||
return None
|
||
key = keys[0]
|
||
bot = self._bots_by_key.get(key)
|
||
if bot is None:
|
||
return None
|
||
try:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
bot.workspace_uuid,
|
||
expected_generation=bot.placement_generation,
|
||
)
|
||
except Exception:
|
||
return None
|
||
if self._bots_by_key.get(key) is bot:
|
||
return bot
|
||
return None
|
||
|
||
async def remove_bot(
|
||
self,
|
||
context: ExecutionContext | RequestContext,
|
||
bot_uuid: str,
|
||
) -> None:
|
||
execution_context = self._normalize_execution_context(context, bot_uuid=bot_uuid)
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
async with self._runtime_mutation_lock:
|
||
await self.ap.workspace_service.get_execution_binding(
|
||
execution_context.workspace_uuid,
|
||
expected_generation=execution_context.placement_generation,
|
||
)
|
||
await self._observe_execution_context_unlocked(execution_context)
|
||
key = (
|
||
execution_context.instance_uuid,
|
||
execution_context.workspace_uuid,
|
||
bot_uuid,
|
||
)
|
||
bot = self._bots_by_key.get(key)
|
||
if bot is not None and bot.placement_generation == execution_context.placement_generation:
|
||
if bot.enable:
|
||
await bot.shutdown()
|
||
self._pop_runtime_bot(key)
|
||
|
||
def get_available_adapters_info(self) -> list[dict]:
|
||
return [
|
||
component.to_plain_dict() for component in self.adapter_components if component.metadata.name != 'websocket'
|
||
]
|
||
|
||
def get_available_adapter_info_by_name(self, name: str) -> dict | None:
|
||
name = canonical_adapter_name(name)
|
||
for component in self.adapter_components:
|
||
if component.metadata.name == name:
|
||
return component.to_plain_dict()
|
||
return None
|
||
|
||
def get_available_adapter_manifest_by_name(self, name: str) -> engine.Component | None:
|
||
name = canonical_adapter_name(name)
|
||
for component in self.adapter_components:
|
||
if component.metadata.name == name:
|
||
return component
|
||
return None
|
||
|
||
async def run(self):
|
||
# This method will only be called when the application launching
|
||
for proxy_bot in self.websocket_proxy_bots.values():
|
||
await proxy_bot.run()
|
||
|
||
for bot in self.bots:
|
||
if bot.enable:
|
||
await bot.run()
|
||
|
||
async def shutdown(self):
|
||
async with self._runtime_mutation_lock:
|
||
runtime_bots = [*self.websocket_proxy_bots.values(), *self.bots]
|
||
self.websocket_proxy_bots = {}
|
||
self._proxy_last_accessed = {}
|
||
self.bots = []
|
||
for bot in runtime_bots:
|
||
if not bot.enable:
|
||
continue
|
||
try:
|
||
await bot.shutdown()
|
||
except Exception as exc:
|
||
self.ap.logger.warning(f'Failed to stop platform runtime during shutdown: {exc}')
|
||
self.ap.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.PLATFORM)
|