"""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-omni', 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'), )