From 4bd4ee6d1817f1860dad02441649cb13462a07f8 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 17:06:29 +0800 Subject: [PATCH 1/5] feat: add eba event probe demo plugin --- EBAEventProbe/.env.example | 7 +++ EBAEventProbe/.gitignore | 11 ++++ EBAEventProbe/README.md | 50 +++++++++++++++++++ EBAEventProbe/assets/icon.svg | 4 ++ EBAEventProbe/components/__init__.py | 1 + .../components/event_listener/__init__.py | 1 + .../components/event_listener/default.py | 40 +++++++++++++++ .../components/event_listener/default.yaml | 12 +++++ EBAEventProbe/main.py | 8 +++ EBAEventProbe/manifest.yaml | 24 +++++++++ 10 files changed, 158 insertions(+) create mode 100644 EBAEventProbe/.env.example create mode 100644 EBAEventProbe/.gitignore create mode 100644 EBAEventProbe/README.md create mode 100644 EBAEventProbe/assets/icon.svg create mode 100644 EBAEventProbe/components/__init__.py create mode 100644 EBAEventProbe/components/event_listener/__init__.py create mode 100644 EBAEventProbe/components/event_listener/default.py create mode 100644 EBAEventProbe/components/event_listener/default.yaml create mode 100644 EBAEventProbe/main.py create mode 100644 EBAEventProbe/manifest.yaml diff --git a/EBAEventProbe/.env.example b/EBAEventProbe/.env.example new file mode 100644 index 0000000..a32ae92 --- /dev/null +++ b/EBAEventProbe/.env.example @@ -0,0 +1,7 @@ +# This is a .env file example, please copy it to .env before running the plugin. +# Start the standalone runtime with `lbp rt --debug-only` first, then keep this +# URL aligned with the runtime debug websocket port. +DEBUG_RUNTIME_WS_URL=ws://localhost:5401/debug/ws + +# Optional. Defaults to eba_event_probe.jsonl in the plugin working directory. +EBA_PROBE_LOG=eba_event_probe.jsonl diff --git a/EBAEventProbe/.gitignore b/EBAEventProbe/.gitignore new file mode 100644 index 0000000..dfcc1ea --- /dev/null +++ b/EBAEventProbe/.gitignore @@ -0,0 +1,11 @@ +# Runtime output generated by this probe plugin. +eba_event_probe.jsonl + +# Local runtime connection settings. +.env + +# Python cache and local virtual environments. +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/EBAEventProbe/README.md b/EBAEventProbe/README.md new file mode 100644 index 0000000..32a5874 --- /dev/null +++ b/EBAEventProbe/README.md @@ -0,0 +1,50 @@ +# EBA Event Probe + +EBA Event Probe is a test plugin for the Event-Based Agent architecture. It registers one `EventListener` and records every supported EBA platform event it receives. + +It is useful when validating: + +- whether LangBot forwards platform EBA events into the plugin runtime; +- whether a standalone runtime can deliver EBA events to plugin listeners; +- whether new platform adapters expose the expected event coverage. + +## Events + +The listener currently records: + +- `MessageReceived` +- `MessageEdited` +- `MessageReactionReceived` +- `FeedbackReceived` +- `GroupMemberJoined` +- `GroupMemberLeft` +- `GroupMemberBanned` +- `BotInvitedToGroup` +- `BotRemovedFromGroup` +- `BotMuted` +- `BotUnmuted` +- `PlatformSpecificEventReceived` + +## Output + +Each received event is appended as one JSON object per line: + +```json +{"event_name":"MessageReceived","query_id":0,"event":{}} +``` + +Set `EBA_PROBE_LOG` to change the log path. If it is not set, the plugin writes to `eba_event_probe.jsonl` in the current working directory. + +## Standalone Runtime + +Start the runtime: + +```bash +lbp rt --debug-only +``` + +Copy `.env.example` to `.env`, adjust `DEBUG_RUNTIME_WS_URL` if needed, then run the plugin: + +```bash +lbp run +``` diff --git a/EBAEventProbe/assets/icon.svg b/EBAEventProbe/assets/icon.svg new file mode 100644 index 0000000..3784313 --- /dev/null +++ b/EBAEventProbe/assets/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/EBAEventProbe/components/__init__.py b/EBAEventProbe/components/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/EBAEventProbe/components/__init__.py @@ -0,0 +1 @@ + diff --git a/EBAEventProbe/components/event_listener/__init__.py b/EBAEventProbe/components/event_listener/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/EBAEventProbe/components/event_listener/__init__.py @@ -0,0 +1 @@ + diff --git a/EBAEventProbe/components/event_listener/default.py b/EBAEventProbe/components/event_listener/default.py new file mode 100644 index 0000000..d7f5a37 --- /dev/null +++ b/EBAEventProbe/components/event_listener/default.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from langbot_plugin.api.definition.components.common.event_listener import EventListener +from langbot_plugin.api.entities import context, events + + +class EBAEventProbeListener(EventListener): + def __init__(self): + super().__init__() + self.log_path = Path(os.getenv("EBA_PROBE_LOG", "eba_event_probe.jsonl")) + + for event_type in ( + events.MessageReceived, + events.MessageEdited, + events.MessageReactionReceived, + events.FeedbackReceived, + events.GroupMemberJoined, + events.GroupMemberLeft, + events.GroupMemberBanned, + events.BotInvitedToGroup, + events.BotRemovedFromGroup, + events.BotMuted, + events.BotUnmuted, + events.PlatformSpecificEventReceived, + ): + self.handler(event_type)(self._record) + + async def _record(self, event_context: context.EventContext): + record = { + "event_name": event_context.event_name, + "query_id": event_context.query_id, + "event": event_context.event.model_dump(), + } + with self.log_path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(record, ensure_ascii=False) + "\n") + print(f"EBA_PROBE_EVENT {event_context.event_name}") diff --git a/EBAEventProbe/components/event_listener/default.yaml b/EBAEventProbe/components/event_listener/default.yaml new file mode 100644 index 0000000..bd35d63 --- /dev/null +++ b/EBAEventProbe/components/event_listener/default.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: EventListener +metadata: + name: default + label: + en_US: EBA Event Probe Listener + zh_Hans: EBA 事件探针监听器 +spec: +execution: + python: + path: default.py + attr: EBAEventProbeListener diff --git a/EBAEventProbe/main.py b/EBAEventProbe/main.py new file mode 100644 index 0000000..bcccedc --- /dev/null +++ b/EBAEventProbe/main.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from langbot_plugin.api.definition.plugin import BasePlugin + + +class EBAEventProbePlugin(BasePlugin): + async def initialize(self) -> None: + return None diff --git a/EBAEventProbe/manifest.yaml b/EBAEventProbe/manifest.yaml new file mode 100644 index 0000000..83be5b0 --- /dev/null +++ b/EBAEventProbe/manifest.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Plugin +metadata: + author: LangBot + name: EBAEventProbe + repository: https://github.com/langbot-app/langbot-plugin-demo + version: 0.1.0 + description: + en_US: Probe plugin for validating Event-Based Agent event delivery. + zh_Hans: 用于验证 Event-Based Agent 事件分发的探针插件。 + label: + en_US: EBA Event Probe + zh_Hans: EBA 事件探针 + icon: assets/icon.svg +spec: + config: [] + components: + EventListener: + fromDirs: + - path: components/event_listener/ +execution: + python: + path: main.py + attr: EBAEventProbePlugin From f8da67fb9b6b01ee6099c5e09ed8656419fe6589 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 17:15:47 +0800 Subject: [PATCH 2/5] test: extend eba probe plugin api coverage --- EBAEventProbe/.env.example | 4 + EBAEventProbe/README.md | 24 ++ .../components/event_listener/default.py | 81 +++++ .../scripts/standalone_runtime_probe.py | 303 ++++++++++++++++++ 4 files changed, 412 insertions(+) create mode 100644 EBAEventProbe/scripts/standalone_runtime_probe.py diff --git a/EBAEventProbe/.env.example b/EBAEventProbe/.env.example index a32ae92..95b2edc 100644 --- a/EBAEventProbe/.env.example +++ b/EBAEventProbe/.env.example @@ -5,3 +5,7 @@ DEBUG_RUNTIME_WS_URL=ws://localhost:5401/debug/ws # Optional. Defaults to eba_event_probe.jsonl in the plugin working directory. EBA_PROBE_LOG=eba_event_probe.jsonl + +# Optional. Set to 1 to make the plugin call LangBot plugin APIs when it +# receives the first MessageReceived event. +EBA_PROBE_API=0 diff --git a/EBAEventProbe/README.md b/EBAEventProbe/README.md index 32a5874..62612e2 100644 --- a/EBAEventProbe/README.md +++ b/EBAEventProbe/README.md @@ -5,6 +5,7 @@ EBA Event Probe is a test plugin for the Event-Based Agent architecture. It regi It is useful when validating: - whether LangBot forwards platform EBA events into the plugin runtime; +- whether plugin API calls still work from an EBA event handler; - whether a standalone runtime can deliver EBA events to plugin listeners; - whether new platform adapters expose the expected event coverage. @@ -35,6 +36,23 @@ Each received event is appended as one JSON object per line: Set `EBA_PROBE_LOG` to change the log path. If it is not set, the plugin writes to `eba_event_probe.jsonl` in the current working directory. +## API Probe + +Set `EBA_PROBE_API=1` to make the listener call plugin APIs after the first `MessageReceived` event: + +- `get_langbot_version` +- `get_bots` +- `get_bot_info` +- `send_message` +- plugin storage set/get/list/delete +- workspace storage set/get/list/delete +- `list_plugins_manifest` +- `list_commands` +- `list_tools` +- `list_knowledge_bases` + +Query-based APIs such as `EventContext.reply()` are intentionally not called by this EBA probe because standalone EBA platform events do not have a pipeline query context. + ## Standalone Runtime Start the runtime: @@ -48,3 +66,9 @@ Copy `.env.example` to `.env`, adjust `DEBUG_RUNTIME_WS_URL` if needed, then run ```bash lbp run ``` + +Run the standalone probe driver from this plugin directory to verify both event delivery and API calls: + +```bash +python scripts/standalone_runtime_probe.py +``` diff --git a/EBAEventProbe/components/event_listener/default.py b/EBAEventProbe/components/event_listener/default.py index d7f5a37..da4d9c4 100644 --- a/EBAEventProbe/components/event_listener/default.py +++ b/EBAEventProbe/components/event_listener/default.py @@ -6,12 +6,15 @@ from langbot_plugin.api.definition.components.common.event_listener import EventListener from langbot_plugin.api.entities import context, events +from langbot_plugin.api.entities.builtin.platform import message as platform_message class EBAEventProbeListener(EventListener): def __init__(self): super().__init__() self.log_path = Path(os.getenv("EBA_PROBE_LOG", "eba_event_probe.jsonl")) + self.api_probe_enabled = os.getenv("EBA_PROBE_API") == "1" + self.api_probe_done = False for event_type in ( events.MessageReceived, @@ -38,3 +41,81 @@ async def _record(self, event_context: context.EventContext): with self.log_path.open("a", encoding="utf-8") as fp: fp.write(json.dumps(record, ensure_ascii=False) + "\n") print(f"EBA_PROBE_EVENT {event_context.event_name}") + + if ( + self.api_probe_enabled + and not self.api_probe_done + and isinstance(event_context.event, events.MessageReceived) + ): + self.api_probe_done = True + await self._probe_plugin_apis(event_context.event) + + async def _probe_plugin_apis(self, event: events.MessageReceived): + api_result = {"event_name": "APIProbe", "ok": True, "calls": []} + + try: + version = await self.plugin.get_langbot_version() + api_result["calls"].append({"name": "get_langbot_version", "result": version}) + + bots = await self.plugin.get_bots() + api_result["calls"].append({"name": "get_bots", "result": bots}) + + if bots: + bot_info = await self.plugin.get_bot_info(bots[0]) + api_result["calls"].append({"name": "get_bot_info", "result": bot_info}) + + target_type = "group" if event.chat_type == "group" else "person" + await self.plugin.send_message( + bots[0], + target_type, + event.chat_id, + platform_message.MessageChain( + [platform_message.Plain(text="EBA API probe message")] + ), + ) + api_result["calls"].append({"name": "send_message", "result": "ok"}) + + await self.plugin.set_plugin_storage("eba_probe_plugin", b"plugin-value") + plugin_value = await self.plugin.get_plugin_storage("eba_probe_plugin") + plugin_keys = await self.plugin.get_plugin_storage_keys() + await self.plugin.delete_plugin_storage("eba_probe_plugin") + api_result["calls"].append( + { + "name": "plugin_storage", + "result": {"value": plugin_value.decode("utf-8"), "keys": plugin_keys}, + } + ) + + await self.plugin.set_workspace_storage("eba_probe_workspace", b"workspace-value") + workspace_value = await self.plugin.get_workspace_storage("eba_probe_workspace") + workspace_keys = await self.plugin.get_workspace_storage_keys() + await self.plugin.delete_workspace_storage("eba_probe_workspace") + api_result["calls"].append( + { + "name": "workspace_storage", + "result": { + "value": workspace_value.decode("utf-8"), + "keys": workspace_keys, + }, + } + ) + + manifests = await self.plugin.list_plugins_manifest() + commands = await self.plugin.list_commands() + tools = await self.plugin.list_tools() + knowledge_bases = await self.plugin.list_knowledge_bases() + api_result["calls"].extend( + [ + {"name": "list_plugins_manifest", "result": manifests}, + {"name": "list_commands", "result": commands}, + {"name": "list_tools", "result": tools}, + {"name": "list_knowledge_bases", "result": knowledge_bases}, + ] + ) + except Exception as exc: + api_result["ok"] = False + api_result["error"] = repr(exc) + + with self.log_path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(api_result, ensure_ascii=False) + "\n") + print(f"EBA_PROBE_API {'OK' if api_result['ok'] else 'FAILED'}") diff --git a/EBAEventProbe/scripts/standalone_runtime_probe.py b/EBAEventProbe/scripts/standalone_runtime_probe.py new file mode 100644 index 0000000..7de4bc1 --- /dev/null +++ b/EBAEventProbe/scripts/standalone_runtime_probe.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + +import websockets + + +CONTROL_URL = os.getenv("EBA_PROBE_CONTROL_URL", "ws://127.0.0.1:5410/control/ws") +PLUGIN_ID = ("LangBot", "EBAEventProbe") + + +class RuntimeControlClient: + def __init__(self, websocket): + self.websocket = websocket + self.seq = 0 + self.waiters: dict[int, asyncio.Future[dict[str, Any]]] = {} + self.binary_storage: dict[str, str] = {} + self.api_calls: list[str] = [] + + async def start_reader(self): + async for message in self.websocket: + payload = json.loads(message) + if "action" in payload: + await self._handle_runtime_action(payload) + elif "code" in payload: + seq_id = payload["seq_id"] + waiter = self.waiters.pop(seq_id, None) + if waiter and not waiter.done(): + waiter.set_result(payload) + + async def _handle_runtime_action(self, payload: dict[str, Any]): + action = payload["action"] + data = payload.get("data", {}) + self.api_calls.append(action) + + if action == "initialize_plugin_settings": + response_data: dict[str, Any] = {} + elif action == "get_plugin_settings": + response_data = { + "enabled": True, + "priority": 0, + "plugin_config": {}, + "install_source": "debug", + "install_info": {}, + } + elif action == "get_langbot_version": + response_data = {"version": "standalone-probe"} + elif action == "get_bots": + response_data = {"bots": ["bot-eba-probe"]} + elif action == "get_bot_info": + response_data = {"bot": {"uuid": data["bot_uuid"], "name": "EBA Probe Bot"}} + elif action == "send_message": + response_data = { + "message_id": "sent-by-standalone-probe", + "echo": data, + } + elif action == "set_binary_storage": + self.binary_storage[self._binary_storage_key(data)] = data["value_base64"] + response_data = {} + elif action == "get_binary_storage": + response_data = { + "value_base64": self.binary_storage[self._binary_storage_key(data)] + } + elif action == "get_binary_storage_keys": + prefix = f"{data['owner_type']}:{data['owner']}:" + response_data = { + "keys": sorted( + key.removeprefix(prefix) + for key in self.binary_storage + if key.startswith(prefix) + ) + } + elif action == "delete_binary_storage": + self.binary_storage.pop(self._binary_storage_key(data), None) + response_data = {} + elif action == "list_knowledge_bases": + response_data = {"knowledge_bases": []} + else: + response_data = {} + + await self.websocket.send( + json.dumps( + { + "seq_id": payload["seq_id"], + "code": 0, + "message": "success", + "data": response_data, + "chunk_status": "continue", + } + ) + ) + + @staticmethod + def _binary_storage_key(data: dict[str, Any]) -> str: + return f"{data['owner_type']}:{data['owner']}:{data['key']}" + + async def call( + self, action: str, data: dict[str, Any], timeout: float = 10.0 + ) -> dict[str, Any]: + self.seq += 1 + seq_id = self.seq + waiter: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self.waiters[seq_id] = waiter + await self.websocket.send(json.dumps({"seq_id": seq_id, "action": action, "data": data})) + response = await asyncio.wait_for(waiter, timeout) + if response["code"] != 0: + raise RuntimeError(response["message"]) + return response["data"] + + +def event_context(event_name: str, event: dict[str, Any], eid: int) -> dict[str, Any]: + return { + "query_id": 0, + "eid": eid, + "event_name": event_name, + "event": {"event_name": event_name, **event}, + "is_prevent_default": False, + "is_prevent_postorder": False, + } + + +def probe_events() -> list[dict[str, Any]]: + group = {"id": "group-1", "name": "Probe Group"} + user = {"id": "user-1", "nickname": "Probe User", "is_bot": False} + return [ + event_context( + "MessageReceived", + { + "message_id": "msg-1", + "message_chain": [{"type": "Plain", "text": "hello"}], + "sender": user, + "chat_type": "private", + "chat_id": "user-1", + "group": None, + }, + 1, + ), + event_context( + "MessageEdited", + { + "message_id": "msg-2", + "new_content": [{"type": "Plain", "text": "edited"}], + "editor": user, + "chat_type": "private", + "chat_id": "user-1", + "group": None, + }, + 2, + ), + event_context( + "MessageReactionReceived", + { + "message_id": "msg-3", + "user": user, + "reaction": "like", + "is_add": True, + "chat_type": "group", + "chat_id": "group-1", + "group": group, + }, + 3, + ), + event_context( + "FeedbackReceived", + { + "feedback_id": "fb-1", + "feedback_type": 2, + "feedback_content": "not accurate", + "inaccurate_reasons": ["wrong_answer"], + "user_id": "user-1", + "session_id": "person_user-1", + "message_id": "msg-4", + "stream_id": "stream-1", + }, + 4, + ), + event_context("GroupMemberJoined", {"group": group, "member": user, "inviter": user, "join_type": "invite"}, 5), + event_context("GroupMemberLeft", {"group": group, "member": user, "is_kicked": True, "operator": user}, 6), + event_context("GroupMemberBanned", {"group": group, "member": user, "operator": user, "duration": 60}, 7), + event_context("BotInvitedToGroup", {"group": group, "inviter": user, "request_id": "req-1"}, 8), + event_context("BotRemovedFromGroup", {"group": group, "operator": user}, 9), + event_context("BotMuted", {"group": group, "operator": user, "duration": 60}, 10), + event_context("BotUnmuted", {"group": group, "operator": user}, 11), + event_context( + "PlatformSpecificEventReceived", + {"adapter_name": "telegram", "action": "callback_query", "data": {"data": "button"}}, + 12, + ), + ] + + +async def wait_for_probe_plugin(client: RuntimeControlClient): + for _ in range(30): + plugins = await client.call("list_plugins", {}) + for plugin in plugins["plugins"]: + metadata = plugin["manifest"]["manifest"]["metadata"] + if (metadata["author"], metadata["name"]) == PLUGIN_ID: + return + await asyncio.sleep(1) + raise TimeoutError("EBAEventProbe did not register with standalone runtime") + + +async def main() -> int: + log_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("eba_event_probe.jsonl") + if log_path.exists(): + log_path.unlink() + + async with websockets.connect(CONTROL_URL, open_timeout=10) as websocket: + client = RuntimeControlClient(websocket) + reader_task = asyncio.create_task(client.start_reader()) + try: + await wait_for_probe_plugin(client) + for event_ctx in probe_events(): + result = await client.call( + "emit_event", + { + "event_context": event_ctx, + "include_plugins": ["LangBot/EBAEventProbe"], + }, + timeout=20, + ) + if not result["emitted_plugins"]: + raise RuntimeError(f"Event was not emitted: {event_ctx['event_name']}") + + expected_events = [event["event_name"] for event in probe_events()] + expected_plugin_api_names = { + "get_langbot_version", + "get_bots", + "get_bot_info", + "send_message", + "plugin_storage", + "workspace_storage", + "list_plugins_manifest", + "list_commands", + "list_tools", + "list_knowledge_bases", + } + expected_forwarded_api_calls = { + "get_langbot_version", + "get_bots", + "get_bot_info", + "send_message", + "set_binary_storage", + "get_binary_storage", + "get_binary_storage_keys", + "delete_binary_storage", + "list_knowledge_bases", + } + + for _ in range(20): + if log_path.exists(): + lines = [ + json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + ] + seen_events = [ + line["event_name"] + for line in lines + if line["event_name"] != "APIProbe" + ] + api_probe = next( + (line for line in lines if line["event_name"] == "APIProbe"), None + ) + api_probe_names = { + call["name"] for call in api_probe["calls"] + } if api_probe else set() + if ( + seen_events[-len(expected_events) :] == expected_events + and api_probe + and api_probe["ok"] + and expected_plugin_api_names <= api_probe_names + and expected_forwarded_api_calls <= set(client.api_calls) + ): + print( + json.dumps( + { + "ok": True, + "events": seen_events[-len(expected_events) :], + "plugin_api_calls": sorted( + expected_plugin_api_names + ), + "forwarded_api_actions": sorted( + expected_forwarded_api_calls + ), + }, + ensure_ascii=False, + ) + ) + return 0 + await asyncio.sleep(0.5) + + raise TimeoutError("Probe plugin did not complete events and API calls") + finally: + reader_task.cancel() + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) From 478118a0c952907d67db091ee7de31b0cc0aa964 Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Thu, 7 May 2026 23:05:04 +0800 Subject: [PATCH 3/5] test: update eba event probe for bot dicts --- EBAEventProbe/components/event_listener/default.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/EBAEventProbe/components/event_listener/default.py b/EBAEventProbe/components/event_listener/default.py index da4d9c4..2d1a6df 100644 --- a/EBAEventProbe/components/event_listener/default.py +++ b/EBAEventProbe/components/event_listener/default.py @@ -19,6 +19,7 @@ def __init__(self): for event_type in ( events.MessageReceived, events.MessageEdited, + events.MessageDeleted, events.MessageReactionReceived, events.FeedbackReceived, events.GroupMemberJoined, @@ -61,12 +62,15 @@ async def _probe_plugin_apis(self, event: events.MessageReceived): api_result["calls"].append({"name": "get_bots", "result": bots}) if bots: - bot_info = await self.plugin.get_bot_info(bots[0]) + selected_bot = next((bot for bot in bots if isinstance(bot, dict) and bot.get("enable")), bots[0]) + selected_bot_uuid = selected_bot["uuid"] if isinstance(selected_bot, dict) else selected_bot + + bot_info = await self.plugin.get_bot_info(selected_bot_uuid) api_result["calls"].append({"name": "get_bot_info", "result": bot_info}) target_type = "group" if event.chat_type == "group" else "person" await self.plugin.send_message( - bots[0], + selected_bot_uuid, target_type, event.chat_id, platform_message.MessageChain( From c69831990f3e3e9c2eb1749f0e77e94563b15ebb Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 10 May 2026 18:58:18 +0800 Subject: [PATCH 4/5] test: expand eba event probe coverage --- EBAEventProbe/README.md | 7 + .../components/event_listener/default.py | 241 +++++++++++++++++- 2 files changed, 245 insertions(+), 3 deletions(-) diff --git a/EBAEventProbe/README.md b/EBAEventProbe/README.md index 62612e2..a93fccf 100644 --- a/EBAEventProbe/README.md +++ b/EBAEventProbe/README.md @@ -15,6 +15,7 @@ The listener currently records: - `MessageReceived` - `MessageEdited` +- `MessageDeleted` - `MessageReactionReceived` - `FeedbackReceived` - `GroupMemberJoined` @@ -51,6 +52,12 @@ Set `EBA_PROBE_API=1` to make the listener call plugin APIs after the first `Mes - `list_tools` - `list_knowledge_bases` +Additional probe flags: + +- `EBA_PROBE_COMPONENT_SWEEP=1` sends a component matrix to the triggering chat: plain text, mentions, `AtAll` in groups, base64 image, quote, file, and flattened forward. +- `EBA_PROBE_PLATFORM_API=1` calls safe common platform APIs and selected `call_platform_api` actions for the adapter. +- `EBA_PROBE_DESTRUCTIVE=1` enables destructive or externally visible moderation-style calls. Keep it disabled unless the test uses disposable targets. + Query-based APIs such as `EventContext.reply()` are intentionally not called by this EBA probe because standalone EBA platform events do not have a pipeline query context. ## Standalone Runtime diff --git a/EBAEventProbe/components/event_listener/default.py b/EBAEventProbe/components/event_listener/default.py index 2d1a6df..cccda0b 100644 --- a/EBAEventProbe/components/event_listener/default.py +++ b/EBAEventProbe/components/event_listener/default.py @@ -3,17 +3,27 @@ import json import os from pathlib import Path +from typing import Any from langbot_plugin.api.definition.components.common.event_listener import EventListener from langbot_plugin.api.entities import context, events from langbot_plugin.api.entities.builtin.platform import message as platform_message +TINY_PNG = ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" +) + + class EBAEventProbeListener(EventListener): def __init__(self): super().__init__() self.log_path = Path(os.getenv("EBA_PROBE_LOG", "eba_event_probe.jsonl")) self.api_probe_enabled = os.getenv("EBA_PROBE_API") == "1" + self.component_sweep_enabled = os.getenv("EBA_PROBE_COMPONENT_SWEEP") == "1" + self.platform_api_probe_enabled = os.getenv("EBA_PROBE_PLATFORM_API") == "1" + self.destructive_probe_enabled = os.getenv("EBA_PROBE_DESTRUCTIVE") == "1" self.api_probe_done = False for event_type in ( @@ -62,14 +72,21 @@ async def _probe_plugin_apis(self, event: events.MessageReceived): api_result["calls"].append({"name": "get_bots", "result": bots}) if bots: - selected_bot = next((bot for bot in bots if isinstance(bot, dict) and bot.get("enable")), bots[0]) + selected_bot = next( + ( + bot + for bot in bots + if isinstance(bot, dict) and bot.get("uuid") == event.bot_uuid + ), + next((bot for bot in bots if isinstance(bot, dict) and bot.get("enable")), bots[0]), + ) selected_bot_uuid = selected_bot["uuid"] if isinstance(selected_bot, dict) else selected_bot bot_info = await self.plugin.get_bot_info(selected_bot_uuid) api_result["calls"].append({"name": "get_bot_info", "result": bot_info}) target_type = "group" if event.chat_type == "group" else "person" - await self.plugin.send_message( + send_result = await self.plugin.send_message( selected_bot_uuid, target_type, event.chat_id, @@ -77,7 +94,25 @@ async def _probe_plugin_apis(self, event: events.MessageReceived): [platform_message.Plain(text="EBA API probe message")] ), ) - api_result["calls"].append({"name": "send_message", "result": "ok"}) + api_result["calls"].append({"name": "send_message", "result": send_result or "ok"}) + + if self.component_sweep_enabled: + api_result["calls"].append( + { + "name": "component_sweep", + "result": await self._probe_outbound_components( + selected_bot_uuid, target_type, event + ), + } + ) + + if self.platform_api_probe_enabled: + api_result["calls"].append( + { + "name": "platform_api_sweep", + "result": await self._probe_platform_apis(selected_bot_uuid, event), + } + ) await self.plugin.set_plugin_storage("eba_probe_plugin", b"plugin-value") plugin_value = await self.plugin.get_plugin_storage("eba_probe_plugin") @@ -123,3 +158,203 @@ async def _probe_plugin_apis(self, event: events.MessageReceived): with self.log_path.open("a", encoding="utf-8") as fp: fp.write(json.dumps(api_result, ensure_ascii=False) + "\n") print(f"EBA_PROBE_API {'OK' if api_result['ok'] else 'FAILED'}") + + async def _probe_outbound_components( + self, + bot_uuid: str, + target_type: str, + event: events.MessageReceived, + ) -> list[dict[str, Any]]: + cases = [ + ( + "plain_at_face", + platform_message.MessageChain( + [ + platform_message.Plain(text="EBA component plain+at+face "), + platform_message.At(target=event.sender.id), + platform_message.Face(face_id=14, face_name="微笑"), + ] + ), + ), + ( + "image_base64", + platform_message.MessageChain( + [ + platform_message.Plain(text="EBA component image "), + platform_message.Image(base64=TINY_PNG), + ] + ), + ), + ( + "quote", + platform_message.MessageChain( + [ + platform_message.Quote( + id=event.message_id, + group_id=event.chat_id if event.chat_type == "group" else None, + sender_id=event.sender.id, + target_id=event.chat_id, + origin=event.message_chain, + ), + platform_message.Plain(text="EBA component quote"), + ] + ), + ), + ( + "file_base64", + platform_message.MessageChain( + [ + platform_message.Plain(text="EBA component file "), + platform_message.File( + name="eba-probe.txt", + base64="ZmlsZSBmcm9tIEVCQSBwcm9iZQo=", + size=20, + ), + ] + ), + ), + ( + "forward", + platform_message.MessageChain( + [ + platform_message.Forward( + node_list=[ + platform_message.ForwardMessageNode( + sender_id=event.sender.id, + sender_name=event.sender.nickname or "EBAProbe", + message_chain=platform_message.MessageChain( + [platform_message.Plain(text="forward node from EBA probe")] + ), + ) + ] + ) + ] + ), + ), + ] + if event.chat_type == "group": + cases.insert( + 1, + ( + "at_all", + platform_message.MessageChain( + [ + platform_message.Plain(text="EBA component at_all "), + platform_message.AtAll(), + ] + ), + ), + ) + + results = [] + for name, message_chain in cases: + try: + result = await self.plugin.send_message( + bot_uuid, + target_type, + event.chat_id, + message_chain, + ) + results.append({"name": name, "ok": True, "result": result}) + except Exception as exc: + results.append({"name": name, "ok": False, "error": repr(exc)}) + return results + + async def _probe_platform_apis( + self, + bot_uuid: str, + event: events.MessageReceived, + ) -> list[dict[str, Any]]: + group_id = ( + event.group.id + if event.chat_type == "group" and event.group + else event.chat_id if event.chat_type == "group" else None + ) + user_id = event.sender.id + calls: list[tuple[str, dict[str, Any]]] = [ + ("get_message", {"chat_type": event.chat_type, "chat_id": event.chat_id, "message_id": event.message_id}), + ("get_user_info", {"user_id": user_id}), + ("get_friend_list", {}), + ] + if group_id: + calls.extend( + [ + ("get_group_info", {"group_id": group_id}), + ("get_group_list", {}), + ("get_group_member_list", {"group_id": group_id}), + ("get_group_member_info", {"group_id": group_id, "user_id": user_id}), + ] + ) + if self.destructive_probe_enabled and group_id: + calls.extend( + [ + ("mute_member", {"group_id": group_id, "user_id": user_id, "duration": 1}), + ("unmute_member", {"group_id": group_id, "user_id": user_id}), + ] + ) + + adapter_name = (event.adapter_name or "").lower() + if "aiocqhttp" in adapter_name: + for action, params in ( + ("get_login_info", {}), + ("get_status", {}), + ("get_version_info", {}), + ("can_send_image", {}), + ("can_send_record", {}), + ): + calls.append(("call_platform_api", {"action": action, "params": params})) + if group_id: + calls.append(("call_platform_api", {"action": "get_group_honor_info", "params": {"group_id": int(group_id), "type": "all"}})) + elif "telegram" in adapter_name and group_id: + calls.extend( + [ + ("call_platform_api", {"action": "get_chat_administrators", "params": {"chat_id": group_id}}), + ("call_platform_api", {"action": "get_chat_member_count", "params": {"chat_id": group_id}}), + ( + "call_platform_api", + { + "action": "send_chat_action", + "params": {"chat_id": group_id, "action": "typing"}, + }, + ), + ] + ) + elif "discord" in adapter_name: + calls.extend( + [ + ("call_platform_api", {"action": "get_channel", "params": {"channel_id": event.chat_id}}), + ("call_platform_api", {"action": "typing", "params": {"channel_id": event.chat_id}}), + ] + ) + guild_id = event.group.id if event.group else None + if guild_id: + calls.extend( + [ + ("call_platform_api", {"action": "get_guild", "params": {"guild_id": guild_id}}), + ("call_platform_api", {"action": "get_guild_channels", "params": {"guild_id": guild_id}}), + ("call_platform_api", {"action": "get_guild_roles", "params": {"guild_id": guild_id}}), + ] + ) + if self.destructive_probe_enabled: + calls.append( + ( + "call_platform_api", + { + "action": "add_reaction", + "params": { + "channel_id": event.chat_id, + "message_id": event.message_id, + "emoji": "✅", + }, + }, + ) + ) + + results = [] + for name, params in calls: + try: + result = await self.plugin.call_platform_api(bot_uuid, name, params) + results.append({"name": name, "ok": True, "result": result}) + except Exception as exc: + results.append({"name": name, "ok": False, "error": repr(exc)}) + return results From d827fabeeb3b7cd5483795cdcbf9382ad18f8dab Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Sun, 10 May 2026 19:52:11 +0800 Subject: [PATCH 5/5] test: add dingtalk eba probe api sweep --- EBAEventProbe/components/event_listener/default.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EBAEventProbe/components/event_listener/default.py b/EBAEventProbe/components/event_listener/default.py index cccda0b..89be128 100644 --- a/EBAEventProbe/components/event_listener/default.py +++ b/EBAEventProbe/components/event_listener/default.py @@ -349,6 +349,8 @@ async def _probe_platform_apis( }, ) ) + elif "dingtalk" in adapter_name: + calls.append(("call_platform_api", {"action": "check_access_token", "params": {}})) results = [] for name, params in calls: