ソースを参照

:zap: 发送失败存储到 fskv 断电可恢复重发, 每连续失败 2 次开关飞行模式

Mizore 1 年間 前
コミット
ae2955d888
4 ファイル変更523 行追加452 行削除
  1. 4 1
      script/main.lua
  2. 108 0
      script/util_mobile.lua
  3. 116 451
      script/util_notify.lua
  4. 295 0
      script/util_notify_channel.lua

+ 4 - 1
script/main.lua

@@ -25,6 +25,9 @@ mobile.setAuto(1000 * 10, nil, nil, true, 1000 * 60)
 -- 开启 IPv6
 -- mobile.ipv6(true)
 
+-- 初始化 fskv
+log.info("main", "fskv.init", fskv.init())
+
 -- POWERKEY
 local rtos_bsp = rtos.bsp()
 local pin_table = { ["EC618"] = 35, ["EC718P"] = 46 }
@@ -111,7 +114,7 @@ end)
 
 sys.taskInit(function()
     -- 等待网络环境准备就绪
-    sys.waitUntil("IP_READY", 20000)
+    sys.waitUntil("IP_READY", 1000 * 60 * 5)
 
     util_netled.init()
 

+ 108 - 0
script/util_mobile.lua

@@ -65,4 +65,112 @@ function util_mobile.queryTraffic()
     end
 end
 
+--- 获取网络状态
+-- @return 网络状态
+function util_mobile.status()
+    local codes = {
+        [0] = "网络未注册",
+        [1] = "网络已注册",
+        [2] = "网络搜索中",
+        [3] = "网络注册被拒绝",
+        [4] = "网络状态未知",
+        [5] = "网络已注册,漫游",
+        [6] = "网络已注册,仅SMS",
+        [7] = "网络已注册,漫游,仅SMS",
+        [8] = "网络已注册,紧急服务",
+        [9] = "网络已注册,非主要服务",
+        [10] = "网络已注册,非主要服务,漫游",
+    }
+    local mobile_status = mobile.status()
+    if mobile_status and mobile_status >= 0 and mobile_status <= 10 then
+        return codes[mobile_status] or "未知网络状态"
+    end
+    return "未知网络状态"
+end
+
+--- 追加设备信息
+--- @return string
+function util_mobile.appendDeviceInfo()
+    local msg = "\n"
+
+    -- 本机号码
+    local number = mobile.number(mobile.simid()) or config.FALLBACK_LOCAL_NUMBER
+    if number then
+        msg = msg .. "\n本机号码: " .. number
+    end
+
+    -- 开机时长
+    local ms = mcu.ticks()
+    local seconds = math.floor(ms / 1000)
+    local minutes = math.floor(seconds / 60)
+    local hours = math.floor(minutes / 60)
+    seconds = seconds % 60
+    minutes = minutes % 60
+    local boot_time = string.format("%02d:%02d:%02d", hours, minutes, seconds)
+    if ms >= 0 then
+        msg = msg .. "\n开机时长: " .. boot_time
+    end
+
+    -- 运营商
+    local oper = util_mobile.getOper(true)
+    if oper ~= "" then
+        msg = msg .. "\n运营商: " .. oper
+    end
+
+    -- 信号
+    msg = msg .. "\n信号: " .. mobile.rsrp() .. "dBm"
+
+    -- 频段
+    -- local band = util_mobile.getBand()
+    -- if band >= 0 then
+    --     msg = msg .. "\n频段: B" .. band
+    -- end
+
+    -- 电压, 读取 VBAT 供电电压, 单位为 mV
+    -- adc.open(adc.CH_VBAT)
+    -- local vbat = adc.get(adc.CH_VBAT)
+    -- adc.close(adc.CH_VBAT)
+    -- if vbat >= 0 then
+    --     msg = msg .. "\n电压: " .. string.format("%.1f", vbat / 1000) .. "V"
+    -- end
+
+    -- 温度
+    -- adc.open(adc.CH_CPU)
+    -- local temp = adc.get(adc.CH_CPU)
+    -- adc.close(adc.CH_CPU)
+    -- if temp >= 0 then
+    --     msg = msg .. "\n温度: " .. string.format("%.1f", temp / 1000) .. "°C"
+    -- end
+
+    -- 基站信息
+    -- msg = msg .. "\nECI: " .. mobile.eci()
+    -- msg = msg .. "\nTAC: " .. mobile.tac()
+    -- msg = msg .. "\nENBID: " .. mobile.enbid()
+
+    -- 流量统计
+    -- local uplinkGB, uplinkB, downlinkGB, downlinkB = mobile.dataTraffic()
+    -- uplinkB = uplinkGB * 1024 * 1024 * 1024 + uplinkB
+    -- downlinkB = downlinkGB * 1024 * 1024 * 1024 + downlinkB
+    -- local function formatBytes(bytes)
+    --     if bytes < 1024 then
+    --         return bytes .. "B"
+    --     elseif bytes < 1024 * 1024 then
+    --         return string.format("%.2fKB", bytes / 1024)
+    --     elseif bytes < 1024 * 1024 * 1024 then
+    --         return string.format("%.2fMB", bytes / 1024 / 1024)
+    --     else
+    --         return string.format("%.2fGB", bytes / 1024 / 1024 / 1024)
+    --     end
+    -- end
+    -- msg = msg .. "\n流量: ↑" .. formatBytes(uplinkB) .. " ↓" .. formatBytes(downlinkB)
+
+    -- 位置
+    local _, _, map_link = util_location.get()
+    if map_link ~= "" then
+        msg = msg .. "\n位置: " .. map_link -- 这里使用 U+00a0 防止换行
+    end
+
+    return msg
+end
+
 return util_mobile

+ 116 - 451
script/util_notify.lua

@@ -1,9 +1,12 @@
-local lib_smtp = require "lib_smtp"
+local util_notify_channel = require "util_notify_channel"
 
 local util_notify = {}
 
 -- 消息队列
 local msg_queue = {}
+-- 发送计数
+local msg_count = 0
+local error_count = 0
 
 local function urlencodeTab(params)
     local msg = {}
@@ -15,407 +18,32 @@ local function urlencodeTab(params)
     return table.concat(msg)
 end
 
-local notify = {
-    -- 发送到 custom_post
-    ["custom_post"] = function(msg)
-        if config.CUSTOM_POST_URL == nil or config.CUSTOM_POST_URL == "" then
-            log.error("util_notify", "未配置 `config.CUSTOM_POST_URL`")
-            return
-        end
-        if type(config.CUSTOM_POST_BODY_TABLE) ~= "table" then
-            log.error("util_notify", "未配置 `config.CUSTOM_POST_BODY_TABLE`")
-            return
-        end
-
-        local header = { ["content-type"] = config.CUSTOM_POST_CONTENT_TYPE }
-
-        local body = json.decode(json.encode(config.CUSTOM_POST_BODY_TABLE))
-        -- 遍历并替换其中的变量
-        local function traverse_and_replace(t)
-            for k, v in pairs(t) do
-                if type(v) == "table" then
-                    traverse_and_replace(v)
-                elseif type(v) == "string" then
-                    t[k] = string.gsub(v, "{msg}", msg)
-                end
-            end
-        end
-        traverse_and_replace(body)
-
-        -- 根据 content-type 进行编码, 默认为 application/x-www-form-urlencoded
-        if string.find(config.CUSTOM_POST_CONTENT_TYPE, "json") then
-            body = json.encode(body)
-            -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-            body = string.gsub(body, "\\b", "\\n")
-        else
-            body = urlencodeTab(body)
-        end
-
-        log.info("util_notify", "POST", config.CUSTOM_POST_URL, config.CUSTOM_POST_CONTENT_TYPE, body)
-        return util_http.fetch(nil, "POST", config.CUSTOM_POST_URL, header, body)
-    end,
-    -- 发送到 telegram
-    ["telegram"] = function(msg)
-        if config.TELEGRAM_API == nil or config.TELEGRAM_API == "" then
-            log.error("util_notify", "未配置 `config.TELEGRAM_API`")
-            return
-        end
-        if config.TELEGRAM_CHAT_ID == nil or config.TELEGRAM_CHAT_ID == "" then
-            log.error("util_notify", "未配置 `config.TELEGRAM_CHAT_ID`")
-            return
-        end
-
-        local header = { ["content-type"] = "application/json" }
-        local body = { ["chat_id"] = config.TELEGRAM_CHAT_ID, ["disable_web_page_preview"] = true, ["text"] = msg }
-        local json_data = json.encode(body)
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.TELEGRAM_API)
-        return util_http.fetch(nil, "POST", config.TELEGRAM_API, header, json_data)
-    end,
-    -- 发送到 gotify
-    ["gotify"] = function(msg)
-        if config.GOTIFY_API == nil or config.GOTIFY_API == "" then
-            log.error("util_notify", "未配置 `config.GOTIFY_API`")
-            return
-        end
-        if config.GOTIFY_TOKEN == nil or config.GOTIFY_TOKEN == "" then
-            log.error("util_notify", "未配置 `config.GOTIFY_TOKEN`")
-            return
-        end
-
-        local url = config.GOTIFY_API .. "/message?token=" .. config.GOTIFY_TOKEN
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { title = config.GOTIFY_TITLE, message = msg, priority = config.GOTIFY_PRIORITY }
-        local json_data = json.encode(body)
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.GOTIFY_API)
-        return util_http.fetch(nil, "POST", url, header, json_data)
-    end,
-    -- 发送到 pushdeer
-    ["pushdeer"] = function(msg)
-        if config.PUSHDEER_API == nil or config.PUSHDEER_API == "" then
-            log.error("util_notify", "未配置 `config.PUSHDEER_API`")
-            return
-        end
-        if config.PUSHDEER_KEY == nil or config.PUSHDEER_KEY == "" then
-            log.error("util_notify", "未配置 `config.PUSHDEER_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = { pushkey = config.PUSHDEER_KEY or "", type = "text", text = msg }
-
-        log.info("util_notify", "POST", config.PUSHDEER_API)
-        return util_http.fetch(nil, "POST", config.PUSHDEER_API, header, urlencodeTab(body))
-    end,
-    -- 发送到 bark
-    ["bark"] = function(msg)
-        if config.BARK_API == nil or config.BARK_API == "" then
-            log.error("util_notify", "未配置 `config.BARK_API`")
-            return
-        end
-        if config.BARK_KEY == nil or config.BARK_KEY == "" then
-            log.error("util_notify", "未配置 `config.BARK_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = { body = msg }
-        local url = config.BARK_API .. "/" .. config.BARK_KEY
-
-        log.info("util_notify", "POST", url)
-        return util_http.fetch(nil, "POST", url, header, urlencodeTab(body))
-    end,
-    -- 发送到 dingtalk
-    ["dingtalk"] = function(msg)
-        if config.DINGTALK_WEBHOOK == nil or config.DINGTALK_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.DINGTALK_WEBHOOK`")
-            return
-        end
-
-        local url = config.DINGTALK_WEBHOOK
-        -- 如果配置了 config.DINGTALK_SECRET 则需要签名(加签), 没配置则为自定义关键词
-        if (config.DINGTALK_SECRET and config.DINGTALK_SECRET ~= "") then
-            -- 时间异常则等待同步
-            if os.time() < 1714500000 then
-                socket.sntp()
-                sys.waitUntil("NTP_UPDATE", 1000 * 10)
-            end
-            local timestamp = tostring(os.time()) .. "000"
-            local sign = crypto.hmac_sha256(timestamp .. "\n" .. config.DINGTALK_SECRET, config.DINGTALK_SECRET):fromHex():toBase64():urlEncode()
-            url = url .. "&timestamp=" .. timestamp .. "&sign=" .. sign
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msgtype = "text", text = { content = msg } }
-        body = json.encode(body)
-
-        log.info("util_notify", "POST", url)
-        local res_code, res_headers, res_body = util_http.fetch(nil, "POST", url, header, body)
-
-        -- 处理响应
-        -- https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages
-        if res_code == 200 and res_body and res_body ~= "" then
-            local res_data = json.decode(res_body)
-            local res_errcode = res_data.errcode or 0
-            local res_errmsg = res_data.errmsg or ""
-            -- 系统繁忙 / 发送速度太快而限流
-            if res_errcode == -1 or res_errcode == 410100 then
-                return 500, res_headers, res_body
-            end
-            -- timestamp 无效
-            if res_errcode == 310000 and (string.find(res_errmsg, "timestamp") or string.find(res_errmsg, "过期")) then
-                socket.sntp()
-                return 500, res_headers, res_body
-            end
-        end
-        return res_code, res_headers, res_body
-    end,
-    -- 发送到 feishu
-    ["feishu"] = function(msg)
-        if config.FEISHU_WEBHOOK == nil or config.FEISHU_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.FEISHU_WEBHOOK`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msg_type = "text", content = { text = msg } }
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.FEISHU_WEBHOOK)
-        return util_http.fetch(nil, "POST", config.FEISHU_WEBHOOK, header, json_data)
-    end,
-    -- 发送到 wecom
-    ["wecom"] = function(msg)
-        if config.WECOM_WEBHOOK == nil or config.WECOM_WEBHOOK == "" then
-            log.error("util_notify", "未配置 `config.WECOM_WEBHOOK`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { msgtype = "text", text = { content = msg } }
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        log.info("util_notify", "POST", config.WECOM_WEBHOOK)
-        return util_http.fetch(nil, "POST", config.WECOM_WEBHOOK, header, json_data)
-    end,
-    -- 发送到 pushover
-    ["pushover"] = function(msg)
-        if config.PUSHOVER_API_TOKEN == nil or config.PUSHOVER_API_TOKEN == "" then
-            log.error("util_notify", "未配置 `config.PUSHOVER_API_TOKEN`")
-            return
-        end
-        if config.PUSHOVER_USER_KEY == nil or config.PUSHOVER_USER_KEY == "" then
-            log.error("util_notify", "未配置 `config.PUSHOVER_USER_KEY`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
-        local body = { token = config.PUSHOVER_API_TOKEN, user = config.PUSHOVER_USER_KEY, message = msg }
-
-        local json_data = json.encode(body)
-        -- LuatOS Bug, json.encode 会将 \n 转换为 \b
-        -- json_data = string.gsub(json_data, "\\b", "\\n")
-
-        local url = "https://api.pushover.net/1/messages.json"
-
-        log.info("util_notify", "POST", url)
-        return util_http.fetch(nil, "POST", url, header, json_data)
-    end,
-    -- 发送到 inotify
-    ["inotify"] = function(msg)
-        if config.INOTIFY_API == nil or config.INOTIFY_API == "" then
-            log.error("util_notify", "未配置 `config.INOTIFY_API`")
-            return
-        end
-        if not config.INOTIFY_API:endsWith(".send") then
-            log.error("util_notify", "`config.INOTIFY_API` 必须以 `.send` 结尾")
-            return
-        end
-
-        local url = config.INOTIFY_API .. "/" .. string.urlEncode(msg)
-
-        log.info("util_notify", "GET", url)
-        return util_http.fetch(nil, "GET", url)
-    end,
-    -- 发送到 next-smtp-proxy
-    ["next-smtp-proxy"] = function(msg)
-        if config.NEXT_SMTP_PROXY_API == nil or config.NEXT_SMTP_PROXY_API == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_API`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_USER == nil or config.NEXT_SMTP_PROXY_USER == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_USER`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_PASSWORD == nil or config.NEXT_SMTP_PROXY_PASSWORD == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PASSWORD`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_HOST == nil or config.NEXT_SMTP_PROXY_HOST == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_HOST`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_PORT == nil or config.NEXT_SMTP_PROXY_PORT == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PORT`")
-            return
-        end
-        if config.NEXT_SMTP_PROXY_TO_EMAIL == nil or config.NEXT_SMTP_PROXY_TO_EMAIL == "" then
-            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_TO_EMAIL`")
-            return
-        end
-
-        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
-        local body = {
-            user = config.NEXT_SMTP_PROXY_USER,
-            password = config.NEXT_SMTP_PROXY_PASSWORD,
-            host = config.NEXT_SMTP_PROXY_HOST,
-            port = config.NEXT_SMTP_PROXY_PORT,
-            form_name = config.NEXT_SMTP_PROXY_FORM_NAME,
-            to_email = config.NEXT_SMTP_PROXY_TO_EMAIL,
-            subject = config.NEXT_SMTP_PROXY_SUBJECT,
-            text = msg,
-        }
-
-        log.info("util_notify", "POST", config.NEXT_SMTP_PROXY_API)
-        return util_http.fetch(nil, "POST", config.NEXT_SMTP_PROXY_API, header, urlencodeTab(body))
-    end,
-    ["smtp"] = function(msg)
-        local smtp_config = {
-            host = config.SMTP_HOST,
-            port = config.SMTP_PORT,
-            username = config.SMTP_USERNAME,
-            password = config.SMTP_PASSWORD,
-            mail_from = config.SMTP_MAIL_FROM,
-            mail_to = config.SMTP_MAIL_TO,
-            tls_enable = config.SMTP_TLS_ENABLE,
-        }
-        local result = lib_smtp.send(msg, config.SMTP_MAIL_SUBJECT, smtp_config)
-        log.info("util_notify", "SMTP", result.success, result.message, result.is_retry)
-        if result.success then return 200, nil, result.message end
-        if result.is_retry then return 500, nil, result.message end
-        return 400, nil, result.message
-    end,
-    -- 发送到 serial
-    ["serial"] = function(msg)
-        uart.write(1, msg)
-        log.info("util_notify", "serial", "消息已转发到串口")
-        sys.wait(1000)
-        return 200
-    end,
-}
-
-local function append()
-    local msg = "\n"
-
-    -- 本机号码
-    local number = mobile.number(mobile.simid()) or config.FALLBACK_LOCAL_NUMBER
-    if number then msg = msg .. "\n本机号码: " .. number end
-
-    -- 开机时长
-    local ms = mcu.ticks()
-    local seconds = math.floor(ms / 1000)
-    local minutes = math.floor(seconds / 60)
-    local hours = math.floor(minutes / 60)
-    seconds = seconds % 60
-    minutes = minutes % 60
-    local boot_time = string.format("%02d:%02d:%02d", hours, minutes, seconds)
-    if ms >= 0 then msg = msg .. "\n开机时长: " .. boot_time end
-
-    -- 运营商
-    local oper = util_mobile.getOper(true)
-    if oper ~= "" then msg = msg .. "\n运营商: " .. oper end
-
-    -- 信号
-    local rsrp = mobile.rsrp()
-    if rsrp ~= 0 then msg = msg .. "\n信号: " .. rsrp .. "dBm" end
-
-    -- 频段
-    local band = util_mobile.getBand()
-    if band >= 0 then msg = msg .. "\n频段: B" .. band end
-
-    -- 电压, 读取 VBAT 供电电压, 单位为 mV
-    adc.open(adc.CH_VBAT)
-    local vbat = adc.get(adc.CH_VBAT)
-    adc.close(adc.CH_VBAT)
-    if vbat >= 0 then msg = msg .. "\n电压: " .. string.format("%.1f", vbat / 1000) .. "V" end
-
-    -- 温度
-    adc.open(adc.CH_CPU)
-    local temp = adc.get(adc.CH_CPU)
-    adc.close(adc.CH_CPU)
-    if temp >= 0 then msg = msg .. "\n温度: " .. string.format("%.1f", temp / 1000) .. "°C" end
-
-    -- 流量统计
-    -- local uplinkGB, uplinkB, downlinkGB, downlinkB = mobile.dataTraffic()
-    -- uplinkB = uplinkGB * 1024 * 1024 * 1024 + uplinkB
-    -- downlinkB = downlinkGB * 1024 * 1024 * 1024 + downlinkB
-    -- local function formatBytes(bytes)
-    --     if bytes < 1024 then
-    --         return bytes .. "B"
-    --     elseif bytes < 1024 * 1024 then
-    --         return string.format("%.2fKB", bytes / 1024)
-    --     elseif bytes < 1024 * 1024 * 1024 then
-    --         return string.format("%.2fMB", bytes / 1024 / 1024)
-    --     else
-    --         return string.format("%.2fGB", bytes / 1024 / 1024 / 1024)
-    --     end
-    -- end
-    -- msg = msg .. "\n流量: ↑" .. formatBytes(uplinkB) .. " ↓" .. formatBytes(downlinkB)
-
-    -- 位置
-    local _, _, map_link = util_location.get()
-    if map_link ~= "" then
-        msg = msg .. "\n位置: " .. map_link -- 这里使用 U+00a0 防止换行
-    end
-
-    return msg
-end
-
 --- 发送通知
 -- @param msg 消息内容
 -- @param channel 通知渠道
 -- @return true: 无需重发, false: 需要重发
-function util_notify.send(msg, channel)
+local function send(msg, channel)
     log.info("util_notify.send", "发送通知", channel)
 
     -- 判断消息内容 msg
-    if type(msg) ~= "string" then
-        log.error("util_notify.send", "发送通知失败", "参数类型错误", type(msg))
-        return true
-    end
-    if msg == "" then
-        log.error("util_notify.send", "发送通知失败", "消息为空")
+    if type(msg) ~= "string" or msg == "" then
+        log.error("util_notify.send", "发送通知失败", "msg 参数错误", type(msg))
         return true
     end
 
     -- 判断通知渠道 channel
-    if channel and notify[channel] == nil then
+    if channel and util_notify_channel[channel] == nil then
         log.error("util_notify.send", "发送通知失败", "未知通知渠道", channel)
         return true
     end
 
     -- 发送通知
-    local code, headers, body = notify[channel](msg)
+    local code, headers, body = util_notify_channel[channel](msg)
     if code == nil then
         log.info("util_notify.send", "发送通知失败, 无需重发", "code:", code, "body:", body)
         return true
     end
-    -- if code == -6 then
-    --     -- 发生在 url 过长时, 重发也不会成功
-    --     log.info("util_notify.send", "发送通知失败, 无需重发", "code:", code, "body:", body)
-    --     return true
-    -- end
-    if code >= 200 and code < 500 and code ~= 429 then
-        -- http 2xx 成功
-        -- http 3xx 重定向, 重发也不会成功
-        -- http 4xx 客户端错误, 重发也不会成功
+    if code >= 200 and code < 500 and code ~= 408 and code ~= 409 and code ~= 425 and code ~= 429 then
         log.info("util_notify.send", "发送通知成功", "code:", code, "body:", body)
         return true
     end
@@ -426,94 +54,131 @@ end
 --- 添加到消息队列
 -- @param msg 消息内容
 -- @param channels 通知渠道
-function util_notify.add(msg, channels)
-    if type(msg) == "table" then msg = table.concat(msg, "\n") end
+-- @param id 消息唯一标识
+function util_notify.add(msg, channels, id)
+    msg_count = msg_count + 1
 
-    -- 通知内容追加更多信息, 若已经包含则不再追加
-    local is_append = true
-    if string.find(msg, "本机号码:") and string.find(msg, "开机时长:") then
-        log.info("util_notify.send", "不追加更多信息")
-        is_append = false
+    if id == nil or id == "" then
+        id = "msg-t" .. os.time() .. "c" .. msg_count .. "r" .. math.random(9999)
     end
-    if config.NOTIFY_APPEND_MORE_INFO and is_append then msg = msg .. append() end
 
-    channels = channels or config.NOTIFY_TYPE
+    if type(msg) == "table" then
+        msg = table.concat(msg, "\n")
+    end
 
-    if type(channels) ~= "table" then channels = { channels } end
+    channels = channels or config.NOTIFY_TYPE
+    if type(channels) ~= "table" then
+        channels = { channels }
+    end
 
-    for _, channel in ipairs(channels) do table.insert(msg_queue, { channel = channel, msg = msg, retry = 0 }) end
+    for _, channel in ipairs(channels) do
+        table.insert(msg_queue, { id = id, channel = channel, msg = msg, retry = 0 })
+    end
     sys.publish("NEW_MSG")
-    log.debug("util_notify.add", "添加到消息队列, 当前队列长度:", #msg_queue)
-    log.debug("util_notify.add", "添加到消息队列的内容:", msg:gsub("\r", "\\r"):gsub("\n", "\\n"))
+    log.debug("util_notify.add", "添加到消息队列, 当前队列长度:", #msg_queue, "消息内容:", msg:gsub("\r", "\\r"):gsub("\n", "\\n"))
 end
 
--- 轮询消息队列
--- 发送成功则从消息队列中删除
--- 发送失败则等待下次轮询
+--- 轮询消息队列, 发送成功则从队列中删除, 发送失败则等待下次
 local function poll()
-    local item, result
-    local codes = {
-        [0] = "网络未注册",
-        [1] = "网络已注册",
-        [2] = "网络搜索中",
-        [3] = "网络注册被拒绝",
-        [4] = "网络状态未知",
-        [5] = "网络已注册,漫游",
-        [6] = "网络已注册,仅SMS",
-        [7] = "网络已注册,漫游,仅SMS",
-        [8] = "网络已注册,紧急服务",
-        [9] = "网络已注册,非主要服务",
-        [10] = "网络已注册,非主要服务,漫游",
-    }
-    while true do
-        -- 打印网络状态
-        if mobile.status() ~= 1 then
-            log.debug("mobile.status:", codes[mobile.status() or 0] or "未知网络状态")
-        end
-        -- 消息队列非空, 且网络已注册
-        if next(msg_queue) ~= nil and (mobile.status() == 1 or mobile.status() == 5) then
-            log.debug("util_notify.poll", "轮询消息队列中", "队列长度:", #msg_queue, "重发次数:", msg_queue[1].retry)
+    -- 打印网络状态
+    if mobile.status() ~= 1 then
+        log.warn("util_notify.poll", "mobile.status", mobile_status, util_mobile.status())
+    end
 
-            item = msg_queue[1]
-            table.remove(msg_queue, 1)
+    -- 消息队列非空
+    if next(msg_queue) == nil then
+        sys.waitUntil("NEW_MSG", 1000 * 10)
+        return
+    end
 
-            local msg = item.msg
-            -- 通知内容添加重发次数
-            if item.retry > 0 then
-                msg = msg .. "\n重发次数: " .. item.retry
-            end
+    local item = msg_queue[1]
+    table.remove(msg_queue, 1)
+    local msg = item.msg
+    log.info("util_notify.poll", "轮询消息队列中", "总长度: " .. #msg_queue, "当前ID: " .. item.id, "当前重发次数: " .. item.retry, "连续失败次数: " .. error_count)
+
+    -- 通知内容添加设备信息
+    if config.NOTIFY_APPEND_MORE_INFO and not string.find(msg, "开机时长:") then
+        msg = msg .. util_mobile.appendDeviceInfo()
+    end
+    -- 通知内容添加重发次数
+    if error_count > 0 then
+        msg = msg .. "\n重发次数: " .. error_count
+    end
 
-            if item.retry > (config.NOTIFY_RETRY_MAX or 100) then
-                log.error("util_notify.poll", "超过最大重发次数", "msg:", item.msg)
-            else
-                result = util_notify.send(msg, item.channel)
-                item.retry = item.retry + 1
+    -- 超过最大重发次数
+    if item.retry > (config.NOTIFY_RETRY_MAX or 20) then
+        log.warn("util_notify.poll", "超过最大重发次数, 放弃重发", item.msg)
+        return
+    end
 
-                if not result then
-                    -- 发送失败, 移到队尾
-                    table.insert(msg_queue, item)
+    -- 开始发送
+    local result = send(msg, item.channel)
 
-                    -- 等待重发时间, 每次增加 2 秒
-                    local delay = 5000 + 2000 * (item.retry - 1)
-                    log.debug("util_notify.poll", "等待下次重发", delay .. "ms")
-                    sys.wait(delay)
-                end
-            end
+    -- 发送成功
+    if result then
+        error_count = 0
+        -- 检查 fskv 中如果存在则删除
+        if fskv.get(item.id) then
+            fskv.del(item.id)
+        end
+        return
+    end
 
-            if item.retry % 5 == 0 then
-                log.warn("util_notify.poll", "重发次数过多, 开关飞行模式")
-                mobile.flymode(0, true)
-                sys.wait(1000)
-                mobile.flymode(0, false)
-            end
+    -- 发送失败
+    error_count = error_count + 1
+    item.retry = item.retry + 1
+    table.insert(msg_queue, item)
+    log.info("util_notify.poll", "等待下次重发", "当前重发次数", item.retry, "连续失败次数", error_count)
+    sys.waitUntil("IP_READY", 1000 * 5)
+
+    -- 每连续失败 2 次, 开关飞行模式
+    if error_count % 2 == 0 then
+        -- 开关飞行模式
+        log.warn("util_notify.poll", "连续失败次数过多, 开关飞行模式")
+        mobile.flymode(0, true)
+        mobile.flymode(0, false)
+        sys.wait(1000)
+    end
 
-            sys.wait(50)
-        else
-            sys.waitUntil("NEW_MSG", 1000 * 10)
+    -- 每条消息第 1 次重发失败后, 保存到 fskv, 断电开机可恢复重发
+    if item.retry == 1 then
+        if not (string.find(item.msg, "#SMS") or string.find(item.msg, "#CALL")) then
+            return
+        end
+        log.info("util_notify.poll", "当前第 1 次重发失败, 保存到 fskv", item.id)
+        if fskv.get(item.id) then
+            log.info("util_notify.poll", "fskv 已存在, 跳过写入", item.id)
+            return
         end
+        local kv_set_result = fskv.set(item.id, item.msg)
+        log.info("util_notify.poll", "fskv.set", kv_set_result, "used,total,count:", fskv.status())
     end
 end
 
-sys.taskInit(poll)
+sys.taskInit(function()
+    while true do
+        poll()
+        sys.wait(100)
+    end
+end)
+
+sys.taskInit(function()
+    sys.waitUntil("IP_READY")
+    sys.wait(10000)
+
+    local iter = fskv.iter()
+    while iter do
+        local k = fskv.next(iter)
+        if not k then
+            break
+        end
+        local v = fskv.get(k)
+        if not (v and v ~= "" and string.find(k, "msg-")) then
+            break
+        end
+        log.info("util_notify", "检查到 fskv 中有历史消息", k, v:gsub("\r", "\\r"):gsub("\n", "\\n"))
+        util_notify.add(v, nil, k)
+    end
+end)
 
 return util_notify

+ 295 - 0
script/util_notify_channel.lua

@@ -0,0 +1,295 @@
+local lib_smtp = require "lib_smtp"
+
+return {
+    -- 发送到 custom_post
+    ["custom_post"] = function(msg)
+        if config.CUSTOM_POST_URL == nil or config.CUSTOM_POST_URL == "" then
+            log.error("util_notify", "未配置 `config.CUSTOM_POST_URL`")
+            return
+        end
+        if type(config.CUSTOM_POST_BODY_TABLE) ~= "table" then
+            log.error("util_notify", "未配置 `config.CUSTOM_POST_BODY_TABLE`")
+            return
+        end
+
+        local header = { ["content-type"] = config.CUSTOM_POST_CONTENT_TYPE }
+        local body = json.decode(json.encode(config.CUSTOM_POST_BODY_TABLE))
+        -- 遍历并替换其中的变量
+        local function traverse_and_replace(t)
+            for k, v in pairs(t) do
+                if type(v) == "table" then
+                    traverse_and_replace(v)
+                elseif type(v) == "string" then
+                    t[k] = string.gsub(v, "{msg}", msg)
+                end
+            end
+        end
+        traverse_and_replace(body)
+
+        -- 根据 content-type 进行编码, 默认为 application/x-www-form-urlencoded
+        if string.find(config.CUSTOM_POST_CONTENT_TYPE, "json") then
+            body = json.encode(body)
+        else
+            body = urlencodeTab(body)
+        end
+
+        log.info("util_notify", "POST", config.CUSTOM_POST_URL, config.CUSTOM_POST_CONTENT_TYPE, body)
+        return util_http.fetch(nil, "POST", config.CUSTOM_POST_URL, header, body)
+    end,
+    -- 发送到 telegram
+    ["telegram"] = function(msg)
+        if config.TELEGRAM_API == nil or config.TELEGRAM_API == "" then
+            log.error("util_notify", "未配置 `config.TELEGRAM_API`")
+            return
+        end
+        if config.TELEGRAM_CHAT_ID == nil or config.TELEGRAM_CHAT_ID == "" then
+            log.error("util_notify", "未配置 `config.TELEGRAM_CHAT_ID`")
+            return
+        end
+
+        local header = { ["content-type"] = "application/json" }
+        local body = { ["chat_id"] = config.TELEGRAM_CHAT_ID, ["disable_web_page_preview"] = true, ["text"] = msg }
+
+        log.info("util_notify", "POST", config.TELEGRAM_API)
+        return util_http.fetch(nil, "POST", config.TELEGRAM_API, header, json.encode(body))
+    end,
+    -- 发送到 gotify
+    ["gotify"] = function(msg)
+        if config.GOTIFY_API == nil or config.GOTIFY_API == "" then
+            log.error("util_notify", "未配置 `config.GOTIFY_API`")
+            return
+        end
+        if config.GOTIFY_TOKEN == nil or config.GOTIFY_TOKEN == "" then
+            log.error("util_notify", "未配置 `config.GOTIFY_TOKEN`")
+            return
+        end
+
+        local url = config.GOTIFY_API .. "/message?token=" .. config.GOTIFY_TOKEN
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { title = config.GOTIFY_TITLE, message = msg, priority = config.GOTIFY_PRIORITY }
+
+        log.info("util_notify", "POST", config.GOTIFY_API)
+        return util_http.fetch(nil, "POST", url, header, json.encode(body))
+    end,
+    -- 发送到 pushdeer
+    ["pushdeer"] = function(msg)
+        if config.PUSHDEER_API == nil or config.PUSHDEER_API == "" then
+            log.error("util_notify", "未配置 `config.PUSHDEER_API`")
+            return
+        end
+        if config.PUSHDEER_KEY == nil or config.PUSHDEER_KEY == "" then
+            log.error("util_notify", "未配置 `config.PUSHDEER_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = { pushkey = config.PUSHDEER_KEY or "", type = "text", text = msg }
+
+        log.info("util_notify", "POST", config.PUSHDEER_API)
+        return util_http.fetch(nil, "POST", config.PUSHDEER_API, header, urlencodeTab(body))
+    end,
+    -- 发送到 bark
+    ["bark"] = function(msg)
+        if config.BARK_API == nil or config.BARK_API == "" then
+            log.error("util_notify", "未配置 `config.BARK_API`")
+            return
+        end
+        if config.BARK_KEY == nil or config.BARK_KEY == "" then
+            log.error("util_notify", "未配置 `config.BARK_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = { body = msg }
+        local url = config.BARK_API .. "/" .. config.BARK_KEY
+
+        log.info("util_notify", "POST", url)
+        return util_http.fetch(nil, "POST", url, header, urlencodeTab(body))
+    end,
+    -- 发送到 dingtalk
+    ["dingtalk"] = function(msg)
+        if config.DINGTALK_WEBHOOK == nil or config.DINGTALK_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.DINGTALK_WEBHOOK`")
+            return
+        end
+
+        local url = config.DINGTALK_WEBHOOK
+        -- 如果配置了 config.DINGTALK_SECRET 则需要签名(加签), 没配置则为自定义关键词
+        if (config.DINGTALK_SECRET and config.DINGTALK_SECRET ~= "") then
+            -- 时间异常则等待同步
+            if os.time() < 1714500000 then
+                socket.sntp()
+                sys.waitUntil("NTP_UPDATE", 1000 * 10)
+            end
+            local timestamp = tostring(os.time()) .. "000"
+            local sign = crypto.hmac_sha256(timestamp .. "\n" .. config.DINGTALK_SECRET, config.DINGTALK_SECRET):fromHex():toBase64():urlEncode()
+            url = url .. "&timestamp=" .. timestamp .. "&sign=" .. sign
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msgtype = "text", text = { content = msg } }
+        body = json.encode(body)
+
+        log.info("util_notify", "POST", url)
+        local res_code, res_headers, res_body = util_http.fetch(nil, "POST", url, header, body)
+
+        -- 处理响应
+        -- https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages
+        if res_code == 200 and res_body and res_body ~= "" then
+            local res_data = json.decode(res_body)
+            local res_errcode = res_data.errcode or 0
+            local res_errmsg = res_data.errmsg or ""
+            -- 系统繁忙 / 发送速度太快而限流
+            if res_errcode == -1 or res_errcode == 410100 then
+                return 500, res_headers, res_body
+            end
+            -- timestamp 无效
+            if res_errcode == 310000 and (string.find(res_errmsg, "timestamp") or string.find(res_errmsg, "过期")) then
+                socket.sntp()
+                return 500, res_headers, res_body
+            end
+        end
+        return res_code, res_headers, res_body
+    end,
+    -- 发送到 feishu
+    ["feishu"] = function(msg)
+        if config.FEISHU_WEBHOOK == nil or config.FEISHU_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.FEISHU_WEBHOOK`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msg_type = "text", content = { text = msg } }
+
+        log.info("util_notify", "POST", config.FEISHU_WEBHOOK)
+        return util_http.fetch(nil, "POST", config.FEISHU_WEBHOOK, header, json.encode(body))
+    end,
+    -- 发送到 wecom
+    ["wecom"] = function(msg)
+        if config.WECOM_WEBHOOK == nil or config.WECOM_WEBHOOK == "" then
+            log.error("util_notify", "未配置 `config.WECOM_WEBHOOK`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { msgtype = "text", text = { content = msg } }
+
+        log.info("util_notify", "POST", config.WECOM_WEBHOOK)
+        local res_code, res_headers, res_body = util_http.fetch(nil, "POST", config.WECOM_WEBHOOK, header, json.encode(body))
+
+        -- 处理响应
+        -- https://developer.work.weixin.qq.com/document/path/90313
+        if res_code == 200 and res_body and res_body ~= "" then
+            local res_data = json.decode(res_body)
+            local res_errcode = res_data.errcode or 0
+            -- 系统繁忙 / 接口调用超过限制
+            if res_errcode == -1 or res_errcode == 45009 then
+                return 500, res_headers, res_body
+            end
+        end
+        return res_code, res_headers, res_body
+    end,
+    -- 发送到 pushover
+    ["pushover"] = function(msg)
+        if config.PUSHOVER_API_TOKEN == nil or config.PUSHOVER_API_TOKEN == "" then
+            log.error("util_notify", "未配置 `config.PUSHOVER_API_TOKEN`")
+            return
+        end
+        if config.PUSHOVER_USER_KEY == nil or config.PUSHOVER_USER_KEY == "" then
+            log.error("util_notify", "未配置 `config.PUSHOVER_USER_KEY`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/json; charset=utf-8" }
+        local body = { token = config.PUSHOVER_API_TOKEN, user = config.PUSHOVER_USER_KEY, message = msg }
+        local url = "https://api.pushover.net/1/messages.json"
+
+        log.info("util_notify", "POST", url)
+        return util_http.fetch(nil, "POST", url, header, json.encode(body))
+    end,
+    -- 发送到 inotify
+    ["inotify"] = function(msg)
+        if config.INOTIFY_API == nil or config.INOTIFY_API == "" then
+            log.error("util_notify", "未配置 `config.INOTIFY_API`")
+            return
+        end
+        if not config.INOTIFY_API:endsWith(".send") then
+            log.error("util_notify", "`config.INOTIFY_API` 必须以 `.send` 结尾")
+            return
+        end
+
+        local url = config.INOTIFY_API .. "/" .. string.urlEncode(msg)
+
+        log.info("util_notify", "GET", url)
+        return util_http.fetch(nil, "GET", url)
+    end,
+    -- 发送到 next-smtp-proxy
+    ["next-smtp-proxy"] = function(msg)
+        if config.NEXT_SMTP_PROXY_API == nil or config.NEXT_SMTP_PROXY_API == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_API`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_USER == nil or config.NEXT_SMTP_PROXY_USER == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_USER`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_PASSWORD == nil or config.NEXT_SMTP_PROXY_PASSWORD == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PASSWORD`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_HOST == nil or config.NEXT_SMTP_PROXY_HOST == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_HOST`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_PORT == nil or config.NEXT_SMTP_PROXY_PORT == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_PORT`")
+            return
+        end
+        if config.NEXT_SMTP_PROXY_TO_EMAIL == nil or config.NEXT_SMTP_PROXY_TO_EMAIL == "" then
+            log.error("util_notify", "未配置 `config.NEXT_SMTP_PROXY_TO_EMAIL`")
+            return
+        end
+
+        local header = { ["Content-Type"] = "application/x-www-form-urlencoded" }
+        local body = {
+            user = config.NEXT_SMTP_PROXY_USER,
+            password = config.NEXT_SMTP_PROXY_PASSWORD,
+            host = config.NEXT_SMTP_PROXY_HOST,
+            port = config.NEXT_SMTP_PROXY_PORT,
+            form_name = config.NEXT_SMTP_PROXY_FORM_NAME,
+            to_email = config.NEXT_SMTP_PROXY_TO_EMAIL,
+            subject = config.NEXT_SMTP_PROXY_SUBJECT,
+            text = msg,
+        }
+
+        log.info("util_notify", "POST", config.NEXT_SMTP_PROXY_API)
+        return util_http.fetch(nil, "POST", config.NEXT_SMTP_PROXY_API, header, urlencodeTab(body))
+    end,
+    ["smtp"] = function(msg)
+        local smtp_config = {
+            host = config.SMTP_HOST,
+            port = config.SMTP_PORT,
+            username = config.SMTP_USERNAME,
+            password = config.SMTP_PASSWORD,
+            mail_from = config.SMTP_MAIL_FROM,
+            mail_to = config.SMTP_MAIL_TO,
+            tls_enable = config.SMTP_TLS_ENABLE,
+        }
+        local result = lib_smtp.send(msg, config.SMTP_MAIL_SUBJECT, smtp_config)
+        log.info("util_notify", "SMTP", result.success, result.message, result.is_retry)
+        if result.success then
+            return 200, nil, result.message
+        end
+        if result.is_retry then
+            return 500, nil, result.message
+        end
+        return 400, nil, result.message
+    end,
+    -- 发送到 serial
+    ["serial"] = function(msg)
+        uart.write(1, msg)
+        log.info("util_notify", "serial", "消息已转发到串口")
+        sys.wait(1000)
+        return 200
+    end,
+}