Skip to main content

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

ProtocolFormatUse CasePrimary Use
WebSocketProtobufReal-time bidirectionalSync requests, moderation, events
GraphQLJSONComplex queries/mutationsUser lookups, permission checks
RESTJSONSimple CRUDHealth 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

EventDirectionDescription
SystemEventAPI → BotSystem-wide notifications
AuditEventBot → APIAudit log entries
DiscordUserLinkedAPI → BotUser linked Discord account
DiscordUserUnlinkedAPI → BotUser unlinked Discord account
SyncDiscordGuildsRequestAPI → BotRequest to sync guilds/channels/roles
SyncDiscordGuildsResponseBot → APISynced guild data
SyncDiscordMembersRequestAPI → BotRequest to sync members
SyncDiscordMembersResponseBot → APISynced member data
ModerateDiscordMemberRequestAPI → BotModeration action request
ModerateDiscordMemberResponseBot → APIModeration result
DiscordBotSettingsChangedAPI → BotGlobal settings changed
DiscordGuildSettingsChangedAPI → BotPer-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

  1. WebSocket (preferred) - Binary protobuf, most efficient
  2. GraphQL (fallback) - Uses logAuditEvent mutation
  3. 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:

  1. Captures the command name, Discord user ID, and guild ID
  2. Looks up the Heimdall user ID (if the user has linked their account)
  3. Logs the command execution to the audit system
  4. 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

MethodDescriptionAutomatic?
log_command_executedCommand was executed✅ Yes (via post_command)
log_moderation_actionModeration action performed❌ No
log_permission_deniedPermission check failed❌ No
log_config_changedSettings were changed❌ No
log_errorAn error occurred❌ No
log_startedBot came online✅ Yes (on startup)
log_stoppedBot 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);
}
}