@mpgd/game-services 0.24.1 → 0.25.0
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/dist/notifications/ait-functional-delivery.d.ts +165 -0
- package/dist/notifications/ait-functional-delivery.js +445 -0
- package/dist/notifications/delivery-provider.d.ts +121 -0
- package/dist/notifications/delivery-provider.js +23 -0
- package/dist/notifications/fcm.d.ts +138 -0
- package/dist/notifications/fcm.js +602 -0
- package/dist/notifications/index.d.ts +12 -0
- package/dist/notifications/index.js +12 -0
- package/dist/notifications/registry.d.ts +86 -0
- package/dist/notifications/registry.js +131 -0
- package/dist/notifications/service.d.ts +222 -0
- package/dist/notifications/service.js +2609 -0
- package/dist/notifications/store-conformance.d.ts +11 -0
- package/dist/notifications/store-conformance.js +795 -0
- package/dist/notifications/store.d.ts +423 -0
- package/dist/notifications/store.js +443 -0
- package/dist/notifications/text.d.ts +2 -0
- package/dist/notifications/text.js +4 -0
- package/package.json +17 -5
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { type NotificationDeliveryProviderEntry, type NotificationTemplateData, type NotificationTopicDeclaration } from '@mpgd/platform/notifications';
|
|
2
|
+
import type { AppsInTossMutualTlsFetcher } from '../apps-in-toss-partner-api.js';
|
|
3
|
+
/**
|
|
4
|
+
* Apps in Toss functional-message delivery provider (notifications RFC
|
|
5
|
+
* "Delivery providers -> Apps in Toss"). It sends one `send-message` call per
|
|
6
|
+
* recipient over the partner API's mTLS client certificate and answers one of
|
|
7
|
+
* the RFC's provider answers ("Provider answers"). The backend's policy gate
|
|
8
|
+
* decides whether to call it and maps each answer to a delivery outcome; this
|
|
9
|
+
* module keeps no ledger, makes no gate decision, and uses only `fetch`
|
|
10
|
+
* through the injected mTLS fetcher, so it runs on Cloudflare Workers. Its
|
|
11
|
+
* `{ id, send }` shape is built from `@mpgd/platform/notifications` types and
|
|
12
|
+
* literal unions only, so the game-services delivery registry takes it
|
|
13
|
+
* structurally.
|
|
14
|
+
*
|
|
15
|
+
* Partner API reference, checked 2026-10-19:
|
|
16
|
+
* https://developers-apps-in-toss.toss.im/api/push.md ("메시지 발송하기") and
|
|
17
|
+
* https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
|
|
18
|
+
* - `POST /api-partner/v1/apps-in-toss/messenger/send-message` with
|
|
19
|
+
* `x-toss-user-key` or `x-anon-key`, and `{ templateSetCode, context }`.
|
|
20
|
+
* - Business errors come as HTTP 200 with `resultType: 'FAIL'` and
|
|
21
|
+
* `error.errorCode`: 5004 (template not approved), 4034 (no workspace or no
|
|
22
|
+
* access to it), 4010 (credentials not found), 4095 (rate limit, with
|
|
23
|
+
* `error.data.retryAfterSeconds`). Other `resultType` values are
|
|
24
|
+
* `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
|
|
25
|
+
* `INTERNAL_ERROR`. The API may pass through undocumented codes of internal
|
|
26
|
+
* systems; they are failures.
|
|
27
|
+
* - A `SUCCESS` answer counts the messages sent per channel (push, inbox, SMS,
|
|
28
|
+
* Alimtalk, Friendtalk) and lists each channel's sent and failed messages,
|
|
29
|
+
* a failed one with a free-text `reachedFailReason`.
|
|
30
|
+
* - The documentation names no recipient-level code, including none for a
|
|
31
|
+
* recipient who has not agreed to the template. `agreementErrorCodes` and
|
|
32
|
+
* `agreementFailReasons` let a deployment name the ones it observes, so
|
|
33
|
+
* such an answer is `rejected` with reason `agreement` and records the
|
|
34
|
+
* denial; without them it is reason `recipient`.
|
|
35
|
+
* - Limits: 15,000 calls per minute per app and 10 per minute per user, the
|
|
36
|
+
* reference registry entry's `rate` and `limits`, which the scheduler and
|
|
37
|
+
* the gate apply.
|
|
38
|
+
*/
|
|
39
|
+
export declare const aitFunctionalMessageProviderId: "apps-in-toss";
|
|
40
|
+
export declare const aitFunctionalMessagePath = "api-partner/v1/apps-in-toss/messenger/send-message";
|
|
41
|
+
export declare const defaultAitFunctionalMessageBaseUrl = "https://apps-in-toss-api.toss.im";
|
|
42
|
+
export declare const defaultAitFunctionalMessageTimeoutMs = 10000;
|
|
43
|
+
/** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
|
|
44
|
+
export declare const maximumAitFunctionalMessageRetryAfterSeconds = 86400;
|
|
45
|
+
/** The channels a `send-message` answer reports. */
|
|
46
|
+
export type AitFunctionalMessageChannel = 'push' | 'inbox' | 'sms' | 'alimtalk' | 'friendtalk';
|
|
47
|
+
export interface AitFunctionalMessageChannelResult {
|
|
48
|
+
readonly sent: readonly {
|
|
49
|
+
readonly contentId: string;
|
|
50
|
+
}[];
|
|
51
|
+
readonly failed: readonly {
|
|
52
|
+
readonly contentId: string;
|
|
53
|
+
readonly reachedFailReason?: string;
|
|
54
|
+
}[];
|
|
55
|
+
}
|
|
56
|
+
export type AitFunctionalMessageChannelResults = Readonly<Record<AitFunctionalMessageChannel, AitFunctionalMessageChannelResult>>;
|
|
57
|
+
/** Why a provider rejected a send (RFC "Provider answers"). */
|
|
58
|
+
export type AitFunctionalMessageRejection = 'configuration' | 'recipient' | 'agreement';
|
|
59
|
+
/**
|
|
60
|
+
* One of the RFC's provider answers. `unregistered` never occurs: recipients
|
|
61
|
+
* are platform users, not device addresses. `providerCode` keeps the partner
|
|
62
|
+
* API's `errorCode` or `resultType` (or `TIMEOUT`, `ABORTED`,
|
|
63
|
+
* `TRANSPORT_ERROR`) for logs, and `channels` the per-channel lists of a
|
|
64
|
+
* `SUCCESS` answer.
|
|
65
|
+
*/
|
|
66
|
+
export type AitFunctionalMessageAnswer = {
|
|
67
|
+
readonly status: 'accepted';
|
|
68
|
+
/** The first sent message's `contentId`. */
|
|
69
|
+
readonly providerMessageId?: string;
|
|
70
|
+
readonly messageCount: number;
|
|
71
|
+
readonly channels: AitFunctionalMessageChannelResults;
|
|
72
|
+
} | {
|
|
73
|
+
readonly status: 'rate-limited';
|
|
74
|
+
readonly retryAfterSeconds?: number;
|
|
75
|
+
readonly providerCode?: string;
|
|
76
|
+
} | {
|
|
77
|
+
readonly status: 'rejected';
|
|
78
|
+
readonly reason: AitFunctionalMessageRejection;
|
|
79
|
+
readonly providerCode?: string;
|
|
80
|
+
readonly channels?: AitFunctionalMessageChannelResults;
|
|
81
|
+
} | {
|
|
82
|
+
readonly status: 'ambiguous';
|
|
83
|
+
readonly providerCode?: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Who one delivery goes to. Apps in Toss recipients are platform users whose
|
|
87
|
+
* id the backend resolved from the player's authenticated identity, never
|
|
88
|
+
* from a key the client sent; a device recipient is refused.
|
|
89
|
+
*/
|
|
90
|
+
export type AitFunctionalMessageRecipient = {
|
|
91
|
+
readonly kind: 'platform-user';
|
|
92
|
+
readonly id: string;
|
|
93
|
+
} | {
|
|
94
|
+
readonly kind: 'device';
|
|
95
|
+
readonly installationId: string;
|
|
96
|
+
readonly address: unknown;
|
|
97
|
+
};
|
|
98
|
+
export interface AitFunctionalMessageDeliveryRequest {
|
|
99
|
+
readonly recipient: AitFunctionalMessageRecipient;
|
|
100
|
+
/** The topic as declared for this provider: its mapping carries `templateSetCode`. */
|
|
101
|
+
readonly topic: Pick<NotificationTopicDeclaration, 'id' | 'mapping'>;
|
|
102
|
+
/** The template's `context` values. */
|
|
103
|
+
readonly templateData?: NotificationTemplateData;
|
|
104
|
+
readonly signal?: AbortSignal;
|
|
105
|
+
}
|
|
106
|
+
export interface CreateAitFunctionalMessageDeliveryProviderOptions {
|
|
107
|
+
/** The partner API mTLS client, such as a Workers mTLS certificate binding (`env.BINDING`). */
|
|
108
|
+
readonly mtls: AppsInTossMutualTlsFetcher;
|
|
109
|
+
/**
|
|
110
|
+
* Which partner key a recipient id is: a Toss login `userKey`
|
|
111
|
+
* (`x-toss-user-key`) or a `User.getAnonymousKey` hash (`x-anon-key`).
|
|
112
|
+
*/
|
|
113
|
+
readonly recipientKey: 'toss-user' | 'anonymous';
|
|
114
|
+
readonly baseUrl?: string;
|
|
115
|
+
/** 1 to 60,000 ms, as the partner API client accepts. */
|
|
116
|
+
readonly timeoutMs?: number;
|
|
117
|
+
/** `FAIL` error codes that mean the recipient has not agreed to the template. */
|
|
118
|
+
readonly agreementErrorCodes?: readonly string[];
|
|
119
|
+
/** `reachedFailReason` texts (trimmed) that mean the recipient has not agreed to the template. */
|
|
120
|
+
readonly agreementFailReasons?: readonly string[];
|
|
121
|
+
/** Further `FAIL` error codes that mean this recipient cannot receive the message. */
|
|
122
|
+
readonly recipientErrorCodes?: readonly string[];
|
|
123
|
+
}
|
|
124
|
+
export interface AitFunctionalMessageDeliveryProvider {
|
|
125
|
+
readonly id: typeof aitFunctionalMessageProviderId;
|
|
126
|
+
/** The registry entry the policy gate reads for this provider. */
|
|
127
|
+
readonly entry: NotificationDeliveryProviderEntry;
|
|
128
|
+
send(request: AitFunctionalMessageDeliveryRequest): Promise<AitFunctionalMessageAnswer>;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Creates the provider. `send` never throws for a partner API answer: every
|
|
132
|
+
* HTTP answer, transport failure, and timeout maps to a provider answer.
|
|
133
|
+
*
|
|
134
|
+
* - `SUCCESS` with at least one message sent on any channel is `accepted`.
|
|
135
|
+
* With none sent, the per-channel `fail` lists decide: `rejected` with
|
|
136
|
+
* reason `agreement` when a failure's `reachedFailReason` is one of
|
|
137
|
+
* `agreementFailReasons`, otherwise `recipient`.
|
|
138
|
+
* - `FAIL` with a code in `agreementErrorCodes` (first) or
|
|
139
|
+
* `recipientErrorCodes` is `rejected` with that reason, before any
|
|
140
|
+
* documented meaning of the code, on any status except HTTP 429, 5xx,
|
|
141
|
+
* 408, and 425, which the status alone decides (see below);
|
|
142
|
+
* otherwise 4095 is `rate-limited` with `retryAfterSeconds` (at most a
|
|
143
|
+
* day), and 5004, 4034, and 4010 are `rejected` with reason
|
|
144
|
+
* `configuration`. Any other code is `rejected` with reason `recipient` on HTTP 200
|
|
145
|
+
* and `configuration` when the HTTP status refused the request (400, 401,
|
|
146
|
+
* 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
|
|
147
|
+
* request, and answering `ambiguous` would replay a send that fails the
|
|
148
|
+
* same way after every lease: the RFC sets no limit on how often an
|
|
149
|
+
* `ambiguous` send is replayed (only the lease paces the replays). On
|
|
150
|
+
* any other status it is `ambiguous`, and so is a `FAIL` without a code.
|
|
151
|
+
* A `rate-limited` delay comes from `error.data.retryAfterSeconds`, else
|
|
152
|
+
* the `Retry-After` header.
|
|
153
|
+
* - `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
|
|
154
|
+
* `INTERNAL_ERROR`, any HTTP 5xx, 408, or 425, a transport failure, a
|
|
155
|
+
* timeout, and an answer that cannot be read are `ambiguous`: the claim
|
|
156
|
+
* stays and the scheduler replays after the lease.
|
|
157
|
+
* - HTTP 429 is `rate-limited` whatever its body (`retryAfterSeconds`, else
|
|
158
|
+
* `Retry-After` in seconds, when present); HTTP 400, 401, 403, 404, 405,
|
|
159
|
+
* 413, 415, and 422 without a business error are `rejected` with reason
|
|
160
|
+
* `configuration`.
|
|
161
|
+
* - A request the API would refuse (a device recipient, a mapping without
|
|
162
|
+
* `templateSetCode`, template data outside the RFC bounds, a recipient id
|
|
163
|
+
* that is not a visible-ASCII header value) is `rejected` without a call.
|
|
164
|
+
*/
|
|
165
|
+
export declare function createAitFunctionalMessageDeliveryProvider(options: CreateAitFunctionalMessageDeliveryProviderOptions): AitFunctionalMessageDeliveryProvider;
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { notificationTemplateDataIssue, referenceNotificationProviders, } from '@mpgd/platform/notifications';
|
|
2
|
+
/**
|
|
3
|
+
* Apps in Toss functional-message delivery provider (notifications RFC
|
|
4
|
+
* "Delivery providers -> Apps in Toss"). It sends one `send-message` call per
|
|
5
|
+
* recipient over the partner API's mTLS client certificate and answers one of
|
|
6
|
+
* the RFC's provider answers ("Provider answers"). The backend's policy gate
|
|
7
|
+
* decides whether to call it and maps each answer to a delivery outcome; this
|
|
8
|
+
* module keeps no ledger, makes no gate decision, and uses only `fetch`
|
|
9
|
+
* through the injected mTLS fetcher, so it runs on Cloudflare Workers. Its
|
|
10
|
+
* `{ id, send }` shape is built from `@mpgd/platform/notifications` types and
|
|
11
|
+
* literal unions only, so the game-services delivery registry takes it
|
|
12
|
+
* structurally.
|
|
13
|
+
*
|
|
14
|
+
* Partner API reference, checked 2026-10-19:
|
|
15
|
+
* https://developers-apps-in-toss.toss.im/api/push.md ("메시지 발송하기") and
|
|
16
|
+
* https://developers-apps-in-toss.toss.im/documentation/common/growth/smart-message.md
|
|
17
|
+
* - `POST /api-partner/v1/apps-in-toss/messenger/send-message` with
|
|
18
|
+
* `x-toss-user-key` or `x-anon-key`, and `{ templateSetCode, context }`.
|
|
19
|
+
* - Business errors come as HTTP 200 with `resultType: 'FAIL'` and
|
|
20
|
+
* `error.errorCode`: 5004 (template not approved), 4034 (no workspace or no
|
|
21
|
+
* access to it), 4010 (credentials not found), 4095 (rate limit, with
|
|
22
|
+
* `error.data.retryAfterSeconds`). Other `resultType` values are
|
|
23
|
+
* `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
|
|
24
|
+
* `INTERNAL_ERROR`. The API may pass through undocumented codes of internal
|
|
25
|
+
* systems; they are failures.
|
|
26
|
+
* - A `SUCCESS` answer counts the messages sent per channel (push, inbox, SMS,
|
|
27
|
+
* Alimtalk, Friendtalk) and lists each channel's sent and failed messages,
|
|
28
|
+
* a failed one with a free-text `reachedFailReason`.
|
|
29
|
+
* - The documentation names no recipient-level code, including none for a
|
|
30
|
+
* recipient who has not agreed to the template. `agreementErrorCodes` and
|
|
31
|
+
* `agreementFailReasons` let a deployment name the ones it observes, so
|
|
32
|
+
* such an answer is `rejected` with reason `agreement` and records the
|
|
33
|
+
* denial; without them it is reason `recipient`.
|
|
34
|
+
* - Limits: 15,000 calls per minute per app and 10 per minute per user, the
|
|
35
|
+
* reference registry entry's `rate` and `limits`, which the scheduler and
|
|
36
|
+
* the gate apply.
|
|
37
|
+
*/
|
|
38
|
+
export const aitFunctionalMessageProviderId = 'apps-in-toss';
|
|
39
|
+
export const aitFunctionalMessagePath = 'api-partner/v1/apps-in-toss/messenger/send-message';
|
|
40
|
+
// The same values as `appsInTossPartnerApiBaseUrl` and
|
|
41
|
+
// `defaultAppsInTossPartnerApiTimeoutMs` (a test keeps them equal), kept here
|
|
42
|
+
// so this module loads none of the partner client's code.
|
|
43
|
+
export const defaultAitFunctionalMessageBaseUrl = 'https://apps-in-toss-api.toss.im';
|
|
44
|
+
export const defaultAitFunctionalMessageTimeoutMs = 10_000;
|
|
45
|
+
/** The longest `Retry-After` the provider passes on; the scheduler backs off from it. */
|
|
46
|
+
export const maximumAitFunctionalMessageRetryAfterSeconds = 86_400;
|
|
47
|
+
/** Codes the documentation names for the project's setup or credentials. */
|
|
48
|
+
const configurationErrorCodes = new Set(['5004', '4034', '4010']);
|
|
49
|
+
const rateLimitedErrorCode = '4095';
|
|
50
|
+
/** `resultType` values that cannot tell whether the message went out. */
|
|
51
|
+
const ambiguousResultTypes = new Set([
|
|
52
|
+
'HTTP_TIMEOUT',
|
|
53
|
+
'NETWORK_ERROR',
|
|
54
|
+
'EXECUTION_FAIL',
|
|
55
|
+
'INTERRUPTED',
|
|
56
|
+
'INTERNAL_ERROR',
|
|
57
|
+
]);
|
|
58
|
+
/** HTTP statuses that refuse the request itself, before anything is sent. */
|
|
59
|
+
const refusedRequestStatuses = new Set([
|
|
60
|
+
400, 401, 403, 404, 405, 413, 415, 422,
|
|
61
|
+
]);
|
|
62
|
+
const channelFields = {
|
|
63
|
+
push: 'sentPush',
|
|
64
|
+
inbox: 'sentInbox',
|
|
65
|
+
sms: 'sentSms',
|
|
66
|
+
alimtalk: 'sentAlimtalk',
|
|
67
|
+
friendtalk: 'sentFriendtalk',
|
|
68
|
+
};
|
|
69
|
+
const maximumResponseBodyBytes = 256 * 1_024;
|
|
70
|
+
const maximumIdentifierLength = 2_048;
|
|
71
|
+
const maximumTimeoutMs = 60_000;
|
|
72
|
+
/**
|
|
73
|
+
* Creates the provider. `send` never throws for a partner API answer: every
|
|
74
|
+
* HTTP answer, transport failure, and timeout maps to a provider answer.
|
|
75
|
+
*
|
|
76
|
+
* - `SUCCESS` with at least one message sent on any channel is `accepted`.
|
|
77
|
+
* With none sent, the per-channel `fail` lists decide: `rejected` with
|
|
78
|
+
* reason `agreement` when a failure's `reachedFailReason` is one of
|
|
79
|
+
* `agreementFailReasons`, otherwise `recipient`.
|
|
80
|
+
* - `FAIL` with a code in `agreementErrorCodes` (first) or
|
|
81
|
+
* `recipientErrorCodes` is `rejected` with that reason, before any
|
|
82
|
+
* documented meaning of the code, on any status except HTTP 429, 5xx,
|
|
83
|
+
* 408, and 425, which the status alone decides (see below);
|
|
84
|
+
* otherwise 4095 is `rate-limited` with `retryAfterSeconds` (at most a
|
|
85
|
+
* day), and 5004, 4034, and 4010 are `rejected` with reason
|
|
86
|
+
* `configuration`. Any other code is `rejected` with reason `recipient` on HTTP 200
|
|
87
|
+
* and `configuration` when the HTTP status refused the request (400, 401,
|
|
88
|
+
* 403, 404, 405, 413, 415, 422): a `FAIL` envelope reports a failed
|
|
89
|
+
* request, and answering `ambiguous` would replay a send that fails the
|
|
90
|
+
* same way after every lease: the RFC sets no limit on how often an
|
|
91
|
+
* `ambiguous` send is replayed (only the lease paces the replays). On
|
|
92
|
+
* any other status it is `ambiguous`, and so is a `FAIL` without a code.
|
|
93
|
+
* A `rate-limited` delay comes from `error.data.retryAfterSeconds`, else
|
|
94
|
+
* the `Retry-After` header.
|
|
95
|
+
* - `HTTP_TIMEOUT`, `NETWORK_ERROR`, `EXECUTION_FAIL`, `INTERRUPTED`, and
|
|
96
|
+
* `INTERNAL_ERROR`, any HTTP 5xx, 408, or 425, a transport failure, a
|
|
97
|
+
* timeout, and an answer that cannot be read are `ambiguous`: the claim
|
|
98
|
+
* stays and the scheduler replays after the lease.
|
|
99
|
+
* - HTTP 429 is `rate-limited` whatever its body (`retryAfterSeconds`, else
|
|
100
|
+
* `Retry-After` in seconds, when present); HTTP 400, 401, 403, 404, 405,
|
|
101
|
+
* 413, 415, and 422 without a business error are `rejected` with reason
|
|
102
|
+
* `configuration`.
|
|
103
|
+
* - A request the API would refuse (a device recipient, a mapping without
|
|
104
|
+
* `templateSetCode`, template data outside the RFC bounds, a recipient id
|
|
105
|
+
* that is not a visible-ASCII header value) is `rejected` without a call.
|
|
106
|
+
*/
|
|
107
|
+
export function createAitFunctionalMessageDeliveryProvider(options) {
|
|
108
|
+
if (options.recipientKey !== 'toss-user' && options.recipientKey !== 'anonymous') {
|
|
109
|
+
throw new TypeError("recipientKey must be 'toss-user' or 'anonymous'.");
|
|
110
|
+
}
|
|
111
|
+
const recipientHeader = options.recipientKey === 'anonymous' ? 'x-anon-key' : 'x-toss-user-key';
|
|
112
|
+
const url = new URL(aitFunctionalMessagePath, normalizeBaseUrl(options.baseUrl ?? defaultAitFunctionalMessageBaseUrl)).href;
|
|
113
|
+
const timeoutMs = normalizeTimeout(options.timeoutMs);
|
|
114
|
+
const agreementErrorCodes = new Set(options.agreementErrorCodes ?? []);
|
|
115
|
+
const recipientErrorCodes = new Set(options.recipientErrorCodes ?? []);
|
|
116
|
+
const agreementFailReasons = new Set((options.agreementFailReasons ?? []).map((reason) => reason.trim()));
|
|
117
|
+
// The deployment's codes come first, then the documented ones (RFC
|
|
118
|
+
// "Delivery providers -> Apps in Toss").
|
|
119
|
+
const classifyFailCode = (code, status) => {
|
|
120
|
+
if (agreementErrorCodes.has(code)) {
|
|
121
|
+
return { status: 'rejected', reason: 'agreement', providerCode: code };
|
|
122
|
+
}
|
|
123
|
+
if (recipientErrorCodes.has(code)) {
|
|
124
|
+
return { status: 'rejected', reason: 'recipient', providerCode: code };
|
|
125
|
+
}
|
|
126
|
+
if (code === rateLimitedErrorCode) {
|
|
127
|
+
return { status: 'rate-limited', providerCode: code };
|
|
128
|
+
}
|
|
129
|
+
if (configurationErrorCodes.has(code) || refusedRequestStatuses.has(status)) {
|
|
130
|
+
return { status: 'rejected', reason: 'configuration', providerCode: code };
|
|
131
|
+
}
|
|
132
|
+
// An undocumented code on another status proves nothing either way.
|
|
133
|
+
return status === 200
|
|
134
|
+
? { status: 'rejected', reason: 'recipient', providerCode: code }
|
|
135
|
+
: { status: 'ambiguous', providerCode: code };
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
id: aitFunctionalMessageProviderId,
|
|
139
|
+
entry: referenceNotificationProviders['apps-in-toss'],
|
|
140
|
+
async send(request) {
|
|
141
|
+
const templateSetCode = request.topic.mapping?.['templateSetCode'];
|
|
142
|
+
if (typeof templateSetCode !== 'string' || !isIdentifier(templateSetCode)) {
|
|
143
|
+
return { status: 'rejected', reason: 'configuration' };
|
|
144
|
+
}
|
|
145
|
+
const context = request.templateData ?? {};
|
|
146
|
+
if (notificationTemplateDataIssue(context) !== undefined) {
|
|
147
|
+
return { status: 'rejected', reason: 'configuration' };
|
|
148
|
+
}
|
|
149
|
+
const { recipient } = request;
|
|
150
|
+
if (recipient.kind !== 'platform-user') {
|
|
151
|
+
return { status: 'rejected', reason: 'configuration' };
|
|
152
|
+
}
|
|
153
|
+
if (!isHeaderValue(recipient.id)) {
|
|
154
|
+
return { status: 'rejected', reason: 'recipient' };
|
|
155
|
+
}
|
|
156
|
+
const response = await post({
|
|
157
|
+
mtls: options.mtls,
|
|
158
|
+
url,
|
|
159
|
+
headers: { [recipientHeader]: recipient.id },
|
|
160
|
+
body: { templateSetCode, context },
|
|
161
|
+
timeoutMs,
|
|
162
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
163
|
+
});
|
|
164
|
+
if (response.kind === 'failed') {
|
|
165
|
+
return { status: 'ambiguous', providerCode: response.reason };
|
|
166
|
+
}
|
|
167
|
+
const { status, body } = response;
|
|
168
|
+
if (status >= 500 || status === 408 || status === 425) {
|
|
169
|
+
return { status: 'ambiguous', ...providerCodeOf(body) };
|
|
170
|
+
}
|
|
171
|
+
if (status === 429) {
|
|
172
|
+
// Throttled at the HTTP layer: nothing was sent, whatever the body says.
|
|
173
|
+
const error = isRecord(body) && isRecord(body['error']) ? body['error'] : undefined;
|
|
174
|
+
const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
|
|
175
|
+
return {
|
|
176
|
+
status: 'rate-limited',
|
|
177
|
+
...providerCodeOf(body),
|
|
178
|
+
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const resultType = isRecord(body) ? body['resultType'] : undefined;
|
|
182
|
+
if (resultType === 'SUCCESS') {
|
|
183
|
+
return status >= 200 && status < 300
|
|
184
|
+
? readSuccess(isRecord(body) ? body['success'] : undefined, agreementFailReasons)
|
|
185
|
+
: { status: 'ambiguous', providerCode: 'SUCCESS' };
|
|
186
|
+
}
|
|
187
|
+
if (resultType === 'FAIL' && isRecord(body)) {
|
|
188
|
+
const error = isRecord(body['error']) ? body['error'] : undefined;
|
|
189
|
+
const code = errorCodeOf(error?.['errorCode']);
|
|
190
|
+
if (code === undefined) {
|
|
191
|
+
return { status: 'ambiguous', providerCode: 'FAIL' };
|
|
192
|
+
}
|
|
193
|
+
const answer = classifyFailCode(code, status);
|
|
194
|
+
if (answer.status === 'rate-limited') {
|
|
195
|
+
const retryAfterSeconds = retryAfterOf(error, response.retryAfter);
|
|
196
|
+
return retryAfterSeconds === undefined ? answer : { ...answer, retryAfterSeconds };
|
|
197
|
+
}
|
|
198
|
+
return answer;
|
|
199
|
+
}
|
|
200
|
+
if (typeof resultType === 'string' && ambiguousResultTypes.has(resultType)) {
|
|
201
|
+
return { status: 'ambiguous', providerCode: resultType };
|
|
202
|
+
}
|
|
203
|
+
if (refusedRequestStatuses.has(status)) {
|
|
204
|
+
return { status: 'rejected', reason: 'configuration', ...providerCodeOf(body) };
|
|
205
|
+
}
|
|
206
|
+
// Any other answer without a readable result proves nothing either way.
|
|
207
|
+
return { status: 'ambiguous', ...providerCodeOf(body) };
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function readSuccess(success, agreementFailReasons) {
|
|
212
|
+
if (!isRecord(success)) {
|
|
213
|
+
return { status: 'ambiguous', providerCode: 'SUCCESS' };
|
|
214
|
+
}
|
|
215
|
+
const messageCount = success['msgCount'];
|
|
216
|
+
const detail = success['detail'];
|
|
217
|
+
const fail = success['fail'];
|
|
218
|
+
if (!isCount(messageCount)) {
|
|
219
|
+
return { status: 'ambiguous', providerCode: 'SUCCESS' };
|
|
220
|
+
}
|
|
221
|
+
const channelOf = (field) => ({
|
|
222
|
+
sent: readContents(isRecord(detail) ? detail[field] : undefined),
|
|
223
|
+
failed: readContents(isRecord(fail) ? fail[field] : undefined),
|
|
224
|
+
});
|
|
225
|
+
const channels = {
|
|
226
|
+
push: channelOf(channelFields.push),
|
|
227
|
+
inbox: channelOf(channelFields.inbox),
|
|
228
|
+
sms: channelOf(channelFields.sms),
|
|
229
|
+
alimtalk: channelOf(channelFields.alimtalk),
|
|
230
|
+
friendtalk: channelOf(channelFields.friendtalk),
|
|
231
|
+
};
|
|
232
|
+
const results = Object.values(channels);
|
|
233
|
+
const firstSent = results.find((result) => result.sent.length > 0)?.sent[0];
|
|
234
|
+
if (messageCount > 0 || firstSent !== undefined) {
|
|
235
|
+
return {
|
|
236
|
+
status: 'accepted',
|
|
237
|
+
...(firstSent === undefined ? {} : { providerMessageId: firstSent.contentId }),
|
|
238
|
+
messageCount,
|
|
239
|
+
channels,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
// Nothing reached any channel. One recipient per call, so the per-channel
|
|
243
|
+
// failures are this recipient's; an agreement failure on any channel
|
|
244
|
+
// decides, because the template itself was not agreed to.
|
|
245
|
+
const agreement = results.some((result) => result.failed.some((failure) => failure.reachedFailReason !== undefined
|
|
246
|
+
&& agreementFailReasons.has(failure.reachedFailReason.trim())));
|
|
247
|
+
return { status: 'rejected', reason: agreement ? 'agreement' : 'recipient', channels };
|
|
248
|
+
}
|
|
249
|
+
function readContents(input) {
|
|
250
|
+
if (!Array.isArray(input)) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const contents = [];
|
|
254
|
+
for (const item of input.slice(0, 64)) {
|
|
255
|
+
if (!isRecord(item) || typeof item['contentId'] !== 'string') {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const reason = item['reachedFailReason'];
|
|
259
|
+
contents.push({
|
|
260
|
+
contentId: item['contentId'].slice(0, 256),
|
|
261
|
+
...(typeof reason === 'string' ? { reachedFailReason: reason.slice(0, 1_024) } : {}),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return contents;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* One POST over the mTLS fetcher, bounded in time (the body read included)
|
|
268
|
+
* and size. A body that is too large or not UTF-8 reads as no body, so the
|
|
269
|
+
* status decides. The request's connection is released when the call ends.
|
|
270
|
+
*/
|
|
271
|
+
async function post(input) {
|
|
272
|
+
// A function, so the check reads the signal at each call rather than once.
|
|
273
|
+
const callerAborted = () => input.signal?.aborted === true;
|
|
274
|
+
if (callerAborted()) {
|
|
275
|
+
// An aborted caller sends nothing; the answer is still the ambiguous one.
|
|
276
|
+
return { kind: 'failed', reason: 'ABORTED' };
|
|
277
|
+
}
|
|
278
|
+
const controller = new AbortController();
|
|
279
|
+
let timedOut = false;
|
|
280
|
+
const abort = () => controller.abort();
|
|
281
|
+
input.signal?.addEventListener('abort', abort, { once: true });
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
timedOut = true;
|
|
284
|
+
controller.abort();
|
|
285
|
+
}, input.timeoutMs);
|
|
286
|
+
try {
|
|
287
|
+
const response = await input.mtls.fetch(input.url, {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: {
|
|
290
|
+
accept: 'application/json',
|
|
291
|
+
'content-type': 'application/json',
|
|
292
|
+
...input.headers,
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify(input.body),
|
|
295
|
+
redirect: 'manual',
|
|
296
|
+
signal: controller.signal,
|
|
297
|
+
});
|
|
298
|
+
const text = await readBoundedText(response);
|
|
299
|
+
let body;
|
|
300
|
+
if (text !== undefined && text.length > 0) {
|
|
301
|
+
try {
|
|
302
|
+
body = JSON.parse(text);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
body = undefined;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
kind: 'answered',
|
|
310
|
+
status: response.status,
|
|
311
|
+
body,
|
|
312
|
+
retryAfter: response.headers.get('retry-after') ?? undefined,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
if (timedOut) {
|
|
317
|
+
return { kind: 'failed', reason: 'TIMEOUT' };
|
|
318
|
+
}
|
|
319
|
+
return { kind: 'failed', reason: callerAborted() ? 'ABORTED' : 'TRANSPORT_ERROR' };
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
input.signal?.removeEventListener('abort', abort);
|
|
324
|
+
// Frees a connection whose body was not read to the end.
|
|
325
|
+
controller.abort();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** The body as UTF-8 text, or `undefined` when it is larger than 256 KiB or not UTF-8. */
|
|
329
|
+
async function readBoundedText(response) {
|
|
330
|
+
const declaredLength = response.headers.get('content-length');
|
|
331
|
+
if (declaredLength !== null
|
|
332
|
+
&& /^\d+$/u.test(declaredLength)
|
|
333
|
+
&& Number(declaredLength) > maximumResponseBodyBytes) {
|
|
334
|
+
await response.body?.cancel().catch(() => { });
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
if (response.body === null) {
|
|
338
|
+
return '';
|
|
339
|
+
}
|
|
340
|
+
const reader = response.body.getReader();
|
|
341
|
+
const chunks = [];
|
|
342
|
+
let length = 0;
|
|
343
|
+
try {
|
|
344
|
+
while (true) {
|
|
345
|
+
const chunk = await reader.read();
|
|
346
|
+
if (chunk.done) {
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
length += chunk.value.byteLength;
|
|
350
|
+
if (length > maximumResponseBodyBytes) {
|
|
351
|
+
await reader.cancel().catch(() => { });
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
chunks.push(chunk.value);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
reader.releaseLock();
|
|
359
|
+
}
|
|
360
|
+
const bytes = new Uint8Array(length);
|
|
361
|
+
let offset = 0;
|
|
362
|
+
for (const chunk of chunks) {
|
|
363
|
+
bytes.set(chunk, offset);
|
|
364
|
+
offset += chunk.byteLength;
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** A string code, or a finite number read as its digits; anything else is no code. */
|
|
374
|
+
function errorCodeOf(value) {
|
|
375
|
+
if (typeof value === 'string') {
|
|
376
|
+
return value.length > 0 && value.length <= 128 ? value : undefined;
|
|
377
|
+
}
|
|
378
|
+
return typeof value === 'number' && Number.isFinite(value) ? String(value) : undefined;
|
|
379
|
+
}
|
|
380
|
+
function providerCodeOf(body) {
|
|
381
|
+
if (!isRecord(body)) {
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
const error = isRecord(body['error']) ? body['error'] : undefined;
|
|
385
|
+
const code = errorCodeOf(error?.['errorCode']);
|
|
386
|
+
if (code !== undefined) {
|
|
387
|
+
return { providerCode: code };
|
|
388
|
+
}
|
|
389
|
+
const resultType = body['resultType'];
|
|
390
|
+
return typeof resultType === 'string' && resultType.length > 0
|
|
391
|
+
? { providerCode: resultType.slice(0, 128) }
|
|
392
|
+
: {};
|
|
393
|
+
}
|
|
394
|
+
/** The body's `error.data.retryAfterSeconds` when it is a usable delay, else the `Retry-After` header's. */
|
|
395
|
+
function retryAfterOf(error, header) {
|
|
396
|
+
const data = isRecord(error?.['data']) ? error['data'] : undefined;
|
|
397
|
+
return positiveSeconds(data?.['retryAfterSeconds']) ?? positiveSeconds(header);
|
|
398
|
+
}
|
|
399
|
+
/** A positive number of seconds, rounded up and capped at a day, from a number or a decimal string. */
|
|
400
|
+
function positiveSeconds(value) {
|
|
401
|
+
const seconds = typeof value === 'string' && /^\d+(?:\.\d+)?$/u.test(value.trim())
|
|
402
|
+
? Number(value.trim())
|
|
403
|
+
: value;
|
|
404
|
+
return typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
|
|
405
|
+
? Math.min(Math.ceil(seconds), maximumAitFunctionalMessageRetryAfterSeconds)
|
|
406
|
+
: undefined;
|
|
407
|
+
}
|
|
408
|
+
function isIdentifier(value) {
|
|
409
|
+
return value.length > 0
|
|
410
|
+
&& value.length <= maximumIdentifierLength
|
|
411
|
+
&& value.trim() === value
|
|
412
|
+
&& !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
413
|
+
}
|
|
414
|
+
/** A value a `fetch` header accepts as is: visible ASCII, at most 2,048 characters. */
|
|
415
|
+
function isHeaderValue(value) {
|
|
416
|
+
return value.length <= maximumIdentifierLength && /^[\x21-\x7e]+$/u.test(value);
|
|
417
|
+
}
|
|
418
|
+
function isCount(value) {
|
|
419
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
420
|
+
}
|
|
421
|
+
function isRecord(value) {
|
|
422
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
423
|
+
}
|
|
424
|
+
/** A credential-free HTTPS base URL ending in `/`, as the partner API client requires. */
|
|
425
|
+
function normalizeBaseUrl(input) {
|
|
426
|
+
const url = new URL(input);
|
|
427
|
+
if (url.protocol !== 'https:'
|
|
428
|
+
|| url.username.length > 0
|
|
429
|
+
|| url.password.length > 0
|
|
430
|
+
|| url.search.length > 0
|
|
431
|
+
|| url.hash.length > 0) {
|
|
432
|
+
throw new TypeError('The Apps in Toss partner API baseUrl must be a credential-free HTTPS URL.');
|
|
433
|
+
}
|
|
434
|
+
url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
|
|
435
|
+
return url.href;
|
|
436
|
+
}
|
|
437
|
+
function normalizeTimeout(value) {
|
|
438
|
+
if (value === undefined) {
|
|
439
|
+
return defaultAitFunctionalMessageTimeoutMs;
|
|
440
|
+
}
|
|
441
|
+
if (!Number.isFinite(value) || value < 1 || value > maximumTimeoutMs) {
|
|
442
|
+
throw new RangeError('The Apps in Toss functional message timeout must be between 1 and 60000 ms.');
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|