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

លទ្ធផលនៃការហាត់

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

អ្នកលេងអាចហាត់ផែនការមេរៀន Lingara មួយនៅក្នុងហ្គេមរបស់អ្នក ហើយអ្វីដែលពួកគេបញ្ចប់ ត្រូវបានរាប់ជាការហាត់ Lingara។ កញ្ចប់ឧបករណ៍អ្នកលេងដំណើរការវគ្គហាត់ ដាក់ពិន្ទុចម្លើយនីមួយៗនៅពេលវាត្រូវបានផ្ដល់ ហើយរាយការណ៍លទ្ធផលតែម្ដង នៅពេលវគ្គបញ្ចប់។ បន្ទាប់មក លទ្ធផលទៅដល់ម៉ាស៊ីនមេរបស់អ្នកជាព្រឹត្តិការណ៍មួយ។

ចាប់ផ្ដើមវគ្គហាត់

វគ្គហាត់មួយហាត់ផែនការមេរៀនដែលអ្នកលេងជាម្ចាស់។ មានវិធីពីរយ៉ាងដើម្បីទទួលបានផែនការមួយ។ កញ្ចប់ឧបករណ៍អ្នកលេងអាចសុំការហាត់ដែលសមនឹងពិភពរបស់អ្នក ដែលផ្ញើព្រឹត្តិការណ៍ world.practice_requested ជាមួយ generate: true ហើយរង់ចាំផែនការដែល Lingara បង្កើត។ ឬហ្គេមអាចបង្កើតផែនការមួយដោយប្រើ POST /v1/lesson-plans ជាមួយតូខិនរបស់អ្នកលេង ដូច្នេះផែនការនោះជារបស់ពួកគេ។ មិនថាវិធីណាក៏ដោយ ការបង្កើតផែនការត្រូវបានរាប់ចូលក្នុងដែនកំណត់របស់អ្នកលេង។

ផែនការសាកល្បង ដែលអ្នកលេងមិនមែនជាម្ចាស់ អាចហាត់បាន ប៉ុន្តែមិនអាចរាយការណ៍បានទេ៖ ចាប់ផ្ដើមវគ្គហាត់របស់វាដោយបិទការរាយការណ៍។ Lingara បដិសេធលទ្ធផលលើផែនការដែលអ្នកលេងមិនមែនជាម្ចាស់។

លទ្ធផលដែលកញ្ចប់ឧបករណ៍រាយការណ៍

នៅពេលវគ្គហាត់មួយបញ្ចប់ កញ្ចប់ឧបករណ៍អ្នកលេងផ្ញើព្រឹត្តិការណ៍ចូលមួយ world.practice_completed ជាមួយតូខិនរបស់អ្នកលេង។ data របស់វាផ្ទុក៖

  • plan_id ផែនការដែលបានហាត់។
  • items ចម្លើយ ពី 1 ដល់ 100 ដោយសំណួរនីមួយៗយ៉ាងច្រើនបំផុតម្ដង។
  • សម្រាប់ធាតុនីមួយៗ set_number របស់វា ពី 1 ដល់ 255 question របស់វា ដែលជាទីតាំងនៅក្នុងឈុតនោះ ពី 0 ដល់ 255 និង outcome របស់វា ដែលជាមួយក្នុងចំណោម correct, incorrect, revealed, self_correct, self_incorrect ឬ skipped។
  • completed ថាតើអ្នកលេងបានបញ្ចប់ផែនការទាំងមូលឬអត់។

តូខិនត្រូវការ events:write និង embed:play។ របាយការណ៍នីមួយៗប្រើប្រាស់សិទ្ធិព្រឹត្តិការណ៍ចូលមួយរបស់អ្នកលេង ហើយការផ្ញើរបាយការណ៍ដដែលម្ដងទៀតមិនផ្លាស់ប្ដូរអ្វីទេ។

ព្រឹត្តិការណ៍ដែលម៉ាស៊ីនមេរបស់អ្នកទទួល

Lingara ឆ្លើយតបលទ្ធផលដែលបានទទួលយកនីមួយៗដោយ practice.completed ដែលទៅដល់ម៉ាស៊ីនមេរបស់អ្នកតាម webhook, feed ឬ stream ប្រសិនបើក្លាយអិនរបស់អ្នកកាន់ events:read និង embed:play។ subject របស់វាដាក់ឈ្មោះអ្នកលេង ដូច្នេះសូមបញ្ជូនវាទៅតាមរបៀបដែលទំព័រ កញ្ចប់ឧបករណ៍ម៉ាស៊ីនមេ ពិពណ៌នា។ data របស់វាគឺជា data ដែលហ្គេមបានផ្ញើ៖ plan_id, items ដែលនីមួយៗមាន set_number, question និង outcome ហើយនិង completed។

អ្វីដែលលទ្ធផលមិនធ្វើ

លទ្ធផលត្រូវបានកត់ត្រា ហើយអ្នកទទួលដំណឹងអំពីវា។ វាមិនផ្ដល់ទិន្នន័យដល់ការរំលឹកឡើងវិញតាមចន្លោះពេល (spaced repetition) របស់អ្នកលេងទេ៖ ពាក្យនៅក្នុងធាតុដែលបានរាយការណ៍ មិនត្រូវបានកំណត់ពេលសម្រាប់ការរំលឹកដោយសារវាឡើយ។

ភាសានៃឧទាហរណ៍កូដ

Bevy

fn practise(
    keys: Res<ButtonInput<KeyCode>>,
    player: Query<(Entity, Option<&PracticeSession>), With<Player>>,
    mut start: MessageWriter<StartPractice>,
    mut input: MessageWriter<PracticeInput>,
) {
    let Ok((entity, session)) = player.single() else { return };
    if keys.just_pressed(KeyCode::KeyP) && session.is_none() {
        let source = PracticeSource::Request {
            topic: "ordering food".into(),
            source_lang: "en".into(),
            target_lang: "zh".into(),
            level: 2,
            tags: vec![],
        };
        start.write(StartPractice { entity, source });
        // A demo plan practises, but must never report:
        // PracticeSource::Plan { plan_id, report: false }
    }
    if keys.just_pressed(KeyCode::Digit2) && session.is_some() {
        input.write(PracticeInput { entity, action: PracticeAction::Choose(1) });
    }
}

/// Written once. Lingara has already sent the one result report, which
/// reaches your webhook as `practice.completed`.
fn on_practice_finished(mut finished: MessageReader<PracticeFinished>) {
    for done in finished.read() {
        info!("practice ended in {:?}", done.snapshot.phase);
    }
}

Unity

// A demo plan reports nothing: start it with Report = false.
public SessionId StartPractice(string planId, bool report) =>
    Client.PracticeStart(new PracticeSource.Plan { PlanId = planId, Report = report });

// Each answer returns the new snapshot in the same frame: show its LastResult, then PracticeNext.
public PracticeSnapshot Answer(SessionId session, PracticeSnapshot current, int choice, string typed) =>
    current.Question?.Options != null ? Client.PracticeChoose(session, (uint)choice) : Client.PracticeAnswer(session, typed);

public PracticeSnapshot Next(SessionId session) => Client.PracticeNext(session);

Godot C#

// A demo plan reports nothing: start it with report = false.
public long StartPractice(string planId, bool report) =>
    Client.PracticeStart(new PracticeSource.Plan { PlanId = planId, Report = report }).Value;

// Each answer returns the new snapshot at once: show its LastResult, then Next.
public PracticeSnapshot Answer(long session, PracticeSnapshot current, int choice, string typed) =>
    current.Question?.Options != null
        ? Client.PracticeChoose(new SessionId(session), (uint)choice)
        : Client.PracticeAnswer(new SessionId(session), typed);

public PracticeSnapshot Next(long session) => Client.PracticeNext(new SessionId(session));

GDScript

# A practice session over a plan Lingara makes for this player. Each answer
# method returns the new snapshot and also emits practice_changed.
func start_practice() -> void:
	_session = lingara.practice_start({
		"type": "request",
		"topic": "buying food at a market",
		"source_lang": "en",
		"target_lang": "zh",
		"level": 3,
	})


# A demo plan Lingara refuses a report on, so practise one with report false.
func practise_held_plan(plan_id: String) -> void:
	_session = lingara.practice_start({"type": "plan", "plan_id": plan_id, "report": false})


func _on_practice_changed(session: int, snapshot: Dictionary) -> void:
	if session != _session or snapshot.phase != "unanswered":
		return
	var question: Dictionary = snapshot.question
	if question.options != null:
		lingara.practice_choose(session, 0)
	else:
		lingara.practice_answer(session, "your learner's answer")


func _on_practice_finished(_session_id: int, snapshot: Dictionary) -> void:
	print("practice ended: ", snapshot.phase)

Unreal C++

// A demo plan reports nothing: start it with bReport = false.
void ALingaraEmbedSnippetActor::StartPractice(bool bReport)
{
	FLingaraPracticeSource Source;
	Source.Type = ELingaraPracticeSourceType::Plan;
	Source.PlanId = PlanId;
	Source.bReport = bReport;
	ELingaraCallResult Result;
	FLingaraEmbedError Error;
	Session = Lingara()->PracticeStart(Source, Result, Error);
}

void ALingaraEmbedSnippetActor::HandlePractice(const FLingaraPracticeUpdate& Update)
{
	if (Update.Session == Session)
	{
		Snapshot = Update.Snapshot;
	}
}

// Each answer returns the new snapshot at once: show its LastResult, then Next.
void ALingaraEmbedSnippetActor::Answer(int32 Choice, const FString& Typed)
{
	ELingaraCallResult Result;
	FLingaraEmbedError Error;
	Snapshot = Snapshot.Question.Options.Num() > 0 ? Lingara()->PracticeChoose(Session, Choice, Result, Error)
		: Lingara()->PracticeAnswer(Session, Typed, Result, Error);
}

void ALingaraEmbedSnippetActor::Next()
{
	ELingaraCallResult Result;
	FLingaraEmbedError Error;
	Snapshot = Lingara()->PracticeNext(Session, Result, Error);
}

Kotlin

fun startPractice() {
    practice = lingara.practiceStart(PracticeSource.Request("ordering tea", "en", "zh", 2, listOf("tea")))
}

fun answer(text: String) {
    val session = practice ?: return
    val graded = lingara.practiceAnswer(session, text) // graded in the same call
    println("${graded.lastResult?.outcome}: ${graded.lastResult?.expected}")
}

Java

void startPractice() {
  practice =
      lingara.practiceStart(new PracticeSource.Request("ordering tea", "en", "zh", 2, List.of()));
}

void answer(String text) {
  PracticeSnapshot graded = lingara.practiceAnswer(practice, text); // graded in the same call
  System.out.println(graded.getLastResult());
}
ភាសានៃឧទាហរណ៍កូដ

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;
    }
}

មើលផងដែរ