@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/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, migrateSchema, schemaColumns, BaseReldbCrudRepository } from '@h-ai/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: {error}",
33
- reach_emailSendFailed: "Email send failed: {error}",
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: {error}",
85
- reach_emailSendFailed: "\u30E1\u30FC\u30EB\u306E\u9001\u4FE1\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
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: {error}",
137
- reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25: {error}",
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
- /** 自动领取次数上限(含首次及崩溃恢复,默认 5,范围 1–100)。 */
303
+ /** 每条队列消息最多处理多少次,包含首次发送、失败重试和异常退出后的重新处理;默认 5,范围 1–100。 */
244
304
  maxAttempts: z.number().int().min(1).max(100).default(5),
245
- /** 首次失败后的退避毫秒数(默认 60000,范围 1000–86400000)。 */
305
+ /** 第一次发送失败后,等待多少毫秒再试;默认 60000(1 分钟),范围 1000–86400000。 */
246
306
  retryDelayMs: z.number().int().min(1e3).max(864e5).default(6e4),
247
- /** 指数退避上限毫秒数,不得小于 retryDelayMs(默认 3600000)。 */
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
- function toReachError(error) {
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
- code: HaiReachError.SEND_FAILED.code,
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(/\+/g, "%20").replace(/\*/g, "%2A").replace(/~/g, "%7E");
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 createAliyunSmsProvider() {
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", { params: { type: config.type } })
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
- if (!smsConfig) {
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
- logger.debug("Sending SMS", { to: message.to, templateCode });
485
+ if (!config.gateway)
486
+ logger.debug("Sending SMS", { to: message.to, templateCode });
360
487
  try {
361
488
  const params = {
362
- AccessKeyId: smsConfig.accessKeyId,
489
+ AccessKeyId: config.accessKeyId,
363
490
  Action: "SendSms",
364
491
  Format: "JSON",
365
492
  PhoneNumbers: message.to,
366
- SignName: smsConfig.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, smsConfig.accessKeySecret, "GET");
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
- const url = `https://${smsConfig.endpoint}/?${queryString}`;
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, message: body.Message });
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", { params: { error: body.Message } })
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(error));
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
- code: HaiReachError.SEND_FAILED.code,
551
+ ...HaiReachError.SEND_FAILED,
409
552
  message: reachM("reach_apiSendFailed", {
410
- params: { error: reachM("reach_apiRequestFailed") }
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({ success: true, ...messageId === void 0 ? {} : { messageId } });
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", { params: { type: config.type } })
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", { endpoint: getSafeEndpoint(config.url), method: config.method });
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", { endpoint: getSafeEndpoint(apiConfig.url) });
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", { params: { error: `HTTP ${response.status}` } })
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(error) {
728
+ function toReachError3() {
572
729
  return {
573
- code: HaiReachError.SEND_FAILED.code,
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", { params: { type: config.type } })
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(error));
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(error));
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 { success: false, error: { ...HaiReachError.SEND_FAILED, message: reachM("reach_sensitiveDeliveryUnconfirmed"), ext: { receiptId } } };
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({ where: "queue_id = ?", params: [queueId], limit: 1 });
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 rendered = await templateRegistry2.render(message.template, message.vars ?? {});
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 || (template.success ? template.data.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", { params: { recipient: "" } })
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", { params: { provider: providerName } })
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 = { queue_id: db.config?.type === "mysql" ? "VARCHAR(255)" : "TEXT", retry_safe: "BOOLEAN NOT NULL DEFAULT FALSE", attempt_count: "BIGINT NOT NULL DEFAULT 0", max_attempts: "BIGINT NOT NULL DEFAULT 5", next_attempt_at: "BIGINT NOT NULL DEFAULT 0", retry_delay_ms: "BIGINT NOT NULL DEFAULT 60000", max_retry_delay_ms: "BIGINT NOT NULL DEFAULT 3600000", last_failure: "TEXT" };
1166
- const steps = [{
1167
- version: 1,
1168
- name: "durable-delivery-ledger",
1169
- async needed(sql) {
1170
- const found = await schemaColumns(db, "hai_reach_send_log", sql);
1171
- return found.success ? ok(found.data.length > 0 && Object.keys(definitions).some((name) => !found.data.includes(name))) : found;
1172
- },
1173
- async apply(sql) {
1174
- const found = await schemaColumns(db, "hai_reach_send_log", sql);
1175
- if (!found.success)
1176
- return found;
1177
- for (const [name, definition] of Object.entries(definitions)) {
1178
- if (found.data.includes(name))
1179
- continue;
1180
- const added = await sql.execute(`ALTER TABLE hai_reach_send_log ADD COLUMN ${name} ${definition}`);
1181
- if (!added.success)
1182
- return added;
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
- async apply(sql) {
1199
- 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");
1200
- if (!isolated.success)
1201
- return isolated;
1202
- while (true) {
1203
- 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");
1204
- if (!rows.success)
1205
- return rows;
1206
- for (const row of rows.data) {
1207
- const saved = await sql.execute("UPDATE hai_reach_send_log SET queue_id=? WHERE id=? AND queue_id IS NULL", [randomUUID(), row.id]);
1208
- if (!saved.success)
1209
- return saved;
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
- async apply(sql) {
1234
- 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");
1235
- }
1236
- }, {
1237
- version: 4,
1238
- name: "unique-operation-receipts",
1239
- async needed(sql) {
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
- return ok(true);
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
- async apply(sql) {
1264
- return sql.execute("CREATE UNIQUE INDEX uq_hai_reach_queue_v1 ON hai_reach_send_log(queue_id)");
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(db, "reach", steps, options);
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 ["pending", eligibleBefore, "processing", eligibleBefore, "sending", eligibleBefore];
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", { params: { error: detail } }),
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 ["id", "createdAt", "updatedAt", "lockedUntil", "nextAttemptAt", "attemptCount", "maxAttempts", "retryDelayMs", "maxRetryDelayMs"]) {
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", { params: { field } }));
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", { params: { error: initResult.error.message } }),
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
- [status, messageId ?? null, Date.now(), queueId]
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({ where: "status = ?", params: ["pending"], orderBy: "created_at ASC" }, tx);
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
- [...claimableParams(eligibleBefore), limit]
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
- ["processing", owner, lockMs, ...claimedIds]
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
- ["processing", owner, lockMs, row.id, ...claimableParams(eligibleBefore)]
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
- ["dead", "attempts_exhausted", "pending", "processing", "sending"]
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
- [dead ? "dead" : "pending", reason, ...dead ? [] : [delay], row.id, owner, row.attemptCount, "processing", "sending"]
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
- ["pending", queueId, "dead", expectedUpdatedAt]
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
- ["sending", flag, lockMs, id, "processing", owner]
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
- [lockMs, id, owner, "processing", "sending"]
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
- ["unknown", id, owner, "processing", "sending"]
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
- [resolution.status, resolution.messageId ?? null, queueId, "unknown"]
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
- ["sent", messageId ?? null, id, "sending", owner]
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", { params: { error: initResult.error.message } }),
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({ where: "name = ?", params: [name], limit: 1 }, tx);
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
- function createProvider(config) {
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", { error: error instanceof Error ? error.message : String(error) });
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", { error: error instanceof Error ? error.message : String(error) });
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", { params: { error: parseResult.error.message } }),
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
- providers = newProviders;
2097
- currentConfig = parsed;
2098
- dndConfig = parsed.dnd;
2099
- templateRepo = await tryInitTemplateRepo();
2100
- templateRegistry = createTemplateRegistry(templateRepo);
2101
- if (parsed.templates && templateRepo) {
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", { error: saveResult.error.message });
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
- sendLogRepo = requiredQueue ?? await tryInitSendLogRepo();
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: { error: error instanceof Error ? error.message : String(error) }
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({ where: "queue_id = ?", params: [queueId], limit: 1 });
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", { params: { error: "init already in progress" } })
2528
+ reachM("reach_configError", {
2529
+ params: { error: "init already in progress" }
2530
+ })
2197
2531
  );
2198
2532
  }
2199
2533
  initInProgress = true;
2200
2534
  try {
2201
- return await doInit(config);
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
- return currentConfig ? core.sanitize.sanitizeSensitiveFields(currentConfig) : null;
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
- await doClose();
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