@h-ai/reach 1.0.0-beta.2 → 1.0.0-beta.20
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.en.md +189 -97
- package/README.ja.md +188 -96
- package/README.md +181 -89
- package/dist/index.d.ts +57 -3
- package/dist/index.js +664 -301
- package/dist/index.js.map +1 -1
- package/package.json +11 -11
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { core, err, ok } from '@h-ai/core';
|
|
3
3
|
import process from 'process';
|
|
4
|
-
import { reldb,
|
|
4
|
+
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
5
5
|
import { randomUUID, createHash, createHmac } from 'crypto';
|
|
6
|
+
import { addAbortListener } from 'events';
|
|
6
7
|
import { createRequire } from 'module';
|
|
7
8
|
import { cache } from '@h-ai/cache';
|
|
8
9
|
|
|
@@ -22,6 +23,7 @@ var en_US_default = {
|
|
|
22
23
|
reach_providerRequired: "No provider specified, set provider field in message or template",
|
|
23
24
|
reach_dndBlocked: "Message discarded by Do Not Disturb period",
|
|
24
25
|
reach_dndPersistenceRequired: "DND delay requires an initialized database and a ready queue table",
|
|
26
|
+
reach_templatePersistenceRequired: "Configured notification templates require an initialized database and successful template persistence",
|
|
25
27
|
reach_dndEnqueueFailed: "The message could not be reliably queued; try again later",
|
|
26
28
|
reach_sendRecordSaveFailed: "Failed to persist the reach send record",
|
|
27
29
|
reach_invalidStoredInteger: "Send record {field} is not a safe non-negative integer",
|
|
@@ -29,8 +31,13 @@ var en_US_default = {
|
|
|
29
31
|
reach_unknownResolutionFailed: "Unknown delivery was not found or has already been resolved",
|
|
30
32
|
reach_queueInputInvalid: "Invalid queue receipt or manual resolution parameters",
|
|
31
33
|
reach_deliveryUnknown: "The external delivery outcome is unknown; manual review is required and automatic redelivery has stopped",
|
|
32
|
-
reach_smsSendFailed: "SMS send failed
|
|
33
|
-
|
|
34
|
+
reach_smsSendFailed: "SMS send failed. Please try again later.",
|
|
35
|
+
reach_smsGatewayFailed: "The SMS gateway request failed or timed out. Delivery is unconfirmed",
|
|
36
|
+
reach_gatewayUrlInvalid: "Use a full HTTPS gateway URL without credentials, a query, or a fragment. For HTTP on a trusted private network, explicitly enable allowInsecureHttp",
|
|
37
|
+
reach_gatewayHeadersInvalid: "Gateway headers are invalid or include a header managed by the HTTP client",
|
|
38
|
+
reach_gatewayBodyInvalid: "The gateway request body must be a JSON object, an array, or text",
|
|
39
|
+
reach_gatewayHookInvalid: "Gateway hooks must be bound to an SMS provider with a gateway configured",
|
|
40
|
+
reach_emailSendFailed: "Email send failed. Please try again later.",
|
|
34
41
|
reach_apiSendFailed: "API callback send failed: {error}",
|
|
35
42
|
reach_configError: "Reach config validation failed: {error}",
|
|
36
43
|
reach_config_nameRequired: "Provider name is required",
|
|
@@ -57,7 +64,8 @@ var en_US_default = {
|
|
|
57
64
|
reach_apiResponseTooLarge: "API acknowledgement exceeds the 64 KiB limit",
|
|
58
65
|
reach_apiResponseInvalid: "API acknowledgement must be empty or a JSON object with an optional messageId string",
|
|
59
66
|
reach_apiRequestFailed: "API request failed or timed out; delivery is unconfirmed",
|
|
60
|
-
reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects"
|
|
67
|
+
reach_apiHttpsRequired: "This API policy requires HTTPS and disallows automatic redirects",
|
|
68
|
+
reach_config_providerNameDuplicate: "Provider names must be unique"
|
|
61
69
|
};
|
|
62
70
|
|
|
63
71
|
// messages/ja-JP.json
|
|
@@ -74,6 +82,7 @@ var ja_JP_default = {
|
|
|
74
82
|
reach_providerRequired: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u30E1\u30C3\u30BB\u30FC\u30B8\u307E\u305F\u306F\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306Eprovider\u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
75
83
|
reach_dndBlocked: "\u901A\u77E5\u3092\u505C\u6B62\u3059\u308B\u6642\u9593\u5E2F\u306E\u305F\u3081\u3001\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u7834\u68C4\u3057\u307E\u3057\u305F",
|
|
76
84
|
reach_dndPersistenceRequired: "\u901A\u77E5\u5236\u9650\u6642\u9593\u5E2F\u306E\u914D\u4FE1\u5EF6\u671F\u306B\u306F\u521D\u671F\u5316\u6E08\u307F\u306E\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u3068\u6E96\u5099\u6E08\u307F\u306E\u30AD\u30E5\u30FC\u30C6\u30FC\u30D6\u30EB\u304C\u5FC5\u8981\u3067\u3059",
|
|
85
|
+
reach_templatePersistenceRequired: "\u901A\u77E5\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u8A2D\u5B9A\u306B\u306F\u521D\u671F\u5316\u6E08\u307F\u306E\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u3068\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u306E\u4FDD\u5B58\u6210\u529F\u304C\u5FC5\u8981\u3067\u3059",
|
|
77
86
|
reach_dndEnqueueFailed: "\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u78BA\u5B9F\u306B\u30AD\u30E5\u30FC\u3078\u767B\u9332\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\u3002\u3057\u3070\u3089\u304F\u3057\u3066\u304B\u3089\u518D\u8A66\u884C\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
78
87
|
reach_sendRecordSaveFailed: "\u914D\u4FE1\u8A18\u9332\u306E\u6C38\u7D9A\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
|
|
79
88
|
reach_invalidStoredInteger: "\u914D\u4FE1\u8A18\u9332\u306E {field} \u306F\u5B89\u5168\u306A\u975E\u8CA0\u6574\u6570\u3067\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
@@ -81,8 +90,13 @@ var ja_JP_default = {
|
|
|
81
90
|
reach_unknownResolutionFailed: "\u7D50\u679C\u4E0D\u660E\u306E\u914D\u4FE1\u304C\u898B\u3064\u304B\u3089\u306A\u3044\u304B\u3001\u65E2\u306B\u89E3\u6C7A\u3055\u308C\u3066\u3044\u307E\u3059",
|
|
82
91
|
reach_queueInputInvalid: "\u30AD\u30E5\u30FC\u306E\u53D7\u9818\u8A18\u9332\u307E\u305F\u306F\u624B\u52D5\u89E3\u6C7A\u306E\u30D1\u30E9\u30E1\u30FC\u30BF\u30FC\u304C\u7121\u52B9\u3067\u3059",
|
|
83
92
|
reach_deliveryUnknown: "\u5916\u90E8\u3078\u306E\u914D\u4FE1\u7D50\u679C\u306F\u4E0D\u660E\u3067\u3059\u3002\u624B\u52D5\u78BA\u8A8D\u304C\u5FC5\u8981\u306A\u305F\u3081\u3001\u81EA\u52D5\u518D\u914D\u4FE1\u3092\u505C\u6B62\u3057\u307E\u3057\u305F",
|
|
84
|
-
reach_smsSendFailed: "SMS\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F
|
|
85
|
-
|
|
93
|
+
reach_smsSendFailed: "SMS\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u3057\u3070\u3089\u304F\u3057\u3066\u304B\u3089\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
|
|
94
|
+
reach_smsGatewayFailed: "SMS \u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u3078\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u3001\u914D\u4FE1\u3092\u78BA\u8A8D\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F",
|
|
95
|
+
reach_gatewayUrlInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306B\u306F\u8A8D\u8A3C\u60C5\u5831\u3001\u30AF\u30A8\u30EA\u3001\u30D5\u30E9\u30B0\u30E1\u30F3\u30C8\u3092\u542B\u307E\u306A\u3044\u5B8C\u5168\u306A HTTPS URL \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u4FE1\u983C\u3067\u304D\u308B\u5185\u90E8\u30CD\u30C3\u30C8\u30EF\u30FC\u30AF\u3067 HTTP \u3092\u4F7F\u7528\u3059\u308B\u5834\u5408\u306F allowInsecureHttp \u3092\u660E\u793A\u7684\u306B\u6709\u52B9\u306B\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
96
|
+
reach_gatewayHeadersInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u30D8\u30C3\u30C0\u30FC\u304C\u7121\u52B9\u304B\u3001HTTP \u30AF\u30E9\u30A4\u30A2\u30F3\u30C8\u304C\u7BA1\u7406\u3059\u308B\u30D8\u30C3\u30C0\u30FC\u304C\u542B\u307E\u308C\u3066\u3044\u307E\u3059",
|
|
97
|
+
reach_gatewayBodyInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u30DC\u30C7\u30A3\u306B\u306F JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3001\u914D\u5217\u3001\u307E\u305F\u306F\u30C6\u30AD\u30B9\u30C8\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
98
|
+
reach_gatewayHookInvalid: "\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u306E\u30D5\u30C3\u30AF\u306F\u3001\u30B2\u30FC\u30C8\u30A6\u30A7\u30A4\u3092\u8A2D\u5B9A\u3057\u305F SMS Provider \u306B\u5272\u308A\u5F53\u3066\u3066\u304F\u3060\u3055\u3044",
|
|
99
|
+
reach_emailSendFailed: "\u30E1\u30FC\u30EB\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F\u3002\u3057\u3070\u3089\u304F\u3057\u3066\u304B\u3089\u3082\u3046\u4E00\u5EA6\u304A\u8A66\u3057\u304F\u3060\u3055\u3044",
|
|
86
100
|
reach_apiSendFailed: "API\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
87
101
|
reach_configError: "\u901A\u77E5\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
88
102
|
reach_config_nameRequired: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u540D\u306F\u5FC5\u9808\u3067\u3059",
|
|
@@ -109,7 +123,8 @@ var ja_JP_default = {
|
|
|
109
123
|
reach_apiResponseTooLarge: "API \u5FDC\u7B54\u304C 64 KiB \u306E\u4E0A\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
|
|
110
124
|
reach_apiResponseInvalid: "API \u5FDC\u7B54\u306F\u7A7A\u304B\u3001\u7701\u7565\u53EF\u80FD\u306A messageId \u6587\u5B57\u5217\u3092\u6301\u3064 JSON \u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
|
|
111
125
|
reach_apiRequestFailed: "API \u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u5931\u6557\u307E\u305F\u306F\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F\u3002\u914D\u4FE1\u7D50\u679C\u306F\u672A\u78BA\u8A8D\u3067\u3059",
|
|
112
|
-
reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059"
|
|
126
|
+
reach_apiHttpsRequired: "\u3053\u306E API \u30DD\u30EA\u30B7\u30FC\u3067\u306F HTTPS \u304C\u5FC5\u8981\u3067\u3001\u81EA\u52D5\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u306F\u7981\u6B62\u3055\u308C\u3066\u3044\u307E\u3059",
|
|
127
|
+
reach_config_providerNameDuplicate: "Provider \u540D\u306F\u4E00\u610F\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059"
|
|
113
128
|
};
|
|
114
129
|
|
|
115
130
|
// messages/zh-CN.json
|
|
@@ -126,6 +141,7 @@ var zh_CN_default = {
|
|
|
126
141
|
reach_providerRequired: "\u672A\u6307\u5B9A Provider\uFF0C\u8BF7\u5728\u6D88\u606F\u6216\u6A21\u677F\u4E2D\u8BBE\u7F6E provider \u5B57\u6BB5",
|
|
127
142
|
reach_dndBlocked: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u4E22\u5F03",
|
|
128
143
|
reach_dndPersistenceRequired: "\u542F\u7528\u514D\u6253\u6270\u5EF6\u8FDF\u53D1\u9001\u9700\u8981\u5148\u521D\u59CB\u5316\u6570\u636E\u5E93\u5E76\u786E\u4FDD\u961F\u5217\u8868\u53EF\u7528",
|
|
144
|
+
reach_templatePersistenceRequired: "\u914D\u7F6E\u901A\u77E5\u6A21\u677F\u524D\u9700\u8981\u5148\u521D\u59CB\u5316\u6570\u636E\u5E93\uFF0C\u5E76\u786E\u4FDD\u6A21\u677F\u80FD\u591F\u4FDD\u5B58",
|
|
129
145
|
reach_dndEnqueueFailed: "\u6D88\u606F\u672A\u80FD\u53EF\u9760\u4FDD\u5B58\u5230\u5EF6\u8FDF\u961F\u5217\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
130
146
|
reach_sendRecordSaveFailed: "\u4FDD\u5B58\u89E6\u8FBE\u53D1\u9001\u8BB0\u5F55\u5931\u8D25",
|
|
131
147
|
reach_invalidStoredInteger: "\u53D1\u9001\u8BB0\u5F55\u7684 {field} \u4E0D\u662F\u5B89\u5168\u7684\u975E\u8D1F\u6574\u6570",
|
|
@@ -133,8 +149,13 @@ var zh_CN_default = {
|
|
|
133
149
|
reach_unknownResolutionFailed: "\u672A\u77E5\u6295\u9012\u7ED3\u679C\u4E0D\u5B58\u5728\u6216\u5DF2\u88AB\u5176\u5B83\u64CD\u4F5C\u88C1\u5B9A",
|
|
134
150
|
reach_queueInputInvalid: "\u961F\u5217\u56DE\u6267\u6216\u4EBA\u5DE5\u88C1\u5B9A\u53C2\u6570\u65E0\u6548",
|
|
135
151
|
reach_deliveryUnknown: "\u5916\u90E8\u6295\u9012\u7ED3\u679C\u672A\u77E5\uFF0C\u9700\u8981\u4EBA\u5DE5\u6838\u5BF9\uFF0C\u5DF2\u505C\u6B62\u81EA\u52A8\u91CD\u53D1",
|
|
136
|
-
reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25
|
|
137
|
-
|
|
152
|
+
reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
153
|
+
reach_smsGatewayFailed: "\u77ED\u4FE1\u7F51\u5173\u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
|
|
154
|
+
reach_gatewayUrlInvalid: "\u7F51\u5173\u987B\u4F7F\u7528\u5B8C\u6574\u7684 HTTPS URL\uFF0C\u4E14\u4E0D\u80FD\u5305\u542B\u51ED\u636E\u3001\u67E5\u8BE2\u53C2\u6570\u6216\u7247\u6BB5\u3002\u53D7\u4FE1\u5185\u7F51\u4F7F\u7528 HTTP \u65F6\u987B\u663E\u5F0F\u542F\u7528 allowInsecureHttp",
|
|
155
|
+
reach_gatewayHeadersInvalid: "\u7F51\u5173\u8BF7\u6C42\u5934\u683C\u5F0F\u65E0\u6548\uFF0C\u6216\u5305\u542B\u7531 HTTP \u5BA2\u6237\u7AEF\u7BA1\u7406\u7684\u8BF7\u6C42\u5934",
|
|
156
|
+
reach_gatewayBodyInvalid: "\u7F51\u5173\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61\u3001\u6570\u7EC4\u6216\u6587\u672C",
|
|
157
|
+
reach_gatewayHookInvalid: "\u7F51\u5173\u51FD\u6570\u5FC5\u987B\u7ED1\u5B9A\u5230\u542F\u7528\u4E86\u7F51\u5173\u7684\u77ED\u4FE1 Provider",
|
|
158
|
+
reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5",
|
|
138
159
|
reach_apiSendFailed: "API \u56DE\u8C03\u53D1\u9001\u5931\u8D25: {error}",
|
|
139
160
|
reach_configError: "\u89E6\u8FBE\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
|
|
140
161
|
reach_config_nameRequired: "Provider \u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A",
|
|
@@ -161,7 +182,8 @@ var zh_CN_default = {
|
|
|
161
182
|
reach_apiResponseTooLarge: "API \u56DE\u6267\u8D85\u8FC7 64 KiB \u4E0A\u9650",
|
|
162
183
|
reach_apiResponseInvalid: "API \u56DE\u6267\u5FC5\u987B\u4E3A\u7A7A\u6216\u542B\u53EF\u9009 messageId \u5B57\u7B26\u4E32\u7684 JSON \u5BF9\u8C61",
|
|
163
184
|
reach_apiRequestFailed: "API \u8BF7\u6C42\u5931\u8D25\u6216\u8D85\u65F6\uFF0C\u6295\u9012\u7ED3\u679C\u672A\u786E\u8BA4",
|
|
164
|
-
reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411"
|
|
185
|
+
reach_apiHttpsRequired: "\u6B64 API \u7B56\u7565\u8981\u6C42 HTTPS \u4E14\u7981\u6B62\u81EA\u52A8\u91CD\u5B9A\u5411",
|
|
186
|
+
reach_config_providerNameDuplicate: "Provider \u540D\u79F0\u4E0D\u80FD\u91CD\u590D"
|
|
165
187
|
};
|
|
166
188
|
|
|
167
189
|
// src/reach-i18n.ts
|
|
@@ -171,6 +193,42 @@ var reachM = core.i18n.createMessageGetter({
|
|
|
171
193
|
"ja-JP": ja_JP_default
|
|
172
194
|
});
|
|
173
195
|
|
|
196
|
+
// src/reach-gateway-config.ts
|
|
197
|
+
var transportHeader = /^(?:connection|content-length|host|keep-alive|proxy-connection|te|trailer|transfer-encoding|upgrade)$/i;
|
|
198
|
+
var jsonValue = z.json();
|
|
199
|
+
var gatewayFields = {
|
|
200
|
+
/** 网关自定义请求头;连接与消息长度相关的头由 HTTP 客户端管理。 */
|
|
201
|
+
headers: z.record(z.string(), z.string()).refine((headers) => {
|
|
202
|
+
try {
|
|
203
|
+
return [
|
|
204
|
+
...new Headers(headers).keys()
|
|
205
|
+
].every((name) => !transportHeader.test(name));
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}, reachM("reach_gatewayHeadersInvalid")).optional(),
|
|
210
|
+
/** 网关请求体;对象和数组序列化为 JSON,字符串原样发送。省略时不发请求体。 */
|
|
211
|
+
body: z.union([
|
|
212
|
+
z.string(),
|
|
213
|
+
z.array(jsonValue),
|
|
214
|
+
z.record(z.string(), jsonValue)
|
|
215
|
+
], reachM("reach_gatewayBodyInvalid")).optional()
|
|
216
|
+
};
|
|
217
|
+
var ReachGatewayFieldsSchema = z.strictObject(gatewayFields);
|
|
218
|
+
var ReachGatewayConfigSchema = z.strictObject({
|
|
219
|
+
/** 网关的完整 HTTP(S) URL;包含路径,不允许凭据、查询参数或片段。 */
|
|
220
|
+
url: z.url(reachM("reach_gatewayUrlInvalid")),
|
|
221
|
+
/** 仅在受信内网显式允许明文 HTTP。 */
|
|
222
|
+
allowInsecureHttp: z.boolean().default(false),
|
|
223
|
+
...gatewayFields
|
|
224
|
+
}).refine(({ url, allowInsecureHttp }) => {
|
|
225
|
+
const target = URL.parse(url);
|
|
226
|
+
return target !== null && /^https?:\/\//i.test(url) && !/[?#]/.test(url) && !target.username && !target.password && (target.protocol === "https:" || target.protocol === "http:" && allowInsecureHttp);
|
|
227
|
+
}, {
|
|
228
|
+
path: ["url"],
|
|
229
|
+
message: reachM("reach_gatewayUrlInvalid")
|
|
230
|
+
});
|
|
231
|
+
|
|
174
232
|
// src/reach-config.ts
|
|
175
233
|
var ConsoleProviderConfigSchema = z.object({
|
|
176
234
|
/** Provider 唯一名称 */
|
|
@@ -204,8 +262,10 @@ var AliyunSmsProviderConfigSchema = z.object({
|
|
|
204
262
|
accessKeySecret: z.string().min(1, reachM("reach_config_accessKeySecretRequired")),
|
|
205
263
|
/** 短信签名 */
|
|
206
264
|
signName: z.string().min(1, reachM("reach_config_signNameRequired")),
|
|
207
|
-
/** API 端点(默认 dysmsapi.aliyuncs.com
|
|
208
|
-
endpoint: z.string().default("dysmsapi.aliyuncs.com")
|
|
265
|
+
/** 直连 API 端点(默认 dysmsapi.aliyuncs.com);配置网关时使用网关 URL。 */
|
|
266
|
+
endpoint: z.string().default("dysmsapi.aliyuncs.com"),
|
|
267
|
+
/** 可选的短信隔离网关,以 POST 请求转发已签名的查询参数。 */
|
|
268
|
+
gateway: ReachGatewayConfigSchema.optional()
|
|
209
269
|
});
|
|
210
270
|
var ApiProviderConfigSchema = z.object({
|
|
211
271
|
/** Provider 唯一名称 */
|
|
@@ -240,11 +300,11 @@ var ReachUnknownResolutionSchema = z.object({
|
|
|
240
300
|
});
|
|
241
301
|
var ReachQueueIdSchema = z.string().uuid();
|
|
242
302
|
var ReachQueueConfigSchema = z.object({
|
|
243
|
-
/**
|
|
303
|
+
/** 每条队列消息最多处理多少次,包含首次发送、失败重试和异常退出后的重新处理;默认 5,范围 1–100。 */
|
|
244
304
|
maxAttempts: z.number().int().min(1).max(100).default(5),
|
|
245
|
-
/**
|
|
305
|
+
/** 第一次发送失败后,等待多少毫秒再试;默认 60000(1 分钟),范围 1000–86400000。 */
|
|
246
306
|
retryDelayMs: z.number().int().min(1e3).max(864e5).default(6e4),
|
|
247
|
-
/**
|
|
307
|
+
/** 后续失败时等待时间逐次加倍,但不超过此值;默认 3600000(1 小时),不得小于 retryDelayMs。 */
|
|
248
308
|
maxRetryDelayMs: z.number().int().min(1e3).max(864e5).default(36e5)
|
|
249
309
|
}).refine((value) => value.maxRetryDelayMs >= value.retryDelayMs, {
|
|
250
310
|
message: reachM("reach_queueRetryInvalid")
|
|
@@ -279,7 +339,7 @@ var TemplateConfigSchema = z.object({
|
|
|
279
339
|
});
|
|
280
340
|
var ReachConfigSchema = z.object({
|
|
281
341
|
/** Provider 配置列表 */
|
|
282
|
-
providers: z.array(ProviderConfigSchema).min(1, reachM("reach_config_providersRequired")),
|
|
342
|
+
providers: z.array(ProviderConfigSchema).min(1, reachM("reach_config_providersRequired")).refine((providers2) => new Set(providers2.map((provider) => provider.name)).size === providers2.length, reachM("reach_config_providerNameDuplicate")),
|
|
283
343
|
/** 模板配置(通过配置文件注册) */
|
|
284
344
|
templates: z.array(TemplateConfigSchema).optional(),
|
|
285
345
|
/** 免打扰配置 */
|
|
@@ -305,17 +365,17 @@ var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
|
|
|
305
365
|
// src/providers/reach-provider-aliyun-sms.ts
|
|
306
366
|
var logger = core.logger.child({ module: "reach", scope: "provider-aliyun-sms" });
|
|
307
367
|
var ISO_MILLISECONDS_SUFFIX_REGEX = /\.\d{3}Z$/;
|
|
308
|
-
|
|
368
|
+
var RFC3986_RESERVED_CHARACTERS_REGEX = /[!'()*]/g;
|
|
369
|
+
var GATEWAY_TIMEOUT_MS = 1e4;
|
|
370
|
+
var GATEWAY_RESPONSE_LIMIT_BYTES = 64 * 1024;
|
|
371
|
+
function toReachError() {
|
|
309
372
|
return {
|
|
310
|
-
|
|
311
|
-
message: reachM("reach_smsSendFailed"
|
|
312
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
313
|
-
}),
|
|
314
|
-
cause: error
|
|
373
|
+
...HaiReachError.SEND_FAILED,
|
|
374
|
+
message: reachM("reach_smsSendFailed")
|
|
315
375
|
};
|
|
316
376
|
}
|
|
317
377
|
function percentEncode(str) {
|
|
318
|
-
return encodeURIComponent(str).replace(
|
|
378
|
+
return encodeURIComponent(str).replace(RFC3986_RESERVED_CHARACTERS_REGEX, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
319
379
|
}
|
|
320
380
|
function signRequest(params, accessKeySecret, method) {
|
|
321
381
|
const sortedKeys = Object.keys(params).sort();
|
|
@@ -325,7 +385,70 @@ function signRequest(params, accessKeySecret, method) {
|
|
|
325
385
|
hmac.update(stringToSign);
|
|
326
386
|
return hmac.digest("base64");
|
|
327
387
|
}
|
|
328
|
-
function
|
|
388
|
+
async function prepareGatewayRequest(gateway, url, signal, hook) {
|
|
389
|
+
let body = gateway.body;
|
|
390
|
+
const headers = new Headers();
|
|
391
|
+
for (const [name, value] of Object.entries(gateway.headers ?? {}))
|
|
392
|
+
headers.set(name, value);
|
|
393
|
+
if (hook) {
|
|
394
|
+
signal.throwIfAborted();
|
|
395
|
+
let listener;
|
|
396
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
397
|
+
listener = addAbortListener(signal, () => reject(signal.reason));
|
|
398
|
+
});
|
|
399
|
+
try {
|
|
400
|
+
const output = await Promise.race([
|
|
401
|
+
Promise.resolve().then(() => hook({
|
|
402
|
+
method: "POST",
|
|
403
|
+
url,
|
|
404
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
405
|
+
signal
|
|
406
|
+
})),
|
|
407
|
+
aborted
|
|
408
|
+
]);
|
|
409
|
+
const patch = ReachGatewayFieldsSchema.parse(output === void 0 ? {} : output);
|
|
410
|
+
body = patch.body ?? body;
|
|
411
|
+
for (const [name, value] of Object.entries(patch.headers ?? {}))
|
|
412
|
+
headers.set(name, value);
|
|
413
|
+
} finally {
|
|
414
|
+
listener?.[Symbol.dispose]();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (body !== void 0 && !headers.has("Content-Type")) {
|
|
418
|
+
headers.set("Content-Type", typeof body === "string" ? "text/plain; charset=utf-8" : "application/json");
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
method: "POST",
|
|
422
|
+
headers,
|
|
423
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
424
|
+
redirect: "error",
|
|
425
|
+
signal
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function readGatewayResponse(response) {
|
|
429
|
+
if (response.ok && response.body) {
|
|
430
|
+
let bytes = 0;
|
|
431
|
+
const stream = response.body.pipeThrough(new TransformStream({
|
|
432
|
+
transform(chunk, controller) {
|
|
433
|
+
bytes += chunk.byteLength;
|
|
434
|
+
if (bytes > GATEWAY_RESPONSE_LIMIT_BYTES)
|
|
435
|
+
throw new Error("SMS gateway response exceeded the size limit");
|
|
436
|
+
controller.enqueue(chunk);
|
|
437
|
+
}
|
|
438
|
+
}));
|
|
439
|
+
const body = await new Response(stream).json();
|
|
440
|
+
if (body && typeof body === "object" && "Code" in body && body.Code === "OK") {
|
|
441
|
+
const messageId = "BizId" in body ? body.BizId : void 0;
|
|
442
|
+
if (messageId === void 0 || typeof messageId === "string" && messageId.length <= 255)
|
|
443
|
+
return ok({ success: true, messageId });
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
await response.body?.cancel();
|
|
447
|
+
}
|
|
448
|
+
logger.warn("SMS gateway returned an unsuccessful receipt", { status: response.status });
|
|
449
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
|
|
450
|
+
}
|
|
451
|
+
function createAliyunSmsProvider(gatewayHook) {
|
|
329
452
|
let smsConfig = null;
|
|
330
453
|
return {
|
|
331
454
|
name: "aliyun-sms",
|
|
@@ -334,11 +457,13 @@ function createAliyunSmsProvider() {
|
|
|
334
457
|
if (config.type !== "aliyun-sms") {
|
|
335
458
|
return err(
|
|
336
459
|
HaiReachError.CONFIG_ERROR,
|
|
337
|
-
reachM("reach_unsupportedType", {
|
|
460
|
+
reachM("reach_unsupportedType", {
|
|
461
|
+
params: { type: config.type }
|
|
462
|
+
})
|
|
338
463
|
);
|
|
339
464
|
}
|
|
340
465
|
smsConfig = config;
|
|
341
|
-
logger.info("Aliyun SMS provider connected", { endpoint: config.endpoint });
|
|
466
|
+
logger.info("Aliyun SMS provider connected", config.gateway ? { gateway: true } : { endpoint: config.endpoint });
|
|
342
467
|
return ok(void 0);
|
|
343
468
|
},
|
|
344
469
|
async close() {
|
|
@@ -349,21 +474,23 @@ function createAliyunSmsProvider() {
|
|
|
349
474
|
return smsConfig !== null;
|
|
350
475
|
},
|
|
351
476
|
async send(message) {
|
|
352
|
-
|
|
477
|
+
const config = smsConfig;
|
|
478
|
+
if (!config) {
|
|
353
479
|
return err(
|
|
354
480
|
HaiReachError.NOT_INITIALIZED,
|
|
355
481
|
reachM("reach_notInitialized")
|
|
356
482
|
);
|
|
357
483
|
}
|
|
358
484
|
const templateCode = message.extra?.templateCode;
|
|
359
|
-
|
|
485
|
+
if (!config.gateway)
|
|
486
|
+
logger.debug("Sending SMS", { to: message.to, templateCode });
|
|
360
487
|
try {
|
|
361
488
|
const params = {
|
|
362
|
-
AccessKeyId:
|
|
489
|
+
AccessKeyId: config.accessKeyId,
|
|
363
490
|
Action: "SendSms",
|
|
364
491
|
Format: "JSON",
|
|
365
492
|
PhoneNumbers: message.to,
|
|
366
|
-
SignName:
|
|
493
|
+
SignName: config.signName,
|
|
367
494
|
SignatureMethod: "HMAC-SHA1",
|
|
368
495
|
SignatureNonce: randomUUID(),
|
|
369
496
|
SignatureVersion: "1.0",
|
|
@@ -374,23 +501,39 @@ function createAliyunSmsProvider() {
|
|
|
374
501
|
if (message.vars) {
|
|
375
502
|
params.TemplateParam = JSON.stringify(message.vars);
|
|
376
503
|
}
|
|
377
|
-
params.Signature = signRequest(params,
|
|
504
|
+
params.Signature = signRequest(params, config.accessKeySecret, config.gateway ? "POST" : "GET");
|
|
378
505
|
const queryString = Object.entries(params).map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join("&");
|
|
379
|
-
|
|
506
|
+
if (config.gateway) {
|
|
507
|
+
const deadline = performance.now() + GATEWAY_TIMEOUT_MS;
|
|
508
|
+
const signal = AbortSignal.timeout(GATEWAY_TIMEOUT_MS);
|
|
509
|
+
const url2 = `${new URL(config.gateway.url).href}?${queryString}`;
|
|
510
|
+
const request = await prepareGatewayRequest(config.gateway, url2, signal, gatewayHook);
|
|
511
|
+
signal.throwIfAborted();
|
|
512
|
+
if (smsConfig !== config || performance.now() >= deadline)
|
|
513
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
|
|
514
|
+
if (message.sensitive && message.expiresAt !== void 0 && Date.now() >= message.expiresAt)
|
|
515
|
+
return err(HaiReachError.MESSAGE_EXPIRED, reachM("reach_messageExpired"));
|
|
516
|
+
return await readGatewayResponse(await fetch(url2, request));
|
|
517
|
+
}
|
|
518
|
+
const url = `https://${config.endpoint}/?${queryString}`;
|
|
380
519
|
const response = await fetch(url);
|
|
381
520
|
const body = await response.json();
|
|
382
521
|
if (body.Code !== "OK") {
|
|
383
|
-
logger.warn("SMS send returned non-OK status", { code: body.Code
|
|
522
|
+
logger.warn("SMS send returned non-OK status", { code: body.Code });
|
|
384
523
|
return err(
|
|
385
524
|
HaiReachError.SEND_FAILED,
|
|
386
|
-
reachM("reach_smsSendFailed"
|
|
525
|
+
reachM("reach_smsSendFailed")
|
|
387
526
|
);
|
|
388
527
|
}
|
|
389
528
|
logger.info("SMS sent", { to: message.to, bizId: body.BizId });
|
|
390
529
|
return ok({ success: true, messageId: body.BizId });
|
|
391
530
|
} catch (error) {
|
|
531
|
+
if (config.gateway) {
|
|
532
|
+
logger.warn("SMS gateway request failed");
|
|
533
|
+
return err(HaiReachError.SEND_FAILED, reachM("reach_smsGatewayFailed"));
|
|
534
|
+
}
|
|
392
535
|
logger.error("SMS send failed", { to: message.to, error });
|
|
393
|
-
return err(toReachError(
|
|
536
|
+
return err(toReachError());
|
|
394
537
|
}
|
|
395
538
|
}
|
|
396
539
|
};
|
|
@@ -405,9 +548,11 @@ function getSafeEndpoint(url) {
|
|
|
405
548
|
}
|
|
406
549
|
function toReachError2() {
|
|
407
550
|
return {
|
|
408
|
-
|
|
551
|
+
...HaiReachError.SEND_FAILED,
|
|
409
552
|
message: reachM("reach_apiSendFailed", {
|
|
410
|
-
params: {
|
|
553
|
+
params: {
|
|
554
|
+
error: reachM("reach_apiRequestFailed")
|
|
555
|
+
}
|
|
411
556
|
})
|
|
412
557
|
};
|
|
413
558
|
}
|
|
@@ -439,7 +584,10 @@ async function readAcknowledgement(response) {
|
|
|
439
584
|
const messageId = "messageId" in result ? result.messageId : void 0;
|
|
440
585
|
if (messageId !== void 0 && (typeof messageId !== "string" || messageId.length > 255))
|
|
441
586
|
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
|
|
442
|
-
return ok({
|
|
587
|
+
return ok({
|
|
588
|
+
success: true,
|
|
589
|
+
...messageId === void 0 ? {} : { messageId }
|
|
590
|
+
});
|
|
443
591
|
} catch {
|
|
444
592
|
return err(HaiReachError.SEND_FAILED, reachM("reach_apiResponseInvalid"));
|
|
445
593
|
} finally {
|
|
@@ -459,7 +607,9 @@ function createApiProvider() {
|
|
|
459
607
|
if (config.type !== "api") {
|
|
460
608
|
return err(
|
|
461
609
|
HaiReachError.CONFIG_ERROR,
|
|
462
|
-
reachM("reach_unsupportedType", {
|
|
610
|
+
reachM("reach_unsupportedType", {
|
|
611
|
+
params: { type: config.type }
|
|
612
|
+
})
|
|
463
613
|
);
|
|
464
614
|
}
|
|
465
615
|
apiConfig = config;
|
|
@@ -467,7 +617,10 @@ function createApiProvider() {
|
|
|
467
617
|
apiConfig = null;
|
|
468
618
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
|
|
469
619
|
}
|
|
470
|
-
logger2.info("API provider connected", {
|
|
620
|
+
logger2.info("API provider connected", {
|
|
621
|
+
endpoint: getSafeEndpoint(config.url),
|
|
622
|
+
method: config.method
|
|
623
|
+
});
|
|
471
624
|
return ok(void 0);
|
|
472
625
|
},
|
|
473
626
|
async close() {
|
|
@@ -484,7 +637,9 @@ function createApiProvider() {
|
|
|
484
637
|
reachM("reach_notInitialized")
|
|
485
638
|
);
|
|
486
639
|
}
|
|
487
|
-
logger2.debug("Sending via API callback", {
|
|
640
|
+
logger2.debug("Sending via API callback", {
|
|
641
|
+
endpoint: getSafeEndpoint(apiConfig.url)
|
|
642
|
+
});
|
|
488
643
|
const requireHttps = apiConfig.requireHttps ?? (message.sensitive === true && core.module.requiresSharedState(process.env));
|
|
489
644
|
if (requireHttps && new URL(apiConfig.url).protocol !== "https:")
|
|
490
645
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_apiHttpsRequired"));
|
|
@@ -518,7 +673,9 @@ function createApiProvider() {
|
|
|
518
673
|
const rejected = response.status >= 300 && response.status < 400 || response.status >= 400 && response.status < 500 && ![408, 425, 429].includes(response.status);
|
|
519
674
|
return err(
|
|
520
675
|
rejected ? HaiReachError.DELIVERY_REJECTED : HaiReachError.SEND_FAILED,
|
|
521
|
-
reachM("reach_apiSendFailed", {
|
|
676
|
+
reachM("reach_apiSendFailed", {
|
|
677
|
+
params: { error: `HTTP ${response.status}` }
|
|
678
|
+
})
|
|
522
679
|
);
|
|
523
680
|
}
|
|
524
681
|
const result = await readAcknowledgement(response);
|
|
@@ -568,13 +725,10 @@ function createConsoleProvider() {
|
|
|
568
725
|
};
|
|
569
726
|
}
|
|
570
727
|
var logger4 = core.logger.child({ module: "reach", scope: "provider-smtp" });
|
|
571
|
-
function toReachError3(
|
|
728
|
+
function toReachError3() {
|
|
572
729
|
return {
|
|
573
|
-
|
|
574
|
-
message: reachM("reach_emailSendFailed"
|
|
575
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
576
|
-
}),
|
|
577
|
-
cause: error
|
|
730
|
+
...HaiReachError.SEND_FAILED,
|
|
731
|
+
message: reachM("reach_emailSendFailed")
|
|
578
732
|
};
|
|
579
733
|
}
|
|
580
734
|
function createSmtpProvider() {
|
|
@@ -587,7 +741,9 @@ function createSmtpProvider() {
|
|
|
587
741
|
if (config.type !== "smtp") {
|
|
588
742
|
return err(
|
|
589
743
|
HaiReachError.CONFIG_ERROR,
|
|
590
|
-
reachM("reach_unsupportedType", {
|
|
744
|
+
reachM("reach_unsupportedType", {
|
|
745
|
+
params: { type: config.type }
|
|
746
|
+
})
|
|
591
747
|
);
|
|
592
748
|
}
|
|
593
749
|
try {
|
|
@@ -604,7 +760,7 @@ function createSmtpProvider() {
|
|
|
604
760
|
return ok(void 0);
|
|
605
761
|
} catch (error) {
|
|
606
762
|
logger4.error("SMTP connection failed", { error });
|
|
607
|
-
return err(toReachError3(
|
|
763
|
+
return err(toReachError3());
|
|
608
764
|
}
|
|
609
765
|
},
|
|
610
766
|
async close() {
|
|
@@ -638,7 +794,7 @@ function createSmtpProvider() {
|
|
|
638
794
|
return ok({ success: true, messageId: info.messageId });
|
|
639
795
|
} catch (error) {
|
|
640
796
|
logger4.error("Email send failed", { to: message.to, error });
|
|
641
|
-
return err(toReachError3(
|
|
797
|
+
return err(toReachError3());
|
|
642
798
|
}
|
|
643
799
|
}
|
|
644
800
|
};
|
|
@@ -730,14 +886,25 @@ function previousResult(row) {
|
|
|
730
886
|
return row.status === "sent" ? ok({ success: true, receiptId: row.queueId ?? void 0 }) : unconfirmed(row.queueId);
|
|
731
887
|
}
|
|
732
888
|
function unconfirmed(receiptId) {
|
|
733
|
-
return {
|
|
889
|
+
return {
|
|
890
|
+
success: false,
|
|
891
|
+
error: {
|
|
892
|
+
...HaiReachError.SEND_FAILED,
|
|
893
|
+
message: reachM("reach_sensitiveDeliveryUnconfirmed"),
|
|
894
|
+
ext: { receiptId }
|
|
895
|
+
}
|
|
896
|
+
};
|
|
734
897
|
}
|
|
735
898
|
async function sendSensitive(message, provider, repo, blocked) {
|
|
736
899
|
if (!repo || !message.idempotencyKey?.trim() || message.idempotencyKey.length > 255 || !Number.isSafeInteger(message.expiresAt))
|
|
737
900
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_sensitivePolicyRequired"));
|
|
738
901
|
const hash = createHash("sha256").update(JSON.stringify([message.provider, message.to, message.idempotencyKey])).digest("hex");
|
|
739
902
|
const queueId = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-5${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
740
|
-
const find = () => repo.findAll({
|
|
903
|
+
const find = () => repo.findAll({
|
|
904
|
+
where: "queue_id = ?",
|
|
905
|
+
params: [queueId],
|
|
906
|
+
limit: 1
|
|
907
|
+
});
|
|
741
908
|
const existing = await find();
|
|
742
909
|
if (!existing.success)
|
|
743
910
|
return existing;
|
|
@@ -786,6 +953,106 @@ async function sendSensitive(message, provider, repo, blocked) {
|
|
|
786
953
|
return unconfirmed(queueId);
|
|
787
954
|
return ok({ success: true, receiptId: queueId });
|
|
788
955
|
}
|
|
956
|
+
function renderString(template, vars) {
|
|
957
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
958
|
+
return Object.hasOwn(vars, key) ? vars[key] : match;
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function renderTemplate(template, vars) {
|
|
962
|
+
try {
|
|
963
|
+
const rendered = {
|
|
964
|
+
subject: template.subject ? renderString(template.subject, vars) : void 0,
|
|
965
|
+
body: renderString(template.body, vars)
|
|
966
|
+
};
|
|
967
|
+
return ok(rendered);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
return err(
|
|
970
|
+
HaiReachError.TEMPLATE_RENDER_FAILED,
|
|
971
|
+
reachM("reach_templateRenderFailed", {
|
|
972
|
+
params: {
|
|
973
|
+
error: error instanceof Error ? error.message : String(error)
|
|
974
|
+
}
|
|
975
|
+
}),
|
|
976
|
+
error
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function noDbError(msgKey) {
|
|
981
|
+
return err(
|
|
982
|
+
HaiReachError.SEND_FAILED,
|
|
983
|
+
reachM(msgKey, {
|
|
984
|
+
params: { error: "database not available" }
|
|
985
|
+
})
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
function createTemplateRegistry(repo) {
|
|
989
|
+
return {
|
|
990
|
+
async resolve(name) {
|
|
991
|
+
if (!repo) {
|
|
992
|
+
return err(
|
|
993
|
+
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
994
|
+
reachM("reach_templateNotFound", {
|
|
995
|
+
params: { template: name }
|
|
996
|
+
})
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
const dbResult = await repo.findByName(name);
|
|
1000
|
+
if (!dbResult.success) {
|
|
1001
|
+
return dbResult;
|
|
1002
|
+
}
|
|
1003
|
+
if (dbResult.data) {
|
|
1004
|
+
return ok({
|
|
1005
|
+
name: dbResult.data.name,
|
|
1006
|
+
provider: dbResult.data.provider,
|
|
1007
|
+
subject: dbResult.data.subject ?? void 0,
|
|
1008
|
+
body: dbResult.data.body
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
return err(
|
|
1012
|
+
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
1013
|
+
reachM("reach_templateNotFound", {
|
|
1014
|
+
params: { template: name }
|
|
1015
|
+
})
|
|
1016
|
+
);
|
|
1017
|
+
},
|
|
1018
|
+
async save(template) {
|
|
1019
|
+
if (!repo) {
|
|
1020
|
+
return noDbError("reach_templateSaveFailed");
|
|
1021
|
+
}
|
|
1022
|
+
return repo.upsert(template);
|
|
1023
|
+
},
|
|
1024
|
+
async saveBatch(templates) {
|
|
1025
|
+
if (!repo) {
|
|
1026
|
+
return noDbError("reach_templateSaveFailed");
|
|
1027
|
+
}
|
|
1028
|
+
const results = await Promise.all(templates.map((t) => repo.upsert(t)));
|
|
1029
|
+
const failed = results.find((r) => !r.success);
|
|
1030
|
+
if (failed) {
|
|
1031
|
+
return failed;
|
|
1032
|
+
}
|
|
1033
|
+
return ok(void 0);
|
|
1034
|
+
},
|
|
1035
|
+
async remove(name) {
|
|
1036
|
+
if (!repo) {
|
|
1037
|
+
return noDbError("reach_templateDeleteFailed");
|
|
1038
|
+
}
|
|
1039
|
+
return repo.deleteByName(name);
|
|
1040
|
+
},
|
|
1041
|
+
async list() {
|
|
1042
|
+
if (!repo) {
|
|
1043
|
+
return ok([]);
|
|
1044
|
+
}
|
|
1045
|
+
return repo.listTemplates();
|
|
1046
|
+
},
|
|
1047
|
+
async render(name, vars) {
|
|
1048
|
+
const resolved = await this.resolve(name);
|
|
1049
|
+
if (!resolved.success) {
|
|
1050
|
+
return resolved;
|
|
1051
|
+
}
|
|
1052
|
+
return renderTemplate(resolved.data, vars);
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
789
1056
|
|
|
790
1057
|
// src/reach-send.ts
|
|
791
1058
|
var logger6 = core.logger.child({ module: "reach", scope: "send" });
|
|
@@ -827,14 +1094,16 @@ async function preprocessMessage(message, templateRegistry2) {
|
|
|
827
1094
|
if (!message.template) {
|
|
828
1095
|
return ok(message);
|
|
829
1096
|
}
|
|
830
|
-
const
|
|
1097
|
+
const template = await templateRegistry2.resolve(message.template);
|
|
1098
|
+
if (!template.success)
|
|
1099
|
+
return template;
|
|
1100
|
+
const rendered = renderTemplate(template.data, message.vars ?? {});
|
|
831
1101
|
if (!rendered.success) {
|
|
832
1102
|
return rendered;
|
|
833
1103
|
}
|
|
834
|
-
const template = await templateRegistry2.resolve(message.template);
|
|
835
1104
|
const processed = {
|
|
836
1105
|
...message,
|
|
837
|
-
provider: message.provider ||
|
|
1106
|
+
provider: message.provider || template.data.provider || "",
|
|
838
1107
|
subject: message.subject ?? rendered.data.subject,
|
|
839
1108
|
body: message.body ?? rendered.data.body
|
|
840
1109
|
};
|
|
@@ -998,7 +1267,9 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
|
|
|
998
1267
|
if (!message.to) {
|
|
999
1268
|
return err(
|
|
1000
1269
|
HaiReachError.INVALID_RECIPIENT,
|
|
1001
|
-
reachM("reach_invalidRecipient", {
|
|
1270
|
+
reachM("reach_invalidRecipient", {
|
|
1271
|
+
params: { recipient: "" }
|
|
1272
|
+
})
|
|
1002
1273
|
);
|
|
1003
1274
|
}
|
|
1004
1275
|
const preprocessed = await preprocessMessage(message, templateRegistry2);
|
|
@@ -1015,7 +1286,9 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
|
|
|
1015
1286
|
if (!providers2.has(providerName)) {
|
|
1016
1287
|
return err(
|
|
1017
1288
|
HaiReachError.PROVIDER_NOT_FOUND,
|
|
1018
|
-
reachM("reach_providerNotFound", {
|
|
1289
|
+
reachM("reach_providerNotFound", {
|
|
1290
|
+
params: { provider: providerName }
|
|
1291
|
+
})
|
|
1019
1292
|
);
|
|
1020
1293
|
}
|
|
1021
1294
|
if (preprocessed.data.sensitive)
|
|
@@ -1069,202 +1342,130 @@ async function executeSend(message, providers2, templateRegistry2, dndConfig2, r
|
|
|
1069
1342
|
}
|
|
1070
1343
|
return result;
|
|
1071
1344
|
}
|
|
1072
|
-
function renderString(template, vars) {
|
|
1073
|
-
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
1074
|
-
return Object.hasOwn(vars, key) ? vars[key] : match;
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
function renderTemplate(template, vars) {
|
|
1078
|
-
try {
|
|
1079
|
-
const rendered = {
|
|
1080
|
-
subject: template.subject ? renderString(template.subject, vars) : void 0,
|
|
1081
|
-
body: renderString(template.body, vars)
|
|
1082
|
-
};
|
|
1083
|
-
return ok(rendered);
|
|
1084
|
-
} catch (error) {
|
|
1085
|
-
return err(
|
|
1086
|
-
HaiReachError.TEMPLATE_RENDER_FAILED,
|
|
1087
|
-
reachM("reach_templateRenderFailed", {
|
|
1088
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
1089
|
-
}),
|
|
1090
|
-
error
|
|
1091
|
-
);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
function noDbError(msgKey) {
|
|
1095
|
-
return err(
|
|
1096
|
-
HaiReachError.SEND_FAILED,
|
|
1097
|
-
reachM(msgKey, { params: { error: "database not available" } })
|
|
1098
|
-
);
|
|
1099
|
-
}
|
|
1100
|
-
function createTemplateRegistry(repo) {
|
|
1101
|
-
return {
|
|
1102
|
-
async resolve(name) {
|
|
1103
|
-
if (!repo) {
|
|
1104
|
-
return err(
|
|
1105
|
-
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
1106
|
-
reachM("reach_templateNotFound", { params: { template: name } })
|
|
1107
|
-
);
|
|
1108
|
-
}
|
|
1109
|
-
const dbResult = await repo.findByName(name);
|
|
1110
|
-
if (!dbResult.success) {
|
|
1111
|
-
return dbResult;
|
|
1112
|
-
}
|
|
1113
|
-
if (dbResult.data) {
|
|
1114
|
-
return ok({
|
|
1115
|
-
name: dbResult.data.name,
|
|
1116
|
-
provider: dbResult.data.provider,
|
|
1117
|
-
subject: dbResult.data.subject ?? void 0,
|
|
1118
|
-
body: dbResult.data.body
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
return err(
|
|
1122
|
-
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
1123
|
-
reachM("reach_templateNotFound", { params: { template: name } })
|
|
1124
|
-
);
|
|
1125
|
-
},
|
|
1126
|
-
async save(template) {
|
|
1127
|
-
if (!repo) {
|
|
1128
|
-
return noDbError("reach_templateSaveFailed");
|
|
1129
|
-
}
|
|
1130
|
-
return repo.upsert(template);
|
|
1131
|
-
},
|
|
1132
|
-
async saveBatch(templates) {
|
|
1133
|
-
if (!repo) {
|
|
1134
|
-
return noDbError("reach_templateSaveFailed");
|
|
1135
|
-
}
|
|
1136
|
-
const results = await Promise.all(templates.map((t) => repo.upsert(t)));
|
|
1137
|
-
const failed = results.find((r) => !r.success);
|
|
1138
|
-
if (failed) {
|
|
1139
|
-
return failed;
|
|
1140
|
-
}
|
|
1141
|
-
return ok(void 0);
|
|
1142
|
-
},
|
|
1143
|
-
async remove(name) {
|
|
1144
|
-
if (!repo) {
|
|
1145
|
-
return noDbError("reach_templateDeleteFailed");
|
|
1146
|
-
}
|
|
1147
|
-
return repo.deleteByName(name);
|
|
1148
|
-
},
|
|
1149
|
-
async list() {
|
|
1150
|
-
if (!repo) {
|
|
1151
|
-
return ok([]);
|
|
1152
|
-
}
|
|
1153
|
-
return repo.listTemplates();
|
|
1154
|
-
},
|
|
1155
|
-
async render(name, vars) {
|
|
1156
|
-
const resolved = await this.resolve(name);
|
|
1157
|
-
if (!resolved.success) {
|
|
1158
|
-
return resolved;
|
|
1159
|
-
}
|
|
1160
|
-
return renderTemplate(resolved.data, vars);
|
|
1161
|
-
}
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
1345
|
function migrateReach(options = {}, db = reldb) {
|
|
1165
|
-
const definitions = {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
const
|
|
1181
|
-
|
|
1182
|
-
|
|
1346
|
+
const definitions = {
|
|
1347
|
+
queue_id: db.config?.type === "mysql" ? "VARCHAR(255)" : "TEXT",
|
|
1348
|
+
retry_safe: "BOOLEAN NOT NULL DEFAULT FALSE",
|
|
1349
|
+
attempt_count: "BIGINT NOT NULL DEFAULT 0",
|
|
1350
|
+
max_attempts: "BIGINT NOT NULL DEFAULT 5",
|
|
1351
|
+
next_attempt_at: "BIGINT NOT NULL DEFAULT 0",
|
|
1352
|
+
retry_delay_ms: "BIGINT NOT NULL DEFAULT 60000",
|
|
1353
|
+
max_retry_delay_ms: "BIGINT NOT NULL DEFAULT 3600000",
|
|
1354
|
+
last_failure: "TEXT"
|
|
1355
|
+
};
|
|
1356
|
+
const steps = [
|
|
1357
|
+
{
|
|
1358
|
+
version: 1,
|
|
1359
|
+
name: "durable-delivery-ledger",
|
|
1360
|
+
async needed(sql) {
|
|
1361
|
+
const found = await db.schemaColumns("hai_reach_send_log", sql);
|
|
1362
|
+
return found.success ? ok(found.data.length > 0 && Object.keys(definitions).some((name) => !found.data.includes(name))) : found;
|
|
1363
|
+
},
|
|
1364
|
+
async apply(sql) {
|
|
1365
|
+
const found = await db.schemaColumns("hai_reach_send_log", sql);
|
|
1366
|
+
if (!found.success)
|
|
1367
|
+
return found;
|
|
1368
|
+
for (const [name, definition] of Object.entries(definitions)) {
|
|
1369
|
+
if (found.data.includes(name))
|
|
1370
|
+
continue;
|
|
1371
|
+
const added = await sql.execute(`ALTER TABLE hai_reach_send_log ADD COLUMN ${name} ${definition}`);
|
|
1372
|
+
if (!added.success)
|
|
1373
|
+
return added;
|
|
1374
|
+
}
|
|
1375
|
+
return ok(void 0);
|
|
1183
1376
|
}
|
|
1184
|
-
return ok(void 0);
|
|
1185
|
-
}
|
|
1186
|
-
}, {
|
|
1187
|
-
version: 2,
|
|
1188
|
-
name: "legacy-delivery-isolation",
|
|
1189
|
-
async needed(sql) {
|
|
1190
|
-
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1191
|
-
if (!found.success || !found.data.length)
|
|
1192
|
-
return found.success ? ok(false) : found;
|
|
1193
|
-
if (!found.data.includes("queue_id"))
|
|
1194
|
-
return ok(true);
|
|
1195
|
-
const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE queue_id IS NULL AND status IN ('pending','processing','unknown') LIMIT 1");
|
|
1196
|
-
return row.success ? ok(Boolean(row.data)) : row;
|
|
1197
1377
|
},
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1378
|
+
{
|
|
1379
|
+
version: 2,
|
|
1380
|
+
name: "legacy-delivery-isolation",
|
|
1381
|
+
async needed(sql) {
|
|
1382
|
+
const found = await db.schemaColumns("hai_reach_send_log", sql);
|
|
1383
|
+
if (!found.success || !found.data.length) {
|
|
1384
|
+
return found.success ? ok(false) : found;
|
|
1385
|
+
}
|
|
1386
|
+
if (!found.data.includes("queue_id"))
|
|
1387
|
+
return ok(true);
|
|
1388
|
+
const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE queue_id IS NULL AND status IN ('pending','processing','unknown') LIMIT 1");
|
|
1389
|
+
return row.success ? ok(Boolean(row.data)) : row;
|
|
1390
|
+
},
|
|
1391
|
+
async apply(sql) {
|
|
1392
|
+
const isolated = await sql.execute("UPDATE hai_reach_send_log SET status='unknown',processing_owner=NULL,locked_until=NULL WHERE status='processing' AND queue_id IS NULL");
|
|
1393
|
+
if (!isolated.success)
|
|
1394
|
+
return isolated;
|
|
1395
|
+
while (true) {
|
|
1396
|
+
const rows = await sql.query("SELECT id FROM hai_reach_send_log WHERE queue_id IS NULL AND status IN ('pending','unknown') ORDER BY id LIMIT 100");
|
|
1397
|
+
if (!rows.success)
|
|
1398
|
+
return rows;
|
|
1399
|
+
for (const row of rows.data) {
|
|
1400
|
+
const saved = await sql.execute("UPDATE hai_reach_send_log SET queue_id=? WHERE id=? AND queue_id IS NULL", [
|
|
1401
|
+
randomUUID(),
|
|
1402
|
+
row.id
|
|
1403
|
+
]);
|
|
1404
|
+
if (!saved.success)
|
|
1405
|
+
return saved;
|
|
1406
|
+
}
|
|
1407
|
+
if (rows.data.length < 100)
|
|
1408
|
+
return ok(void 0);
|
|
1210
1409
|
}
|
|
1211
|
-
if (rows.data.length < 100)
|
|
1212
|
-
return ok(void 0);
|
|
1213
1410
|
}
|
|
1214
|
-
}
|
|
1215
|
-
}, {
|
|
1216
|
-
version: 3,
|
|
1217
|
-
name: "credential-payload-retirement",
|
|
1218
|
-
async needed(sql) {
|
|
1219
|
-
const found = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1220
|
-
if (!found.success || !found.data.length)
|
|
1221
|
-
return found.success ? ok(false) : found;
|
|
1222
|
-
const ledger = await schemaColumns(db, "hai_schema_migrations", sql);
|
|
1223
|
-
if (!ledger.success)
|
|
1224
|
-
return ledger;
|
|
1225
|
-
const version = ledger.data.length ? await sql.get("SELECT version FROM hai_schema_migrations WHERE module=?", ["reach"]) : ok(null);
|
|
1226
|
-
if (!version.success)
|
|
1227
|
-
return version;
|
|
1228
|
-
if (Number(version.data?.version ?? 0) >= 3)
|
|
1229
|
-
return ok(false);
|
|
1230
|
-
const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE subject IS NOT NULL OR body IS NOT NULL OR template IS NOT NULL OR vars_json IS NOT NULL OR extra_json IS NOT NULL LIMIT 1");
|
|
1231
|
-
return row.success ? ok(Boolean(row.data)) : row;
|
|
1232
1411
|
},
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
const columns = await schemaColumns(db, "hai_reach_send_log", sql);
|
|
1241
|
-
if (!columns.success || !columns.data.length)
|
|
1242
|
-
return columns.success ? ok(false) : columns;
|
|
1243
|
-
if (!columns.data.includes("queue_id"))
|
|
1244
|
-
return ok(true);
|
|
1245
|
-
if (db.config?.type === "sqlite") {
|
|
1246
|
-
const indexes = await sql.query("PRAGMA index_list(hai_reach_send_log)");
|
|
1247
|
-
if (!indexes.success)
|
|
1248
|
-
return indexes;
|
|
1249
|
-
for (const index of indexes.data) {
|
|
1250
|
-
if (!index.unique || !/^\w+$/.test(index.name))
|
|
1251
|
-
continue;
|
|
1252
|
-
const fields = await sql.query(`PRAGMA index_info(${index.name})`);
|
|
1253
|
-
if (!fields.success)
|
|
1254
|
-
return fields;
|
|
1255
|
-
if (fields.data.length === 1 && fields.data[0]?.name === "queue_id")
|
|
1256
|
-
return ok(false);
|
|
1412
|
+
{
|
|
1413
|
+
version: 3,
|
|
1414
|
+
name: "credential-payload-retirement",
|
|
1415
|
+
async needed(sql) {
|
|
1416
|
+
const found = await db.schemaColumns("hai_reach_send_log", sql);
|
|
1417
|
+
if (!found.success || !found.data.length) {
|
|
1418
|
+
return found.success ? ok(false) : found;
|
|
1257
1419
|
}
|
|
1258
|
-
|
|
1420
|
+
const ledger = await db.schemaColumns("hai_schema_migrations", sql);
|
|
1421
|
+
if (!ledger.success)
|
|
1422
|
+
return ledger;
|
|
1423
|
+
const version = ledger.data.length ? await sql.get("SELECT version FROM hai_schema_migrations WHERE module=?", ["reach"]) : ok(null);
|
|
1424
|
+
if (!version.success)
|
|
1425
|
+
return version;
|
|
1426
|
+
if (Number(version.data?.version ?? 0) >= 3)
|
|
1427
|
+
return ok(false);
|
|
1428
|
+
const row = await sql.get("SELECT id FROM hai_reach_send_log WHERE subject IS NOT NULL OR body IS NOT NULL OR template IS NOT NULL OR vars_json IS NOT NULL OR extra_json IS NOT NULL LIMIT 1");
|
|
1429
|
+
return row.success ? ok(Boolean(row.data)) : row;
|
|
1430
|
+
},
|
|
1431
|
+
async apply(sql) {
|
|
1432
|
+
return sql.execute("UPDATE hai_reach_send_log SET body=NULL,subject=NULL,vars_json=NULL,extra_json=NULL,template=NULL,status=CASE WHEN status IN ('pending','processing') THEN 'discarded' WHEN status='sending' THEN 'unknown' ELSE status END,processing_owner=NULL,locked_until=NULL WHERE subject IS NOT NULL OR body IS NOT NULL OR template IS NOT NULL OR vars_json IS NOT NULL OR extra_json IS NOT NULL");
|
|
1259
1433
|
}
|
|
1260
|
-
const found = db.config?.type === "mysql" ? await sql.get("SELECT index_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND non_unique=0 GROUP BY index_name HAVING COUNT(*)=1 AND MAX(column_name)=?", ["hai_reach_send_log", "queue_id"]) : await sql.get("SELECT i.indexrelid FROM pg_index i JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=ANY(i.indkey) WHERE t.relname=? AND n.nspname=current_schema() AND i.indisunique AND i.indnatts=1 AND a.attname=?", ["hai_reach_send_log", "queue_id"]);
|
|
1261
|
-
return found.success ? ok(!found.data) : found;
|
|
1262
1434
|
},
|
|
1263
|
-
|
|
1264
|
-
|
|
1435
|
+
{
|
|
1436
|
+
version: 4,
|
|
1437
|
+
name: "unique-operation-receipts",
|
|
1438
|
+
async needed(sql) {
|
|
1439
|
+
const columns = await db.schemaColumns("hai_reach_send_log", sql);
|
|
1440
|
+
if (!columns.success || !columns.data.length) {
|
|
1441
|
+
return columns.success ? ok(false) : columns;
|
|
1442
|
+
}
|
|
1443
|
+
if (!columns.data.includes("queue_id"))
|
|
1444
|
+
return ok(true);
|
|
1445
|
+
if (db.config?.type === "sqlite") {
|
|
1446
|
+
const indexes = await sql.query("PRAGMA index_list(hai_reach_send_log)");
|
|
1447
|
+
if (!indexes.success)
|
|
1448
|
+
return indexes;
|
|
1449
|
+
for (const index of indexes.data) {
|
|
1450
|
+
if (!index.unique || !/^\w+$/.test(index.name))
|
|
1451
|
+
continue;
|
|
1452
|
+
const fields = await sql.query(`PRAGMA index_info(${index.name})`);
|
|
1453
|
+
if (!fields.success)
|
|
1454
|
+
return fields;
|
|
1455
|
+
if (fields.data.length === 1 && fields.data[0]?.name === "queue_id")
|
|
1456
|
+
return ok(false);
|
|
1457
|
+
}
|
|
1458
|
+
return ok(true);
|
|
1459
|
+
}
|
|
1460
|
+
const found = db.config?.type === "mysql" ? await sql.get("SELECT index_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND non_unique=0 GROUP BY index_name HAVING COUNT(*)=1 AND MAX(column_name)=?", ["hai_reach_send_log", "queue_id"]) : await sql.get("SELECT i.indexrelid FROM pg_index i JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=ANY(i.indkey) WHERE t.relname=? AND n.nspname=current_schema() AND i.indisunique AND i.indnatts=1 AND a.attname=?", ["hai_reach_send_log", "queue_id"]);
|
|
1461
|
+
return found.success ? ok(!found.data) : found;
|
|
1462
|
+
},
|
|
1463
|
+
async apply(sql) {
|
|
1464
|
+
return sql.execute("CREATE UNIQUE INDEX uq_hai_reach_queue_v1 ON hai_reach_send_log(queue_id)");
|
|
1465
|
+
}
|
|
1265
1466
|
}
|
|
1266
|
-
|
|
1267
|
-
return migrateSchema(
|
|
1467
|
+
];
|
|
1468
|
+
return db.migrateSchema("reach", steps, options);
|
|
1268
1469
|
}
|
|
1269
1470
|
|
|
1270
1471
|
// src/repositories/reach-repository-send-log.ts
|
|
@@ -1275,25 +1476,47 @@ function claimableWhere(clock) {
|
|
|
1275
1476
|
return `(attempt_count < max_attempts AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND updated_at < ? AND (locked_until IS NULL OR locked_until <= ${clock})) OR (status = ? AND updated_at < ? AND retry_safe = TRUE AND locked_until <= ${clock})))`;
|
|
1276
1477
|
}
|
|
1277
1478
|
function claimableParams(eligibleBefore) {
|
|
1278
|
-
return [
|
|
1479
|
+
return [
|
|
1480
|
+
"pending",
|
|
1481
|
+
eligibleBefore,
|
|
1482
|
+
"processing",
|
|
1483
|
+
eligibleBefore,
|
|
1484
|
+
"sending",
|
|
1485
|
+
eligibleBefore
|
|
1486
|
+
];
|
|
1279
1487
|
}
|
|
1280
1488
|
function buildSendLogError(detail, cause) {
|
|
1281
1489
|
return err(
|
|
1282
1490
|
HaiReachError.SEND_FAILED,
|
|
1283
|
-
reachM("reach_sendFailed", {
|
|
1491
|
+
reachM("reach_sendFailed", {
|
|
1492
|
+
params: { error: detail }
|
|
1493
|
+
}),
|
|
1284
1494
|
cause
|
|
1285
1495
|
);
|
|
1286
1496
|
}
|
|
1287
1497
|
function normalizeSendLog(row) {
|
|
1288
1498
|
const normalized = { ...row };
|
|
1289
|
-
for (const field of [
|
|
1499
|
+
for (const field of [
|
|
1500
|
+
"id",
|
|
1501
|
+
"createdAt",
|
|
1502
|
+
"updatedAt",
|
|
1503
|
+
"lockedUntil",
|
|
1504
|
+
"nextAttemptAt",
|
|
1505
|
+
"attemptCount",
|
|
1506
|
+
"maxAttempts",
|
|
1507
|
+
"retryDelayMs",
|
|
1508
|
+
"maxRetryDelayMs"
|
|
1509
|
+
]) {
|
|
1290
1510
|
const value = row[field];
|
|
1291
1511
|
if (value === void 0 && (field === "updatedAt" || field === "lockedUntil"))
|
|
1292
1512
|
continue;
|
|
1293
1513
|
const validType = typeof value === "number" || typeof value === "bigint" || typeof value === "string" && /^\d+$/.test(value);
|
|
1294
1514
|
const integer = validType ? Number(value) : Number.NaN;
|
|
1295
|
-
if (!Number.isSafeInteger(integer) || integer < 0)
|
|
1296
|
-
return buildSendLogError(reachM("reach_invalidStoredInteger", {
|
|
1515
|
+
if (!Number.isSafeInteger(integer) || integer < 0) {
|
|
1516
|
+
return buildSendLogError(reachM("reach_invalidStoredInteger", {
|
|
1517
|
+
params: { field }
|
|
1518
|
+
}));
|
|
1519
|
+
}
|
|
1297
1520
|
Object.assign(normalized, { [field]: integer });
|
|
1298
1521
|
}
|
|
1299
1522
|
return ok(normalized);
|
|
@@ -1477,7 +1700,9 @@ async function createSendLogRepository(db) {
|
|
|
1477
1700
|
if (!initResult.success) {
|
|
1478
1701
|
return err(
|
|
1479
1702
|
HaiReachError.SEND_FAILED,
|
|
1480
|
-
reachM("reach_sendFailed", {
|
|
1703
|
+
reachM("reach_sendFailed", {
|
|
1704
|
+
params: { error: initResult.error.message }
|
|
1705
|
+
}),
|
|
1481
1706
|
initResult.error
|
|
1482
1707
|
);
|
|
1483
1708
|
}
|
|
@@ -1501,7 +1726,12 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1501
1726
|
async settleSensitive(queueId, status, messageId) {
|
|
1502
1727
|
const settled = await this.sql().execute(
|
|
1503
1728
|
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, locked_until = NULL, updated_at = ? WHERE queue_id = ? AND status = 'sending' AND body IS NULL AND vars_json IS NULL AND retry_safe = FALSE`,
|
|
1504
|
-
[
|
|
1729
|
+
[
|
|
1730
|
+
status,
|
|
1731
|
+
messageId ?? null,
|
|
1732
|
+
Date.now(),
|
|
1733
|
+
queueId
|
|
1734
|
+
]
|
|
1505
1735
|
);
|
|
1506
1736
|
if (!settled.success)
|
|
1507
1737
|
return settled;
|
|
@@ -1543,7 +1773,11 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1543
1773
|
}
|
|
1544
1774
|
/** 获取所有待发送记录 */
|
|
1545
1775
|
async findPending(tx) {
|
|
1546
|
-
const result = await this.findAll({
|
|
1776
|
+
const result = await this.findAll({
|
|
1777
|
+
where: "status = ?",
|
|
1778
|
+
params: ["pending"],
|
|
1779
|
+
orderBy: "created_at ASC"
|
|
1780
|
+
}, tx);
|
|
1547
1781
|
if (!result.success) {
|
|
1548
1782
|
return result;
|
|
1549
1783
|
}
|
|
@@ -1565,7 +1799,10 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1565
1799
|
ORDER BY created_at ASC
|
|
1566
1800
|
LIMIT ?
|
|
1567
1801
|
FOR UPDATE SKIP LOCKED`,
|
|
1568
|
-
[
|
|
1802
|
+
[
|
|
1803
|
+
...claimableParams(eligibleBefore),
|
|
1804
|
+
limit
|
|
1805
|
+
]
|
|
1569
1806
|
);
|
|
1570
1807
|
if (!lockedRowsResult.success) {
|
|
1571
1808
|
throw lockedRowsResult.error;
|
|
@@ -1585,7 +1822,12 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1585
1822
|
`UPDATE ${TABLE_NAME}
|
|
1586
1823
|
SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
|
|
1587
1824
|
WHERE id IN (${placeholders})`,
|
|
1588
|
-
[
|
|
1825
|
+
[
|
|
1826
|
+
"processing",
|
|
1827
|
+
owner,
|
|
1828
|
+
lockMs,
|
|
1829
|
+
...claimedIds
|
|
1830
|
+
]
|
|
1589
1831
|
);
|
|
1590
1832
|
if (!updateResult.success) {
|
|
1591
1833
|
throw updateResult.error;
|
|
@@ -1615,7 +1857,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1615
1857
|
`UPDATE ${TABLE_NAME}
|
|
1616
1858
|
SET status = ?, processing_owner = ?, locked_until = ${clock} + ?, updated_at = ${clock}, attempt_count = attempt_count + 1
|
|
1617
1859
|
WHERE id = ? AND ${claimableWhere(clock)}`,
|
|
1618
|
-
[
|
|
1860
|
+
[
|
|
1861
|
+
"processing",
|
|
1862
|
+
owner,
|
|
1863
|
+
lockMs,
|
|
1864
|
+
row.id,
|
|
1865
|
+
...claimableParams(eligibleBefore)
|
|
1866
|
+
]
|
|
1619
1867
|
);
|
|
1620
1868
|
if (!updateResult.success) {
|
|
1621
1869
|
throw updateResult.error;
|
|
@@ -1669,7 +1917,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1669
1917
|
WHERE attempt_count >= max_attempts
|
|
1670
1918
|
AND (status = ? OR (status = ? AND (locked_until IS NULL OR locked_until <= ${clock}))
|
|
1671
1919
|
OR (status = ? AND retry_safe = TRUE AND locked_until <= ${clock}))`,
|
|
1672
|
-
[
|
|
1920
|
+
[
|
|
1921
|
+
"dead",
|
|
1922
|
+
"attempts_exhausted",
|
|
1923
|
+
"pending",
|
|
1924
|
+
"processing",
|
|
1925
|
+
"sending"
|
|
1926
|
+
]
|
|
1673
1927
|
);
|
|
1674
1928
|
if (!exhausted.success)
|
|
1675
1929
|
throw exhausted.error;
|
|
@@ -1709,7 +1963,16 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1709
1963
|
SET status = ?, last_failure = ?, next_attempt_at = ${dead ? "0" : `${clock} + ?`}, processing_owner = NULL, locked_until = NULL, updated_at = ${clock}
|
|
1710
1964
|
WHERE id = ? AND processing_owner = ? AND locked_until > ${clock} AND attempt_count = ?
|
|
1711
1965
|
AND (status = ? OR (status = ? AND retry_safe = TRUE))`,
|
|
1712
|
-
[
|
|
1966
|
+
[
|
|
1967
|
+
dead ? "dead" : "pending",
|
|
1968
|
+
reason,
|
|
1969
|
+
...dead ? [] : [delay],
|
|
1970
|
+
row.id,
|
|
1971
|
+
owner,
|
|
1972
|
+
row.attemptCount,
|
|
1973
|
+
"processing",
|
|
1974
|
+
"sending"
|
|
1975
|
+
]
|
|
1713
1976
|
);
|
|
1714
1977
|
if (!result.success) {
|
|
1715
1978
|
return buildSendLogError(result.error.message, result.error);
|
|
@@ -1725,7 +1988,12 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1725
1988
|
const result = await this.sql().execute(
|
|
1726
1989
|
`UPDATE ${TABLE_NAME} SET status = ?, attempt_count = 0, next_attempt_at = ${clock}, last_failure = NULL, updated_at = CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END
|
|
1727
1990
|
WHERE queue_id = ? AND status = ? AND updated_at = ?`,
|
|
1728
|
-
[
|
|
1991
|
+
[
|
|
1992
|
+
"pending",
|
|
1993
|
+
queueId,
|
|
1994
|
+
"dead",
|
|
1995
|
+
expectedUpdatedAt
|
|
1996
|
+
]
|
|
1729
1997
|
);
|
|
1730
1998
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_deadRequeueFailed"));
|
|
1731
1999
|
}
|
|
@@ -1736,7 +2004,14 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1736
2004
|
const result = await this.sql().execute(
|
|
1737
2005
|
`UPDATE ${TABLE_NAME} SET status = ?, retry_safe = ?, locked_until = ${clock} + ?, updated_at = ${clock}
|
|
1738
2006
|
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
|
|
1739
|
-
[
|
|
2007
|
+
[
|
|
2008
|
+
"sending",
|
|
2009
|
+
flag,
|
|
2010
|
+
lockMs,
|
|
2011
|
+
id,
|
|
2012
|
+
"processing",
|
|
2013
|
+
owner
|
|
2014
|
+
]
|
|
1740
2015
|
);
|
|
1741
2016
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1742
2017
|
}
|
|
@@ -1746,7 +2021,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1746
2021
|
const result = await this.sql().execute(
|
|
1747
2022
|
`UPDATE ${TABLE_NAME} SET locked_until = ${clock} + ?, updated_at = ${clock}
|
|
1748
2023
|
WHERE id = ? AND processing_owner = ? AND status IN (?, ?) AND locked_until > ${clock}`,
|
|
1749
|
-
[
|
|
2024
|
+
[
|
|
2025
|
+
lockMs,
|
|
2026
|
+
id,
|
|
2027
|
+
owner,
|
|
2028
|
+
"processing",
|
|
2029
|
+
"sending"
|
|
2030
|
+
]
|
|
1750
2031
|
);
|
|
1751
2032
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1752
2033
|
}
|
|
@@ -1755,7 +2036,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1755
2036
|
const result = await this.sql().execute(
|
|
1756
2037
|
`UPDATE ${TABLE_NAME} SET status = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${this.clockSql()}
|
|
1757
2038
|
WHERE id = ? AND processing_owner = ? AND status IN (?, ?)`,
|
|
1758
|
-
[
|
|
2039
|
+
[
|
|
2040
|
+
"unknown",
|
|
2041
|
+
id,
|
|
2042
|
+
owner,
|
|
2043
|
+
"processing",
|
|
2044
|
+
"sending"
|
|
2045
|
+
]
|
|
1759
2046
|
);
|
|
1760
2047
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_claimLost"));
|
|
1761
2048
|
}
|
|
@@ -1763,7 +2050,12 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1763
2050
|
async resolveUnknown(queueId, resolution) {
|
|
1764
2051
|
const result = await this.sql().execute(
|
|
1765
2052
|
`UPDATE ${TABLE_NAME} SET status = ?, message_id = ?, next_attempt_at = 0, updated_at = ${this.clockSql()} WHERE queue_id = ? AND status = ?`,
|
|
1766
|
-
[
|
|
2053
|
+
[
|
|
2054
|
+
resolution.status,
|
|
2055
|
+
resolution.messageId ?? null,
|
|
2056
|
+
queueId,
|
|
2057
|
+
"unknown"
|
|
2058
|
+
]
|
|
1767
2059
|
);
|
|
1768
2060
|
return result.success && result.data.changes === 1 ? ok(void 0) : buildSendLogError(reachM("reach_unknownResolutionFailed"));
|
|
1769
2061
|
}
|
|
@@ -1774,7 +2066,13 @@ var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
|
1774
2066
|
`UPDATE ${TABLE_NAME}
|
|
1775
2067
|
SET status = ?, message_id = ?, processing_owner = NULL, locked_until = NULL, next_attempt_at = 0, updated_at = ${clock}
|
|
1776
2068
|
WHERE id = ? AND status = ? AND processing_owner = ? AND locked_until > ${clock}`,
|
|
1777
|
-
[
|
|
2069
|
+
[
|
|
2070
|
+
"sent",
|
|
2071
|
+
messageId ?? null,
|
|
2072
|
+
id,
|
|
2073
|
+
"sending",
|
|
2074
|
+
owner
|
|
2075
|
+
]
|
|
1778
2076
|
);
|
|
1779
2077
|
if (!result.success) {
|
|
1780
2078
|
return buildSendLogError(result.error.message, result.error);
|
|
@@ -1858,7 +2156,9 @@ async function createTemplateRepository(db) {
|
|
|
1858
2156
|
if (!initResult.success) {
|
|
1859
2157
|
return err(
|
|
1860
2158
|
HaiReachError.SEND_FAILED,
|
|
1861
|
-
reachM("reach_templateDbInitFailed", {
|
|
2159
|
+
reachM("reach_templateDbInitFailed", {
|
|
2160
|
+
params: { error: initResult.error.message }
|
|
2161
|
+
}),
|
|
1862
2162
|
initResult.error
|
|
1863
2163
|
);
|
|
1864
2164
|
}
|
|
@@ -1886,7 +2186,11 @@ var DbTemplateRepository = class extends BaseReldbCrudRepository {
|
|
|
1886
2186
|
}
|
|
1887
2187
|
/** 按名称查找模板 */
|
|
1888
2188
|
async findByName(name, tx) {
|
|
1889
|
-
const result = await this.findAll({
|
|
2189
|
+
const result = await this.findAll({
|
|
2190
|
+
where: "name = ?",
|
|
2191
|
+
params: [name],
|
|
2192
|
+
limit: 1
|
|
2193
|
+
}, tx);
|
|
1890
2194
|
if (!result.success) {
|
|
1891
2195
|
return result;
|
|
1892
2196
|
}
|
|
@@ -1963,16 +2267,17 @@ var currentConfig = null;
|
|
|
1963
2267
|
var dndConfig;
|
|
1964
2268
|
var templateRegistry = createTemplateRegistry();
|
|
1965
2269
|
var sendLogRepo = null;
|
|
1966
|
-
var templateRepo = null;
|
|
1967
2270
|
var initInProgress = false;
|
|
1968
|
-
|
|
2271
|
+
var initCompletion = null;
|
|
2272
|
+
var closeInProgress = null;
|
|
2273
|
+
function createProvider(config, gatewayHook) {
|
|
1969
2274
|
switch (config.type) {
|
|
1970
2275
|
case "console":
|
|
1971
2276
|
return createConsoleProvider();
|
|
1972
2277
|
case "smtp":
|
|
1973
2278
|
return createSmtpProvider();
|
|
1974
2279
|
case "aliyun-sms":
|
|
1975
|
-
return createAliyunSmsProvider();
|
|
2280
|
+
return createAliyunSmsProvider(gatewayHook);
|
|
1976
2281
|
case "api":
|
|
1977
2282
|
return createApiProvider();
|
|
1978
2283
|
}
|
|
@@ -1993,7 +2298,9 @@ async function tryInitSendLogRepo() {
|
|
|
1993
2298
|
}
|
|
1994
2299
|
return result.data;
|
|
1995
2300
|
} catch (error) {
|
|
1996
|
-
logger7.debug("Send log repository not initialized", {
|
|
2301
|
+
logger7.debug("Send log repository not initialized", {
|
|
2302
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2303
|
+
});
|
|
1997
2304
|
return null;
|
|
1998
2305
|
}
|
|
1999
2306
|
}
|
|
@@ -2010,7 +2317,9 @@ async function tryInitTemplateRepo() {
|
|
|
2010
2317
|
}
|
|
2011
2318
|
return result.data;
|
|
2012
2319
|
} catch (error) {
|
|
2013
|
-
logger7.debug("Template repository not initialized", {
|
|
2320
|
+
logger7.debug("Template repository not initialized", {
|
|
2321
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2322
|
+
});
|
|
2014
2323
|
return null;
|
|
2015
2324
|
}
|
|
2016
2325
|
}
|
|
@@ -2036,13 +2345,12 @@ async function doClose() {
|
|
|
2036
2345
|
dndConfig = void 0;
|
|
2037
2346
|
templateRegistry = createTemplateRegistry();
|
|
2038
2347
|
sendLogRepo = null;
|
|
2039
|
-
templateRepo = null;
|
|
2040
2348
|
resetSendLogRepoSingleton();
|
|
2041
2349
|
resetTemplateRepoSingleton();
|
|
2042
2350
|
resetSendState();
|
|
2043
2351
|
logger7.info("Reach module closed");
|
|
2044
2352
|
}
|
|
2045
|
-
async function doInit(config) {
|
|
2353
|
+
async function doInit(config, options) {
|
|
2046
2354
|
if (providers.size > 0) {
|
|
2047
2355
|
logger7.warn("Reach module is already initialized, reinitializing");
|
|
2048
2356
|
await doClose();
|
|
@@ -2053,13 +2361,24 @@ async function doInit(config) {
|
|
|
2053
2361
|
logger7.error("Reach config validation failed", { error: parseResult.error.message });
|
|
2054
2362
|
return err(
|
|
2055
2363
|
HaiReachError.CONFIG_ERROR,
|
|
2056
|
-
reachM("reach_configError", {
|
|
2364
|
+
reachM("reach_configError", {
|
|
2365
|
+
params: { error: parseResult.error.message }
|
|
2366
|
+
}),
|
|
2057
2367
|
parseResult.error
|
|
2058
2368
|
);
|
|
2059
2369
|
}
|
|
2060
2370
|
const parsed = parseResult.data;
|
|
2371
|
+
const gatewayHooks = new Map(Object.entries(options?.gatewayHooks ?? {}));
|
|
2372
|
+
for (const [name, hook] of gatewayHooks) {
|
|
2373
|
+
if (typeof hook !== "function" || !parsed.providers.some((provider) => provider.name === name && provider.type === "aliyun-sms" && provider.gateway))
|
|
2374
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_gatewayHookInvalid"));
|
|
2375
|
+
}
|
|
2061
2376
|
if (core.module.requiresSharedState(process.env) && parsed.providers.some((provider) => provider.type === "console"))
|
|
2062
2377
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_productionProviderRequired"));
|
|
2378
|
+
if (parsed.templates?.length && !reldb.isInitialized)
|
|
2379
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_templatePersistenceRequired"));
|
|
2380
|
+
const newProviders = /* @__PURE__ */ new Map();
|
|
2381
|
+
let published = false;
|
|
2063
2382
|
try {
|
|
2064
2383
|
let requiredQueue = null;
|
|
2065
2384
|
if (parsed.dnd?.enabled && parsed.dnd.strategy === "delay") {
|
|
@@ -2070,9 +2389,9 @@ async function doInit(config) {
|
|
|
2070
2389
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"), queue.error);
|
|
2071
2390
|
requiredQueue = queue.data;
|
|
2072
2391
|
}
|
|
2073
|
-
const newProviders = /* @__PURE__ */ new Map();
|
|
2074
2392
|
for (const providerConfig of parsed.providers) {
|
|
2075
|
-
const provider = createProvider(providerConfig);
|
|
2393
|
+
const provider = createProvider(providerConfig, gatewayHooks.get(providerConfig.name));
|
|
2394
|
+
newProviders.set(providerConfig.name, provider);
|
|
2076
2395
|
const connectResult = await provider.connect(providerConfig);
|
|
2077
2396
|
if (!connectResult.success) {
|
|
2078
2397
|
logger7.error("Provider initialization failed", {
|
|
@@ -2081,30 +2400,27 @@ async function doInit(config) {
|
|
|
2081
2400
|
code: connectResult.error.code,
|
|
2082
2401
|
message: connectResult.error.message
|
|
2083
2402
|
});
|
|
2084
|
-
logger7.info("Rolling back connected providers", { count: newProviders.size });
|
|
2085
|
-
for (const [name, p] of newProviders.entries()) {
|
|
2086
|
-
try {
|
|
2087
|
-
await p.close();
|
|
2088
|
-
} catch (rollbackError) {
|
|
2089
|
-
logger7.error("Provider rollback close failed", { name, error: rollbackError });
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
2403
|
return connectResult;
|
|
2093
2404
|
}
|
|
2094
|
-
newProviders.set(providerConfig.name, provider);
|
|
2095
2405
|
}
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
const saveResult = await templateRegistry.saveBatch(parsed.templates);
|
|
2406
|
+
const candidateTemplateRepo = await tryInitTemplateRepo();
|
|
2407
|
+
if (parsed.templates?.length && !candidateTemplateRepo)
|
|
2408
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_templatePersistenceRequired"));
|
|
2409
|
+
const candidateRegistry = createTemplateRegistry(candidateTemplateRepo);
|
|
2410
|
+
if (parsed.templates?.length) {
|
|
2411
|
+
const saveResult = await candidateRegistry.saveBatch(parsed.templates);
|
|
2103
2412
|
if (!saveResult.success) {
|
|
2104
|
-
logger7.warn("Failed to save config templates to database", {
|
|
2413
|
+
logger7.warn("Failed to save config templates to database", { errorCode: saveResult.error.code });
|
|
2414
|
+
return err(HaiReachError.CONFIG_ERROR, reachM("reach_templatePersistenceRequired"), saveResult.error);
|
|
2105
2415
|
}
|
|
2106
2416
|
}
|
|
2107
|
-
|
|
2417
|
+
const candidateSendLogRepo = requiredQueue ?? await tryInitSendLogRepo();
|
|
2418
|
+
providers = newProviders;
|
|
2419
|
+
currentConfig = parsed;
|
|
2420
|
+
dndConfig = parsed.dnd;
|
|
2421
|
+
templateRegistry = candidateRegistry;
|
|
2422
|
+
sendLogRepo = candidateSendLogRepo;
|
|
2423
|
+
published = true;
|
|
2108
2424
|
if (dndConfig) {
|
|
2109
2425
|
startDndScheduler(dndConfig, providers, sendLogRepo);
|
|
2110
2426
|
}
|
|
@@ -2116,10 +2432,22 @@ async function doInit(config) {
|
|
|
2116
2432
|
return err(
|
|
2117
2433
|
HaiReachError.CONFIG_ERROR,
|
|
2118
2434
|
reachM("reach_initFailed", {
|
|
2119
|
-
params: {
|
|
2435
|
+
params: {
|
|
2436
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2437
|
+
}
|
|
2120
2438
|
}),
|
|
2121
2439
|
error
|
|
2122
2440
|
);
|
|
2441
|
+
} finally {
|
|
2442
|
+
if (!published) {
|
|
2443
|
+
for (const [name, provider] of newProviders) {
|
|
2444
|
+
try {
|
|
2445
|
+
await provider.close();
|
|
2446
|
+
} catch {
|
|
2447
|
+
logger7.error("Candidate provider cleanup failed", { name });
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2123
2451
|
}
|
|
2124
2452
|
}
|
|
2125
2453
|
function queueEntry(row) {
|
|
@@ -2144,7 +2472,11 @@ var reach = {
|
|
|
2144
2472
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_dndPersistenceRequired"));
|
|
2145
2473
|
if (!ReachQueueIdSchema.safeParse(queueId).success)
|
|
2146
2474
|
return err(HaiReachError.CONFIG_ERROR, reachM("reach_queueInputInvalid"));
|
|
2147
|
-
const rows = await sendLogRepo.findAll({
|
|
2475
|
+
const rows = await sendLogRepo.findAll({
|
|
2476
|
+
where: "queue_id = ?",
|
|
2477
|
+
params: [queueId],
|
|
2478
|
+
limit: 1
|
|
2479
|
+
});
|
|
2148
2480
|
if (!rows.success)
|
|
2149
2481
|
return rows;
|
|
2150
2482
|
const row = rows.data[0];
|
|
@@ -2188,19 +2520,23 @@ var reach = {
|
|
|
2188
2520
|
return sendLogRepo.resolveUnknown(queueId, parsed.data);
|
|
2189
2521
|
}
|
|
2190
2522
|
},
|
|
2191
|
-
async init(config) {
|
|
2192
|
-
if (initInProgress) {
|
|
2523
|
+
async init(config, options) {
|
|
2524
|
+
if (initInProgress || closeInProgress) {
|
|
2193
2525
|
logger7.warn("Reach module init already in progress, skipping");
|
|
2194
2526
|
return err(
|
|
2195
2527
|
HaiReachError.CONFIG_ERROR,
|
|
2196
|
-
reachM("reach_configError", {
|
|
2528
|
+
reachM("reach_configError", {
|
|
2529
|
+
params: { error: "init already in progress" }
|
|
2530
|
+
})
|
|
2197
2531
|
);
|
|
2198
2532
|
}
|
|
2199
2533
|
initInProgress = true;
|
|
2200
2534
|
try {
|
|
2201
|
-
|
|
2535
|
+
initCompletion = doInit(config, options);
|
|
2536
|
+
return await initCompletion;
|
|
2202
2537
|
} finally {
|
|
2203
2538
|
initInProgress = false;
|
|
2539
|
+
initCompletion = null;
|
|
2204
2540
|
}
|
|
2205
2541
|
},
|
|
2206
2542
|
async send(message) {
|
|
@@ -2214,13 +2550,40 @@ var reach = {
|
|
|
2214
2550
|
return templateRegistry;
|
|
2215
2551
|
},
|
|
2216
2552
|
get config() {
|
|
2217
|
-
|
|
2553
|
+
if (!currentConfig)
|
|
2554
|
+
return null;
|
|
2555
|
+
const safeProviders = currentConfig.providers.map((provider) => {
|
|
2556
|
+
if (provider.type === "api" && provider.headers) {
|
|
2557
|
+
return {
|
|
2558
|
+
...provider,
|
|
2559
|
+
headers: Object.fromEntries(Object.keys(provider.headers).map((name) => [name, core.sanitize.defaultSensitiveReplacement]))
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
if (provider.type !== "aliyun-sms" || !provider.gateway)
|
|
2563
|
+
return provider;
|
|
2564
|
+
const { url, allowInsecureHttp } = provider.gateway;
|
|
2565
|
+
return {
|
|
2566
|
+
...provider,
|
|
2567
|
+
gateway: { url, allowInsecureHttp }
|
|
2568
|
+
};
|
|
2569
|
+
});
|
|
2570
|
+
return core.sanitize.sanitizeSensitiveFields({ ...currentConfig, providers: safeProviders });
|
|
2218
2571
|
},
|
|
2219
2572
|
get isInitialized() {
|
|
2220
2573
|
return providers.size > 0;
|
|
2221
2574
|
},
|
|
2222
2575
|
async close() {
|
|
2223
|
-
|
|
2576
|
+
if (closeInProgress)
|
|
2577
|
+
return closeInProgress;
|
|
2578
|
+
closeInProgress = (async () => {
|
|
2579
|
+
await initCompletion;
|
|
2580
|
+
await doClose();
|
|
2581
|
+
})();
|
|
2582
|
+
try {
|
|
2583
|
+
await closeInProgress;
|
|
2584
|
+
} finally {
|
|
2585
|
+
closeInProgress = null;
|
|
2586
|
+
}
|
|
2224
2587
|
}
|
|
2225
2588
|
};
|
|
2226
2589
|
|