Skip to content

Repository files navigation

discord-social-sdk-kotlin

Kotlin bindings for the Discord Social SDK, built on the Java Foreign Function & Memory API. No JNI, no native glue to compile, no System.loadLibrary dance in your own code.

The C API is callback-driven and requires manual handle lifetimes. These bindings turn that into suspend functions, Flow events, and immutable data classes, so nothing in your application code has to manage native memory.

DiscordClient.create(applicationId = 1234567890L).use { client ->
    val verifier = client.createCodeVerifier()
    val code = client.authorize(Discord.defaultPresenceScopes, verifier.challenge)
    val token = client.exchangeCodeForToken(code, verifier.verifier)

    client.updateToken(AuthorizationTokenType.BEARER, token.accessToken)
    client.connect()
    client.awaitReady()

    client.updateRichPresence(
        activity {
            details = "Ranked - Solo Queue"
            state = "In a match"
            timestamps(start = Instant.now())
        }
    )
}

Status

Bindings cover the full C surface of Discord Social SDK 1.9.17380: 513 exported functions, 27 enums, and 32 handle types.

Verified on Windows x64. The binding layer is platform-neutral and the loader knows about the macOS, Linux, and arm64 layouts the SDK ships, but only Windows is currently exercised by the test suite.

The Discord Social SDK is distributed by Discord under its own licence and is not included in this repository. You must download it yourself and accept Discord's terms.

Before your first run

Register a redirect URL on the OAuth2 tab of your app in the Discord developer portal:

  • Desktop — http://127.0.0.1/callback
  • Mobile — discord-<APPLICATION_ID>:/authorize/callback

You never pass this URL to authorize(); the SDK runs its own local webserver on it. Skipping this step is the most common first-run failure, and it surfaces as:

DiscordException: authorize failed: rpc_error - OAuth2 Error: invalid_request: Missing "redirect_uri" in request.

Requirements

JDK 25 (FFM is final as of JDK 22; the toolchain targets 25)
Kotlin 2.4
Discord Social SDK 1.9.x, downloaded separately

Installing

Add the module to your build, then tell it where the SDK lives.

dependencies {
    implementation("gg.sona:discord-social-sdk-kotlin:1.0.0")
}

tasks.withType<JavaExec> {
    // FFM's restricted methods are denied by default from JDK 24 onward.
    jvmArgs("--enable-native-access=ALL-UNNAMED")
}

The native runtime ships inside the jar and unpacks itself on first use, so there is nothing else to install.

The native runtime

The SDK's runtime is more than one file. discord_partner_sdk loads discord_krisp from beside itself at runtime, and Krisp locates its .kef model files relative to its own location. All of them are therefore bundled and extracted into a single directory together — split them up and noise cancellation and voice activity detection degrade silently rather than failing.

On first use the payload is unpacked into a persistent cache directory:

OS Location
Windows %LOCALAPPDATA%\discord-social-sdk-kotlin\natives
macOS ~/Library/Caches/discord-social-sdk-kotlin/natives
Linux $XDG_CACHE_HOME/discord-social-sdk-kotlin/natives

Override it with -Ddiscord.sdk.cache=<dir>. Files are keyed by content hash, so upgrading the SDK writes a new directory instead of colliding with the old one, and each file is verified against a SHA-256 manifest as it is written. Extraction is guarded by a file lock, so several JVMs can start at once. A cached file that already matches is never rewritten — which is what lets a damaged cache be repaired while the library is loaded, since Windows will not let a mapped DLL be replaced.

Resolution order:

  1. discord.sdk.library / DISCORD_SDK_LIBRARY — the shared library file itself.
  2. discord.sdk.path / DISCORD_SDK_PATH — an extracted discord_social_sdk directory (Windows binaries are found under bin/, Unix ones under lib/).
  3. The binaries bundled in this jar. Default.
  4. The platform's default library search path.

The first two exist so a development checkout can point at a local SDK without rebuilding.

Discord.isAvailable reports whether the library loaded, without throwing. Discord.nativeRuntime reports where it came from and whether Krisp is usable — worth logging at startup, since missing models are otherwise invisible:

println(Discord.nativeRuntime)
// discord_partner_sdk from BUNDLED_RESOURCES at C:\...\natives\windows-x86_64\a1b2c3...\discord_partner_sdk.dll
// (krisp=available, 5 models)

Platform coverage

Platform Krisp noise cancellation
Windows x86-64 / aarch64 yes
macOS (universal) yes
Linux x86-64 no — the SDK ships no Krisp build for Linux

Bundling every platform makes the jar large (~158 MB). Build with -Pdiscord.natives=host to include only the building machine's platform, or =none to exclude them entirely and rely on discord.sdk.path at runtime.

Design

Values out, builders in. The C API hands out reference-counted handles that must be dropped. Leaking that into application code would mean use blocks around every user and message, so the bindings read each handle once, at the boundary, and return plain data classes. A DiscordUser is a snapshot, not a live view — re-read it after the relevant event fires.

The one deliberate exception is Call, which represents an ongoing voice session rather than a value, and is AutoCloseable.

Callbacks become coroutines and flows. Every asynchronous C function is a suspend function that throws DiscordException on failure. Every event callback is a SharedFlow. Connection state is a StateFlow.

Ownership is centralised. The rules for who frees a Discord_String, a span, or a Discord_Properties are documented and implemented once, in Marshalling, rather than restated at 500 call sites.

Failures cannot crash the VM. An exception unwinding from an upcall into native code terminates the process, so every callback body is wrapped in a guard that routes throwables to Discord.uncaughtCallbackHandler.

Threading

The SDK queues callbacks and dispatches them when Discord_RunCallbacks is called. By default a daemon thread does this every 16 ms, and your suspend calls and flow collectors first resume there — move to your own dispatcher before doing real work.

Games that own the main loop should drive dispatch themselves:

val client = DiscordClient.create(applicationId, ClientOptions(autoPump = false))

while (running) {
    Discord.runCallbacks()   // callbacks now arrive on this thread
    renderFrame()
}

Discord.setFreeThreaded() lets the SDK invoke callbacks directly on its own threads instead. It removes the need for a pump, but callbacks then arrive on arbitrary threads and it cannot be undone for the lifetime of the process.

Usage

Events

client.messages.filterIsInstance<MessageEvent.Created>().collect { event ->
    val message = client.message(event.messageId) ?: return@collect
    println("${message.author?.displayName}: ${message.content}")
}

client.connection.collect { state ->
    if (state.isReady) println("connected")
}

Events carry identifiers rather than full objects, mirroring the C API: the SDK's cache is already current when an event fires, so the matching lookup costs nothing.

Available flows: connection, messages, lobbyEvents, relationshipEvents, activityInvites, activityJoins, userUpdates, voiceParticipants, audioDevices, logs, tokenExpirations.

Lobbies

val lobbyId = client.createOrJoinLobby(
    secret = "match-abc123",
    lobbyMetadata = mapOf("map" to "dust2"),
)

client.sendLobbyMessage(lobbyId, "glhf")
println("${client.lobby(lobbyId)?.members?.size} players")
client.leaveLobby(lobbyId)

Everyone who supplies the same secret joins the same lobby, so derive it from something that identifies the match rather than something guessable.

Voice

client.startCall(channelId).use { call ->
    call.audioMode = AudioMode.PUSH_TO_TALK
    call.pushToTalkActive = keyIsDown

    call.speaking.collect { println("${it.userId} speaking: ${it.speaking}") }
}

Staying signed in

authorize() opens a browser. You only want that once, so persist the refresh token and exchange it on subsequent launches:

client.authenticate(
    scopes = Discord.defaultPresenceScopes,
    savedRefreshToken = store.read(),
) { issued ->
    store.write(issued.refreshToken)   // must overwrite: refresh tokens rotate
}

client.connect()
client.awaitReady()

authenticate refreshes when a saved token works and falls back to the browser when it doesn't, so expired or revoked tokens recover on their own.

Two things to get right:

  • Refresh tokens rotate. Every exchange invalidates both the old access token and the refresh token you passed in. Overwrite the stored value each time; keeping the original locks you out and silently sends users back to the prompt.
  • Refreshing needs a public client, set on the OAuth2 tab of the developer portal. Confidential clients must refresh from a backend holding the client secret, then install the result with updateToken.

If the browser prompt appears on every run, the refresh is failing and authenticate is quietly recovering. Pass onFallback to see why:

client.authenticate(
    scopes = Discord.defaultCommunicationScopes,
    savedRefreshToken = store.read(),
    onFallback = { reason, cause -> println("interactive sign-in: $reason $cause") },
) { issued -> store.write(issued.refreshToken) }

REFRESH_FAILED with an invalid_client response means the app is still a confidential client — the SDK's public flow sends no client secret, so Discord rejects it. invalid_grant instead means the token itself was stale, which fixes itself once the newly issued one is stored.

For long-running sessions, collect tokenExpirations and refresh before the token lapses:

launch {
    client.tokenExpirations.collect {
        val refreshed = client.refreshToken(store.read())
        store.write(refreshed.refreshToken)
        client.updateToken(refreshed.tokenType, refreshed.accessToken)
    }
}

Treat the refresh token as a credential: it grants access to the user's Discord account under your app's scopes. Use the OS keychain or an equivalent protected store rather than a plaintext file.

Errors

Suspending calls throw DiscordException, which carries the full DiscordResult:

try {
    client.sendUserMessage(userId, "hello")
} catch (e: DiscordException) {
    if (e.retryable) retryAfter(e.result.retryAfterSeconds)
    else log.warn("send failed: ${e.type} ${e.result.error}")
}

Building

./gradlew build

Tests that need the native library are skipped, not failed, when it is absent, so a checkout without the SDK still builds green.

Regenerating the binding layer

The one-to-one downcall layer is generated from the C header; the idiomatic API on top of it is written by hand.

python tools/generate_bindings.py

This reads discord_social_sdk/include/cdiscord.h and rewrites internal/ffi/CDiscord.kt and internal/ffi/NativeEnums.kt. Re-run it after updating the native SDK, then check whether any hand-written code needs to follow.

Project layout

src/main/kotlin/gg/sona/discord/
├── DiscordClient.kt          the main API: suspend functions and event flows
├── Call.kt                   live voice sessions
├── Activity.kt               rich presence and its builder DSL
├── Models.kt                 immutable snapshots of the SDK's handle types
├── Events.kt                 event types carried by the flows
├── Enums.kt                  public enums, mapped to the C enumerators
├── DiscordResult.kt          results and DiscordException
├── Discord.kt                process-wide entry points and the callback pump
└── internal/
    ├── Codecs.kt             handle <-> data class conversion
    └── ffi/
        ├── CDiscord.kt       generated downcalls (513 functions)
        ├── NativeEnums.kt    generated enumerator values
        ├── NativeLibrary.kt  library discovery and loading
        ├── Layouts.kt        struct layouts for the C ABI
        ├── Marshalling.kt    strings, spans, properties, and their ownership
        └── Upcalls.kt        native function pointers that call into Kotlin

Contributing

Issues and pull requests are welcome.

  • Changes under internal/ffi/CDiscord.kt or NativeEnums.kt belong in the generator, not the generated file.
  • New API surface needs KDoc and a test.
  • ./gradlew build must pass without the native SDK present, and with it.

Licence

These bindings are released under the MIT Licence.

The Discord Social SDK itself is not covered by that licence, is not redistributed here, and remains subject to the Discord Developer Terms of Service and the Discord Social SDK Terms.

This project is not affiliated with or endorsed by Discord Inc.

About

Kotlin bindings for Discord Social SDK

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages