API Audit
Lingchu Bot records structured audit records for every stage of message processing: event receipt, matcher outcome, bot lifecycle, and platform API calls. The audit pipeline is built on the message store runtime hooks and extended with a command-level CommandAudit payload used by local and remote group-management handlers.
Pipeline overview
Section titled “Pipeline overview”Audit data flows through three layers, all of which live under hooks/ and services/.
| Stage | Hook / Caller | Handler | Record type |
|---|---|---|---|
| Event receipt | event_preprocessor |
hooks/handlers/message_store.py::message_store_preprocessor |
MessageRecord (or QQOneBotV11NoneBotEventRecord) |
| Matcher result | run_postprocessor |
hooks/handlers/message_store.py::message_store_run_postprocessor |
updates process_status on the event record |
| Bot lifecycle | driver.on_bot_connect / on_bot_disconnect |
hooks/handlers/bot_connection.py |
AuditRecord with audit_type = "lifecycle" |
| Platform API call | Bot.on_called_api |
hooks/handlers/api_audit.py::on_called_api |
AuditRecord with audit_type = "api_call" |
| Command audit | handler call site | handle/qq/adapters/onebot11/default/common.py::record_command_audit |
AuditRecord with audit_type = "command" |
Every stage funnels work through core/async_utils.py::fire_and_forget(coro, *, name=...) so the NoneBot event loop is never blocked by storage writes. The platform adapter layer in hooks/adapters.py resolves a Bot to a PlatformContext(platform_id, adapter_id, bot_id, protocol_id) and normalizes events into a NormalizedMessageEvent so handlers stay adapter-agnostic.
Configuration
Section titled “Configuration”Audit deployment settings are read from the NoneBot environment or its .env files through core/config.py::Config. They are not read from config.toml. Online-editable overrides are stored separately in the localstore-owned runtime-overrides.toml; currently that file owns trigger and platform-runtime permission overrides, not the audit fields below.
| Field | NoneBot environment / .env |
Default | Purpose |
|---|---|---|---|
message_store_enabled |
LINGCHU_MESSAGE_STORE_ENABLED |
true |
Master switch for the whole pipeline (event, lifecycle, API, command) |
message_store_retention_days |
LINGCHU_MESSAGE_STORE_RETENTION_DAYS |
30 |
Days to retain records; 0 disables day-based expiry |
message_store_summary_limit |
LINGCHU_MESSAGE_STORE_SUMMARY_LIMIT |
500 |
Max length for text, data, and result summaries |
message_store_record_api_calls |
LINGCHU_MESSAGE_STORE_RECORD_API_CALLS |
true |
Whether to record platform API call summaries |
message_store_cleanup_enabled |
LINGCHU_MESSAGE_STORE_CLEANUP_ENABLED |
true |
Whether to clean expired records (shutdown + periodic job) |
When message_store_enabled = false, every handler short-circuits and no records are written. When message_store_record_api_calls = false, the on_called_api hook and handle_api_called() skip API call recording, but event, matcher, lifecycle, and command records are still written.
Event and matcher records
Section titled “Event and matcher records”message_store_preprocessor runs before any matcher. It calls normalize_message_event(bot, event) from hooks/adapters.py to produce a NormalizedMessageEvent containing identity, event type, message type, text summary, and raw payloads. The MessageIdentity is stashed in state[STATE_KEY] ("_lingchu_message_record_identity") so downstream hooks can correlate the record. The write is dispatched through fire_and_forget(handle_event_received(normalized), name="record_event_received").
message_store_run_postprocessor runs after the matcher completes. It reads the identity back from state, computes a process_status string, and calls handle_matcher_result(identity, matcher, exception, ...):
"handled"when the matcher returned without exception,"failed"when the matcher raised,":blocked"is appended whenmatcher.blockisTrue.
event_postprocessor and run_preprocessor are registered as reserved no-op placeholders for future aggregate updates and matcher timing.
Bot lifecycle records
Section titled “Bot lifecycle records”hooks/handlers/bot_connection.py registers driver.on_bot_connect and driver.on_bot_disconnect:
on_bot_connectfire-and-forgetsrecord_bot_lifecycle(bot, "bot_connected")andsend_pending_restart_feedback(bot).on_bot_disconnectfire-and-forgetsrecord_bot_lifecycle(bot, "bot_disconnected").
record_bot_lifecycle() in services/message_store.py resolves the adapter to a platform profile through resolve_adapter_id / get_platform_profile. If the adapter is unknown or the platform is not enabled, it returns False without writing anything. Otherwise it builds an AuditEvent with audit_type = "lifecycle", api_name = event_type, and empty data/result/exception summaries, and calls repository.record_api_call().
Platform API call audit
Section titled “Platform API call audit”hooks/handlers/api_audit.py registers two NoneBot hooks:
Bot.on_calling_api—on_calling_api(bot, api, data)is a reserved no-op placeholder for future correlation identifiers.Bot.on_called_api—on_called_api(bot, exception, api, data, result)records the call result.
on_called_api short-circuits when message_store_enabled or message_store_record_api_calls is false, or when resolve_platform_context(bot) returns None (unknown adapter). Otherwise it fire-and-forgets handle_api_called(platform_context, exception, api, data, result).
handle_api_called() builds an AuditEvent with audit_type = "api_call", the API name, and stringified data/result/exception summaries (truncated to message_store_summary_limit characters through _stringify/_truncate). The write is routed through repository.record_api_call().
Command audit
Section titled “Command audit”Group-management handlers (kick, mute, set member card/title/admin, block, remote operations) record an additional command-level audit entry after the platform API succeeds. The shared helpers live in handle/qq/adapters/onebot11/default/common.py.
CommandAudit payload
Section titled “CommandAudit payload”CommandAudit is a frozen dataclass with the fields a group-management action needs:
| Field | Type | Purpose |
|---|---|---|
action |
str |
Stable action name, for example set_member_card, kick_member |
target_user_id |
int | None |
The user the action targets |
reason |
str | None |
Operator-supplied reason |
duration |
int | None |
Duration in seconds (for mute actions) |
group_id |
int | None |
Override group; falls back to event.group_id |
Recording functions
Section titled “Recording functions”| Function | Module | Behavior |
|---|---|---|
record_command_audit(bot, event, audit) |
handle/qq/adapters/onebot11/default/common.py |
Builds a data_summary string (operator=..., target=..., action=..., group=..., plus duration= / reason= when present) and calls repository.record_api_call() with api_name = f"command:{audit.action}", audit_type = "command", and result_summary = "success". Catches DatabaseError and logs. |
record_audit_fire_and_forget(bot, event, audit) |
same module | Wraps record_command_audit in fire_and_forget(..., name="record_command_audit") so the matcher is not blocked by the write. |
Handlers call record_audit_fire_and_forget after a successful platform API call:
await record_audit_fire_and_forget( bot, event, CommandAudit(action="set_member_card", target_user_id=target_user_id),)Command audit records share the AuditRecord table with platform API call and lifecycle records, but audit_type = "command" and api_name prefixed with command: make them easy to filter.
Repository layer
Section titled “Repository layer”repositories/message_store.py owns the actual ORM writes. It selects the ORM model per record based on the platform/adapter/framework combination:
- QQ +
~onebot.v11+nonebotuses the dedicated partition tablesQQOneBotV11NoneBotEventRecordandQQOneBotV11NoneBotAuditRecord. - Any other combination falls back to the legacy global
MessageRecordandAuditRecordtables.
record_event_received() upserts on (platform_id, adapter_id, protocol_id, bot_id, conversation_id, message_id) when message_id is present, so an event received twice does not create a duplicate row. record_matcher_result() looks up the existing row by the same key and updates process_status and exception_summary. record_api_call() always creates a new row because each call is a distinct event.
Retention and cleanup
Section titled “Retention and cleanup”Expired records are deleted by cleanup_expired_messages(retention_days) in repositories/message_store.py. The cutoff is datetime.now(UTC) - timedelta(days=retention_days). Four tables are cleaned in sequence:
MessageRecordAuditRecordQQOneBotV11NoneBotEventRecordQQOneBotV11NoneBotAuditRecord
The function returns (total_count, all_known) where all_known is True only if every delete reported a known table. When retention_days <= 0 it returns (0, True) without deleting anything.
Cleanup is triggered in two places:
- On shutdown through
shutdown_message_store(), gated bymessage_store_cleanup_enabled. - By the periodic scheduler job keyed
message_store.cleanup_expired_messages, registered instart/startup.py. See Scheduler for the registration flow.
fire_and_forget semantics
Section titled “fire_and_forget semantics”core/async_utils.py::fire_and_forget(coro, *, name="fire_and_forget") is the single mechanism every audit stage uses to avoid blocking the event loop.
- The coroutine is scheduled as an
asyncio.Taskwith the givenname. - The task is stored in a module-level
_background_tasks: set[asyncio.Task]so it is not garbage-collected before completion. - A done callback
_on_background_task_donediscards the reference, swallowsCancelledError, and logs any other exception vialogger.exceptionso failures are never silently lost. - The function returns the
asyncio.Taskso a caller mayawaitit when it actually needs the result.