-module(glopenai@webhook). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/glopenai/webhook.gleam"). -export([webhook_event_decoder/0, created_at/1, event_type/1, parse_event/1, verify_signature_with_tolerance/7, build_event_with_tolerance/7, verify_signature/6, build_event/6]). -export_type([webhook_error/0, webhook_batch_data/0, webhook_eval_run_data/0, webhook_fine_tuning_job_data/0, webhook_response_data/0, sip_header/0, webhook_realtime_call_data/0, webhook_event/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. -type webhook_error() :: invalid_signature | {invalid, binary()} | {deserialization, binary(), gleam@json:decode_error()}. -type webhook_batch_data() :: {webhook_batch_data, binary()}. -type webhook_eval_run_data() :: {webhook_eval_run_data, binary()}. -type webhook_fine_tuning_job_data() :: {webhook_fine_tuning_job_data, binary()}. -type webhook_response_data() :: {webhook_response_data, binary()}. -type sip_header() :: {sip_header, binary(), binary()}. -type webhook_realtime_call_data() :: {webhook_realtime_call_data, binary(), list(sip_header())}. -type webhook_event() :: {batch_cancelled, integer(), binary(), gleam@option:option(binary()), webhook_batch_data()} | {batch_completed, integer(), binary(), gleam@option:option(binary()), webhook_batch_data()} | {batch_expired, integer(), binary(), gleam@option:option(binary()), webhook_batch_data()} | {batch_failed, integer(), binary(), gleam@option:option(binary()), webhook_batch_data()} | {eval_run_canceled, integer(), binary(), gleam@option:option(binary()), webhook_eval_run_data()} | {eval_run_failed, integer(), binary(), gleam@option:option(binary()), webhook_eval_run_data()} | {eval_run_succeeded, integer(), binary(), gleam@option:option(binary()), webhook_eval_run_data()} | {fine_tuning_job_cancelled, integer(), binary(), gleam@option:option(binary()), webhook_fine_tuning_job_data()} | {fine_tuning_job_failed, integer(), binary(), gleam@option:option(binary()), webhook_fine_tuning_job_data()} | {fine_tuning_job_succeeded, integer(), binary(), gleam@option:option(binary()), webhook_fine_tuning_job_data()} | {realtime_call_incoming, integer(), binary(), gleam@option:option(binary()), webhook_realtime_call_data()} | {response_cancelled, integer(), binary(), gleam@option:option(binary()), webhook_response_data()} | {response_completed, integer(), binary(), gleam@option:option(binary()), webhook_response_data()} | {response_failed, integer(), binary(), gleam@option:option(binary()), webhook_response_data()} | {response_incomplete, integer(), binary(), gleam@option:option(binary()), webhook_response_data()}. -file("src/glopenai/webhook.gleam", 172). -spec batch_data_decoder() -> gleam@dynamic@decode:decoder(webhook_batch_data()). batch_data_decoder() -> gleam@dynamic@decode:field( <<"id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Id) -> gleam@dynamic@decode:success({webhook_batch_data, Id}) end ). -file("src/glopenai/webhook.gleam", 177). -spec eval_run_data_decoder() -> gleam@dynamic@decode:decoder(webhook_eval_run_data()). eval_run_data_decoder() -> gleam@dynamic@decode:field( <<"id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Id) -> gleam@dynamic@decode:success({webhook_eval_run_data, Id}) end ). -file("src/glopenai/webhook.gleam", 182). -spec fine_tuning_job_data_decoder() -> gleam@dynamic@decode:decoder(webhook_fine_tuning_job_data()). fine_tuning_job_data_decoder() -> gleam@dynamic@decode:field( <<"id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Id) -> gleam@dynamic@decode:success({webhook_fine_tuning_job_data, Id}) end ). -file("src/glopenai/webhook.gleam", 187). -spec response_data_decoder() -> gleam@dynamic@decode:decoder(webhook_response_data()). response_data_decoder() -> gleam@dynamic@decode:field( <<"id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Id) -> gleam@dynamic@decode:success({webhook_response_data, Id}) end ). -file("src/glopenai/webhook.gleam", 192). -spec sip_header_decoder() -> gleam@dynamic@decode:decoder(sip_header()). sip_header_decoder() -> gleam@dynamic@decode:field( <<"name"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Name) -> gleam@dynamic@decode:field( <<"value"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Value) -> gleam@dynamic@decode:success({sip_header, Name, Value}) end ) end ). -file("src/glopenai/webhook.gleam", 198). -spec realtime_call_data_decoder() -> gleam@dynamic@decode:decoder(webhook_realtime_call_data()). realtime_call_data_decoder() -> gleam@dynamic@decode:field( <<"call_id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Call_id) -> gleam@dynamic@decode:field( <<"sip_headers"/utf8>>, gleam@dynamic@decode:list(sip_header_decoder()), fun(Sip_headers) -> gleam@dynamic@decode:success( {webhook_realtime_call_data, Call_id, Sip_headers} ) end ) end ). -file("src/glopenai/webhook.gleam", 212). ?DOC( " Decode a `WebhookEvent` from JSON. The variant is chosen by the `type`\n" " field on the envelope.\n" ). -spec webhook_event_decoder() -> gleam@dynamic@decode:decoder(webhook_event()). webhook_event_decoder() -> gleam@dynamic@decode:field( <<"type"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Event_type) -> gleam@dynamic@decode:field( <<"created_at"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_int/1}, fun(Created_at) -> gleam@dynamic@decode:field( <<"id"/utf8>>, {decoder, fun gleam@dynamic@decode:decode_string/1}, fun(Id) -> gleam@dynamic@decode:optional_field( <<"object"/utf8>>, none, gleam@dynamic@decode:optional( {decoder, fun gleam@dynamic@decode:decode_string/1} ), fun(Object) -> case Event_type of <<"batch.cancelled"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, batch_data_decoder(), fun(Data) -> gleam@dynamic@decode:success( {batch_cancelled, Created_at, Id, Object, Data} ) end ); <<"batch.completed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, batch_data_decoder(), fun(Data@1) -> gleam@dynamic@decode:success( {batch_completed, Created_at, Id, Object, Data@1} ) end ); <<"batch.expired"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, batch_data_decoder(), fun(Data@2) -> gleam@dynamic@decode:success( {batch_expired, Created_at, Id, Object, Data@2} ) end ); <<"batch.failed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, batch_data_decoder(), fun(Data@3) -> gleam@dynamic@decode:success( {batch_failed, Created_at, Id, Object, Data@3} ) end ); <<"eval.run.canceled"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, eval_run_data_decoder(), fun(Data@4) -> gleam@dynamic@decode:success( {eval_run_canceled, Created_at, Id, Object, Data@4} ) end ); <<"eval.run.failed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, eval_run_data_decoder(), fun(Data@5) -> gleam@dynamic@decode:success( {eval_run_failed, Created_at, Id, Object, Data@5} ) end ); <<"eval.run.succeeded"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, eval_run_data_decoder(), fun(Data@6) -> gleam@dynamic@decode:success( {eval_run_succeeded, Created_at, Id, Object, Data@6} ) end ); <<"fine_tuning.job.cancelled"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, fine_tuning_job_data_decoder(), fun(Data@7) -> gleam@dynamic@decode:success( {fine_tuning_job_cancelled, Created_at, Id, Object, Data@7} ) end ); <<"fine_tuning.job.failed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, fine_tuning_job_data_decoder(), fun(Data@8) -> gleam@dynamic@decode:success( {fine_tuning_job_failed, Created_at, Id, Object, Data@8} ) end ); <<"fine_tuning.job.succeeded"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, fine_tuning_job_data_decoder(), fun(Data@9) -> gleam@dynamic@decode:success( {fine_tuning_job_succeeded, Created_at, Id, Object, Data@9} ) end ); <<"realtime.call.incoming"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, realtime_call_data_decoder(), fun(Data@10) -> gleam@dynamic@decode:success( {realtime_call_incoming, Created_at, Id, Object, Data@10} ) end ); <<"response.cancelled"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, response_data_decoder(), fun(Data@11) -> gleam@dynamic@decode:success( {response_cancelled, Created_at, Id, Object, Data@11} ) end ); <<"response.completed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, response_data_decoder(), fun(Data@12) -> gleam@dynamic@decode:success( {response_completed, Created_at, Id, Object, Data@12} ) end ); <<"response.failed"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, response_data_decoder(), fun(Data@13) -> gleam@dynamic@decode:success( {response_failed, Created_at, Id, Object, Data@13} ) end ); <<"response.incomplete"/utf8>> -> gleam@dynamic@decode:field( <<"data"/utf8>>, response_data_decoder(), fun(Data@14) -> gleam@dynamic@decode:success( {response_incomplete, Created_at, Id, Object, Data@14} ) end ); Other -> gleam@dynamic@decode:failure( {batch_completed, 0, <<""/utf8>>, none, {webhook_batch_data, <<""/utf8>>}}, <<"WebhookEvent: unknown type "/utf8, Other/binary>> ) end end ) end ) end ) end ). -file("src/glopenai/webhook.gleam", 293). ?DOC(" Return the `created_at` timestamp of any webhook event variant.\n"). -spec created_at(webhook_event()) -> integer(). created_at(Event) -> case Event of {batch_cancelled, T, _, _, _} -> T; {batch_completed, T@1, _, _, _} -> T@1; {batch_expired, T@2, _, _, _} -> T@2; {batch_failed, T@3, _, _, _} -> T@3; {eval_run_canceled, T@4, _, _, _} -> T@4; {eval_run_failed, T@5, _, _, _} -> T@5; {eval_run_succeeded, T@6, _, _, _} -> T@6; {fine_tuning_job_cancelled, T@7, _, _, _} -> T@7; {fine_tuning_job_failed, T@8, _, _, _} -> T@8; {fine_tuning_job_succeeded, T@9, _, _, _} -> T@9; {realtime_call_incoming, T@10, _, _, _} -> T@10; {response_cancelled, T@11, _, _, _} -> T@11; {response_completed, T@12, _, _, _} -> T@12; {response_failed, T@13, _, _, _} -> T@13; {response_incomplete, T@14, _, _, _} -> T@14 end. -file("src/glopenai/webhook.gleam", 314). ?DOC(" Return the wire string for the event's `type` discriminator.\n"). -spec event_type(webhook_event()) -> binary(). event_type(Event) -> case Event of {batch_cancelled, _, _, _, _} -> <<"batch.cancelled"/utf8>>; {batch_completed, _, _, _, _} -> <<"batch.completed"/utf8>>; {batch_expired, _, _, _, _} -> <<"batch.expired"/utf8>>; {batch_failed, _, _, _, _} -> <<"batch.failed"/utf8>>; {eval_run_canceled, _, _, _, _} -> <<"eval.run.canceled"/utf8>>; {eval_run_failed, _, _, _, _} -> <<"eval.run.failed"/utf8>>; {eval_run_succeeded, _, _, _, _} -> <<"eval.run.succeeded"/utf8>>; {fine_tuning_job_cancelled, _, _, _, _} -> <<"fine_tuning.job.cancelled"/utf8>>; {fine_tuning_job_failed, _, _, _, _} -> <<"fine_tuning.job.failed"/utf8>>; {fine_tuning_job_succeeded, _, _, _, _} -> <<"fine_tuning.job.succeeded"/utf8>>; {realtime_call_incoming, _, _, _, _} -> <<"realtime.call.incoming"/utf8>>; {response_cancelled, _, _, _, _} -> <<"response.cancelled"/utf8>>; {response_completed, _, _, _, _} -> <<"response.completed"/utf8>>; {response_failed, _, _, _, _} -> <<"response.failed"/utf8>>; {response_incomplete, _, _, _, _} -> <<"response.incomplete"/utf8>> end. -file("src/glopenai/webhook.gleam", 338). ?DOC( " Decode a webhook payload string into a `WebhookEvent`. Does NOT verify the\n" " signature; use `verify_signature` first.\n" ). -spec parse_event(binary()) -> {ok, webhook_event()} | {error, webhook_error()}. parse_event(Body) -> case gleam@json:parse(Body, webhook_event_decoder()) of {ok, Event} -> {ok, Event}; {error, Error} -> {error, {deserialization, Body, Error}} end. -file("src/glopenai/webhook.gleam", 454). -spec parse_timestamp(binary()) -> {ok, integer()} | {error, webhook_error()}. parse_timestamp(Timestamp) -> case gleam_stdlib:parse_int(Timestamp) of {ok, Value} -> {ok, Value}; {error, _} -> {error, {invalid, <<"invalid timestamp format"/utf8>>}} end. -file("src/glopenai/webhook.gleam", 479). ?DOC( " Parse the value of the `webhook-signature` header. Two formats are\n" " accepted:\n" "\n" " - `\"v1,base64sig v1,base64sig2\"` — Standard Webhooks list of versioned\n" " signatures separated by whitespace; we only honour `v1`.\n" " - `\"base64sig\"` — a bare signature with no version tag.\n" ). -spec parse_signature_header(binary()) -> list(binary()). parse_signature_header(Signature) -> case gleam_stdlib:contains_string(Signature, <<","/utf8>>) of true -> _pipe = Signature, _pipe@1 = gleam@string:split(_pipe, <<" "/utf8>>), gleam@list:filter_map( _pipe@1, fun(Part) -> case gleam@string:split(Part, <<","/utf8>>) of [<<"v1"/utf8>>, Sig] -> {ok, Sig}; _ -> {error, nil} end end ); false -> [Signature] end. -file("src/glopenai/webhook.gleam", 505). -spec constant_time_eq_bits(bitstring(), bitstring(), integer()) -> boolean(). constant_time_eq_bits(A, B, Acc) -> case {A, B} of {<>, <>} -> constant_time_eq_bits( Rest_a, Rest_b, erlang:'bor'(Acc, erlang:'bxor'(X, Y)) ); {_, _} -> Acc =:= 0 end. -file("src/glopenai/webhook.gleam", 496). ?DOC( " Constant-time string comparison via byte-wise XOR. Avoids early exits that\n" " could leak length information through timing.\n" ). -spec constant_time_eq_string(binary(), binary()) -> boolean(). constant_time_eq_string(A, B) -> A_bits = gleam_stdlib:identity(A), B_bits = gleam_stdlib:identity(B), case erlang:byte_size(A_bits) =:= erlang:byte_size(B_bits) of false -> false; true -> constant_time_eq_bits(A_bits, B_bits, 0) end. -file("src/glopenai/webhook.gleam", 519). -spec hmac_sha256(bitstring(), bitstring()) -> bitstring(). hmac_sha256(Key, Data) -> glopenai_webhook_ffi:hmac_sha256(Key, Data). -file("src/glopenai/webhook.gleam", 523). -spec base64_encode_binary(bitstring()) -> binary(). base64_encode_binary(Bytes) -> case gleam@bit_array:to_string(glopenai_webhook_ffi:base64_encode(Bytes)) of {ok, S} -> S; {error, _} -> <<""/utf8>> end. -file("src/glopenai/webhook.gleam", 531). -spec base64_decode(bitstring()) -> {ok, bitstring()} | {error, nil}. base64_decode(Input) -> glopenai_webhook_ffi:base64_decode(Input). -file("src/glopenai/webhook.gleam", 461). -spec decode_secret(binary()) -> {ok, bitstring()} | {error, webhook_error()}. decode_secret(Secret) -> Stripped = case gleam_stdlib:string_starts_with(Secret, <<"whsec_"/utf8>>) of true -> gleam@string:drop_start(Secret, 6); false -> Secret end, case base64_decode(gleam_stdlib:identity(Stripped)) of {ok, Bytes} -> {ok, Bytes}; {error, _} -> {error, {invalid, <<"failed to decode secret from base64"/utf8>>}} end. -file("src/glopenai/webhook.gleam", 374). ?DOC(" Verify a webhook signature with an explicit tolerance window in seconds.\n"). -spec verify_signature_with_tolerance( binary(), binary(), binary(), binary(), binary(), integer(), integer() ) -> {ok, nil} | {error, webhook_error()}. verify_signature_with_tolerance( Body, Signature, Timestamp, Webhook_id, Secret, Now, Tolerance_seconds ) -> gleam@result:'try'( parse_timestamp(Timestamp), fun(Timestamp_seconds) -> case (Now - Timestamp_seconds) > Tolerance_seconds of true -> {error, {invalid, <<"webhook timestamp is too old"/utf8>>}}; false -> case Timestamp_seconds > (Now + Tolerance_seconds) of true -> {error, {invalid, <<"webhook timestamp is too new"/utf8>>}}; false -> gleam@result:'try'( decode_secret(Secret), fun(Secret_bytes) -> Signed_payload = <<<<<<<>/binary, Timestamp/binary>>/binary, "."/utf8>>/binary, Body/binary>>, Mac = hmac_sha256( Secret_bytes, gleam_stdlib:identity(Signed_payload) ), Expected = base64_encode_binary(Mac), Candidates = parse_signature_header( Signature ), case gleam@list:any( Candidates, fun(Sig) -> constant_time_eq_string( Sig, Expected ) end ) of true -> {ok, nil}; false -> {error, invalid_signature} end end ) end end end ). -file("src/glopenai/webhook.gleam", 431). ?DOC(" Verify with explicit tolerance, then decode the body.\n"). -spec build_event_with_tolerance( binary(), binary(), binary(), binary(), binary(), integer(), integer() ) -> {ok, webhook_event()} | {error, webhook_error()}. build_event_with_tolerance( Body, Signature, Timestamp, Webhook_id, Secret, Now, Tolerance_seconds ) -> gleam@result:'try'( verify_signature_with_tolerance( Body, Signature, Timestamp, Webhook_id, Secret, Now, Tolerance_seconds ), fun(_) -> parse_event(Body) end ). -file("src/glopenai/webhook.gleam", 354). ?DOC( " Verify a webhook signature using the default 300-second tolerance.\n" "\n" " `now` is the current Unix timestamp in seconds. Pass a clock you trust;\n" " this keeps verification sans-IO.\n" ). -spec verify_signature( binary(), binary(), binary(), binary(), binary(), integer() ) -> {ok, nil} | {error, webhook_error()}. verify_signature(Body, Signature, Timestamp, Webhook_id, Secret, Now) -> verify_signature_with_tolerance( Body, Signature, Timestamp, Webhook_id, Secret, Now, 300 ). -file("src/glopenai/webhook.gleam", 411). ?DOC(" Verify a signature and decode the body in one step.\n"). -spec build_event(binary(), binary(), binary(), binary(), binary(), integer()) -> {ok, webhook_event()} | {error, webhook_error()}. build_event(Body, Signature, Timestamp, Webhook_id, Secret, Now) -> build_event_with_tolerance( Body, Signature, Timestamp, Webhook_id, Secret, Now, 300 ).