okengine 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +148 -13
  2. package/package.json +3 -3
  3. package/site/content/docs/elements/channel.mdx +71 -7
  4. package/site/content/docs/reference/configuration.mdx +5 -4
  5. package/site/content/docs/reference/environment-variables.mdx +32 -8
  6. package/src/cli/openbao-restart.integration.test.ts +106 -97
  7. package/src/docker/dockerfile.integration.test.ts +126 -119
  8. package/src/docker/stack.integration.test.ts +118 -102
  9. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  10. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  11. package/src/drivers/channel-fcm.ts +49 -53
  12. package/src/drivers/channel-msegat.ts +61 -0
  13. package/src/drivers/channel-sently-map.ts +57 -0
  14. package/src/drivers/channel-sently.test.ts +99 -0
  15. package/src/drivers/channel-sndr.ts +28 -0
  16. package/src/drivers/channel-taqnyat.ts +57 -0
  17. package/src/drivers/channel-types.ts +79 -2
  18. package/src/drivers/channel-unifonic.ts +26 -43
  19. package/src/drivers/channel-wa-cloud.ts +33 -47
  20. package/src/drivers/channel-webpush.ts +39 -239
  21. package/src/drivers/index.ts +4 -0
  22. package/src/elements/channel/costs.test.ts +2 -2
  23. package/src/elements/channel/costs.ts +14 -2
  24. package/src/elements/channel/mime.ts +11 -0
  25. package/src/elements/channel/runtime.ts +94 -0
  26. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  27. package/src/elements/channel.ts +10 -1
  28. package/src/elements/index.ts +9 -0
  29. package/src/kernel/boot-bind/channel.test.ts +68 -3
  30. package/src/kernel/boot-bind/channel.ts +93 -2
  31. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  32. package/src/release/exports.test.ts +26 -0
  33. package/src/release/exports.ts +64 -5
  34. package/src/release/index.ts +5 -0
  35. package/src/release/measure.exports.test.ts +13 -1
  36. package/src/release/measure.ts +76 -13
  37. package/src/release/official-plugins.ts +46 -0
  38. package/src/release/readme.test.ts +30 -2
@@ -1,7 +1,9 @@
1
1
  /**
2
- * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 (protocol-shaped).
2
+ * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 via sently.
3
3
  */
4
4
 
5
+ import { FcmTransport } from "sently/transports/fcm";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
5
7
  import type {
6
8
  ChannelDriver,
7
9
  ChannelMessage,
@@ -13,70 +15,64 @@ import type {
13
15
  /**
14
16
  * Open an FCM push driver.
15
17
  *
16
- * @param options - `token` (OAuth access token) + `from` (project id)
18
+ * Prefer service-account credentials (`clientEmail` + `privateKey` + `from`/
19
+ * `projectId`). For tests, pass `token` as a pre-fetched access token via
20
+ * `getAccessToken` (still requires `from` / project id).
21
+ *
22
+ * @param options - Project id + service account or injectable token
17
23
  */
18
24
  export function openFcmChannel(options: ChannelOpenOptions = {}): ChannelDriver {
25
+ const projectId = options.projectId ?? options.from;
26
+ if (!projectId) {
27
+ throw new Error("fcm channel: projectId (or from) is required");
28
+ }
29
+
30
+ const clientEmail = options.clientEmail ?? options.user;
31
+ const privateKey = options.privateKey ?? options.pass;
19
32
  const accessToken = options.token ?? options.apiKey;
20
- const projectId = options.from;
21
- const fetchFn = options.fetch ?? globalThis.fetch;
22
- const base = options.url ?? "https://fcm.googleapis.com";
33
+
34
+ if (!accessToken && !(clientEmail && privateKey)) {
35
+ throw new Error(
36
+ "fcm channel: clientEmail+privateKey (service account) or token (access token) required",
37
+ );
38
+ }
39
+ if ((clientEmail && !privateKey) || (!clientEmail && privateKey)) {
40
+ throw new Error("fcm channel: clientEmail and privateKey must be provided together");
41
+ }
42
+
43
+ // Token-only mode (tests / pre-fetched OAuth): sently still requires placeholder
44
+ // service-account fields; getAccessToken skips JWT exchange.
45
+ const transport = new FcmTransport({
46
+ projectId,
47
+ clientEmail: clientEmail ?? "oke-fcm@local",
48
+ privateKey:
49
+ privateKey ??
50
+ "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF6PZGFw=\n-----END PRIVATE KEY-----\n",
51
+ ...(accessToken ? { getAccessToken: async () => accessToken } : {}),
52
+ });
23
53
 
24
54
  const channel: ChannelTransport = {
25
55
  provider: "fcm",
26
56
  mediums: ["push"],
27
57
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
28
- if (!accessToken || !projectId) {
29
- throw new Error("fcm: token and from (project id) are required");
30
- }
31
- const res = await fetchFn(`${base}/v1/projects/${projectId}/messages:send`, {
32
- method: "POST",
33
- headers: {
34
- Authorization: `Bearer ${accessToken}`,
35
- "Content-Type": "application/json",
36
- },
37
- body: JSON.stringify({
38
- message: {
39
- token: message.to,
40
- notification: {
41
- title: message.subject ?? message.template ?? "notification",
42
- body: message.text ?? "",
43
- },
44
- data: Object.fromEntries(
45
- Object.entries(message.data ?? {}).map(([k, v]) => [k, String(v)]),
46
- ),
47
- },
48
- }),
49
- });
50
- const body = (await res.json().catch(() => ({}))) as {
51
- name?: string;
52
- error?: { message?: string };
53
- };
54
- const id = body.name ?? crypto.randomUUID();
55
- if (!res.ok) {
56
- return {
57
- ok: false,
58
- messageId: id,
59
- driverId: "fcm",
60
- attempts: [
61
- {
62
- driverId: "fcm",
63
- ok: false,
64
- error: body.error?.message ?? `HTTP ${res.status}`,
65
- at: Date.now(),
66
- },
67
- ],
68
- };
58
+ try {
59
+ const title = message.subject ?? message.template ?? "notification";
60
+ const body = message.text ?? "";
61
+ const result = await transport.send({
62
+ token: message.to,
63
+ title,
64
+ body,
65
+ ...(message.data ? { data: { ...message.data } } : {}),
66
+ });
67
+ return mapSentlySendResult("fcm", result);
68
+ } catch (err) {
69
+ return mapSentlySendError("fcm", err);
69
70
  }
70
- return {
71
- ok: true,
72
- messageId: id,
73
- driverId: "fcm",
74
- attempts: [{ driverId: "fcm", ok: true, at: Date.now(), messageId: id }],
75
- };
76
71
  },
72
+ verify: () => transport.verify(),
77
73
  };
78
74
 
79
- return { id: "fcm", channel };
75
+ return { id: "fcm", channel, pushTransport: transport };
80
76
  }
81
77
 
82
78
  /** FCM driver factory. */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `msegat` channel driver — SMS via sently's Msegat transport.
3
+ */
4
+
5
+ import { MsegatTransport } from "sently/transports/msegat";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
+ import type {
8
+ ChannelDriver,
9
+ ChannelMessage,
10
+ ChannelOpenOptions,
11
+ ChannelSendResult,
12
+ ChannelTransport,
13
+ } from "./channel-types.ts";
14
+
15
+ /**
16
+ * Open a Msegat SMS driver.
17
+ *
18
+ * @param options - `userName`/`user` + `apiKey` + `sender`/`from`
19
+ */
20
+ export function openMsegatChannel(options: ChannelOpenOptions = {}): ChannelDriver {
21
+ const userName = options.userName ?? options.user;
22
+ const apiKey = options.apiKey;
23
+ const sender = options.sender ?? options.from;
24
+ if (!userName) {
25
+ throw new Error("msegat channel: userName (or user) is required");
26
+ }
27
+ if (!apiKey) {
28
+ throw new Error("msegat channel: apiKey is required");
29
+ }
30
+ if (!sender) {
31
+ throw new Error("msegat channel: sender (or from) is required");
32
+ }
33
+
34
+ const transport = new MsegatTransport({ userName, apiKey, sender });
35
+
36
+ const channel: ChannelTransport = {
37
+ provider: "msegat",
38
+ mediums: ["sms"],
39
+ async send(message: ChannelMessage): Promise<ChannelSendResult> {
40
+ try {
41
+ const result = await transport.send({
42
+ to: message.to,
43
+ body: message.text ?? String(message.data?.code ?? ""),
44
+ ...(message.from ? { from: message.from } : {}),
45
+ });
46
+ return mapSentlySendResult("msegat", result);
47
+ } catch (err) {
48
+ return mapSentlySendError("msegat", err);
49
+ }
50
+ },
51
+ verify: () => transport.verify(),
52
+ };
53
+
54
+ return { id: "msegat", channel, smsTransport: transport };
55
+ }
56
+
57
+ /** Msegat driver factory. */
58
+ export const msegatChannelDriver = {
59
+ id: "msegat" as const,
60
+ open: openMsegatChannel,
61
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Map sently channel send results into OKE {@link ChannelSendResult}.
3
+ */
4
+
5
+ import { toChannelSendResult, type AnySendResult } from "sently/channel-result";
6
+ import type { ChannelSendResult } from "./channel-types.ts";
7
+
8
+ /**
9
+ * Convert a sently email/SMS/WhatsApp/push result into an OKE channel result.
10
+ *
11
+ * @param driverId - OKE driver id fallback when provider is missing
12
+ * @param result - Sently channel-specific send result
13
+ * @param at - Attempt timestamp
14
+ */
15
+ export function mapSentlySendResult(
16
+ driverId: string,
17
+ result: AnySendResult,
18
+ at: number = Date.now(),
19
+ ): ChannelSendResult {
20
+ const normalized = toChannelSendResult(result);
21
+ const id = normalized.provider ?? driverId;
22
+ return {
23
+ ok: normalized.accepted,
24
+ messageId: normalized.messageId || crypto.randomUUID(),
25
+ driverId: id,
26
+ attempts: [
27
+ {
28
+ driverId: id,
29
+ ok: normalized.accepted,
30
+ at,
31
+ ...(normalized.messageId ? { messageId: normalized.messageId } : {}),
32
+ },
33
+ ],
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Build a failed OKE channel result from a thrown error.
39
+ *
40
+ * @param driverId - Driver id
41
+ * @param err - Error
42
+ * @param at - Timestamp
43
+ */
44
+ export function mapSentlySendError(
45
+ driverId: string,
46
+ err: unknown,
47
+ at: number = Date.now(),
48
+ ): ChannelSendResult {
49
+ const error = err instanceof Error ? err.message : String(err);
50
+ const id = crypto.randomUUID();
51
+ return {
52
+ ok: false,
53
+ messageId: id,
54
+ driverId,
55
+ attempts: [{ driverId, ok: false, error, at }],
56
+ };
57
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Sently-backed channel drivers — construction and message mapping.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { openFcmChannel } from "./channel-fcm.ts";
7
+ import { openMsegatChannel } from "./channel-msegat.ts";
8
+ import { openSndrChannel } from "./channel-sndr.ts";
9
+ import { openTaqnyatChannel } from "./channel-taqnyat.ts";
10
+ import { openUnifonicChannel } from "./channel-unifonic.ts";
11
+ import { openWaCloudChannel } from "./channel-wa-cloud.ts";
12
+ import { openWebPushChannel } from "./channel-webpush.ts";
13
+
14
+ describe("sently channel drivers", () => {
15
+ test("sndr requires apiKey", () => {
16
+ expect(() => openSndrChannel({})).toThrow("apiKey");
17
+ const d = openSndrChannel({ apiKey: "sndr_test_x" });
18
+ expect(d.id).toBe("sndr");
19
+ expect(d.transport?.provider).toBe("sndr");
20
+ });
21
+
22
+ test("taqnyat requires bearer + sender", () => {
23
+ expect(() => openTaqnyatChannel({ bearerToken: "t" })).toThrow("sender");
24
+ const d = openTaqnyatChannel({ bearerToken: "t", sender: "Brand" });
25
+ expect(d.id).toBe("taqnyat");
26
+ expect(d.channel?.mediums).toContain("sms");
27
+ });
28
+
29
+ test("msegat requires userName + apiKey + sender", () => {
30
+ expect(() => openMsegatChannel({ userName: "u", apiKey: "k" })).toThrow("sender");
31
+ const d = openMsegatChannel({ userName: "u", apiKey: "k", sender: "Brand" });
32
+ expect(d.id).toBe("msegat");
33
+ expect(d.smsTransport?.provider).toBe("msegat");
34
+ });
35
+
36
+ test("unifonic requires appSid and exposes smsTransport", () => {
37
+ expect(() => openUnifonicChannel({})).toThrow("appSid");
38
+ const d = openUnifonicChannel({ appSid: "sid", sender: "Brand" });
39
+ expect(d.id).toBe("unifonic");
40
+ expect(d.smsTransport?.provider).toBe("unifonic");
41
+ });
42
+
43
+ test("fcm accepts access-token mode", () => {
44
+ const d = openFcmChannel({ from: "my-project", token: "ya29.test" });
45
+ expect(d.id).toBe("fcm");
46
+ expect(d.pushTransport?.provider).toBe("fcm");
47
+ });
48
+
49
+ test("wa-cloud maps text and template sends", async () => {
50
+ const calls: unknown[] = [];
51
+ const originalFetch = globalThis.fetch;
52
+ globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => {
53
+ calls.push(JSON.parse(String(init?.body ?? "{}")));
54
+ return new Response(JSON.stringify({ messages: [{ id: "wamid.1" }] }), { status: 200 });
55
+ }) as typeof fetch;
56
+ try {
57
+ const d = openWaCloudChannel({ token: "tok", from: "123" });
58
+ const text = await d.channel!.send({
59
+ medium: "whatsapp",
60
+ to: "15551234567",
61
+ text: "hi",
62
+ });
63
+ expect(text.ok).toBe(true);
64
+ expect(text.messageId).toBe("wamid.1");
65
+ expect(calls[0]).toMatchObject({
66
+ messaging_product: "whatsapp",
67
+ to: "15551234567",
68
+ type: "text",
69
+ });
70
+
71
+ const tpl = await d.channel!.send({
72
+ medium: "whatsapp",
73
+ to: "15551234567",
74
+ template: "welcome",
75
+ locale: "en_US",
76
+ });
77
+ expect(tpl.ok).toBe(true);
78
+ expect(calls[1]).toMatchObject({
79
+ type: "template",
80
+ template: { name: "welcome", language: { code: "en_US" } },
81
+ });
82
+ } finally {
83
+ globalThis.fetch = originalFetch;
84
+ }
85
+ });
86
+
87
+ test("webpush requires subscription keys", async () => {
88
+ const d = openWebPushChannel({
89
+ vapidPublicKey: "BPtestpublickeythatislongenoughforvapidxxxxxxxxxxxx",
90
+ vapidPrivateKey: "dGVzdC1wcml2YXRlLWtleS0zMmJ5dGVzLW9rISEh",
91
+ vapidSubject: "mailto:ops@example.com",
92
+ });
93
+ expect(d.id).toBe("webpush");
94
+ expect(d.pushTransport?.provider).toBe("webpush");
95
+ await expect(
96
+ d.channel!.send({ medium: "push", to: "https://fcm.googleapis.com/fcm/send/x" }),
97
+ ).rejects.toThrow("pushSubscription");
98
+ });
99
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `sndr` channel driver — wraps sently's SNDR transport unchanged.
3
+ */
4
+
5
+ import { SndrTransport } from "sently/transports/sndr";
6
+ import type { ChannelDriver, ChannelOpenOptions } from "./channel-types.ts";
7
+
8
+ /**
9
+ * Open an SNDR channel driver.
10
+ *
11
+ * @param options - API key
12
+ */
13
+ export function openSndrChannel(options: ChannelOpenOptions = {}): ChannelDriver {
14
+ if (!options.apiKey) {
15
+ throw new Error("sndr channel: apiKey is required");
16
+ }
17
+ const transport = new SndrTransport({
18
+ apiKey: options.apiKey,
19
+ ...(options.url ? { baseUrl: options.url } : {}),
20
+ });
21
+ return { id: "sndr", transport };
22
+ }
23
+
24
+ /** SNDR driver factory. */
25
+ export const sndrChannelDriver = {
26
+ id: "sndr" as const,
27
+ open: openSndrChannel,
28
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `taqnyat` channel driver — SMS via sently's Taqnyat transport.
3
+ */
4
+
5
+ import { TaqnyatSmsTransport } from "sently/transports/taqnyat-sms";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
+ import type {
8
+ ChannelDriver,
9
+ ChannelMessage,
10
+ ChannelOpenOptions,
11
+ ChannelSendResult,
12
+ ChannelTransport,
13
+ } from "./channel-types.ts";
14
+
15
+ /**
16
+ * Open a Taqnyat SMS driver.
17
+ *
18
+ * @param options - `bearerToken`/`token`/`apiKey` + `sender`/`from`
19
+ */
20
+ export function openTaqnyatChannel(options: ChannelOpenOptions = {}): ChannelDriver {
21
+ const bearerToken = options.bearerToken ?? options.token ?? options.apiKey;
22
+ const sender = options.sender ?? options.from;
23
+ if (!bearerToken) {
24
+ throw new Error("taqnyat channel: bearerToken (or token/apiKey) is required");
25
+ }
26
+ if (!sender) {
27
+ throw new Error("taqnyat channel: sender (or from) is required");
28
+ }
29
+
30
+ const transport = new TaqnyatSmsTransport({ bearerToken, sender });
31
+
32
+ const channel: ChannelTransport = {
33
+ provider: "taqnyat",
34
+ mediums: ["sms"],
35
+ async send(message: ChannelMessage): Promise<ChannelSendResult> {
36
+ try {
37
+ const result = await transport.send({
38
+ to: message.to,
39
+ body: message.text ?? String(message.data?.code ?? ""),
40
+ ...(message.from ? { from: message.from } : {}),
41
+ });
42
+ return mapSentlySendResult("taqnyat", result);
43
+ } catch (err) {
44
+ return mapSentlySendError("taqnyat", err);
45
+ }
46
+ },
47
+ verify: () => transport.verify(),
48
+ };
49
+
50
+ return { id: "taqnyat", channel, smsTransport: transport };
51
+ }
52
+
53
+ /** Taqnyat driver factory. */
54
+ export const taqnyatChannelDriver = {
55
+ id: "taqnyat" as const,
56
+ open: openTaqnyatChannel,
57
+ };
@@ -2,7 +2,8 @@
2
2
  * Protocol-named channel driver contracts.
3
3
  *
4
4
  * Email transports implement sently's {@link Transport} so sently transports
5
- * run unchanged. Non-email media (sms / whatsapp / push) use {@link ChannelTransport}.
5
+ * run unchanged. Non-email media expose sently SMS / WhatsApp / Push transports
6
+ * plus a {@link ChannelTransport} adapter for the OKE runtime.
6
7
  */
7
8
 
8
9
  import type { MailOptions, SendResult, Transport, VerifyResult } from "sently";
@@ -19,11 +20,66 @@ export type {
19
20
  RetryConfig,
20
21
  } from "sently";
21
22
 
23
+ /**
24
+ * Sently-compatible SMS transport (structural — sently keeps `SmsTransport` on
25
+ * internal sms-types; OKE drivers wrap concrete sently transports).
26
+ */
27
+ export interface SmsTransport {
28
+ readonly provider?: string;
29
+ send(options: {
30
+ readonly to: string;
31
+ readonly body: string;
32
+ readonly from?: string;
33
+ readonly messageId?: string;
34
+ }): Promise<{
35
+ readonly messageId: string;
36
+ readonly to: string;
37
+ readonly status: string;
38
+ readonly response: string;
39
+ readonly provider?: string;
40
+ readonly providerIndex?: number;
41
+ }>;
42
+ verify?(): Promise<VerifyResult>;
43
+ close?(): Promise<void>;
44
+ }
45
+
46
+ /** Sently-compatible WhatsApp transport (structural). */
47
+ export interface WhatsAppTransport {
48
+ readonly provider?: string;
49
+ send(options: unknown): Promise<{
50
+ readonly messageId: string;
51
+ readonly to: string;
52
+ readonly status: string;
53
+ readonly response: string;
54
+ readonly provider?: string;
55
+ readonly providerIndex?: number;
56
+ }>;
57
+ verify?(): Promise<VerifyResult>;
58
+ close?(): Promise<void>;
59
+ }
60
+
61
+ /** Sently-compatible push transport (structural). */
62
+ export interface PushTransport {
63
+ readonly provider?: string;
64
+ send(options: unknown): Promise<{
65
+ readonly messageId: string;
66
+ readonly status: string;
67
+ readonly response: string;
68
+ readonly provider?: string;
69
+ readonly providerIndex?: number;
70
+ }>;
71
+ verify?(): Promise<VerifyResult>;
72
+ close?(): Promise<void>;
73
+ }
74
+
22
75
  /** Protocol ids for channel drivers. */
23
76
  export type ChannelDriverId =
24
77
  | "console"
25
78
  | "smtp"
26
79
  | "resend"
80
+ | "sndr"
81
+ | "taqnyat"
82
+ | "msegat"
27
83
  | "unifonic"
28
84
  | "wa-cloud"
29
85
  | "fcm"
@@ -82,12 +138,19 @@ export interface ChannelTransport {
82
138
 
83
139
  /**
84
140
  * Dual-shape driver: email drivers expose a sently {@link Transport};
85
- * others expose a {@link ChannelTransport}.
141
+ * SMS / WhatsApp / push expose the matching sently transport plus a
142
+ * {@link ChannelTransport} adapter.
86
143
  */
87
144
  export interface ChannelDriver {
88
145
  readonly id: ChannelDriverId;
89
146
  /** Sently-compatible email transport (smtp / resend / console-email). */
90
147
  readonly transport?: Transport;
148
+ /** Sently SMS transport (used by runtime FallbackTransport chains). */
149
+ readonly smsTransport?: SmsTransport;
150
+ /** Sently WhatsApp transport. */
151
+ readonly whatsappTransport?: WhatsAppTransport;
152
+ /** Sently push transport (webpush / fcm). */
153
+ readonly pushTransport?: PushTransport;
91
154
  /** Medium-agnostic transport. */
92
155
  readonly channel?: ChannelTransport;
93
156
  }
@@ -102,6 +165,20 @@ export interface ChannelOpenOptions {
102
165
  readonly pass?: string;
103
166
  readonly url?: string;
104
167
  readonly token?: string;
168
+ /** Taqnyat bearer (alias of `token` / `apiKey`). */
169
+ readonly bearerToken?: string;
170
+ /** Msegat account username (alias of `user`). */
171
+ readonly userName?: string;
172
+ /** SMS alphanumeric sender id (alias of `from`). */
173
+ readonly sender?: string;
174
+ /** Unifonic AppSid (alias of `apiKey`). */
175
+ readonly appSid?: string;
176
+ /** FCM / GCP project id (alias of `from`). */
177
+ readonly projectId?: string;
178
+ /** FCM service-account client email. */
179
+ readonly clientEmail?: string;
180
+ /** FCM service-account PEM private key. */
181
+ readonly privateKey?: string;
105
182
  /** VAPID keys for webpush. */
106
183
  readonly vapidPublicKey?: string;
107
184
  readonly vapidPrivateKey?: string;
@@ -1,7 +1,9 @@
1
1
  /**
2
- * `unifonic` channel driver — SMS via Unifonic REST API.
2
+ * `unifonic` channel driver — SMS via sently's Unifonic transport.
3
3
  */
4
4
 
5
+ import { UnifonicTransport } from "sently/transports/unifonic";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
5
7
  import type {
6
8
  ChannelDriver,
7
9
  ChannelMessage,
@@ -13,59 +15,40 @@ import type {
13
15
  /**
14
16
  * Open a Unifonic SMS driver.
15
17
  *
16
- * @param options - `apiKey` + optional `from` (sender id)
18
+ * @param options - `apiKey`/`appSid` (AppSid) + optional `sender`/`from` (SenderID)
17
19
  */
18
20
  export function openUnifonicChannel(options: ChannelOpenOptions = {}): ChannelDriver {
19
- const apiKey = options.apiKey;
20
- const from = options.from ?? "OKE";
21
- const fetchFn = options.fetch ?? globalThis.fetch;
22
- const base = options.url ?? "https://el.cloud.unifonic.com";
21
+ const appSid = options.appSid ?? options.apiKey;
22
+ const senderId = options.sender ?? options.from;
23
+ if (!appSid) {
24
+ throw new Error("unifonic channel: appSid (or apiKey) is required");
25
+ }
26
+
27
+ const transport = new UnifonicTransport({
28
+ appSid,
29
+ ...(senderId ? { senderId } : {}),
30
+ ...(options.url?.startsWith("https://") ? { statusCallback: options.url } : {}),
31
+ });
23
32
 
24
33
  const channel: ChannelTransport = {
25
34
  provider: "unifonic",
26
35
  mediums: ["sms"],
27
36
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
28
- if (!apiKey) {
29
- throw new Error("unifonic: apiKey is required");
30
- }
31
- const body = new URLSearchParams({
32
- AppSid: apiKey,
33
- Recipient: message.to,
34
- Body: message.text ?? String(message.data?.code ?? ""),
35
- SenderID: message.from ?? from,
36
- });
37
- const res = await fetchFn(`${base}/rest/SMS/messages`, {
38
- method: "POST",
39
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
40
- body,
41
- });
42
- const text = await res.text();
43
- const id = crypto.randomUUID();
44
- if (!res.ok) {
45
- return {
46
- ok: false,
47
- messageId: id,
48
- driverId: "unifonic",
49
- attempts: [
50
- {
51
- driverId: "unifonic",
52
- ok: false,
53
- error: text || `HTTP ${res.status}`,
54
- at: Date.now(),
55
- },
56
- ],
57
- };
37
+ try {
38
+ const result = await transport.send({
39
+ to: message.to,
40
+ body: message.text ?? String(message.data?.code ?? ""),
41
+ ...(message.from ? { from: message.from } : {}),
42
+ });
43
+ return mapSentlySendResult("unifonic", result);
44
+ } catch (err) {
45
+ return mapSentlySendError("unifonic", err);
58
46
  }
59
- return {
60
- ok: true,
61
- messageId: id,
62
- driverId: "unifonic",
63
- attempts: [{ driverId: "unifonic", ok: true, at: Date.now(), messageId: id }],
64
- };
65
47
  },
48
+ verify: () => transport.verify(),
66
49
  };
67
50
 
68
- return { id: "unifonic", channel };
51
+ return { id: "unifonic", channel, smsTransport: transport };
69
52
  }
70
53
 
71
54
  /** Unifonic driver factory. */