feat(agent): integrate structured runner interactions

This commit is contained in:
fdc310
2026-07-22 11:12:26 +08:00
parent e3df35e7f7
commit 6b13c37c3b
58 changed files with 6153 additions and 319 deletions
-2
View File
@@ -721,7 +721,6 @@ class QQOfficialClient:
"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
@@ -730,7 +729,6 @@ class QQOfficialClient:
url = f'{self.base_url}/channels/{target_id}/messages'
else:
raise ValueError(f'Unsupported target_type for markdown+keyboard: {target_type}')
body: dict[str, Any] = {
'msg_type': 2,
'markdown': {'content': markdown_content},
@@ -430,6 +430,8 @@ class WecomBotWsClient:
card_payload: ``{"msgtype": "template_card", "template_card": {...}}``
as produced by :func:`build_button_interaction_payload`.
"""
if card_payload.get('msgtype') != 'template_card' or not isinstance(card_payload.get('template_card'), dict):
raise ValueError('invalid WeCom template_card payload')
req_id = _generate_req_id(CMD_SEND_MSG)
body = dict(card_payload)
body['chatid'] = chat_id
@@ -30,16 +30,10 @@ class AgentBindingResolver:
Agent and does not carry enough scope metadata to make that decision
safely here.
"""
matches = [
agent
for agent in agents
if agent.enabled and event.event_type in agent.event_types
]
matches = [agent for agent in agents if agent.enabled and event.event_type in agent.event_types]
if not matches:
raise AgentBindingResolutionError(
f'No Agent binding matches event_type={event.event_type}'
)
raise AgentBindingResolutionError(f'No Agent binding matches event_type={event.event_type}')
if len(matches) > 1:
agent_ids = ', '.join(agent.agent_id or '<anonymous>' for agent in matches)
@@ -57,7 +51,7 @@ class AgentBindingResolver:
)
return AgentBinding(
binding_id=f"agent_{agent.agent_id or 'default'}_{agent.runner_id}",
binding_id=f'agent_{agent.agent_id or "default"}_{agent.runner_id}',
scope=scope,
event_types=list(agent.event_types),
runner_id=agent.runner_id,
@@ -67,4 +61,6 @@ class AgentBindingResolver:
delivery_policy=agent.delivery_policy,
enabled=agent.enabled,
agent_id=agent.agent_id,
processor_type=agent.processor_type,
processor_id=agent.processor_id or agent.agent_id,
)
@@ -45,6 +45,7 @@ class AgentInput(typing.TypedDict):
text: str | None
contents: list[dict[str, typing.Any]]
attachments: list[dict[str, typing.Any]]
interaction: typing.NotRequired[dict[str, typing.Any] | None]
class AgentRunState(typing.TypedDict):
@@ -299,6 +300,11 @@ class AgentRunContextBuilder:
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
],
}
if getattr(event.input, 'interaction', None) is not None:
interaction = event.input.interaction
input['interaction'] = (
interaction.model_dump(mode='json') if hasattr(interaction, 'model_dump') else interaction
)
# Build context access (no history inlined by default for Protocol v1)
# Populate with actual values from stores
@@ -333,6 +339,11 @@ class AgentRunContextBuilder:
'max_message_size': event.delivery.max_message_size,
'platform_capabilities': event.delivery.platform_capabilities,
}
if getattr(event.delivery, 'interactions', None) is not None:
interactions = event.delivery.interactions
delivery_context['interactions'] = (
interactions.model_dump(mode='json') if hasattr(interactions, 'model_dump') else interactions
)
# Build adapter context (empty for event-first)
adapter_context = {
+5
View File
@@ -1,4 +1,5 @@
"""Canonical AgentRunner event names reserved for future EBA integration."""
from __future__ import annotations
@@ -14,6 +15,9 @@ GROUP_MEMBER_JOINED = 'group.member_joined'
FRIEND_REQUEST_RECEIVED = 'friend.request_received'
"""A new friend/contact request was received."""
INTERACTION_SUBMITTED = 'interaction.submitted'
"""A user submitted a Host-rendered structured interaction."""
RESERVED_EVENT_TYPES = frozenset(
{
@@ -21,5 +25,6 @@ RESERVED_EVENT_TYPES = frozenset(
MESSAGE_RECALLED,
GROUP_MEMBER_JOINED,
FRIEND_REQUEST_RECEIVED,
INTERACTION_SUBMITTED,
}
)
@@ -139,6 +139,9 @@ class DeliveryPolicy(pydantic.BaseModel):
enable_reply: bool = True
"""Whether reply is enabled."""
enable_interactions: bool = False
"""Whether the binding permits structured interaction delivery."""
max_message_size: int | None = None
"""Maximum message size."""
@@ -154,6 +157,12 @@ class AgentConfig(pydantic.BaseModel):
agent_id: str | None = None
"""Host-side Agent/config identifier."""
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
"""Product processor kind represented by this runtime config."""
processor_id: str | None = None
"""Stable product processor identifier."""
runner_id: str
"""Runner ID to invoke."""
@@ -215,3 +224,9 @@ class AgentBinding(pydantic.BaseModel):
agent_id: str | None = None
"""Host-side Agent/config identifier for this binding."""
processor_type: typing.Literal['agent', 'pipeline'] = 'agent'
"""Product processor kind selected for this binding."""
processor_id: str | None = None
"""Stable product processor identifier used for callback resume."""
@@ -0,0 +1,441 @@
"""Host validation, persistence, and delivery for structured interactions."""
from __future__ import annotations
import json
import time
import typing
import pydantic
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from .descriptor import AgentRunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
from .interaction_store import InteractionStore
INTERACTION_REQUESTED_ACTION = 'interaction.requested'
INTERACTION_DELIVERY_API = 'interaction.request'
INTERACTION_ACKNOWLEDGE_API = 'interaction.acknowledge'
INTERACTION_PAYLOAD_MAX_BYTES = 256 * 1024
DEFAULT_INTERACTION_TTL_SECONDS = 30 * 60
MAX_INTERACTION_TTL_SECONDS = 24 * 60 * 60
class HostInteractionOption(pydantic.BaseModel):
"""Host-validated selectable interaction value."""
value: str = pydantic.Field(min_length=1, max_length=512)
label: str = pydantic.Field(min_length=1, max_length=512)
description: str | None = pydantic.Field(default=None, max_length=2000)
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionField(pydantic.BaseModel):
"""Host-validated structured interaction field."""
id: str = pydantic.Field(min_length=1, max_length=128)
label: str = pydantic.Field(min_length=1, max_length=512)
type: typing.Literal['text', 'textarea', 'select', 'multiselect', 'number', 'boolean', 'file']
required: bool = False
options: list[HostInteractionOption] = pydantic.Field(default_factory=list, max_length=100)
placeholder: str | None = pydantic.Field(default=None, max_length=2000)
default: typing.Any = None
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionAction(pydantic.BaseModel):
"""Host-validated interaction submit action."""
id: str = pydantic.Field(min_length=1, max_length=128)
label: str = pydantic.Field(min_length=1, max_length=512)
style: typing.Literal['default', 'primary', 'danger'] = 'default'
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionRequest(pydantic.BaseModel):
"""Defensive Host projection of the SDK interaction request contract."""
interaction_id: str = pydantic.Field(min_length=1, max_length=255)
kind: typing.Literal['form', 'confirmation', 'choice'] = 'form'
title: str = pydantic.Field(min_length=1, max_length=1000)
description: str | None = pydantic.Field(default=None, max_length=10000)
fields: list[HostInteractionField] = pydantic.Field(default_factory=list, max_length=50)
actions: list[HostInteractionAction] = pydantic.Field(default_factory=list, max_length=20)
expires_at: int | None = None
fallback_text: str = pydantic.Field(min_length=1, max_length=20000)
model_config = pydantic.ConfigDict(extra='forbid')
class HostInteractionSubmission(pydantic.BaseModel):
"""Host-normalized platform callback payload."""
interaction_id: str = pydantic.Field(min_length=1, max_length=255)
action_id: str | None = pydantic.Field(default=None, max_length=128)
values: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
submitted_at: int | None = None
model_config = pydantic.ConfigDict(extra='forbid')
class InteractionManager:
"""Consume the interaction action whitelist for one Host application."""
def __init__(self, ap: typing.Any, store: InteractionStore | None = None):
self.ap = ap
self.store = store or InteractionStore(ap.persistence_mgr.get_db_engine())
async def handle_result(
self,
*,
result_dict: dict[str, typing.Any],
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
run_id: str,
adapter_context: dict[str, typing.Any] | None,
) -> bool:
"""Execute an authorized interaction request; return whether it was consumed."""
data = result_dict.get('data')
if not isinstance(data, dict) or data.get('action') != INTERACTION_REQUESTED_ACTION:
return False
self._authorize(descriptor, binding)
payload = data.get('payload')
try:
request = HostInteractionRequest.model_validate(payload)
except pydantic.ValidationError as exc:
raise RunnerProtocolError(descriptor.id, f'invalid interaction request: {exc}') from exc
processor_id = binding.processor_id or binding.agent_id
if not processor_id:
raise RunnerProtocolError(descriptor.id, 'interaction request has no stable processor identity')
adapter = self._resolve_adapter(adapter_context)
if adapter is None:
raise RunnerProtocolError(descriptor.id, 'interaction request has no delivery adapter')
request_data = request.model_dump(mode='json')
now = int(time.time())
expires_at = now + DEFAULT_INTERACTION_TTL_SECONDS if request.expires_at is None else request.expires_at
if expires_at <= now:
raise RunnerProtocolError(descriptor.id, 'interaction request is already expired')
if expires_at > now + MAX_INTERACTION_TTL_SECONDS:
raise RunnerProtocolError(descriptor.id, 'interaction request expiry exceeds 24 hours')
request_data['expires_at'] = expires_at
if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES:
raise RunnerProtocolError(descriptor.id, 'interaction request exceeds 256 KiB')
self._validate_request_identity(request, descriptor.id)
reply_target = event.delivery.reply_target or {}
target_type = reply_target.get('target_type')
target_id = reply_target.get('target_id')
callback_conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id
update_target = await self._find_update_target(
event=event,
binding=binding,
descriptor=descriptor,
processor_id=processor_id,
conversation_id=callback_conversation_id,
)
_, callback_token = await self.store.create_request(
interaction_id=request.interaction_id,
run_id=run_id,
binding_id=binding.binding_id,
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
request=request_data,
delivery_target=reply_target,
replaces_interaction_id=(update_target or {}).get('interaction_id'),
bot_id=event.bot_id,
workspace_id=event.workspace_id,
conversation_id=callback_conversation_id,
thread_id=event.thread_id,
actor_id=event.actor.actor_id if event.actor else None,
expires_at=expires_at,
)
try:
if self._supports_interaction_delivery(adapter):
delivery_params = {
'request': request_data,
'callback_token': callback_token,
'reply_target': event.delivery.reply_target,
}
if update_target is not None:
delivery_params['update_target'] = update_target.get('delivery_result')
try:
delivery_result = await adapter.call_platform_api(
INTERACTION_DELIVERY_API,
delivery_params,
)
except Exception as exc:
if update_target is None:
raise
self._warning(f'Interaction update failed; sending a new presentation: {exc}')
fallback_params = dict(delivery_params)
fallback_params.pop('update_target', None)
delivery_result = await adapter.call_platform_api(
INTERACTION_DELIVERY_API,
fallback_params,
)
if isinstance(delivery_result, dict):
try:
await self.store.record_delivery_success(
run_id,
request.interaction_id,
delivery_result,
)
except Exception as exc:
self._warning(f'Failed to persist interaction delivery result: {exc}')
else:
await self._deliver_fallback(adapter, event, request.fallback_text)
except Exception as exc:
await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc))
raise
return True
async def acknowledge_submission(self, record: dict[str, typing.Any], adapter: typing.Any) -> None:
"""Best-effort transition of submitted controls into a read-only state."""
delivery_result = record.get('delivery_result')
if not isinstance(delivery_result, dict) or not self._supports_platform_api(
adapter, INTERACTION_ACKNOWLEDGE_API
):
return
try:
delivery_result = await adapter.call_platform_api(
INTERACTION_ACKNOWLEDGE_API,
{
'request': record.get('request') or {},
'submission': record.get('submission') or {},
'update_target': delivery_result,
},
)
if isinstance(delivery_result, dict):
await self.store.record_delivery_success(
record['run_id'],
record['interaction_id'],
delivery_result,
)
except Exception as exc:
self._warning(f'Failed to acknowledge interaction submission: {exc}')
async def _find_update_target(
self,
*,
event: AgentEventEnvelope,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
processor_id: str,
conversation_id: str | None,
) -> dict[str, typing.Any] | None:
capabilities = getattr(event.delivery, 'interactions', None)
if not capabilities or not getattr(capabilities, 'supports_updates', False):
return None
prior = getattr(event.input, 'interaction', None)
prior_interaction_id = getattr(prior, 'interaction_id', None)
if not prior_interaction_id:
return None
return await self.store.find_update_target(
interaction_id=str(prior_interaction_id),
binding_id=binding.binding_id,
runner_id=descriptor.id,
processor_type=binding.processor_type,
processor_id=processor_id,
bot_id=event.bot_id,
conversation_id=conversation_id,
actor_id=event.actor.actor_id if event.actor else None,
)
@staticmethod
def _validate_request_identity(request: HostInteractionRequest, runner_id: str) -> None:
field_ids = [field.id for field in request.fields]
action_ids = [action.id for action in request.actions]
if len(field_ids) != len(set(field_ids)):
raise RunnerProtocolError(runner_id, 'interaction request has duplicate field IDs')
if len(action_ids) != len(set(action_ids)):
raise RunnerProtocolError(runner_id, 'interaction request has duplicate action IDs')
for field in request.fields:
if field.type in {'select', 'multiselect'} and not field.options:
raise RunnerProtocolError(
runner_id,
f'interaction field {field.id} requires at least one option',
)
option_values = [option.value for option in field.options]
if len(option_values) != len(set(option_values)):
raise RunnerProtocolError(
runner_id,
f'interaction field {field.id} has duplicate option values',
)
async def consume_callback(
self,
*,
callback_token: str,
submission: dict[str, typing.Any],
bot_id: str | None,
conversation_id: str | None,
actor_id: str | None,
) -> dict[str, typing.Any]:
"""Validate a platform callback and return the persisted resume target."""
pending = await self.store.get_by_callback_token(callback_token)
if pending is None:
from .interaction_store import InteractionNotFoundError
raise InteractionNotFoundError('Unknown interaction callback token')
normalized_submission = self._resolve_submission_refs(pending, submission)
try:
normalized = HostInteractionSubmission.model_validate(normalized_submission)
except pydantic.ValidationError as exc:
raise ValueError(f'invalid interaction submission: {exc}') from exc
self._validate_submission(pending, normalized)
submission_data = normalized.model_dump(mode='json')
submission_data['submitted_at'] = int(time.time())
if len(json.dumps(submission_data, ensure_ascii=False).encode('utf-8')) > 256 * 1024:
raise ValueError('interaction submission exceeds 256 KiB')
return await self.store.consume_submission(
callback_token=callback_token,
submission=submission_data,
bot_id=bot_id,
conversation_id=conversation_id,
actor_id=actor_id,
submitted_at=submission_data['submitted_at'],
)
@staticmethod
def _resolve_submission_refs(
pending: dict[str, typing.Any],
submission: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Resolve compact platform indexes against the persisted request."""
resolved = dict(submission)
resolved['interaction_id'] = pending['interaction_id']
request = pending.get('request') if isinstance(pending.get('request'), dict) else {}
action_ref = resolved.pop('action_ref', None)
if action_ref is not None:
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
try:
action = actions[InteractionManager._callback_index(action_ref, 'action')]
except (IndexError, TypeError, ValueError) as exc:
raise ValueError('interaction action reference is invalid') from exc
if not isinstance(action, dict) or not action.get('id'):
raise ValueError('interaction action reference has no action ID')
resolved['action_id'] = str(action['id'])
field_ref = resolved.pop('field_ref', None)
option_ref = resolved.pop('option_ref', None)
if field_ref is not None or option_ref is not None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
try:
field = fields[InteractionManager._callback_index(field_ref, 'field')]
option = field['options'][InteractionManager._callback_index(option_ref, 'option')]
except (IndexError, KeyError, TypeError, ValueError) as exc:
raise ValueError('interaction field option reference is invalid') from exc
if not isinstance(field, dict) or not field.get('id') or not isinstance(option, dict):
raise ValueError('interaction field option reference is malformed')
resolved['values'] = {str(field['id']): option.get('value')}
return resolved
@staticmethod
def _callback_index(value: typing.Any, label: str) -> int:
if isinstance(value, bool):
raise ValueError(f'interaction {label} reference is not an integer')
if isinstance(value, int):
index = value
elif isinstance(value, str) and value.isdigit():
index = int(value)
else:
raise ValueError(f'interaction {label} reference is not an integer')
if index < 0:
raise ValueError(f'interaction {label} reference is negative')
return index
@staticmethod
def _validate_submission(
pending: dict[str, typing.Any],
submission: HostInteractionSubmission,
) -> None:
request = pending.get('request') if isinstance(pending.get('request'), dict) else {}
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
action_ids = {str(action.get('id')) for action in actions if isinstance(action, dict) and action.get('id')}
if submission.action_id is not None and submission.action_id not in action_ids:
raise ValueError('interaction submission action is not present in the request')
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
fields_by_id = {str(field.get('id')): field for field in fields if isinstance(field, dict) and field.get('id')}
for field_id, value in submission.values.items():
field = fields_by_id.get(field_id)
if field is None:
raise ValueError(f'interaction submission field is not present in the request: {field_id}')
field_type = field.get('type')
if field_type not in {'select', 'multiselect'}:
continue
options = field.get('options') if isinstance(field.get('options'), list) else []
allowed_values = {
option.get('value')
for option in options
if isinstance(option, dict) and option.get('value') is not None
}
selected_values = value if field_type == 'multiselect' and isinstance(value, list) else [value]
if field_type == 'multiselect' and not isinstance(value, list):
raise ValueError(f'interaction multiselect field requires a list: {field_id}')
if any(selected not in allowed_values for selected in selected_values):
raise ValueError(f'interaction submission option is not present in the request: {field_id}')
@staticmethod
def _authorize(descriptor: AgentRunnerDescriptor, binding: AgentBinding) -> None:
supports_interactions = bool(getattr(descriptor.capabilities, 'interactions', False))
permissions = set(getattr(descriptor.permissions, 'interactions', []) or [])
if not supports_interactions or 'request' not in permissions:
raise RunnerProtocolError(descriptor.id, 'runner did not declare interaction capability and permission')
if not binding.delivery_policy.enable_interactions:
raise RunnerProtocolError(descriptor.id, 'binding delivery policy does not allow interactions')
@staticmethod
def _resolve_adapter(adapter_context: dict[str, typing.Any] | None) -> typing.Any | None:
if not adapter_context:
return None
query = adapter_context.get('_query')
if query is not None and getattr(query, 'adapter', None) is not None:
return query.adapter
return adapter_context.get('_delivery_adapter')
@staticmethod
def _supports_interaction_delivery(adapter: typing.Any) -> bool:
return InteractionManager._supports_platform_api(adapter, INTERACTION_DELIVERY_API)
@staticmethod
def _supports_platform_api(adapter: typing.Any, api_name: str) -> bool:
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
if not callable(get_supported_apis):
return False
try:
return api_name in (get_supported_apis() or [])
except Exception:
return False
def _warning(self, message: str) -> None:
logger = getattr(self.ap, 'logger', None)
warning = getattr(logger, 'warning', None)
if callable(warning):
warning(message)
@staticmethod
async def _deliver_fallback(adapter: typing.Any, event: AgentEventEnvelope, text: str) -> None:
target = event.delivery.reply_target or {}
target_type = target.get('target_type')
target_id = target.get('target_id')
if not target_type or not target_id:
raise ValueError('interaction fallback has no reply target')
await adapter.send_message(
str(target_type),
str(target_id),
platform_message.MessageChain([platform_message.Plain(text=text)]),
)
@@ -0,0 +1,407 @@
"""Persistent Host store for structured interaction requests."""
from __future__ import annotations
import datetime
import hashlib
import json
import secrets
import typing
import sqlalchemy
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.orm import sessionmaker
from ...entity.persistence.agent_interaction import AgentInteraction
UTC = datetime.timezone.utc
INTERACTION_STATUSES = {'pending', 'submitted', 'expired', 'cancelled', 'delivery_failed'}
PROCESSOR_TYPES = {'agent', 'pipeline'}
class InteractionStoreError(Exception):
"""Base error for interaction persistence operations."""
class InteractionNotFoundError(InteractionStoreError):
"""Raised when a callback token does not identify an interaction."""
class InteractionScopeError(InteractionStoreError):
"""Raised when a callback does not belong to the stored delivery scope."""
class InteractionExpiredError(InteractionStoreError):
"""Raised when an interaction is no longer accepting submissions."""
class InteractionAlreadySubmittedError(InteractionStoreError):
"""Raised when an interaction callback is replayed."""
class DuplicateInteractionError(InteractionStoreError):
"""Raised when one run emits the same interaction ID more than once."""
def _utc_now() -> datetime.datetime:
return datetime.datetime.now(UTC)
def _as_utc(value: datetime.datetime | None) -> datetime.datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def _epoch_to_datetime(value: int | float | None) -> datetime.datetime | None:
if value is None:
return None
try:
return datetime.datetime.fromtimestamp(float(value), UTC)
except (TypeError, ValueError, OSError) as exc:
raise ValueError('expires_at must be a valid epoch timestamp') from exc
def _datetime_to_epoch(value: datetime.datetime | None) -> int | None:
normalized = _as_utc(value)
return int(normalized.timestamp()) if normalized is not None else None
def _json_dumps(value: typing.Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
def _json_loads(value: str | None, default: typing.Any) -> typing.Any:
if not value:
return default
try:
return json.loads(value)
except (TypeError, ValueError):
return default
def _token_hash(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
class InteractionStore:
"""Store interaction correlation and consume callbacks exactly once."""
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def create_request(
self,
*,
interaction_id: str,
run_id: str,
binding_id: str,
runner_id: str,
processor_type: str,
processor_id: str,
request: dict[str, typing.Any],
delivery_target: dict[str, typing.Any] | None,
replaces_interaction_id: str | None = None,
bot_id: str | None = None,
workspace_id: str | None = None,
conversation_id: str | None = None,
thread_id: str | None = None,
actor_id: str | None = None,
expires_at: int | float | None = None,
) -> tuple[dict[str, typing.Any], str]:
"""Persist a request and return its record plus one-time callback token."""
if not interaction_id or not run_id or not binding_id or not runner_id or not processor_id:
raise ValueError('interaction, run, binding, runner, and processor IDs are required')
if processor_type not in PROCESSOR_TYPES:
raise ValueError(f'Unsupported processor type: {processor_type}')
request_json = _json_dumps(request)
delivery_target_json = _json_dumps(delivery_target) if delivery_target is not None else None
expires_at_dt = _epoch_to_datetime(expires_at)
now = _utc_now()
if expires_at_dt is not None and expires_at_dt <= now:
raise ValueError('expires_at must be in the future')
callback_token = secrets.token_urlsafe(18)
row = AgentInteraction(
interaction_id=interaction_id,
run_id=run_id,
binding_id=binding_id,
runner_id=runner_id,
processor_type=processor_type,
processor_id=processor_id,
bot_id=bot_id,
workspace_id=workspace_id,
conversation_id=conversation_id,
thread_id=thread_id,
actor_id=actor_id,
status='pending',
request_json=request_json,
delivery_target_json=delivery_target_json,
replaces_interaction_id=replaces_interaction_id,
callback_token_hash=_token_hash(callback_token),
expires_at=expires_at_dt,
created_at=now,
updated_at=now,
)
async with self._session_factory() as session:
existing = await self._get_by_run_interaction(session, run_id, interaction_id)
if existing is not None:
raise DuplicateInteractionError(f'Interaction {interaction_id} already exists for run {run_id}')
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateInteractionError(
f'Interaction {interaction_id} could not be created for run {run_id}'
) from exc
return self._to_dict(row), callback_token
async def record_delivery_success(
self,
run_id: str,
interaction_id: str,
delivery_result: dict[str, typing.Any],
) -> bool:
"""Persist the platform presentation handle returned after delivery."""
payload = _json_dumps(delivery_result)
if len(payload.encode('utf-8')) > 64 * 1024:
raise ValueError('interaction delivery result exceeds 64 KiB')
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(
AgentInteraction.run_id == run_id,
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status.in_(['pending', 'submitted']),
)
.values(delivery_result_json=payload, updated_at=_utc_now())
)
await session.commit()
return result.rowcount == 1
async def find_update_target(
self,
*,
interaction_id: str,
binding_id: str,
runner_id: str,
processor_type: str,
processor_id: str,
bot_id: str | None,
conversation_id: str | None,
actor_id: str | None,
) -> dict[str, typing.Any] | None:
"""Find a submitted interaction whose platform presentation can be replaced."""
if not interaction_id:
return None
conditions = [
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.binding_id == binding_id,
AgentInteraction.runner_id == runner_id,
AgentInteraction.processor_type == processor_type,
AgentInteraction.processor_id == processor_id,
AgentInteraction.status == 'submitted',
AgentInteraction.delivery_result_json.is_not(None),
]
for column, value in (
(AgentInteraction.bot_id, bot_id),
(AgentInteraction.conversation_id, conversation_id),
(AgentInteraction.actor_id, actor_id),
):
conditions.append(column.is_(None) if value is None else column == value)
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(AgentInteraction)
.where(*conditions)
.order_by(AgentInteraction.submitted_at.desc(), AgentInteraction.id.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else None
async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None:
"""Return a request by the runner-visible identity."""
async with self._session_factory() as session:
row = await self._get_by_run_interaction(session, run_id, interaction_id)
return self._to_dict(row) if row is not None else None
async def get_by_callback_token(self, callback_token: str) -> dict[str, typing.Any] | None:
"""Resolve callback metadata without exposing the stored token hash."""
if not callback_token:
return None
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(AgentInteraction).where(
AgentInteraction.callback_token_hash == _token_hash(callback_token)
)
)
row = result.scalar_one_or_none()
return self._to_dict(row) if row is not None else None
async def consume_submission(
self,
*,
callback_token: str,
submission: dict[str, typing.Any],
bot_id: str | None,
conversation_id: str | None,
actor_id: str | None,
submitted_at: int | float | None = None,
) -> dict[str, typing.Any]:
"""Validate and atomically consume one platform callback."""
if not callback_token:
raise InteractionNotFoundError('Missing interaction callback token')
now = _utc_now()
submission_time = _epoch_to_datetime(submitted_at) or now
token_hash = _token_hash(callback_token)
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(AgentInteraction).where(AgentInteraction.callback_token_hash == token_hash)
)
row = result.scalar_one_or_none()
if row is None:
raise InteractionNotFoundError('Unknown interaction callback token')
if row.status == 'submitted':
raise InteractionAlreadySubmittedError('Interaction was already submitted')
if row.status != 'pending':
raise InteractionExpiredError(f'Interaction is not pending: {row.status}')
if submission.get('interaction_id') != row.interaction_id:
raise InteractionScopeError('Interaction ID does not match callback token')
expires_at = _as_utc(row.expires_at)
if expires_at is not None and expires_at <= now:
row.status = 'expired'
row.status_reason = 'interaction expired before submission'
row.updated_at = now
await session.commit()
raise InteractionExpiredError('Interaction has expired')
self._validate_scope(row, bot_id, conversation_id, actor_id)
update_result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(
AgentInteraction.id == row.id,
AgentInteraction.status == 'pending',
)
.values(
status='submitted',
submitted_at=submission_time,
submission_json=_json_dumps(submission),
updated_at=now,
)
)
if update_result.rowcount != 1:
await session.rollback()
raise InteractionAlreadySubmittedError('Interaction callback lost an idempotency race')
await session.commit()
refreshed = await session.get(AgentInteraction, row.id)
if refreshed is None:
raise InteractionNotFoundError('Interaction disappeared after submission')
return self._to_dict(refreshed)
async def mark_delivery_failed(self, run_id: str, interaction_id: str, reason: str) -> bool:
"""Mark an undelivered pending interaction terminal."""
now = _utc_now()
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(
AgentInteraction.run_id == run_id,
AgentInteraction.interaction_id == interaction_id,
AgentInteraction.status == 'pending',
)
.values(status='delivery_failed', status_reason=reason, updated_at=now)
)
await session.commit()
return result.rowcount == 1
async def expire_pending(self, *, now: datetime.datetime | None = None) -> int:
"""Expire pending interactions whose deadline has elapsed."""
cutoff = _as_utc(now) or _utc_now()
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.update(AgentInteraction)
.where(
AgentInteraction.status == 'pending',
AgentInteraction.expires_at.is_not(None),
AgentInteraction.expires_at <= cutoff,
)
.values(
status='expired',
status_reason='interaction expired',
updated_at=cutoff,
)
)
await session.commit()
return int(result.rowcount or 0)
@staticmethod
def _validate_scope(
row: AgentInteraction,
bot_id: str | None,
conversation_id: str | None,
actor_id: str | None,
) -> None:
checks = (
('bot', row.bot_id, bot_id),
('conversation', row.conversation_id, conversation_id),
('actor', row.actor_id, actor_id),
)
for label, expected, actual in checks:
if expected is not None and expected != actual:
raise InteractionScopeError(f'Interaction {label} scope mismatch')
@staticmethod
async def _get_by_run_interaction(
session: AsyncSession,
run_id: str,
interaction_id: str,
) -> AgentInteraction | None:
result = await session.execute(
sqlalchemy.select(AgentInteraction).where(
AgentInteraction.run_id == run_id,
AgentInteraction.interaction_id == interaction_id,
)
)
return result.scalar_one_or_none()
@staticmethod
def _to_dict(row: AgentInteraction) -> dict[str, typing.Any]:
return {
'id': row.id,
'interaction_id': row.interaction_id,
'run_id': row.run_id,
'binding_id': row.binding_id,
'runner_id': row.runner_id,
'processor_type': row.processor_type,
'processor_id': row.processor_id,
'bot_id': row.bot_id,
'workspace_id': row.workspace_id,
'conversation_id': row.conversation_id,
'thread_id': row.thread_id,
'actor_id': row.actor_id,
'status': row.status,
'request': _json_loads(row.request_json, {}),
'delivery_target': _json_loads(row.delivery_target_json, None),
'delivery_result': _json_loads(row.delivery_result_json, None),
'replaces_interaction_id': row.replaces_interaction_id,
'expires_at': _datetime_to_epoch(row.expires_at),
'submitted_at': _datetime_to_epoch(row.submitted_at),
'submission': _json_loads(row.submission_json, None),
'status_reason': row.status_reason,
'created_at': _datetime_to_epoch(row.created_at),
'updated_at': _datetime_to_epoch(row.updated_at),
}
@@ -22,6 +22,7 @@ from .execution_context import (
)
from .host_models import AgentBinding, AgentEventEnvelope
from .invoker import AgentRunnerInvoker
from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge
from .registry import AgentRunnerRegistry
from .resource_builder import AgentResourceBuilder
@@ -51,6 +52,7 @@ class AgentRunOrchestrator:
binding_resolver: AgentBindingResolver
query_bridge: QueryRunBridge
invoker: AgentRunnerInvoker
interaction_manager: InteractionManager
journal: AgentRunJournal
_session_registry: AgentRunSessionRegistry
@@ -67,6 +69,7 @@ class AgentRunOrchestrator:
self.binding_resolver = AgentBindingResolver()
self.query_bridge = QueryRunBridge(self.binding_resolver)
self.invoker = AgentRunnerInvoker(ap)
self.interaction_manager = InteractionManager(ap)
self.journal = AgentRunJournal(ap)
self._session_registry = get_session_registry()
@@ -130,6 +133,8 @@ class AgentRunOrchestrator:
run_authorization = {
'runner_id': descriptor.id,
'binding_id': binding.binding_id,
'processor_type': binding.processor_type,
'processor_id': binding.processor_id,
'plugin_identity': descriptor.get_plugin_id(),
'resources': resources,
'available_apis': available_apis,
@@ -252,6 +257,17 @@ class AgentRunOrchestrator:
await self.result_normalizer.normalize(result_dict, descriptor)
continue
if result_type == 'action.requested':
if await self.interaction_manager.handle_result(
result_dict=result_dict,
event=event,
binding=binding,
descriptor=descriptor,
run_id=run_id,
adapter_context=adapter_context,
):
continue
if result_type == 'run.completed':
terminal_status = 'completed'
terminal_reason = (
@@ -7,6 +7,7 @@ Protocol v1 architecture without exposing Query internals to runners.
from __future__ import annotations
import hashlib
import math
import typing
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
@@ -129,16 +130,25 @@ class QueryEntryAdapter:
delivery_policy = DeliveryPolicy(
enable_streaming=True,
enable_reply=True,
enable_interactions=True,
)
variables = getattr(query, 'variables', None) or {}
event_type = (
runner_events.INTERACTION_SUBMITTED
if isinstance(variables.get('_interaction_submission'), dict)
else runner_events.MESSAGE_RECEIVED
)
return AgentConfig(
agent_id=agent_id,
processor_type='pipeline',
processor_id=agent_id,
runner_id=runner_id,
runner_config=runner_config,
resource_policy=resource_policy,
state_policy=state_policy,
delivery_policy=delivery_policy,
event_types=[runner_events.MESSAGE_RECEIVED],
event_types=[event_type],
enabled=True,
metadata={'source': 'pipeline_adapter'},
)
@@ -217,22 +227,37 @@ class QueryEntryAdapter:
if message_id == -1:
message_id = None
event_time = None
if message_event:
event_time = getattr(message_event, 'time', None)
if isinstance(event_time, (int, float)):
event_time = int(event_time)
event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None)
variables = getattr(query, 'variables', None) or {}
interaction = variables.get('_interaction_submission')
event_type = runner_events.MESSAGE_RECEIVED
if isinstance(interaction, dict):
event_type = runner_events.INTERACTION_SUBMITTED
event_data['interaction'] = interaction
source_event_id = str(message_id or query.query_id)
return AgentEventContext(
event_id=cls._build_scoped_event_id(query, source_event_id, event_time),
event_type=runner_events.MESSAGE_RECEIVED,
event_type=event_type,
event_time=event_time,
source='host_adapter',
source_event_type=source_event_type,
data=event_data,
)
@staticmethod
def _normalize_event_time(value: typing.Any) -> int | None:
"""Normalize legacy second/millisecond/microsecond Unix timestamps."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
timestamp = float(value)
if not math.isfinite(timestamp):
return None
while abs(timestamp) > 10_000_000_000:
timestamp /= 1000
return int(timestamp)
@classmethod
def _compact_event_data(
cls,
@@ -370,6 +395,15 @@ class QueryEntryAdapter:
if launcher_type is not None:
launcher_type_value = getattr(launcher_type, 'value', launcher_type)
variables = getattr(query, 'variables', None) or {}
interaction = variables.get('_interaction_submission')
if isinstance(interaction, dict):
return SubjectContext(
subject_type='interaction',
subject_id=str(interaction.get('interaction_id') or ''),
data={'action_id': interaction.get('action_id')},
)
return SubjectContext(
subject_type='message',
subject_id=str(message_id or query_id or ''),
@@ -435,11 +469,16 @@ class QueryEntryAdapter:
attachments = cls._build_attachments(query, contents)
return AgentInput(
text=text,
contents=contents,
attachments=attachments,
)
input_data: dict[str, typing.Any] = {
'text': text,
'contents': contents,
'attachments': attachments,
}
variables = getattr(query, 'variables', None) or {}
interaction = variables.get('_interaction_submission')
if isinstance(interaction, dict) and 'interaction' in AgentInput.model_fields:
input_data['interaction'] = interaction
return AgentInput.model_validate(input_data)
@classmethod
def _build_attachments(
@@ -570,16 +609,42 @@ class QueryEntryAdapter:
) -> DeliveryContext:
"""Build DeliveryContext from Query."""
message_chain = getattr(query, 'message_chain', None)
return DeliveryContext(
surface='platform',
reply_target={
launcher_type = getattr(query, 'launcher_type', None)
target_type = getattr(launcher_type, 'value', launcher_type)
target_id = getattr(query, 'launcher_id', None)
adapter = getattr(query, 'adapter', None)
supported_apis: list[str] = []
get_supported_apis = getattr(adapter, 'get_supported_apis', None)
if callable(get_supported_apis):
try:
declared_apis = get_supported_apis()
if isinstance(declared_apis, (list, tuple, set)):
supported_apis = list(dict.fromkeys(api for api in declared_apis if isinstance(api, str) and api))
except Exception:
pass
delivery_data: dict[str, typing.Any] = {
'surface': 'platform',
'reply_target': {
'target_type': target_type,
'target_id': target_id,
'message_id': getattr(message_chain, 'message_id', None),
},
supports_streaming=True,
supports_edit=False,
supports_reaction=False,
platform_capabilities={},
)
'supports_streaming': True,
'supports_edit': 'edit_message' in supported_apis,
'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)),
'platform_capabilities': {
'adapter': adapter.__class__.__name__ if adapter is not None else None,
'supported_apis': supported_apis,
},
}
if 'interactions' in DeliveryContext.model_fields and 'interaction.request' in supported_apis:
get_capabilities = getattr(adapter, 'get_interaction_capabilities', None)
if callable(get_capabilities):
capabilities = get_capabilities()
if isinstance(capabilities, dict):
delivery_data['interactions'] = capabilities
return DeliveryContext.model_validate(delivery_data)
@classmethod
def _build_raw_ref(
@@ -1,4 +1,5 @@
"""Agent result normalizer for converting AgentRunResult to Pipeline messages."""
from __future__ import annotations
import typing
@@ -57,7 +58,7 @@ class AgentResultNormalizer:
- state.updated
- run.completed
- run.failed
- action.requested (log only, don't execute)
- action.requested (non-whitelisted actions are telemetry only)
"""
ap: app.Application
@@ -91,6 +92,7 @@ class AgentResultNormalizer:
# Validate result size
try:
import json
result_json = json.dumps(result_dict)
if len(result_json) > MAX_RESULT_SIZE_BYTES:
self.ap.logger.warning(
@@ -120,16 +122,12 @@ class AgentResultNormalizer:
elif result_type == 'tool.call.started':
# Log only, don't yield to pipeline
self.ap.logger.debug(
f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}'
)
self.ap.logger.debug(f'Runner {descriptor.id} tool call started: {data.get("tool_name", "unknown")}')
return None
elif result_type == 'tool.call.completed':
# Log only, don't yield to pipeline
self.ap.logger.debug(
f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}'
)
self.ap.logger.debug(f'Runner {descriptor.id} tool call completed: {data.get("tool_name", "unknown")}')
return None
elif result_type == 'state.updated':
@@ -161,10 +159,9 @@ class AgentResultNormalizer:
)
elif result_type == 'action.requested':
# Reserved for EBA - log only, don't execute
# The orchestrator consumes whitelisted actions before normalization.
self.ap.logger.info(
f'Runner {descriptor.id} requested action (not executed in current phase): '
f'{data.get("action", "unknown")}'
f'Runner {descriptor.id} requested unsupported action (not executed): {data.get("action", "unknown")}'
)
return None
@@ -0,0 +1,61 @@
"""Host-owned structured interaction persistence entities."""
from __future__ import annotations
import datetime
import sqlalchemy
from .base import Base
class AgentInteraction(Base):
"""Persist one interaction request and its eventual submission."""
__tablename__ = 'agent_interaction'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
interaction_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
binding_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
runner_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
processor_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
processor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
workspace_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
conversation_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
thread_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
actor_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
request_json = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
delivery_target_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
delivery_result_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
replaces_interaction_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
callback_token_hash = sqlalchemy.Column(sqlalchemy.String(64), nullable=False, unique=True, index=True)
expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
submitted_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
submission_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
status_reason = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
default=datetime.datetime.utcnow,
onupdate=datetime.datetime.utcnow,
)
__table_args__ = (
sqlalchemy.UniqueConstraint('run_id', 'interaction_id', name='uq_agent_interaction_run_interaction'),
sqlalchemy.Index(
'ix_agent_interaction_scope_status',
'bot_id',
'conversation_id',
'actor_id',
'status',
),
sqlalchemy.Index('ix_agent_interaction_processor_status', 'processor_type', 'processor_id', 'status'),
)
@@ -17,6 +17,7 @@ from langbot.pkg.entity.persistence.base import Base
# This is required for autogenerate to detect model changes
from langbot.pkg.entity.persistence import (
agent, # noqa: F401
agent_interaction, # noqa: F401
agent_run, # noqa: F401
agent_runner_state, # noqa: F401
apikey, # noqa: F401
@@ -0,0 +1,83 @@
"""Add Host-owned structured interaction records.
Revision ID: 0013_agent_interactions
Revises: 0012_monitoring_tool_calls
"""
from alembic import op
import sqlalchemy as sa
revision = '0013_agent_interactions'
down_revision = '0012_monitoring_tool_calls'
branch_labels = None
depends_on = None
_INDEXES = {
'ix_agent_interaction_actor_id': (['actor_id'], False),
'ix_agent_interaction_binding_id': (['binding_id'], False),
'ix_agent_interaction_bot_id': (['bot_id'], False),
'ix_agent_interaction_callback_token_hash': (['callback_token_hash'], True),
'ix_agent_interaction_conversation_id': (['conversation_id'], False),
'ix_agent_interaction_expires_at': (['expires_at'], False),
'ix_agent_interaction_processor_id': (['processor_id'], False),
'ix_agent_interaction_processor_status': (['processor_type', 'processor_id', 'status'], False),
'ix_agent_interaction_processor_type': (['processor_type'], False),
'ix_agent_interaction_run_id': (['run_id'], False),
'ix_agent_interaction_runner_id': (['runner_id'], False),
'ix_agent_interaction_scope_status': (['bot_id', 'conversation_id', 'actor_id', 'status'], False),
'ix_agent_interaction_status': (['status'], False),
}
def _table_exists() -> bool:
return 'agent_interaction' in sa.inspect(op.get_bind()).get_table_names()
def _index_names() -> set[str]:
if not _table_exists():
return set()
return {index['name'] for index in sa.inspect(op.get_bind()).get_indexes('agent_interaction')}
def upgrade() -> None:
if not _table_exists():
op.create_table(
'agent_interaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('interaction_id', sa.String(length=255), nullable=False),
sa.Column('run_id', sa.String(length=255), nullable=False),
sa.Column('binding_id', sa.String(length=255), nullable=False),
sa.Column('runner_id', sa.String(length=255), nullable=False),
sa.Column('processor_type', sa.String(length=50), nullable=False),
sa.Column('processor_id', sa.String(length=255), nullable=False),
sa.Column('bot_id', sa.String(length=255), nullable=True),
sa.Column('workspace_id', sa.String(length=255), nullable=True),
sa.Column('conversation_id', sa.String(length=255), nullable=True),
sa.Column('thread_id', sa.String(length=255), nullable=True),
sa.Column('actor_id', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('request_json', sa.Text(), nullable=False),
sa.Column('delivery_target_json', sa.Text(), nullable=True),
sa.Column('callback_token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('submitted_at', sa.DateTime(), nullable=True),
sa.Column('submission_json', sa.Text(), nullable=True),
sa.Column('status_reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('callback_token_hash'),
sa.UniqueConstraint('run_id', 'interaction_id', name='uq_agent_interaction_run_interaction'),
)
existing_indexes = _index_names()
for index_name, (columns, unique) in _INDEXES.items():
if index_name not in existing_indexes:
op.create_index(index_name, 'agent_interaction', columns, unique=unique)
def downgrade() -> None:
if _table_exists():
op.drop_table('agent_interaction')
@@ -0,0 +1,42 @@
"""Persist interaction presentation handles for in-place updates.
Revision ID: 0014_interaction_delivery
Revises: 0013_agent_interactions
"""
from alembic import op
import sqlalchemy as sa
revision = '0014_interaction_delivery'
down_revision = '0013_agent_interactions'
branch_labels = None
depends_on = None
def _column_names() -> set[str]:
bind = op.get_bind()
if 'agent_interaction' not in sa.inspect(bind).get_table_names():
return set()
return {column['name'] for column in sa.inspect(bind).get_columns('agent_interaction')}
def upgrade() -> None:
columns = _column_names()
if not columns:
return
if 'delivery_result_json' not in columns:
op.add_column('agent_interaction', sa.Column('delivery_result_json', sa.Text(), nullable=True))
if 'replaces_interaction_id' not in columns:
op.add_column(
'agent_interaction',
sa.Column('replaces_interaction_id', sa.String(length=255), nullable=True),
)
def downgrade() -> None:
columns = _column_names()
if 'replaces_interaction_id' in columns:
op.drop_column('agent_interaction', 'replaces_interaction_id')
if 'delivery_result_json' in columns:
op.drop_column('agent_interaction', 'delivery_result_json')
+9
View File
@@ -38,6 +38,7 @@ class PendingMessage:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
pipeline_uuid: typing.Optional[str]
routed_by_rule: bool = False
variables: dict[str, typing.Any] | None = None
timestamp: float = field(default_factory=time.time)
@@ -127,6 +128,7 @@ class MessageAggregator:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
variables: dict[str, typing.Any] | None = None,
) -> None:
"""Add a message to the aggregation buffer
@@ -135,6 +137,8 @@ class MessageAggregator:
merged with other messages from the same session.
"""
enabled, delay = await self._get_aggregation_config(pipeline_uuid)
if variables:
enabled = False
if not enabled:
# Aggregation disabled, send directly to query pool
@@ -148,6 +152,7 @@ class MessageAggregator:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
variables=variables,
)
return
@@ -163,6 +168,7 @@ class MessageAggregator:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
variables=variables,
)
force_flush = False
@@ -222,6 +228,7 @@ class MessageAggregator:
adapter=msg.adapter,
pipeline_uuid=msg.pipeline_uuid,
routed_by_rule=msg.routed_by_rule,
variables=msg.variables,
)
return
@@ -237,6 +244,7 @@ class MessageAggregator:
adapter=merged_msg.adapter,
pipeline_uuid=merged_msg.pipeline_uuid,
routed_by_rule=merged_msg.routed_by_rule,
variables=merged_msg.variables,
)
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
@@ -276,6 +284,7 @@ class MessageAggregator:
adapter=base_msg.adapter,
pipeline_uuid=base_msg.pipeline_uuid,
routed_by_rule=any(msg.routed_by_rule for msg in messages),
variables=base_msg.variables,
)
async def flush_all(self) -> None:
+1 -1
View File
@@ -179,7 +179,7 @@ class RuntimePipeline:
bot_message=query.resp_messages[-1],
message=result.user_notice,
quote_origin=query.pipeline_config['output']['misc']['quote-origin'],
is_final=[msg.is_final for msg in query.resp_messages][-1],
is_final=query.resp_messages[-1].is_final,
)
else:
await query.adapter.reply_message(
+1 -1
View File
@@ -42,7 +42,7 @@ class QueryPool:
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
pipeline_uuid: typing.Optional[str] = None,
routed_by_rule: bool = False,
variables: typing.Optional[dict[str, typing.Any]] = None,
variables: dict[str, typing.Any] | None = None,
) -> pipeline_query.Query:
async with self.condition:
query_id = self.query_id_counter
+23 -2
View File
@@ -43,9 +43,12 @@ class SendResponseBackStage(stage.PipelineStage):
response_index = len(query.resp_message_chain) - 1
message_chain = query.resp_message_chain[-1]
is_streaming_response = False
is_final = False
try:
if await query.adapter.is_stream_output_supported() and has_chunks:
is_final = [msg.is_final for msg in query.resp_messages][-1]
is_streaming_response = await query.adapter.is_stream_output_supported() and has_chunks
if is_streaming_response:
is_final = query.resp_messages[-1].is_final
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
@@ -68,6 +71,24 @@ class SendResponseBackStage(stage.PipelineStage):
e,
)
plugin_diagnostics.clear_response_source(query, response_index)
if is_streaming_response:
self.ap.logger.warning(
'Streaming response delivery failed; continuing runner event consumption: %s',
e,
)
if is_final:
try:
await query.adapter.reply_message(
message_source=query.message_event,
message=message_chain,
quote_origin=quote_origin,
)
except Exception as fallback_error:
self.ap.logger.warning(
'Final non-streaming response fallback also failed: %s',
fallback_error,
)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
raise
plugin_diagnostics.clear_response_source(query, response_index)
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin
from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter
from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter
from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.dingtalk.interaction import (
interaction_delivery_capabilities,
interaction_event_from_native,
send_interaction,
)
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
@@ -27,6 +32,9 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
async def process(self, message: dingtalk_stream.CallbackMessage):
callback = dingtalk_stream.CardCallbackMessage.from_dict(message.data or {})
content = dict(callback.content) if isinstance(callback.content, dict) else {}
if isinstance(message.data, dict):
content['_raw_callback'] = message.data
event = DingTalkEvent.from_payload(
{
'conversation_type': 'CardCallback',
@@ -35,7 +43,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler):
'extension': callback.extension,
'corp_id': callback.corp_id,
'user_id': callback.user_id,
'content': callback.content,
'content': content,
'space_id': callback.space_id,
'card_instance_id': callback.card_instance_id,
},
@@ -61,6 +69,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
_message_cache: dict[str, platform_events.MessageReceivedEvent] = {}
_user_cache: dict[str, platform_entities.User] = {}
_group_cache: dict[str, platform_entities.UserGroup] = {}
interaction_callback_contexts: dict[str, dict[str, typing.Any]] = {}
class Config:
arbitrary_types_allowed = True
@@ -89,6 +98,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
_message_cache={},
_user_cache={},
_group_cache={},
interaction_callback_contexts={},
)
self._register_native_handlers()
@@ -100,7 +110,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
]
def get_supported_apis(self) -> list[str]:
return [
apis = [
'send_message',
'reply_message',
'get_message',
@@ -112,6 +122,16 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
'get_file_url',
'call_platform_api',
]
if self.config.get('human_input_card_template_id') and hasattr(self.bot, 'create_and_deliver_card'):
apis.append('interaction.request')
return apis
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
@staticmethod
def _plain_message(text: str) -> platform_message.MessageChain:
return platform_message.MessageChain([platform_message.Plain(text=text)])
async def send_message(
self,
@@ -184,6 +204,8 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
return bool(self.config.get('enable-stream-reply', False))
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request' and action in self.get_supported_apis():
return await send_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
@@ -233,6 +255,10 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
async def _handle_native_event(self, event: DingTalkEvent):
try:
interaction_event = interaction_event_from_native(event, self.interaction_callback_contexts)
if interaction_event is not None:
await self._dispatch_eba_event(interaction_event)
return
await self.logger.debug(
'DingTalk EBA event received: '
f'conversation={event.conversation}, message_id={getattr(event.incoming_message, "message_id", None)}'
@@ -0,0 +1,346 @@
"""DingTalk card rendering and callbacks for structured interactions."""
from __future__ import annotations
import json
import time
import typing
import uuid
from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['text', 'textarea', 'number', 'select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _select_options(options: list[dict[str, typing.Any]]) -> list[dict[str, typing.Any]]:
locales = ['zh_CN', 'zh_TW', 'en_US', 'ja_JP', 'vi_VN', 'th_TH', 'id_ID', 'ms_MY', 'ko_KR']
result = []
for option in options:
value = str(option.get('value') or '')
label = str(option.get('label') or value)
if value:
result.append({'value': value, 'text': {locale: label for locale in locales}})
return result
def _field_card_params(field: dict[str, typing.Any]) -> dict[str, typing.Any] | None:
field_type = str(field.get('type') or '')
field_id = str(field.get('id') or '')
if field_type not in {'text', 'textarea', 'number', 'select'} or not field_id:
return None
label = str(field.get('label') or field_id)
placeholder = str(field.get('placeholder') or label)
default = field.get('default')
params: dict[str, typing.Any] = {
'input_visible': '',
'input_title': '',
'input_placeholder': '',
'input_value': '',
'select_visible': '',
'select_placeholder': '',
'select_options': [],
'index_o': [],
'select_index': -1,
}
if field_type == 'select':
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
options = [option for option in raw_options if isinstance(option, dict)]
encoded_options = _select_options(options)
if not encoded_options:
return None
selected_index = next(
(index for index, option in enumerate(options) if str(option.get('value')) == str(default)),
-1,
)
params.update(
{
'select_visible': 'true',
'select_placeholder': placeholder,
'select_options': [str(option.get('value') or '') for option in options],
'index_o': encoded_options,
'select_index': selected_index,
}
)
else:
params.update(
{
'input_visible': 'true',
'input_title': label,
'input_placeholder': placeholder,
'input_value': '' if default is None else str(default),
}
)
return params
def _prune_callback_contexts(adapter: typing.Any, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
contexts = adapter.interaction_callback_contexts
for key in [key for key, value in contexts.items() if float(value.get('expires_at') or 0) <= now]:
contexts.pop(key, None)
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[dict[str, typing.Any]]:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
items: list[tuple[str, str, str]] = []
if actions and not fields:
items = [
(
str(action.get('label') or action.get('id') or index + 1),
f'lbi:{callback_token}:a:{index}',
str(action.get('style') or 'default'),
)
for index, action in enumerate(actions)
if isinstance(action, dict)
]
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return []
options = field.get('options') if isinstance(field.get('options'), list) else []
items = [
(
str(option.get('label') or option.get('value') or index + 1),
f'lbi:{callback_token}:f:0:{index}',
'default',
)
for index, option in enumerate(options)
if isinstance(option, dict)
]
return [
{
'text': label,
'color': 'blue' if style == 'primary' else 'red' if style == 'danger' else 'gray',
'status': 'normal',
'event': {
'type': 'sendCardRequest',
'params': {'actionId': callback_data, 'params': {'action_id': callback_data}},
},
}
for label, callback_data, style in items
]
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons = _buttons(request, callback_token)
field_params = (
_field_card_params(fields[0]) if len(fields) == 1 and not actions and isinstance(fields[0], dict) else None
)
if not buttons and field_params is None:
fallback = '\n\n'.join(
part
for part in (
str(request.get('title') or '').strip(),
str(request.get('description') or '').strip(),
str(request.get('fallback_text') or '').strip(),
)
if part
)
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
return {'ok': True, 'message_id': result.message_id, 'rich': False}
if target_type not in {'group', 'person'} or not target_id:
raise ValueError('interaction.request has an invalid DingTalk target')
field_label = str(fields[0].get('label') or '').strip() if fields and isinstance(fields[0], dict) else ''
content = '\n\n'.join(
part
for part in (
f'## {str(request.get("title") or "").strip()}',
str(request.get('description') or '').strip(),
field_label,
)
if part
)
out_track_id = uuid.uuid4().hex
card_param_map = {'content': content, 'btns': buttons}
if field_params is not None:
card_param_map.update(field_params)
await adapter.bot.create_and_deliver_card(
card_template_id=adapter.config['human_input_card_template_id'],
out_track_id=out_track_id,
open_space_id=(
f'dtv1.card//IM_GROUP.{target_id}' if target_type == 'group' else f'dtv1.card//IM_ROBOT.{target_id}'
),
is_group=target_type == 'group',
card_param_map=card_param_map,
)
if field_params is not None:
_prune_callback_contexts(adapter)
field = fields[0]
adapter.interaction_callback_contexts[out_track_id] = {
'callback_token': callback_token,
'field_id': str(field.get('id') or ''),
'field_type': str(field.get('type') or ''),
'options': [
str(option.get('value') or '') for option in field.get('options') or [] if isinstance(option, dict)
],
'expires_at': time.monotonic() + 30 * 60,
}
return {'ok': True, 'message_id': out_track_id, 'rich': True}
def parse_callback_data(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid DingTalk interaction callback data')
def _find_callback_data(value: typing.Any) -> str:
if isinstance(value, str):
return value if value.startswith('lbi:') else ''
if isinstance(value, dict):
for key in ('actionId', 'action_id', 'id'):
found = _find_callback_data(value.get(key))
if found:
return found
for child in value.values():
found = _find_callback_data(child)
if found:
return found
if isinstance(value, list):
for child in value:
found = _find_callback_data(child)
if found:
return found
return ''
def _find_named_value(value: typing.Any, names: set[str]) -> typing.Any:
if isinstance(value, dict):
for key, child in value.items():
if key in names and child not in (None, ''):
return child
for child in value.values():
found = _find_named_value(child, names)
if found not in (None, ''):
return found
elif isinstance(value, list):
for child in value:
found = _find_named_value(child, names)
if found not in (None, ''):
return found
return None
def _callback_track_id(callback: dict[str, typing.Any]) -> str:
value = _find_named_value(callback, {'outTrackId', 'out_track_id', 'outtrackid'})
return str(value or '')
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _native_field_value(callback: dict[str, typing.Any], context: dict[str, typing.Any]) -> typing.Any:
field_type = str(context.get('field_type') or '')
names = (
{'select', 'selectResult', 'select_result', '__built_in_selectResult__'}
if field_type == 'select'
else {'input', 'inputResult', 'input_result', '__built_in_inputResult__'}
)
value = _find_named_value(callback, names)
parsed = _mapping(value)
if parsed:
value = parsed.get('value', parsed.get('input', parsed.get('index')))
if isinstance(value, dict):
value = value.get('value') or value.get('input') or value.get('index')
if field_type == 'select' and isinstance(value, int) and not isinstance(value, bool):
options = context.get('options') if isinstance(context.get('options'), list) else []
if 0 <= value < len(options):
value = options[value]
if field_type == 'number' and value not in (None, ''):
text = str(value).strip()
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return value
return value
def interaction_event_from_native(
event: DingTalkEvent,
callback_contexts: dict[str, dict[str, typing.Any]] | None = None,
) -> platform_events.PlatformSpecificEvent | None:
callback = event.get('CardCallback') or {}
callback_data = _find_callback_data(callback)
if callback_data:
parsed = parse_callback_data(callback_data)
else:
contexts = callback_contexts if callback_contexts is not None else {}
track_id = _callback_track_id(callback)
context = contexts.get(track_id)
if context is None:
return None
value = _native_field_value(callback, context)
if value in (None, ''):
return None
parsed = {
'callback_token': str(context.get('callback_token') or ''),
'values': {str(context.get('field_id') or ''): value},
}
contexts.pop(track_id, None)
space_id = str(callback.get('space_id') or '')
if 'IM_GROUP.' in space_id:
target_type = 'group'
target_id = space_id.split('IM_GROUP.', 1)[1]
elif 'IM_ROBOT.' in space_id:
target_type = 'person'
target_id = space_id.split('IM_ROBOT.', 1)[1]
else:
raise ValueError('DingTalk interaction callback has no delivery space')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='dingtalk-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(callback.get('user_id') or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event,
)
@@ -90,6 +90,19 @@ spec:
required: true
default: "填写你的卡片template_id"
- name: human_input_card_template_id
label:
en_US: Human Input Card Template ID
zh_Hans: 人工输入卡片模板 ID
zh_Hant: 人工輸入卡片範本 ID
description:
en_US: Import the bundled `src/langbot/templates/dingtalk_human_input_card.json`, then enter its DingTalk template ID here. It contains the `content`, `btns`, `input_*`, `select_*`, and `index_o` variables required for native interactions.
zh_Hans: 可选。模板需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 变量以支持原生交互。
zh_Hant: 可選。範本需包含 `content`、`btns`、`input_*`、`select_*` 和 `index_o` 變數以支援原生互動。
type: string
required: false
default: ""
supported_events:
- message.received
- feedback.received
@@ -108,6 +121,7 @@ spec:
- get_friend_list
- get_file_url
- call_platform_api
- interaction.request
platform_specific_apis:
- action: check_access_token
@@ -13,6 +13,12 @@ from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin
from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter
from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter
from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.discord.interaction import (
interaction_delivery_capabilities,
interaction_event_from_component,
parse_interaction_custom_id,
send_interaction,
)
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
@@ -63,6 +69,25 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
except Exception:
await adapter_self.logger.error(f'Error in discord on_message: {traceback.format_exc()}')
async def on_interaction(self: discord.Client, interaction: discord.Interaction):
custom_id = (interaction.data or {}).get('custom_id') if isinstance(interaction.data, dict) else None
try:
parsed = parse_interaction_custom_id(custom_id)
except ValueError:
if not interaction.response.is_done():
await interaction.response.send_message('Invalid or expired action', ephemeral=True)
return
if parsed is None:
return
if not interaction.response.is_done():
await interaction.response.defer()
try:
await adapter_self._dispatch_eba_event(interaction_event_from_component(interaction, parsed))
if interaction.message is not None:
await interaction.message.edit(view=None)
except Exception:
await adapter_self.logger.error(f'Error in Discord interaction callback: {traceback.format_exc()}')
async def on_message_edit(self: discord.Client, before: discord.Message, after: discord.Message):
await adapter_self._dispatch_gateway_tuple(
'message_edit', (before, after), self.user.id if self.user else None
@@ -176,8 +201,12 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
'kick_member',
'leave_group',
'call_platform_api',
'interaction.request',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
content, files = await self.message_converter.yiri2target(message)
channel = await self._get_channel(target_id)
@@ -238,6 +267,8 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
self.listeners.pop(event_type, None)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request':
return await send_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
@@ -0,0 +1,132 @@
"""Discord component rendering and callbacks for structured interactions."""
from __future__ import annotations
import time
import typing
import discord
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def parse_interaction_custom_id(custom_id: str | None) -> dict[str, typing.Any] | None:
if not custom_id or not custom_id.startswith('lbi:'):
return None
parts = custom_id.split(':')
if len(parts) == 4 and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if len(parts) == 5 and parts[1] and parts[2] == 'f' and parts[3].isdigit() and parts[4].isdigit():
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid Discord interaction custom_id')
def _style(value: str) -> discord.ButtonStyle:
if value == 'primary':
return discord.ButtonStyle.primary
if value == 'danger':
return discord.ButtonStyle.danger
return discord.ButtonStyle.secondary
def build_interaction_view(request: dict[str, typing.Any], callback_token: str) -> discord.ui.View | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
view = discord.ui.View(timeout=None)
if actions and not fields:
for index, action in enumerate(actions):
if not isinstance(action, dict):
continue
view.add_item(
discord.ui.Button(
label=str(action.get('label') or action.get('id') or index + 1)[:80],
style=_style(str(action.get('style') or 'default')),
custom_id=f'lbi:{callback_token}:a:{index}',
)
)
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
for option_index, option in enumerate(options):
if not isinstance(option, dict):
continue
view.add_item(
discord.ui.Button(
label=str(option.get('label') or option.get('value') or option_index + 1)[:80],
style=discord.ButtonStyle.secondary,
custom_id=f'lbi:{callback_token}:f:0:{option_index}',
)
)
else:
return None
return view if view.children else None
def _content(request: dict[str, typing.Any], *, rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
if rich and len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
if label:
parts.append(label)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)[:2000]
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_id = str(reply_target.get('target_id') or '')
if not target_id:
raise ValueError('interaction.request has no target_id')
channel = await adapter._get_channel(target_id)
view = build_interaction_view(request, callback_token)
sent = await channel.send(content=_content(request, rich=view is not None), view=view)
return {'ok': True, 'message_id': sent.id, 'rich': view is not None}
def interaction_event_from_component(
interaction: discord.Interaction,
parsed: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent:
if interaction.user is None or interaction.channel is None:
raise ValueError('Discord interaction has no actor or channel')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='discord',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(interaction.user.id),
'target_type': 'group' if interaction.guild is not None else 'person',
'target_id': str(interaction.channel.id),
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=interaction,
)
@@ -60,6 +60,7 @@ spec:
- kick_member
- leave_group
- call_platform_api
- interaction.request
platform_specific_apis:
- action: get_channel
+158 -22
View File
@@ -23,12 +23,16 @@ from lark_oapi.api.auth.v3 import (
ResendAppTicketResponse,
)
from lark_oapi.api.cardkit.v1 import (
Card,
ContentCardElementRequest,
ContentCardElementRequestBody,
ContentCardElementResponse,
CreateCardRequest,
CreateCardRequestBody,
CreateCardResponse,
UpdateCardRequest,
UpdateCardRequestBody,
UpdateCardResponse,
)
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
@@ -52,6 +56,13 @@ from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin
from langbot.pkg.platform.adapters.lark.event_converter import LarkEventConverter
from langbot.pkg.platform.adapters.lark.message_converter import LarkMessageConverter
from langbot.pkg.platform.adapters.lark.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.lark.interaction import (
acknowledge_interaction,
interaction_delivery_capabilities,
interaction_event_from_callback,
interaction_event_from_webhook,
send_interaction,
)
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
from langbot_plugin.api.entities.builtin.platform import events as platform_events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
@@ -101,6 +112,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
] = pydantic.Field(default_factory=dict)
card_id_dict: dict[str, str] = pydantic.Field(default_factory=dict)
card_sequence_dict: dict[str, int] = pydantic.Field(default_factory=dict)
card_last_update_dict: dict[str, float] = pydantic.Field(default_factory=dict)
closed_streaming_cards: set[str] = pydantic.Field(default_factory=set)
pending_monitoring_msg: dict[str, str] = pydantic.Field(default_factory=dict)
reply_to_monitoring_msg: dict[str, tuple[str, float]] = pydantic.Field(default_factory=dict)
_message_cache: dict[str, platform_events.MessageReceivedEvent] = pydantic.PrivateAttr(default_factory=dict)
@@ -133,6 +147,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
cipher=cipher,
listeners={},
card_id_dict={},
card_sequence_dict={},
card_last_update_dict={},
closed_streaming_cards=set(),
pending_monitoring_msg={},
reply_to_monitoring_msg={},
event_loop=None,
@@ -177,8 +194,17 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
'get_user_info',
'get_file_url',
'call_platform_api',
'interaction.request',
'interaction.acknowledge',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
@staticmethod
def _plain_message(text: str) -> platform_message.MessageChain:
return platform_message.MessageChain([platform_message.Plain(text=text)])
def build_api_client(self, config: dict) -> lark_oapi.Client:
builder = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret'])
if config.get('app_type', 'self') == 'isv':
@@ -378,7 +404,15 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
async def create_card_id(self, message_id) -> str:
card_data = {
'schema': '2.0',
'config': {'update_multi': True, 'streaming_mode': True},
'config': {
'update_multi': True,
'streaming_mode': True,
'streaming_config': {
'print_step': {'default': 1},
'print_frequency_ms': {'default': 70},
'print_strategy': 'fast',
},
},
'body': {
'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}],
@@ -392,8 +426,49 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
if not response.success():
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
self.card_id_dict[str(message_id)] = response.data.card_id
return response.data.card_id
card_id = str(response.data.card_id)
self.card_id_dict[str(message_id)] = card_id
self.card_sequence_dict[card_id] = 0
self.card_last_update_dict.pop(card_id, None)
self.closed_streaming_cards.discard(card_id)
return card_id
def _next_card_sequence(self, card_id: str) -> int:
current = self.card_sequence_dict.get(card_id, 0)
sequence = current + 1
self.card_sequence_dict[card_id] = sequence
return sequence
@staticmethod
def _streaming_mode_closed(response: ContentCardElementResponse) -> bool:
return response.code == 300309 or 'streaming mode is closed' in str(response.msg).lower()
async def _replace_streaming_card(self, card_id: str, content: str) -> None:
sequence = self._next_card_sequence(card_id)
card_data = {
'schema': '2.0',
'config': {'update_multi': True},
'body': {
'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': content}],
},
}
request = (
UpdateCardRequest.builder()
.card_id(card_id)
.request_body(
UpdateCardRequestBody.builder()
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.card(Card.builder().type('card_json').data(json.dumps(card_data, ensure_ascii=False)).build())
.build()
)
.build()
)
response: UpdateCardResponse = await self.api_client.cardkit.v1.card.aupdate(request)
if not response.success():
raise RuntimeError(f'Lark card update failed: {response.code} {response.msg}')
self.closed_streaming_cards.add(card_id)
async def reply_message_chunk(
self,
@@ -403,31 +478,53 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
quote_origin: bool = False,
is_final: bool = False,
):
if bot_message.msg_sequence % 8 != 0 and not is_final:
card_id = self.card_id_dict[bot_message.resp_message_id]
has_sent_update = self.card_sequence_dict.get(card_id, 0) > 0
now = time.monotonic()
last_update = self.card_last_update_dict.get(card_id, 0.0)
is_high_frequency_chunk = now - last_update < 1.0
if has_sent_update and is_high_frequency_chunk and bot_message.msg_sequence % 8 != 0 and not is_final:
return
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
content = '\n\n'.join(
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
for paragraph in text_elements
)
request = (
ContentCardElementRequest.builder()
.card_id(self.card_id_dict[bot_message.resp_message_id])
.element_id('streaming_txt')
.request_body(
ContentCardElementRequestBody.builder().content(content).sequence(bot_message.msg_sequence).build()
cumulative_content = getattr(bot_message, 'all_content', None)
if isinstance(cumulative_content, str) and cumulative_content:
content = cumulative_content
else:
text_elements, _ = await self.message_converter.yiri2target(message, self.api_client)
content = '\n\n'.join(
''.join(ele.get('text', '') for ele in paragraph if ele.get('tag') in {'text', 'md'})
for paragraph in text_elements
)
.build()
)
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
request, self.request_option(self._tenant_key_from_source(message_source))
)
if not response.success():
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
if card_id in self.closed_streaming_cards:
await self._replace_streaming_card(card_id, content)
else:
sequence = self._next_card_sequence(card_id)
request = (
ContentCardElementRequest.builder()
.card_id(card_id)
.element_id('streaming_txt')
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
.build()
)
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(
request, self.request_option(self._tenant_key_from_source(message_source))
)
if not response.success():
if self._streaming_mode_closed(response):
await self._replace_streaming_card(card_id, content)
else:
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
self.card_last_update_dict[card_id] = now
if is_final and bot_message.tool_calls is None:
self.card_id_dict.pop(bot_message.resp_message_id, None)
self.card_sequence_dict.pop(card_id, None)
self.card_last_update_dict.pop(card_id, None)
self.closed_streaming_cards.discard(card_id)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request':
return await send_interaction(self, params)
if action == 'interaction.acknowledge':
return await acknowledge_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
@@ -493,6 +590,10 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
await self._dispatch_eba_event(LarkEventConverter.bot_invited_to_group(data, chat_id))
return {'code': 200, 'message': 'ok'}
if event_type == 'card.action.trigger':
interaction_event = interaction_event_from_webhook(data)
if interaction_event is not None:
await self._dispatch_eba_event(interaction_event)
return self._interaction_action_response(interaction_event)
feedback_event = self._feedback_event_from_webhook(data)
if feedback_event and platform_events.FeedbackEvent in self.listeners:
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
@@ -571,6 +672,12 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
self._group_cache[str(event.group.id)] = event.group
def _handle_card_action_sync(self, event):
interaction_event = interaction_event_from_callback(event)
if interaction_event is not None:
self._submit_coro(self._dispatch_eba_event(interaction_event))
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
return P2CardActionTriggerResponse(self._interaction_action_response(interaction_event))
feedback_event = self._feedback_event_from_callback(event)
if feedback_event and platform_events.FeedbackEvent in self.listeners:
self._submit_coro(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
@@ -578,6 +685,26 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
@staticmethod
def _interaction_action_response(event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]:
response: dict[str, typing.Any] = {
'toast': {'type': 'success', 'content': 'Submitted / 已提交'},
}
if not event.data.get('cardkit'):
response['card'] = {
'type': 'raw',
'data': {
'config': {'wide_screen_mode': True},
'elements': [
{
'tag': 'div',
'text': {'tag': 'lark_md', 'content': '**Submitted / 已提交**'},
}
],
},
}
return response
def _submit_coro(self, coro):
try:
loop = asyncio.get_running_loop()
@@ -681,4 +808,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
message_id = getattr(message, 'message_id', None)
if message_id:
return str(message_id)
context = getattr(source_event, 'context', None) if source_event else None
message_id = getattr(context, 'open_message_id', None)
if message_id:
return str(message_id)
if isinstance(source, dict):
source_event_data = source.get('event') if isinstance(source.get('event'), dict) else source
context_data = source_event_data.get('context') if isinstance(source_event_data, dict) else None
if isinstance(context_data, dict) and context_data.get('open_message_id'):
return str(context_data['open_message_id'])
raise RuntimeError('Lark message source does not contain message_id')
@@ -0,0 +1,568 @@
"""Lark card rendering and callback conversion for structured interactions."""
from __future__ import annotations
import json
import time
import typing
import uuid
from lark_oapi.api.cardkit.v1 import (
Card,
CreateCardRequest,
CreateCardRequestBody,
CreateCardResponse,
UpdateCardRequest,
UpdateCardRequestBody,
UpdateCardResponse,
)
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageResponse,
)
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['text', 'textarea', 'number', 'select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _callback_value(
callback_token: str,
reply_target: dict[str, typing.Any],
**refs: typing.Any,
) -> dict[str, typing.Any]:
return {
'lbi': callback_token,
't': str(reply_target.get('target_type') or ''),
'ck': 1,
**refs,
}
def _field_form_elements(
field: dict[str, typing.Any],
callback_token: str,
reply_target: dict[str, typing.Any],
) -> list[dict[str, typing.Any]] | None:
field_type = str(field.get('type') or '')
if field_type not in {'text', 'textarea', 'number', 'select'}:
return None
field_id = str(field.get('id') or '')
if not field_id:
return None
component_name = 'lbi_field_0'
label = str(field.get('label') or field_id)
placeholder = str(
field.get('placeholder')
or ('Select an option / 请选择' if field_type == 'select' else 'Enter a value / 请输入')
)
required = bool(field.get('required'))
if field_type == 'select':
raw_options = field.get('options') if isinstance(field.get('options'), list) else []
options = [
{
'text': {
'tag': 'plain_text',
'content': str(option.get('label') or option.get('value') or ''),
},
'value': str(option.get('value') or ''),
}
for option in raw_options
if isinstance(option, dict) and option.get('value') not in (None, '')
]
if not options:
return None
field_element: dict[str, typing.Any] = {
'tag': 'select_static',
'name': component_name,
'label': {'tag': 'plain_text', 'content': label},
'placeholder': {'tag': 'plain_text', 'content': placeholder},
'options': options,
'width': 'fill',
'required': required,
}
default = field.get('default')
if default not in (None, ''):
field_element['initial_option'] = str(default)
else:
default = field.get('default')
field_element = {
'tag': 'input',
'name': component_name,
'label': {'tag': 'plain_text', 'content': label},
'placeholder': {'tag': 'plain_text', 'content': placeholder},
'default_value': '' if default is None else str(default),
'width': 'fill',
'required': required,
}
if field_type == 'textarea':
field_element.update(
{
'input_type': 'multiline_text',
'rows': 3,
'auto_resize': True,
'max_rows': 6,
}
)
submit_value = _callback_value(
callback_token,
reply_target,
fm={component_name: field_id},
ft={field_id: field_type},
)
submit_button = {
'tag': 'button',
'name': 'lbi_submit',
'text': {'tag': 'plain_text', 'content': 'Submit / 提交'},
'type': 'primary',
'width': 'fill',
'form_action_type': 'submit',
'behaviors': [{'type': 'callback', 'value': submit_value}],
}
form_elements: list[dict[str, typing.Any]] = [field_element, submit_button]
if field_type == 'select':
form_elements.insert(
0,
{
'tag': 'markdown',
'content': f'**{label}{"*" if required else ""}**',
},
)
return [
{
'tag': 'form',
'name': 'lbi_form',
'direction': 'vertical',
'vertical_spacing': '12px',
'elements': form_elements,
}
]
def _render_submitted_value(value: typing.Any) -> str:
rendered = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value)
rendered = rendered.strip().replace('\n', '\n ')
return rendered if len(rendered) <= 2000 else rendered[:1997] + '...'
def _submission_display_values(
request: dict[str, typing.Any],
submission: dict[str, typing.Any],
) -> list[dict[str, str]]:
display_values: list[dict[str, str]] = []
values = submission.get('values') if isinstance(submission.get('values'), dict) else {}
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
for field in fields:
if not isinstance(field, dict):
continue
field_id = str(field.get('id') or '')
if field_id and field_id in values:
display_value = {
'label': str(field.get('label') or field_id),
'value': _render_submitted_value(values[field_id]),
}
description = str(request.get('description') or '').strip()
if description:
display_value['description'] = description
display_values.append(display_value)
action_id = submission.get('action_id')
if action_id:
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
action_label = next(
(
str(action.get('label') or action_id)
for action in actions
if isinstance(action, dict) and str(action.get('id') or '') == str(action_id)
),
str(action_id),
)
display_values.append({'label': 'Action', 'value': action_label})
return display_values
def _stored_submitted_values(value: typing.Any) -> list[dict[str, str]]:
if not isinstance(value, list):
return []
stored_values = [
{
'label': str(item['label'])[:200],
'value': str(item['value'])[:2000],
**({'description': str(item['description'])[:4000]} if item.get('description') is not None else {}),
}
for item in value[:50]
if isinstance(item, dict) and item.get('label') is not None and item.get('value') is not None
]
return stored_values
def _submitted_value_elements(values: list[dict[str, str]]) -> list[dict[str, typing.Any]]:
elements: list[dict[str, typing.Any]] = []
for item in values:
lines = []
description = str(item.get('description') or '').strip()
if description:
lines.append(description)
lines.append(f'{item["label"]}{item["value"]}')
elements.append({'tag': 'markdown', 'content': '\n'.join(lines)})
return elements
def build_interaction_card(
request: dict[str, typing.Any],
callback_token: str,
reply_target: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any] | None:
"""Build the Lark subset that maps to a single atomic submission."""
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons: list[dict[str, typing.Any]] = []
field_elements: list[dict[str, typing.Any]] | None = None
if actions and not fields:
for index, action in enumerate(actions):
if not isinstance(action, dict):
continue
style = str(action.get('style') or 'default')
buttons.append(
{
'tag': 'button',
'text': {
'tag': 'plain_text',
'content': str(action.get('label') or action.get('id') or index + 1),
},
'type': 'primary' if style == 'primary' else 'danger' if style == 'danger' else 'default',
'behaviors': [
{
'type': 'callback',
'value': _callback_value(callback_token, reply_target, a=index),
}
],
}
)
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
field_elements = _field_form_elements(field, callback_token, reply_target)
if field_elements is None:
return None
else:
return None
if not buttons and not field_elements:
return None
elements: list[dict[str, typing.Any]] = []
elements.extend(_submitted_value_elements(submitted_values or []))
description = str(request.get('description') or '').strip()
if description:
elements.append({'tag': 'markdown', 'content': description})
if field_elements:
elements.extend(field_elements)
else:
elements.append(
{
'tag': 'column_set',
'horizontal_spacing': '8px',
'columns': [
{
'tag': 'column',
'width': 'weighted',
'weight': 1,
'elements': [button],
}
for button in buttons
],
}
)
return {
'schema': '2.0',
'config': {'update_multi': True},
'header': {
'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')},
},
'body': {'direction': 'vertical', 'elements': elements},
}
def build_submitted_card(
request: dict[str, typing.Any],
submission: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any]:
"""Render a read-only snapshot after a user submits an interaction."""
elements: list[dict[str, typing.Any]] = []
all_submitted_values = [
*(submitted_values or []),
*_submission_display_values(request, submission),
]
elements.extend(_submitted_value_elements(all_submitted_values))
return {
'schema': '2.0',
'config': {'update_multi': True},
'header': {'title': {'tag': 'plain_text', 'content': str(request.get('title') or '')}},
'body': {'direction': 'vertical', 'elements': elements},
}
async def _update_interaction_card(
adapter: typing.Any,
update_target: dict[str, typing.Any],
card: dict[str, typing.Any],
submitted_values: list[dict[str, str]] | None = None,
) -> dict[str, typing.Any]:
message_id = str(update_target.get('message_id') or '')
card_id = str(update_target.get('card_id') or '')
if not message_id or not card_id or update_target.get('rich') is not True:
raise ValueError('Lark interaction update requires a CardKit target')
persisted_sequence = int(update_target.get('sequence') or 0)
current_sequence = int(adapter.card_sequence_dict.get(card_id, persisted_sequence))
sequence = max(persisted_sequence, current_sequence) + 1
request = (
UpdateCardRequest.builder()
.card_id(card_id)
.request_body(
UpdateCardRequestBody.builder()
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.card(Card.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build())
.build()
)
.build()
)
response: UpdateCardResponse = await adapter.api_client.cardkit.v1.card.aupdate(request)
if not response.success():
raise RuntimeError(f'Lark CardKit interaction update failed: {response.code} {response.msg}')
adapter.card_sequence_dict[card_id] = sequence
result = {
'ok': True,
'message_id': message_id,
'card_id': card_id,
'sequence': sequence,
'rich': True,
'updated': True,
}
if submitted_values:
result['submitted_values'] = submitted_values
return result
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
update_target = params.get('update_target')
submitted_values = _stored_submitted_values(
update_target.get('submitted_values') if isinstance(update_target, dict) else None
)
card = build_interaction_card(request, callback_token, reply_target, submitted_values)
if card is None:
fallback = str(request.get('fallback_text') or '')
result = await adapter.send_message(
str(reply_target.get('target_type') or ''),
str(reply_target.get('target_id') or ''),
adapter._plain_message(fallback),
)
return {'ok': True, 'message_id': result.message_id, 'rich': False}
if isinstance(update_target, dict):
return await _update_interaction_card(adapter, update_target, card, submitted_values)
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
if target_type not in {'group', 'person'} or not target_id:
raise ValueError('interaction.request has an invalid reply target')
card_request = (
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder().type('card_json').data(json.dumps(card, ensure_ascii=False)).build()
)
.build()
)
card_response: CreateCardResponse = await adapter.api_client.cardkit.v1.card.acreate(card_request)
if not card_response.success():
raise RuntimeError(f'Lark CardKit interaction create failed: {card_response.code} {card_response.msg}')
card_id = str(getattr(card_response.data, 'card_id', '') or '')
if not card_id:
raise RuntimeError('Lark CardKit interaction create returned no card_id')
message_request = (
CreateMessageRequest.builder()
.receive_id_type('chat_id' if target_type == 'group' else 'open_id')
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(json.dumps({'type': 'card', 'data': {'card_id': card_id}}, ensure_ascii=False))
.msg_type('interactive')
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response: CreateMessageResponse = await adapter.api_client.im.v1.message.acreate(message_request)
if not response.success():
raise RuntimeError(f'Lark interaction send failed: {response.code} {response.msg}')
adapter.card_sequence_dict[card_id] = 0
return {
'ok': True,
'message_id': getattr(response.data, 'message_id', ''),
'card_id': card_id,
'sequence': 0,
'rich': True,
}
async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
submission = params.get('submission')
update_target = params.get('update_target')
if not isinstance(request, dict) or not isinstance(submission, dict) or not isinstance(update_target, dict):
raise ValueError('interaction.acknowledge requires request, submission, and update_target')
submitted_values = [
*_stored_submitted_values(update_target.get('submitted_values')),
*_submission_display_values(request, submission),
]
return await _update_interaction_card(
adapter,
update_target,
build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))),
submitted_values,
)
def _mapping(value: typing.Any) -> dict[str, typing.Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _action_attr(action: typing.Any, name: str) -> typing.Any:
return action.get(name) if isinstance(action, dict) else getattr(action, name, None)
def _coerce_field_value(value: typing.Any, field_type: str) -> typing.Any:
if isinstance(value, dict):
value = value.get('value') if value.get('value') is not None else value.get('option')
if field_type != 'number' or isinstance(value, (int, float)) and not isinstance(value, bool):
return value
text = str(value).strip()
try:
return int(text)
except ValueError:
try:
return float(text)
except ValueError:
return value
def _form_submission_values(action: typing.Any, payload: dict[str, typing.Any]) -> dict[str, typing.Any]:
field_map = payload.get('fm') if isinstance(payload.get('fm'), dict) else {}
field_types = payload.get('ft') if isinstance(payload.get('ft'), dict) else {}
if not field_map:
return {}
form_value = _mapping(_action_attr(action, 'form_value'))
if not form_value:
for key in ('form_value', 'formValue', 'form_values', 'formValues'):
form_value = _mapping(payload.get(key))
if form_value:
break
if not form_value:
action_name = _action_attr(action, 'name')
input_value = _action_attr(action, 'input_value')
option_value = _action_attr(action, 'option')
if action_name and input_value not in (None, ''):
form_value = {str(action_name): input_value}
elif action_name and option_value not in (None, ''):
form_value = {str(action_name): option_value}
values: dict[str, typing.Any] = {}
for component_name, value in form_value.items():
field_id = field_map.get(component_name)
if field_id and value not in (None, ''):
values[str(field_id)] = _coerce_field_value(value, str(field_types.get(str(field_id)) or ''))
return values
def _event_from_parts(
*,
raw: typing.Any,
action: typing.Any,
actor_id: typing.Any,
chat_id: typing.Any,
message_id: typing.Any,
) -> platform_events.PlatformSpecificEvent | None:
payload = _mapping(_action_attr(action, 'value'))
callback_token = str(payload.get('lbi') or '')
if not callback_token:
return None
target_type = str(payload.get('t') or '')
target_id = str(chat_id or '') if target_type == 'group' else str(actor_id or '')
data: dict[str, typing.Any] = {
'callback_token': callback_token,
'actor_id': str(actor_id or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
}
if payload.get('ck') == 1:
data['cardkit'] = True
if message_id:
data['message_id'] = str(message_id)
if payload.get('a') is not None:
data['action_ref'] = payload['a']
if payload.get('f') is not None or payload.get('o') is not None:
data['field_ref'] = payload.get('f')
data['option_ref'] = payload.get('o')
if payload.get('fm'):
data['values'] = _form_submission_values(action, payload)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='lark-eba',
action='interaction.submitted',
data=data,
timestamp=time.time(),
source_platform_object=raw,
)
def interaction_event_from_callback(event: typing.Any) -> platform_events.PlatformSpecificEvent | None:
action = getattr(getattr(event, 'event', None), 'action', None)
operator = getattr(getattr(event, 'event', None), 'operator', None)
context = getattr(getattr(event, 'event', None), 'context', None)
return _event_from_parts(
raw=event,
action=action,
actor_id=getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None),
chat_id=getattr(context, 'open_chat_id', None),
message_id=getattr(context, 'open_message_id', None),
)
def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.PlatformSpecificEvent | None:
event = data.get('event') if isinstance(data.get('event'), dict) else {}
action = event.get('action') if isinstance(event.get('action'), dict) else {}
operator = event.get('operator') if isinstance(event.get('operator'), dict) else {}
context = event.get('context') if isinstance(event.get('context'), dict) else {}
return _event_from_parts(
raw=data,
action=action,
actor_id=operator.get('open_id') or operator.get('user_id'),
chat_id=context.get('open_chat_id'),
message_id=context.get('open_message_id'),
)
@@ -164,6 +164,8 @@ spec:
- get_user_info
- get_file_url
- call_platform_api
- interaction.request
- interaction.acknowledge
platform_specific_apis:
- action: check_tenant_access_token
@@ -23,6 +23,11 @@ from langbot.pkg.platform.adapters.qqofficial.event_converter import (
)
from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter
from langbot.pkg.platform.adapters.qqofficial.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.qqofficial.interaction import (
interaction_delivery_capabilities,
interaction_event_from_payload,
send_interaction,
)
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -117,8 +122,16 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
'get_group_member_list',
'get_group_member_info',
'call_platform_api',
'interaction.request',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
@staticmethod
def _plain_message(text: str) -> platform_message.MessageChain:
return platform_message.MessageChain([platform_message.Plain(text=text)])
async def send_message(
self,
target_type: str,
@@ -149,6 +162,8 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request':
return await send_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
@@ -296,6 +311,15 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
):
self.bot.on_message(event_type)(self._handle_native_event)
@self.bot.on_interaction()
async def on_interaction(event_data: dict, ws_event_id: str | None):
interaction_id = str(event_data.get('id') or '')
if interaction_id:
await self.bot.ack_interaction(interaction_id)
event = interaction_event_from_payload(event_data)
if event is not None:
await self._dispatch_eba_event(event)
async def _handle_native_event(self, event: QQOfficialEvent):
self.bot_account_id = self.config.get('appid', self.bot_account_id)
try:
@@ -0,0 +1,177 @@
"""QQ Official keyboard rendering and interaction callback conversion."""
from __future__ import annotations
import time
import typing
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _buttons(request: dict[str, typing.Any], callback_token: str) -> list[tuple[str, str, int]]:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
if actions and not fields:
return [
(
str(action.get('label') or action.get('id') or index + 1),
f'lbi:{callback_token}:a:{index}',
1 if action.get('style') == 'primary' else 0,
)
for index, action in enumerate(actions[:25])
if isinstance(action, dict)
]
if len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return []
options = field.get('options') if isinstance(field.get('options'), list) else []
return [
(
str(option.get('label') or option.get('value') or index + 1),
f'lbi:{callback_token}:f:0:{index}',
0,
)
for index, option in enumerate(options[:25])
if isinstance(option, dict)
]
return []
def build_interaction_keyboard(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
buttons = _buttons(request, callback_token)
if not buttons:
return None
rows = []
for start in range(0, len(buttons), 2):
rows.append(
{
'buttons': [
{
'id': str(start + offset + 1),
'render_data': {
'label': label,
'visited_label': f'{label}',
'style': style,
},
'action': {
'type': 1,
'permission': {'type': 2},
'data': callback_data,
'unsupport_tips': 'Please update QQ to use this action.',
},
}
for offset, (label, callback_data, style) in enumerate(buttons[start : start + 2])
]
}
)
return {'content': {'rows': rows}}
def _text(request: dict[str, typing.Any], rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
keyboard = build_interaction_keyboard(request, callback_token)
if keyboard is None or target_type not in {'person', 'group'}:
result = await adapter.send_message(
target_type,
target_id,
adapter._plain_message(_text(request, False)),
)
return {'ok': True, 'message_id': result.message_id, 'rich': False}
raw = await adapter.bot.send_markdown_keyboard(
target_type='c2c' if target_type == 'person' else 'group',
target_id=target_id,
markdown_content=_text(request, True),
keyboard=keyboard,
msg_id=reply_target.get('message_id'),
)
return {'ok': True, 'message_id': raw.get('id') if isinstance(raw, dict) else None, 'rich': True}
def parse_callback_data(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid QQ interaction callback data')
def interaction_event_from_payload(
event_data: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent | None:
resolved = (event_data.get('data') or {}).get('resolved') or {}
callback_data = str(resolved.get('button_data') or '')
if not callback_data.startswith('lbi:'):
return None
parsed = parse_callback_data(callback_data)
chat_type = event_data.get('chat_type')
if chat_type == 2 or event_data.get('user_openid'):
target_type = 'person'
target_id = str(event_data.get('user_openid') or '')
elif chat_type == 1 or event_data.get('group_openid'):
target_type = 'group'
target_id = str(event_data.get('group_openid') or '')
elif chat_type == 0 or event_data.get('channel_id'):
target_type = 'group'
target_id = str(event_data.get('channel_id') or '')
else:
raise ValueError('QQ interaction callback has no target')
actor_id = str(
event_data.get('member_openid')
or event_data.get('user_openid')
or ((event_data.get('member') or {}).get('user') or {}).get('id')
or ''
)
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='qqofficial-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': actor_id,
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event_data,
)
@@ -107,6 +107,7 @@ spec:
- get_group_member_list
- get_group_member_info
- call_platform_api
- interaction.request
platform_specific_apis:
- action: check_access_token
@@ -34,6 +34,12 @@ from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMes
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter
from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin
from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.telegram.interaction import (
interaction_delivery_capabilities,
interaction_event_from_update,
parse_interaction_callback,
send_interaction,
)
class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
@@ -79,6 +85,23 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
return
try:
if update.callback_query:
try:
interaction_callback = parse_interaction_callback(update.callback_query.data)
except ValueError:
await update.callback_query.answer(text='Invalid or expired action', show_alert=True)
await self.logger.warning('Rejected malformed Telegram interaction callback')
return
if interaction_callback is not None:
await update.callback_query.answer()
event = interaction_event_from_update(update, interaction_callback)
await self._dispatch_eba_event(event)
try:
await update.callback_query.edit_message_reply_markup(reply_markup=None)
except Exception:
await self.logger.warning('Failed to clear Telegram interaction buttons')
return
# Legacy event type callbacks (compat with existing botmgr FriendMessage / GroupMessage listeners)
if update.message and (
platform_events.FriendMessage in self.listeners or platform_events.GroupMessage in self.listeners
@@ -176,8 +199,12 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
'kick_member',
'leave_group',
'call_platform_api',
'interaction.request',
]
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
# ---- Message Send / Reply (preserving original logic) ----
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
@@ -410,6 +437,8 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
params: dict = {},
) -> dict:
"""Call a Telegram-specific platform API."""
if action == 'interaction.request':
return await send_interaction(self.bot, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
@@ -406,10 +406,11 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
source_platform_object=event,
)
else:
sender = event.message.from_user
return legacy_events.GroupMessage(
sender=legacy_entities.GroupMember(
id=event.effective_chat.id,
member_name=event.effective_chat.title,
id=sender.id if sender else '',
member_name=sender.first_name if sender else '',
permission=legacy_entities.Permission.Member,
group=legacy_entities.Group(
id=event.effective_chat.id,
@@ -0,0 +1,163 @@
"""Telegram rendering and callback conversion for structured interactions."""
from __future__ import annotations
import time
import typing
import telegram
import langbot_plugin.api.entities.builtin.platform.events as platform_events
INTERACTION_CALLBACK_PREFIX = 'lbi'
def parse_interaction_callback(data: str | None) -> dict[str, typing.Any] | None:
"""Parse a compact interaction callback without trusting platform values."""
if not data or not data.startswith(f'{INTERACTION_CALLBACK_PREFIX}:'):
return None
parts = data.split(':')
if len(parts) == 4 and parts[2] == 'a':
token, action_ref = parts[1], parts[3]
if token and action_ref.isdigit():
return {'callback_token': token, 'action_ref': int(action_ref)}
if len(parts) == 5 and parts[2] == 'f':
token, field_ref, option_ref = parts[1], parts[3], parts[4]
if token and field_ref.isdigit() and option_ref.isdigit():
return {
'callback_token': token,
'field_ref': int(field_ref),
'option_ref': int(option_ref),
}
raise ValueError('invalid Telegram interaction callback data')
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
"""Return the structured interaction subset Telegram can render natively."""
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _callback_data(callback_token: str, *parts: str | int) -> str:
value = ':'.join((INTERACTION_CALLBACK_PREFIX, callback_token, *(str(part) for part in parts)))
if len(value.encode('utf-8')) > 64:
raise ValueError('Telegram interaction callback exceeds 64 bytes')
return value
def _build_keyboard(request: dict[str, typing.Any], callback_token: str) -> telegram.InlineKeyboardMarkup | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
if actions and not fields:
rows = [
[
telegram.InlineKeyboardButton(
str(action.get('label') or action.get('id') or index + 1),
callback_data=_callback_data(callback_token, 'a', index),
)
]
for index, action in enumerate(actions)
if isinstance(action, dict)
]
return telegram.InlineKeyboardMarkup(rows) if rows else None
if len(fields) == 1 and not actions:
field = fields[0]
if not isinstance(field, dict) or field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
rows = [
[
telegram.InlineKeyboardButton(
str(option.get('label') or option.get('value') or option_index + 1),
callback_data=_callback_data(callback_token, 'f', 0, option_index),
)
]
for option_index, option in enumerate(options)
if isinstance(option, dict)
]
return telegram.InlineKeyboardMarkup(rows) if rows else None
return None
def _message_text(request: dict[str, typing.Any], *, rich: bool) -> str:
parts = [str(request.get('title') or '').strip()]
description = str(request.get('description') or '').strip()
if description:
parts.append(description)
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
if rich and len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
if label:
parts.append(label)
if not rich:
fallback = str(request.get('fallback_text') or '').strip()
if fallback:
parts.append(fallback)
return '\n\n'.join(part for part in parts if part)
async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
"""Render a supported interaction or its required plain-text fallback."""
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_id = str(reply_target.get('target_id') or '')
if not target_id:
raise ValueError('interaction.request has no target_id')
chat_id_text, _, thread_id_text = target_id.partition('#')
chat_id: int | str = int(chat_id_text) if chat_id_text.lstrip('-').isdigit() else chat_id_text
message_thread_id = int(thread_id_text) if thread_id_text.isdigit() else None
keyboard = _build_keyboard(request, callback_token)
send_args: dict[str, typing.Any] = {
'chat_id': chat_id,
'text': _message_text(request, rich=keyboard is not None),
}
if message_thread_id is not None:
send_args['message_thread_id'] = message_thread_id
if keyboard is not None:
send_args['reply_markup'] = keyboard
sent = await bot.send_message(**send_args)
return {'ok': True, 'message_id': getattr(sent, 'message_id', None), 'rich': keyboard is not None}
def interaction_event_from_update(
update: telegram.Update,
parsed: dict[str, typing.Any],
) -> platform_events.PlatformSpecificEvent:
"""Convert a trusted callback shape into the Host interaction event."""
query = update.callback_query
if query is None or query.message is None or query.from_user is None:
raise ValueError('Telegram interaction callback has no message or actor')
message = query.message
target_type = 'person' if message.chat.type == 'private' else 'group'
target_id = str(message.chat.id)
if message.message_thread_id:
target_id = f'{target_id}#{message.message_thread_id}'
data = {
**parsed,
'actor_id': str(query.from_user.id),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
}
return platform_events.PlatformSpecificEvent(
type='platform.specific',
timestamp=time.time(),
adapter_name='telegram',
action='interaction.submitted',
data=data,
source_platform_object=update,
)
@@ -72,6 +72,7 @@ spec:
- kick_member
- leave_group
- call_platform_api
- interaction.request
platform_specific_apis:
- action: pin_message
@@ -14,6 +14,11 @@ from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin
from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter
from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter
from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP
from langbot.pkg.platform.adapters.wecombot.interaction import (
interaction_delivery_capabilities,
interaction_event_from_native,
send_interaction,
)
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot_plugin.api.entities.builtin.platform import entities as platform_entities
@@ -100,7 +105,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
]
def get_supported_apis(self) -> list[str]:
return [
apis = [
'send_message',
'reply_message',
'get_message',
@@ -111,6 +116,16 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
'get_group_member_list',
'call_platform_api',
]
if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'):
apis.append('interaction.request')
return apis
def get_interaction_capabilities(self) -> dict[str, typing.Any]:
return interaction_delivery_capabilities()
@staticmethod
def _plain_message(text: str) -> platform_message.MessageChain:
return platform_message.MessageChain([platform_message.Plain(text=text)])
async def send_message(
self,
@@ -163,6 +178,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
return self.config.get('enable-stream-reply', True)
async def call_platform_api(self, action: str, params: dict = {}) -> dict:
if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis():
return await send_interaction(self, params)
handler = PLATFORM_API_MAP.get(action)
if handler is None:
raise NotSupportedError(f'call_platform_api:{action}')
@@ -229,6 +246,15 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
self.bot.on_feedback()(self._handle_feedback)
if hasattr(self.bot, 'on_message'):
self.bot.on_message('event')(self._handle_native_event)
self.bot.on_message('template_card_event')(self._handle_interaction_event)
async def _handle_interaction_event(self, event: WecomBotEvent):
try:
interaction_event = interaction_event_from_native(event)
if interaction_event is not None:
await self._dispatch_eba_event(interaction_event)
except Exception:
await self.logger.error(f'Error in WeComBot interaction callback: {traceback.format_exc()}')
async def _handle_native_event(self, event: WecomBotEvent):
try:
@@ -277,6 +303,8 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
def _cleanup_stream_mapping(self):
now = time.time()
expired = [key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL]
expired = [
key for key, (_, ts) in self._stream_to_monitoring_msg.items() if now - ts > self._STREAM_MAPPING_TTL
]
for key in expired:
del self._stream_to_monitoring_msg[key]
@@ -0,0 +1,168 @@
"""WeCom template-card rendering and callbacks for structured interactions."""
from __future__ import annotations
import time
import typing
import uuid
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
from langbot_plugin.api.entities.builtin.platform import events as platform_events
def interaction_delivery_capabilities() -> dict[str, typing.Any]:
return {
'field_types': ['select'],
'action_styles': ['default', 'primary', 'danger'],
'supports_updates': True,
'max_fields': 1,
}
def _button_style(style: str) -> int:
if style == 'primary':
return 1
if style == 'danger':
return 2
return 0
def build_interaction_card(request: dict[str, typing.Any], callback_token: str) -> dict[str, typing.Any] | None:
fields = request.get('fields') if isinstance(request.get('fields'), list) else []
actions = request.get('actions') if isinstance(request.get('actions'), list) else []
buttons: list[dict[str, typing.Any]] = []
if actions and not fields:
buttons = [
{
'text': str(action.get('label') or action.get('id') or index + 1),
'style': _button_style(str(action.get('style') or 'default')),
'key': f'lbi:{callback_token}:a:{index}',
}
for index, action in enumerate(actions[:6])
if isinstance(action, dict)
]
elif len(fields) == 1 and not actions and isinstance(fields[0], dict):
field = fields[0]
if field.get('type') != 'select':
return None
options = field.get('options') if isinstance(field.get('options'), list) else []
buttons = [
{
'text': str(option.get('label') or option.get('value') or index + 1),
'style': 0,
'key': f'lbi:{callback_token}:f:0:{index}',
}
for index, option in enumerate(options[:6])
if isinstance(option, dict)
]
if not buttons:
return None
description = str(request.get('description') or '').strip()
if len(fields) == 1 and isinstance(fields[0], dict):
label = str(fields[0].get('label') or '').strip()
description = '\n\n'.join(part for part in (description, label) if part)
return {
'msgtype': 'template_card',
'template_card': {
'card_type': 'button_interaction',
'main_title': {'title': str(request.get('title') or '')},
'sub_title_text': description,
'button_list': buttons,
'task_id': f'lbi-{uuid.uuid4().hex}',
},
}
async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) -> dict[str, typing.Any]:
request = params.get('request')
reply_target = params.get('reply_target')
callback_token = str(params.get('callback_token') or '')
if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token:
raise ValueError('interaction.request requires request, reply_target, and callback_token')
target_type = str(reply_target.get('target_type') or '')
target_id = str(reply_target.get('target_id') or '')
card = build_interaction_card(request, callback_token)
if card is None:
fallback = '\n\n'.join(
part
for part in (
str(request.get('title') or '').strip(),
str(request.get('description') or '').strip(),
str(request.get('fallback_text') or '').strip(),
)
if part
)
result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback))
return {'ok': True, 'message_id': result.message_id, 'rich': False}
raw = await adapter.bot.send_template_card(target_id, card)
return {'ok': True, 'message_id': None, 'rich': True, 'raw': raw}
def _template_card_event(event: WecomBotEvent) -> dict[str, typing.Any]:
wrapper = event.get('event') or {}
if not isinstance(wrapper, dict):
return {}
for key in ('template_card_event', 'templateCardEvent', 'TemplateCardEvent'):
value = wrapper.get(key)
if isinstance(value, dict):
return value
return wrapper
def _callback_key(payload: dict[str, typing.Any]) -> str:
value = payload.get('EventKey') or payload.get('event_key') or payload.get('eventKey') or payload.get('key') or ''
if value:
return str(value)
for button_name in ('button', 'Button', 'selected_button', 'selectedButton'):
button = payload.get(button_name)
if not isinstance(button, dict):
continue
value = button.get('key') or button.get('Key') or button.get('event_key') or button.get('EventKey') or ''
if value:
return str(value)
return ''
def parse_callback_key(value: str) -> dict[str, typing.Any]:
parts = value.split(':')
if len(parts) == 4 and parts[0] == 'lbi' and parts[1] and parts[2] == 'a' and parts[3].isdigit():
return {'callback_token': parts[1], 'action_ref': int(parts[3])}
if (
len(parts) == 5
and parts[0] == 'lbi'
and parts[1]
and parts[2] == 'f'
and parts[3].isdigit()
and parts[4].isdigit()
):
return {
'callback_token': parts[1],
'field_ref': int(parts[3]),
'option_ref': int(parts[4]),
}
raise ValueError('invalid WeCom interaction callback key')
def interaction_event_from_native(
event: WecomBotEvent,
) -> platform_events.PlatformSpecificEvent | None:
callback_key = _callback_key(_template_card_event(event))
if not callback_key.startswith('lbi:'):
return None
parsed = parse_callback_key(callback_key)
target_type = 'group' if event.type == 'group' or event.chatid else 'person'
target_id = str(event.chatid or event.userid or '')
return platform_events.PlatformSpecificEvent(
type='platform.specific',
adapter_name='wecombot-eba',
action='interaction.submitted',
data={
**parsed,
'actor_id': str(event.userid or ''),
'target_type': target_type,
'target_id': target_id,
'display_text': 'submitted',
},
timestamp=time.time(),
source_platform_object=event,
)
@@ -143,6 +143,7 @@ spec:
- get_group_member_info
- get_group_member_list
- call_platform_api
- interaction.request
platform_specific_apis:
- action: is_websocket_mode
+258 -21
View File
@@ -996,6 +996,27 @@ class RuntimeBot:
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,
@@ -1009,23 +1030,7 @@ class RuntimeBot:
actor=self._infer_actor_context(event),
subject=self._infer_subject_context(event),
input=self._build_agent_input(event),
delivery=DeliveryContext(
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,
},
),
delivery=DeliveryContext.model_validate(delivery_data),
raw_ref=RawEventRef(ref_id=str(event_id), storage_key=None),
data=self._compact_event_data(event),
)
@@ -1045,6 +1050,22 @@ class RuntimeBot:
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],
@@ -1068,9 +1089,15 @@ class RuntimeBot:
runner_config=runner_config,
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
delivery_policy=DeliveryPolicy(
enable_streaming=False,
enable_reply=True,
enable_interactions=True,
),
enabled=True,
agent_id=agent.get('uuid'),
processor_type='agent',
processor_id=agent.get('uuid'),
)
@staticmethod
@@ -1137,6 +1164,10 @@ class RuntimeBot:
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
) -> None:
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
await self._handle_interaction_submission(event, adapter)
return
event.bot_uuid = self.bot_entity.uuid
plugin_event = self._eba_event_to_plugin_event(event)
@@ -1303,7 +1334,11 @@ class RuntimeBot:
envelope = self._eba_event_to_agent_envelope(event, adapter)
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
try:
async for output in self.ap.agent_run_orchestrator.run(envelope, binding):
async for output in self.ap.agent_run_orchestrator.run(
envelope,
binding,
adapter_context={'_delivery_adapter': adapter},
):
outputs.append(output)
except Exception:
return await self._record_event_route_trace(
@@ -1408,6 +1443,7 @@ class RuntimeBot:
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:
is_group_message = isinstance(event, platform_events.GroupMessage)
launcher_kind = 'group' if is_group_message else 'person'
@@ -1475,8 +1511,197 @@ class RuntimeBot:
adapter=adapter,
pipeline_uuid=pipeline_uuid,
routed_by_rule=routed_by_rule,
variables=variables,
)
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(record['processor_id'])
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']},
)
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(record['processor_id'])
if not agent or agent.get('kind') != 'agent' or not agent.get('enabled', True):
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},
)
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
async for output in self.ap.agent_run_orchestrator.run(
envelope,
binding,
adapter_context={'_delivery_adapter': adapter},
):
outputs.append(output)
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
async def _dispatch_eba_message_to_pipeline(
self,
event: platform_events.EBAEvent,
@@ -1523,8 +1748,20 @@ class RuntimeBot:
return
await self._handle_platform_event(self._legacy_message_to_eba_event(event, adapter), adapter)
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
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, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
# Register feedback listener (only effective on adapters that support it)
async def on_feedback(