Files
LangBot/src/langbot/pkg/telemetry/adapter_diagnostics.py
T

253 lines
9.5 KiB
Python

"""Finite adapter acceptance evidence, without message contents or target IDs."""
from __future__ import annotations
from .diagnostic_catalog import catalog
def boundary_fields(module, kind, operation, bound, parent):
"""Only the actual adapter API / EBA conversion boundary counts as a test."""
entry = next((v for k, v in catalog().items() if k in module.split('.')), None)
if entry is None or '.platform.adapters.' not in module:
return {}
evidence = kind == 'event' and operation == 'platform.target2yiri'
resolved = operation
if kind == 'api':
evidence = True
if operation == 'call_platform_api':
action = bound.get('action')
if isinstance(action, str) and 'platform_api.' + action in entry['specific_apis']:
resolved = 'platform_api.' + action
elif action in entry['apis'] and action != 'call_platform_api':
resolved = action
else:
# Keep the generic boundary for investigation, not a false named test.
evidence = False
if (
entry['adapter'] == 'lark-omni'
and resolved
in {
'platform_api.check_tenant_access_token',
'platform_api.refresh_app_access_token',
'platform_api.refresh_tenant_access_token',
}
and getattr(bound.get('self'), 'config', {}).get('app_type', 'self') != 'isv'
):
# Self-built apps return {'ok': True} without checking/refreshing a token.
evidence = False
# A forwarding API may call another decorated method. Count the outer call.
if parent and parent.adapter_api_active:
evidence = False
if not evidence:
return {'_adapter_api_active': kind == 'api'}
return {
'_adapter_api_active': kind == 'api',
'_adapter_void_ack': completed_void_api(module, resolved),
'_adapter_read_ack': completed_read_api(module, resolved),
'operation': resolved,
'attributes': {'adapter_evidence': True, **message_scenario(bound)},
}
def message_scenario(bound):
"""Read typed routing/media categories only; never serialize a payload."""
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
from langbot_plugin.api.entities.builtin.platform.message import MessageChain
event = bound.get('event') or bound.get('message_source')
target = bound.get('target_type') or bound.get('chat_type')
message = bound.get('message') or bound.get('new_content')
if isinstance(event, MessageReceivedEvent):
target = event.chat_type
message = event.message_chain
result = {}
if target is not None:
target = getattr(target, 'value', target)
if target == 'private':
target = 'person'
result['chat_type'] = target if target in ('person', 'group') else 'unknown'
if isinstance(message, MessageChain):
types = set()
for item in message:
name = type(item).__name__
types.add(
{
'Plain': 'text',
'Image': 'image',
'Voice': 'audio',
'Audio': 'audio',
'Video': 'video',
'File': 'file',
}.get(name, 'other')
)
result['content_type'] = next(iter(types)) if len(types) == 1 else 'mixed' if types else 'unknown'
return result
# These exact API implementations always await an exception-raising SDK call,
# including SDKs whose successful acknowledgement has no return payload.
# Do not extend this to methods with conditional no-op or queued work.
VOID_ACKNOWLEDGED_APIS = {
'aiocqhttp': frozenset(
{
'delete_message',
'set_group_name',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
'approve_friend_request',
'approve_group_invite',
}
),
'discord': frozenset(
{
'edit_message',
'delete_message',
'mute_member',
'unmute_member',
'kick_member',
'leave_group',
}
),
'kook': frozenset({'delete_message'}),
}
def completed_void_api(module, operation):
return module.endswith('.api_impl') and any(
directory in module.split('.') and operation in operations
for directory, operations in VOID_ACKNOWLEDGED_APIS.items()
)
# These read methods return actual fetched/cached data or raise. Exclude the
# known identity-only placeholders and file-ID passthroughs: a typed result alone
# is not evidence that a lookup worked. Unsupported methods still raise normally.
_READ_APIS = frozenset(
{
'get_message',
'get_group_info',
'get_group_list',
'get_group_member_list',
'get_group_member_info',
'get_user_info',
'get_friend_list',
'get_file_url',
}
)
READ_ACKNOWLEDGED_APIS = {
'aiocqhttp': _READ_APIS,
'telegram': _READ_APIS,
'discord': _READ_APIS - {'get_file_url'},
'dingtalk': _READ_APIS - {'get_group_info', 'get_user_info'},
'kook': _READ_APIS - {'get_file_url'},
'lark': _READ_APIS - {'get_user_info', 'get_group_member_info', 'get_file_url'},
'officialaccount': _READ_APIS,
'qqofficial': _READ_APIS,
'slack': _READ_APIS,
'wecom': _READ_APIS - {'get_user_info'},
'wecombot': _READ_APIS,
'wecomcs': _READ_APIS,
}
def completed_read_api(module, operation):
return module.endswith('.api_impl') and any(
directory in module.split('.') and operation in operations
for directory, operations in READ_ACKNOWLEDGED_APIS.items()
)
def response_outcome(value, adapter=None):
"""Finite acknowledgement contracts; never inspect or export message content.
A MessageResult may contain an *inbound* message ID even when nothing was
sent. Only its actual raw acknowledgement is eligible. Empty/queued/unknown
returns are unconfirmed, not failures. Unknown SDK shapes need an explicit
confirmation at their operation boundary, not a truthiness fallback.
"""
from langbot_plugin.api.entities.builtin.platform.events import MessageResult
message_result = isinstance(value, MessageResult)
if message_result:
value = value.raw
if not isinstance(value, dict):
return 'skipped'
if (
value.get('ok') is False
or value.get('status') == 'failed'
or any(type(value.get(key)) is int and value[key] != 0 for key in ('retcode', 'errcode', 'code'))
):
return 'failed'
if value.get('queued') is True or value.get('status') == 'async':
return 'skipped'
# The wrappers that batch sends retain the real responses in these fields.
if 'results' in value:
results = value['results']
if not isinstance(results, list) or not results:
return 'skipped'
outcomes = [response_outcome(item, adapter) for item in results]
return 'failed' if 'failed' in outcomes else 'skipped' if 'skipped' in outcomes else 'succeeded'
for key in ('result', 'raw'):
if key in value:
return response_outcome(value[key], adapter)
if (
value.get('ok') is True
or value.get('status') == 'ok'
or any(type(value.get(key)) is int and value[key] == 0 for key in ('retcode', 'errcode', 'code'))
):
return 'succeeded'
if message_result:
# These wrappers put only SDK-returned IDs in raw (never the source ID).
if adapter in {'aiocqhttp-omni', 'discord-omni', 'telegram-omni'}:
message_id = value.get('message_id')
if type(message_id) in (str, int) and message_id:
return 'succeeded'
if adapter == 'lark-omni':
ids = value.get('message_ids')
if isinstance(ids, list) and ids and all(isinstance(item, str) and item for item in ids):
return 'succeeded'
return 'skipped'
def record_api_result(value, *, edited_content=None):
"""Record Telegram's Message/True acknowledgement and return it unchanged.
This runs after the SDK await, not after conversion or local stream setup.
No payload is retained; exceptions and the adapter's public return stay intact.
"""
try:
from .diagnostics import current_span
span = current_span()
if span is not None and span.kind == 'api' and span.fields.get('attributes', {}).get('adapter_evidence'):
from telegram import Message
outcome = (
'succeeded'
if value is True or isinstance(value, Message)
else 'failed'
if value is False
else 'skipped'
)
if outcome == 'succeeded' and edited_content is not None:
from langbot_plugin.api.entities.builtin.platform.message import Plain
if any(not isinstance(item, Plain) for item in edited_content):
outcome = 'partial'
# One unacknowledged component must not be hidden by a later success.
prior = span.adapter_api_result
span.adapter_api_result = (
'failed'
if 'failed' in (prior, outcome)
else 'skipped'
if 'skipped' in (prior, outcome)
else 'partial'
if 'partial' in (prior, outcome)
else outcome
)
except Exception:
pass
return value