Lingara Lingara بەڵگەنامەکان ڕێنماییەکان API کتێبخانەکان ئەپەکان دروستکردن جێگیرکردن ئەپی وێب
زمان: سۆرانی

کیتەکانی سێرڤەر

ئەم پەڕەیە لە ئینگلیزییەوە وەرگێڕدراوە. ئەگەر هەردووکیان جیاواز بوون، پەڕە ئینگلیزییەکە ڕاستە. پەڕە ئینگلیزییەکە بخوێنەوە

سێرڤەرەکەت ئەو نیوەیەی جێگیرکردنە کە نهێنییەکان هەڵدەگرێت. کیتی سێرڤەر کتێبخانەی Lingara ی زمانەکەت دەپێچێتەوە، بۆیە بە کلاینتی OAuth ـەکەت دەچێتە ژوورەوە و تۆکن بۆ یاریزانەکانت دروست دەکات، ئەو وێبهووکانە دەپشکنێت کە Lingara بۆت دەنێرێت، و یاریزانەکان دەسڕێتەوە کاتێک دەڕۆن. نموونەکانی خوارەوە لە هەموو زمانێکدا هەمان هەنگاوەکان دەگرنەبەر.

کلاینتێکی پێوانەکراو دروست بکە

لە کۆنسۆڵدا کلاینتێکی OAuth دروست بکە و بیخەرە سەر پلانی پێوانەکراو. embed:mint ی پێبدە، ئەو سکۆپەی کە تۆکن دروست دەکات و یاریزانەکان دەسڕێتەوە. کلاینتێک لەسەر هەر پلانێکی تر ئەو سکۆپەی پێنادرێت. هەروەها هەموو ئەو سکۆپانەی پێبدە کە دەتەوێت تۆکنی یاریزانەکانت هەڵیانبگرن، چونکە تۆکنێک تەنها دەتوانێت ئەو سکۆپانە هەڵبگرێت کە کلاینتەکەت هەیەتی. embed:mint خۆی هەرگیز نادرێت بە یاریزانێک.

ناسنامە و نهێنی کلاینتەکە لە ژینگەی سێرڤەرەکەتدا بهێڵەرەوە، هەرگیز لە یارییەک یان پەڕەیەکدا نا.

origin ـە وێبییەکانت تۆمار بکە

ویجێتێکی ماڵپەڕ پێویستی بە تۆکنێکە کە بە origin ـی پەڕەکەوە بەسترابێت. لە کۆنسۆڵدا، لە ڕێکخستنەکانی کلاینتەکەتدا هەموو ئەو origin انە لیست بکە کە ویجێتەکەت لەسەریان کار دەکات، وەک https://www.example.com. دەتوانیت بەلایەنی زۆرەوە 10 لیست بکەیت. origin ێک بریتییە لە scheme، host و port ێکی ئارەزوومەندانە، بێ ڕێڕەو. بۆ گەشەپێدان، origin ێکی loopback لەسەر port ێکی خۆت ڕێگەپێدراوە، وەک http://localhost:5173. تۆکنێک تەنها لەو origin ـە ڕێک و دروستەوە پەسەند دەکرێت کە بۆی دروستکراوە، بۆیە تۆکنێک کە بۆ هەر origin ێکی تر دروستکرابێت لەلایەن API ـەوە ڕەت دەکرێتەوە. یارییەک پێویستی بە origin نییە.

تۆکنێک دروست بکە

لە پشت چوونەژوورەوەی خۆتەوە دروستی بکە: ڕێڕەوی تۆکنەکەت سەرەتا دانیشتنی خۆت دەپشکنێت، و تەنها پاشان بۆ ئەو یاریزانە دروستی دەکات. ڕێڕەوەکە بۆ هەر دانیشتنێک سنووری ڕێژە بۆ دابنێ. داواکارییەکی دروستکردن سێ خانە وەردەگرێت:

  • player_ref: ئاماژەی خۆت بۆ یاریزانەکە، لە 1 تا 128 بایتی UTF-8. Lingara تەنها hash ـەکەی هەڵدەگرێت.
  • scopes: بەلایەنی زۆرەوە 5، لەوانەی تۆکنی یاریزانێک دەتوانێت هەڵیانبگرێت: events:read، events:write، lesson_plans:read، lesson_plans:write و embed:play. ئەگەر لێی دەربهێنیت، تۆکنەکە هەموو ئەوانەیان هەڵدەگرێت کە کلاینتەکەت هەیەتی.
  • origin: بۆ ماڵپەڕێک، origin ـی پەڕەکە، کە دەبێت یەکێک بێت لەوانەی تۆمارت کردوون. بۆ یارییەک لێی دەربهێنە.

وەڵامەکە token، expires_at، expires_in (هەمان کاتی بەسەرچوون بە چرکە، بۆ ئامێرێک کە ناتوانیت متمانە بە کاتژمێرەکەی بکەیت)، subject، scopes ـی تۆکنەکە، و account_linked لەخۆ دەگرێت، کە دەڵێت ئایا یاریزانەکە هەژماری Lingara ی خۆی بەستووەتەوە. subject یاریزانەکە بە کلاینتەکەت دەناسێنێت، و هەموو ڕووداوێک دەربارەی ئەو هەڵیدەگرێت.

تۆکنەکە و کاتی بەسەرچوونی ڕێک وەک ئەوەی ڕادەستکردنی کیتەکە دەیاننووسێت بدە بە یاریزانەکە، لە ڕێگەی دانیشتنی خۆتەوە و هەرگیز لە URL دا نا، و وەڵامەکە لەگەڵ Cache-Control: no-store بنێرە. کیتی یاریزان کاتێک تۆکنەکە لە کاتی بەسەرچوونی نزیک دەبێتەوە دووبارە داوا دەکات؛ تۆکنێک هەرگیز نوێ ناکرێتەوە.

وێبهووکێک ئاڕاستەی یاریزانەکەی بکە

Lingara هەموو ئەو وێبهووکانە واژوو دەکات کە بۆت دەنێرێت. جەستەی خاوی داواکارییەکە بدە بە کیتەکە، پێش هەر شیکردنەوەیەکی JSON، چونکە واژووەکە بایتەکانی دادەپۆشێت. کیتەکە واژووەکە دەپشکنێت و بەدوای ئەو یاریزانەدا دەگەڕێت کە subject ـی ڕووداوەکە ناوی دەبات، لە خشتەیەکی خۆتدا، لە تەنیشت یاریزانەکانتەوە، کە هەر subject ێک بە player_ref ـەکەتەوە دەبەستێتەوە. یاریزانێک کە هەژمارێک دەبەستێتەوە خاوەنی دوو subject ـە، بۆیە ستوونی subject بە تاک بهێڵەرەوە و ڕێگە بدە یەک player_ref دوو جار دەربکەوێت.

گەیاندن بەلایەنی کەمەوە یەک جارە: ناسنامەی ئەو ڕووداوەی پێشتر چارەسەرت کردووە تێپەڕێنە. لە ماوەی 10 چرکەدا وەڵام بدەرەوە، و هەر شتێکی خاو بخەرە سەر ڕیزێکی خۆت.

یاریزانێک بسڕەوە

لە ڕێڕەوی سڕینەوەی هەژماری خۆتەوە، یاریزانەکە بە player_ref ـەکەی بسڕەوە. Lingara هەموو ئەو تۆکنانەی یاریزانەکە هەیەتی هەڵدەوەشێنێتەوە و هەموو ئەوەی بۆی هەڵدەگرێت لادەبات. سڕینەوەی یاریزانێکی نەناسراو سەرکەوتوو دەبێت، بۆیە دووبارەکردنەوەی بانگکردنەکە بێ مەترسییە. سڕینەوەی داتای خۆت هەر کاری خۆتە.

یاریزانێک کە هەژماری Lingara ی خۆی دەهێنێت

یاریزانێک کە هەژماری Lingara ی خۆی هەیە دەتوانێت بیبەستێتەوە، بۆ ئەوەی ڕاهێنان و پلانەکانی لەسەر ئەو هەژمارە بژین. هێشتا هیچ کیتێک ئەمە ناپێچێتەوە؛ ئەمە دوو بانگکردنی API ـە لە سێرڤەرەکەتەوە.

سێرڤەرەکەت ڕەوتی authorization-code لەگەڵ هەمان ئەو کلاینتەی تۆکن دروست دەکات بەڕێوە دەبات، بە بەکارهێنانی PKCE: یاریزانەکە لە وێبگەڕەکەیدا دەچێتە ژوورەوە و ڕەزامەندی دەدات، هەرگیز لەناو یارییەکەتدا نا، و سێرڤەرەکەت کۆدەکە بە نهێنییەکەی دەگۆڕێتەوە. لەو ڕەزامەندییەدا داوای embed:play و هەموو ئەو سکۆپانە بکە کە تۆکنیان پێ دروست دەکەیت، و events:read تێدا دابنێ، ئەگەرنا کاتێک یاریزانەکە بەستیەوە هیچ شتێک دەربارەی نابیستیت.

پاشان بانگی linkEmbedPlayer بکە بەو تۆکنە ڕاسپێردراوەی ئەو گۆڕینەوەیە گەڕاندییەوە، هەرگیز بە تۆکنی خودی کلاینتەکەت نا. وەڵامەکەی ئەمانە لەخۆ دەگرێت:

  • status: linked کاتێک ئەم بانگکردنە بەستنەوەکەی دروست کرد، existing کاتێک پێشتر چالاک بوو؛
  • subject، subject ـی هەژمارەکە، کە ڕووداوەکانی یاریزانەکە لە ئێستاوە هەڵیدەگرن؛
  • previous_subject، subject ـی میوانەکە، کە ڕووداوە پێشووەکانی دەیپارێزن؛
  • linked_at، کاتی دروستکردنی بەستنەوەکە؛
  • scopes، زۆرترینی ئەوەی تۆکنێک بۆ ئەم یاریزانە ئێستا دەتوانێت هەڵیبگرێت؛
  • carried، ئەو ڕیزانەی ئەم بانگکردنە گواستییەوە بۆ هەژمارەکە، بەپێی هەر خشتەیەک؛
  • kept، ئەو ڕیزانەی لای میوانەکە هێشتنییەوە، وەک پلانێک کە هێشتا دەنووسرێت.

دووبارەکردنەوەی بەستنەوەیەک بێ مەترسییە، و دووبارەکردنەوەیەک گواستنەوەی ئەوەی kept ڕایدەگەیەنێت تەواو دەکات. ئەو ڕووداوانەی هەر یەکێک لە دوو subject ـەکە هەڵدەگرن ئاڕاستەی هەمان player_ref بکە. دروستکردنی داهاتووت بۆ یاریزانەکە account_linked بە ڕاست وەڵام دەداتەوە، و تۆ هێشتا پارەی ئەوە دەدەیت کە یاریزانەکە دەیکات.

بۆ کۆتاییهێنان بە بەستنەوەیەک، بانگی unlinkEmbedPlayer بکە بە تۆکنی خودی کلاینتەکەت. هیچ شتێک ناگەڕێتەوە بۆ میوانەکە. فێرخوازێکیش دەتوانێت خۆی کۆتایی بە بەستنەوەکە بهێنێت، بە لابردنی ئەپەکەت لە «ئەپە پەیوەستکراوەکان».

زمانی نموونەی کۆد

TypeScript

npm i @lingara/embed@0.1.0-alpha.6 @lingara/api

Rust

cargo add lingara-embed@0.1.0-alpha.6 --features server
cargo add lingara tokio --features tokio/full

Go

go get github.com/Spinning-Cat-Studios/lingara_embeddable_sdk/go@v0.1.0-alpha.6

Java

<dependency>
  <groupId>com.getlingara</groupId>
  <artifactId>lingara-embed-server-java</artifactId>
  <version>0.1.0-alpha.6</version>
</dependency>

Kotlin

implementation("com.getlingara:lingara-embed-server-kotlin:0.1.0-alpha.6")

Ruby

gem install lingara-embed -v 0.1.0-alpha.6
# or, in a Gemfile:
# gem "lingara-embed", "0.1.0-alpha.6"

PHP

composer require spinningcatstudios/lingara-embed:0.1.0-alpha.6 spinningcatstudios/lingara:@alpha
# The lingara library sends through Symfony HttpClient (or Guzzle 7):
composer require symfony/http-client nyholm/psr7

C#

<PackageReference Include="Lingara.Embed.Server" Version="0.1.0-alpha.6" />
زمانی نموونەی کۆد

TypeScript

const lingara = new Lingara({ clientId: process.env.LINGARA_CLIENT_ID, clientSecret: process.env.LINGARA_CLIENT_SECRET })
const server = new EmbedServer(lingara, { directory })

// POST /lingara/token, behind your own sign-in. Rate-limit it per session.
export async function token(request: Request): Promise<Response> {
  const user = await signedInUser(request)
  if (user === null) return new Response(null, { status: 401 })
  const minted = await server.mintToken(user.id, { origin: 'https://www.example.com' })
  return new Response(tokenHandoff(minted), { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } })
}

Rust

/// `POST /lingara/token`: your own route, behind your own session, and
/// rate-limited per session. The widget calls it with same-origin cookies.
pub async fn token_route(State(app): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
    let Some(player_ref) = current_player(&headers) else {
        return (StatusCode::UNAUTHORIZED, [(header::CACHE_CONTROL, "no-store")], String::new());
    };
    // This page's origin, one of your client's embed origins. A game's own
    // server mints without one.
    let options = MintOptions { origin: Some("https://game.example".into()), ..MintOptions::default() };
    match app.embed.mint_token(&player_ref, options).await {
        // {"token":"lgr_et_…","expires_at":"…"}
        Ok(minted) => (StatusCode::OK, [(header::CACHE_CONTROL, "no-store")], token_handoff(&minted)),
        Err(_) => (StatusCode::BAD_GATEWAY, [(header::CACHE_CONTROL, "no-store")], String::new()),
    }
}

Go

// POST /lingara/token: your own route, behind your own session, and
// rate-limited per session. The widget calls it with same-origin cookies.
return func(w http.ResponseWriter, r *http.Request) {
	playerRef, ok := currentPlayer(r) // your user, never a value from the request body
	if !ok {
		http.Error(w, "sign in first", http.StatusUnauthorized)
		return
	}
	minted, err := embed.MintToken(r.Context(), playerRef, lingaraembed.MintOptions{
		Origin: "https://game.example", // this page's origin, one of your client's embed origins
	})
	if err != nil {
		http.Error(w, "no token", http.StatusBadGateway)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	_, _ = w.Write(lingaraembed.TokenHandoff(minted)) // {"token":"lgr_et_…","expires_at":"…"}
}

Java

import com.getlingara.client.LingaraClient;
import com.getlingara.client.MintedToken;
import com.getlingara.embed.server.EmbedServer;
import com.getlingara.embed.server.MintOptions;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.TokenHandoff;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
LingaraClient client =
    LingaraClient.builder()
        .clientCredentials(
            System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
        .build();
EmbedServer embed = EmbedServer.of(client, players);

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
String playerId = currentPlayer(exchange);
if (playerId == null) {
  exchange.sendResponseHeaders(401, -1);
  return;
}
MintedToken minted =
    embed.mintToken(playerId, MintOptions.none().withOrigin("https://game.example"));
byte[] body = TokenHandoff.body(minted).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.sendResponseHeaders(200, body.length); // {"token":"lgr_et_…","expires_at":"…"}
exchange.getResponseBody().write(body);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.EmbedServer
import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.tokenHandoff
import com.getlingara.kotlin.LingaraClient
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.post

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
val client =
    LingaraClient {
        clientCredentials(System.getenv("LINGARA_CLIENT_ID"), System.getenv("LINGARA_CLIENT_SECRET"))
    }
val embed = EmbedServer(client, players)

// POST /lingara/token: your own route, behind your own login, rate-limited
// per session. Every call is one mint; the widget calls it with
// same-origin cookies.
post("/lingara/token") {
    val playerId = call.request.headers["X-Example-Player"] ?: return@post call.respond(HttpStatusCode.Unauthorized)
    val minted = embed.mintToken(playerId, origin = "https://game.example")
    call.response.header(HttpHeaders.CacheControl, "no-store")
    call.respondText(tokenHandoff(minted), ContentType.Application.Json) // {"token":"lgr_et_…","expires_at":"…"}
}

Ruby

# POST /lingara/token, behind your own sign-in. Rate-limit it per session.
def self.token(env, server)
  player = env["warden"]&.user or return [401, {}, []]
  minted = server.mint_token(player.id.to_s, origin: "https://game.example")
  [200, {"content-type" => "application/json", "cache-control" => "no-store"}, [Lingara::Embed.token_handoff(minted)]]
end

PHP

use Lingara\Client;
use Lingara\Embed\EmbedServer;
use Lingara\Embed\TokenHandoff;

// Once, at startup. The credentials are your metered client's (lgr_ci_…,
// lgr_cs_…), from the environment; never ship them to a device.
function embedServer(\PDO $db): EmbedServer
{
    $client = new Client(
        clientId: getenv('LINGARA_CLIENT_ID') ?: null,
        clientSecret: getenv('LINGARA_CLIENT_SECRET') ?: null,
    );
    return new EmbedServer($client, new PdoPlayerDirectory($db));
}

// POST /lingara/token: your own route, behind your own login. Rate-limit it
// per session; every call is one mint.
function tokenRoute(EmbedServer $embed): void
{
    session_start();
    $playerId = $_SESSION['player_id'] ?? null;
    if (!is_string($playerId)) {
        http_response_code(401);
        return;
    }
    $minted = $embed->mintToken($playerId, origin: 'https://game.example');
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo TokenHandoff::body($minted); // {"token":"lgr_et_…","expires_at":"…"}
}

C#

// One EmbedServer for the process: it caches the client-credentials token.
var lingara = new EmbedServer(new EmbedServerOptions
{
    ClientId = Environment.GetEnvironmentVariable("LINGARA_CLIENT_ID")!,         // lgr_cid_…
    ClientSecret = Environment.GetEnvironmentVariable("LINGARA_CLIENT_SECRET")!, // lgr_cs_…, from your secret store
}, new SqlPlayerDirectory(db));

// Your own session says who the player is. Rate-limit this route per session.
app.MapPost("/lingara/token", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier);
    if (playerRef is null)
    {
        return Results.Unauthorized();
    }

    // This page's origin, one of your client's embed origins. A game's server omits it.
    var minted = await lingara.MintTokenAsync(playerRef, new MintOptions { Origin = "https://game.example" }, ct);
    return Results.Text(TokenHandoff.Body(minted), "application/json"); // {"token":"lgr_et_…","expires_at":"…"}
}).RequireAuthorization().RequireRateLimiting("lingara-token");
زمانی نموونەی کۆد

TypeScript

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their rows. Deleting your own data stays yours.
export async function deleteAccount(userId: string): Promise<void> {
  await server.deletePlayer(userId)
}

Rust

// Revokes every token the player holds, then forgets their subjects.
// An unknown player is a success; on a failure the row is kept.
app.embed.delete_player(player_ref).await?;

Go

// From your own account-deletion path: revokes every token the player
// holds and forgets their subjects. An unknown player is a success.
if err := embed.DeletePlayer(r.Context(), playerRef); err != nil {
	var apiErr *lingara.APIError
	if errors.As(err, &apiErr) {
		return apiErr // the library's own error: Status, Code, RetryAfter
	}
	return err
}

Java

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId);

Kotlin

// From your account-deletion path: revokes every token the player holds and
// forgets their subjects. An unknown player is a success, so a retry is safe.
embed.deletePlayer(playerId)

Ruby

# From your account-deletion path: revokes the player's tokens at Lingara
# and forgets their rows. An unknown player is a success.
def self.delete_account(server, player)
  server.delete_player(player.id.to_s)
end

PHP

use Lingara\Embed\EmbedServer;

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success too,
// so a retry is safe; on a failure the rows are kept.
function deleteAccount(EmbedServer $embed, \PDO $db, string $playerId): void
{
    $embed->deletePlayer($playerId);
    $delete = $db->prepare('DELETE FROM players WHERE id = ?') ?: throw new \RuntimeException('cannot prepare');
    $delete->execute([$playerId]);
}

C#

// From your own account-deletion path: revokes the player's tokens at
// Lingara, then forgets their subjects. An unknown player is a success.
app.MapDelete("/account", async (ClaimsPrincipal user, CancellationToken ct) =>
{
    var playerRef = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
    await lingara.DeletePlayerAsync(playerRef, ct);
    // …then delete your own rows for this player.
    return Results.NoContent();
}).RequireAuthorization();
زمانی نموونەی کۆد

TypeScript

// Your own table, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE);
//   CREATE INDEX ON lingara_players (player_ref);
class PlayerTable implements PlayerDirectory {
  async remember(playerRef: string, subject: string): Promise<void> {
    await db.query('INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1', [playerRef, subject])
  }
  async playerRefFor(subject: string): Promise<string | undefined> {
    const rows = await db.query<{ player_ref: string }>('SELECT player_ref FROM lingara_players WHERE subject = $1', [subject])
    return rows[0]?.player_ref
  }
  async forget(playerRef: string): Promise<void> {
    await db.query('DELETE FROM lingara_players WHERE player_ref = $1', [playerRef])
  }
}

const directory = new PlayerTable()
const router = new PlayerRouter({ secrets: process.env.LINGARA_WEBHOOK_SECRET ?? '', directory })

// POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
export async function webhook(request: Request): Promise<Response> {
  const body = new Uint8Array(await request.arrayBuffer())
  const status = await router.handle(body, request.headers, async ({ event, playerRef }) => {
    // Delivery is at least once: skip an event.id you have already seen.
    // Answer within 10 s; enqueue anything slow and return.
    if (playerRef !== null && event.type === 'practice.completed') await practiceJobs.enqueue(playerRef, event.id)
  })
  return new Response(null, { status })
}

Rust

// Every live secret: two during a rotation, so either one verifies.
let webhook = lingara::events::Webhook::with_secrets(env("LINGARA_WEBHOOK_SECRETS").split(','))?;
let router = PlayerRouter::new(webhook, players);

/// `POST /lingara/webhook`: the **raw** body, before any JSON parsing.
/// 204 handled, 401 a bad signature, 400 a malformed payload, 500 your
/// handler failed. Lingara redelivers every non-2xx; dedupe on the event id.
pub async fn webhook_route(State(app): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
    let status = app
        .router
        .handle(&body, &headers, |routed| async move {
            match routed.player_ref {
                Some(player) => println!("{} for {player}", routed.event.event_type()),
                None => println!("{} for no player of ours", routed.event.event_type()),
            }
            Ok(()) // a slow handler enqueues and returns: delivery expects a 2xx within 10 s
        })
        .await;
    StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}

/// Your own store, beside your player rows: `player_ref`, and `subject`
/// with a unique index (a player who links an account owns two subjects).
pub struct SqlPlayerDirectory {
    pub pool: Arc<tokio::sync::Mutex<Vec<(String, String)>>>, // stands in for your database pool
}

impl PlayerDirectory for SqlPlayerDirectory {
    fn remember<'a>(&'a self, player_ref: &'a str, subject: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
        //   ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref
        Box::pin(async move {
            let mut rows = self.pool.lock().await;
            rows.retain(|(_, s)| s != subject);
            rows.push((player_ref.to_string(), subject.to_string()));
            Ok(())
        })
    }

    fn player_ref_for<'a>(&'a self, subject: &'a str) -> BoxFuture<'a, Result<Option<String>, DirectoryError>> {
        // SELECT player_ref FROM lingara_players WHERE subject = $1
        Box::pin(async move { Ok(self.pool.lock().await.iter().find(|(_, s)| s == subject).map(|(p, _)| p.clone())) })
    }

    fn forget<'a>(&'a self, player_ref: &'a str) -> BoxFuture<'a, Result<(), DirectoryError>> {
        // DELETE FROM lingara_players WHERE player_ref = $1
        Box::pin(async move {
            self.pool.lock().await.retain(|(p, _)| p != player_ref);
            Ok(())
        })
    }
}

Go

// lingara_players(player_ref TEXT NOT NULL, subject TEXT NOT NULL UNIQUE),
// with a plain index on player_ref: one player may own several subjects.
type sqlPlayers struct{ db *sql.DB }

func (p sqlPlayers) Remember(ctx context.Context, playerRef, subject string) error {
	_, err := p.db.ExecContext(ctx, `INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2)
		ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref`, playerRef, subject)
	return err
}

func (p sqlPlayers) PlayerRefFor(ctx context.Context, subject string) (string, bool, error) {
	var playerRef string
	err := p.db.QueryRowContext(ctx, `SELECT player_ref FROM lingara_players WHERE subject = $1`, subject).Scan(&playerRef)
	if errors.Is(err, sql.ErrNoRows) {
		return "", false, nil
	}
	return playerRef, err == nil, err
}

func (p sqlPlayers) Forget(ctx context.Context, playerRef string) error {
	_, err := p.db.ExecContext(ctx, `DELETE FROM lingara_players WHERE player_ref = $1`, playerRef)
	return err
}

// WebhookRoute is the webhook example.
func WebhookRoute(db *sql.DB) (http.HandlerFunc, error) {
	router, err := lingaraembed.NewPlayerRouter(sqlPlayers{db}, os.Getenv("LINGARA_WEBHOOK_SECRET"))
	if err != nil {
		return nil, err // a malformed lgr_whsec_… fails here, at startup
	}
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body) // the raw body: verify before parsing
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(router.Handle(r.Context(), body, r.Header, onEvent))
	}, nil
}

func onEvent(ctx context.Context, routed lingaraembed.RoutedEvent) error {
	// Delivery is at least once: skip an event.Meta().ID you have seen.
	if e, ok := routed.Event.(lingara.PracticeCompleted); ok && routed.Known {
		slog.InfoContext(ctx, "practice completed", "player", routed.PlayerRef, "plan", e.Data.PlanID)
	}
	return nil // a returned error answers 500, and Lingara redelivers
}

Java

import com.getlingara.client.events.PracticeCompleted;
import com.getlingara.embed.server.PlayerDirectory;
import com.getlingara.embed.server.PlayerRouter;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import javax.sql.DataSource;

/**
 * Your player directory, beside your player rows:
 *
 * <pre>
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * </pre>
 */
public record JdbcPlayerDirectory(DataSource db) implements PlayerDirectory {
  @Override
  public void remember(String playerRef, String subject) {
    update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?)"
            + " ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject);
  }

  @Override
  public Optional<String> playerRefFor(String subject) {
    try (Connection c = db.getConnection();
        PreparedStatement q =
            c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?")) {
      q.setString(1, subject);
      try (ResultSet row = q.executeQuery()) {
        return row.next() ? Optional.of(row.getString(1)) : Optional.empty();
      }
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }

  @Override
  public void forget(String playerRef) {
    update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef);
  }

  private void update(String sql, String... values) {
    try (Connection c = db.getConnection();
        PreparedStatement q = c.prepareStatement(sql)) {
      for (int i = 0; i < values.length; i++) {
        q.setString(i + 1, values[i]);
      }
      q.executeUpdate();
    } catch (SQLException e) {
      throw new IllegalStateException("the player directory failed", e);
    }
  }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
PlayerRouter router =
    PlayerRouter.of(new JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"));

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
byte[] body = exchange.getRequestBody().readAllBytes();
int status =
    router.handle(
        body,
        exchange.getRequestHeaders(),
        routed -> {
          // Delivery is at least once: skip an event.id you have seen.
          if (routed.event() instanceof PracticeCompleted done
              && routed.playerRef().isPresent()) {
            award(routed.playerRef().get(), done);
          }
        });
exchange.sendResponseHeaders(status, -1);
exchange.close();

Kotlin

import com.getlingara.embed.server.kotlin.PlayerDirectory
import com.getlingara.embed.server.kotlin.PlayerRouter
import com.getlingara.kotlin.events.PracticeCompleted
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.sql.DataSource

/**
 * Your player directory, beside your player rows:
 *
 * ```sql
 * CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
 * CREATE UNIQUE INDEX ON lingara_players (subject);
 * CREATE INDEX ON lingara_players (player_ref);
 * ```
 */
class JdbcPlayerDirectory(
    private val db: DataSource,
) : PlayerDirectory {
    override suspend fun remember(
        playerRef: String,
        subject: String,
    ) = update(
        "INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) " +
            "ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref",
        playerRef,
        subject,
    )

    override suspend fun playerRefFor(subject: String): String? =
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement("SELECT player_ref FROM lingara_players WHERE subject = ?").use { q ->
                    q.setString(1, subject)
                    q.executeQuery().use { row -> if (row.next()) row.getString(1) else null }
                }
            }
        }

    override suspend fun forget(playerRef: String) = update("DELETE FROM lingara_players WHERE player_ref = ?", playerRef)

    private suspend fun update(
        sql: String,
        vararg values: String,
    ) {
        withContext(Dispatchers.IO) {
            db.connection.use { c ->
                c.prepareStatement(sql).use { q ->
                    values.forEachIndexed { i, v -> q.setString(i + 1, v) }
                    q.executeUpdate()
                }
            }
        }
    }
}

// Once, at startup: your webhook secret (lgr_whsec_…) from the environment.
// A malformed secret fails here, not on the first delivery.
val router = PlayerRouter(JdbcPlayerDirectory(db), System.getenv("LINGARA_WEBHOOK_SECRET"))

// POST /lingara/webhook: hand handle() the raw bytes, before anything parses
// them, and send the status it returns with an empty body.
post("/lingara/webhook") {
    val headers =
        call.request.headers
            .entries()
            .associate { it.key to it.value }
    val status =
        router.handle(call.receive<ByteArray>(), headers) { routed ->
            // Delivery is at least once: skip an event.id you have seen.
            val event = routed.event
            val playerId = routed.playerRef
            if (event is PracticeCompleted && playerId != null) award(playerId, event)
        }
    call.respond(HttpStatusCode.fromValue(status))
}

Ruby

# Your own table: CREATE TABLE lingara_players (player_ref TEXT NOT NULL,
# subject TEXT NOT NULL UNIQUE); CREATE INDEX ON lingara_players (player_ref).
# Any object answering these three methods is a directory; this one is over
# Sequel (`db = Sequel.connect(ENV.fetch("DATABASE_URL"))`).
class PlayerTable
  def initialize(db)
    @rows = db[:lingara_players]
  end

  def remember(player_ref, subject)
    @rows.insert_conflict(target: :subject, update: {player_ref: player_ref})
      .insert(player_ref: player_ref, subject: subject)
  end

  def player_ref_for(subject)
    @rows.where(subject: subject).get(:player_ref)
  end

  def forget(player_ref)
    @rows.where(player_ref: player_ref).delete
  end
end

# POST /lingara/webhook. Pass the raw body: the signature covers its bytes.
def self.webhook(env, router)
  status = router.handle(env["rack.input"].read, env) do |routed|
    # Delivery is at least once: skip an event.id you have already seen.
    # Answer within 10 s; enqueue anything slow and return.
    PracticeJobs.enqueue(routed.player_ref, routed.event) if routed.player_ref
  end
  [status, {}, []]
end

PHP

use Lingara\Embed\PlayerDirectory;
use Lingara\Embed\PlayerRouter;
use Lingara\Embed\RoutedEvent;
use Lingara\Events\Generated\PracticeCompleted;
use Psr\Http\Message\ServerRequestInterface;

// POST /lingara/webhook, from any PSR-7 stack (or pass getallheaders()).
// Read the body exactly as received. handle() returns the status to send,
// with an empty body: 204, 401 a bad signature, 400 a malformed payload, 500
// your handler or directory threw. Lingara redelivers every non-2xx, and
// expects the 2xx within 10 s: enqueue slow work and return.
function webhookRoute(ServerRequestInterface $request, \PDO $db): int
{
    $router = new PlayerRouter(new PdoPlayerDirectory($db), getenv('LINGARA_WEBHOOK_SECRET') ?: '');
    return $router->handle((string) $request->getBody(), $request, static function (RoutedEvent $routed) use ($db): void {
        $event = $routed->event;
        if (!$event instanceof PracticeCompleted || $routed->playerRef === null) {
            return; // your own subject, a deleted player, or another type
        }
        // Delivery is at least once: deduplicate on the event's id.
        $insert = $db->prepare('INSERT INTO practice (event_id, player_id, plan_id) VALUES (?, ?, ?) ON CONFLICT (event_id) DO NOTHING')
            ?: throw new \RuntimeException('cannot prepare');
        $insert->execute([$event->id, $routed->playerRef, $event->data->getPlanId()]);
    });
}

// The directory, beside your players:
//   CREATE TABLE lingara_players (player_ref TEXT NOT NULL, subject TEXT NOT NULL);
//   CREATE UNIQUE INDEX ON lingara_players (subject);
//   CREATE INDEX ON lingara_players (player_ref);
final class PdoPlayerDirectory implements PlayerDirectory
{
    public function __construct(
        private readonly \PDO $db,
    ) {}

    public function remember(string $playerRef, string $subject): void
    {
        $this->run('INSERT INTO lingara_players (player_ref, subject) VALUES (?, ?) '
            . 'ON CONFLICT (subject) DO UPDATE SET player_ref = excluded.player_ref', [$playerRef, $subject]);
    }

    public function playerRefFor(string $subject): ?string
    {
        $ref = $this->run('SELECT player_ref FROM lingara_players WHERE subject = ?', [$subject])->fetchColumn();
        return is_string($ref) ? $ref : null;
    }

    public function forget(string $playerRef): void
    {
        $this->run('DELETE FROM lingara_players WHERE player_ref = ?', [$playerRef]);
    }

    /** @param list<string> $args */
    private function run(string $sql, array $args): \PDOStatement
    {
        $statement = $this->db->prepare($sql) ?: throw new \RuntimeException('cannot prepare a statement');
        $statement->execute($args);
        return $statement;
    }
}

C#

public static partial class LingaraWebhook
{
    public static void MapLingaraWebhook(this WebApplication app, DbDataSource db)
    {
        // lgr_whsec_… from your secret store; pass both during a rotation.
        var router = new PlayerRouter(new SqlPlayerDirectory(db), Environment.GetEnvironmentVariable("LINGARA_WEBHOOK_SECRET")!);

        app.MapPost("/lingara/webhook", async (HttpRequest request, CancellationToken ct) =>
        {
            using var body = new MemoryStream();
            await request.Body.CopyToAsync(body, ct); // the raw bytes: never a re-serialised object
            var status = await router.HandleAsync(body.ToArray(), name => request.Headers[name].FirstOrDefault(), async (routed, token) =>
            {
                // Delivery is at least once: skip an Event.Id you have seen. Enqueue
                // slow work and return, since Lingara expects a 2xx within 10 s.
                if (routed.PlayerRef is { } player && routed.Event.Type == "practice.completed")
                {
                    await SaveProgressAsync(db, player, routed.Event.Id, routed.Event.Data.GetRawText(), token);
                }
            }, ct);
            return Results.StatusCode(status); // 204, 400, 401 or 500, always with an empty body
        });
    }
}

/// <summary>
/// Your player directory over your own database (PostgreSQL here):
/// <c>CREATE TABLE lingara_players (player_ref text NOT NULL, subject text PRIMARY KEY)</c>
/// with <c>CREATE INDEX ON lingara_players (player_ref)</c>. Unique on
/// <c>subject</c> only: a player who links an account owns two subjects.
/// </summary>
public sealed class SqlPlayerDirectory(DbDataSource db) : IPlayerDirectory
{
    public async ValueTask RememberAsync(string playerRef, string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "INSERT INTO lingara_players (player_ref, subject) VALUES ($1, $2) ON CONFLICT (subject) DO UPDATE SET player_ref = $1", playerRef, subject);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    public async ValueTask<string?> PlayerRefForAsync(string subject, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "SELECT player_ref FROM lingara_players WHERE subject = $1", subject);
        return await cmd.ExecuteScalarAsync(ct) as string;
    }

    public async ValueTask ForgetAsync(string playerRef, CancellationToken ct = default)
    {
        await using var cmd = Command(db, "DELETE FROM lingara_players WHERE player_ref = $1", playerRef);
        await cmd.ExecuteNonQueryAsync(ct);
    }

    internal static DbCommand Command(DbDataSource db, string sql, params string[] values)
    {
        var cmd = db.CreateCommand(sql);
        foreach (var value in values)
        {
            var p = cmd.CreateParameter();
            p.Value = value;
            cmd.Parameters.Add(p);
        }

        return cmd;
    }
}

هەروەها ببینە