feat: add Lark group bot notifications (#47)

This commit is contained in:
Nayacco
2026-08-17 13:47:10 +08:00
committed by GitHub
parent 6ec950bfd2
commit 1a4032d013
17 changed files with 1013 additions and 22 deletions
@@ -57,7 +57,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", "wecom"} {
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom", "lark"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
@@ -91,6 +91,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
return sendAutomaticTaskWebhook(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
case "lark":
return sendLarkNotification(ctx, config, larkAutomaticTaskValues(message))
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
+282
View File
@@ -0,0 +1,282 @@
package server
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
const maxLarkPayloadBytes = 20 << 10
var larkTemplateVariableNames = []string{
"event",
"title",
"message",
"timestamp",
"content",
"number",
"device_id",
"device_name",
"device_label",
"time",
}
var larkTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^{}]*\}\}`)
var larkWebhookHosts = map[string]struct{}{
"open.feishu.cn": {},
"open.larksuite.com": {},
}
type larkTemplateValues map[string]string
func renderLarkPayload(template string, values larkTemplateValues) ([]byte, error) {
encodedValues := make(map[string]string, len(larkTemplateVariableNames))
for _, name := range larkTemplateVariableNames {
encoded, err := json.Marshal(values[name])
if err != nil {
return nil, fmt.Errorf("encode Lark template value %q: %w", name, err)
}
encodedValues[name] = string(encoded)
}
unsupported := false
rendered := larkTemplatePlaceholderPattern.ReplaceAllStringFunc(template, func(placeholder string) string {
name := placeholder[2 : len(placeholder)-2]
encoded, ok := encodedValues[name]
if !ok {
unsupported = true
return placeholder
}
return encoded
})
remainder := larkTemplatePlaceholderPattern.ReplaceAllString(template, "")
if unsupported || strings.Contains(remainder, "{{") {
return nil, errors.New("lark.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(rendered), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("lark.payload_template must render to a non-empty JSON object")
}
if len(rendered) > maxLarkPayloadBytes {
return nil, errors.New("lark.payload_template renders beyond the 20 KB Lark limit")
}
return []byte(rendered), nil
}
func larkSignature(timestamp int64, secret string) string {
key := strconv.FormatInt(timestamp, 10) + "\n" + secret
signature := hmac.New(sha256.New, []byte(key))
return base64.StdEncoding.EncodeToString(signature.Sum(nil))
}
func signLarkPayload(payload []byte, secret string, now time.Time) ([]byte, error) {
if secret == "" {
return payload, nil
}
var document map[string]json.RawMessage
if err := json.Unmarshal(payload, &document); err != nil || len(document) == 0 {
return nil, errors.New("lark payload must be a non-empty JSON object")
}
timestamp := now.Unix()
document["timestamp"], _ = json.Marshal(strconv.FormatInt(timestamp, 10))
document["sign"], _ = json.Marshal(larkSignature(timestamp, secret))
signed, err := json.Marshal(document)
if err != nil {
return nil, fmt.Errorf("encode signed Lark payload: %w", err)
}
if len(signed) > maxLarkPayloadBytes {
return nil, errors.New("lark payload exceeds the 20 KB Lark limit after signing")
}
return signed, nil
}
func validateLarkResponse(status int, body []byte) error {
var result struct {
Code *int `json:"code"`
StatusCode *int `json:"StatusCode"`
}
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil {
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
}
if result.Code != nil {
if *result.Code == 0 {
return nil
}
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
}
if result.StatusCode == nil || *result.StatusCode != 0 {
return fmt.Errorf("%w: Lark response was not successful", errProviderRejected)
}
return nil
}
func parseLarkWebhookURL(raw string) (*url.URL, error) {
parsed, err := parseOutboundURL(raw, true)
if err != nil {
return nil, err
}
if _, ok := larkWebhookHosts[strings.ToLower(parsed.Hostname())]; !ok {
return nil, errors.New("Lark group bot webhook must use open.feishu.cn or open.larksuite.com")
}
if parsed.Port() != "" && parsed.Port() != "443" {
return nil, errors.New("Lark group bot webhook must use the default HTTPS port")
}
const prefix = "/open-apis/bot/v2/hook/"
token := strings.TrimPrefix(parsed.Path, prefix)
if token == parsed.Path || token == "" || strings.Contains(token, "/") || parsed.RawPath != "" ||
parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" {
return nil, errors.New("Lark group bot webhook path is invalid")
}
return parsed, nil
}
func validateLarkWebhookURL(ctx context.Context, raw string) (*url.URL, error) {
parsed, err := parseLarkWebhookURL(raw)
if err != nil {
return nil, err
}
if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil {
return nil, err
}
return parsed, nil
}
func larkTestValues(now time.Time) larkTemplateValues {
return larkTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func larkSMSValues(message smsNotification) larkTemplateValues {
return larkTemplateValues{
"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 larkAutomaticTaskValues(message automaticTaskNotification) larkTemplateValues {
return larkTemplateValues{
"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 validateLarkNotificationConfig(config map[string]any) error {
if configString(config, "url") == "" {
return errors.New("lark.url is required")
}
template := configString(config, "payload_template")
if template == "" {
return errors.New("lark.payload_template is required")
}
if signingEnabled, _ := config["signing_enabled"].(bool); signingEnabled {
secret := configString(config, "secret")
if secret == "" {
return errors.New("lark.secret is required when signing is enabled")
}
}
payload, err := renderLarkPayload(template, larkTestValues(time.Unix(0, 0)))
if err != nil {
return err
}
_, err = signLarkPayload(payload, larkSigningSecret(config), time.Unix(0, 0))
return err
}
func larkSigningSecret(config map[string]any) string {
enabled, _ := config["signing_enabled"].(bool)
if !enabled {
return ""
}
return configString(config, "secret")
}
func sendLarkNotification(ctx context.Context, config map[string]any, values larkTemplateValues) error {
if err := validateLarkNotificationConfig(config); err != nil {
return err
}
payload, err := renderLarkPayload(configString(config, "payload_template"), values)
if err != nil {
return err
}
payload, err = signLarkPayload(payload, larkSigningSecret(config), time.Now())
if err != nil {
return err
}
parsed, err := validateLarkWebhookURL(ctx, configString(config, "url"))
if err != nil {
return err
}
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
return postLarkNotification(ctx, client, parsed.String(), payload)
}
func postLarkNotification(ctx context.Context, client *http.Client, endpoint string, payload []byte) error {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create Lark notification request: %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-lark-notification/1")
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send Lark notification: %w", sanitizeLarkRequestError(err))
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
closeErr := response.Body.Close()
if readErr != nil {
return fmt.Errorf("read Lark response: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("close Lark response: %w", closeErr)
}
if err := validateLarkResponse(response.StatusCode, body); err != nil {
return err
}
return nil
}
func sanitizeLarkRequestError(err error) error {
var requestErr *url.Error
if errors.As(err, &requestErr) && requestErr.Err != nil {
return requestErr.Err
}
return err
}
func sendLarkNotificationTest(ctx context.Context, config map[string]any) error {
return sendLarkNotification(ctx, config, larkTestValues(time.Now()))
}
+204
View File
@@ -0,0 +1,204 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestRenderLarkPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderLarkPayload(
`{"msg_type":"text","content":{"text":{{message}},"number":{{number}}}}`,
larkTemplateValues{
"message": "quote: \"\nline",
"number": "+447386",
},
)
if err != nil {
t.Fatal(err)
}
if got, want := string(payload), `{"msg_type":"text","content":{"text":"quote: \"\nline","number":"+447386"}}`; got != want {
t.Fatalf("payload = %s, want %s", got, want)
}
}
func TestRenderLarkPayloadDoesNotInterpretPlaceholdersInsideValues(t *testing.T) {
payload, err := renderLarkPayload(
`{"msg_type":"text","content":{"text":{{message}}}}`,
larkTemplateValues{"message": "keep {{timestamp}} literally", "timestamp": "changed"},
)
if err != nil {
t.Fatal(err)
}
if got, want := string(payload), `{"msg_type":"text","content":{"text":"keep {{timestamp}} literally"}}`; got != want {
t.Fatalf("payload = %s, want %s", got, want)
}
}
func TestRenderLarkPayloadRejectsInvalidTemplate(t *testing.T) {
for _, template := range []string{
`{"text":{{unknown}}}`,
`[]`,
`{"msg_type":"text"`,
`{"text":"` + strings.Repeat("x", maxLarkPayloadBytes) + `"}`,
} {
t.Run(template[:min(len(template), 40)], func(t *testing.T) {
if _, err := renderLarkPayload(template, larkTemplateValues{}); err == nil {
t.Fatalf("template was accepted")
}
})
}
}
func TestSignLarkPayload(t *testing.T) {
const timestamp = int64(1_599_360_473)
if got, want := larkSignature(timestamp, "demo"), "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8="; got != want {
t.Fatalf("signature = %q, want %q", got, want)
}
unsigned := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
signed, err := signLarkPayload(unsigned, "demo", time.Unix(timestamp, 0))
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(signed, &payload); err != nil {
t.Fatal(err)
}
if payload["timestamp"] != "1599360473" || payload["sign"] != "l1N0gAcBjdwBvGm1xMjOF0XSyaLRpR7tuO5dHfhAYc8=" {
t.Fatalf("signed payload = %#v", payload)
}
untouched, err := signLarkPayload(unsigned, "", time.Unix(timestamp, 0))
if err != nil || string(untouched) != string(unsigned) {
t.Fatalf("unsigned payload = %s, err = %v", untouched, err)
}
}
func TestValidateLarkResponse(t *testing.T) {
for _, body := range []string{
`{"code":0,"msg":"success"}`,
`{"StatusCode":0,"StatusMessage":"success"}`,
} {
if err := validateLarkResponse(http.StatusOK, []byte(body)); err != nil {
t.Fatalf("successful response %s = %v", body, err)
}
}
for _, response := range []struct {
status int
body string
}{
{http.StatusBadGateway, `{"code":0}`},
{http.StatusOK, `{"code":19021,"msg":"sign match fail or timestamp is not within one hour from current time","StatusCode":0}`},
{http.StatusOK, `{"StatusCode":19021,"StatusMessage":"sign error"}`},
{http.StatusOK, `{}`},
{http.StatusOK, `not-json`},
} {
if err := validateLarkResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
t.Fatalf("validateLarkResponse(%d, %s) = %v", response.status, response.body, err)
}
}
}
func TestPostLarkNotificationSendsJSONPayload(t *testing.T) {
payload := []byte(`{"msg_type":"text","content":{"text":"hello"}}`)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "vocat-lark-notification/1" {
t.Errorf("User-Agent = %q", got)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if string(body) != string(payload) {
t.Errorf("body = %s, want %s", body, payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":0,"msg":"success"}`)
}))
t.Cleanup(provider.Close)
if err := postLarkNotification(context.Background(), provider.Client(), provider.URL, payload); err != nil {
t.Fatalf("postLarkNotification() = %v", err)
}
}
func TestParseLarkWebhookURL(t *testing.T) {
for _, raw := range []string{
"https://open.feishu.cn/open-apis/bot/v2/hook/feishu-token",
"https://open.larksuite.com/open-apis/bot/v2/hook/lark-token",
"https://open.larksuite.com:443/open-apis/bot/v2/hook/lark-token",
} {
if _, err := parseLarkWebhookURL(raw); err != nil {
t.Errorf("parseLarkWebhookURL(%q) = %v", raw, err)
}
}
for _, raw := range []string{
"http://open.larksuite.com/open-apis/bot/v2/hook/token",
"https://example.com/open-apis/bot/v2/hook/token",
"https://open.larksuite.com/open-apis/bot/hook/token",
"https://open.larksuite.com/open-apis/bot/v2/hook/",
"https://open.larksuite.com/open-apis/bot/v2/hook/token/extra",
"https://open.larksuite.com/open-apis/bot/v2/hook/token?query=1",
} {
if _, err := parseLarkWebhookURL(raw); err == nil {
t.Errorf("parseLarkWebhookURL(%q) accepted an invalid group bot webhook", raw)
}
}
}
func TestValidateLarkNotificationConfig(t *testing.T) {
valid := map[string]any{
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
"signing_enabled": true,
"secret": "demo",
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
}
if err := validateLarkNotificationConfig(valid); err != nil {
t.Fatalf("valid config = %v", err)
}
unsigned := map[string]any{
"url": valid["url"],
"signing_enabled": false,
"payload_template": valid["payload_template"],
}
if err := validateLarkNotificationConfig(unsigned); err != nil {
t.Fatalf("unsigned config = %v", err)
}
if secret := larkSigningSecret(map[string]any{"signing_enabled": false, "secret": "demo"}); secret != "" {
t.Fatalf("disabled signing secret = %q", secret)
}
if secret := larkSigningSecret(valid); secret != "demo" {
t.Fatalf("enabled signing secret = %q", secret)
}
for _, config := range []map[string]any{
{"payload_template": valid["payload_template"]},
{"url": valid["url"]},
{"url": valid["url"], "signing_enabled": true, "payload_template": valid["payload_template"]},
} {
if err := validateLarkNotificationConfig(config); err == nil {
t.Fatalf("invalid config was accepted: %#v", config)
}
}
}
func TestSanitizeLarkRequestErrorRemovesWebhookURL(t *testing.T) {
const webhookURL = "https://open.feishu.cn/open-apis/bot/v2/hook/sensitive-token"
err := sanitizeLarkRequestError(&url.Error{Op: "Post", URL: webhookURL, Err: errors.New("dial failed")})
if strings.Contains(err.Error(), "sensitive-token") || err.Error() != "dial failed" {
t.Fatalf("sanitized error = %q", err)
}
}
+38 -3
View File
@@ -41,6 +41,7 @@ var notificationChannels = []string{
"bark",
"pushplus",
"wecom",
"lark",
}
var notificationFields = map[string]map[string]string{
@@ -65,6 +66,9 @@ var notificationFields = map[string]map[string]string{
"wecom": {
"urls": "strings", "payload_template": "string",
},
"lark": {
"url": "string", "signing_enabled": "boolean", "secret": "string", "payload_template": "string",
},
}
// routeSettingsAPI is intentionally independent of the main router so it can
@@ -240,6 +244,20 @@ func decodeNotificationConfig(
if err != nil {
return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err)
}
if enabled && channel == "lark" {
var resolved map[string]any
if err := json.Unmarshal(config, &resolved); err != nil {
return false, nil, fmt.Errorf("decode lark notification config: %w", err)
}
signingEnabled, _ := resolved["signing_enabled"].(bool)
if signingEnabled && configString(resolved, "url") != store.SecretMask &&
configString(resolved, "secret") == store.SecretMask {
return false, nil, errors.New("lark.secret must be re-entered when lark.url changes")
}
if err := validateLarkNotificationConfig(resolved); err != nil {
return false, nil, err
}
}
return enabled, config, nil
}
@@ -265,6 +283,9 @@ func validateNotificationField(
if name == "text_template" {
limit = 32768
}
if channel == "lark" && name == "payload_template" {
limit = maxLarkPayloadBytes
}
if len(value) > limit || strings.ContainsAny(value, "\x00") {
return fmt.Errorf("%s is too long or contains invalid characters", field)
}
@@ -300,6 +321,16 @@ func validateNotificationField(
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
}
}
if channel == "lark" && name == "payload_template" && value != "" {
if _, err := renderLarkPayload(value, larkTestValues(time.Unix(0, 0))); err != nil {
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
}
}
if channel == "lark" && name == "url" && value != "" && value != store.SecretMask {
if _, err := parseLarkWebhookURL(value); err != nil {
return fmt.Errorf("%s must be a valid Feishu or Lark group bot webhook URL: %w", field, err)
}
}
case "integer":
var value int
if err := json.Unmarshal(raw, &value); err != nil {
@@ -387,7 +418,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" && channel != "wecom" {
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" && channel != "lark" {
writeError(
w,
http.StatusNotImplemented,
@@ -441,6 +472,8 @@ func (s *Server) handleNotificationTest(
err = sendBarkNotificationTest(notificationContext, resolved)
case "wecom":
err = sendWecomNotificationTest(notificationContext, resolved)
case "lark":
err = sendLarkNotificationTest(notificationContext, resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -546,8 +579,8 @@ func (s *Server) resolveNotificationTestConfig(
// 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.
// request. Provider webhook URLs can be sensitive lists, unlike the
// string-based secrets used by the other notification channels.
func mergeNotificationTestSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
@@ -595,6 +628,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
}
case "wecom":
return validateWecomNotificationConfig(config)
case "lark":
return validateLarkNotificationConfig(config)
case "telegram":
token := configString(config, "bot_token")
if token == "" || token == store.SecretMask {
+137 -1
View File
@@ -73,7 +73,7 @@ func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) m
return response
}
func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) {
func TestNotificationSettingsAlwaysReturnsKnownChannelsAndPreservesSecrets(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "")
if recorder.Code != http.StatusOK {
@@ -179,6 +179,73 @@ func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
}
}
func TestLarkNotificationSettingsPreserveSecrets(t *testing.T) {
test := newSettingsAPITest(t)
webhookURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
secret := "lark-signing-secret"
template := `{"msg_type":"text","content":{"text":{{message}}}}`
first, err := json.Marshal(map[string]any{
"lark": map[string]any{
"enabled": true, "url": webhookURL, "signing_enabled": true, "secret": secret, "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("lark-token")) || bytes.Contains(recorder.Body.Bytes(), []byte(secret)) {
t.Fatalf("PUT response leaked Lark secrets: %s", recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
lark := response["data"].(map[string]any)["lark"].(map[string]any)
if lark["url"] != store.SecretMask || lark["secret"] != store.SecretMask {
t.Fatalf("redacted Lark config = %#v", lark)
}
second, err := json.Marshal(map[string]any{
"lark": map[string]any{
"enabled": true, "url": store.SecretMask, "signing_enabled": true, "secret": 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(), "lark")
if err != nil || !bytes.Contains(stored.Config, []byte("lark-token")) || !bytes.Contains(stored.Config, []byte(secret)) {
t.Fatalf("stored Lark config = %s, err = %v", stored.Config, err)
}
}
func TestUnsignedLarkNotificationDoesNotCreateSigningSecret(t *testing.T) {
test := newSettingsAPITest(t)
template := `{"msg_type":"text","content":{"text":{{message}}}}`
body, err := json.Marshal(map[string]any{
"lark": map[string]any{
"enabled": true, "url": "https://open.larksuite.com/open-apis/bot/v2/hook/token",
"signing_enabled": false, "payload_template": template,
},
})
if err != nil {
t.Fatal(err)
}
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(body))
if recorder.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
lark := response["data"].(map[string]any)["lark"].(map[string]any)
if _, exists := lark["secret"]; exists {
t.Fatalf("unsigned Lark config unexpectedly contains a secret: %#v", lark)
}
}
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
raw, err := json.Marshal(map[string]any{
@@ -232,6 +299,45 @@ func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *
}
}
func TestResolveLarkNotificationTestConfigMergesMaskedSecrets(t *testing.T) {
test := newSettingsAPITest(t)
storedURL := "https://open.larksuite.com/open-apis/bot/v2/hook/stored"
storedConfig, err := json.Marshal(map[string]any{
"url": storedURL,
"signing_enabled": true,
"secret": "stored-signing-secret",
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
Channel: "lark",
Config: storedConfig,
}); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{
"url": store.SecretMask,
"signing_enabled": true,
"secret": store.SecretMask,
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "lark", raw)
if err != nil {
t.Fatal(err)
}
if resolved["url"] != storedURL {
t.Fatalf("resolved URL = %#v", resolved["url"])
}
if resolved["secret"] != "stored-signing-secret" {
t.Fatalf("resolved secret = %#v", resolved["secret"])
}
}
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
test := newSettingsAPITest(t)
cases := []struct {
@@ -284,6 +390,36 @@ func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`,
code: "invalid_notification_config",
},
{
name: "invalid Lark payload template",
body: `{"lark":{"enabled":true,"payload_template":"[]"}}`,
code: "invalid_notification_config",
},
{
name: "enabled Lark config without webhook URL",
body: `{"lark":{"enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
code: "invalid_notification_config",
},
{
name: "enabled Lark signing without secret",
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/token","signing_enabled":true,"payload_template":"{\"msg_type\":\"text\"}"}}`,
code: "invalid_notification_config",
},
{
name: "changed Lark URL with masked signing secret",
body: `{"lark":{"enabled":true,"url":"https://open.larksuite.com/open-apis/bot/v2/hook/new-token","signing_enabled":true,"secret":"********","payload_template":"{\"msg_type\":\"text\"}"}}`,
code: "invalid_notification_config",
},
{
name: "insecure Lark group bot URL",
body: `{"lark":{"enabled":false,"url":"http://open.larksuite.com/open-apis/bot/v2/hook/token"}}`,
code: "invalid_notification_config",
},
{
name: "non-Lark group bot URL",
body: `{"lark":{"enabled":false,"url":"https://example.com/open-apis/bot/v2/hook/token"}}`,
code: "invalid_notification_config",
},
{
name: "null body",
body: `null`,
+4 -2
View File
@@ -24,7 +24,7 @@ import (
const smsNotificationPollInterval = 2 * time.Second
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom", "lark"}
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", "wecom":
case "bark", "email", "webhook", "wecom", "lark":
if err := validateNotificationTestConfig(channel, config); err != nil {
return err
}
@@ -204,6 +204,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
return sendWebhookSMSNotification(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
case "lark":
return sendLarkNotification(ctx, config, larkSMSValues(message))
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
+31
View File
@@ -66,6 +66,31 @@ func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
}
}
func TestLarkTemplateValuesCoverSMSAndAutomaticTasks(t *testing.T) {
message := smsNotification{
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
Number: "+447386", Time: time.Unix(1_700_000_000, 0), Content: "hello",
}
smsValues := larkSMSValues(message)
if smsValues["event"] != "sms.received" || smsValues["title"] != "收到新短信" ||
smsValues["message"] != message.Text() || smsValues["content"] != "hello" ||
smsValues["device_label"] != "EC20" {
t.Fatalf("Lark SMS values = %#v", smsValues)
}
taskValues := larkAutomaticTaskValues(automaticTaskNotification{
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
})
if taskValues["event"] != "automatic_task.completed" || taskValues["title"] != "自动任务执行成功" || taskValues["message"] != "任务已完成" {
t.Fatalf("Lark automatic task values = %#v", taskValues)
}
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
if taskValues[name] != "" {
t.Fatalf("%s = %q, want empty", name, taskValues[name])
}
}
}
func TestValidateSMSNotificationConfig(t *testing.T) {
valid := map[string]map[string]any{
"bark": {"urls": []any{"https://api.day.app/key"}},
@@ -76,6 +101,12 @@ func TestValidateSMSNotificationConfig(t *testing.T) {
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
},
"lark": {
"url": "https://open.larksuite.com/open-apis/bot/v2/hook/secret",
"signing_enabled": true,
"secret": "signing-secret",
"payload_template": `{"msg_type":"text","content":{"text":{{message}}}}`,
},
}
for channel, config := range valid {
if err := validateSMSNotificationConfig(channel, config); err != nil {
+50
View File
@@ -917,6 +917,56 @@ func TestNotificationArraySecretPreservation(t *testing.T) {
}
}
func TestLarkNotificationSecretsAreRedactedAndPreserved(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
originalURL := "https://open.feishu.cn/open-apis/bot/v2/hook/lark-token"
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "lark", Enabled: true,
Config: json.RawMessage(`{"url":"` + originalURL + `","secret":"signing-secret"}`),
}); err != nil {
t.Fatal(err)
}
setting, err := database.NotificationSetting(ctx, "lark")
if err != nil {
t.Fatal(err)
}
var redacted map[string]any
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
t.Fatal(err)
}
if redacted["url"] != SecretMask || redacted["secret"] != SecretMask {
t.Fatalf("redacted Lark config = %#v", redacted)
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "lark", Enabled: true,
Config: json.RawMessage(`{"url":"` + SecretMask + `","secret":"` + SecretMask + `"}`),
}); err != nil {
t.Fatal(err)
}
setting, err = database.NotificationSetting(ctx, "lark")
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("signing-secret")) {
t.Fatalf("stored Lark config = %s", setting.Config)
}
}
func TestNotificationRedactionKeepsEmptySensitiveValuesEmpty(t *testing.T) {
setting := NotificationSetting{
Config: json.RawMessage(`{"url":"","secret":""}`),
SensitiveFields: []string{"url", "secret"},
}
var redacted map[string]any
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
t.Fatal(err)
}
if redacted["url"] != "" || redacted["secret"] != "" {
t.Fatalf("empty sensitive values were masked: %#v", redacted)
}
}
func TestEventsPoliciesAndTraffic(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
+3
View File
@@ -610,6 +610,9 @@ func mergeJSONSecrets(
func redactJSONValue(value any, replacement string) any {
switch typed := value.(type) {
case string:
if typed == "" {
return ""
}
return replacement
case []any:
result := make([]any, len(typed))
+2
View File
@@ -24,6 +24,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
return []string{"token"}
case "wecom":
return []string{"urls"}
case "lark":
return []string{"url", "secret"}
default:
return nil
}
+1 -1
View File
@@ -56,7 +56,7 @@ export function PushplusTab({ value, onChange }: ChannelProps<PushplusForm>) {
<ChannelHeader title={t("启用 Pushplus 推送")} enabled={value.enabled} onToggle={(enabled) => onChange({ enabled })} />
<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("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
</div>
<Field label="Token">
<Input value={value.token} onChange={(e) => onChange({ token: e.target.value })} disabled={off} placeholder={t("Pushplus 用户的 Token")} />
+82 -8
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, WecomForm } from "./model";
import type { BarkForm, EmailForm, HeaderRow, LarkForm, WebhookForm, WecomForm } from "./model";
const HEADER_LIST_ID = "vocat-webhook-header-names";
@@ -21,11 +21,11 @@ function hasAnyUrl(urls: string[]): boolean {
return Array.isArray(urls) && urls.some((url) => String(url || "").trim().length > 0);
}
function SMSOnlyHint() {
function OneWayNotificationHint() {
const { t } = useI18n();
return (
<div className="mb-4 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("该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。")}
{t("该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。")}
</div>
);
}
@@ -51,7 +51,7 @@ export function BarkTab({ value, onChange, testing, onTest }: PushChannelProps<B
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<UrlListEditor
urls={value.urls}
@@ -97,7 +97,7 @@ export function EmailTab({ value, onChange, testing, onTest }: PushChannelProps<
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-10">
<Field label={t("SMTP 主机")} className="sm:col-span-5">
@@ -163,7 +163,7 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<div className="space-y-4">
<UrlListEditor
urls={value.urls}
@@ -284,7 +284,7 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
</Button>
}
/>
<SMSOnlyHint />
<OneWayNotificationHint />
<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。")}
@@ -300,7 +300,81 @@ export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
{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>
);
}
export function LarkTab({ value, onChange, testing, onTest }: PushChannelProps<LarkForm>) {
const { t, lang } = useI18n();
const off = !value.enabled;
const complete = !!value.url.trim() && !!value.payloadTemplate.trim() && (!value.signingEnabled || !!value.secret.trim());
return (
<div className="pt-2">
<ChannelHeader
title={t("启用飞书 / Lark 群自定义机器人通知")}
enabled={value.enabled}
onToggle={(enabled) => onChange({ enabled })}
actions={
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
{t("测试通知")}
</Button>
}
/>
<OneWayNotificationHint />
<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("支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。")}
</div>
<Field label={t("群机器人 Webhook URL")}>
<Input
value={value.url}
onChange={(event) => {
const url = event.target.value;
onChange(value.url === "********" && url !== value.url ? { url, secret: "" } : { url });
}}
disabled={off}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
/>
</Field>
<div className="space-y-1">
<label className="block text-xs font-bold uppercase tracking-wider text-gray-500">{t("启用签名校验")}</label>
<div className="flex h-10 items-center">
<Switch checked={value.signingEnabled} onChange={(signingEnabled) => onChange({ signingEnabled })} disabled={off} />
</div>
</div>
{value.signingEnabled ? (
<Field
label={t("签名密钥 (Secret)")}
hint={t("填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。")}
>
<Input
value={value.secret}
onChange={(event) => onChange({ secret: event.target.value })}
disabled={off}
type="password"
placeholder={t("群机器人签名密钥")}
/>
</Field>
) : null}
<Field
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : ". "}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
+43
View File
@@ -59,6 +59,14 @@ export interface WecomForm {
payloadTemplate: string;
}
export interface LarkForm {
enabled: boolean;
url: string;
signingEnabled: boolean;
secret: string;
payloadTemplate: string;
}
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": {
@@ -66,6 +74,13 @@ export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
}
}`;
export const DEFAULT_LARK_PAYLOAD_TEMPLATE = `{
"msg_type": "text",
"content": {
"text": {{message}}
}
}`;
export interface NotifyForms {
telegram: TelegramForm;
webhook: WebhookForm;
@@ -73,6 +88,7 @@ export interface NotifyForms {
email: EmailForm;
pushplus: PushplusForm;
wecom: WecomForm;
lark: LarkForm;
}
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
@@ -148,6 +164,7 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const wecom = asRecord(data.wecom);
const lark = asRecord(data.lark);
return {
telegram: {
enabled: !!telegram.enabled,
@@ -197,6 +214,13 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
urls: strList(wecom.urls),
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
},
lark: {
enabled: !!lark.enabled,
url: str(lark.url),
signingEnabled: !!lark.signingEnabled,
secret: lark.signingEnabled ? str(lark.secret) : "",
payloadTemplate: str(lark.payloadTemplate ?? lark.payload_template) || DEFAULT_LARK_PAYLOAD_TEMPLATE,
},
};
}
@@ -255,6 +279,24 @@ export function buildWecomPayload(form: WecomForm, forTest = false) {
};
}
export function buildLarkPayload(form: LarkForm, forTest = false) {
const payload: {
enabled: boolean;
url?: string;
signing_enabled: boolean;
payload_template: string;
secret?: string;
} = {
enabled: !!form.enabled,
signing_enabled: !!form.signingEnabled,
payload_template: String(form.payloadTemplate || ""),
};
const url = forTest ? String(form.url || "").trim() : String(form.url || "");
if (url) payload.url = url;
if (form.signingEnabled) payload.secret = String(form.secret || "");
return payload;
}
export function buildNotificationsPayload(forms: NotifyForms) {
return {
telegram: {
@@ -276,5 +318,6 @@ export function buildNotificationsPayload(forms: NotifyForms) {
webhook: buildWebhookPayload(forms.webhook),
bark: buildBarkPayload(forms.bark),
wecom: buildWecomPayload(forms.wecom),
lark: buildLarkPayload(forms.lark),
};
}
+16 -3
View File
@@ -325,6 +325,7 @@ export const EN_DICT: Record<string, string> = {
"Bark 测试失败": "Bark test failed",
"Email 测试失败": "Email test failed",
"企业微信消息推送测试失败": "WeCom message push test failed",
"飞书 / Lark 群机器人通知测试失败": "Feishu / Lark group bot notification test failed",
// ---- 设置页:安全卡 ----
: "Security",
@@ -394,8 +395,8 @@ export const EN_DICT: Record<string, string> = {
"启用后会推送新短信,并允许指定管理员通过 Bot 查看状态、切卡、管理 WiFi Calling、发送短信和限时拨号。拨号只执行呼叫并自动挂断,不处理音频。":
"When enabled, new SMS messages are pushed and the designated administrator can check status, switch profiles, manage WiFi Calling, send SMS, and place timed calls. Calls only dial and hang up automatically; audio is not processed.",
"启用 Pushplus 推送": "Enable Pushplus",
"该渠道只推送新收到的短信,不提供设备控制功能。每条短信都会单独推送,不按内容合并。":
"This channel only pushes newly received SMS messages and provides no device controls. Every SMS is pushed separately and is not merged by content.",
"该渠道仅用于单向通知,不提供设备控制功能。新短信会逐条推送;启用通知的自动任务也会推送执行结果。":
"This channel is for one-way notifications only and provides no device controls. New SMS messages are delivered individually, and notification-enabled automatic tasks also send their results.",
"例如 123456": "e.g. 123456",
"接收短信通知和命令回复的私聊或群组 ID。群组 ID 可以是负数。":
"Private chat or group ID that receives SMS notifications and command replies. Group IDs may be negative.",
@@ -424,7 +425,9 @@ export const EN_DICT: Record<string, string> = {
"启用 Webhook 推送": "Enable Webhook",
"企业微信消息推送": "WeCom Message Push",
"启用企业微信消息推送": "Enable WeCom Message Push",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
"飞书 / Lark 群机器人": "Feishu / Lark Group Bot",
"启用飞书 / Lark 群自定义机器人通知": "Enable Feishu / Lark Custom Group Bot Notifications",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信 / 飞书 / Lark 群机器人": "Telegram / Bark / Email / Pushplus / Webhook / WeCom / Feishu / Lark Group Bot",
"目标 URLs": "Target URLs",
"添加 URL": "Add URL",
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
@@ -436,6 +439,16 @@ export const EN_DICT: Record<string, string> = {
"支持完整企业微信消息推送 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}}.",
"支持飞书 open.feishu.cn 与国际版 Lark open.larksuite.com 的群自定义机器人 Webhook,无需创建应用。":
"Supports custom group bot Webhooks for Feishu at open.feishu.cn and international Lark at open.larksuite.com; no app is required.",
"群机器人 Webhook URL": "Group Bot Webhook URL",
: "Enable Signature Verification",
"签名密钥 (Secret)": "Signing Secret",
"填写群机器人安全设置生成的签名密钥;Webhook URL 与密钥都会作为敏感配置并在页面中脱敏。":
"Enter the signing secret generated in the group bot security settings. The Webhook URL and secret are treated as sensitive and masked in the UI.",
: "Group bot signing secret",
"支持完整飞书 / Lark 群自定义机器人 JSON。变量必须作为 JSON 值使用,例如":
"Supports a complete Feishu / Lark custom group bot JSON payload. Use variables as JSON values, for example",
"分组 (Group)": "Group",
"例如 vocat": "e.g. vocat",
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
+25 -3
View File
@@ -13,6 +13,7 @@ import { useAuth } from "../store/auth";
import {
buildBarkPayload,
buildEmailPayload,
buildLarkPayload,
buildNotificationsPayload,
buildWecomPayload,
buildWebhookPayload,
@@ -21,7 +22,7 @@ import {
type NotifyForms,
} from "../components/settings/model";
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { BarkTab, EmailTab, LarkTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
@@ -36,6 +37,7 @@ const NOTIFY_TABS = [
{ key: "pushplus", label: "Pushplus" },
{ key: "webhook", label: "Webhook" },
{ key: "wecom", label: "企业微信消息推送" },
{ key: "lark", label: "飞书 / Lark 群机器人" },
];
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
@@ -54,6 +56,7 @@ export default function SettingsPage() {
const [testingBark, setTestingBark] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testingWecom, setTestingWecom] = useState(false);
const [testingLark, setTestingLark] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [applyingUpdate, setApplyingUpdate] = useState(false);
@@ -265,10 +268,11 @@ export default function SettingsPage() {
setSavingNotif(true);
try {
// vocat 后端 PUT 成功即返回完整配置文档(参考实现返回 {applied, warning}
await api("/settings/notifications", {
const data = await api<NotificationSettings>("/settings/notifications", {
method: "PUT",
body: buildNotificationsPayload(forms),
});
setForms(formsFromNotifications(data));
message.success(t("通知配置已保存"));
} catch (error) {
message.error(apiMessage(error) || t("通知配置保存失败"));
@@ -338,6 +342,21 @@ export default function SettingsPage() {
}
}, [forms.wecom]);
const onTestLark = useCallback(async () => {
setTestingLark(true);
try {
await api("/settings/notifications/lark/test", {
method: "POST",
body: buildLarkPayload(forms.lark, true),
});
message.success(t("测试通知已发送"));
} catch (error) {
message.error(apiMessage(error) || t("飞书 / Lark 群机器人通知测试失败"));
} finally {
setTestingLark(false);
}
}, [forms.lark]);
const onCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
@@ -466,7 +485,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 / 企业微信 / 飞书 / Lark 群机器人")} />
</div>
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
{t("保存通知配置")}
@@ -500,6 +519,9 @@ export default function SettingsPage() {
{activeTab === "wecom" ? (
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
) : null}
{activeTab === "lark" ? (
<LarkTab value={forms.lark} onChange={(p) => updateChannel("lark", p)} testing={testingLark} onTest={onTestLark} />
) : null}
</div>
)}
</div>
+1
View File
@@ -420,6 +420,7 @@ export interface NotificationSettings {
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
lark: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import ts from "typescript";
const source = await readFile(new URL("../src/components/settings/model.ts", import.meta.url), "utf8");
const compiled = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.ES2022,
target: ts.ScriptTarget.ES2022,
},
});
const moduleURL = `data:text/javascript;base64,${Buffer.from(compiled.outputText).toString("base64")}`;
const {
DEFAULT_LARK_PAYLOAD_TEMPLATE,
buildLarkPayload,
buildNotificationsPayload,
formsFromNotifications,
} = await import(moduleURL);
test("loads a masked signed Lark group bot config", () => {
const forms = formsFromNotifications({
lark: {
enabled: true,
url: "********",
signingEnabled: true,
secret: "********",
payloadTemplate: '{"msg_type":"text"}',
},
});
assert.deepEqual(forms.lark, {
enabled: true,
url: "********",
signingEnabled: true,
secret: "********",
payloadTemplate: '{"msg_type":"text"}',
});
});
test("does not expose a dormant signing secret when signing is disabled", () => {
const forms = formsFromNotifications({
lark: {
enabled: true,
url: "********",
signingEnabled: false,
secret: "********",
},
});
assert.equal(forms.lark.secret, "");
assert.equal(forms.lark.payloadTemplate, DEFAULT_LARK_PAYLOAD_TEMPLATE);
});
test("builds the single group bot webhook contract", () => {
const signed = buildLarkPayload({
enabled: true,
url: " https://open.larksuite.com/open-apis/bot/v2/hook/token ",
signingEnabled: true,
secret: "demo",
payloadTemplate: '{"msg_type":"text"}',
}, true);
assert.deepEqual(signed, {
enabled: true,
url: "https://open.larksuite.com/open-apis/bot/v2/hook/token",
signing_enabled: true,
secret: "demo",
payload_template: '{"msg_type":"text"}',
});
const unsigned = buildLarkPayload({
enabled: false,
url: "",
signingEnabled: false,
secret: "stale-secret",
payloadTemplate: DEFAULT_LARK_PAYLOAD_TEMPLATE,
});
assert.equal(Object.hasOwn(unsigned, "url"), false);
assert.equal(Object.hasOwn(unsigned, "secret"), false);
});
test("includes Lark in the complete notification settings payload", () => {
const forms = formsFromNotifications({});
const payload = buildNotificationsPayload(forms);
assert.deepEqual(payload.lark, {
enabled: false,
signing_enabled: false,
payload_template: DEFAULT_LARK_PAYLOAD_TEMPLATE,
});
});