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.
- package/README.md +148 -13
- package/package.json +3 -3
- package/site/content/docs/elements/channel.mdx +71 -7
- package/site/content/docs/reference/configuration.mdx +5 -4
- package/site/content/docs/reference/environment-variables.mdx +32 -8
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
- package/src/drivers/ai-ollama.integration.test.ts +3 -19
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +4 -0
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/index.ts +9 -0
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +76 -13
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `fcm` channel driver — Firebase Cloud Messaging HTTP v1
|
|
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
|
-
*
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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` (
|
|
18
|
+
* @param options - `apiKey`/`appSid` (AppSid) + optional `sender`/`from` (SenderID)
|
|
17
19
|
*/
|
|
18
20
|
export function openUnifonicChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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. */
|