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 دەداتەوە، کە بە وێبهووک، فید یان ستریم دەگاتە سێرڤەرەکەت ئەگەر کلاینتەکەت events:read و embed:play ی هەبێت. subject ـەکەی یاریزانەکە ناودەبات، بۆیە وەک پەڕەی کیتەکانی سێرڤەر ڕوونی دەکاتەوە ئاڕاستەی بکە. data ـەکەی هەمان ئەو data یەیە کە یارییەکە ناردی: plan_id، items، هەر یەکێک لەگەڵ set_number، question و outcome، و completed.

ئەوەی ئەنجامێک نایکات

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

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

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

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