9 Commits
Author SHA1 Message Date
MengMengCode 5bb5808706 fix: update expected local time format in WeCom SMS values test 2026-08-11 23:06:48 +08:00
Meng MengandGitHub d70937cc47 Merge pull request #2 from zAhYAng/feat/wecom-notifications
feat: add WeCom message push notifications
2026-08-11 22:56:19 +08:00
zAhYAng eab658dc90 fix: polish WeCom notification settings 2026-08-11 22:28:36 +08:00
MengMengCode 0d738d4ce4 feat: add custom phone number support to card policies
- Introduced a new field `custom_phone_number` in the CardPolicy model and database schema.
- Updated the API to handle custom phone number input, including validation and normalization.
- Modified the CardPolicyPanel component to allow users to set and save a custom phone number.
- Enhanced the settings API to include the custom phone number in responses and updates.
- Added tests to ensure the correct functionality of custom phone number handling.
- Removed hardcoded environment variable for VOCAT_ADDR in service files.
2026-08-11 21:58:28 +08:00
zAhYAng 962c58fdd1 feat: add WeCom notification settings 2026-08-11 21:41:43 +08:00
zAhYAng 7b2e005b37 feat: dispatch WeCom notifications 2026-08-11 21:39:53 +08:00
zAhYAng f1e70ecee5 feat: configure WeCom notifications 2026-08-11 21:38:09 +08:00
zAhYAng f012c556e9 feat: add WeCom payload renderer 2026-08-11 21:33:38 +08:00
zAhYAng ab8bbbc1ed docs: plan WeCom notifications and worktree setup 2026-08-11 21:19:02 +08:00
33 changed files with 1665 additions and 155 deletions
+1
View File
@@ -55,3 +55,4 @@ Thumbs.db
# ---- Claude Code / agent ----
.claude/
.worktrees/
+2 -2
View File
@@ -32,8 +32,8 @@ Usage:
GITHUB_TOKEN Optional bearer token for private repos
or higher rate limits.
vocat menu Interactive lifecycle menu (root on the host):
toggle language, change password, restart, update,
uninstall.
toggle language, change password, change the Web port,
restart, update, uninstall.
vocat help Show this help message.
When run without a subcommand on a non-TTY (e.g. systemd), vocat starts the
+11 -8
View File
@@ -1070,15 +1070,18 @@ func enforceCardRegion(
}
}
if snapshot.ICCID != "" {
policy := store.CardPolicy{
ICCID: snapshot.ICCID,
NetworkEnabled: false,
VoWiFiEnabled: false,
AirplaneEnabled: true,
IPVersion: "IPV4V6",
Source: cardPolicySourceRegionBlock,
policy, policyErr := database.CardPolicy(ctx, snapshot.ICCID)
if errors.Is(policyErr, store.ErrNotFound) {
policy = store.CardPolicy{ICCID: snapshot.ICCID, IPVersion: "IPV4V6"}
policyErr = nil
}
if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
policy.NetworkEnabled = false
policy.VoWiFiEnabled = false
policy.AirplaneEnabled = true
policy.Source = cardPolicySourceRegionBlock
if policyErr != nil && ctx.Err() == nil {
logger.Warn("region block: failed to read card policy", "device_id", id, "iccid", snapshot.ICCID, "error", policyErr)
} else if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil {
logger.Warn(
"region block: failed to persist card policy",
"device_id", id, "iccid", snapshot.ICCID, "error", err,
+256 -51
View File
@@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"strconv"
"strings"
"time"
@@ -25,6 +27,11 @@ import (
// rewrite it or the next restart reverts the password.
const envFilePath = "/etc/vocat/env"
// legacyEnvFilePath was used by the standalone deploy/vocat.service. Keep it
// discoverable so the menu works on installations made before the installer
// and service template converged on /etc/vocat/env.
const legacyEnvFilePath = "/etc/vocat/vocat.env"
const systemdUnitPath = "/etc/systemd/system/vocat.service"
// defaultDatabasePath is the install-default SQLite location written into the
@@ -50,7 +57,7 @@ func loadMenuEnv() {
if _, ok := os.LookupEnv("VOCAT_DATABASE_PATH"); !ok {
_ = os.Setenv("VOCAT_DATABASE_PATH", defaultDatabasePath)
}
if data, err := os.ReadFile(envFilePath); err == nil {
if data, err := os.ReadFile(menuEnvFilePath()); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
@@ -69,10 +76,20 @@ func loadMenuEnv() {
}
}
func menuEnvFilePath() string {
if _, err := os.Stat(envFilePath); err == nil {
return envFilePath
}
if _, err := os.Stat(legacyEnvFilePath); err == nil {
return legacyEnvFilePath
}
return envFilePath
}
// runMenu is the interactive lifecycle menu: toggle language, change password,
// restart the systemd unit, self-update, or fully uninstall vocat. It must run
// as root on the host (needs systemctl + the 0600 env file). Docker deployments
// do not use it.
// change the Web listener port, restart the systemd unit, self-update, or fully
// uninstall vocat. It must run as root on the host (needs systemctl + the 0600
// env file). Docker deployments do not use it.
func runMenu(logger *slog.Logger) error {
if os.Geteuid() != 0 {
return errors.New("vocat menu must run as root (needs systemctl and /etc/vocat/env)")
@@ -113,10 +130,14 @@ func runMenu(logger *slog.Logger) error {
fmt.Println(menu.errorPrefix(err))
}
case "3":
if err := menuRestart(menu); err != nil {
if err := menuChangeWebPort(reader, menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "4":
if err := menuRestart(menu); err != nil {
fmt.Println(menu.errorPrefix(err))
}
case "5":
if err := menuUpdate(menu, logger); err != nil {
fmt.Println(menu.errorPrefix(err))
}
@@ -242,9 +263,18 @@ func readPasswordMasked() (string, error) {
// the temp file lives in the same directory so os.Rename stays on one
// filesystem.
func rewriteEnvPassword(newPassword string) error {
const key = "VOCAT_ADMIN_PASSWORD="
return rewriteEnvValue(menuEnvFilePath(), "VOCAT_ADMIN_PASSWORD", newPassword)
}
// rewriteEnvValue replaces or appends one systemd EnvironmentFile value. The
// write is atomic and rejects line breaks so one setting cannot inject another.
func rewriteEnvValue(path, name, value string) error {
if name == "" || strings.ContainsAny(name, "=\r\n\x00") || strings.ContainsAny(value, "\r\n\x00") {
return errors.New("invalid environment setting")
}
key := name + "="
var lines []string
if data, err := os.ReadFile(envFilePath); err == nil {
if data, err := os.ReadFile(path); err == nil {
lines = strings.Split(string(data), "\n")
} else if !errors.Is(err, os.ErrNotExist) {
return err
@@ -253,27 +283,34 @@ func rewriteEnvPassword(newPassword string) error {
replaced := false
for i, line := range lines {
if strings.HasPrefix(line, key) {
lines[i] = key + newPassword
lines[i] = key + value
replaced = true
break
}
}
if !replaced {
lines = append(lines, key+newPassword)
lines = append(lines, key+value)
}
content := strings.Join(lines, "\n")
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return writeEnvFileAtomic(path, []byte(content))
}
dir := envFilePath[:strings.LastIndex(envFilePath, "/")]
func writeEnvFileAtomic(path string, content []byte) error {
dirIndex := strings.LastIndexAny(path, "/\\")
if dirIndex < 0 {
return errors.New("environment file path has no directory")
}
dir := path[:dirIndex]
tmp, err := os.CreateTemp(dir, ".vocat-env-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.WriteString(content); err != nil {
if _, err := tmp.Write(content); err != nil {
_ = tmp.Close()
return err
}
@@ -284,7 +321,125 @@ func rewriteEnvPassword(newPassword string) error {
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, envFilePath)
return os.Rename(tmpName, path)
}
func menuChangeWebPort(reader *bufio.Reader, m *menu) error {
if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
_, currentPortText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
if err != nil {
return fmt.Errorf("%w: %v", errMenuConfig, err)
}
fmt.Println(m.currentWebAddress(cfg.Address))
fmt.Println(m.reverseProxyNotice())
fmt.Print(m.newWebPort(currentPortText))
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("read Web port: %w", err)
}
portText := strings.TrimSpace(line)
if portText == "" {
fmt.Println(m.webPortCancelled())
return nil
}
newAddress, newPort, err := webAddressWithPort(cfg.Address, portText)
if err != nil {
return errInvalidWebPort
}
currentPort, _ := strconv.Atoi(currentPortText)
if newPort == currentPort {
fmt.Println(m.webPortUnchanged())
return nil
}
listener, err := net.Listen("tcp", newAddress)
if err != nil {
return fmt.Errorf("%w: %v", errWebPortUnavailable, err)
}
_ = listener.Close()
environmentPath := menuEnvFilePath()
original, readErr := os.ReadFile(environmentPath)
originalExisted := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return fmt.Errorf("%w: %v", errMenuPortWrite, readErr)
}
if err := rewriteEnvValue(environmentPath, "VOCAT_ADDR", newAddress); err != nil {
return fmt.Errorf("%w: %v", errMenuPortWrite, err)
}
if err := restartVocatService(); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
if err := waitForWebListener(newAddress, 5*time.Second); err != nil {
rollbackErr := restoreMenuEnvFile(environmentPath, original, originalExisted)
_ = restartVocatService()
if rollbackErr != nil {
return fmt.Errorf("%w: %v; rollback failed: %v", errRestartFailed, err, rollbackErr)
}
return fmt.Errorf("%w: %v", errRestartFailed, err)
}
_ = os.Setenv("VOCAT_ADDR", newAddress)
fmt.Println(m.webPortChanged(newAddress))
return nil
}
func webAddressWithPort(address, portText string) (string, int, error) {
host, _, err := net.SplitHostPort(strings.TrimSpace(address))
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(strings.TrimSpace(portText))
if err != nil || port < 1 || port > 65535 {
return "", 0, errInvalidWebPort
}
return net.JoinHostPort(host, strconv.Itoa(port)), port, nil
}
func waitForWebListener(address string, timeout time.Duration) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
switch host {
case "", "0.0.0.0":
host = "127.0.0.1"
case "::":
host = "::1"
}
target := net.JoinHostPort(host, port)
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
connection, dialErr := net.DialTimeout("tcp", target, 500*time.Millisecond)
if dialErr == nil {
_ = connection.Close()
return nil
}
lastErr = dialErr
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("Web listener %s did not become reachable: %w", target, lastErr)
}
func restoreMenuEnvFile(path string, content []byte, existed bool) error {
if existed {
return writeEnvFileAtomic(path, content)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// menuToggleLanguage flips the persisted language preference between "zh" and
@@ -327,6 +482,14 @@ func menuToggleLanguage(m *menu, logger *slog.Logger) error {
}
func menuRestart(m *menu) error {
if err := restartVocatService(); err != nil {
return err
}
fmt.Println(m.restarted())
return nil
}
func restartVocatService() error {
if _, err := exec.LookPath("systemctl"); err != nil {
return errNoSystemctl
}
@@ -334,7 +497,9 @@ func menuRestart(m *menu) error {
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", errRestartFailed, strings.TrimSpace(string(out)))
}
fmt.Println(m.restarted())
if out, err := exec.Command("systemctl", "is-active", "--quiet", "vocat").CombinedOutput(); err != nil {
return fmt.Errorf("%w: service is not active: %s", errRestartFailed, strings.TrimSpace(string(out)))
}
return nil
}
@@ -378,6 +543,7 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
_ = os.Remove(systemdUnitPath)
_ = os.RemoveAll("/opt/vocat")
_ = os.Remove(envFilePath)
_ = os.Remove(legacyEnvFilePath)
_ = os.Remove("/etc/vocat") // succeeds only when empty
runIgnore("systemctl", "daemon-reload")
runIgnore("userdel", "vocat")
@@ -388,15 +554,18 @@ func menuUninstall(reader *bufio.Reader, m *menu) error {
// menu-local sentinel errors so callers can map them to localized messages.
var (
errCurrentWrong = errors.New("menu: current password is incorrect")
errPasswordsDiffer = errors.New("menu: passwords do not match")
errNoSystemctl = errors.New("menu: systemctl not found")
errRestartFailed = errors.New("menu: restart failed")
errUpdateFailed = errors.New("menu: update failed")
errMenuConfig = errors.New("menu: load configuration")
errMenuStore = errors.New("menu: open database")
errMenuAuth = errors.New("menu: auth service")
errMenuEnvWrite = errors.New("menu: write env file")
errCurrentWrong = errors.New("menu: current password is incorrect")
errPasswordsDiffer = errors.New("menu: passwords do not match")
errNoSystemctl = errors.New("menu: systemctl not found")
errRestartFailed = errors.New("menu: restart failed")
errUpdateFailed = errors.New("menu: update failed")
errMenuConfig = errors.New("menu: load configuration")
errMenuStore = errors.New("menu: open database")
errMenuAuth = errors.New("menu: auth service")
errMenuEnvWrite = errors.New("menu: write env file")
errMenuPortWrite = errors.New("menu: write Web port")
errInvalidWebPort = errors.New("menu: invalid Web port")
errWebPortUnavailable = errors.New("menu: Web port unavailable")
)
// ---- i18n ----
@@ -409,18 +578,28 @@ func newMenu(lang string) *menu { return &menu{lang: lang} }
func (m *menu) msg(key string) string {
const zh, en = 0, 1
table := map[string][2]string{
"title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
"opt_restart": {"3) 重启软件", "3) Restart software"},
"opt_update": {"4) 更新软件", "4) Update software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"cur_pw": {"当前密码: ", "Current password: "},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
"title": {"vocat 管理菜单", "vocat management menu"},
"opt_lang": {"1) 切换中英文", "1) Toggle language"},
"opt_change": {"2) 修改账号密码", "2) Change admin password"},
"opt_port": {"3) 修改 Web 监听端口", "3) Change Web listening port"},
"opt_restart": {"4) 重启软件", "4) Restart software"},
"opt_update": {"5) 更新软件", "5) Update software"},
"opt_uninstall": {"0) 卸载软件", "0) Uninstall software"},
"prompt": {"请选择: ", "Select: "},
"invalid": {"无效选项,请重试。按 Ctrl+C 退出。", "Invalid choice, try again. Press Ctrl+C to exit."},
"cur_pw": {"当前密码: ", "Current password: "},
"new_pw": {"新密码 (至少 12 位): ", "New password (min 12 chars): "},
"confirm_pw": {"确认新密码: ", "Confirm new password: "},
"pw_changed": {"密码已修改。重启后仍然有效。", "Password changed. Survives restart."},
"current_web_address": {"当前 Web 监听地址: %s", "Current Web listening address: %s"},
"new_web_port": {"新端口 (1-65535,直接回车取消,当前 %s): ", "New port (1-65535, Enter to cancel, current %s): "},
"web_port_cancelled": {"已取消修改端口。", "Web port change cancelled."},
"web_port_unchanged": {"端口未改变。", "Web port is unchanged."},
"web_port_changed": {"Web 监听地址已改为 %s,软件已重启。", "Web listening address changed to %s; software restarted."},
"reverse_proxy_notice": {
"如使用 Nginx/Caddy 等反向代理,请同步修改其上游端口。",
"If you use Nginx, Caddy, or another reverse proxy, update its upstream port too.",
},
"lang_switched": {
"语言已切换。Web 界面下次刷新后同步。",
"Language switched. The web UI syncs on next refresh.",
@@ -431,9 +610,9 @@ func (m *menu) msg(key string) string {
"警告: 将删除程序、数据与配置,且不可恢复!",
"WARNING: removes the program, data and config. Irreversible!",
},
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
"uninstall_confirm": {"输入 yes 确认卸载: ", "Type yes to confirm uninstall: "},
"uninstall_cancelled": {"已取消卸载。", "Uninstall cancelled."},
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
"uninstalled": {"vocat 已卸载。", "vocat uninstalled."},
}
entry, ok := table[key]
if !ok {
@@ -445,25 +624,36 @@ func (m *menu) msg(key string) string {
return entry[zh]
}
func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
func (m *menu) restarted() string { return m.msg("restarted") }
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
func (m *menu) title() string { return m.msg("title") }
func (m *menu) prompt() string { return m.msg("prompt") }
func (m *menu) invalid() string { return m.msg("invalid") }
func (m *menu) currentPassword() string { return m.msg("cur_pw") }
func (m *menu) newPassword() string { return m.msg("new_pw") }
func (m *menu) confirmPassword() string { return m.msg("confirm_pw") }
func (m *menu) passwordChanged() string { return m.msg("pw_changed") }
func (m *menu) currentWebAddress(address string) string {
return fmt.Sprintf(m.msg("current_web_address"), address)
}
func (m *menu) newWebPort(port string) string { return fmt.Sprintf(m.msg("new_web_port"), port) }
func (m *menu) webPortCancelled() string { return m.msg("web_port_cancelled") }
func (m *menu) webPortUnchanged() string { return m.msg("web_port_unchanged") }
func (m *menu) webPortChanged(address string) string {
return fmt.Sprintf(m.msg("web_port_changed"), address)
}
func (m *menu) reverseProxyNotice() string { return m.msg("reverse_proxy_notice") }
func (m *menu) languageSwitched() string { return m.msg("lang_switched") }
func (m *menu) updateChecking() string { return m.msg("upd_checking") }
func (m *menu) restarted() string { return m.msg("restarted") }
func (m *menu) uninstallWarn() string { return m.msg("uninstall_warn") }
func (m *menu) uninstallConfirm() string { return m.msg("uninstall_confirm") }
func (m *menu) uninstallCancelled() string { return m.msg("uninstall_cancelled") }
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
func (m *menu) uninstalled() string { return m.msg("uninstalled") }
func (m *menu) options() []string {
return []string{
m.msg("opt_lang"),
m.msg("opt_change"),
m.msg("opt_port"),
m.msg("opt_restart"),
m.msg("opt_update"),
m.msg("opt_uninstall"),
@@ -514,9 +704,24 @@ func (m *menu) errorPrefix(err error) string {
return "认证服务错误。"
case errors.Is(err, errMenuEnvWrite):
if m.lang == "en" {
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + envFilePath + "."
return "Password changed in DB, but the env file rewrite failed — restart will revert it. Check " + menuEnvFilePath() + "."
}
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + envFilePath + "。"
return "数据库密码已修改,但环境变量文件写入失败——重启后将回滚。请检查 " + menuEnvFilePath() + "。"
case errors.Is(err, errInvalidWebPort):
if m.lang == "en" {
return "Invalid port. Enter a number from 1 to 65535."
}
return "端口无效,请输入 1 到 65535。"
case errors.Is(err, errWebPortUnavailable):
if m.lang == "en" {
return "The new Web port is unavailable or already in use."
}
return "新的 Web 端口不可用或已被占用。"
case errors.Is(err, errMenuPortWrite):
if m.lang == "en" {
return "Failed to save the Web listening port to " + menuEnvFilePath() + "."
}
return "无法将 Web 监听端口保存到 " + menuEnvFilePath() + "。"
default:
if m.lang == "en" {
return "Error: " + err.Error()
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWebAddressWithPort(t *testing.T) {
tests := []struct {
name string
address string
port string
want string
wantErr bool
}{
{name: "IPv4", address: "0.0.0.0:7575", port: "8080", want: "0.0.0.0:8080"},
{name: "IPv6", address: "[::]:7575", port: "8443", want: "[::]:8443"},
{name: "minimum", address: "127.0.0.1:7575", port: "1", want: "127.0.0.1:1"},
{name: "maximum", address: "127.0.0.1:7575", port: "65535", want: "127.0.0.1:65535"},
{name: "zero", address: "0.0.0.0:7575", port: "0", wantErr: true},
{name: "too large", address: "0.0.0.0:7575", port: "65536", wantErr: true},
{name: "not numeric", address: "0.0.0.0:7575", port: "http", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, _, err := webAddressWithPort(test.address, test.port)
if test.wantErr {
if !errors.Is(err, errInvalidWebPort) {
t.Fatalf("error = %v, want errInvalidWebPort", err)
}
return
}
if err != nil || got != test.want {
t.Fatalf("webAddressWithPort() = %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestRewriteEnvValuePreservesOtherSettings(t *testing.T) {
path := filepath.Join(t.TempDir(), "env")
if err := os.WriteFile(path, []byte("VOCAT_ADMIN_PASSWORD=secret\nVOCAT_ADDR=0.0.0.0:7575\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:8080"); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
got := string(content)
if !strings.Contains(got, "VOCAT_ADMIN_PASSWORD=secret\n") || !strings.Contains(got, "VOCAT_ADDR=0.0.0.0:8080\n") || strings.Contains(got, ":7575") {
t.Fatalf("rewritten env = %q", got)
}
if err := rewriteEnvValue(path, "VOCAT_ADDR", "0.0.0.0:9000\nVOCAT_ADMIN_PASSWORD=changed"); err == nil {
t.Fatal("environment line injection was accepted")
}
}
func TestMenuIncludesWebPortOptionInBothLanguages(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
options := strings.Join(newMenu(lang).options(), "\n")
if !strings.Contains(options, "3)") || !strings.Contains(strings.ToLower(options), "web") {
t.Fatalf("%s menu options do not contain Web port entry: %q", lang, options)
}
}
}
-1
View File
@@ -13,7 +13,6 @@ Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=20s
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
EnvironmentFile=/etc/vocat/vocat.env
@@ -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)
}
+13 -1
View File
@@ -274,7 +274,19 @@ func (s *Server) prepareAutomaticTaskEnvironment(ctx context.Context, config *st
if err := s.store.UpsertDevice(ctx, *config); err != nil {
return err
}
if err := s.store.UpsertCardPolicy(ctx, store.CardPolicy{ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true, IPVersion: "IPV4V6", Source: "automatic_task"}); err != nil {
policy, policyErr := s.store.CardPolicy(ctx, iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policy = defaultCardPolicy(iccid)
policyErr = nil
}
if policyErr != nil {
return policyErr
}
policy.NetworkEnabled = false
policy.VoWiFiEnabled = true
policy.AirplaneEnabled = true
policy.Source = "automatic_task"
if err := s.store.UpsertCardPolicy(ctx, policy); err != nil {
return err
}
if s.vowifi == nil {
+9 -5
View File
@@ -263,11 +263,15 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
if selected.Snapshot != nil {
iccid := strings.TrimSpace(selected.Snapshot.ICCID)
if iccid != "" {
if err := s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
}); err != nil {
s.writeStoreError(w, err)
_, policyErr := s.store.CardPolicy(r.Context(), iccid)
if errors.Is(policyErr, store.ErrNotFound) {
policyErr = s.store.UpsertCardPolicy(r.Context(), store.CardPolicy{
ICCID: iccid, VoWiFiEnabled: true, AirplaneEnabled: true,
IPVersion: "IPV4V6", Source: "default",
})
}
if policyErr != nil {
s.writeStoreError(w, policyErr)
return true
}
}
+97 -16
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 {
@@ -1227,17 +1272,18 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)})
case http.MethodPut:
var request struct {
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
VoWiFiEnabled *bool `json:"vowifi_enabled"`
AirplaneEnabled *bool `json:"airplane_enabled"`
APN *string `json:"apn"`
IPVersion *string `json:"ip_version"`
CustomPhoneNumber *string `json:"custom_phone_number"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if request.VoWiFiEnabled == nil && request.AirplaneEnabled == nil &&
request.APN == nil && request.IPVersion == nil {
request.APN == nil && request.IPVersion == nil && request.CustomPhoneNumber == nil {
writeError(
w,
http.StatusBadRequest,
@@ -1277,6 +1323,14 @@ func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid
}
policy.IPVersion = ipVersion
}
if request.CustomPhoneNumber != nil {
phoneNumber, phoneErr := normalizeCustomPhoneNumber(*request.CustomPhoneNumber)
if phoneErr != nil {
writeError(w, http.StatusBadRequest, "invalid_card_policy", phoneErr.Error())
return
}
policy.CustomPhoneNumber = phoneNumber
}
if request.VoWiFiEnabled != nil {
policy.VoWiFiEnabled = *request.VoWiFiEnabled
}
@@ -1575,15 +1629,42 @@ func validICCID(value string) bool {
return true
}
func normalizeCustomPhoneNumber(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
var normalized strings.Builder
digitCount := 0
for index, character := range value {
switch {
case character >= '0' && character <= '9':
normalized.WriteRune(character)
digitCount++
case character == '+' && index == 0:
normalized.WriteRune(character)
case character == ' ' || character == '-' || character == '(' || character == ')':
// Common visual separators are accepted but not persisted.
default:
return "", errors.New("custom phone number may contain only digits, a leading plus sign, spaces, parentheses, or hyphens")
}
}
if digitCount < 3 || digitCount > 20 {
return "", errors.New("custom phone number must contain between 3 and 20 digits")
}
return normalized.String(), nil
}
func cardPolicyResponse(policy store.CardPolicy) map[string]any {
response := map[string]any{
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"source": policy.Source,
"iccid": policy.ICCID,
"network_enabled": false,
"vowifi_enabled": policy.VoWiFiEnabled,
"airplane_enabled": policy.AirplaneEnabled,
"apn": policy.APN,
"ip_version": policy.IPVersion,
"custom_phone_number": policy.CustomPhoneNumber,
"source": policy.Source,
}
if !policy.CreatedAt.IsZero() {
response["created_at"] = policy.CreatedAt
+136 -3
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 {
@@ -426,10 +523,35 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
policy := response["data"].(map[string]any)
if policy["iccid"] != iccid || policy["source"] != "default" ||
policy["ip_version"] != "IPV4V6" || policy["vowifi_enabled"] != true ||
policy["airplane_enabled"] != true {
policy["airplane_enabled"] != true || policy["custom_phone_number"] != "" {
t.Fatalf("default policy = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86 (138) 0013-8000"}`,
)
if recorder.Code != http.StatusOK {
t.Fatalf("custom phone policy status = %d, body = %s", recorder.Code, recorder.Body)
}
response = decodeSettingsResponse(t, recorder)
policy = response["data"].(map[string]any)
if policy["custom_phone_number"] != "+8613800138000" {
t.Fatalf("normalized custom phone number = %#v", policy)
}
recorder = test.request(
t,
http.MethodPut,
"/api/cards/"+iccid+"/policy",
`{"custom_phone_number":"+86-CALL-ME"}`,
)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
recorder = test.request(
t,
http.MethodPut,
@@ -456,7 +578,7 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("saved policy = %#v", policy)
}
stored, err := test.database.CardPolicy(context.Background(), iccid)
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" {
if err != nil || !stored.VoWiFiEnabled || !stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("stored policy = %+v, %v", stored, err)
}
@@ -471,10 +593,21 @@ func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) {
t.Fatalf("partial policy status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" {
if err != nil || stored.VoWiFiEnabled || stored.AirplaneEnabled || stored.APN != "ims" || stored.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("partially updated policy = %+v, %v", stored, err)
}
// Clearing the override restores system-number display without affecting the
// rest of this ICCID's policy.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"custom_phone_number":""}`)
if recorder.Code != http.StatusOK {
t.Fatalf("clear custom phone status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err = test.database.CardPolicy(context.Background(), iccid)
if err != nil || stored.CustomPhoneNumber != "" || stored.APN != "ims" {
t.Fatalf("cleared custom phone policy = %+v, %v", stored, err)
}
// APN-only updates are accepted without changing either switch.
recorder = test.request(t, http.MethodPut, "/api/cards/"+iccid+"/policy", `{"apn":"mobile.example","ip_version":"ip"}`)
if recorder.Code != http.StatusOK {
+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)
}
+34
View File
@@ -36,12 +36,46 @@ 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)
}
wantLocalTime := message.Time.Local().Format("2006-01-02 15:04:05")
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != wantLocalTime {
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)
}
}
}
+39 -2
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:")
@@ -784,7 +821,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
if err := database.UpsertCardPolicy(ctx, CardPolicy{
ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true,
APN: "ims", IPVersion: "ipv4v6",
APN: "ims", IPVersion: "ipv4v6", CustomPhoneNumber: "+8613800138000",
}); err != nil {
t.Fatal(err)
}
@@ -794,7 +831,7 @@ func TestEventsPoliciesAndTraffic(t *testing.T) {
t.Fatalf("RF-safe VoWiFi policy was rejected: %v", err)
}
policy, err := database.CardPolicy(ctx, "89860001")
if err != nil || !policy.VoWiFiEnabled {
if err != nil || !policy.VoWiFiEnabled || policy.CustomPhoneNumber != "+8613800138000" {
t.Fatalf("CardPolicy() = %+v, %v", policy, err)
}
safePolicy, err := database.CardPolicy(ctx, "89860002")
+5
View File
@@ -255,6 +255,11 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_apn_profiles ADD COLUMN auth_type TEXT NOT NULL DEFAULT 'NONE'
CHECK (auth_type IN ('NONE', 'PAP', 'CHAP', 'PAP_OR_CHAP'))`,
}
case 15:
return []string{
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+58 -21
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 {
@@ -477,15 +474,16 @@ type LogFilter struct {
}
type CardPolicy struct {
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
Source string
CreatedAt time.Time
UpdatedAt time.Time
ICCID string
NetworkEnabled bool
VoWiFiEnabled bool
AirplaneEnabled bool
APN string
IPVersion string
CustomPhoneNumber string
Source string
CreatedAt time.Time
UpdatedAt time.Time
}
type CardAPNProfile struct {
@@ -575,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)
@@ -601,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
+9 -5
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
}
@@ -360,6 +362,7 @@ func maskedJSONValue(value json.RawMessage) bool {
func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
value.ICCID = strings.TrimSpace(value.ICCID)
value.CustomPhoneNumber = strings.TrimSpace(value.CustomPhoneNumber)
if value.ICCID == "" {
return errors.New("card policy ICCID is required")
}
@@ -381,20 +384,21 @@ func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO card_policies (
iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
apn, ip_version, custom_phone_number, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(iccid) DO UPDATE SET
network_enabled = excluded.network_enabled,
vowifi_enabled = excluded.vowifi_enabled,
airplane_enabled = excluded.airplane_enabled,
apn = excluded.apn,
ip_version = excluded.ip_version,
custom_phone_number = excluded.custom_phone_number,
source = excluded.source,
updated_at = excluded.updated_at
`,
value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled),
boolInt(value.AirplaneEnabled), value.APN, value.IPVersion,
value.Source, createdAt.Unix(), updatedAt.Unix(),
value.CustomPhoneNumber, value.Source, createdAt.Unix(), updatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err)
@@ -440,7 +444,7 @@ func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error {
const cardPolicySelect = `
SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled,
apn, ip_version, source, created_at, updated_at
apn, ip_version, custom_phone_number, source, created_at, updated_at
FROM card_policies`
func cardPolicy(row rowScanner) (CardPolicy, error) {
@@ -449,7 +453,7 @@ func cardPolicy(row rowScanner) (CardPolicy, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled,
&value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt,
&value.APN, &value.IPVersion, &value.CustomPhoneNumber, &value.Source, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return CardPolicy{}, ErrNotFound
+1 -1
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 14
const schemaVersion = 15
var ErrNotFound = errors.New("store: not found")
-1
View File
@@ -210,7 +210,6 @@ User=root
Group=root
WorkingDirectory=/opt/vocat
EnvironmentFile=${ENV_FILE}
Environment=VOCAT_ADDR=0.0.0.0:7575
Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db
ExecStart=${BINARY_PATH}
Restart=on-failure
+71 -13
View File
@@ -1,25 +1,30 @@
import { useEffect, useState } from "react";
import { CardUiRegular } from "@fluentui/react-icons";
import { Tag } from "../ui";
import { Button, Input, Tag, message } from "../ui";
import { PolicySwitchCard } from "./PolicySwitchCard";
import { CardPolicyAPN } from "./CardPolicyAPN";
import { useCardPolicyToggles } from "./useCardPolicyToggles";
import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions";
import { enableVoWiFi, disableVoWiFi, setFlightMode, updateCardPolicy } from "./deviceActions";
import type { CardPolicy } from "../../types";
import { useI18n } from "../../lib/i18n";
import { apiMessage } from "../../api";
export interface CardPolicyPanelProps {
deviceId: string;
iccid?: string;
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void;
onPolicyChanged: () => void | Promise<void>;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const flags = policy
? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled }
const currentPolicy = policy?.iccid === iccid ? policy : null;
const [customPhoneNumber, setCustomPhoneNumber] = useState(currentPolicy?.customPhoneNumber || "");
const [phoneSaving, setPhoneSaving] = useState(false);
const flags = currentPolicy
? { vowifiEnabled: currentPolicy.vowifiEnabled, airplaneEnabled: currentPolicy.airplaneEnabled }
: null;
const toggles = useCardPolicyToggles(flags, {
@@ -28,9 +33,30 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
onChanged: onPolicyChanged,
});
const isManual = policy?.source === "user" || policy?.source === "manual";
const sourceLabel = policy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const isManual = currentPolicy?.source === "user" || currentPolicy?.source === "manual";
const sourceLabel = currentPolicy ? (isManual ? t("手动设置") : t("自动默认")) : "";
const { local } = toggles;
const savedPhoneNumber = currentPolicy?.customPhoneNumber || "";
const phoneChanged = customPhoneNumber.trim() !== savedPhoneNumber;
useEffect(() => {
setCustomPhoneNumber(currentPolicy?.customPhoneNumber || "");
}, [iccid, currentPolicy?.customPhoneNumber]);
const saveCustomPhoneNumber = async () => {
if (!iccid || phoneSaving || !phoneChanged) return;
setPhoneSaving(true);
try {
const saved = await updateCardPolicy(iccid, { customPhoneNumber: customPhoneNumber.trim() });
setCustomPhoneNumber(saved.customPhoneNumber || "");
message.success(saved.customPhoneNumber ? t("自定义手机号已保存") : t("已恢复显示系统读取的号码"));
await onPolicyChanged();
} catch (error) {
message.error(apiMessage(error) || t("保存自定义手机号失败"));
} finally {
setPhoneSaving(false);
}
};
return (
<div>
@@ -53,12 +79,44 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
) : null}
{iccid ? (
<div className="space-y-3">
<div className="ui-panel-muted flex items-center justify-between p-3">
<div>
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="font-mono text-sm text-gray-800 dark:text-gray-100">{iccid}</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<div className="ui-panel-muted flex min-w-0 items-center justify-between gap-3 p-3">
<div className="min-w-0">
<div className="mb-0.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("当前卡 ICCID")}</div>
<div className="truncate font-mono text-sm text-gray-800 dark:text-gray-100" title={iccid}>{iccid}</div>
</div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="ui-panel-muted p-3">
<div className="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-500">{t("自定义手机号")}</div>
<div className="flex items-center gap-2">
<Input
value={customPhoneNumber}
onChange={(event) => setCustomPhoneNumber(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCustomPhoneNumber();
}}
placeholder={t("请输入手机号(可留空)")}
inputMode="tel"
maxLength={32}
disabled={phoneSaving}
aria-label={t("自定义手机号")}
/>
<Button
variant="primary"
size="small"
className="shrink-0 !border-0"
loading={phoneSaving}
disabled={!phoneChanged}
onClick={() => void saveCustomPhoneNumber()}
>
{t("保存")}
</Button>
</div>
<div className="mt-1.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{t("支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码")}
</div>
</div>
{sourceLabel ? <Tag type={isManual ? "primary" : "info"}>{sourceLabel}</Tag> : null}
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
@@ -85,7 +143,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
<CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={policy}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
@@ -12,6 +12,7 @@ import { isVoWiFiInUse } from "./shared";
export interface DeviceOverviewTabProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
trafficSpeedRx: string;
trafficSpeedTx: string;
trafficMinuteRx: string;
@@ -39,6 +40,7 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
<OverviewSimPanel
device={device}
simOperatorDisplay={props.simOperatorDisplay}
customPhoneNumber={props.customPhoneNumber}
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
@@ -10,11 +10,12 @@ import { CountryFlag } from "../CountryFlag";
export interface OverviewSimPanelProps {
device: DeviceDetail;
simOperatorDisplay: string;
customPhoneNumber?: string;
e911Starting: boolean;
onSetupE911: () => void;
}
export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
export function OverviewSimPanel({ device, simOperatorDisplay, customPhoneNumber, e911Starting, onSetupE911 }: OverviewSimPanelProps) {
const { t } = useI18n();
const [showSensitive, toggleSensitive] = useShowSensitive();
const modem = device.modem;
@@ -22,6 +23,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
const activeEsim = (device.activeEsimProfileName || "").trim();
const flightOn = device.vowifiActive || modem?.operatingMode === 0 || modem?.operatingMode === 4;
const carrierCountryCode = carrierBrandIso(modem?.nativeSpn, modem?.imsi);
const displayedPhoneNumber = customPhoneNumber?.trim() || device.localPhone || "--";
const backendLabel =
device.backendMode === "qmi" ? "QMI" : device.backendMode === "mbim" ? "MBIM" : device.backendMode === "at" ? "AT" : "Auto";
@@ -40,7 +42,7 @@ export function OverviewSimPanel({ device, simOperatorDisplay, e911Starting, onS
<FieldRow label="IMEI" value={modem?.imei} sensitive={sensitive} monospace copyable />
<FieldRow label="ICCID" value={modem?.iccid} sensitive={sensitive} monospace copyable />
<FieldRow label="IMSI" value={modem?.imsi} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={device.localPhone || "--"} sensitive={sensitive} monospace copyable />
<FieldRow label={t("本机号码")} value={displayedPhoneNumber} sensitive={sensitive} monospace copyable />
{device?.e911SetupAvailable ? (
<div className="flex justify-between gap-3">
<span className="text-gray-500">{t("E911地址")}</span>
@@ -27,6 +27,7 @@ export interface CardPolicyUpdate {
airplaneEnabled?: boolean;
apn?: string;
ipVersion?: "IP" | "IPV6" | "IPV4V6";
customPhoneNumber?: string;
}
export function updateCardPolicy(iccid: string, body: CardPolicyUpdate) {
return api<CardPolicy>(`/cards/${iccid}/policy`, { method: "PUT", body });
+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),
};
}
+66
View File
@@ -5,6 +5,52 @@
* 富文本片段(嵌套链接/代码块的说明框)不走字典,在组件里按语言分支渲染。
*/
export const EN_DICT: Record<string, string> = {
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
"新增 APN": "Add APN",
"正在读取 APN 列表...": "Loading APN profiles...",
"账号 / 认证": "Account / Authentication",
"协议 / 漫游": "Protocol / Roaming",
: "Source",
"运营商自动配置": "Carrier Automatic",
: "Default",
"模组已有": "Modem",
: "Custom",
: "Roaming",
"已设密码": "Password set",
使: "Active",
"APN 已启用": "APN enabled",
"已使用运营商自动 APN 配置": "Carrier automatic APN configuration enabled",
"启用 APN 失败": "Failed to enable APN",
"设备离线:自定义列表仍可管理,模组已有 APN 将在上线后读取":
"Device offline: custom profiles can still be managed; modem APNs will load when the device comes online",
"新增 APN 配置": "Add APN Profile",
"修改 APN 配置": "Edit APN Profile",
"保存修改": "Save Changes",
"添加到列表": "Add to List",
: "Leave blank",
"留空表示保持原密码": "Leave blank to keep the current password",
"APN 协议": "APN Protocol",
"APN 漫游协议": "APN Roaming Protocol",
"认证类型": "Authentication Type",
"清除已保存密码": "Clear Saved Password",
"关闭时,密码输入框留空会保持原密码": "When disabled, leaving the password blank keeps the current password",
"APN 只能包含字母、数字、点、下划线或连字符,且最长 100 个字符":
"APN may contain only letters, numbers, dots, underscores, or hyphens, with a maximum of 100 characters",
"MCC 必须是 3 位数字": "MCC must be exactly 3 digits",
"MNC 必须是 2 或 3 位数字": "MNC must be 2 or 3 digits",
"自定义 APN 已添加,请点击启用后使用": "Custom APN added. Click Enable to use it",
"自定义 APN 已修改": "Custom APN updated",
"添加 APN 失败": "Failed to add APN",
"修改 APN 失败": "Failed to update APN",
"确定删除这个自定义 APN 配置吗?": "Delete this custom APN profile?",
"删除 APN": "Delete APN",
"APN 已删除,并恢复运营商自动配置": "APN deleted; carrier automatic configuration restored",
"自定义 APN 已删除": "Custom APN deleted",
"删除 APN 失败": "Failed to delete APN",
: "Edit",
// External extensions.
"插件": "Plugins",
"通过 URL 或本地插件包扩展 VoCat 功能": "Extend VoCat with a URL or a local plugin package",
@@ -229,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",
@@ -326,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.",
@@ -939,6 +996,15 @@ export const EN_DICT: Record<string, string> = {
"大疆 4G 模块(移远芯片)": "DJI 4G Module (Quectel)",
"PCIe EC20/EC25(移远芯片)": "PCIe EC20/EC25 (Quectel)",
// ---- Per-Profile phone display override ----
"自定义手机号": "Custom Phone Number",
"请输入手机号(可留空)": "Enter a phone number (optional)",
"自定义手机号已保存": "Custom phone number saved",
"已恢复显示系统读取的号码": "Restored the system-detected number",
"保存自定义手机号失败": "Failed to save custom phone number",
"支持开头的 + 和 3-20 位数字;留空时显示系统从 SIM/网络读取的号码":
"Supports a leading + and 320 digits. Leave blank to show the number read from the SIM/network.",
// ---- AT 快捷指令(按 group · item 分组翻译) ----
: "Basics",
: "Network Control",
+1
View File
@@ -589,6 +589,7 @@ export default function DevicesPage() {
<DeviceOverviewTab
device={detail}
simOperatorDisplay={simOperator}
customPhoneNumber={cardPolicy?.iccid === detail.modem?.iccid ? cardPolicy.customPhoneNumber : ""}
trafficSpeedRx={''}
trafficSpeedTx={''}
trafficMinuteRx={''}
+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>
+2
View File
@@ -230,6 +230,7 @@ export interface CardPolicy {
airplaneEnabled: boolean;
apn?: string;
ipVersion?: string;
customPhoneNumber?: string;
source?: string;
createdAt?: string;
updatedAt?: string;
@@ -371,6 +372,7 @@ export interface NotificationSettings {
bark: Record<string, unknown>;
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。