API Integration
The Discord bot communicates with the Heimdall API using three protocols: WebSocket for real-time events, GraphQL for complex queries, and REST for simple operations.
Protocol Overview
| Protocol | Format | Use Case | Primary Use |
|---|---|---|---|
| WebSocket | Protobuf | Real-time bidirectional | Sync requests, moderation, events |
| GraphQL | JSON | Complex queries/mutations | User lookups, permission checks |
| REST | JSON | Simple CRUD | Health checks, basic operations |
WebSocket Client
The WebSocket client is the primary communication channel between the bot and Heimdall API.
Features
- Protobuf serialization for efficient binary messages
- Automatic reconnection with exponential backoff
- Request-response correlation via request IDs
- Event broadcasting and subscription
Connection Setup
use crate::clients::websocket::WebSocketClient;
// Create client (stored in Data struct)
let ws_client = WebSocketClient::new(
&settings.api.websocket_endpoint, // ws://localhost:3000/v1/ws
&settings.api.api_key,
);
// Connect and get message receiver
let mut rx = ws_client.connect().await?;
// Spawn event handler
tokio::spawn(async move {
while let Some(bytes) = rx.recv().await {
if let Ok(envelope) = WsEnvelope::decode(&bytes[..]) {
handle_envelope(envelope).await;
}
}
});
Sending Messages
use heimdall_proto::{WsEnvelope, ws_envelope::Payload};
use prost::Message as ProstMessage;
// Create protobuf message
let envelope = WsEnvelope {
r#type: WsMessageType::AuditEvent as i32,
payload: Some(Payload::AuditEvent(audit_event)),
};
// Send via WebSocket
ws_client.send(envelope).await?;
Receiving Events
match envelope.payload {
Some(Payload::SystemEvent(event)) => {
tracing::info!("System event: {:?}", event);
}
Some(Payload::SyncDiscordGuildsRequest(request)) => {
let response = handle_sync_request(request).await;
ws_client.send(response).await?;
}
Some(Payload::ModerateDiscordMemberRequest(request)) => {
let response = handle_moderation(request).await;
ws_client.send(response).await?;
}
Some(Payload::DiscordBotSettingsChanged(settings)) => {
update_settings(settings).await;
}
_ => {}
}
Event Types
| Event | Direction | Description |
|---|---|---|
SystemEvent | API → Bot | System-wide notifications |
AuditEvent | Bot → API | Audit log entries |
DiscordUserLinked | API → Bot | User linked Discord account |
DiscordUserUnlinked | API → Bot | User unlinked Discord account |
SyncDiscordGuildsRequest | API → Bot | Request to sync guilds/channels/roles |
SyncDiscordGuildsResponse | Bot → API | Synced guild data |
SyncDiscordMembersRequest | API → Bot | Request to sync members |
SyncDiscordMembersResponse | Bot → API | Synced member data |
ModerateDiscordMemberRequest | API → Bot | Moderation action request |
ModerateDiscordMemberResponse | Bot → API | Moderation result |
DiscordBotSettingsChanged | API → Bot | Global settings changed |
DiscordGuildSettingsChanged | API → Bot | Per-guild settings changed |
Protobuf Schema
The protobuf definitions are in platform/proto/heimdall.proto. Code is auto-generated via build.rs in the heimdall-proto crate.
// Import generated types
use heimdall_proto::{
WsEnvelope, WsMessageType,
SyncDiscordGuildsRequest, SyncDiscordGuildsResponse,
DiscordGuildData, DiscordChannelData, DiscordRoleData,
};
GraphQL Client
The GraphQL client handles complex queries and mutations.
Features
- Type-safe queries with serde deserialization
- Bearer token authentication
- Error handling with detailed messages
Usage
use crate::clients::graphql::GraphqlClient;
use serde_json::json;
let graphql = GraphqlClient::new(
&settings.api.graphql_endpoint, // http://localhost:3000/v1/gql
&settings.api.api_key,
);
// Query
let user: UserData = graphql.query(
r#"
query GetUser($id: ID!) {
user(id: $id) {
id
email
displayName
}
}
"#,
Some(json!({ "id": user_id })),
).await?;
// Mutation
let result: MutationResult = graphql.mutate(
r#"
mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
updateUser(id: $id, input: $input) {
id
displayName
}
}
"#,
Some(json!({
"id": user_id,
"input": { "displayName": "New Name" }
})),
).await?;
Common Queries
Find User by Discord ID
#[derive(Deserialize)]
struct UserLookupResponse {
#[serde(rename = "findUserByPlatform")]
find_user_by_platform: Option<UserInfo>,
}
async fn find_heimdall_user(discord_user_id: &str) -> Option<String> {
let query = r#"
query FindUserByPlatform($platform: String!, $platformUserId: String!) {
findUserByPlatform(platform: $platform, platformUserId: $platformUserId) {
id
}
}
"#;
let variables = json!({
"platform": "discord",
"platformUserId": discord_user_id
});
graphql.query::<UserLookupResponse>(query, Some(variables))
.await
.ok()
.and_then(|r| r.find_user_by_platform.map(|u| u.id))
}
Check User Permissions
async fn check_permission(user_id: &str, permission: &str) -> bool {
let query = r#"
query UserPermissions($userId: String!) {
userPermissions(userId: $userId)
}
"#;
let result: Vec<String> = graphql.query(
query,
Some(json!({ "userId": user_id })),
).await.unwrap_or_default();
result.contains(&permission.to_string()) || result.contains(&"*:*".to_string())
}
REST Client
The REST client handles simple CRUD operations.
Features
- Standard HTTP methods (GET, POST, PUT, PATCH, DELETE)
- JSON serialization
- Rate limit handling
- Bearer token authentication
Usage
use crate::clients::rest::RestClient;
let rest = RestClient::new(
&settings.api.rest_endpoint, // http://localhost:3000/v1
&settings.api.api_key,
);
// GET
let user: User = rest.get("/users/me").await?;
// POST
let role: Role = rest.post("/roles", CreateRoleInput {
name: "Moderator".to_string(),
permissions: vec!["discord:guild.warn".to_string(), "discord:guild.kick".to_string()],
}).await?;
// PUT
let updated: Role = rest.put("/roles/123", UpdateRoleInput {
name: "Senior Moderator".to_string(),
}).await?;
// DELETE
rest.delete::<()>("/roles/123").await?;
Audit Client
The Audit client logs events to the Heimdall audit system with automatic transport fallback.
Transport Priority
- WebSocket (preferred) - Binary protobuf, most efficient
- GraphQL (fallback) - Uses
logAuditEventmutation - REST (last resort) - POST to
/internal/audit
Automatic Audit Logging
All commands are automatically logged via the post_command hook. You don't need to manually call audit methods for basic command execution logging.
The post_command hook automatically:
- Captures the command name, Discord user ID, and guild ID
- Looks up the Heimdall user ID (if the user has linked their account)
- Logs the command execution to the audit system
- Includes any custom metadata set by the command
Adding Custom Metadata to Audit Events
Commands can add custom metadata to their audit events using the command_metadata system:
use crate::bot::data::{Context, Error};
#[poise::command(slash_command)]
pub async fn sync_all(ctx: Context<'_>) -> Result<(), Error> {
let interaction_id = ctx.id().to_string();
// Perform the sync operation...
let result = ctx.data().graphql_client.sync_all().await?;
// Set custom metadata for the audit event
ctx.data().set_command_metadata(
&interaction_id,
serde_json::json!({
"sync_type": "all",
"success": result.success,
"guilds_synced": result.guilds_synced,
"channels_synced": result.channels_synced,
"roles_synced": result.roles_synced,
}),
);
// The metadata will be automatically included in the audit event
// and cleaned up after post_command runs
ctx.say("Sync complete!").await?;
Ok(())
}
The custom metadata is merged with the base metadata (discord_id, command, guild_id) in the audit event:
{
"discord_id": "123456789",
"command": "sync all",
"guild_id": "987654321",
"sync_type": "all",
"success": true,
"guilds_synced": 5,
"channels_synced": 20,
"roles_synced": 10
}
Manual Audit Logging
For specific events (not automatic command logging), you can call audit methods directly:
use crate::clients::AuditClient;
#[poise::command(slash_command)]
async fn some_command(ctx: Context<'_>) -> Result<(), Error> {
let audit = &ctx.data().audit_client;
let discord_user_id = ctx.author().id.get().to_string();
// Resolve Heimdall user ID (if linked)
let heimdall_user_id = find_heimdall_user(&discord_user_id).await;
// Log a moderation action (not automatic)
audit.log_moderation_action(
heimdall_user_id.as_deref(),
&discord_user_id,
target_user_id.as_deref(),
&target_discord_id,
"kick",
&guild_id,
Some("Reason for kick"),
).await;
Ok(())
}
Available Methods
| Method | Description | Automatic? |
|---|---|---|
log_command_executed | Command was executed | ✅ Yes (via post_command) |
log_moderation_action | Moderation action performed | ❌ No |
log_permission_denied | Permission check failed | ❌ No |
log_config_changed | Settings were changed | ❌ No |
log_error | An error occurred | ❌ No |
log_started | Bot came online | ✅ Yes (on startup) |
log_stopped | Bot went offline | ✅ Yes (on shutdown) |
Source Service Tracking
All audit events include source_service: "discord_bot" to identify the origin:
use heimdall_audit::{events, resources, sources};
// Event types
events::BOT_COMMAND_EXECUTED
events::BOT_MODERATION_ACTION
events::BOT_CONFIG_CHANGED
// Resource types
resources::DISCORD_BOT
resources::DISCORD_GUILD
resources::DISCORD_MEMBER
// Source
sources::DISCORD_BOT // "discord_bot"
Accessing Clients in Commands
Clients are stored in the Poise Data struct and accessed via ctx.data():
use crate::bot::data::{Context, Error, Data, SyncSettings};
/// Runtime sync settings (updated from API)
#[derive(Clone)]
pub struct SyncSettings {
pub auto_sync_enabled: bool,
pub sync_interval_seconds: i32,
pub initial_delay_seconds: i32,
}
/// Shared state containing all API clients
#[derive(Clone)]
pub struct Data {
pub settings: Arc<Settings>,
pub graphql_client: Arc<GraphqlClient>,
pub rest_client: Arc<RestClient>,
pub ws_client: Arc<WebSocketClient>,
pub rbac_client: Arc<RbacClient>,
pub audit_client: Arc<AuditClient>,
pub start_time: Instant,
pub runtime_locale: Arc<RwLock<String>>,
pub guild_locales: Arc<RwLock<HashMap<String, String>>>,
pub guild_respect_user_locale: Arc<RwLock<HashMap<String, bool>>>,
pub guild_uuids: Arc<RwLock<HashMap<String, String>>>,
pub sync_settings: Arc<RwLock<SyncSettings>>,
}
/// Access in commands
#[poise::command(slash_command)]
async fn example(ctx: Context<'_>) -> Result<(), Error> {
// Access clients
let graphql = &ctx.data().graphql_client;
let rest = &ctx.data().rest_client;
let ws = &ctx.data().ws_client;
let audit = &ctx.data().audit_client;
// Use clients...
Ok(())
}
Error Handling
All clients return BotResult<T> with detailed error types:
pub enum BotError {
GraphQL(String),
Rest(reqwest::Error),
WebSocket(tungstenite::Error),
ChannelSend,
// ...
}
// Handle errors
match graphql.query::<User>(query, vars).await {
Ok(user) => { /* success */ }
Err(BotError::GraphQL(msg)) => {
tracing::error!("GraphQL error: {}", msg);
}
Err(e) => {
tracing::error!("Unexpected error: {:?}", e);
}
}