Lingara Lingara ឯកសារ មគ្គុទ្ទេសក៍សិក្សា API បណ្ណាល័យ កម្មវិធី បង្កើត បង្កប់ កម្មវិធីលើបណ្ដាញ
ភាសា: ខ្មែរ

កញ្ចប់ឧបករណ៍ម៉ាស៊ីនមេ

ទំព័រនេះត្រូវបានបកប្រែពីភាសាអង់គ្លេស។ ប្រសិនបើទាំងពីរខុសគ្នា ទំព័រភាសាអង់គ្លេសគឺត្រឹមត្រូវ។ អានទំព័រភាសាអង់គ្លេស

ម៉ាស៊ីនមេរបស់អ្នក គឺជាផ្នែកនៃការបង្កប់ដែលកាន់សម្ងាត់។ កញ្ចប់ឧបករណ៍ម៉ាស៊ីនមេរុំបណ្ណាល័យ Lingara សម្រាប់ភាសារបស់អ្នក ដូច្នេះវាចូលដោយប្រើក្លាយអិន OAuth របស់អ្នក ហើយបង្កើតតូខិនសម្រាប់អ្នកលេងរបស់អ្នក ពិនិត្យ webhook ដែល Lingara ផ្ញើមកអ្នក និងលុបអ្នកលេងនៅពេលពួកគេចាកចេញ។ គំរូខាងក្រោមធ្វើតាមជំហានដូចគ្នានៅក្នុងភាសានីមួយៗ។

បង្កើតក្លាយអិនគិតតាមការប្រើប្រាស់

បង្កើតក្លាយអិន OAuth មួយនៅក្នុងកុងសូល ហើយដាក់វានៅលើគម្រោងគិតតាមការប្រើប្រាស់។ ផ្ដល់ឲ្យវានូវ embed:mint ដែលជាវិសាលភាពសម្រាប់បង្កើតតូខិន និងលុបអ្នកលេង។ ក្លាយអិននៅលើគម្រោងផ្សេងទៀតណាមួយ ត្រូវបានបដិសេធវិសាលភាពនោះ។ ផ្ដល់ឲ្យវាផងដែរនូវវិសាលភាពទាំងអស់ដែលអ្នកចង់ឲ្យតូខិនរបស់អ្នកលេងរបស់អ្នកផ្ទុក ព្រោះតូខិនមួយអាចកាន់បានតែវិសាលភាពដែលក្លាយអិនរបស់អ្នកកាន់ប៉ុណ្ណោះ។ embed:mint ខ្លួនវាផ្ទាល់ មិនដែលត្រូវបានប្រគល់ឲ្យអ្នកលេងទេ។

រក្សា ID និងសម្ងាត់របស់ក្លាយអិននៅក្នុងបរិស្ថាននៃម៉ាស៊ីនមេរបស់អ្នក មិនដែលនៅក្នុងហ្គេម ឬទំព័រវេបឡើយ។

ចុះឈ្មោះប្រភពដើមវេបរបស់អ្នក

វិដជិតគេហទំព័រត្រូវការតូខិនដែលចងភ្ជាប់នឹងប្រភពដើម (origin) របស់ទំព័រ។ នៅក្នុងកុងសូល សូមរាយប្រភពដើមទាំងអស់ដែលវិដជិតរបស់អ្នកដំណើរការលើ នៅក្នុងការកំណត់នៃក្លាយអិនរបស់អ្នក ដូចជា https://www.example.com។ អ្នកអាចរាយបានយ៉ាងច្រើនបំផុត 10។ ប្រភពដើមមួយគឺជា scheme មួយ host មួយ និង port ជាជម្រើសមួយ ដោយគ្មាន path។ សម្រាប់ការអភិវឌ្ឍ ប្រភពដើម loopback នៅលើ port ផ្ទាល់ខ្លួនរបស់អ្នកក៏ត្រូវបានអនុញ្ញាតដែរ ដូចជា http://localhost:5173។ តូខិនមួយត្រូវបានទទួលយកតែពីប្រភពដើមពិតប្រាកដដែលវាត្រូវបានបង្កើតសម្រាប់ប៉ុណ្ណោះ ដូច្នេះតូខិនដែលបានបង្កើតសម្រាប់ប្រភពដើមផ្សេងទៀតណាមួយ ត្រូវបាន API បដិសេធ។ ហ្គេមមិនត្រូវការប្រភពដើមទេ។

បង្កើតតូខិន

បង្កើតតូខិននៅពីក្រោយការចូលគណនីផ្ទាល់ខ្លួនរបស់អ្នក៖ ផ្លូវតូខិន (token route) របស់អ្នកពិនិត្យសម័យផ្ទាល់ខ្លួនរបស់អ្នកជាមុនសិន ហើយមានតែពេលនោះទេទើបបង្កើតតូខិនសម្រាប់អ្នកលេងនោះ។ កំណត់អត្រាកំណត់នៃផ្លូវនោះតាមសម័យនីមួយៗ។ សំណើបង្កើតតូខិនមួយទទួលវាលបី៖

  • player_ref៖ ឯកសារយោងផ្ទាល់ខ្លួនរបស់អ្នកសម្រាប់អ្នកលេង ពី 1 ដល់ 128 បៃនៃ UTF-8។ Lingara រក្សាទុកតែ hash របស់វាប៉ុណ្ណោះ។
  • scopes៖ យ៉ាងច្រើនបំផុត 5 ពីវិសាលភាពដែលតូខិនរបស់អ្នកលេងអាចកាន់បាន៖ events:read, events:write, lesson_plans:read, lesson_plans:write និង embed:play។ ប្រសិនបើអ្នកលុបវាចោល តូខិននឹងកាន់វិសាលភាពទាំងអស់ក្នុងចំណោមនោះដែលក្លាយអិនរបស់អ្នកកាន់។
  • origin៖ សម្រាប់គេហទំព័រ ប្រភពដើមរបស់ទំព័រ ដែលត្រូវតែជាប្រភពដើមមួយដែលអ្នកបានចុះឈ្មោះ។ លុបវាចោលសម្រាប់ហ្គេម។

ចម្លើយផ្ទុក token, expires_at, expires_in (ពេលផុតកំណត់ដដែលគិតជាវិនាទី សម្រាប់ឧបករណ៍ដែលនាឡិការបស់វាអ្នកមិនអាចទុកចិត្តបាន), subject, scopes របស់តូខិន និង account_linked ដែលប្រាប់ថាតើអ្នកលេងបានភ្ជាប់គណនី Lingara ផ្ទាល់ខ្លួនរបស់ពួកគេឬអត់។ subject សម្គាល់អត្តសញ្ញាណអ្នកលេងចំពោះក្លាយអិនរបស់អ្នក ហើយព្រឹត្តិការណ៍ទាំងអស់អំពីពួកគេផ្ទុកវា។

ប្រគល់តូខិន និងពេលផុតកំណត់របស់វាឲ្យអ្នកលេង ដូចដែលការប្រគល់របស់កញ្ចប់ឧបករណ៍សរសេរវាយ៉ាងពិតប្រាកដ តាមរយៈសម័យផ្ទាល់ខ្លួនរបស់អ្នក ហើយមិនដែលនៅក្នុង URL ឡើយ ហើយផ្ញើចម្លើយជាមួយ Cache-Control: no-store។ កញ្ចប់ឧបករណ៍អ្នកលេងសុំម្ដងទៀតនៅពេលតូខិនជិតផុតកំណត់ តូខិនមិនដែលត្រូវបានធ្វើឲ្យថ្មីឡើងវិញទេ។

បញ្ជូន webhook ទៅកាន់អ្នកលេងរបស់វា

Lingara ចុះហត្ថលេខាលើ webhook ទាំងអស់ដែលវាផ្ញើមកអ្នក។ ផ្ដល់ឲ្យកញ្ចប់ឧបករណ៍នូវតួសំណើដើម មុនពេលធ្វើការញែក JSON ណាមួយ ព្រោះហត្ថលេខាគ្របដណ្ដប់លើបៃរបស់វា។ កញ្ចប់ឧបករណ៍ពិនិត្យហត្ថលេខា ហើយរកមើលអ្នកលេងដែល subject នៃព្រឹត្តិការណ៍ដាក់ឈ្មោះ នៅក្នុងតារាងផ្ទាល់ខ្លួនរបស់អ្នក នៅក្បែរអ្នកលេងរបស់អ្នក ដែលផ្គូផ្គង subject នីមួយៗទៅនឹង player_ref របស់អ្នក។ អ្នកលេងដែលភ្ជាប់គណនីមួយ មាន subject ពីរ ដូច្នេះសូមរក្សាជួរឈរ subject ឲ្យមានតម្លៃមិនស្ទួន ហើយអនុញ្ញាតឲ្យ player_ref មួយលេចឡើងពីរដង។

ការបញ្ជូនគឺយ៉ាងហោចណាស់ម្ដង៖ រំលង ID ព្រឹត្តិការណ៍ដែលអ្នកបានដោះស្រាយរួចហើយ។ ឆ្លើយតបក្នុងរយៈពេល 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 ជា true ហើយអ្នកនៅតែបង់ប្រាក់សម្រាប់អ្វីដែលអ្នកលេងធ្វើ។

ដើម្បីបញ្ចប់ការភ្ជាប់ សូមហៅ 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;
    }
}

មើលផងដែរ