@h-ai/reach 0.1.0-alpha5
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/LICENSE +202 -0
- package/README.md +161 -0
- package/dist/index.d.ts +468 -0
- package/dist/index.js +1324 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { core, err, ok } from '@h-ai/core';
|
|
3
|
+
import { randomUUID, createHmac } from 'crypto';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import { cache } from '@h-ai/cache';
|
|
6
|
+
import { BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
7
|
+
|
|
8
|
+
// src/reach-config.ts
|
|
9
|
+
|
|
10
|
+
// messages/en-US.json
|
|
11
|
+
var en_US_default = {
|
|
12
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
13
|
+
reach_notInitialized: "Reach module not initialized, call reach.init() first",
|
|
14
|
+
reach_initFailed: "Reach module initialization failed: {error}",
|
|
15
|
+
reach_unsupportedType: "Unsupported reach type: {type}",
|
|
16
|
+
reach_sendFailed: "Send failed: {error}",
|
|
17
|
+
reach_templateNotFound: "Template not found: {template}",
|
|
18
|
+
reach_templateRenderFailed: "Template render failed: {error}",
|
|
19
|
+
reach_invalidRecipient: "Invalid recipient: {recipient}",
|
|
20
|
+
reach_providerNotFound: "Provider not found: {provider}",
|
|
21
|
+
reach_providerRequired: "No provider specified, set provider field in message or template",
|
|
22
|
+
reach_dndBlocked: "Message discarded by Do Not Disturb period",
|
|
23
|
+
reach_dndDeferred: "Message deferred by Do Not Disturb period, will be sent after DND ends",
|
|
24
|
+
reach_smsSendFailed: "SMS send failed: {error}",
|
|
25
|
+
reach_emailSendFailed: "Email send failed: {error}",
|
|
26
|
+
reach_apiSendFailed: "API callback send failed: {error}",
|
|
27
|
+
reach_configError: "Reach config validation failed: {error}",
|
|
28
|
+
reach_config_nameRequired: "Provider name is required",
|
|
29
|
+
reach_config_hostRequired: "SMTP host is required",
|
|
30
|
+
reach_config_fromRequired: "Sender address (from) is required",
|
|
31
|
+
reach_config_accessKeyIdRequired: "AccessKey ID is required",
|
|
32
|
+
reach_config_accessKeySecretRequired: "AccessKey Secret is required",
|
|
33
|
+
reach_config_signNameRequired: "SMS sign name is required",
|
|
34
|
+
reach_config_urlRequired: "Callback URL is required",
|
|
35
|
+
reach_config_dndTimeInvalid: "Invalid time format, must be HH:mm (24-hour)",
|
|
36
|
+
reach_config_templateNameRequired: "Template name is required",
|
|
37
|
+
reach_config_templateProviderRequired: "Template provider is required",
|
|
38
|
+
reach_config_templateBodyRequired: "Template body is required",
|
|
39
|
+
reach_config_providersRequired: "At least one provider is required",
|
|
40
|
+
reach_templateDbInitFailed: "Template database initialization failed: {error}",
|
|
41
|
+
reach_templateSaveFailed: "Template save failed: {error}",
|
|
42
|
+
reach_templateDeleteFailed: "Template delete failed: {error}"
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// messages/zh-CN.json
|
|
46
|
+
var zh_CN_default = {
|
|
47
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
48
|
+
reach_notInitialized: "\u89E6\u8FBE\u6A21\u5757\u5C1A\u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 reach.init()",
|
|
49
|
+
reach_initFailed: "\u89E6\u8FBE\u6A21\u5757\u521D\u59CB\u5316\u5931\u8D25: {error}",
|
|
50
|
+
reach_unsupportedType: "\u4E0D\u652F\u6301\u7684\u89E6\u8FBE\u7C7B\u578B: {type}",
|
|
51
|
+
reach_sendFailed: "\u53D1\u9001\u5931\u8D25: {error}",
|
|
52
|
+
reach_templateNotFound: "\u6A21\u677F\u672A\u627E\u5230: {template}",
|
|
53
|
+
reach_templateRenderFailed: "\u6A21\u677F\u6E32\u67D3\u5931\u8D25: {error}",
|
|
54
|
+
reach_invalidRecipient: "\u65E0\u6548\u7684\u63A5\u6536\u65B9: {recipient}",
|
|
55
|
+
reach_providerNotFound: "Provider \u672A\u627E\u5230: {provider}",
|
|
56
|
+
reach_providerRequired: "\u672A\u6307\u5B9A Provider\uFF0C\u8BF7\u5728\u6D88\u606F\u6216\u6A21\u677F\u4E2D\u8BBE\u7F6E provider \u5B57\u6BB5",
|
|
57
|
+
reach_dndBlocked: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u4E22\u5F03",
|
|
58
|
+
reach_dndDeferred: "\u5F53\u524D\u5904\u4E8E\u514D\u6253\u6270\u65F6\u6BB5\uFF0C\u6D88\u606F\u5DF2\u6682\u5B58\u5F85\u53D1\u9001",
|
|
59
|
+
reach_smsSendFailed: "\u77ED\u4FE1\u53D1\u9001\u5931\u8D25: {error}",
|
|
60
|
+
reach_emailSendFailed: "\u90AE\u4EF6\u53D1\u9001\u5931\u8D25: {error}",
|
|
61
|
+
reach_apiSendFailed: "API \u56DE\u8C03\u53D1\u9001\u5931\u8D25: {error}",
|
|
62
|
+
reach_configError: "\u89E6\u8FBE\u914D\u7F6E\u6821\u9A8C\u5931\u8D25\uFF1A{error}",
|
|
63
|
+
reach_config_nameRequired: "Provider \u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A",
|
|
64
|
+
reach_config_hostRequired: "SMTP \u670D\u52A1\u5668\u5730\u5740\u4E0D\u80FD\u4E3A\u7A7A",
|
|
65
|
+
reach_config_fromRequired: "\u53D1\u4EF6\u4EBA\u5730\u5740\u4E0D\u80FD\u4E3A\u7A7A",
|
|
66
|
+
reach_config_accessKeyIdRequired: "AccessKey ID \u4E0D\u80FD\u4E3A\u7A7A",
|
|
67
|
+
reach_config_accessKeySecretRequired: "AccessKey Secret \u4E0D\u80FD\u4E3A\u7A7A",
|
|
68
|
+
reach_config_signNameRequired: "\u77ED\u4FE1\u7B7E\u540D\u4E0D\u80FD\u4E3A\u7A7A",
|
|
69
|
+
reach_config_urlRequired: "\u56DE\u8C03 URL \u4E0D\u80FD\u4E3A\u7A7A",
|
|
70
|
+
reach_config_dndTimeInvalid: "\u65F6\u95F4\u683C\u5F0F\u65E0\u6548\uFF0C\u987B\u4E3A HH:mm\uFF0824 \u5C0F\u65F6\u5236\uFF09",
|
|
71
|
+
reach_config_templateNameRequired: "\u6A21\u677F\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A",
|
|
72
|
+
reach_config_templateProviderRequired: "\u6A21\u677F\u7ED1\u5B9A\u7684 Provider \u4E0D\u80FD\u4E3A\u7A7A",
|
|
73
|
+
reach_config_templateBodyRequired: "\u6A21\u677F\u6B63\u6587\u4E0D\u80FD\u4E3A\u7A7A",
|
|
74
|
+
reach_config_providersRequired: "\u81F3\u5C11\u9700\u8981\u914D\u7F6E\u4E00\u4E2A Provider",
|
|
75
|
+
reach_templateDbInitFailed: "\u6A21\u677F\u6570\u636E\u5E93\u521D\u59CB\u5316\u5931\u8D25: {error}",
|
|
76
|
+
reach_templateSaveFailed: "\u6A21\u677F\u4FDD\u5B58\u5931\u8D25: {error}",
|
|
77
|
+
reach_templateDeleteFailed: "\u6A21\u677F\u5220\u9664\u5931\u8D25: {error}"
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// src/reach-i18n.ts
|
|
81
|
+
var reachM = core.i18n.createMessageGetter({
|
|
82
|
+
"zh-CN": zh_CN_default,
|
|
83
|
+
"en-US": en_US_default
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// src/reach-config.ts
|
|
87
|
+
var ConsoleProviderConfigSchema = z.object({
|
|
88
|
+
/** Provider 唯一名称 */
|
|
89
|
+
name: z.string().min(1, reachM("reach_config_nameRequired")),
|
|
90
|
+
type: z.literal("console")
|
|
91
|
+
});
|
|
92
|
+
var SmtpProviderConfigSchema = z.object({
|
|
93
|
+
/** Provider 唯一名称 */
|
|
94
|
+
name: z.string().min(1, reachM("reach_config_nameRequired")),
|
|
95
|
+
type: z.literal("smtp"),
|
|
96
|
+
/** SMTP 服务器地址 */
|
|
97
|
+
host: z.string().min(1, reachM("reach_config_hostRequired")),
|
|
98
|
+
/** SMTP 端口(默认 465) */
|
|
99
|
+
port: z.number().int().min(1).max(65535).default(465),
|
|
100
|
+
/** 是否使用 TLS(默认 true) */
|
|
101
|
+
secure: z.boolean().default(true),
|
|
102
|
+
/** SMTP 认证用户名 */
|
|
103
|
+
user: z.string().optional(),
|
|
104
|
+
/** SMTP 认证密码 */
|
|
105
|
+
pass: z.string().optional(),
|
|
106
|
+
/** 发件人地址 */
|
|
107
|
+
from: z.string().min(1, reachM("reach_config_fromRequired"))
|
|
108
|
+
});
|
|
109
|
+
var AliyunSmsProviderConfigSchema = z.object({
|
|
110
|
+
/** Provider 唯一名称 */
|
|
111
|
+
name: z.string().min(1, reachM("reach_config_nameRequired")),
|
|
112
|
+
type: z.literal("aliyun-sms"),
|
|
113
|
+
/** 阿里云 AccessKey ID */
|
|
114
|
+
accessKeyId: z.string().min(1, reachM("reach_config_accessKeyIdRequired")),
|
|
115
|
+
/** 阿里云 AccessKey Secret */
|
|
116
|
+
accessKeySecret: z.string().min(1, reachM("reach_config_accessKeySecretRequired")),
|
|
117
|
+
/** 短信签名 */
|
|
118
|
+
signName: z.string().min(1, reachM("reach_config_signNameRequired")),
|
|
119
|
+
/** API 端点(默认 dysmsapi.aliyuncs.com) */
|
|
120
|
+
endpoint: z.string().default("dysmsapi.aliyuncs.com")
|
|
121
|
+
});
|
|
122
|
+
var ApiProviderConfigSchema = z.object({
|
|
123
|
+
/** Provider 唯一名称 */
|
|
124
|
+
name: z.string().min(1, reachM("reach_config_nameRequired")),
|
|
125
|
+
type: z.literal("api"),
|
|
126
|
+
/** 回调 URL */
|
|
127
|
+
url: z.string().min(1, reachM("reach_config_urlRequired")),
|
|
128
|
+
/** HTTP 方法(默认 POST) */
|
|
129
|
+
method: z.enum(["POST", "PUT"]).default("POST"),
|
|
130
|
+
/** 自定义请求头 */
|
|
131
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
132
|
+
/** 请求超时毫秒数(默认 10000) */
|
|
133
|
+
timeout: z.number().int().min(0).default(1e4)
|
|
134
|
+
});
|
|
135
|
+
var ProviderConfigSchema = z.discriminatedUnion("type", [
|
|
136
|
+
ConsoleProviderConfigSchema,
|
|
137
|
+
SmtpProviderConfigSchema,
|
|
138
|
+
AliyunSmsProviderConfigSchema,
|
|
139
|
+
ApiProviderConfigSchema
|
|
140
|
+
]);
|
|
141
|
+
var DndConfigSchema = z.object({
|
|
142
|
+
/** 是否启用免打扰(默认 false) */
|
|
143
|
+
enabled: z.boolean().default(false),
|
|
144
|
+
/** 免打扰策略:discard 丢弃 / delay 延时发送(默认 discard) */
|
|
145
|
+
strategy: z.enum(["discard", "delay"]).default("discard"),
|
|
146
|
+
/** 免打扰开始时间(HH:mm 格式) */
|
|
147
|
+
start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, reachM("reach_config_dndTimeInvalid")).default("00:00"),
|
|
148
|
+
/** 免打扰结束时间(HH:mm 格式) */
|
|
149
|
+
end: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, reachM("reach_config_dndTimeInvalid")).default("00:00")
|
|
150
|
+
});
|
|
151
|
+
var TemplateConfigSchema = z.object({
|
|
152
|
+
/** 模板名称 */
|
|
153
|
+
name: z.string().min(1, reachM("reach_config_templateNameRequired")),
|
|
154
|
+
/** 绑定的 Provider 名称 */
|
|
155
|
+
provider: z.string().min(1, reachM("reach_config_templateProviderRequired")),
|
|
156
|
+
/** 邮件主题模板 */
|
|
157
|
+
subject: z.string().optional(),
|
|
158
|
+
/** 正文模板 */
|
|
159
|
+
body: z.string().min(1, reachM("reach_config_templateBodyRequired"))
|
|
160
|
+
});
|
|
161
|
+
var ReachConfigSchema = z.object({
|
|
162
|
+
/** Provider 配置列表 */
|
|
163
|
+
providers: z.array(ProviderConfigSchema).min(1, reachM("reach_config_providersRequired")),
|
|
164
|
+
/** 模板配置(通过配置文件注册) */
|
|
165
|
+
templates: z.array(TemplateConfigSchema).optional(),
|
|
166
|
+
/** 免打扰配置 */
|
|
167
|
+
dnd: DndConfigSchema.optional()
|
|
168
|
+
});
|
|
169
|
+
var ReachErrorInfo = {
|
|
170
|
+
SEND_FAILED: "001:500",
|
|
171
|
+
TEMPLATE_NOT_FOUND: "002:404",
|
|
172
|
+
TEMPLATE_RENDER_FAILED: "003:500",
|
|
173
|
+
INVALID_RECIPIENT: "004:400",
|
|
174
|
+
PROVIDER_NOT_FOUND: "005:500",
|
|
175
|
+
DND_BLOCKED: "006:403",
|
|
176
|
+
DND_DEFERRED: "007:202",
|
|
177
|
+
NOT_INITIALIZED: "010:500",
|
|
178
|
+
UNSUPPORTED_TYPE: "011:400",
|
|
179
|
+
CONFIG_ERROR: "012:500"
|
|
180
|
+
};
|
|
181
|
+
var HaiReachError = core.error.buildHaiErrorsDef("reach", ReachErrorInfo);
|
|
182
|
+
|
|
183
|
+
// src/providers/reach-provider-aliyun-sms.ts
|
|
184
|
+
var logger = core.logger.child({ module: "reach", scope: "provider-aliyun-sms" });
|
|
185
|
+
function toReachError(error) {
|
|
186
|
+
return {
|
|
187
|
+
code: HaiReachError.SEND_FAILED.code,
|
|
188
|
+
message: reachM("reach_smsSendFailed", {
|
|
189
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
190
|
+
}),
|
|
191
|
+
cause: error
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function percentEncode(str) {
|
|
195
|
+
return encodeURIComponent(str).replace(/\+/g, "%20").replace(/\*/g, "%2A").replace(/~/g, "%7E");
|
|
196
|
+
}
|
|
197
|
+
function signRequest(params, accessKeySecret, method) {
|
|
198
|
+
const sortedKeys = Object.keys(params).sort();
|
|
199
|
+
const canonicalQuery = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join("&");
|
|
200
|
+
const stringToSign = `${method}&${percentEncode("/")}&${percentEncode(canonicalQuery)}`;
|
|
201
|
+
const hmac = createHmac("sha1", `${accessKeySecret}&`);
|
|
202
|
+
hmac.update(stringToSign);
|
|
203
|
+
return hmac.digest("base64");
|
|
204
|
+
}
|
|
205
|
+
function createAliyunSmsProvider() {
|
|
206
|
+
let smsConfig = null;
|
|
207
|
+
return {
|
|
208
|
+
name: "aliyun-sms",
|
|
209
|
+
async connect(config) {
|
|
210
|
+
if (config.type !== "aliyun-sms") {
|
|
211
|
+
return err(
|
|
212
|
+
HaiReachError.CONFIG_ERROR,
|
|
213
|
+
reachM("reach_unsupportedType", { params: { type: config.type } })
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
smsConfig = config;
|
|
217
|
+
logger.info("Aliyun SMS provider connected", { endpoint: config.endpoint });
|
|
218
|
+
return ok(void 0);
|
|
219
|
+
},
|
|
220
|
+
async close() {
|
|
221
|
+
smsConfig = null;
|
|
222
|
+
logger.info("Aliyun SMS provider disconnected");
|
|
223
|
+
},
|
|
224
|
+
isConnected() {
|
|
225
|
+
return smsConfig !== null;
|
|
226
|
+
},
|
|
227
|
+
async send(message) {
|
|
228
|
+
if (!smsConfig) {
|
|
229
|
+
return err(
|
|
230
|
+
HaiReachError.NOT_INITIALIZED,
|
|
231
|
+
reachM("reach_notInitialized")
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const templateCode = message.extra?.templateCode;
|
|
235
|
+
logger.debug("Sending SMS", { to: message.to, templateCode });
|
|
236
|
+
try {
|
|
237
|
+
const params = {
|
|
238
|
+
AccessKeyId: smsConfig.accessKeyId,
|
|
239
|
+
Action: "SendSms",
|
|
240
|
+
Format: "JSON",
|
|
241
|
+
PhoneNumbers: message.to,
|
|
242
|
+
SignName: smsConfig.signName,
|
|
243
|
+
SignatureMethod: "HMAC-SHA1",
|
|
244
|
+
SignatureNonce: randomUUID(),
|
|
245
|
+
SignatureVersion: "1.0",
|
|
246
|
+
TemplateCode: templateCode ?? "",
|
|
247
|
+
Timestamp: (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
248
|
+
Version: "2017-05-25"
|
|
249
|
+
};
|
|
250
|
+
if (message.vars) {
|
|
251
|
+
params.TemplateParam = JSON.stringify(message.vars);
|
|
252
|
+
}
|
|
253
|
+
params.Signature = signRequest(params, smsConfig.accessKeySecret, "GET");
|
|
254
|
+
const queryString = Object.entries(params).map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join("&");
|
|
255
|
+
const url = `https://${smsConfig.endpoint}/?${queryString}`;
|
|
256
|
+
const response = await fetch(url);
|
|
257
|
+
const body = await response.json();
|
|
258
|
+
if (body.Code !== "OK") {
|
|
259
|
+
logger.warn("SMS send returned non-OK status", { code: body.Code, message: body.Message });
|
|
260
|
+
return err(
|
|
261
|
+
HaiReachError.SEND_FAILED,
|
|
262
|
+
reachM("reach_smsSendFailed", { params: { error: body.Message } })
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
logger.info("SMS sent", { to: message.to, bizId: body.BizId });
|
|
266
|
+
return ok({ success: true, messageId: body.BizId });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger.error("SMS send failed", { to: message.to, error });
|
|
269
|
+
return err(toReachError(error));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
var logger2 = core.logger.child({ module: "reach", scope: "provider-api" });
|
|
275
|
+
function toReachError2(error) {
|
|
276
|
+
return {
|
|
277
|
+
code: HaiReachError.SEND_FAILED.code,
|
|
278
|
+
message: reachM("reach_apiSendFailed", {
|
|
279
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
280
|
+
}),
|
|
281
|
+
cause: error
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function createApiProvider() {
|
|
285
|
+
let apiConfig = null;
|
|
286
|
+
return {
|
|
287
|
+
name: "api",
|
|
288
|
+
async connect(config) {
|
|
289
|
+
if (config.type !== "api") {
|
|
290
|
+
return err(
|
|
291
|
+
HaiReachError.CONFIG_ERROR,
|
|
292
|
+
reachM("reach_unsupportedType", { params: { type: config.type } })
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
apiConfig = config;
|
|
296
|
+
logger2.info("API provider connected", { url: config.url, method: config.method });
|
|
297
|
+
return ok(void 0);
|
|
298
|
+
},
|
|
299
|
+
async close() {
|
|
300
|
+
apiConfig = null;
|
|
301
|
+
logger2.info("API provider disconnected");
|
|
302
|
+
},
|
|
303
|
+
isConnected() {
|
|
304
|
+
return apiConfig !== null;
|
|
305
|
+
},
|
|
306
|
+
async send(message) {
|
|
307
|
+
if (!apiConfig) {
|
|
308
|
+
return err(
|
|
309
|
+
HaiReachError.NOT_INITIALIZED,
|
|
310
|
+
reachM("reach_notInitialized")
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
logger2.debug("Sending via API callback", { url: apiConfig.url, to: message.to });
|
|
314
|
+
try {
|
|
315
|
+
const payload = {
|
|
316
|
+
to: message.to,
|
|
317
|
+
subject: message.subject,
|
|
318
|
+
body: message.body,
|
|
319
|
+
template: message.template,
|
|
320
|
+
vars: message.vars,
|
|
321
|
+
extra: message.extra
|
|
322
|
+
};
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
const timeoutId = setTimeout(() => controller.abort(), apiConfig.timeout);
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(apiConfig.url, {
|
|
327
|
+
method: apiConfig.method,
|
|
328
|
+
headers: {
|
|
329
|
+
"Content-Type": "application/json",
|
|
330
|
+
...apiConfig.headers
|
|
331
|
+
},
|
|
332
|
+
body: JSON.stringify(payload),
|
|
333
|
+
signal: controller.signal
|
|
334
|
+
});
|
|
335
|
+
if (!response.ok) {
|
|
336
|
+
const text = await response.text();
|
|
337
|
+
logger2.warn("API callback returned non-OK status", { status: response.status, body: text });
|
|
338
|
+
return err(
|
|
339
|
+
HaiReachError.SEND_FAILED,
|
|
340
|
+
reachM("reach_apiSendFailed", { params: { error: `HTTP ${response.status}: ${text}` } })
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const result = await response.json();
|
|
344
|
+
logger2.info("API callback sent", { to: message.to, messageId: result.messageId });
|
|
345
|
+
return ok({ success: true, messageId: result.messageId });
|
|
346
|
+
} finally {
|
|
347
|
+
clearTimeout(timeoutId);
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logger2.error("API callback send failed", { to: message.to, error });
|
|
351
|
+
return err(toReachError2(error));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
var logger3 = core.logger.child({ module: "reach", scope: "provider-console" });
|
|
357
|
+
function createConsoleProvider() {
|
|
358
|
+
let connected = false;
|
|
359
|
+
return {
|
|
360
|
+
name: "console",
|
|
361
|
+
async connect(_config) {
|
|
362
|
+
connected = true;
|
|
363
|
+
logger3.info("Console provider connected");
|
|
364
|
+
return ok(void 0);
|
|
365
|
+
},
|
|
366
|
+
async close() {
|
|
367
|
+
connected = false;
|
|
368
|
+
logger3.info("Console provider disconnected");
|
|
369
|
+
},
|
|
370
|
+
isConnected() {
|
|
371
|
+
return connected;
|
|
372
|
+
},
|
|
373
|
+
async send(message) {
|
|
374
|
+
logger3.debug("Sending message via console", {
|
|
375
|
+
provider: message.provider,
|
|
376
|
+
to: message.to,
|
|
377
|
+
subject: message.subject,
|
|
378
|
+
body: message.body,
|
|
379
|
+
template: message.template,
|
|
380
|
+
vars: message.vars,
|
|
381
|
+
extra: message.extra
|
|
382
|
+
});
|
|
383
|
+
const messageId = `console-${Date.now()}`;
|
|
384
|
+
return ok({ success: true, messageId });
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
var logger4 = core.logger.child({ module: "reach", scope: "provider-smtp" });
|
|
389
|
+
function toReachError3(error) {
|
|
390
|
+
return {
|
|
391
|
+
code: HaiReachError.SEND_FAILED.code,
|
|
392
|
+
message: reachM("reach_emailSendFailed", {
|
|
393
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
394
|
+
}),
|
|
395
|
+
cause: error
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function createSmtpProvider() {
|
|
399
|
+
let transporter = null;
|
|
400
|
+
let smtpConfig = null;
|
|
401
|
+
return {
|
|
402
|
+
name: "smtp",
|
|
403
|
+
async connect(config) {
|
|
404
|
+
if (config.type !== "smtp") {
|
|
405
|
+
return err(
|
|
406
|
+
HaiReachError.CONFIG_ERROR,
|
|
407
|
+
reachM("reach_unsupportedType", { params: { type: config.type } })
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const require2 = createRequire(import.meta.url);
|
|
412
|
+
const nodemailer = require2("nodemailer");
|
|
413
|
+
smtpConfig = config;
|
|
414
|
+
transporter = nodemailer.createTransport({
|
|
415
|
+
host: config.host,
|
|
416
|
+
port: config.port,
|
|
417
|
+
secure: config.secure,
|
|
418
|
+
auth: config.user ? { user: config.user, pass: config.pass } : void 0
|
|
419
|
+
});
|
|
420
|
+
logger4.info("SMTP provider connected", { host: config.host, port: config.port });
|
|
421
|
+
return ok(void 0);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
logger4.error("SMTP connection failed", { error });
|
|
424
|
+
return err(toReachError3(error));
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
async close() {
|
|
428
|
+
if (transporter && typeof transporter.close === "function") {
|
|
429
|
+
transporter.close();
|
|
430
|
+
}
|
|
431
|
+
transporter = null;
|
|
432
|
+
smtpConfig = null;
|
|
433
|
+
logger4.info("SMTP provider disconnected");
|
|
434
|
+
},
|
|
435
|
+
isConnected() {
|
|
436
|
+
return transporter !== null;
|
|
437
|
+
},
|
|
438
|
+
async send(message) {
|
|
439
|
+
if (!transporter || !smtpConfig) {
|
|
440
|
+
return err(
|
|
441
|
+
HaiReachError.NOT_INITIALIZED,
|
|
442
|
+
reachM("reach_notInitialized")
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
logger4.debug("Sending email", { to: message.to, subject: message.subject });
|
|
446
|
+
try {
|
|
447
|
+
const sendMail = transporter.sendMail.bind(transporter);
|
|
448
|
+
const info = await sendMail({
|
|
449
|
+
from: smtpConfig.from,
|
|
450
|
+
to: message.to,
|
|
451
|
+
subject: message.subject ?? "",
|
|
452
|
+
html: message.body ?? ""
|
|
453
|
+
});
|
|
454
|
+
logger4.info("Email sent", { to: message.to, messageId: info.messageId });
|
|
455
|
+
return ok({ success: true, messageId: info.messageId });
|
|
456
|
+
} catch (error) {
|
|
457
|
+
logger4.error("Email send failed", { to: message.to, error });
|
|
458
|
+
return err(toReachError3(error));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
var logger5 = core.logger.child({ module: "reach", scope: "send" });
|
|
464
|
+
var reachNodeId = `reach:${crypto.randomUUID()}`;
|
|
465
|
+
function parseTimeToMinutes(time) {
|
|
466
|
+
const [h, m] = time.split(":").map(Number);
|
|
467
|
+
return h * 60 + m;
|
|
468
|
+
}
|
|
469
|
+
function isDndBlocked(dnd, now = /* @__PURE__ */ new Date()) {
|
|
470
|
+
if (!dnd?.enabled) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
const startMin = parseTimeToMinutes(dnd.start);
|
|
474
|
+
const endMin = parseTimeToMinutes(dnd.end);
|
|
475
|
+
const currentMin = now.getHours() * 60 + now.getMinutes();
|
|
476
|
+
if (startMin === endMin) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
if (startMin < endMin) {
|
|
480
|
+
return currentMin >= startMin && currentMin < endMin;
|
|
481
|
+
}
|
|
482
|
+
return currentMin >= startMin || currentMin < endMin;
|
|
483
|
+
}
|
|
484
|
+
function msUntilDndEnd(dnd, now = /* @__PURE__ */ new Date()) {
|
|
485
|
+
const endMin = parseTimeToMinutes(dnd.end);
|
|
486
|
+
const currentMin = now.getHours() * 60 + now.getMinutes();
|
|
487
|
+
const currentSec = now.getSeconds();
|
|
488
|
+
let diffMin = endMin - currentMin;
|
|
489
|
+
if (diffMin <= 0) {
|
|
490
|
+
diffMin += 24 * 60;
|
|
491
|
+
}
|
|
492
|
+
return diffMin * 60 * 1e3 - currentSec * 1e3;
|
|
493
|
+
}
|
|
494
|
+
async function preprocessMessage(message, templateRegistry2) {
|
|
495
|
+
if (!message.template) {
|
|
496
|
+
return ok(message);
|
|
497
|
+
}
|
|
498
|
+
const rendered = await templateRegistry2.render(message.template, message.vars ?? {});
|
|
499
|
+
if (!rendered.success) {
|
|
500
|
+
return rendered;
|
|
501
|
+
}
|
|
502
|
+
const template = await templateRegistry2.resolve(message.template);
|
|
503
|
+
const processed = {
|
|
504
|
+
...message,
|
|
505
|
+
provider: message.provider || (template.success ? template.data.provider : "") || "",
|
|
506
|
+
subject: message.subject ?? rendered.data.subject,
|
|
507
|
+
body: message.body ?? rendered.data.body
|
|
508
|
+
};
|
|
509
|
+
return ok(processed);
|
|
510
|
+
}
|
|
511
|
+
async function saveSendRecord(repo, message, status, provider, messageId) {
|
|
512
|
+
if (!repo) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
await repo.create({
|
|
517
|
+
provider,
|
|
518
|
+
toAddr: message.to,
|
|
519
|
+
subject: message.subject ?? null,
|
|
520
|
+
body: message.body ?? null,
|
|
521
|
+
template: message.template ?? null,
|
|
522
|
+
varsJson: message.vars ? JSON.stringify(message.vars) : null,
|
|
523
|
+
extraJson: message.extra ? JSON.stringify(message.extra) : null,
|
|
524
|
+
status,
|
|
525
|
+
messageId: messageId ?? null,
|
|
526
|
+
createdAt: Date.now()
|
|
527
|
+
});
|
|
528
|
+
} catch {
|
|
529
|
+
logger5.debug("Send record not saved (db module unavailable)");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
var dndTimer = null;
|
|
533
|
+
var schedulerContext = null;
|
|
534
|
+
function startDndScheduler(dndConfig2, providers2, repo) {
|
|
535
|
+
stopDndScheduler();
|
|
536
|
+
if (!dndConfig2.enabled || dndConfig2.strategy !== "delay") {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
schedulerContext = { dndConfig: dndConfig2, providers: providers2, repo };
|
|
540
|
+
if (!isDndBlocked(dndConfig2)) {
|
|
541
|
+
flushPendingMessages(providers2, repo).catch((error) => {
|
|
542
|
+
logger5.warn("Failed to flush pending messages on init", { error });
|
|
543
|
+
});
|
|
544
|
+
scheduleDndCheck();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
scheduleFlushAtDndEnd();
|
|
548
|
+
}
|
|
549
|
+
function scheduleFlushAtDndEnd() {
|
|
550
|
+
if (!schedulerContext)
|
|
551
|
+
return;
|
|
552
|
+
const { dndConfig: dndConfig2, providers: providers2, repo } = schedulerContext;
|
|
553
|
+
const delayMs = msUntilDndEnd(dndConfig2);
|
|
554
|
+
logger5.info("DND scheduler started", { delayMs, dndEnd: dndConfig2.end });
|
|
555
|
+
dndTimer = setTimeout(() => {
|
|
556
|
+
logger5.info("DND period ended, flushing pending messages");
|
|
557
|
+
flushPendingMessages(providers2, repo).catch((error) => {
|
|
558
|
+
logger5.error("Failed to flush pending messages", { error });
|
|
559
|
+
}).finally(() => {
|
|
560
|
+
scheduleDndCheck();
|
|
561
|
+
});
|
|
562
|
+
}, delayMs);
|
|
563
|
+
}
|
|
564
|
+
function scheduleDndCheck() {
|
|
565
|
+
if (!schedulerContext)
|
|
566
|
+
return;
|
|
567
|
+
const { dndConfig: dndConfig2 } = schedulerContext;
|
|
568
|
+
const CHECK_INTERVAL = 60 * 1e3;
|
|
569
|
+
dndTimer = setTimeout(() => {
|
|
570
|
+
if (!schedulerContext)
|
|
571
|
+
return;
|
|
572
|
+
if (isDndBlocked(dndConfig2)) {
|
|
573
|
+
scheduleFlushAtDndEnd();
|
|
574
|
+
} else {
|
|
575
|
+
scheduleDndCheck();
|
|
576
|
+
}
|
|
577
|
+
}, CHECK_INTERVAL);
|
|
578
|
+
}
|
|
579
|
+
function stopDndScheduler() {
|
|
580
|
+
if (dndTimer !== null) {
|
|
581
|
+
clearTimeout(dndTimer);
|
|
582
|
+
dndTimer = null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function resetSendState() {
|
|
586
|
+
stopDndScheduler();
|
|
587
|
+
schedulerContext = null;
|
|
588
|
+
}
|
|
589
|
+
async function flushPendingMessages(providers2, repo) {
|
|
590
|
+
if (!repo) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const FLUSH_LOCK_KEY = "hai:reach:flush-pending";
|
|
594
|
+
const FLUSH_LOCK_TTL = 60;
|
|
595
|
+
let lockAcquired = false;
|
|
596
|
+
if (cache.isInitialized) {
|
|
597
|
+
const lockResult = await cache.lock.acquire(FLUSH_LOCK_KEY, { ttl: FLUSH_LOCK_TTL, owner: reachNodeId });
|
|
598
|
+
if (lockResult.success && !lockResult.data) {
|
|
599
|
+
logger5.info("Skipping flush, another node holds the lock");
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
lockAcquired = lockResult.success && lockResult.data;
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const result = await repo.findPending();
|
|
606
|
+
if (!result.success || !result.data.length) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
logger5.info("Flushing pending messages", { count: result.data.length });
|
|
610
|
+
for (const row of result.data) {
|
|
611
|
+
const provider = providers2.get(row.provider);
|
|
612
|
+
if (!provider) {
|
|
613
|
+
logger5.warn("Provider not found for pending message, skipping", { provider: row.provider, id: row.id });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
let vars;
|
|
617
|
+
let extra;
|
|
618
|
+
try {
|
|
619
|
+
vars = row.varsJson ? JSON.parse(row.varsJson) : void 0;
|
|
620
|
+
extra = row.extraJson ? JSON.parse(row.extraJson) : void 0;
|
|
621
|
+
} catch {
|
|
622
|
+
logger5.warn("Failed to parse pending message JSON, skipping", { id: row.id });
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const message = {
|
|
626
|
+
provider: row.provider,
|
|
627
|
+
to: row.toAddr,
|
|
628
|
+
subject: row.subject ?? void 0,
|
|
629
|
+
body: row.body ?? void 0,
|
|
630
|
+
template: row.template ?? void 0,
|
|
631
|
+
vars,
|
|
632
|
+
extra
|
|
633
|
+
};
|
|
634
|
+
const sendResult = await provider.send(message);
|
|
635
|
+
if (sendResult.success) {
|
|
636
|
+
await repo.markSent(row.id, sendResult.data.messageId);
|
|
637
|
+
logger5.info("Pending message sent", { id: row.id, to: row.toAddr, provider: row.provider });
|
|
638
|
+
} else {
|
|
639
|
+
logger5.warn("Pending message send failed", { id: row.id, to: row.toAddr, error: sendResult.error.code });
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
} finally {
|
|
643
|
+
if (lockAcquired) {
|
|
644
|
+
await cache.lock.release(FLUSH_LOCK_KEY, reachNodeId).catch((error) => {
|
|
645
|
+
logger5.warn("Failed to release flush lock", { error });
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async function executeSend(message, providers2, templateRegistry2, dndConfig2, repo) {
|
|
651
|
+
if (!message.to) {
|
|
652
|
+
return err(
|
|
653
|
+
HaiReachError.INVALID_RECIPIENT,
|
|
654
|
+
reachM("reach_invalidRecipient", { params: { recipient: "" } })
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
const preprocessed = await preprocessMessage(message, templateRegistry2);
|
|
658
|
+
if (!preprocessed.success) {
|
|
659
|
+
return preprocessed;
|
|
660
|
+
}
|
|
661
|
+
const providerName = preprocessed.data.provider;
|
|
662
|
+
if (!providerName) {
|
|
663
|
+
return err(
|
|
664
|
+
HaiReachError.PROVIDER_NOT_FOUND,
|
|
665
|
+
reachM("reach_providerRequired")
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (!providers2.has(providerName)) {
|
|
669
|
+
return err(
|
|
670
|
+
HaiReachError.PROVIDER_NOT_FOUND,
|
|
671
|
+
reachM("reach_providerNotFound", { params: { provider: providerName } })
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (isDndBlocked(dndConfig2)) {
|
|
675
|
+
const strategy = dndConfig2?.strategy ?? "discard";
|
|
676
|
+
if (strategy === "delay") {
|
|
677
|
+
logger5.info("Message deferred by DND (delay strategy)", { provider: providerName, to: message.to });
|
|
678
|
+
try {
|
|
679
|
+
await saveSendRecord(repo ?? null, preprocessed.data, "pending", providerName);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
logger5.warn("Failed to save deferred message to DB", { provider: providerName, to: message.to, error });
|
|
682
|
+
return err(
|
|
683
|
+
HaiReachError.SEND_FAILED,
|
|
684
|
+
reachM("reach_dndDeferred"),
|
|
685
|
+
error
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
return ok({ success: true, deferred: true });
|
|
689
|
+
}
|
|
690
|
+
logger5.info("Message blocked by DND (discard strategy)", { provider: providerName, to: message.to });
|
|
691
|
+
return err(
|
|
692
|
+
HaiReachError.DND_BLOCKED,
|
|
693
|
+
reachM("reach_dndBlocked")
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const provider = providers2.get(providerName);
|
|
697
|
+
logger5.debug("Sending message", {
|
|
698
|
+
provider: providerName,
|
|
699
|
+
to: preprocessed.data.to,
|
|
700
|
+
template: message.template
|
|
701
|
+
});
|
|
702
|
+
const result = await provider.send(preprocessed.data);
|
|
703
|
+
if (!result.success) {
|
|
704
|
+
logger5.warn("Message send failed", {
|
|
705
|
+
provider: providerName,
|
|
706
|
+
to: message.to,
|
|
707
|
+
error: result.error.code
|
|
708
|
+
});
|
|
709
|
+
return result;
|
|
710
|
+
}
|
|
711
|
+
saveSendRecord(repo ?? null, preprocessed.data, "sent", providerName, result.data.messageId).catch((error) => {
|
|
712
|
+
logger5.warn("Failed to save send record", { provider: providerName, to: message.to, error });
|
|
713
|
+
});
|
|
714
|
+
return result;
|
|
715
|
+
}
|
|
716
|
+
function renderString(template, vars) {
|
|
717
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => {
|
|
718
|
+
return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function renderTemplate(template, vars) {
|
|
722
|
+
try {
|
|
723
|
+
const rendered = {
|
|
724
|
+
subject: template.subject ? renderString(template.subject, vars) : void 0,
|
|
725
|
+
body: renderString(template.body, vars)
|
|
726
|
+
};
|
|
727
|
+
return ok(rendered);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
return err(
|
|
730
|
+
HaiReachError.TEMPLATE_RENDER_FAILED,
|
|
731
|
+
reachM("reach_templateRenderFailed", {
|
|
732
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
733
|
+
}),
|
|
734
|
+
error
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function noDbError(msgKey) {
|
|
739
|
+
return err(
|
|
740
|
+
HaiReachError.SEND_FAILED,
|
|
741
|
+
reachM(msgKey, { params: { error: "database not available" } })
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
function createTemplateRegistry(repo) {
|
|
745
|
+
return {
|
|
746
|
+
async resolve(name) {
|
|
747
|
+
if (!repo) {
|
|
748
|
+
return err(
|
|
749
|
+
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
750
|
+
reachM("reach_templateNotFound", { params: { template: name } })
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
const dbResult = await repo.findByName(name);
|
|
754
|
+
if (!dbResult.success) {
|
|
755
|
+
return dbResult;
|
|
756
|
+
}
|
|
757
|
+
if (dbResult.data) {
|
|
758
|
+
return ok({
|
|
759
|
+
name: dbResult.data.name,
|
|
760
|
+
provider: dbResult.data.provider,
|
|
761
|
+
subject: dbResult.data.subject ?? void 0,
|
|
762
|
+
body: dbResult.data.body
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
return err(
|
|
766
|
+
HaiReachError.TEMPLATE_NOT_FOUND,
|
|
767
|
+
reachM("reach_templateNotFound", { params: { template: name } })
|
|
768
|
+
);
|
|
769
|
+
},
|
|
770
|
+
async save(template) {
|
|
771
|
+
if (!repo) {
|
|
772
|
+
return noDbError("reach_templateSaveFailed");
|
|
773
|
+
}
|
|
774
|
+
return repo.upsert(template);
|
|
775
|
+
},
|
|
776
|
+
async saveBatch(templates) {
|
|
777
|
+
if (!repo) {
|
|
778
|
+
return noDbError("reach_templateSaveFailed");
|
|
779
|
+
}
|
|
780
|
+
const results = await Promise.all(templates.map((t) => repo.upsert(t)));
|
|
781
|
+
const failed = results.find((r) => !r.success);
|
|
782
|
+
if (failed) {
|
|
783
|
+
return failed;
|
|
784
|
+
}
|
|
785
|
+
return ok(void 0);
|
|
786
|
+
},
|
|
787
|
+
async remove(name) {
|
|
788
|
+
if (!repo) {
|
|
789
|
+
return noDbError("reach_templateDeleteFailed");
|
|
790
|
+
}
|
|
791
|
+
return repo.deleteByName(name);
|
|
792
|
+
},
|
|
793
|
+
async list() {
|
|
794
|
+
if (!repo) {
|
|
795
|
+
return ok([]);
|
|
796
|
+
}
|
|
797
|
+
return repo.listTemplates();
|
|
798
|
+
},
|
|
799
|
+
async render(name, vars) {
|
|
800
|
+
const resolved = await this.resolve(name);
|
|
801
|
+
if (!resolved.success) {
|
|
802
|
+
return resolved;
|
|
803
|
+
}
|
|
804
|
+
return renderTemplate(resolved.data, vars);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
var TABLE_NAME = "hai_reach_send_log";
|
|
809
|
+
var SEND_LOG_FIELDS = [
|
|
810
|
+
{
|
|
811
|
+
fieldName: "id",
|
|
812
|
+
columnName: "id",
|
|
813
|
+
def: { type: "INTEGER", primaryKey: true, autoIncrement: true },
|
|
814
|
+
select: true,
|
|
815
|
+
create: false,
|
|
816
|
+
update: false
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
fieldName: "provider",
|
|
820
|
+
columnName: "provider",
|
|
821
|
+
def: { type: "TEXT", notNull: true },
|
|
822
|
+
select: true,
|
|
823
|
+
create: true,
|
|
824
|
+
update: false
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
fieldName: "toAddr",
|
|
828
|
+
columnName: "to_addr",
|
|
829
|
+
def: { type: "TEXT", notNull: true },
|
|
830
|
+
select: true,
|
|
831
|
+
create: true,
|
|
832
|
+
update: false
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
fieldName: "subject",
|
|
836
|
+
columnName: "subject",
|
|
837
|
+
def: { type: "TEXT" },
|
|
838
|
+
select: true,
|
|
839
|
+
create: true,
|
|
840
|
+
update: false
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
fieldName: "body",
|
|
844
|
+
columnName: "body",
|
|
845
|
+
def: { type: "TEXT" },
|
|
846
|
+
select: true,
|
|
847
|
+
create: true,
|
|
848
|
+
update: false
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
fieldName: "template",
|
|
852
|
+
columnName: "template",
|
|
853
|
+
def: { type: "TEXT" },
|
|
854
|
+
select: true,
|
|
855
|
+
create: true,
|
|
856
|
+
update: false
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
fieldName: "varsJson",
|
|
860
|
+
columnName: "vars_json",
|
|
861
|
+
def: { type: "TEXT" },
|
|
862
|
+
select: true,
|
|
863
|
+
create: true,
|
|
864
|
+
update: false
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
fieldName: "extraJson",
|
|
868
|
+
columnName: "extra_json",
|
|
869
|
+
def: { type: "TEXT" },
|
|
870
|
+
select: true,
|
|
871
|
+
create: true,
|
|
872
|
+
update: false
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
fieldName: "status",
|
|
876
|
+
columnName: "status",
|
|
877
|
+
def: { type: "TEXT", notNull: true },
|
|
878
|
+
select: true,
|
|
879
|
+
create: true,
|
|
880
|
+
update: true
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
fieldName: "messageId",
|
|
884
|
+
columnName: "message_id",
|
|
885
|
+
def: { type: "TEXT" },
|
|
886
|
+
select: true,
|
|
887
|
+
create: true,
|
|
888
|
+
update: true
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
fieldName: "createdAt",
|
|
892
|
+
columnName: "created_at",
|
|
893
|
+
def: { type: "TIMESTAMP", notNull: true },
|
|
894
|
+
select: true,
|
|
895
|
+
create: true,
|
|
896
|
+
update: false
|
|
897
|
+
}
|
|
898
|
+
];
|
|
899
|
+
var sendLogRepoInstance = null;
|
|
900
|
+
var sendLogRepoDbConfig = null;
|
|
901
|
+
function resetSendLogRepoSingleton() {
|
|
902
|
+
sendLogRepoInstance = null;
|
|
903
|
+
sendLogRepoDbConfig = null;
|
|
904
|
+
}
|
|
905
|
+
async function createSendLogRepository(db) {
|
|
906
|
+
if (sendLogRepoInstance && sendLogRepoDbConfig === db.config)
|
|
907
|
+
return ok(sendLogRepoInstance);
|
|
908
|
+
const repo = new DbSendLogRepository(db);
|
|
909
|
+
const initResult = await repo.count();
|
|
910
|
+
if (!initResult.success) {
|
|
911
|
+
return err(
|
|
912
|
+
HaiReachError.SEND_FAILED,
|
|
913
|
+
reachM("reach_sendFailed", { params: { error: initResult.error.message } }),
|
|
914
|
+
initResult.error
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
sendLogRepoInstance = repo;
|
|
918
|
+
sendLogRepoDbConfig = db.config;
|
|
919
|
+
return ok(repo);
|
|
920
|
+
}
|
|
921
|
+
var DbSendLogRepository = class extends BaseReldbCrudRepository {
|
|
922
|
+
constructor(db) {
|
|
923
|
+
super(db, {
|
|
924
|
+
table: TABLE_NAME,
|
|
925
|
+
fields: SEND_LOG_FIELDS,
|
|
926
|
+
idColumn: "id",
|
|
927
|
+
idField: "id",
|
|
928
|
+
createTableIfNotExists: true
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
/** 获取所有待发送记录 */
|
|
932
|
+
async findPending(tx) {
|
|
933
|
+
const result = await this.findAll({ where: "status = ?", params: ["pending"], orderBy: "created_at ASC" }, tx);
|
|
934
|
+
if (!result.success) {
|
|
935
|
+
return result;
|
|
936
|
+
}
|
|
937
|
+
return ok(result.data);
|
|
938
|
+
}
|
|
939
|
+
/** 将记录标记为已发送 */
|
|
940
|
+
async markSent(id, messageId, tx) {
|
|
941
|
+
const result = await this.updateById(id, { status: "sent", messageId: messageId ?? null }, tx);
|
|
942
|
+
if (!result.success) {
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
return ok(void 0);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
var TABLE_NAME2 = "hai_reach_template";
|
|
949
|
+
var TEMPLATE_FIELDS = [
|
|
950
|
+
{
|
|
951
|
+
fieldName: "id",
|
|
952
|
+
columnName: "id",
|
|
953
|
+
def: { type: "INTEGER", primaryKey: true, autoIncrement: true },
|
|
954
|
+
select: true,
|
|
955
|
+
create: false,
|
|
956
|
+
update: false
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
fieldName: "name",
|
|
960
|
+
columnName: "name",
|
|
961
|
+
def: { type: "TEXT", notNull: true, unique: true },
|
|
962
|
+
select: true,
|
|
963
|
+
create: true,
|
|
964
|
+
update: true
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
fieldName: "provider",
|
|
968
|
+
columnName: "provider",
|
|
969
|
+
def: { type: "TEXT", notNull: true },
|
|
970
|
+
select: true,
|
|
971
|
+
create: true,
|
|
972
|
+
update: true
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
fieldName: "subject",
|
|
976
|
+
columnName: "subject",
|
|
977
|
+
def: { type: "TEXT" },
|
|
978
|
+
select: true,
|
|
979
|
+
create: true,
|
|
980
|
+
update: true
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
fieldName: "body",
|
|
984
|
+
columnName: "body",
|
|
985
|
+
def: { type: "TEXT", notNull: true },
|
|
986
|
+
select: true,
|
|
987
|
+
create: true,
|
|
988
|
+
update: true
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
fieldName: "createdAt",
|
|
992
|
+
columnName: "created_at",
|
|
993
|
+
def: { type: "TIMESTAMP", notNull: true },
|
|
994
|
+
select: true,
|
|
995
|
+
create: true,
|
|
996
|
+
update: false
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
fieldName: "updatedAt",
|
|
1000
|
+
columnName: "updated_at",
|
|
1001
|
+
def: { type: "TIMESTAMP", notNull: true },
|
|
1002
|
+
select: true,
|
|
1003
|
+
create: true,
|
|
1004
|
+
update: true
|
|
1005
|
+
}
|
|
1006
|
+
];
|
|
1007
|
+
var templateRepoInstance = null;
|
|
1008
|
+
var templateRepoDbConfig = null;
|
|
1009
|
+
function resetTemplateRepoSingleton() {
|
|
1010
|
+
templateRepoInstance = null;
|
|
1011
|
+
templateRepoDbConfig = null;
|
|
1012
|
+
}
|
|
1013
|
+
async function createTemplateRepository(db) {
|
|
1014
|
+
if (templateRepoInstance && templateRepoDbConfig === db.config)
|
|
1015
|
+
return ok(templateRepoInstance);
|
|
1016
|
+
const repo = new DbTemplateRepository(db);
|
|
1017
|
+
const initResult = await repo.count();
|
|
1018
|
+
if (!initResult.success) {
|
|
1019
|
+
return err(
|
|
1020
|
+
HaiReachError.SEND_FAILED,
|
|
1021
|
+
reachM("reach_templateDbInitFailed", { params: { error: initResult.error.message } }),
|
|
1022
|
+
initResult.error
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
templateRepoInstance = repo;
|
|
1026
|
+
templateRepoDbConfig = db.config;
|
|
1027
|
+
return ok(repo);
|
|
1028
|
+
}
|
|
1029
|
+
function toReachTemplate(stored) {
|
|
1030
|
+
return {
|
|
1031
|
+
name: stored.name,
|
|
1032
|
+
provider: stored.provider,
|
|
1033
|
+
subject: stored.subject ?? void 0,
|
|
1034
|
+
body: stored.body
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
var DbTemplateRepository = class extends BaseReldbCrudRepository {
|
|
1038
|
+
constructor(db) {
|
|
1039
|
+
super(db, {
|
|
1040
|
+
table: TABLE_NAME2,
|
|
1041
|
+
fields: TEMPLATE_FIELDS,
|
|
1042
|
+
idColumn: "id",
|
|
1043
|
+
idField: "id",
|
|
1044
|
+
createTableIfNotExists: true
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
/** 按名称查找模板 */
|
|
1048
|
+
async findByName(name, tx) {
|
|
1049
|
+
const result = await this.findAll({ where: "name = ?", params: [name], limit: 1 }, tx);
|
|
1050
|
+
if (!result.success) {
|
|
1051
|
+
return result;
|
|
1052
|
+
}
|
|
1053
|
+
return ok(result.data[0]);
|
|
1054
|
+
}
|
|
1055
|
+
/** 保存模板(存在则更新,不存在则插入) */
|
|
1056
|
+
async upsert(template, tx) {
|
|
1057
|
+
const existing = await this.findByName(template.name, tx);
|
|
1058
|
+
if (!existing.success) {
|
|
1059
|
+
return existing;
|
|
1060
|
+
}
|
|
1061
|
+
const now = Date.now();
|
|
1062
|
+
if (existing.data) {
|
|
1063
|
+
const updateResult = await this.updateById(
|
|
1064
|
+
existing.data.id,
|
|
1065
|
+
{
|
|
1066
|
+
provider: template.provider,
|
|
1067
|
+
subject: template.subject ?? null,
|
|
1068
|
+
body: template.body,
|
|
1069
|
+
updatedAt: now
|
|
1070
|
+
},
|
|
1071
|
+
tx
|
|
1072
|
+
);
|
|
1073
|
+
if (!updateResult.success) {
|
|
1074
|
+
return updateResult;
|
|
1075
|
+
}
|
|
1076
|
+
} else {
|
|
1077
|
+
const createResult = await this.create(
|
|
1078
|
+
{
|
|
1079
|
+
name: template.name,
|
|
1080
|
+
provider: template.provider,
|
|
1081
|
+
subject: template.subject ?? null,
|
|
1082
|
+
body: template.body,
|
|
1083
|
+
createdAt: now,
|
|
1084
|
+
updatedAt: now
|
|
1085
|
+
},
|
|
1086
|
+
tx
|
|
1087
|
+
);
|
|
1088
|
+
if (!createResult.success) {
|
|
1089
|
+
return createResult;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return ok(void 0);
|
|
1093
|
+
}
|
|
1094
|
+
/** 按名称删除模板 */
|
|
1095
|
+
async deleteByName(name, tx) {
|
|
1096
|
+
const existing = await this.findByName(name, tx);
|
|
1097
|
+
if (!existing.success) {
|
|
1098
|
+
return existing;
|
|
1099
|
+
}
|
|
1100
|
+
if (!existing.data) {
|
|
1101
|
+
return ok(void 0);
|
|
1102
|
+
}
|
|
1103
|
+
const result = await this.deleteById(existing.data.id, tx);
|
|
1104
|
+
if (!result.success) {
|
|
1105
|
+
return result;
|
|
1106
|
+
}
|
|
1107
|
+
return ok(void 0);
|
|
1108
|
+
}
|
|
1109
|
+
/** 获取所有模板(转换为 ReachTemplate 格式) */
|
|
1110
|
+
async listTemplates(tx) {
|
|
1111
|
+
const result = await this.findAll({ orderBy: "name ASC" }, tx);
|
|
1112
|
+
if (!result.success) {
|
|
1113
|
+
return result;
|
|
1114
|
+
}
|
|
1115
|
+
return ok(result.data.map(toReachTemplate));
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// src/reach-main.ts
|
|
1120
|
+
var logger6 = core.logger.child({ module: "reach", scope: "main" });
|
|
1121
|
+
var providers = /* @__PURE__ */ new Map();
|
|
1122
|
+
var currentConfig = null;
|
|
1123
|
+
var dndConfig;
|
|
1124
|
+
var templateRegistry = createTemplateRegistry();
|
|
1125
|
+
var sendLogRepo = null;
|
|
1126
|
+
var templateRepo = null;
|
|
1127
|
+
var initInProgress = false;
|
|
1128
|
+
function createProvider(config) {
|
|
1129
|
+
switch (config.type) {
|
|
1130
|
+
case "console":
|
|
1131
|
+
return createConsoleProvider();
|
|
1132
|
+
case "smtp":
|
|
1133
|
+
return createSmtpProvider();
|
|
1134
|
+
case "aliyun-sms":
|
|
1135
|
+
return createAliyunSmsProvider();
|
|
1136
|
+
case "api":
|
|
1137
|
+
return createApiProvider();
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
async function tryGetDb() {
|
|
1141
|
+
try {
|
|
1142
|
+
const dbModuleName = "@h-ai/reldb";
|
|
1143
|
+
const mod = await import(
|
|
1144
|
+
/* @vite-ignore */
|
|
1145
|
+
dbModuleName
|
|
1146
|
+
);
|
|
1147
|
+
if (!mod.reldb.isInitialized) {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
return mod.reldb;
|
|
1151
|
+
} catch {
|
|
1152
|
+
return null;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async function tryInitSendLogRepo() {
|
|
1156
|
+
try {
|
|
1157
|
+
const db = await tryGetDb();
|
|
1158
|
+
if (!db) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
const result = await createSendLogRepository(db);
|
|
1162
|
+
if (!result.success) {
|
|
1163
|
+
logger6.debug("Send log repository not initialized", { error: result.error.message });
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
return result.data;
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
logger6.debug("Send log repository not initialized", { error: error instanceof Error ? error.message : String(error) });
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
async function tryInitTemplateRepo() {
|
|
1173
|
+
try {
|
|
1174
|
+
const db = await tryGetDb();
|
|
1175
|
+
if (!db) {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
const result = await createTemplateRepository(db);
|
|
1179
|
+
if (!result.success) {
|
|
1180
|
+
logger6.debug("Template repository not initialized", { error: result.error.message });
|
|
1181
|
+
return null;
|
|
1182
|
+
}
|
|
1183
|
+
return result.data;
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
logger6.debug("Template repository not initialized", { error: error instanceof Error ? error.message : String(error) });
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
var notInitialized = core.module.createNotInitializedKit(
|
|
1190
|
+
HaiReachError.NOT_INITIALIZED,
|
|
1191
|
+
() => reachM("reach_notInitialized")
|
|
1192
|
+
);
|
|
1193
|
+
async function doClose() {
|
|
1194
|
+
if (providers.size === 0 && currentConfig === null) {
|
|
1195
|
+
logger6.info("Reach module already closed, skipping");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
logger6.info("Closing reach module");
|
|
1199
|
+
for (const [name, provider] of providers.entries()) {
|
|
1200
|
+
try {
|
|
1201
|
+
await provider.close();
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
logger6.error("Provider close failed", { name, error });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
providers = /* @__PURE__ */ new Map();
|
|
1207
|
+
currentConfig = null;
|
|
1208
|
+
dndConfig = void 0;
|
|
1209
|
+
templateRegistry = createTemplateRegistry();
|
|
1210
|
+
sendLogRepo = null;
|
|
1211
|
+
templateRepo = null;
|
|
1212
|
+
resetSendLogRepoSingleton();
|
|
1213
|
+
resetTemplateRepoSingleton();
|
|
1214
|
+
resetSendState();
|
|
1215
|
+
logger6.info("Reach module closed");
|
|
1216
|
+
}
|
|
1217
|
+
async function doInit(config) {
|
|
1218
|
+
if (providers.size > 0) {
|
|
1219
|
+
logger6.warn("Reach module is already initialized, reinitializing");
|
|
1220
|
+
await doClose();
|
|
1221
|
+
}
|
|
1222
|
+
logger6.info("Initializing reach module");
|
|
1223
|
+
const parseResult = ReachConfigSchema.safeParse(config);
|
|
1224
|
+
if (!parseResult.success) {
|
|
1225
|
+
logger6.error("Reach config validation failed", { error: parseResult.error.message });
|
|
1226
|
+
return err(
|
|
1227
|
+
HaiReachError.CONFIG_ERROR,
|
|
1228
|
+
reachM("reach_configError", { params: { error: parseResult.error.message } }),
|
|
1229
|
+
parseResult.error
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
const parsed = parseResult.data;
|
|
1233
|
+
try {
|
|
1234
|
+
const newProviders = /* @__PURE__ */ new Map();
|
|
1235
|
+
for (const providerConfig of parsed.providers) {
|
|
1236
|
+
const provider = createProvider(providerConfig);
|
|
1237
|
+
const connectResult = await provider.connect(providerConfig);
|
|
1238
|
+
if (!connectResult.success) {
|
|
1239
|
+
logger6.error("Provider initialization failed", {
|
|
1240
|
+
name: providerConfig.name,
|
|
1241
|
+
type: providerConfig.type,
|
|
1242
|
+
code: connectResult.error.code,
|
|
1243
|
+
message: connectResult.error.message
|
|
1244
|
+
});
|
|
1245
|
+
logger6.info("Rolling back connected providers", { count: newProviders.size });
|
|
1246
|
+
for (const [name, p] of newProviders.entries()) {
|
|
1247
|
+
try {
|
|
1248
|
+
await p.close();
|
|
1249
|
+
} catch (rollbackError) {
|
|
1250
|
+
logger6.error("Provider rollback close failed", { name, error: rollbackError });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return connectResult;
|
|
1254
|
+
}
|
|
1255
|
+
newProviders.set(providerConfig.name, provider);
|
|
1256
|
+
}
|
|
1257
|
+
providers = newProviders;
|
|
1258
|
+
currentConfig = parsed;
|
|
1259
|
+
dndConfig = parsed.dnd;
|
|
1260
|
+
templateRepo = await tryInitTemplateRepo();
|
|
1261
|
+
templateRegistry = createTemplateRegistry(templateRepo);
|
|
1262
|
+
if (parsed.templates && templateRepo) {
|
|
1263
|
+
const saveResult = await templateRegistry.saveBatch(parsed.templates);
|
|
1264
|
+
if (!saveResult.success) {
|
|
1265
|
+
logger6.warn("Failed to save config templates to database", { error: saveResult.error.message });
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
sendLogRepo = await tryInitSendLogRepo();
|
|
1269
|
+
if (dndConfig) {
|
|
1270
|
+
startDndScheduler(dndConfig, providers, sendLogRepo);
|
|
1271
|
+
}
|
|
1272
|
+
const providerNames = parsed.providers.map((p) => p.name);
|
|
1273
|
+
logger6.info("Reach module initialized", { providers: providerNames });
|
|
1274
|
+
return ok(void 0);
|
|
1275
|
+
} catch (error) {
|
|
1276
|
+
logger6.error("Reach module initialization failed", { error });
|
|
1277
|
+
return err(
|
|
1278
|
+
HaiReachError.CONFIG_ERROR,
|
|
1279
|
+
reachM("reach_initFailed", {
|
|
1280
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
1281
|
+
}),
|
|
1282
|
+
error
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
var reach = {
|
|
1287
|
+
async init(config) {
|
|
1288
|
+
if (initInProgress) {
|
|
1289
|
+
logger6.warn("Reach module init already in progress, skipping");
|
|
1290
|
+
return err(
|
|
1291
|
+
HaiReachError.CONFIG_ERROR,
|
|
1292
|
+
reachM("reach_configError", { params: { error: "init already in progress" } })
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
initInProgress = true;
|
|
1296
|
+
try {
|
|
1297
|
+
return await doInit(config);
|
|
1298
|
+
} finally {
|
|
1299
|
+
initInProgress = false;
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
async send(message) {
|
|
1303
|
+
if (providers.size === 0) {
|
|
1304
|
+
return notInitialized.result();
|
|
1305
|
+
}
|
|
1306
|
+
return executeSend(message, providers, templateRegistry, dndConfig, sendLogRepo);
|
|
1307
|
+
},
|
|
1308
|
+
get template() {
|
|
1309
|
+
return templateRegistry;
|
|
1310
|
+
},
|
|
1311
|
+
get config() {
|
|
1312
|
+
return currentConfig;
|
|
1313
|
+
},
|
|
1314
|
+
get isInitialized() {
|
|
1315
|
+
return providers.size > 0;
|
|
1316
|
+
},
|
|
1317
|
+
async close() {
|
|
1318
|
+
await doClose();
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1322
|
+
export { AliyunSmsProviderConfigSchema, ApiProviderConfigSchema, ConsoleProviderConfigSchema, DndConfigSchema, HaiReachError, ProviderConfigSchema, ReachConfigSchema, SmtpProviderConfigSchema, TemplateConfigSchema, reach };
|
|
1323
|
+
//# sourceMappingURL=index.js.map
|
|
1324
|
+
//# sourceMappingURL=index.js.map
|