Merge pull request #2 from zAhYAng/feat/wecom-notifications

feat: add WeCom message push notifications
This commit is contained in:
Meng Meng
2026-08-11 22:56:19 +08:00
committed by GitHub
18 changed files with 1055 additions and 39 deletions
+1
View File
@@ -55,3 +55,4 @@ Thumbs.db
# ---- Claude Code / agent ----
.claude/
.worktrees/
@@ -0,0 +1,383 @@
# 企业微信消息推送实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
**技术栈:** Go 1.25、标准库 `net/http``encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
---
## 文件结构
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
- 修改:`web/src/types.ts`,扩展通知设置类型。
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
### 任务 1:企业微信模板与响应判定
**文件:**
- 创建:`internal/server/wecom_notification_test.go`
- 创建:`internal/server/wecom_notification.go`
- [ ] **步骤 1:编写失败的模板与响应测试**
```go
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
)
if err != nil { t.Fatal(err) }
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
t.Fatalf("payload = %s", got)
}
}
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
}
}
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
t.Fatalf("error = %v", err)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:FAIL,提示 `renderWecomPayload``wecomTemplateValues``validateWecomResponse` 未定义。
- [ ] **步骤 3:实现最少的模板与响应代码**
`internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{``}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
```go
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, _ := json.Marshal(values[name])
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct { ErrCode int `json:"errcode"` }
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil { return err }
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil { return err }
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil { return err }
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil { return fmt.Errorf("create WeCom notification request: %w", err) }
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
}
return nil
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:PASS。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
### 任务 2:设置 API 与敏感 Webhook URL
**文件:**
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/settings_api.go`
- [ ] **步骤 1:编写失败的 API 测试**
```go
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
}
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
预期:FAIL,设置 API 返回 `invalid_notification_channel`
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
`notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings``payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig``handleNotificationTest` 和发送分支中支持 `wecom`
```go
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
case "wecom":
return []string{"urls"}
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
```
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
```go
case "wecom_template":
var template string
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
return fmt.Errorf("%s must be a template string", field)
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
case "wecom":
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
return errors.New("wecom.urls and wecom.payload_template are required")
}
```
测试消息的变量必须为 `event: "test"``title: "vocat"``message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
预期:PASSGET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 3:接入短信与自动任务分发
**文件:**
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/automatic_task_notifications.go`
- [ ] **步骤 1:编写失败的事件变量测试**
```go
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
values := wecomSMSValues(message)
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
t.Fatalf("values = %#v", values)
}
}
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
t.Fatalf("values = %#v", values)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
预期:FAIL,两个事件变量构建函数未定义。
- [ ] **步骤 3:实现分发接入**
在企业微信模块中实现 `wecomSMSValues``wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch
```go
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
```
```go
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
for _, channel := range channels {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
}
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
```
保持既有游标、错误限流日志和其他通道的行为不变。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
预期:PASS`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 4:企业微信配置界面
**文件:**
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:扩展前端类型和表单映射**
`NotificationSettings``NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
```ts
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": { "content": {{message}} }
}`;
```
`formsFromNotifications` 读取 `payload_template``buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
- [ ] **步骤 2:实现企业微信页签与测试请求**
`PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
```tsx
<Field label={t("JSON 请求体模板")} hint={<span> JSON 使 <code>{'{{message}}'}</code></span>}>
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
</Field>
```
`SettingsPage.tsx` 增加 `testingWecom``onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
- [ ] **步骤 3:运行前端构建验证**
运行:`npm run build`
工作目录:`web`
预期:Vite 类型检查与生产构建均以退出码 0 完成。
- [ ] **步骤 4:提交本任务**
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 5:完整验证
**文件:**
- 修改:`internal/server/wecom_notification.go`
- 修改:`internal/server/wecom_notification_test.go`
- 修改:`internal/server/settings_api.go`
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/automatic_task_notifications.go`
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:格式化 Go 代码**
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
预期:所有修改的 Go 文件采用项目标准格式。
- [ ] **步骤 2:运行前端生产构建**
运行:`npm run build`
工作目录:`web`
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
- [ ] **步骤 3:运行后端回归测试**
运行:`go test ./...`
预期:所有目标包通过,无失败测试;`cmd/vocat``web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
- [ ] **步骤 4:检查最终变更**
运行:`git diff --check && git status --short`
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
@@ -0,0 +1,55 @@
# 企业微信消息推送设计
## 目标
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
## 配置模型
`wecom` 配置包含:
- `enabled`:是否启用通道。
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
不使用逗号、空格或换行分隔多个 URL。
- `payload_template`:完整 JSON 请求体模板。
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
## 模板语义
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text``markdown``news``template_card`
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
```json
{
"msgtype": "text",
"text": {
"content": {{message}}
}
}
```
可用变量:
- 通用:`{{event}}``{{title}}``{{message}}``{{timestamp}}`
- 短信事件:`{{content}}``{{number}}``{{device_id}}``{{device_name}}``{{device_label}}``{{time}}`
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
## 发送流程
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
## Web 与 API
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
## 验证
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
@@ -56,7 +56,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
}, "\n"),
Time: run.FinishedAt, Task: task, Run: run,
}
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
@@ -88,6 +88,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
case "webhook":
return sendAutomaticTaskWebhook(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
+49 -4
View File
@@ -40,6 +40,7 @@ var notificationChannels = []string{
"webhook",
"bark",
"pushplus",
"wecom",
}
var notificationFields = map[string]map[string]string{
@@ -61,6 +62,9 @@ var notificationFields = map[string]map[string]string{
"pushplus": {
"token": "string", "topic": "string", "channel": "string",
},
"wecom": {
"urls": "strings", "payload_template": "string",
},
}
// routeSettingsAPI is intentionally independent of the main router so it can
@@ -291,6 +295,11 @@ func validateNotificationField(
return fmt.Errorf("%s is not a valid email address", field)
}
}
if channel == "wecom" && name == "payload_template" && value != "" {
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
}
}
case "integer":
var value int
if err := json.Unmarshal(raw, &value); err != nil {
@@ -324,6 +333,9 @@ func validateNotificationField(
return fmt.Errorf("%s contains an invalid value", field)
}
if name == "urls" {
if channel == "wecom" && value == store.SecretMask {
continue
}
if _, err := parseOutboundURL(value, false); err != nil {
return fmt.Errorf("%s contains an invalid HTTP URL", field)
}
@@ -375,7 +387,7 @@ func (s *Server) handleNotificationTest(
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
return
}
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
writeError(
w,
http.StatusNotImplemented,
@@ -426,6 +438,8 @@ func (s *Server) handleNotificationTest(
err = sendEmailNotificationTest(r.Context(), resolved)
case "bark":
err = sendBarkNotificationTest(r.Context(), resolved)
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -503,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
}
for key, value := range overlay {
if _, secret := sensitive[key]; secret {
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
continue
}
value = mergeNotificationTestSecretValue(value, resolved[key])
}
resolved[key] = value
}
@@ -531,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
return resolved, provider, nil
}
// mergeNotificationTestSecretValue preserves masked values submitted by the
// settings form while allowing newly entered sensitive values in the same
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
// used by the other notification channels.
func mergeNotificationTestSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == store.SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func validateNotificationTestConfig(channel string, config map[string]any) error {
switch channel {
case "webhook":
@@ -549,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
if len(urls) > 8 {
return errors.New("bark test is limited to 8 URLs")
}
case "wecom":
return validateWecomNotificationConfig(config)
case "telegram":
token := configString(config, "bot_token")
if token == "" || token == store.SecretMask {
+97
View File
@@ -135,6 +135,103 @@ func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *tes
}
}
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
webhookURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-secret"
template := `{"msgtype":"text","text":{"content":{{message}}}}`
first, err := json.Marshal(map[string]any{
"wecom": map[string]any{
"enabled": true, "urls": []string{webhookURL}, "payload_template": template,
},
})
if err != nil {
t.Fatal(err)
}
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
if recorder.Code != http.StatusOK {
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
if bytes.Contains(recorder.Body.Bytes(), []byte("wecom-secret")) {
t.Fatalf("PUT response leaked webhook URL: %s", recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
wecom := response["data"].(map[string]any)["wecom"].(map[string]any)
urls, ok := wecom["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != store.SecretMask {
t.Fatalf("redacted WeCom URLs = %#v", wecom["urls"])
}
second, err := json.Marshal(map[string]any{
"wecom": map[string]any{
"enabled": true, "urls": []string{store.SecretMask}, "payload_template": template,
},
})
if err != nil {
t.Fatal(err)
}
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
if recorder.Code != http.StatusOK {
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("wecom-secret")) {
t.Fatalf("stored WeCom config = %s, err = %v", stored.Config, err)
}
}
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
raw, err := json.Marshal(map[string]any{
"urls": []string{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
if err != nil {
t.Fatal(err)
}
urls, ok := resolved["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" {
t.Fatalf("resolved URLs = %#v", resolved["urls"])
}
}
func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
storedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=stored"
unsavedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"
storedConfig, err := json.Marshal(map[string]any{
"urls": []string{storedURL},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
Channel: "wecom",
Config: storedConfig,
}); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{
"urls": []string{store.SecretMask, unsavedURL},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
if err != nil {
t.Fatal(err)
}
urls, ok := resolved["urls"].([]any)
if !ok || len(urls) != 2 || urls[0] != storedURL || urls[1] != unsavedURL {
t.Fatalf("resolved URLs = %#v", resolved["urls"])
}
}
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
test := newSettingsAPITest(t)
cases := []struct {
+4 -2
View File
@@ -24,7 +24,7 @@ import (
const smsNotificationPollInterval = 2 * time.Second
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
type smsNotification struct {
DeviceID string
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
func validateSMSNotificationConfig(channel string, config map[string]any) error {
switch channel {
case "bark", "email", "webhook":
case "bark", "email", "webhook", "wecom":
if err := validateNotificationTestConfig(channel, config); err != nil {
return err
}
@@ -202,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
return sendPushplusSMSNotification(ctx, config, message)
case "webhook":
return sendWebhookSMSNotification(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
+33
View File
@@ -36,12 +36,45 @@ func TestRenderSMSWebhookTemplate(t *testing.T) {
}
}
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
location := time.FixedZone("UTC+8", 8*60*60)
message := smsNotification{
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
Number: "+447386", Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "hello",
}
values := wecomSMSValues(message)
if values["event"] != "sms.received" || values["title"] != "收到新短信" || values["message"] != message.Text() {
t.Fatalf("common values = %#v", values)
}
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != "2026-08-08 17:25:35" {
t.Fatalf("SMS values = %#v", values)
}
}
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
values := wecomAutomaticTaskValues(automaticTaskNotification{
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
})
if values["event"] != "automatic_task.completed" || values["title"] != "自动任务执行成功" || values["message"] != "任务已完成" {
t.Fatalf("common values = %#v", values)
}
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
if values[name] != "" {
t.Fatalf("%s = %q, want empty", name, values[name])
}
}
}
func TestValidateSMSNotificationConfig(t *testing.T) {
valid := map[string]map[string]any{
"bark": {"urls": []any{"https://api.day.app/key"}},
"email": {"smtp_host": "smtp.example.com", "from_address": "[email protected]", "to_addresses": []any{"[email protected]"}},
"pushplus": {"token": "secret"},
"webhook": {"urls": []any{"https://example.com/hook"}},
"wecom": {
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
},
}
for channel, config := range valid {
if err := validateSMSNotificationConfig(channel, config); err != nil {
+154
View File
@@ -0,0 +1,154 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var wecomTemplateVariableNames = []string{
"event",
"title",
"message",
"timestamp",
"content",
"number",
"device_id",
"device_name",
"device_label",
"time",
}
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, err := json.Marshal(values[name])
if err != nil {
return nil, fmt.Errorf("encode WeCom template value %q: %w", name, err)
}
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct {
ErrCode *int `json:"errcode"`
}
if status < http.StatusOK || status >= http.StatusMultipleChoices ||
json.Unmarshal(body, &result) != nil || result.ErrCode == nil || *result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func wecomSMSValues(message smsNotification) wecomTemplateValues {
return wecomTemplateValues{
"event": "sms.received",
"title": "收到新短信",
"message": message.Text(),
"timestamp": message.Time.UTC().Format(time.RFC3339),
"content": message.Content,
"number": message.Number,
"device_id": message.DeviceID,
"device_name": message.DeviceName,
"device_label": message.DeviceLabel,
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
}
}
func wecomAutomaticTaskValues(message automaticTaskNotification) wecomTemplateValues {
return wecomTemplateValues{
"event": "automatic_task.completed",
"title": message.Title,
"message": message.Text,
"timestamp": message.Time.UTC().Format(time.RFC3339),
"content": "",
"number": "",
"device_id": "",
"device_name": "",
"device_label": "",
"time": "",
}
}
func validateWecomNotificationConfig(config map[string]any) error {
urls := configStrings(config, "urls")
if len(urls) == 0 {
return errors.New("wecom.urls must contain at least one URL")
}
if len(urls) > 8 {
return errors.New("wecom.urls cannot contain more than 8 URLs")
}
template := configString(config, "payload_template")
if template == "" {
return errors.New("wecom.payload_template is required")
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil {
return err
}
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
for _, destination := range configStrings(config, "urls") {
parsed, err := validateOutboundURL(ctx, destination, false)
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create WeCom notification request: %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send WeCom notification: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
closeErr := response.Body.Close()
if readErr != nil {
return fmt.Errorf("read WeCom response: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("close WeCom response: %w", closeErr)
}
if err := validateWecomResponse(response.StatusCode, body); err != nil {
return err
}
}
return nil
}
func sendWecomNotificationTest(ctx context.Context, config map[string]any) error {
return sendWecomNotification(ctx, config, wecomTestValues(time.Now()))
}
@@ -0,0 +1,56 @@
package server
import (
"errors"
"net/http"
"testing"
)
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{
"message": "quote: \"\nline",
"number": "+447386",
},
)
if err != nil {
t.Fatal(err)
}
if got, want := string(payload), `{"msgtype":"text","text":{"content":"quote: \"\nline","number":"+447386"}}`; got != want {
t.Fatalf("payload = %s, want %s", got, want)
}
}
func TestRenderWecomPayloadRejectsInvalidTemplate(t *testing.T) {
for _, template := range []string{
`{"text":{{unknown}}}`,
`[]`,
`{"msgtype":"text"`,
} {
t.Run(template, func(t *testing.T) {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
})
}
}
func TestValidateWecomResponse(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":0,"errmsg":"ok"}`)); err != nil {
t.Fatalf("successful response = %v", err)
}
for _, response := range []struct {
status int
body string
}{
{http.StatusBadGateway, `{"errcode":0}`},
{http.StatusOK, `{"errcode":40058,"errmsg":"invalid"}`},
{http.StatusOK, `{}`},
{http.StatusOK, `not-json`},
} {
if err := validateWecomResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
t.Fatalf("validateWecomResponse(%d, %s) = %v", response.status, response.body, err)
}
}
}
+37
View File
@@ -737,6 +737,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) {
}
}
func TestNotificationArraySecretPreservation(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret"
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err := database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
var redacted map[string]any
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
t.Fatal(err)
}
urls, ok := redacted["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != SecretMask {
t.Fatalf("redacted URLs = %#v", redacted["urls"])
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err = database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) {
t.Fatalf("stored URLs = %s", setting.Config)
}
}
func TestEventsPoliciesAndTraffic(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
+48 -12
View File
@@ -339,12 +339,9 @@ func (value NotificationSetting) SensitiveValues() []string {
}
values := make([]string, 0, len(value.SensitiveFields))
for _, field := range value.SensitiveFields {
if secret, ok := getJSONPath(document, field).(string); ok &&
secret != "" && secret != SecretMask {
values = append(values, secret)
}
collectJSONStringValues(getJSONPath(document, field), &values)
}
return values
return uniqueNonemptyStrings(values)
}
type AppSetting struct {
@@ -576,8 +573,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string
return json.RawMessage(`{}`)
}
for _, field := range fields {
if getJSONPath(document, field) != nil {
setJSONPath(document, field, replacement)
if current := getJSONPath(document, field); current != nil {
setJSONPath(document, field, redactJSONValue(current, replacement))
}
}
encoded, err := json.Marshal(document)
@@ -602,16 +599,55 @@ func mergeJSONSecrets(
}
for _, field := range fields {
value := getJSONPath(next, field)
text, stringValue := value.(string)
if value == nil || (stringValue && (text == "" || text == SecretMask)) {
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, previous)
}
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
}
}
return json.Marshal(next)
}
func redactJSONValue(value any, replacement string) any {
switch typed := value.(type) {
case string:
return replacement
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = redactJSONValue(item, replacement)
}
return result
default:
return replacement
}
}
func mergeJSONSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeJSONSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func getJSONPath(document map[string]any, path string) any {
if strings.TrimSpace(path) == "" {
return nil
+2
View File
@@ -22,6 +22,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
return []string{"secret"}
case "pushplus":
return []string{"token"}
case "wecom":
return []string{"urls"}
default:
return nil
}
+51 -1
View File
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
const HEADER_LIST_ID = "vocat-webhook-header-names";
@@ -267,3 +267,53 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
</div>
);
}
export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<WecomForm>) {
const { t, lang } = useI18n();
const off = !value.enabled;
const complete = hasAnyUrl(value.urls) && !!value.payloadTemplate.trim();
return (
<div className="pt-2">
<ChannelHeader
title={t("启用企业微信消息推送")}
enabled={value.enabled}
onToggle={(enabled) => onChange({ enabled })}
actions={
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
{t("测试通知")}
</Button>
}
/>
<SMSOnlyHint />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
</div>
<UrlListEditor
urls={value.urls}
onChange={(urls) => onChange({ urls })}
enabled={value.enabled}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
emptyText={t("尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。")}
/>
<Field
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
>
<Textarea
value={value.payloadTemplate}
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
disabled={off}
rows={12}
className="font-mono text-xs"
/>
</Field>
</div>
</div>
);
}
+47 -17
View File
@@ -47,18 +47,32 @@ export interface EmailForm {
}
export interface PushplusForm {
enabled: boolean;
token: string;
topic: string;
channel: string;
enabled: boolean;
token: string;
topic: string;
channel: string;
}
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": {
"content": {{message}}
}
}`;
export interface NotifyForms {
telegram: TelegramForm;
webhook: WebhookForm;
bark: BarkForm;
email: EmailForm;
pushplus: PushplusForm;
email: EmailForm;
pushplus: PushplusForm;
wecom: WecomForm;
}
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
@@ -131,8 +145,9 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
const telegram = asRecord(data.telegram);
const webhook = asRecord(data.webhook);
const bark = asRecord(data.bark);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const wecom = asRecord(data.wecom);
return {
telegram: {
enabled: !!telegram.enabled,
@@ -171,13 +186,18 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
fromAddress: str(email.fromAddress),
toAddresses: joinList(email.toAddresses),
},
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
};
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
wecom: {
enabled: !!wecom.enabled,
urls: strList(wecom.urls),
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
},
};
}
function splitList(value: string): string[] {
@@ -226,6 +246,15 @@ export function buildEmailPayload(form: EmailForm, forTest = false) {
};
}
export function buildWecomPayload(form: WecomForm, forTest = false) {
const urls = Array.isArray(form.urls) ? form.urls : [];
return {
enabled: !!form.enabled,
urls: forTest ? urls.map((url) => String(url || "").trim()).filter(Boolean) : urls,
payload_template: String(form.payloadTemplate || ""),
};
}
export function buildNotificationsPayload(forms: NotifyForms) {
return {
telegram: {
@@ -245,6 +274,7 @@ export function buildNotificationsPayload(forms: NotifyForms) {
channel: forms.pushplus.channel || "",
},
webhook: buildWebhookPayload(forms.webhook),
bark: buildBarkPayload(forms.bark),
};
bark: buildBarkPayload(forms.bark),
wecom: buildWecomPayload(forms.wecom),
};
}
+11
View File
@@ -275,6 +275,7 @@ export const EN_DICT: Record<string, string> = {
"Webhook 测试失败": "Webhook test failed",
"Bark 测试失败": "Bark test failed",
"Email 测试失败": "Email test failed",
"企业微信消息推送测试失败": "WeCom message push test failed",
// ---- 设置页:安全卡 ----
: "Security",
@@ -372,10 +373,20 @@ export const EN_DICT: Record<string, string> = {
"启用 Bark 推送": "Enable Bark",
"启用 Email 推送": "Enable Email",
"启用 Webhook 推送": "Enable Webhook",
"企业微信消息推送": "WeCom Message Push",
"启用企业微信消息推送": "Enable WeCom Message Push",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
"目标 URLs": "Target URLs",
"添加 URL": "Add URL",
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
"尚未配置任何 Webhook URL,点击右侧添加按钮。": "No Webhook URLs yet. Click the add button on the right.",
"每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。":
"Enter one WeCom message push Webhook URL per line. Use Add URL to add another row; do not separate URLs with commas, spaces, or line breaks.",
"尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。": "No WeCom message push Webhook URLs yet. Click the add button on the right.",
"JSON 请求体模板": "JSON Request Body Template",
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
"分组 (Group)": "Group",
"例如 vocat": "e.g. vocat",
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
+23 -2
View File
@@ -14,13 +14,14 @@ import {
buildBarkPayload,
buildEmailPayload,
buildNotificationsPayload,
buildWecomPayload,
buildWebhookPayload,
defaultNotifyForms,
formsFromNotifications,
type NotifyForms,
} from "../components/settings/model";
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
@@ -34,6 +35,7 @@ const NOTIFY_TABS = [
{ key: "email", label: "Email" },
{ key: "pushplus", label: "Pushplus" },
{ key: "webhook", label: "Webhook" },
{ key: "wecom", label: "企业微信消息推送" },
];
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
@@ -51,6 +53,7 @@ export default function SettingsPage() {
const [testingWebhook, setTestingWebhook] = useState(false);
const [testingBark, setTestingBark] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testingWecom, setTestingWecom] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [applyingUpdate, setApplyingUpdate] = useState(false);
@@ -320,6 +323,21 @@ export default function SettingsPage() {
}
}, [forms.email]);
const onTestWecom = useCallback(async () => {
setTestingWecom(true);
try {
await api("/settings/notifications/wecom/test", {
method: "POST",
body: buildWecomPayload(forms.wecom, true),
});
message.success(t("测试通知已发送"));
} catch (error) {
message.error(apiMessage(error) || t("企业微信消息推送测试失败"));
} finally {
setTestingWecom(false);
}
}, [forms.wecom]);
const onCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
@@ -448,7 +466,7 @@ export default function SettingsPage() {
<CardIcon>
<AlertRegular className="text-[24px]" />
</CardIcon>
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook")} />
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送")} />
</div>
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
{t("保存通知配置")}
@@ -479,6 +497,9 @@ export default function SettingsPage() {
onTest={onTestWebhook}
/>
) : null}
{activeTab === "wecom" ? (
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
) : null}
</div>
)}
</div>
+1
View File
@@ -372,6 +372,7 @@ export interface NotificationSettings {
bark: Record<string, unknown>;
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。