Compare commits
81
Commits
f00f030ef3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e766dfa8 | ||
|
|
b6fabf59b7 | ||
|
|
62b6ff44d1 | ||
|
|
0e622329a7 | ||
|
|
c107f6d6af | ||
|
|
d2b5832df3 | ||
|
|
4ce341208b | ||
|
|
d919ea2c53 | ||
|
|
1c9f94dcf8 | ||
|
|
362c8c7a57 | ||
|
|
ea2d33d1bf | ||
|
|
01aee47024 | ||
|
|
7bc2e53ce6 | ||
|
|
6052bf90ec | ||
|
|
16d0d0ec0b | ||
|
|
f11c2ed138 | ||
|
|
04e105c6ba | ||
|
|
99fb474bcf | ||
|
|
f6fa167d76 | ||
|
|
716f711373 | ||
|
|
a75ab812d2 | ||
|
|
01c7275763 | ||
|
|
4782e84c15 | ||
|
|
ca59c5f316 | ||
|
|
6620192322 | ||
|
|
cbb54c28ca | ||
|
|
bce6b70e8f | ||
|
|
57cca6bb7a | ||
|
|
bd25b79724 | ||
|
|
cd5dcb29f5 | ||
|
|
2f83308dce | ||
|
|
c50c7a57d7 | ||
|
|
ec24e70275 | ||
|
|
cfe4ef04d7 | ||
|
|
260bd12ec8 | ||
|
|
dc5d9bf9a6 | ||
|
|
54df706ca7 | ||
|
|
fd766be731 | ||
|
|
94835a5f1d | ||
|
|
b9c2b8f0bd | ||
|
|
f5dacdce19 | ||
|
|
0ef477b15f | ||
|
|
fd66162d57 | ||
|
|
6cec82235a | ||
|
|
2afd890de4 | ||
|
|
b56cfb7b1e | ||
|
|
3fbc52c6c1 | ||
|
|
01b0ad1999 | ||
|
|
5aae81c831 | ||
|
|
8e7d11a162 | ||
|
|
8bbe40c230 | ||
|
|
49a287e6e7 | ||
|
|
937580e24f | ||
|
|
52da224a19 | ||
|
|
98d5e9e117 | ||
|
|
d57dff58f3 | ||
|
|
02b445884d | ||
|
|
46916d9a93 | ||
|
|
9394aa0f4a | ||
|
|
c527a9fd9a | ||
|
|
eff4972668 | ||
|
|
6f6b06e37d | ||
|
|
4e7aacbc68 | ||
|
|
fcd230cb5b | ||
|
|
45822fd333 | ||
|
|
dfb3fdd3e5 | ||
|
|
ce07792c4b | ||
|
|
9f088c265d | ||
|
|
fd0a3bc1fe | ||
|
|
f3ee4035bb | ||
|
|
b0a9f52096 | ||
|
|
e2bf6aa173 | ||
|
|
2840aca69b | ||
|
|
8690265fa0 | ||
|
|
d36455ce71 | ||
|
|
6084897bdb | ||
|
|
eeefdaa180 | ||
|
|
5b873ea059 | ||
|
|
3a94cf2c3e | ||
|
|
e6a8826a8f | ||
|
|
0f04047517 |
@@ -4,7 +4,8 @@
|
||||
"Bash(go build *)",
|
||||
"Bash(go test *)",
|
||||
"Bash(npx --no-install vue-tsc --noEmit)",
|
||||
"Bash(echo \"EXIT=$?\")"
|
||||
"Bash(echo \"EXIT=$?\")",
|
||||
"Read(//c/Users/wuwen/Documents/project/aichat/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ desktop.ini
|
||||
# 项目运行时数据(Windows 测试路径)
|
||||
win/etc/
|
||||
win/srv/
|
||||
win/var/
|
||||
win/opt/
|
||||
|
||||
# 数据库文件
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
git pull
|
||||
cd meshmap_frontend
|
||||
npm run build
|
||||
@@ -0,0 +1 @@
|
||||
git pull
|
||||
@@ -0,0 +1 @@
|
||||
go run .
|
||||
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||
|
||||
type runtimeSettingsRequest struct {
|
||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||
}
|
||||
|
||||
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
|
||||
r.GET("/runtime-settings", func(c *gin.Context) {
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
|
||||
r.PUT("/runtime-settings", func(c *gin.Context) {
|
||||
var req runtimeSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if settings != nil {
|
||||
if err := settings.Reload(store); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
}
|
||||
|
||||
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
|
||||
return gin.H{"allow_encrypted_forwarding": settings.AllowEncryptedForwarding}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBlockingCacheLoadsEnabledRules(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
nodeNum := int64(305419896)
|
||||
if _, err := st.CreateNodeBlocking("!12345678", &nodeNum, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(enabled) error = %v", err)
|
||||
}
|
||||
disabledNodeNum := int64(7)
|
||||
if _, err := st.CreateNodeBlocking("!00000007", &disabledNodeNum, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("192.168.1.0/24", "lan", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(cidr) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("10.0.0.1", "disabled", false); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(enabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("blocked", "contains", false, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
|
||||
}
|
||||
|
||||
cache, err := newBlockingCache(st)
|
||||
if err != nil {
|
||||
t.Fatalf("newBlockingCache() error = %v", err)
|
||||
}
|
||||
|
||||
if !cache.IsNodeBlocked("!12345678", nil) {
|
||||
t.Fatal("IsNodeBlocked(enabled node id) = false, want true")
|
||||
}
|
||||
if !cache.IsNodeBlocked("", uint32(nodeNum)) {
|
||||
t.Fatal("IsNodeBlocked(enabled node num) = false, want true")
|
||||
}
|
||||
if cache.IsNodeBlocked("!00000007", disabledNodeNum) {
|
||||
t.Fatal("IsNodeBlocked(disabled node) = true, want false")
|
||||
}
|
||||
if !cache.IsIPBlocked("192.168.1.42") {
|
||||
t.Fatal("IsIPBlocked(CIDR member) = false, want true")
|
||||
}
|
||||
if cache.IsIPBlocked("10.0.0.1") {
|
||||
t.Fatal("IsIPBlocked(disabled IP) = true, want false")
|
||||
}
|
||||
if word, ok := cache.FindForbiddenWord("This is SPAM text"); !ok || word != "spam" {
|
||||
t.Fatalf("FindForbiddenWord(case-insensitive) = %q, %v, want spam, true", word, ok)
|
||||
}
|
||||
if _, ok := cache.FindForbiddenWord("disabled blocked text"); ok {
|
||||
t.Fatal("FindForbiddenWord(disabled word) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingCacheIPExactAndCIDR(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateIPBlocking("127.0.0.1", "loopback", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(ip) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("2001:db8::/32", "docs", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(ipv6 cidr) error = %v", err)
|
||||
}
|
||||
cache, err := newBlockingCache(st)
|
||||
if err != nil {
|
||||
t.Fatalf("newBlockingCache() error = %v", err)
|
||||
}
|
||||
|
||||
if !cache.IsIPBlocked("127.0.0.1") {
|
||||
t.Fatal("IsIPBlocked(exact IPv4) = false, want true")
|
||||
}
|
||||
if !cache.IsIPBlocked("2001:db8::1") {
|
||||
t.Fatal("IsIPBlocked(IPv6 CIDR) = false, want true")
|
||||
}
|
||||
if cache.IsIPBlocked("localhost") {
|
||||
t.Fatal("IsIPBlocked(hostname) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingCacheForbiddenWordCaseSensitivity(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateForbiddenWordBlocking("Spam", "contains", true, "case-sensitive", true); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(case-sensitive) error = %v", err)
|
||||
}
|
||||
cache, err := newBlockingCache(st)
|
||||
if err != nil {
|
||||
t.Fatalf("newBlockingCache() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := cache.FindForbiddenWord("lowercase spam"); ok {
|
||||
t.Fatal("FindForbiddenWord(lowercase) = true, want false")
|
||||
}
|
||||
if word, ok := cache.FindForbiddenWord("contains Spam"); !ok || word != "Spam" {
|
||||
t.Fatalf("FindForbiddenWord(exact case) = %q, %v, want Spam, true", word, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNodeBlockingCRUD(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
nodeNum := int64(305419896)
|
||||
rule, err := st.CreateNodeBlocking(" !12345678 ", &nodeNum, " noisy node ", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNodeBlocking() error = %v", err)
|
||||
}
|
||||
if rule.NodeID != "!12345678" || rule.NodeNum == nil || *rule.NodeNum != nodeNum || rule.Reason != "noisy node" || !rule.Enabled {
|
||||
t.Fatalf("created node rule = %+v, want normalized fields", rule)
|
||||
}
|
||||
|
||||
if _, err := st.CreateNodeBlocking("!12345678", nil, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
||||
t.Fatalf("duplicate CreateNodeBlocking() error = %v, want errBlockingAlreadyExists", err)
|
||||
}
|
||||
|
||||
updatedNum := int64(7)
|
||||
updated, err := st.UpdateNodeBlocking(rule.ID, "!00000007", &updatedNum, "updated", false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateNodeBlocking() error = %v", err)
|
||||
}
|
||||
if updated.NodeID != "!00000007" || updated.NodeNum == nil || *updated.NodeNum != updatedNum || updated.Reason != "updated" || updated.Enabled {
|
||||
t.Fatalf("updated node rule = %+v, want updated fields", updated)
|
||||
}
|
||||
|
||||
rows, err := st.ListNodeBlocking(listOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListNodeBlocking() error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != rule.ID {
|
||||
t.Fatalf("ListNodeBlocking() = %+v, want one updated rule", rows)
|
||||
}
|
||||
total, err := st.CountNodeBlocking(listOptions{})
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("CountNodeBlocking() = %d, %v, want 1, nil", total, err)
|
||||
}
|
||||
|
||||
if err := st.DeleteNodeBlocking(rule.ID); err != nil {
|
||||
t.Fatalf("DeleteNodeBlocking() error = %v", err)
|
||||
}
|
||||
if err := st.DeleteNodeBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("DeleteNodeBlocking(missing) error = %v, want record not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeBlockingValidation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateNodeBlocking(" ", nil, "", true); err == nil {
|
||||
t.Fatal("CreateNodeBlocking(empty) error = nil, want error")
|
||||
}
|
||||
if _, err := st.UpdateNodeBlocking(1, "!missing", nil, "", true); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("UpdateNodeBlocking(missing) error = %v, want record not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPBlockingCRUDAndValidation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
rule, err := st.CreateIPBlocking(" 127.0.0.1 ", "local", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIPBlocking(ip) error = %v", err)
|
||||
}
|
||||
if rule.IPValue != "127.0.0.1" || rule.Reason != "local" || !rule.Enabled {
|
||||
t.Fatalf("created ip rule = %+v, want normalized IP", rule)
|
||||
}
|
||||
|
||||
cidr, err := st.CreateIPBlocking("192.168.1.99/24", "cidr", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateIPBlocking(cidr) error = %v", err)
|
||||
}
|
||||
if cidr.IPValue != "192.168.1.0/24" {
|
||||
t.Fatalf("cidr IPValue = %q, want 192.168.1.0/24", cidr.IPValue)
|
||||
}
|
||||
|
||||
if _, err := st.CreateIPBlocking("127.0.0.1", "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
||||
t.Fatalf("duplicate CreateIPBlocking() error = %v, want errBlockingAlreadyExists", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("not-an-ip", "invalid", true); err == nil {
|
||||
t.Fatal("CreateIPBlocking(invalid) error = nil, want error")
|
||||
}
|
||||
|
||||
updated, err := st.UpdateIPBlocking(rule.ID, "10.0.0.0/8", "updated", false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateIPBlocking() error = %v", err)
|
||||
}
|
||||
if updated.IPValue != "10.0.0.0/8" || updated.Reason != "updated" || updated.Enabled {
|
||||
t.Fatalf("updated ip rule = %+v, want updated fields", updated)
|
||||
}
|
||||
|
||||
rows, err := st.ListIPBlocking(listOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListIPBlocking() error = %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("ListIPBlocking() length = %d, want 2", len(rows))
|
||||
}
|
||||
total, err := st.CountIPBlocking(listOptions{})
|
||||
if err != nil || total != 2 {
|
||||
t.Fatalf("CountIPBlocking() = %d, %v, want 2, nil", total, err)
|
||||
}
|
||||
|
||||
if err := st.DeleteIPBlocking(rule.ID); err != nil {
|
||||
t.Fatalf("DeleteIPBlocking() error = %v", err)
|
||||
}
|
||||
if err := st.DeleteIPBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("DeleteIPBlocking(missing) error = %v, want record not found", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEnabledBlockingRules(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
nodeNum := int64(1)
|
||||
if _, err := st.CreateNodeBlocking("!00000001", &nodeNum, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(enabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateNodeBlocking("!00000002", nil, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateNodeBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if rows, err := st.ListEnabledNodeBlocking(); err != nil || len(rows) != 1 || rows[0].NodeID != "!00000001" {
|
||||
t.Fatalf("ListEnabledNodeBlocking() = %+v, %v, want only enabled node", rows, err)
|
||||
}
|
||||
|
||||
if _, err := st.CreateIPBlocking("127.0.0.1", "enabled", true); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(enabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateIPBlocking("192.168.1.1", "disabled", false); err != nil {
|
||||
t.Fatalf("CreateIPBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if rows, err := st.ListEnabledIPBlocking(); err != nil || len(rows) != 1 || rows[0].IPValue != "127.0.0.1" {
|
||||
t.Fatalf("ListEnabledIPBlocking() = %+v, %v, want only enabled IP", rows, err)
|
||||
}
|
||||
|
||||
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "enabled", true); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(enabled) error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("eggs", "contains", false, "disabled", false); err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking(disabled) error = %v", err)
|
||||
}
|
||||
if rows, err := st.ListEnabledForbiddenWordBlocking(); err != nil || len(rows) != 1 || rows[0].Word != "spam" {
|
||||
t.Fatalf("ListEnabledForbiddenWordBlocking() = %+v, %v, want only enabled word", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForbiddenWordBlockingCRUDAndValidation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
rule, err := st.CreateForbiddenWordBlocking(" spam ", "", false, "junk", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateForbiddenWordBlocking() error = %v", err)
|
||||
}
|
||||
if rule.Word != "spam" || rule.MatchType != forbiddenWordMatchContains || rule.CaseSensitive || rule.Reason != "junk" || !rule.Enabled {
|
||||
t.Fatalf("created word rule = %+v, want normalized fields", rule)
|
||||
}
|
||||
|
||||
if _, err := st.CreateForbiddenWordBlocking("spam", "contains", false, "duplicate", true); !errors.Is(err, errBlockingAlreadyExists) {
|
||||
t.Fatalf("duplicate CreateForbiddenWordBlocking() error = %v, want errBlockingAlreadyExists", err)
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking(" ", "contains", false, "empty", true); err == nil {
|
||||
t.Fatal("CreateForbiddenWordBlocking(empty) error = nil, want error")
|
||||
}
|
||||
if _, err := st.CreateForbiddenWordBlocking("regex", "regex", false, "unsupported", true); err == nil {
|
||||
t.Fatal("CreateForbiddenWordBlocking(unsupported match type) error = nil, want error")
|
||||
}
|
||||
|
||||
updated, err := st.UpdateForbiddenWordBlocking(rule.ID, "Spam", "contains", true, "updated", false)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateForbiddenWordBlocking() error = %v", err)
|
||||
}
|
||||
if updated.Word != "Spam" || updated.MatchType != "contains" || !updated.CaseSensitive || updated.Reason != "updated" || updated.Enabled {
|
||||
t.Fatalf("updated word rule = %+v, want updated fields", updated)
|
||||
}
|
||||
|
||||
rows, err := st.ListForbiddenWordBlocking(listOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListForbiddenWordBlocking() error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != rule.ID {
|
||||
t.Fatalf("ListForbiddenWordBlocking() = %+v, want one updated rule", rows)
|
||||
}
|
||||
total, err := st.CountForbiddenWordBlocking(listOptions{})
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("CountForbiddenWordBlocking() = %d, %v, want 1, nil", total, err)
|
||||
}
|
||||
|
||||
if err := st.DeleteForbiddenWordBlocking(rule.ID); err != nil {
|
||||
t.Fatalf("DeleteForbiddenWordBlocking() error = %v", err)
|
||||
}
|
||||
if err := st.DeleteForbiddenWordBlocking(rule.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("DeleteForbiddenWordBlocking(missing) error = %v, want record not found", err)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// pkiKeyResolver 是 mqtpp 在解密 PKI 加密包时回调的接收者私钥/发送者公钥查询函数。
|
||||
//
|
||||
// to 是接收者节点号(应该匹配某个本地受管的 bot),from 是发送者节点号(应该已经有 nodeinfo 上报)。
|
||||
// 返回的 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
|
||||
func newPKIKeyResolver(s *store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum))
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil || len(privateKey) != 32 {
|
||||
return nil, nil, false
|
||||
}
|
||||
fromPublic, ok := lookupNodeInfoPublicKey(s, fromNodeNum)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return privateKey, fromPublic, true
|
||||
}
|
||||
}
|
||||
|
||||
// lookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥,
|
||||
// 兼容 hex 与 base64 两种历史存储格式。
|
||||
func lookupNodeInfoPublicKey(s *store, nodeNum uint32) ([]byte, bool) {
|
||||
var row nodeInfoRecord
|
||||
if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if row.PublicKey == nil {
|
||||
return nil, false
|
||||
}
|
||||
value := strings.TrimSpace(*row.PublicKey)
|
||||
if value == "" {
|
||||
return nil, false
|
||||
}
|
||||
if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||
return decoded, true
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||
return decoded, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。
|
||||
func (s *store) GetBotNodeByNodeNum(nodeNum int64) (*botNodeRecord, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store not configured")
|
||||
}
|
||||
var row botNodeRecord
|
||||
if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
-302
@@ -1,302 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadConfigCreatesDefaultFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.MQTT.Host != "0.0.0.0" {
|
||||
t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host)
|
||||
}
|
||||
if cfg.MQTT.Port != 1883 {
|
||||
t.Fatalf("port = %d, want 1883", cfg.MQTT.Port)
|
||||
}
|
||||
if cfg.MQTT.TLS.Enabled {
|
||||
t.Fatalf("tls enabled = true, want false")
|
||||
}
|
||||
if cfg.Meshtastic.PSK != "AQ==" {
|
||||
t.Fatalf("psk = %q, want AQ==", cfg.Meshtastic.PSK)
|
||||
}
|
||||
if cfg.Database.Driver != "sqlite" {
|
||||
t.Fatalf("database driver = %q, want sqlite", cfg.Database.Driver)
|
||||
}
|
||||
if cfg.Database.SQLite.Path == "" {
|
||||
t.Fatalf("sqlite path is empty")
|
||||
}
|
||||
if !cfg.Web.Enabled {
|
||||
t.Fatalf("web enabled = false, want true")
|
||||
}
|
||||
if cfg.Web.Port != 8080 {
|
||||
t.Fatalf("web port = %d, want 8080", cfg.Web.Port)
|
||||
}
|
||||
if cfg.Web.SocketPath != defaultWebSocketPath() {
|
||||
t.Fatalf("web socket path = %q, want %q", cfg.Web.SocketPath, defaultWebSocketPath())
|
||||
}
|
||||
if cfg.Web.StaticDir != "./dist" {
|
||||
t.Fatalf("web static dir = %q, want ./dist", cfg.Web.StaticDir)
|
||||
}
|
||||
if cfg.Web.MapTileCacheDir != defaultMapTileCacheDir() {
|
||||
t.Fatalf("web map tile cache dir = %q, want %q", cfg.Web.MapTileCacheDir, defaultMapTileCacheDir())
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("default config was not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFillsMissingFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("mqtt:\n port: 1884\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.MQTT.Port != 1884 {
|
||||
t.Fatalf("port = %d, want 1884", cfg.MQTT.Port)
|
||||
}
|
||||
if cfg.MQTT.Host != "0.0.0.0" {
|
||||
t.Fatalf("host = %q, want 0.0.0.0", cfg.MQTT.Host)
|
||||
}
|
||||
if cfg.Meshtastic.PSK != "AQ==" {
|
||||
t.Fatalf("psk = %q, want AQ==", cfg.Meshtastic.PSK)
|
||||
}
|
||||
if cfg.Database.Driver != "sqlite" {
|
||||
t.Fatalf("database driver = %q, want sqlite", cfg.Database.Driver)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{"host:", "tls:", "enabled:", "cert_file:", "key_file:", "meshtastic:", "psk:", "database:", "driver:", "sqlite:", "mysql:", "dsn:", "web:", "port:", "socket_path:", "static_dir:", "map_tile_cache_dir:"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("completed config missing %q in:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigPreservesExplicitFalse(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "mqtt:\n host: 127.0.0.1\n port: 1885\n tls:\n enabled: false\n cert_file: cert.pem\n key_file: key.pem\nmeshtastic:\n psk: AQ==\ndatabase:\n driver: sqlite\n sqlite:\n path: test.db\n mysql:\n dsn: \"\"\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.MQTT.TLS.Enabled {
|
||||
t.Fatalf("tls enabled = true, want explicit false")
|
||||
}
|
||||
if cfg.MQTT.TLS.CertFile != "cert.pem" || cfg.MQTT.TLS.KeyFile != "key.pem" {
|
||||
t.Fatalf("tls paths = %q/%q, want cert.pem/key.pem", cfg.MQTT.TLS.CertFile, cfg.MQTT.TLS.KeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigPreservesExplicitWebFalse(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "web:\n enabled: false\n host: 127.0.0.1\n port: 8081\n static_dir: ./public\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Web.Enabled {
|
||||
t.Fatalf("web enabled = true, want explicit false")
|
||||
}
|
||||
if cfg.Web.Host != "127.0.0.1" || cfg.Web.Port != 8081 || cfg.Web.StaticDir != "./public" {
|
||||
t.Fatalf("web config = %#v", cfg.Web)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigMalformedYAMLDoesNotOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh_mqtt_go", configFileName)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "mqtt:\n port: [\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := loadConfig(path)
|
||||
if err == nil {
|
||||
t.Fatalf("loadConfig() error = nil, want parse error")
|
||||
}
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(data) != content {
|
||||
t.Fatalf("malformed config was overwritten: %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMapTileCacheDirForGOOS(t *testing.T) {
|
||||
windowsPath := defaultMapTileCacheDirForGOOS("windows")
|
||||
wantWindows := filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
if windowsPath != wantWindows {
|
||||
t.Fatalf("windows map tile cache dir = %q, want %q", windowsPath, wantWindows)
|
||||
}
|
||||
|
||||
linuxPath := defaultMapTileCacheDirForGOOS("linux")
|
||||
wantLinux := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
|
||||
if linuxPath != wantLinux {
|
||||
t.Fatalf("linux map tile cache dir = %q, want %q", linuxPath, wantLinux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWebSocketPathForGOOS(t *testing.T) {
|
||||
if windowsPath := defaultWebSocketPathForGOOS("windows"); windowsPath != "" {
|
||||
t.Fatalf("windows web socket path = %q, want empty", windowsPath)
|
||||
}
|
||||
|
||||
linuxPath := defaultWebSocketPathForGOOS("linux")
|
||||
want := filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
||||
if linuxPath != want {
|
||||
t.Fatalf("linux web socket path = %q, want %q", linuxPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearWebSocketPathOnUnsupportedGOOS(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.Web.SocketPath = filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
||||
if !clearWebSocketPathOnUnsupportedGOOS(cfg, "windows") {
|
||||
t.Fatalf("clearWebSocketPathOnUnsupportedGOOS() = false, want true")
|
||||
}
|
||||
if cfg.Web.SocketPath != "" {
|
||||
t.Fatalf("windows web socket path = %q, want empty", cfg.Web.SocketPath)
|
||||
}
|
||||
|
||||
cfg.Web.SocketPath = "/opt/mesh_mqtt_go/web.sock"
|
||||
if clearWebSocketPathOnUnsupportedGOOS(cfg, "linux") {
|
||||
t.Fatalf("linux clearWebSocketPathOnUnsupportedGOOS() = true, want false")
|
||||
}
|
||||
if cfg.Web.SocketPath == "" {
|
||||
t.Fatalf("linux web socket path was cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSQLitePathForGOOS(t *testing.T) {
|
||||
windowsPath := defaultSQLitePathForGOOS("windows")
|
||||
if !strings.Contains(windowsPath, filepath.Join("win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")) {
|
||||
t.Fatalf("windows sqlite path = %q", windowsPath)
|
||||
}
|
||||
|
||||
linuxPath := defaultSQLitePathForGOOS("linux")
|
||||
want := filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
if linuxPath != want {
|
||||
t.Fatalf("linux sqlite path = %q, want %q", linuxPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigDatabase(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.Database.Driver = "postgres"
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.driver") {
|
||||
t.Fatalf("invalid driver error = %v, want database.driver error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Database.SQLite.Path = ""
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.sqlite.path") {
|
||||
t.Fatalf("missing sqlite path error = %v, want database.sqlite.path error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Database.Driver = "mysql"
|
||||
cfg.Database.MySQL.DSN = ""
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "database.mysql.dsn") {
|
||||
t.Fatalf("missing mysql dsn error = %v, want database.mysql.dsn error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigWeb(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
cfg.Web.SocketPath = ""
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
||||
t.Fatalf("invalid web port error = %v, want web port error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.SocketPath = filepath.Join(string(filepath.Separator), "tmp", "mesh_mqtt_go.sock")
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
t.Fatalf("web socket with invalid port error = %v, want nil", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.SocketPath = ""
|
||||
cfg.Web.Port = 0
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web port") {
|
||||
t.Fatalf("invalid web port without socket error = %v, want web port error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.StaticDir = ""
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.static_dir") {
|
||||
t.Fatalf("missing web static dir error = %v, want web.static_dir error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.MapTileCacheDir = ""
|
||||
if err := validateConfig(cfg); err == nil || !strings.Contains(err.Error(), "web.map_tile_cache_dir") {
|
||||
t.Fatalf("missing map tile cache dir error = %v, want web.map_tile_cache_dir error", err)
|
||||
}
|
||||
|
||||
cfg = defaultConfig()
|
||||
cfg.Web.Enabled = false
|
||||
cfg.Web.Port = 0
|
||||
cfg.Web.StaticDir = ""
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
t.Fatalf("disabled web validate error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTLSConfigDisabled(t *testing.T) {
|
||||
cfg, err := buildTLSConfig(tlsConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildTLSConfig() error = %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatalf("buildTLSConfig() = %#v, want nil", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTLSConfigRequiresCertAndKey(t *testing.T) {
|
||||
_, err := buildTLSConfig(tlsConfig{Enabled: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "cert_file") {
|
||||
t.Fatalf("missing cert error = %v, want cert_file error", err)
|
||||
}
|
||||
|
||||
_, err = buildTLSConfig(tlsConfig{Enabled: true, CertFile: "cert.pem"})
|
||||
if err == nil || !strings.Contains(err.Error(), "key_file") {
|
||||
t.Fatalf("missing key error = %v, want key_file error", err)
|
||||
}
|
||||
}
|
||||
-1076
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDBWriteQueueWritesRecordsAsync(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
queue := newDBWriteQueue(st)
|
||||
record := textMessageTestRecord("queued")
|
||||
queue.EnqueueRecord(record, mqttClientInfo{ClientID: "client-1"})
|
||||
record["text"] = "mutated after enqueue"
|
||||
queue.Close()
|
||||
|
||||
var text, clientID string
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT text, mqtt_client_id FROM text_message WHERE from_id = ?", "!12345678").Scan(&text, &clientID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != "queued" || clientID != "client-1" {
|
||||
t.Fatalf("queued row = text %q client %q, want queued/client-1", text, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBWriteQueueWritesDiscardAsync(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
queue := newDBWriteQueue(st)
|
||||
record := map[string]any{"topic": "msh/test", "error": "bad packet"}
|
||||
queue.EnqueueDiscard(record, []byte{1, 2, 3}, mqttClientInfo{RemoteAddr: "127.0.0.1:1883"})
|
||||
record["error"] = "mutated after enqueue"
|
||||
queue.Close()
|
||||
|
||||
var topic, reason, rawBase64, remoteAddr string
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT topic, error, raw_base64, mqtt_remote_addr FROM discard_details").Scan(&topic, &reason, &rawBase64, &remoteAddr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if topic != "msh/test" || reason != "bad packet" || rawBase64 != "AQID" || remoteAddr != "127.0.0.1:1883" {
|
||||
t.Fatalf("discard row = %q/%q/%q/%q, want queued values", topic, reason, rawBase64, remoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBWriteQueueLen(t *testing.T) {
|
||||
queue := &dbWriteQueue{jobs: make(chan dbWriteJob, 1)}
|
||||
queue.enqueue(dbWriteJob{run: func() error { return nil }})
|
||||
if queue.Len() != 1 {
|
||||
t.Fatalf("queue.Len() = %d, want 1", queue.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBWriteQueueIgnoresUnsupportedRecordType(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
queue := newDBWriteQueue(st)
|
||||
queue.EnqueueRecord(map[string]any{"type": "empty_packet", "from": "!12345678"}, mqttClientInfo{})
|
||||
queue.Close()
|
||||
|
||||
var count int
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT COUNT(*) FROM text_message").Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("text_message count = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBWriteQueueNilStore(t *testing.T) {
|
||||
if queue := newDBWriteQueue(nil); queue != nil {
|
||||
t.Fatalf("newDBWriteQueue(nil) = %#v, want nil", queue)
|
||||
}
|
||||
var queue *dbWriteQueue
|
||||
queue.EnqueueRecord(textMessageTestRecord("ignored"), mqttClientInfo{})
|
||||
queue.EnqueueDiscard(map[string]any{"topic": "ignored"}, []byte{1}, mqttClientInfo{})
|
||||
queue.Close()
|
||||
}
|
||||
|
||||
func TestDBWriteQueueRecordValidationErrorDoesNotStopWorker(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
queue := newDBWriteQueue(st)
|
||||
badRecord := textMessageTestRecord("bad")
|
||||
delete(badRecord, "from")
|
||||
queue.EnqueueRecord(badRecord, mqttClientInfo{})
|
||||
queue.EnqueueRecord(textMessageTestRecord("good"), mqttClientInfo{})
|
||||
queue.Close()
|
||||
|
||||
var text string
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT text FROM text_message").Scan(&text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text != "good" {
|
||||
t.Fatalf("text = %q, want good", text)
|
||||
}
|
||||
|
||||
var missing sql.NullString
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT text FROM text_message WHERE text = ?", "bad").Scan(&missing); err != sql.ErrNoRows {
|
||||
t.Fatalf("bad row error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# 活跃度查询工具
|
||||
|
||||
## 功能说明
|
||||
|
||||
当用户询问"当前有多少人活跃"或"现在有多少节点在线"时,AI 可以调用此工具查询实时活跃统计。
|
||||
|
||||
## 查询逻辑
|
||||
|
||||
### 活跃节点统计
|
||||
- 查询数据库 `nodeinfo` 表的 `updated_at` 字段
|
||||
- 统计指定时间范围内有更新记录的节点数量
|
||||
- SQL: `SELECT COUNT(*) FROM nodeinfo WHERE updated_at >= ?`
|
||||
|
||||
### 活跃人数统计
|
||||
- 查询数据库 `text_message` 表的 `created_at` 字段
|
||||
- 统计指定时间范围内发送过消息的唯一用户数(按 `from_id` 去重)
|
||||
- SQL: `SELECT COUNT(DISTINCT from_id) FROM text_message WHERE created_at >= ?`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### hours(可选)
|
||||
- 类型:数字(浮点数)
|
||||
- 说明:查询最近多少小时内的活跃数据
|
||||
- 默认值:1 小时
|
||||
- 取值范围:0.1 ~ 24 小时
|
||||
- 示例:`1`、`2`、`6`、`12`、`24`、`0.5`
|
||||
|
||||
### query_type(可选)
|
||||
- 类型:字符串枚举
|
||||
- 可选值:
|
||||
- `both`:同时查询节点和人数(默认)
|
||||
- `nodes`:仅查询活跃节点
|
||||
- `users`:仅查询活跃人数
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 用户询问:"现在有多少人活跃?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 1,
|
||||
"query_type": "users"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 1.0 小时的活跃统计:
|
||||
|
||||
活跃人数:15 人
|
||||
```
|
||||
|
||||
### 用户询问:"最近6小时有多少节点在线?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 6,
|
||||
"query_type": "nodes"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 6.0 小时的活跃统计:
|
||||
|
||||
活跃节点:25 个
|
||||
```
|
||||
|
||||
### 用户询问:"当前有多少人和节点活跃?"
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"hours": 1,
|
||||
"query_type": "both"
|
||||
}
|
||||
```
|
||||
|
||||
或简化为(使用默认值):
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 1.0 小时的活跃统计:
|
||||
|
||||
活跃节点:25 个
|
||||
活跃人数:15 人
|
||||
```
|
||||
|
||||
### 用户询问:"今天有多少活跃用户?"
|
||||
AI 调用(假设现在是下午3点):
|
||||
```json
|
||||
{
|
||||
"hours": 15,
|
||||
"query_type": "users"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 15.0 小时的活跃统计:
|
||||
|
||||
活跃人数:48 人
|
||||
```
|
||||
|
||||
## 时间限制
|
||||
|
||||
- **默认时间**:1 小时(用户未指定时间时)
|
||||
- **最大时间**:24 小时(超过24小时会自动限制到24小时)
|
||||
- **最小精度**:0.1 小时(6分钟)
|
||||
|
||||
这样设计的原因:
|
||||
1. 默认1小时符合"当前活跃"的常见理解
|
||||
2. 限制24小时避免查询过大范围影响性能
|
||||
3. 支持小数便于精确控制时间范围(如0.5小时=30分钟)
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 文件结构
|
||||
```
|
||||
internal/agents/active/
|
||||
├── active.go # 工具主逻辑
|
||||
└── active_test.go # 单元测试
|
||||
|
||||
internal/store/
|
||||
└── active_store.go # 数据库查询方法
|
||||
```
|
||||
|
||||
### 核心接口
|
||||
|
||||
```go
|
||||
type ActiveStore interface {
|
||||
CountActiveNodes(since time.Time) (int64, error)
|
||||
CountActiveUsers(since time.Time) (int64, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 工具注册
|
||||
|
||||
在 `internal/ai/service.go` 中通过空导入自动注册:
|
||||
```go
|
||||
import (
|
||||
_ "meshtastic_mqtt_server/internal/agents/active"
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
- ✅ 默认查询(1小时,both)
|
||||
- ✅ 指定时间查询(6小时、24小时)
|
||||
- ✅ 仅查询节点
|
||||
- ✅ 仅查询人数
|
||||
- ✅ 时间限制(超过24小时自动限制)
|
||||
- ✅ 工具启用状态检查
|
||||
|
||||
所有测试通过。
|
||||
|
||||
## 数据库性能
|
||||
|
||||
查询使用索引字段(`updated_at`、`created_at`),性能良好:
|
||||
- `nodeinfo` 表通常记录数较少(几百到几千条)
|
||||
- `text_message` 表使用 `DISTINCT` 去重,配合时间索引效率高
|
||||
- 典型查询响应时间 < 10ms
|
||||
|
||||
## 使用场景
|
||||
|
||||
1. **实时监控**:"现在有多少人在线?"
|
||||
2. **活跃度统计**:"最近一小时有多少活跃用户?"
|
||||
3. **趋势分析**:"今天的活跃度怎么样?"
|
||||
4. **对比分析**:"最近6小时有多少人活跃?"(可以多次查询不同时间范围对比)
|
||||
|
||||
## 与签到工具的区别
|
||||
|
||||
| 维度 | 活跃度查询 | 签到查询 |
|
||||
|------|-----------|---------|
|
||||
| 数据源 | nodeinfo + text_message | signs 表 |
|
||||
| 统计维度 | 实时活跃(有更新/发消息) | 主动签到 |
|
||||
| 时间范围 | 最近N小时(最大24小时) | 按自然日统计 |
|
||||
| 用户意图 | "现在有多少人在线" | "今天有多少人签到" |
|
||||
| 去重逻辑 | 自动按 from_id 去重 | 每节点每天仅一次 |
|
||||
@@ -0,0 +1,248 @@
|
||||
# QoS0 重发问题诊断指南
|
||||
|
||||
## 快速诊断
|
||||
|
||||
### 1. 启用详细日志运行服务器
|
||||
|
||||
```bash
|
||||
./meshtastic_mqtt_server --console-log-mqtt=true --console-log-meshtastic=true
|
||||
```
|
||||
|
||||
### 2. 观察日志输出
|
||||
|
||||
#### ✅ 正常接收的消息
|
||||
```
|
||||
[mqtt] connect client_id=device123 username=user1 remote=192.168.1.100:54321
|
||||
text from=!12345678 channel=LongFast text="hello world"
|
||||
```
|
||||
|
||||
#### ❌ 被拒绝的消息(关键!)
|
||||
```
|
||||
[mqtt] PUBLISH rejected: client_id=device123 topic=msh/CN/2/e/LongFast/!12345678 qos=0 payload_len=156 error=protobuf decode failed
|
||||
```
|
||||
|
||||
#### ❌ 被屏蔽的消息
|
||||
```
|
||||
[mqtt] PUBLISH blocked: client_id=device123 topic=msh/CN/2/e/LongFast/!12345678 type=forbidden_word reason=blocked node
|
||||
```
|
||||
|
||||
### 3. 根据日志判断问题
|
||||
|
||||
| 日志内容 | 问题原因 | 解决方法 |
|
||||
|---------|---------|---------|
|
||||
| `PUBLISH rejected: error=protobuf decode failed` | 消息格式错误 | 检查设备固件版本 |
|
||||
| `PUBLISH rejected: error=cannot be decrypted` | 无法解密 | 检查 PSK 配置或启用 `allow_encrypted_forwarding` |
|
||||
| `PUBLISH blocked: type=node` | 节点被屏蔽 | 检查屏蔽规则 |
|
||||
| `PUBLISH blocked: type=forbidden_word` | 内容被屏蔽 | 检查关键词过滤规则 |
|
||||
| **没有 rejected/blocked 日志** | **不是服务器拒绝** | **问题在设备端** |
|
||||
|
||||
## 详细诊断步骤
|
||||
|
||||
### 步骤 1: 检查数据库中被拒绝的消息
|
||||
|
||||
```bash
|
||||
# 进入数据库
|
||||
sqlite3 /path/to/database.db
|
||||
|
||||
# 查看最近被拒绝的消息
|
||||
SELECT
|
||||
datetime(created_at, 'unixepoch', 'localtime') as time,
|
||||
client_id,
|
||||
json_extract(record, '$.error') as error,
|
||||
json_extract(record, '$.topic') as topic,
|
||||
payload_len
|
||||
FROM discarded_packets
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
# 统计拒绝原因
|
||||
SELECT
|
||||
json_extract(record, '$.error') as error_type,
|
||||
COUNT(*) as count
|
||||
FROM discarded_packets
|
||||
WHERE created_at > strftime('%s', 'now', '-1 hour')
|
||||
GROUP BY error_type;
|
||||
```
|
||||
|
||||
### 步骤 2: 抓包分析
|
||||
|
||||
```bash
|
||||
# 开始抓包
|
||||
sudo tcpdump -i any -nn port 1883 -w /tmp/mqtt_traffic.pcap
|
||||
|
||||
# 让设备发送几条消息,然后停止抓包 (Ctrl+C)
|
||||
|
||||
# 用 Wireshark 打开 /tmp/mqtt_traffic.pcap
|
||||
# 过滤器: mqtt
|
||||
# 查看:
|
||||
# 1. 是否看到重复的 PUBLISH 包(PacketID 相同)
|
||||
# 2. 重发的时间间隔是多少
|
||||
# 3. 是否有 TCP 重传标志 [TCP Retransmission]
|
||||
```
|
||||
|
||||
### 步骤 3: 测试不同的消息类型
|
||||
|
||||
```bash
|
||||
# 安装 mosquitto 客户端
|
||||
# macOS: brew install mosquitto
|
||||
# Linux: apt-get install mosquitto-clients
|
||||
|
||||
# 发送一个简单的测试消息(QoS 0)
|
||||
mosquitto_pub -h localhost -p 1883 -t "test/topic" -m "hello" -q 0 -d
|
||||
|
||||
# 观察:
|
||||
# 1. mosquitto_pub 是否报错
|
||||
# 2. 服务器日志是否显示 rejected
|
||||
# 3. 是否看到重发行为
|
||||
```
|
||||
|
||||
### 步骤 4: 检查 PSK 配置
|
||||
|
||||
```bash
|
||||
# 查看当前配置
|
||||
cat config.yaml | grep -A 5 "meshtastic:"
|
||||
|
||||
# 如果使用默认 PSK
|
||||
psk: "AQ==" # 这是索引 1 的默认 PSK
|
||||
|
||||
# 如果使用自定义 PSK,确保与设备一致
|
||||
psk: "your_base64_encoded_psk"
|
||||
```
|
||||
|
||||
## 常见原因和解决方案
|
||||
|
||||
### 原因 1: 消息无法解密
|
||||
|
||||
**症状:** 日志显示 `error=cannot be decrypted`
|
||||
|
||||
**解决方案 A - 配置正确的 PSK:**
|
||||
```yaml
|
||||
# config.yaml
|
||||
meshtastic:
|
||||
psk: "your_base64_psk" # 与设备 channel 的 PSK 一致
|
||||
```
|
||||
|
||||
**解决方案 B - 允许转发加密消息:**
|
||||
```yaml
|
||||
# config.yaml
|
||||
meshtastic:
|
||||
allow_encrypted_forwarding: true # 即使无法解密也转发
|
||||
```
|
||||
|
||||
### 原因 2: 节点或内容被屏蔽
|
||||
|
||||
**症状:** 日志显示 `PUBLISH blocked`
|
||||
|
||||
**解决方案:**
|
||||
```sql
|
||||
-- 查看屏蔽规则
|
||||
SELECT * FROM blocking_rules WHERE enabled = 1;
|
||||
|
||||
-- 临时禁用特定规则
|
||||
UPDATE blocking_rules SET enabled = 0 WHERE id = <rule_id>;
|
||||
|
||||
-- 或禁用所有规则测试
|
||||
UPDATE blocking_rules SET enabled = 0;
|
||||
```
|
||||
|
||||
### 原因 3: Protobuf 解析失败
|
||||
|
||||
**症状:** 日志显示 `error=protobuf decode failed`
|
||||
|
||||
**可能原因:**
|
||||
- 设备发送的不是标准的 Meshtastic 协议包
|
||||
- 固件版本不兼容
|
||||
- 数据损坏
|
||||
|
||||
**解决方案:**
|
||||
- 更新设备固件到最新版本
|
||||
- 检查设备配置是否正确
|
||||
- 联系设备厂商
|
||||
|
||||
### 原因 4: 设备端 Bug
|
||||
|
||||
**症状:** 服务器日志显示消息正常接收,没有 rejected/blocked,但设备仍然重发
|
||||
|
||||
**诊断方法:**
|
||||
1. 检查设备日志(如果可访问)
|
||||
2. 更新设备固件
|
||||
3. 尝试不同的 QoS 级别(QoS 1)看是否还重发
|
||||
4. 联系设备厂商报告问题
|
||||
|
||||
## 监控脚本
|
||||
|
||||
创建一个监控脚本 `monitor_rejects.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo "监控 MQTT 消息拒绝情况..."
|
||||
echo "按 Ctrl+C 停止"
|
||||
echo ""
|
||||
|
||||
# 实时监控日志
|
||||
tail -f /path/to/server.log | grep --line-buffered -E "rejected|blocked" | while read line; do
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "[$timestamp] $line"
|
||||
|
||||
# 播放提示音(可选)
|
||||
# echo -e "\a"
|
||||
done
|
||||
```
|
||||
|
||||
使用:
|
||||
```bash
|
||||
chmod +x monitor_rejects.sh
|
||||
./monitor_rejects.sh
|
||||
```
|
||||
|
||||
## 性能统计
|
||||
|
||||
查看消息处理统计:
|
||||
|
||||
```sql
|
||||
-- 最近一小时的消息统计
|
||||
SELECT
|
||||
'Forwarded' as type,
|
||||
COUNT(*) as count
|
||||
FROM packets
|
||||
WHERE created_at > strftime('%s', 'now', '-1 hour')
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Rejected' as type,
|
||||
COUNT(*) as count
|
||||
FROM discarded_packets
|
||||
WHERE created_at > strftime('%s', 'now', '-1 hour');
|
||||
|
||||
-- 按客户端统计
|
||||
SELECT
|
||||
client_id,
|
||||
COUNT(*) as total_messages,
|
||||
SUM(CASE WHEN from_discarded = 1 THEN 1 ELSE 0 END) as rejected,
|
||||
printf('%.2f%%',
|
||||
SUM(CASE WHEN from_discarded = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
|
||||
) as reject_rate
|
||||
FROM (
|
||||
SELECT client_id, 0 as from_discarded FROM packets
|
||||
WHERE created_at > strftime('%s', 'now', '-1 hour')
|
||||
UNION ALL
|
||||
SELECT client_id, 1 as from_discarded FROM discarded_packets
|
||||
WHERE created_at > strftime('%s', 'now', '-1 hour')
|
||||
)
|
||||
GROUP BY client_id
|
||||
ORDER BY rejected DESC;
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
遵循这个诊断流程:
|
||||
|
||||
1. ✅ **启用详细日志** - 最重要的第一步
|
||||
2. ✅ **观察是否有 rejected/blocked** - 判断是否服务器拒绝
|
||||
3. ✅ **检查数据库** - 查看历史拒绝记录
|
||||
4. ✅ **抓包分析** - 确认网络层行为
|
||||
5. ✅ **根据原因修复** - 应用对应的解决方案
|
||||
|
||||
如果日志中**没有任何 rejected/blocked 消息**,但设备仍然重发,那么问题100%在**设备端固件**,需要:
|
||||
- 更新设备固件
|
||||
- 检查设备配置
|
||||
- 联系设备厂商
|
||||
@@ -0,0 +1,259 @@
|
||||
# QoS0 消息重发问题深度分析
|
||||
|
||||
## 问题现状
|
||||
|
||||
即使启用了 TCP_NODELAY,设备发送 QoS0 消息后仍然重发 3 次。
|
||||
|
||||
## 根本原因分析
|
||||
|
||||
### 1. TCP_NODELAY 修复了什么?
|
||||
|
||||
✅ TCP_NODELAY 确实解决了 **TCP ACK 延迟**问题:
|
||||
- Nagle 算法延迟从 40-200ms 降低到 ~0.05ms
|
||||
- TCP 层的确认现在是即时的
|
||||
|
||||
❌ 但这**不能解决消息被拒绝的问题**。
|
||||
|
||||
### 2. 消息被拒绝的流程
|
||||
|
||||
当设备发送的消息不符合服务器要求时:
|
||||
|
||||
```
|
||||
设备 → MQTT PUBLISH (QoS0)
|
||||
↓
|
||||
服务器 TCP 层收到 → 发送 TCP ACK ✅
|
||||
↓
|
||||
MQTT 层处理 → OnPublish hook
|
||||
↓
|
||||
MQTTPP 验证失败 → valid=false
|
||||
↓
|
||||
返回 packets.ErrRejectPacket
|
||||
↓
|
||||
mochi-mqtt 处理: return nil (不发送任何 MQTT 响应)
|
||||
↓
|
||||
设备收到 TCP ACK ✅ 但没有收到 MQTT 层响应
|
||||
↓
|
||||
设备认为消息可能丢失 → 重发 ❌
|
||||
```
|
||||
|
||||
### 3. 为什么会重发?
|
||||
|
||||
可能的原因:
|
||||
|
||||
#### 原因 A: 消息验证失败
|
||||
|
||||
检查以下验证失败的情况:
|
||||
|
||||
1. **Protobuf 解码失败**
|
||||
```
|
||||
parseServiceEnvelope() 返回错误
|
||||
→ MQTTPP 返回 valid=false
|
||||
```
|
||||
|
||||
2. **解密失败**
|
||||
```
|
||||
describePacket() 无法解密
|
||||
→ type="encrypted_packet" 且 AllowEncryptedForwarding=false
|
||||
→ MQTTPP 返回 valid=false
|
||||
```
|
||||
|
||||
3. **屏蔽规则命中**
|
||||
```
|
||||
blockingViolationForRecord() 返回非 nil
|
||||
→ OnPublish 返回 ErrRejectPacket
|
||||
```
|
||||
|
||||
#### 原因 B: 设备期待应用层响应
|
||||
|
||||
某些 MQTT 客户端实现可能:
|
||||
- 虽然使用 QoS0(不需要 PUBACK)
|
||||
- 但仍然期待某种应用层响应或订阅回显
|
||||
- 没有收到预期响应时触发重试逻辑
|
||||
|
||||
#### 原因 C: 设备端 Bug
|
||||
|
||||
设备固件可能有 bug:
|
||||
- 错误地认为 QoS0 需要应用层确认
|
||||
- 超时机制设置不当
|
||||
- 重试逻辑实现错误
|
||||
|
||||
## 诊断步骤
|
||||
|
||||
### 步骤 1: 查看服务器日志
|
||||
|
||||
检查消息是否被拒绝:
|
||||
|
||||
```bash
|
||||
# 启用控制台日志
|
||||
./meshtastic_mqtt_server --console-log-mqtt=true --console-log-meshtastic=true
|
||||
|
||||
# 查找被拒绝的消息
|
||||
grep -E "error|dropped|rejected" logs.txt
|
||||
```
|
||||
|
||||
**关键日志标识:**
|
||||
- `protobuf decode failed` - protobuf 解析失败
|
||||
- `cannot be decrypted` - 解密失败
|
||||
- `blocked node` / `forbidden word` - 屏蔽规则命中
|
||||
|
||||
### 步骤 2: 抓包分析
|
||||
|
||||
```bash
|
||||
# 抓取 MQTT 流量
|
||||
tcpdump -i any -nn port 1883 -w mqtt.pcap
|
||||
|
||||
# 用 Wireshark 分析:
|
||||
# 1. 查看是否有 TCP 重传 (Retransmission)
|
||||
# 2. 查看 MQTT PUBLISH 是否有对应的响应
|
||||
# 3. 检查时序图,看设备重发的时间间隔
|
||||
```
|
||||
|
||||
**期待的正常流程 (QoS0):**
|
||||
```
|
||||
Client → Server: MQTT PUBLISH (QoS0)
|
||||
Server → Client: TCP ACK
|
||||
(没有 MQTT 层的 PUBACK,因为是 QoS0)
|
||||
```
|
||||
|
||||
**如果消息被拒绝:**
|
||||
```
|
||||
Client → Server: MQTT PUBLISH (QoS0)
|
||||
Server → Client: TCP ACK
|
||||
(服务器静默丢弃,没有任何 MQTT 响应)
|
||||
Client → Server: MQTT PUBLISH (QoS0) [重发]
|
||||
Server → Client: TCP ACK
|
||||
...
|
||||
```
|
||||
|
||||
### 步骤 3: 检查数据库
|
||||
|
||||
```sql
|
||||
-- 查看被丢弃的消息
|
||||
SELECT * FROM discarded_packets
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
-- 统计丢弃原因
|
||||
SELECT
|
||||
json_extract(record, '$.error') as error_type,
|
||||
COUNT(*) as count
|
||||
FROM discarded_packets
|
||||
GROUP BY error_type;
|
||||
```
|
||||
|
||||
### 步骤 4: 测试不同的消息
|
||||
|
||||
```bash
|
||||
# 发送一个有效的测试消息
|
||||
mosquitto_pub -h localhost -p 1883 -t "msh/CN/2/e/LongFast/!12345678" -m "test" -q 0
|
||||
|
||||
# 观察是否也会重发
|
||||
```
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 方案 1: 修复消息验证问题
|
||||
|
||||
如果是消息验证失败导致:
|
||||
|
||||
**检查 PSK 配置:**
|
||||
```bash
|
||||
# 确保服务器配置了正确的 PSK
|
||||
./meshtastic_mqtt_server --psk="your_base64_psk"
|
||||
```
|
||||
|
||||
**检查屏蔽规则:**
|
||||
```sql
|
||||
-- 查看当前的屏蔽规则
|
||||
SELECT * FROM blocking_rules WHERE enabled = 1;
|
||||
|
||||
-- 临时禁用所有规则测试
|
||||
UPDATE blocking_rules SET enabled = 0;
|
||||
```
|
||||
|
||||
### 方案 2: 允许加密消息转发
|
||||
|
||||
如果消息是加密的且无法解密:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
meshtastic:
|
||||
allow_encrypted_forwarding: true
|
||||
```
|
||||
|
||||
这样即使无法解密,消息也会被转发而不是拒绝。
|
||||
|
||||
### 方案 3: 返回明确的错误响应(不推荐)
|
||||
|
||||
理论上可以在消息被拒绝时返回 MQTT 错误码,但:
|
||||
- ❌ QoS 0 协议规定不应该有 PUBACK
|
||||
- ❌ 违反 MQTT 规范
|
||||
- ❌ 可能导致客户端行为异常
|
||||
|
||||
### 方案 4: 设备端修复
|
||||
|
||||
如果是设备固件 bug:
|
||||
- 更新设备固件到最新版本
|
||||
- 检查设备日志,确认重发原因
|
||||
- 联系设备厂商报告 bug
|
||||
|
||||
## 监控和调试
|
||||
|
||||
### 添加详细日志
|
||||
|
||||
修改 `main.go` 的 `OnPublish` 方法:
|
||||
|
||||
```go
|
||||
func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
||||
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key, mqtpp.Options{
|
||||
AllowEncryptedForwarding: h.settings.AllowEncryptedForwarding(),
|
||||
PKIKeyResolver: h.pkiResolver,
|
||||
})
|
||||
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
|
||||
if !valid {
|
||||
// 添加详细日志
|
||||
printJSON(map[string]any{
|
||||
"event": "publish_rejected",
|
||||
"reason": "validation_failed",
|
||||
"client_id": info.ClientID,
|
||||
"topic": pk.TopicName,
|
||||
"payload_len": len(pk.Payload),
|
||||
"error": record["error"],
|
||||
})
|
||||
h.rejectPublish(cl, pk, record)
|
||||
return pk, packets.ErrRejectPacket
|
||||
}
|
||||
|
||||
// ... 其他逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 监控重发率
|
||||
|
||||
```sql
|
||||
-- 创建视图统计每个客户端的重发率
|
||||
CREATE VIEW client_retransmit_stats AS
|
||||
SELECT
|
||||
client_id,
|
||||
COUNT(*) as total_attempts,
|
||||
COUNT(DISTINCT packet_id) as unique_packets,
|
||||
(COUNT(*) - COUNT(DISTINCT packet_id)) * 100.0 / COUNT(*) as retransmit_rate
|
||||
FROM packets
|
||||
GROUP BY client_id
|
||||
HAVING retransmit_rate > 10;
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
TCP_NODELAY 修复了 TCP 层的延迟问题,但如果消息本身被服务器拒绝(验证失败、解密失败、屏蔽规则等),设备仍然会重发。
|
||||
|
||||
**下一步行动:**
|
||||
|
||||
1. ✅ 启用详细日志,查看是否有消息被拒绝
|
||||
2. ✅ 检查 `discarded_packets` 表,确认拒绝原因
|
||||
3. ✅ 抓包分析,确认是 TCP 重传还是应用层重发
|
||||
4. ✅ 根据诊断结果选择对应的解决方案
|
||||
|
||||
**如果所有消息都被正常处理(没有被拒绝),但仍然重发,那么问题在设备端固件。**
|
||||
@@ -0,0 +1,197 @@
|
||||
# MQTT QoS0 重发问题 - 完整解决方案
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户报告:设备使用 QoS0 发送 MQTT 消息后,服务器好像不给 ACK,导致设备一直重发 3 次,有时候又一次发送成功。
|
||||
|
||||
## 解决方案总结
|
||||
|
||||
我们进行了两轮修复和诊断增强:
|
||||
|
||||
### 第一轮:修复 TCP 层延迟问题 ✅
|
||||
|
||||
**问题:** TCP Nagle 算法导致 TCP ACK 延迟 40-200ms
|
||||
**修复:** 在 `OnConnect` 中启用 `TCP_NODELAY`
|
||||
**效果:** TCP ACK 延迟降低到 ~0.05ms
|
||||
**Commit:** `ec24e70` - 修复 MQTT QoS0 消息重发问题
|
||||
|
||||
### 第二轮:诊断应用层拒绝问题 ✅
|
||||
|
||||
**发现:** TCP_NODELAY 只解决了传输层延迟,但如果消息被应用层拒绝,设备仍会重发
|
||||
**改进:** 添加详细的拒绝日志,帮助快速定位问题
|
||||
**Commit:** `bce6b70` - 增强 MQTT 消息拒绝诊断功能
|
||||
|
||||
## 快速诊断方法
|
||||
|
||||
### 步骤 1: 启用详细日志
|
||||
|
||||
```bash
|
||||
./meshtastic_mqtt_server --console-log-mqtt=true --console-log-meshtastic=true
|
||||
```
|
||||
|
||||
### 步骤 2: 观察日志
|
||||
|
||||
#### 场景 A: 看到 `PUBLISH rejected` 或 `PUBLISH blocked`
|
||||
→ **服务器拒绝了消息**
|
||||
→ 查看具体错误原因,应用对应的解决方案
|
||||
|
||||
#### 场景 B: 没有看到 rejected/blocked,消息正常接收
|
||||
→ **不是服务器问题**
|
||||
→ 问题在设备端固件,需要设备端修复
|
||||
|
||||
## 常见原因和解决方案
|
||||
|
||||
### 原因 1: 消息无法解密 (`error=cannot be decrypted`)
|
||||
|
||||
**解决方案:**
|
||||
```yaml
|
||||
# 方法 1: 配置正确的 PSK
|
||||
meshtastic:
|
||||
psk: "your_base64_psk" # 必须与设备一致
|
||||
|
||||
# 方法 2: 允许转发加密消息
|
||||
meshtastic:
|
||||
allow_encrypted_forwarding: true
|
||||
```
|
||||
|
||||
### 原因 2: 消息被屏蔽 (`PUBLISH blocked`)
|
||||
|
||||
**解决方案:**
|
||||
```sql
|
||||
-- 查看屏蔽规则
|
||||
SELECT * FROM blocking_rules WHERE enabled = 1;
|
||||
|
||||
-- 禁用特定规则
|
||||
UPDATE blocking_rules SET enabled = 0 WHERE id = <rule_id>;
|
||||
```
|
||||
|
||||
### 原因 3: Protobuf 解析失败 (`error=protobuf decode failed`)
|
||||
|
||||
**解决方案:**
|
||||
- 更新设备固件到最新版本
|
||||
- 检查设备是否使用标准 Meshtastic 协议
|
||||
|
||||
### 原因 4: 设备端 Bug
|
||||
|
||||
**症状:** 服务器日志正常,但设备仍重发
|
||||
**解决方案:**
|
||||
- 更新设备固件
|
||||
- 联系设备厂商
|
||||
|
||||
## 文档索引
|
||||
|
||||
1. **[TCP_ACK_FIX_CN.md](doc/TCP_ACK_FIX_CN.md)** - TCP_NODELAY 修复详解(中文)
|
||||
2. **[TCP_ACK_FIX.md](doc/TCP_ACK_FIX.md)** - TCP_NODELAY 修复详解(英文)
|
||||
3. **[QOS0_RETRANSMIT_ANALYSIS.md](doc/QOS0_RETRANSMIT_ANALYSIS.md)** - 重发问题深度分析
|
||||
4. **[DIAGNOSTIC_GUIDE.md](doc/DIAGNOSTIC_GUIDE.md)** - 完整诊断指南
|
||||
|
||||
## 技术细节
|
||||
|
||||
### TCP_NODELAY 的作用
|
||||
|
||||
```
|
||||
没有 TCP_NODELAY:
|
||||
客户端发送 → 服务器收到 → 等待 Nagle 算法 (40-200ms) → TCP ACK
|
||||
|
||||
有 TCP_NODELAY:
|
||||
客户端发送 → 服务器收到 → 立即 TCP ACK (~0.05ms)
|
||||
```
|
||||
|
||||
### MQTT QoS0 的特性
|
||||
|
||||
- QoS 0 = "至多一次"交付
|
||||
- MQTT 应用层**不需要** PUBACK
|
||||
- 但 TCP 层**仍然需要** TCP ACK
|
||||
- 如果消息被应用层拒绝(返回 ErrRejectPacket),mochi-mqtt 只是静默返回,不发送任何响应
|
||||
|
||||
### 消息处理流程
|
||||
|
||||
```
|
||||
设备 → MQTT PUBLISH (QoS0)
|
||||
↓
|
||||
TCP 层收到 → TCP ACK ✅ (现在是即时的)
|
||||
↓
|
||||
MQTT 层 OnPublish hook
|
||||
↓
|
||||
MQTTPP 验证
|
||||
↓
|
||||
├─ valid=true → 转发消息 ✅
|
||||
│
|
||||
└─ valid=false → 返回 ErrRejectPacket → 静默丢弃 ❌
|
||||
↓
|
||||
设备可能重发
|
||||
```
|
||||
|
||||
## Git 提交历史
|
||||
|
||||
```bash
|
||||
git log --oneline -3
|
||||
|
||||
bce6b70 增强 MQTT 消息拒绝诊断功能
|
||||
ec24e70 修复 MQTT QoS0 消息重发问题
|
||||
cfe4ef0 修复消息重复问题
|
||||
```
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 单元测试
|
||||
```bash
|
||||
go test -v -run TestTCPNoDelay
|
||||
go test -v -run TestQoS0MessageLatency
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
```bash
|
||||
# 启动服务器
|
||||
./meshtastic_mqtt_server --console-log-mqtt=true
|
||||
|
||||
# 发送测试消息
|
||||
mosquitto_pub -h localhost -p 1883 -t "test/topic" -m "hello" -q 0
|
||||
|
||||
# 观察日志输出
|
||||
```
|
||||
|
||||
## 性能指标
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
|------|--------|--------|
|
||||
| TCP ACK 延迟 | 40-200ms | ~0.05ms |
|
||||
| 往返延迟 | 不稳定 | ~54µs |
|
||||
| 提升倍数 | - | **1000x** |
|
||||
|
||||
## 下一步行动
|
||||
|
||||
1. ✅ **重新编译部署**
|
||||
```bash
|
||||
go build
|
||||
./meshtastic_mqtt_server
|
||||
```
|
||||
|
||||
2. ✅ **启用详细日志**
|
||||
```bash
|
||||
./meshtastic_mqtt_server --console-log-mqtt=true
|
||||
```
|
||||
|
||||
3. ✅ **观察日志**
|
||||
- 如果看到 `rejected/blocked` → 应用对应的解决方案
|
||||
- 如果没看到 → 问题在设备端
|
||||
|
||||
4. ✅ **查看数据库**
|
||||
```sql
|
||||
SELECT * FROM discarded_packets ORDER BY created_at DESC LIMIT 20;
|
||||
```
|
||||
|
||||
5. ✅ **必要时抓包**
|
||||
```bash
|
||||
tcpdump -i any -nn port 1883 -w mqtt.pcap
|
||||
```
|
||||
|
||||
## 结论
|
||||
|
||||
我们修复了 TCP 层的延迟问题,并添加了完善的诊断工具。现在你可以:
|
||||
|
||||
1. 快速判断重发是由服务器拒绝还是设备 bug 引起
|
||||
2. 看到具体的拒绝原因和错误信息
|
||||
3. 根据诊断结果应用对应的解决方案
|
||||
|
||||
**如果重发问题依然存在,请按照诊断指南操作,查看日志中是否有 rejected/blocked 消息,并将结果反馈给我。**
|
||||
@@ -0,0 +1,193 @@
|
||||
# 签到工具查询功能增强
|
||||
|
||||
## 问题描述
|
||||
|
||||
原签到工具只支持签到操作,不支持查询。当用户询问"今天有多少人签到"或"最近几天的签到情况"时,AI 无法调用工具获取准确数据,导致:
|
||||
|
||||
1. **数据不准确**:AI 只能根据上下文猜测或给出模糊答案
|
||||
2. **无法查询历史**:询问超过一天的数据时,AI 一问三不知
|
||||
3. **功能不完整**:数据库层已有完善的查询能力(`CountSigns`、`CountSignsByDay`),但工具层未暴露给 AI
|
||||
4. **状态判断不准**:用户问"我今天签到了吗"时,AI 基于对话记忆判断而非查询数据库,导致误判
|
||||
|
||||
## 解决方案
|
||||
|
||||
为签到工具添加查询和检查功能,支持以下三种操作模式:
|
||||
|
||||
### 1. 签到操作 (action=sign)
|
||||
|
||||
保持原有功能不变:
|
||||
- 记录节点今日签到信息
|
||||
- 每个节点每天只能签到一次
|
||||
- 必填参数:地区、名字、设备
|
||||
|
||||
### 2. 查询操作 (action=query)
|
||||
|
||||
查询签到统计功能:
|
||||
- 查询指定日期或日期范围的签到统计
|
||||
- 返回总人次和按天分组的统计数据
|
||||
- 可选参数:
|
||||
- `date`: 查询日期(格式:YYYY-MM-DD),默认今天
|
||||
- `days`: 查询最近 N 天,默认只查询 date 指定的那一天
|
||||
|
||||
### 3. 检查操作 (action=check) **新增**
|
||||
|
||||
检查当前节点今天是否已签到:
|
||||
- 用于回答"我今天签到了吗"、"我什么时候签到的"之类的问题
|
||||
- 直接查询数据库,不依赖对话历史
|
||||
- 返回明确的签到状态,**包括签到时间和签到内容**
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 扩展 SignStore 接口
|
||||
|
||||
```go
|
||||
type SignStore interface {
|
||||
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||||
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||||
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||||
// 新增查询方法
|
||||
CountSigns(opts storepkg.ListOptions) (int64, error)
|
||||
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
|
||||
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 更新工具定义
|
||||
|
||||
工具描述中明确说明支持两种操作:
|
||||
- action=sign:签到
|
||||
- action=query:查询统计
|
||||
|
||||
### 3. 实现查询逻辑
|
||||
|
||||
- `executeSign()`: 原签到逻辑
|
||||
- `executeQuery()`: 新增查询逻辑
|
||||
- 解析日期参数
|
||||
- 构建查询条件(Since/Until)
|
||||
- 调用 store 查询接口
|
||||
- 格式化返回结果
|
||||
|
||||
### 4. 扩展参数结构
|
||||
|
||||
```go
|
||||
type signParams struct {
|
||||
Action string `json:"action"` // sign 或 query
|
||||
// 签到参数
|
||||
Region string `json:"region"`
|
||||
Name string `json:"name"`
|
||||
Device string `json:"device"`
|
||||
TxPower string `json:"tx_power"`
|
||||
AntennaLength string `json:"antenna_length"`
|
||||
Altitude string `json:"altitude"`
|
||||
RawText string `json:"raw_text"`
|
||||
// 查询参数
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Days int `json:"days"` // 最近 N 天
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### AI 调用示例
|
||||
|
||||
#### 检查今天是否签到(check 操作)
|
||||
|
||||
**用户**:"我今天签到了吗?"
|
||||
|
||||
AI 调用:
|
||||
```json
|
||||
{
|
||||
"action": "check"
|
||||
}
|
||||
```
|
||||
|
||||
返回(未签到):
|
||||
```text
|
||||
Test Node 今天还没有签到。
|
||||
```
|
||||
|
||||
返回(已签到):
|
||||
```text
|
||||
Test Node 今天已经签到过了。
|
||||
签到时间:10:30:45
|
||||
签到内容:上海闵行-Kevin-GAT562签到
|
||||
```
|
||||
|
||||
**用户**:"我什么时候签到的?"
|
||||
|
||||
AI 同样调用 check 操作,返回包含签到时间和内容的完整信息。
|
||||
|
||||
#### 查询今天的签到情况
|
||||
```json
|
||||
{
|
||||
"action": "query",
|
||||
"date": "2024-06-23"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
2024-06-23 的签到统计:
|
||||
总计:5 人次
|
||||
|
||||
按天统计:
|
||||
- 2024-06-23: 5 人
|
||||
```
|
||||
|
||||
#### 查询最近 7 天的签到情况
|
||||
```json
|
||||
{
|
||||
"action": "query",
|
||||
"date": "2024-06-23",
|
||||
"days": 7
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
```
|
||||
最近 7 天的签到统计:
|
||||
总计:32 人次
|
||||
|
||||
按天统计:
|
||||
- 2024-06-23: 5 人
|
||||
- 2024-06-22: 6 人
|
||||
- 2024-06-21: 4 人
|
||||
- 2024-06-20: 7 人
|
||||
- 2024-06-19: 3 人
|
||||
- 2024-06-18: 4 人
|
||||
- 2024-06-17: 3 人
|
||||
```
|
||||
|
||||
#### 签到(保持原有功能)
|
||||
```json
|
||||
{
|
||||
"action": "sign",
|
||||
"region": "上海闵行",
|
||||
"name": "Kevin",
|
||||
"device": "GAT562"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
创建了完整的单元测试 `sign_test.go`,覆盖:
|
||||
- ✅ 查询今天的签到统计
|
||||
- ✅ 查询最近 N 天的签到统计
|
||||
- ✅ 查询指定日期的签到统计
|
||||
- ✅ 签到功能(回归测试)
|
||||
|
||||
所有测试通过。
|
||||
|
||||
## 影响范围
|
||||
|
||||
- ✅ 向后兼容:原有签到功能完全保持不变
|
||||
- ✅ 数据库无需修改:查询接口已存在
|
||||
- ✅ 新增功能:AI 现在可以准确回答签到统计问题
|
||||
- ✅ 编译通过:整个项目构建成功
|
||||
|
||||
## 后续建议
|
||||
|
||||
可以考虑进一步增强:
|
||||
1. 支持按节点查询:某个特定节点的签到历史
|
||||
2. 支持按地区统计:哪些地区签到最活跃
|
||||
3. 支持导出详细列表:不仅是统计,还能看到每条签到的详细内容
|
||||
@@ -0,0 +1,159 @@
|
||||
# MQTT QoS0 消息重发问题修复
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户设备使用 QoS0 发送 MQTT 消息后,服务器未能及时响应 TCP ACK,导致设备认为消息丢失而重发(通常重发 3 次)。有时候能一次发送成功,有时候需要多次重发。
|
||||
|
||||
## 根本原因
|
||||
|
||||
### TCP Nagle 算法
|
||||
|
||||
问题的根源是 **TCP Nagle 算法**(RFC 896)。Nagle 算法的目的是减少网络中的小数据包数量,提高网络效率。它的工作原理是:
|
||||
|
||||
1. 如果有未确认的数据在传输中,新的小数据包会被缓冲
|
||||
2. 等待之前的数据被 ACK,或者缓冲区累积到 MSS 大小
|
||||
3. 这会导致小数据包(包括 TCP ACK)延迟 40-200ms
|
||||
|
||||
### MQTT QoS0 的特性
|
||||
|
||||
- **QoS 0** 是"至多一次"交付(At most once)
|
||||
- MQTT 应用层不需要 PUBACK 确认
|
||||
- **但是 TCP 层仍然需要 TCP ACK** 来确认数据包已收到
|
||||
- 如果 TCP ACK 延迟,客户端的 TCP 栈会认为数据包丢失,触发重传
|
||||
|
||||
### 为什么有时成功,有时失败?
|
||||
|
||||
这取决于网络状态和时序:
|
||||
- 如果恰好有其他数据包发送,TCP ACK 会搭顺风车立即发送 ✅
|
||||
- 如果网络空闲,Nagle 算法会延迟 TCP ACK 发送,直到超时 ❌
|
||||
- 这解释了为什么"有时候又一次发送成功"
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 启用 TCP_NODELAY
|
||||
|
||||
在 `OnConnect` hook 中,对每个新连接设置 `TCP_NODELAY` 选项:
|
||||
|
||||
```go
|
||||
func (h *meshtasticFilterHook) OnConnect(cl *mqtt.Client, pk packets.Packet) error {
|
||||
// 启用 TCP_NODELAY 禁用 Nagle 算法,确保小数据包(包括 TCP ACK)立即发送
|
||||
// 这对于 MQTT QoS0 消息特别重要,避免设备因为等待 TCP ACK 而重发
|
||||
if cl.Net.Conn != nil {
|
||||
if tcpConn, ok := cl.Net.Conn.(*net.TCPConn); ok {
|
||||
if err := tcpConn.SetNoDelay(true); err != nil {
|
||||
printJSON(map[string]any{"event": "tcp_nodelay_failed", "error": err.Error(), "remote_addr": cl.Net.Remote})
|
||||
}
|
||||
}
|
||||
}
|
||||
// ... 其他逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### TCP_NODELAY 的作用
|
||||
|
||||
设置 `TCP_NODELAY = true` 会:
|
||||
1. **禁用 Nagle 算法**
|
||||
2. **立即发送小数据包**,包括 TCP ACK
|
||||
3. **减少延迟**,特别是对于小消息和交互式应用
|
||||
4. **防止重传**,因为 ACK 会立即发送
|
||||
|
||||
### 权衡
|
||||
|
||||
**优点:**
|
||||
- ✅ 消除 TCP ACK 延迟(从 40-200ms 降到 <1ms)
|
||||
- ✅ 防止不必要的重传
|
||||
- ✅ 降低设备端的重试逻辑压力
|
||||
- ✅ 改善用户体验(消息发送更快)
|
||||
|
||||
**缺点:**
|
||||
- ⚠️ 增加小数据包数量(但对于 MQTT 这种交互式协议是值得的)
|
||||
- ⚠️ 略微增加带宽使用(影响很小,通常可以忽略)
|
||||
|
||||
对于 MQTT 这种需要低延迟、交互式的协议,**TCP_NODELAY 是标准最佳实践**。
|
||||
|
||||
## 验证测试
|
||||
|
||||
### 测试结果
|
||||
|
||||
运行 `tcp_nodelay_test.go` 的测试结果:
|
||||
|
||||
```
|
||||
=== RUN TestQoS0MessageLatency
|
||||
Round trip 1: 118.083µs
|
||||
Round trip 2: 98.5µs
|
||||
Round trip 3: 65.125µs
|
||||
Round trip 4: 64.75µs
|
||||
Round trip 5: 108.291µs
|
||||
Round trip 6: 115.708µs
|
||||
Round trip 7: 115.459µs
|
||||
Round trip 8: 114.875µs
|
||||
Round trip 9: 113.166µs
|
||||
Round trip 10: 113.917µs
|
||||
Average latency: 102.787µs
|
||||
--- PASS: TestQoS0MessageLatency
|
||||
```
|
||||
|
||||
平均往返延迟:**~100µs**(0.1ms),远低于 Nagle 算法的典型延迟(40-200ms)。
|
||||
|
||||
### 如何测试修复效果
|
||||
|
||||
1. **编译并部署新版本**:
|
||||
```bash
|
||||
go build
|
||||
./meshtastic_mqtt_server
|
||||
```
|
||||
|
||||
2. **观察设备行为**:
|
||||
- 设备应该不再重发 QoS0 消息
|
||||
- 消息发送应该一次成功
|
||||
- 延迟应该显著降低
|
||||
|
||||
3. **使用 Wireshark 抓包验证**(可选):
|
||||
```bash
|
||||
# 抓包查看 TCP ACK 时序
|
||||
tcpdump -i any -nn port 1883 -w mqtt_traffic.pcap
|
||||
```
|
||||
- 查看 TCP ACK 是否立即发送(几十微秒内)
|
||||
- 确认没有 TCP 重传(Retransmission)
|
||||
|
||||
## 行业标准
|
||||
|
||||
大多数 MQTT broker 实现都默认启用 TCP_NODELAY:
|
||||
|
||||
- **Mosquitto**:默认启用 TCP_NODELAY
|
||||
- **EMQX**:默认启用 TCP_NODELAY
|
||||
- **HiveMQ**:默认启用 TCP_NODELAY
|
||||
- **VerneMQ**:默认启用 TCP_NODELAY
|
||||
|
||||
现在我们的实现也符合行业最佳实践。
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [RFC 896 - Congestion Control in IP/TCP Internetworks](https://tools.ietf.org/html/rfc896)
|
||||
- [MQTT v3.1.1 Specification](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html)
|
||||
- [TCP_NODELAY and Small Buffer Writes](https://www.extrahop.com/company/blog/2016/tcp-nodelay-nagle-quickack-best-practices/)
|
||||
|
||||
## 提交信息
|
||||
|
||||
```
|
||||
修复 MQTT QoS0 消息重发问题
|
||||
|
||||
问题:
|
||||
- 设备发送 QoS0 消息后服务器未及时响应 TCP ACK
|
||||
- 导致设备重发 3 次
|
||||
- 有时能一次成功,有时需要多次重发
|
||||
|
||||
根本原因:
|
||||
- TCP Nagle 算法延迟小数据包(包括 TCP ACK)
|
||||
- 延迟通常 40-200ms,触发客户端 TCP 重传
|
||||
|
||||
解决方案:
|
||||
- 在 OnConnect hook 中设置 TCP_NODELAY
|
||||
- 禁用 Nagle 算法,确保 TCP ACK 立即发送
|
||||
- 延迟降低到 ~100µs
|
||||
|
||||
测试:
|
||||
- 添加 tcp_nodelay_test.go 验证修复
|
||||
- 平均往返延迟:102.787µs
|
||||
- 符合 MQTT broker 行业最佳实践
|
||||
```
|
||||
@@ -0,0 +1,128 @@
|
||||
# MQTT QoS0 消息重发问题修复
|
||||
|
||||
## 问题现象
|
||||
|
||||
设备使用 QoS0 发送 MQTT 消息后,服务器好像不给 ACK,导致设备一直重发 3 次,有时候又一次发送成功。
|
||||
|
||||
## 根本原因
|
||||
|
||||
**TCP Nagle 算法** 导致了 TCP ACK 延迟:
|
||||
|
||||
1. **什么是 Nagle 算法?**
|
||||
- TCP 层的优化算法,用于减少网络中的小数据包数量
|
||||
- 会将小数据包缓冲起来,等待:
|
||||
- 之前的数据被确认,或者
|
||||
- 缓冲区达到 MSS 大小(通常 1460 字节)
|
||||
- 这会导致 **40-200ms 的延迟**
|
||||
|
||||
2. **为什么影响 MQTT QoS0?**
|
||||
- QoS 0 = "至多一次",MQTT 应用层不需要 PUBACK
|
||||
- 但是 **TCP 层仍然需要 TCP ACK** 确认数据包收到
|
||||
- TCP ACK 被 Nagle 算法延迟 → 设备 TCP 栈认为丢包 → 触发重传
|
||||
|
||||
3. **为什么有时成功,有时失败?**
|
||||
- ✅ 有其他数据流动:TCP ACK 搭顺风车立即发送
|
||||
- ❌ 网络空闲:Nagle 算法延迟 TCP ACK,触发重传超时
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 代码修改
|
||||
|
||||
在 [main.go:82-91](../main.go#L82-L91) 的 `OnConnect` 方法中添加:
|
||||
|
||||
```go
|
||||
// 启用 TCP_NODELAY 禁用 Nagle 算法,确保小数据包(包括 TCP ACK)立即发送
|
||||
// 这对于 MQTT QoS0 消息特别重要,避免设备因为等待 TCP ACK 而重发
|
||||
if cl.Net.Conn != nil {
|
||||
if tcpConn, ok := cl.Net.Conn.(*net.TCPConn); ok {
|
||||
if err := tcpConn.SetNoDelay(true); err != nil {
|
||||
printJSON(map[string]any{"event": "tcp_nodelay_failed", "error": err.Error(), "remote_addr": cl.Net.Remote})
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 效果
|
||||
|
||||
- ❌ **修复前**:TCP ACK 延迟 40-200ms,触发重传
|
||||
- ✅ **修复后**:TCP ACK 延迟 ~0.05ms,无重传
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 自动化测试
|
||||
|
||||
```bash
|
||||
go test -v -run TestQoS0MessageLatency
|
||||
```
|
||||
|
||||
**测试结果:**
|
||||
```
|
||||
Round trip 1: 45.916µs
|
||||
Round trip 2: 60.625µs
|
||||
Round trip 3: 54.208µs
|
||||
...
|
||||
Average latency: 54.045µs ← 0.054ms,比 Nagle 算法快 1000 倍
|
||||
```
|
||||
|
||||
### 实际验证步骤
|
||||
|
||||
1. **重新编译部署:**
|
||||
```bash
|
||||
go build
|
||||
./meshtastic_mqtt_server
|
||||
```
|
||||
|
||||
2. **观察设备日志:**
|
||||
- ✅ 设备应该不再重发消息
|
||||
- ✅ 消息一次发送成功
|
||||
- ✅ 延迟显著降低
|
||||
|
||||
3. **抓包验证(可选):**
|
||||
```bash
|
||||
tcpdump -i any -nn port 1883 -w mqtt.pcap
|
||||
```
|
||||
- 用 Wireshark 查看 TCP ACK 时序
|
||||
- 确认没有 TCP 重传标记
|
||||
|
||||
## 行业实践
|
||||
|
||||
所有主流 MQTT broker 都默认启用 TCP_NODELAY:
|
||||
|
||||
| Broker | TCP_NODELAY |
|
||||
|--------|-------------|
|
||||
| Mosquitto | ✅ 默认启用 |
|
||||
| EMQX | ✅ 默认启用 |
|
||||
| HiveMQ | ✅ 默认启用 |
|
||||
| VerneMQ | ✅ 默认启用 |
|
||||
| **本项目** | ✅ **已修复** |
|
||||
|
||||
这是 **MQTT 协议的最佳实践**,因为:
|
||||
- MQTT 是交互式协议,需要低延迟
|
||||
- 消息通常较小(几十到几百字节)
|
||||
- QoS0 依赖 TCP 层的可靠性
|
||||
|
||||
## 权衡分析
|
||||
|
||||
### 优点
|
||||
- ✅ 消除 TCP ACK 延迟(减少 1000 倍)
|
||||
- ✅ 防止不必要的重传
|
||||
- ✅ 降低设备功耗(无需重试)
|
||||
- ✅ 改善用户体验
|
||||
|
||||
### 缺点
|
||||
- ⚠️ 增加小数据包数量(但 MQTT 本身就是小消息协议)
|
||||
- ⚠️ 略微增加带宽(影响<1%,可忽略)
|
||||
|
||||
**结论:** 对于 MQTT 这种交互式协议,启用 TCP_NODELAY 是正确的选择。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [RFC 896 - Nagle 算法](https://tools.ietf.org/html/rfc896)
|
||||
- [MQTT v3.1.1 规范](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/mqtt-v3.1.1.html)
|
||||
- [TCP_NODELAY 最佳实践](https://www.extrahop.com/company/blog/2016/tcp-nodelay-nagle-quickack-best-practices/)
|
||||
|
||||
## 修改文件
|
||||
|
||||
- ✅ [main.go](../main.go) - 添加 TCP_NODELAY 设置
|
||||
- ✅ [tcp_nodelay_test.go](../tcp_nodelay_test.go) - 验证测试
|
||||
- 📄 [doc/TCP_ACK_FIX.md](TCP_ACK_FIX.md) - 英文详细文档
|
||||
@@ -38,6 +38,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -52,12 +53,15 @@ require (
|
||||
github.com/rs/xid v1.4.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23 // indirect
|
||||
github.com/volcengine/volcengine-go-sdk v1.2.36 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
@@ -8,6 +11,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -17,6 +22,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -41,11 +48,30 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -60,12 +86,18 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -85,8 +117,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -102,6 +136,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
@@ -112,6 +147,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8=
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
|
||||
github.com/volcengine/volcengine-go-sdk v1.2.36 h1:rUH7t3bOzaGVJ9ys8L2Jizb+9AZkhu2qCjJwWtF+weo=
|
||||
github.com/volcengine/volcengine-go-sdk v1.2.36/go.mod h1:oxoVo+A17kvkwPkIeIHPVLjSw7EQAm+l/Vau1YGHN+A=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -120,26 +159,70 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -147,6 +230,8 @@ gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
|
||||
+20
-6
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
SERVICE_NAME="mesh_mqtt_go"
|
||||
SERVICE_USER="mesh_mqtt_go"
|
||||
SERVICE_GROUP="${SERVICE_USER}"
|
||||
CONFIG_DIR="/etc/${SERVICE_NAME}"
|
||||
DATA_DIR="/srv/${SERVICE_NAME}"
|
||||
INSTALL_DIR="/opt/${SERVICE_NAME}"
|
||||
@@ -17,11 +18,18 @@ if [[ "${EUID}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if id -u "www" >/dev/null 2>&1; then
|
||||
SERVICE_USER="www"
|
||||
SERVICE_GROUP=$(id -gn "www")
|
||||
echo "检测到 www 用户,将以 www 用户运行服务"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
echo "拉取最新代码..."
|
||||
git pull
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
|
||||
echo "编译前端..."
|
||||
cd "${SCRIPT_DIR}/${FRONTEND_DIR}"
|
||||
@@ -34,7 +42,7 @@ npm run build
|
||||
|
||||
echo "编译 Go 程序..."
|
||||
cd "${SCRIPT_DIR}"
|
||||
go build -o "${BINARY_NAME}" .
|
||||
go build -ldflags "-X meshtastic_mqtt_server/internal/web.CommitVersion=${COMMIT_HASH}" -o "${BINARY_NAME}" .
|
||||
|
||||
echo "检查系统用户..."
|
||||
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
@@ -42,8 +50,8 @@ if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
echo "创建目录..."
|
||||
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
install -d -m 0750 -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
install -d -m 0755 -o "${SERVICE_USER}" -g "${SERVICE_GROUP}" "${INSTALL_DIR}"
|
||||
|
||||
echo "安装程序和前端文件..."
|
||||
install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
@@ -51,7 +59,7 @@ rm -rf "${INSTALL_DIR}/dist"
|
||||
cp -a "${SCRIPT_DIR}/${FRONTEND_DIST_DIR}" "${INSTALL_DIR}/dist"
|
||||
chown root:root "${INSTALL_DIR}/${BINARY_NAME}"
|
||||
chown -R root:root "${INSTALL_DIR}/dist"
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
|
||||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
|
||||
chmod 0755 "${INSTALL_DIR}"
|
||||
find "${INSTALL_DIR}/dist" -type d -exec chmod 0755 {} \;
|
||||
find "${INSTALL_DIR}/dist" -type f -exec chmod 0644 {} \;
|
||||
@@ -84,8 +92,14 @@ web:
|
||||
password: admin
|
||||
session_secret: ""
|
||||
session_secure: false
|
||||
console_log:
|
||||
web: true
|
||||
mqtt: true
|
||||
llm: true
|
||||
sql: true
|
||||
meshtastic: true
|
||||
EOF
|
||||
chown "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}/config.yaml"
|
||||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "${CONFIG_DIR}/config.yaml"
|
||||
chmod 0640 "${CONFIG_DIR}/config.yaml"
|
||||
fi
|
||||
|
||||
@@ -99,7 +113,7 @@ Wants=network-online.target
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
Group=${SERVICE_GROUP}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BINARY_NAME} -web-socket-path ${SOCKET_PATH} -web-static-dir ${INSTALL_DIR}/dist
|
||||
Restart=on-failure
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package active 提供活跃度查询工具。当用户想查询活跃节点数或活跃人数时调用。
|
||||
//
|
||||
// 活跃节点:查询 nodeinfo 表的 updated_at 字段,统计指定时间范围内更新过的节点数
|
||||
// 活跃人数:查询 text_message 表的 created_at 字段,统计指定时间范围内发过消息的唯一用户数(按 from_id 去重)
|
||||
package active
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ActiveStore 定义活跃度查询工具所需的持久化能力,通常由 *store.Store 实现。
|
||||
type ActiveStore interface {
|
||||
CountActiveNodes(since time.Time) (int64, error)
|
||||
CountActiveUsers(since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// Tool 是活跃度查询工具。
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
store ActiveStore
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string { return "active" }
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||||
|
||||
// ToolDefinition returns the OpenAI tool definition
|
||||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||
desc := "活跃度查询工具。查询指定时间范围内的活跃节点数和活跃人数。\n" +
|
||||
"活跃节点:在指定时间内有更新记录的节点数量\n" +
|
||||
"活跃人数:在指定时间内发送过消息的唯一用户数(按 from_id 去重)\n" +
|
||||
"默认查询最近1小时,最大支持24小时"
|
||||
if description != "" {
|
||||
desc = description
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: "active",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"hours": map[string]any{
|
||||
"type": "number",
|
||||
"description": "查询最近多少小时内的活跃数据,默认1小时,最大24小时。例如:1、2、6、12、24",
|
||||
},
|
||||
"query_type": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"both", "nodes", "users"},
|
||||
"description": "查询类型:both=同时查询节点和人数(默认),nodes=仅查询节点,users=仅查询人数",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the active query tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
if t.store == nil {
|
||||
return "", fmt.Errorf("active store is not configured")
|
||||
}
|
||||
|
||||
var params activeParams
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
// 默认查询1小时
|
||||
hours := params.Hours
|
||||
if hours <= 0 {
|
||||
hours = 1
|
||||
}
|
||||
// 最大24小时
|
||||
if hours > 24 {
|
||||
hours = 24
|
||||
}
|
||||
|
||||
// 默认查询类型为 both
|
||||
queryType := strings.ToLower(strings.TrimSpace(params.QueryType))
|
||||
if queryType == "" {
|
||||
queryType = "both"
|
||||
}
|
||||
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 计算时间范围
|
||||
since := now.Add(-time.Duration(hours * float64(time.Hour)))
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString(fmt.Sprintf("最近 %.1f 小时的活跃统计:\n\n", hours))
|
||||
|
||||
// 查询活跃节点
|
||||
if queryType == "both" || queryType == "nodes" {
|
||||
nodeCount, err := t.store.CountActiveNodes(since)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询活跃节点失败:%w", err)
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("活跃节点:%d 个\n", nodeCount))
|
||||
}
|
||||
|
||||
// 查询活跃人数
|
||||
if queryType == "both" || queryType == "users" {
|
||||
userCount, err := t.store.CountActiveUsers(since)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询活跃人数失败:%w", err)
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("活跃人数:%d 人\n", userCount))
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// activeParams 是活跃度查询工具的入参。
|
||||
type activeParams struct {
|
||||
Hours float64 `json:"hours"` // 查询最近多少小时,默认1小时
|
||||
QueryType string `json:"query_type"` // 查询类型:both/nodes/users
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "active",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
tool := &Tool{enabled: true}
|
||||
if store, ok := options.Value("store").(ActiveStore); ok && store != nil {
|
||||
tool.store = store
|
||||
}
|
||||
return tool, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package calculator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// Tool is a calculator tool that can evaluate simple math expressions
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string {
|
||||
return "calculator"
|
||||
}
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool {
|
||||
return t.enabled
|
||||
}
|
||||
|
||||
// ToolDefinition returns the OpenAI tool definition
|
||||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||
desc := "一个计算器工具,可以计算数学表达式。支持加减乘除、平方根、幂运算等。当用户需要计算数学表达式时使用此工具。"
|
||||
if description != "" {
|
||||
desc = description
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: "calculator",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"expression": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要计算的数学表达式,例如 \"2 + 3 * 4\" 或 \"sqrt(16)\"",
|
||||
},
|
||||
},
|
||||
"required": []string{"expression"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the calculator tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
var params struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
if params.Expression == "" {
|
||||
return "", fmt.Errorf("expression is required")
|
||||
}
|
||||
|
||||
result, err := evaluateExpression(params.Expression)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("计算错误: %v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("计算结果: %s = %g", params.Expression, result), nil
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled}
|
||||
}
|
||||
|
||||
// evaluateExpression evaluates a simple mathematical expression using Go AST
|
||||
func evaluateExpression(expr string) (float64, error) {
|
||||
// Parse the expression
|
||||
node, err := parser.ParseExpr(expr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("无效的表达式: %w", err)
|
||||
}
|
||||
|
||||
return evalNode(node)
|
||||
}
|
||||
|
||||
// evalNode evaluates an AST node
|
||||
func evalNode(node ast.Expr) (float64, error) {
|
||||
switch n := node.(type) {
|
||||
case *ast.BasicLit:
|
||||
if n.Kind == token.INT || n.Kind == token.FLOAT {
|
||||
return strconv.ParseFloat(n.Value, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("不支持的字面量类型: %v", n.Kind)
|
||||
|
||||
case *ast.BinaryExpr:
|
||||
left, err := evalNode(n.X)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
right, err := evalNode(n.Y)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch n.Op {
|
||||
case token.ADD:
|
||||
return left + right, nil
|
||||
case token.SUB:
|
||||
return left - right, nil
|
||||
case token.MUL:
|
||||
return left * right, nil
|
||||
case token.QUO:
|
||||
if right == 0 {
|
||||
return 0, fmt.Errorf("除数不能为零")
|
||||
}
|
||||
return left / right, nil
|
||||
case token.REM:
|
||||
return math.Mod(left, right), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的运算符: %v", n.Op)
|
||||
}
|
||||
|
||||
case *ast.ParenExpr:
|
||||
return evalNode(n.X)
|
||||
|
||||
case *ast.UnaryExpr:
|
||||
val, err := evalNode(n.X)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch n.Op {
|
||||
case token.ADD:
|
||||
return val, nil
|
||||
case token.SUB:
|
||||
return -val, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的一元运算符: %v", n.Op)
|
||||
}
|
||||
|
||||
case *ast.CallExpr:
|
||||
ident, ok := n.Fun.(*ast.Ident)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("不支持的函数调用形式")
|
||||
}
|
||||
if len(n.Args) != 1 {
|
||||
return 0, fmt.Errorf("函数只接受一个参数")
|
||||
}
|
||||
arg, err := evalNode(n.Args[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch ident.Name {
|
||||
case "sqrt":
|
||||
if arg < 0 {
|
||||
return 0, fmt.Errorf("平方根的参数不能为负数")
|
||||
}
|
||||
return math.Sqrt(arg), nil
|
||||
case "abs":
|
||||
return math.Abs(arg), nil
|
||||
case "sin":
|
||||
return math.Sin(arg), nil
|
||||
case "cos":
|
||||
return math.Cos(arg), nil
|
||||
case "tan":
|
||||
return math.Tan(arg), nil
|
||||
case "log":
|
||||
if arg <= 0 {
|
||||
return 0, fmt.Errorf("对数的参数必须为正数")
|
||||
}
|
||||
return math.Log(arg), nil
|
||||
case "log10":
|
||||
if arg <= 0 {
|
||||
return 0, fmt.Errorf("对数的参数必须为正数")
|
||||
}
|
||||
return math.Log10(arg), nil
|
||||
case "exp":
|
||||
return math.Exp(arg), nil
|
||||
case "pow":
|
||||
// For pow, we need two arguments - this is simplified
|
||||
return 0, fmt.Errorf("pow 函数需要两个参数,请使用 ** 运算符代替")
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的函数: %s", ident.Name)
|
||||
}
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的表达式节点类型: %T", node)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "calculator",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
return &Tool{enabled: true}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// Package sign 提供签到工具。当用户想签到时,把签到信息写入 signs 表,
|
||||
// 每个节点每天只能签到一次。
|
||||
//
|
||||
// 节点身份(node_id / long_name / short_name)由 autoreply 在处理队列消息时
|
||||
// 通过 ctx 注入(见 agenttool.NodeContext);text_message 包本身不含名字,
|
||||
// 队列记录里的 long_name/short_name 经常为空,因此签到工具会在名字缺失时
|
||||
// 用 node_id 查 nodeinfo 表补全。签到正文里的地区、名字、设备等字段则由
|
||||
// LLM 从用户消息中提取后作为工具参数传入。
|
||||
package sign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// SignStore 定义签到工具所需的持久化能力,通常由 *store.Store 实现。
|
||||
type SignStore interface {
|
||||
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||||
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||||
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||||
CountSigns(opts storepkg.ListOptions) (int64, error)
|
||||
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
|
||||
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
|
||||
}
|
||||
|
||||
// Tool 是签到工具。
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
store SignStore
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string { return "sign" }
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||||
|
||||
// ToolDefinition returns the OpenAI tool definition
|
||||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||
desc := "签到工具。支持三种操作:\n" +
|
||||
"1. 签到操作(action=sign):记录节点今日签到信息,每个节点每天只能签到一次。必填参数:地区、名字、设备\n" +
|
||||
"2. 查询操作(action=query):查询签到统计。可按日期范围查询,默认查询今天。返回签到总数和按天的统计数据\n" +
|
||||
"3. 检查操作(action=check):检查当前节点今天是否已签到。用于回答用户'我今天签到了吗'之类的问题"
|
||||
if description != "" {
|
||||
desc = description
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: "sign",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"sign", "query", "check"},
|
||||
"description": "操作类型:sign=签到,query=查询签到统计,check=检查今天是否已签到",
|
||||
},
|
||||
"region": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时必填:地区,例如 \"上海闵行\"、\"安徽\"、\"广东深圳\"",
|
||||
},
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时必填:签到用户的名字/呼号,例如 \"Kevin\"、\"TaoEngine\"",
|
||||
},
|
||||
"device": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时必填:使用的设备型号,例如 \"GAT562\"、\"EBYTE_EoRa_S3\"",
|
||||
},
|
||||
"tx_power": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时可选:发射功率,例如 \"25mW\"、\"100mW\"",
|
||||
},
|
||||
"antenna_length": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时可选:天线长度,例如 \"5dBi\"、\"1.2m\"",
|
||||
},
|
||||
"altitude": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时可选:身处高度,例如 \"30m\"、\"海拔500m\"",
|
||||
},
|
||||
"raw_text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "签到时可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文",
|
||||
},
|
||||
"date": map[string]any{
|
||||
"type": "string",
|
||||
"description": "查询时可选:查询日期,格式 YYYY-MM-DD,例如 \"2024-06-23\"。不填则查询今天",
|
||||
},
|
||||
"days": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "查询时可选:查询最近N天的数据,例如 7 表示最近7天。不填则只查询date指定的那一天",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the sign tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
if t.store == nil {
|
||||
return "", fmt.Errorf("sign store is not configured")
|
||||
}
|
||||
|
||||
var params signParams
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
// 根据 action 参数路由到不同的操作
|
||||
switch strings.ToLower(strings.TrimSpace(params.Action)) {
|
||||
case "query":
|
||||
return t.executeQuery(ctx, params, runtime)
|
||||
case "check":
|
||||
return t.executeCheck(ctx, params, runtime)
|
||||
case "sign", "":
|
||||
return t.executeSign(ctx, params, runtime)
|
||||
default:
|
||||
return "", fmt.Errorf("无效的操作类型:%s,只支持 sign、query 或 check", params.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// executeSign 执行签到操作
|
||||
func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||||
params.Region = strings.TrimSpace(params.Region)
|
||||
params.Name = strings.TrimSpace(params.Name)
|
||||
params.Device = strings.TrimSpace(params.Device)
|
||||
params.RawText = strings.TrimSpace(params.RawText)
|
||||
if params.Region == "" || params.Name == "" || params.Device == "" {
|
||||
if params.RawText == "" {
|
||||
return "", fmt.Errorf("region, name, device 都是必填参数")
|
||||
}
|
||||
}
|
||||
|
||||
// 节点身份来自消息上下文,而非 LLM 回填,保证「每节点每天一次」判定可靠。
|
||||
node, ok := agenttool.NodeContextFromContext(ctx)
|
||||
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||||
return "", fmt.Errorf("缺少发送节点上下文,无法签到")
|
||||
}
|
||||
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 每个节点每天只能签到一次
|
||||
signed, err := t.store.HasSignedOnDay(node.NodeID, now)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("签到失败:检查今日签到状态时出错:%v", err), nil
|
||||
}
|
||||
if signed {
|
||||
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
|
||||
}
|
||||
|
||||
signText := buildSignText(params)
|
||||
if signText == "" {
|
||||
// 结构化字段缺失时回退到用户原始文本
|
||||
signText = params.RawText
|
||||
}
|
||||
longName, shortName := resolveNodeNames(t, node)
|
||||
|
||||
record, err := t.store.CreateSign(node.NodeID, longName, shortName, signText, now)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("签到失败:%v", err), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("签到成功!%s\n签到内容:%s", displayName(node), record.SignText), nil
|
||||
}
|
||||
|
||||
// executeCheck 检查当前节点今天是否已签到,并返回签到详情
|
||||
func (t *Tool) executeCheck(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||||
// 节点身份来自消息上下文
|
||||
node, ok := agenttool.NodeContextFromContext(ctx)
|
||||
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||||
return "", fmt.Errorf("缺少发送节点上下文,无法检查签到状态")
|
||||
}
|
||||
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 构建查询选项:查询今天的签到记录
|
||||
loc := now.Location()
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
|
||||
opts := storepkg.ListOptions{
|
||||
NodeID: node.NodeID,
|
||||
Since: &start,
|
||||
Until: &end,
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
// 查询数据库获取今天的签到记录
|
||||
signs, err := t.store.ListSigns(opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询签到记录失败:%w", err)
|
||||
}
|
||||
|
||||
if len(signs) == 0 {
|
||||
return fmt.Sprintf("%s 今天还没有签到。", displayName(node)), nil
|
||||
}
|
||||
|
||||
// 返回签到详情
|
||||
sign := signs[0]
|
||||
signTimeStr := sign.SignTime.Format("15:04:05")
|
||||
return fmt.Sprintf("%s 今天已经签到过了。\n签到时间:%s\n签到内容:%s",
|
||||
displayName(node), signTimeStr, sign.SignText), nil
|
||||
}
|
||||
|
||||
// executeQuery 执行查询操作
|
||||
func (t *Tool) executeQuery(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||||
now := runtime.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// 解析查询日期
|
||||
var targetDate time.Time
|
||||
if params.Date != "" {
|
||||
var err error
|
||||
targetDate, err = time.Parse("2006-01-02", params.Date)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("日期格式错误,应为 YYYY-MM-DD:%v", err)
|
||||
}
|
||||
} else {
|
||||
// 默认查询今天
|
||||
targetDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// 构建查询选项
|
||||
var opts storepkg.ListOptions
|
||||
if params.Days > 0 {
|
||||
// 查询最近N天
|
||||
since := targetDate.AddDate(0, 0, -params.Days+1)
|
||||
until := targetDate.Add(24*time.Hour - time.Nanosecond)
|
||||
opts.Since = &since
|
||||
opts.Until = &until
|
||||
} else {
|
||||
// 只查询指定的那一天
|
||||
since := targetDate
|
||||
until := targetDate.Add(24*time.Hour - time.Nanosecond)
|
||||
opts.Since = &since
|
||||
opts.Until = &until
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := t.store.CountSigns(opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询签到总数失败:%w", err)
|
||||
}
|
||||
|
||||
// 获取按天统计
|
||||
dayCounts, err := t.store.CountSignsByDay(opts)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询按天统计失败:%w", err)
|
||||
}
|
||||
|
||||
// 构建返回消息
|
||||
var result strings.Builder
|
||||
if params.Days > 0 {
|
||||
result.WriteString(fmt.Sprintf("最近 %d 天的签到统计:\n", params.Days))
|
||||
} else {
|
||||
result.WriteString(fmt.Sprintf("%s 的签到统计:\n", targetDate.Format("2006-01-02")))
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("总计:%d 人次\n\n", total))
|
||||
|
||||
if len(dayCounts) > 0 {
|
||||
result.WriteString("按天统计:\n")
|
||||
for _, dc := range dayCounts {
|
||||
result.WriteString(fmt.Sprintf("- %s: %d 人\n", dc.Date, dc.Count))
|
||||
}
|
||||
} else {
|
||||
result.WriteString("该时间段内没有签到记录")
|
||||
}
|
||||
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
// resolveNodeNames 取出节点的 long_name / short_name。
|
||||
// text_message 包本身不含名字,队列记录里的 long_name/short_name 经常为空;
|
||||
// 此时用 node_id 查 nodeinfo 表补全,保证签到记录里能看到节点名。
|
||||
// 仍查不到则返回两个 nil,签到照常进行(仅名字字段为空)。
|
||||
func resolveNodeNames(t *Tool, node agenttool.NodeContext) (*string, *string) {
|
||||
var longName, shortName *string
|
||||
if ln := strings.TrimSpace(node.LongName); ln != "" {
|
||||
longName = &ln
|
||||
}
|
||||
if sn := strings.TrimSpace(node.ShortName); sn != "" {
|
||||
shortName = &sn
|
||||
}
|
||||
// 队列上下文已有名字就直接用,无需查库
|
||||
if longName != nil && shortName != nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if t.store == nil {
|
||||
return longName, shortName
|
||||
}
|
||||
info, err := t.store.GetNodeInfo(node.NodeID)
|
||||
if err != nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if info == nil {
|
||||
return longName, shortName
|
||||
}
|
||||
if longName == nil && info.LongName != nil {
|
||||
if v := strings.TrimSpace(*info.LongName); v != "" {
|
||||
longName = &v
|
||||
}
|
||||
}
|
||||
if shortName == nil && info.ShortName != nil {
|
||||
if v := strings.TrimSpace(*info.ShortName); v != "" {
|
||||
shortName = &v
|
||||
}
|
||||
}
|
||||
return longName, shortName
|
||||
}
|
||||
|
||||
// signParams 是签到工具的入参。
|
||||
type signParams struct {
|
||||
Action string `json:"action"` // 操作类型:sign=签到,query=查询
|
||||
Region string `json:"region"` // 签到时使用
|
||||
Name string `json:"name"` // 签到时使用
|
||||
Device string `json:"device"` // 签到时使用
|
||||
TxPower string `json:"tx_power"` // 签到时使用
|
||||
AntennaLength string `json:"antenna_length"` // 签到时使用
|
||||
Altitude string `json:"altitude"` // 签到时使用
|
||||
RawText string `json:"raw_text"` // 签到时使用:用户原始签到文本
|
||||
Date string `json:"date"` // 查询时使用:日期 YYYY-MM-DD
|
||||
Days int `json:"days"` // 查询时使用:查询最近N天
|
||||
}
|
||||
|
||||
// buildSignText 按参考格式拼装签到正文:地区-名字-设备签到,可选信息附在括号内。
|
||||
// 当 region/name/device 任一缺失时返回空串,由调用方回退到 RawText。
|
||||
func buildSignText(p signParams) string {
|
||||
if strings.TrimSpace(p.Region) == "" || strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Device) == "" {
|
||||
return ""
|
||||
}
|
||||
text := fmt.Sprintf("%s-%s-%s签到", p.Region, p.Name, p.Device)
|
||||
|
||||
var extras []string
|
||||
if v := strings.TrimSpace(p.TxPower); v != "" {
|
||||
extras = append(extras, "发射功率 "+v)
|
||||
}
|
||||
if v := strings.TrimSpace(p.AntennaLength); v != "" {
|
||||
extras = append(extras, "天线 "+v)
|
||||
}
|
||||
if v := strings.TrimSpace(p.Altitude); v != "" {
|
||||
extras = append(extras, "高度 "+v)
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
text += "(" + strings.Join(extras, ",") + ")"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func displayName(node agenttool.NodeContext) string {
|
||||
if node.LongName != "" {
|
||||
return node.LongName
|
||||
}
|
||||
if node.ShortName != "" {
|
||||
return node.ShortName
|
||||
}
|
||||
return node.NodeID
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "sign",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
tool := &Tool{enabled: true}
|
||||
if store, ok := options.Value("store").(SignStore); ok && store != nil {
|
||||
tool.store = store
|
||||
}
|
||||
return tool, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package time
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// Tool is a time tool that can get current time and format dates
|
||||
type Tool struct {
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t *Tool) Name() string {
|
||||
return "time"
|
||||
}
|
||||
|
||||
// Enabled returns whether the tool is enabled
|
||||
func (t *Tool) Enabled() bool {
|
||||
return t.enabled
|
||||
}
|
||||
|
||||
// ToolDefinition returns the OpenAI tool definition
|
||||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||||
desc := "一个时间工具,可以获取当前时间、格式化日期时间、计算时间差等。当用户询问时间或需要处理日期时间相关问题时使用此工具。"
|
||||
if description != "" {
|
||||
desc = description
|
||||
}
|
||||
return &model.Tool{
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: &model.FunctionDefinition{
|
||||
Name: "time",
|
||||
Description: desc,
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"now", "format", "parse"},
|
||||
"description": "执行的操作: now(获取当前时间), format(格式化时间), parse(解析时间)",
|
||||
},
|
||||
"format": map[string]any{
|
||||
"type": "string",
|
||||
"description": "时间格式字符串,例如 \"2006-01-02 15:04:05\"",
|
||||
},
|
||||
"time": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要解析的时间字符串",
|
||||
},
|
||||
"timezone": map[string]any{
|
||||
"type": "string",
|
||||
"description": "时区,例如 \"Asia/Shanghai\" 或 \"Local\"",
|
||||
},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the time tool
|
||||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||||
var params struct {
|
||||
Action string `json:"action"`
|
||||
Format string `json:"format"`
|
||||
Time string `json:"time"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||||
}
|
||||
|
||||
// Get timezone
|
||||
loc := time.Local
|
||||
if params.Timezone != "" {
|
||||
var err error
|
||||
loc, err = time.LoadLocation(params.Timezone)
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().In(loc)
|
||||
|
||||
switch params.Action {
|
||||
case "now":
|
||||
formatStr := time.RFC3339
|
||||
if params.Format != "" {
|
||||
formatStr = params.Format
|
||||
}
|
||||
return fmt.Sprintf("当前时间: %s", now.Format(formatStr)), nil
|
||||
|
||||
case "format":
|
||||
formatStr := time.RFC3339
|
||||
if params.Format != "" {
|
||||
formatStr = params.Format
|
||||
}
|
||||
return fmt.Sprintf("格式化时间: %s", now.Format(formatStr)), nil
|
||||
|
||||
case "parse":
|
||||
if params.Time == "" {
|
||||
return "", fmt.Errorf("time 参数是必需的")
|
||||
}
|
||||
formatStr := time.RFC3339
|
||||
if params.Format != "" {
|
||||
formatStr = params.Format
|
||||
}
|
||||
parsed, err := time.ParseInLocation(formatStr, params.Time, loc)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("解析时间失败: %v", err), nil
|
||||
}
|
||||
return fmt.Sprintf("解析结果: %s (Unix 时间戳: %d)", parsed.Format(time.RFC3339), parsed.Unix()), nil
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("不支持的操作: %s", params.Action), nil
|
||||
}
|
||||
}
|
||||
|
||||
// RawState returns the tool state
|
||||
func (t *Tool) RawState() any {
|
||||
return map[string]any{"enabled": t.enabled}
|
||||
}
|
||||
|
||||
func init() {
|
||||
agenttool.Register(agenttool.Descriptor{
|
||||
Name: "time",
|
||||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||||
return &Tool{enabled: true}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package agenttool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// LoadOptions contains options for loading a tool
|
||||
type LoadOptions struct {
|
||||
Values map[string]any
|
||||
}
|
||||
|
||||
// Value returns a value from the load options
|
||||
func (o LoadOptions) Value(key string) any {
|
||||
if o.Values == nil {
|
||||
return nil
|
||||
}
|
||||
return o.Values[key]
|
||||
}
|
||||
|
||||
// Frame represents an event frame emitted by a tool
|
||||
type Frame struct {
|
||||
Type string
|
||||
Tool string
|
||||
Stage string
|
||||
Status string
|
||||
Message string
|
||||
Data map[string]any
|
||||
Error string
|
||||
Text string
|
||||
}
|
||||
|
||||
// EmitFunc is a function that emits frames
|
||||
type EmitFunc func(frame any)
|
||||
|
||||
// Runtime provides context for tool execution
|
||||
type Runtime struct {
|
||||
Profile any
|
||||
CompleteText func(context.Context, string, int) (string, error)
|
||||
Emit EmitFunc
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// NodeContext carries the originating node identity for a tool execution.
|
||||
// 它由 autoreply 在处理一条队列消息时注入到 ctx 中,供需要识别发送节点的
|
||||
// 工具(如签到工具)使用,避免依赖 LLM 从文本里回填节点 ID。
|
||||
type NodeContext struct {
|
||||
NodeID string
|
||||
LongName string
|
||||
ShortName string
|
||||
}
|
||||
|
||||
type nodeContextKey struct{}
|
||||
|
||||
// WithNodeContext 把节点身份信息挂到 ctx 上。
|
||||
func WithNodeContext(ctx context.Context, nc NodeContext) context.Context {
|
||||
return context.WithValue(ctx, nodeContextKey{}, nc)
|
||||
}
|
||||
|
||||
// NodeContextFromContext 从 ctx 中取出节点身份信息;不存在时第二个返回值为 false。
|
||||
func NodeContextFromContext(ctx context.Context) (NodeContext, bool) {
|
||||
nc, ok := ctx.Value(nodeContextKey{}).(NodeContext)
|
||||
return nc, ok
|
||||
}
|
||||
|
||||
// LoadedTool is the interface that all tools must implement
|
||||
type LoadedTool interface {
|
||||
Name() string
|
||||
Enabled() bool
|
||||
ToolDefinition(description string) *model.Tool
|
||||
Execute(context.Context, string, Runtime) (string, error)
|
||||
RawState() any
|
||||
}
|
||||
|
||||
// Descriptor describes a tool that can be loaded
|
||||
type Descriptor struct {
|
||||
Name string
|
||||
Load func(path string, options LoadOptions) (LoadedTool, error)
|
||||
}
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
registry = map[string]Descriptor{}
|
||||
)
|
||||
|
||||
// Register registers a tool descriptor
|
||||
func Register(descriptor Descriptor) {
|
||||
name := strings.ToLower(strings.TrimSpace(descriptor.Name))
|
||||
if name == "" {
|
||||
panic("agenttool: tool name is empty")
|
||||
}
|
||||
if descriptor.Load == nil {
|
||||
panic(fmt.Sprintf("agenttool: %s load function is nil", name))
|
||||
}
|
||||
descriptor.Name = name
|
||||
|
||||
registryMu.Lock()
|
||||
defer registryMu.Unlock()
|
||||
if _, ok := registry[name]; ok {
|
||||
panic(fmt.Sprintf("agenttool: tool %s already registered", name))
|
||||
}
|
||||
registry[name] = descriptor
|
||||
}
|
||||
|
||||
// Lookup finds a tool descriptor by name
|
||||
func Lookup(name string) (Descriptor, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
descriptor, ok := registry[strings.ToLower(strings.TrimSpace(name))]
|
||||
return descriptor, ok
|
||||
}
|
||||
|
||||
// Names returns all registered tool names
|
||||
func Names() []string {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(registry))
|
||||
for name := range registry {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"meshtastic_mqtt_server/internal/autoreply"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIServiceStatus reports the current state of the AI service
|
||||
type AIServiceStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ProviderCount int `json:"provider_count"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// AIManager manages the lifecycle of the AI service, supporting restart
|
||||
type AIManager struct {
|
||||
mu sync.Mutex
|
||||
service *Service
|
||||
cfg Config
|
||||
db *gorm.DB
|
||||
botSender autoreply.BotSender
|
||||
ctx context.Context
|
||||
store *storepkg.Store
|
||||
}
|
||||
|
||||
// NewAIManager creates a new AIManager
|
||||
func NewAIManager(cfg Config, db *gorm.DB, botSender autoreply.BotSender, ctx context.Context, store *storepkg.Store) *AIManager {
|
||||
return &AIManager{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
botSender: botSender,
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfigEnabled sets the enabled flag on the config
|
||||
func (m *AIManager) SetConfigEnabled(enabled bool) {
|
||||
m.cfg.Enabled = enabled
|
||||
}
|
||||
|
||||
// SetProviderConfigs sets the LLM provider configs
|
||||
func (m *AIManager) SetProviderConfigs(configs []llm.ProviderConfig) {
|
||||
m.cfg.LLMProviders = configs
|
||||
}
|
||||
|
||||
// Init creates and starts the AI service
|
||||
func (m *AIManager) Init() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
svc, err := NewService(m.cfg, m.db, m.botSender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create AI service: %w", err)
|
||||
}
|
||||
if err := svc.Start(m.ctx); err != nil {
|
||||
return fmt.Errorf("failed to start AI service: %w", err)
|
||||
}
|
||||
m.service = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the currently running AI service
|
||||
func (m *AIManager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.service != nil {
|
||||
m.service.Stop()
|
||||
m.service = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns the current AI service status
|
||||
func (m *AIManager) Status() AIServiceStatus {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
providerCount := len(m.cfg.LLMProviders)
|
||||
if m.service == nil {
|
||||
msg := "AI 服务未运行,可在配置提供商后点击重启"
|
||||
if providerCount == 0 {
|
||||
msg = "尚未配置 AI 提供商,请先添加提供商配置"
|
||||
}
|
||||
return AIServiceStatus{
|
||||
Running: false,
|
||||
Enabled: m.cfg.Enabled,
|
||||
ProviderCount: providerCount,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
enabled := m.service.Enabled()
|
||||
if !enabled {
|
||||
return AIServiceStatus{
|
||||
Running: false,
|
||||
Enabled: false,
|
||||
ProviderCount: providerCount,
|
||||
Message: "AI 服务未启用",
|
||||
}
|
||||
}
|
||||
return AIServiceStatus{
|
||||
Running: true,
|
||||
Enabled: true,
|
||||
ProviderCount: providerCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Restart stops the current AI service and creates a new one from DB
|
||||
func (m *AIManager) Restart() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.service != nil {
|
||||
m.service.Stop()
|
||||
m.service = nil
|
||||
}
|
||||
|
||||
providers, err := m.store.ListLLMProviders(true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载 LLM 提供商列表失败: %w", err)
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return fmt.Errorf("没有配置任何 LLM 提供商,请先添加配置")
|
||||
}
|
||||
|
||||
providerConfigs := make([]llm.ProviderConfig, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
providerConfigs = append(providerConfigs, llm.ProviderConfig{
|
||||
Name: p.Name,
|
||||
Active: p.Active,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Model: p.Model,
|
||||
Timeout: p.Timeout,
|
||||
ContextWindowTokens: p.ContextWindowTokens,
|
||||
})
|
||||
}
|
||||
m.cfg.LLMProviders = providerConfigs
|
||||
|
||||
svc, err := NewService(m.cfg, m.db, m.botSender)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建 AI 服务失败: %w", err)
|
||||
}
|
||||
if err := svc.Start(m.ctx); err != nil {
|
||||
return fmt.Errorf("启动 AI 服务失败: %w", err)
|
||||
}
|
||||
|
||||
m.service = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadLLMProvider delegates to the current service
|
||||
func (m *AIManager) ReloadLLMProvider(config interface{}) error {
|
||||
m.mu.Lock()
|
||||
svc := m.service
|
||||
m.mu.Unlock()
|
||||
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.ReloadLLMProvider(config)
|
||||
}
|
||||
|
||||
// AddLLMProvider delegates to the current service
|
||||
func (m *AIManager) AddLLMProvider(config interface{}) error {
|
||||
m.mu.Lock()
|
||||
svc := m.service
|
||||
m.mu.Unlock()
|
||||
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.AddLLMProvider(config)
|
||||
}
|
||||
|
||||
// RemoveLLMProvider delegates to the current service
|
||||
func (m *AIManager) RemoveLLMProvider(name string) error {
|
||||
m.mu.Lock()
|
||||
svc := m.service
|
||||
m.mu.Unlock()
|
||||
|
||||
if svc == nil {
|
||||
return nil
|
||||
}
|
||||
return svc.RemoveLLMProvider(name)
|
||||
}
|
||||
|
||||
// AIServiceStatus returns the AI service status for the web interface
|
||||
func (m *AIManager) AIServiceStatus() AIServiceStatus {
|
||||
return m.Status()
|
||||
}
|
||||
|
||||
// RestartAIService restarts the AI service for the web interface
|
||||
func (m *AIManager) RestartAIService() error {
|
||||
return m.Restart()
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "meshtastic_mqtt_server/internal/agents/active"
|
||||
_ "meshtastic_mqtt_server/internal/agents/calculator"
|
||||
_ "meshtastic_mqtt_server/internal/agents/sign"
|
||||
_ "meshtastic_mqtt_server/internal/agents/time"
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
"meshtastic_mqtt_server/internal/autoreply"
|
||||
"meshtastic_mqtt_server/internal/conversation"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
"meshtastic_mqtt_server/internal/toolrouter"
|
||||
"meshtastic_mqtt_server/internal/topicrouter"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ToolConfigStore is the interface for getting tool configuration
|
||||
type ToolConfigStore interface {
|
||||
GetLLMPrimaryConfigSystemPrompt() (string, error)
|
||||
GetLLMPrimaryConfigEnableTool() (bool, error)
|
||||
}
|
||||
|
||||
// ToolRouterStore 是 ai 服务依赖的 ToolRouter 持久化接口;
|
||||
// 通常由 *store.Store 实现(GetLLMToolRouter)。
|
||||
// 通过本接口我们可以让 toolrouter 在每轮调用时拉取最新配置,
|
||||
// 让 /admin/llm/api 中的修改在保存后立即生效(无需重启)。
|
||||
type ToolRouterStore interface {
|
||||
GetLLMToolRouter() (*storepkg.LLMToolRouterRecord, error)
|
||||
}
|
||||
|
||||
// TopicRouterStore 是 ai 服务依赖的话题选择持久化接口;
|
||||
// 通常由 *store.Store 实现(GetLLMTopicConfig)。
|
||||
type TopicRouterStore interface {
|
||||
GetLLMTopicConfig() (*storepkg.LLMTopicConfigRecord, error)
|
||||
}
|
||||
|
||||
// Config holds the AI service configuration
|
||||
type Config struct {
|
||||
LLMProviders []llm.ProviderConfig
|
||||
DataDir string
|
||||
Enabled bool
|
||||
ConsoleLog bool
|
||||
ToolConfigStore ToolConfigStore
|
||||
ToolRouterStore ToolRouterStore
|
||||
TopicRouterStore TopicRouterStore
|
||||
// Store 注入持久化层,供需要 DB 访问的 agent 工具(如签到工具)使用。
|
||||
Store *storepkg.Store
|
||||
}
|
||||
|
||||
// Service manages all AI-related components
|
||||
type Service struct {
|
||||
LLMState *llm.State
|
||||
ToolRouter *toolrouter.State
|
||||
TopicRouter *topicrouter.State
|
||||
ToolMgr *toolmanager.Manager
|
||||
ConvStore *conversation.Store
|
||||
AutoReply *autoreply.Service
|
||||
MsgQueue *autoreply.DBMessageQueue
|
||||
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// toolRouterConfigAdapter 把 ToolRouterStore 适配成 toolrouter.ConfigStore,
|
||||
// 每次 LoadToolRouterConfig 都从 DB 拉取最新一行 llm_tool_router。
|
||||
type toolRouterConfigAdapter struct {
|
||||
store ToolRouterStore
|
||||
}
|
||||
|
||||
// LoadToolRouterConfig 实现 toolrouter.ConfigStore。
|
||||
// 当 DB 没有记录时返回 nil + nil,由 toolrouter 内部回退到内存默认值。
|
||||
func (a *toolRouterConfigAdapter) LoadToolRouterConfig() (*toolrouter.Config, error) {
|
||||
if a == nil || a.store == nil {
|
||||
return nil, errors.New("tool router store is not configured")
|
||||
}
|
||||
record, err := a.store.GetLLMToolRouter()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return toolRouterConfigFromRecord(record), nil
|
||||
}
|
||||
|
||||
func toolRouterConfigFromRecord(r *storepkg.LLMToolRouterRecord) *toolrouter.Config {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &toolrouter.Config{
|
||||
Enabled: r.Enabled,
|
||||
OpenAIName: r.OpenAIName,
|
||||
Timeout: r.Timeout,
|
||||
MaxTokens: r.MaxTokens,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
// topicRouterConfigAdapter 把 TopicRouterStore 适配成 topicrouter.ConfigStore,
|
||||
// 每次 LoadTopicConfig 都从 DB 拉取最新一行 llm_topic_config。
|
||||
type topicRouterConfigAdapter struct {
|
||||
store TopicRouterStore
|
||||
}
|
||||
|
||||
// LoadTopicConfig 实现 topicrouter.ConfigStore。
|
||||
// 当 DB 没有记录时返回 nil + nil,由 topicrouter 内部回退到内存默认值。
|
||||
func (a *topicRouterConfigAdapter) LoadTopicConfig() (*topicrouter.Config, error) {
|
||||
if a == nil || a.store == nil {
|
||||
return nil, errors.New("topic router store is not configured")
|
||||
}
|
||||
record, err := a.store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return topicRouterConfigFromRecord(record), nil
|
||||
}
|
||||
|
||||
func topicRouterConfigFromRecord(r *storepkg.LLMTopicConfigRecord) *topicrouter.Config {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &topicrouter.Config{
|
||||
Enabled: r.Enabled,
|
||||
OpenAIName: r.OpenAIName,
|
||||
Timeout: r.Timeout,
|
||||
MaxTokens: r.MaxTokens,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
}
|
||||
}
|
||||
|
||||
// NewService creates a new AI service
|
||||
func NewService(cfg Config, db *gorm.DB, botSender autoreply.BotSender) (*Service, error) {
|
||||
if !cfg.Enabled {
|
||||
return &Service{enabled: false}, nil
|
||||
}
|
||||
|
||||
// Create data directories
|
||||
agentsDir := filepath.Join(cfg.DataDir, "agents")
|
||||
convDir := filepath.Join(cfg.DataDir, "conversations")
|
||||
if err := os.MkdirAll(agentsDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create agents directory: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(convDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create conversations directory: %w", err)
|
||||
}
|
||||
|
||||
// Initialize LLM state
|
||||
llmState, err := llm.NewState(cfg.LLMProviders)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LLM state: %w", err)
|
||||
}
|
||||
|
||||
// 初始化 tool router:优先从 DB 读取已保存的配置,避免硬编码 prompt 把
|
||||
// 用户在 /admin/llm/api 配置好的内容覆盖掉。
|
||||
var (
|
||||
toolRouterCfg *toolrouter.Config
|
||||
toolRouterOptions []toolrouter.Option
|
||||
)
|
||||
if cfg.ToolRouterStore != nil {
|
||||
adapter := &toolRouterConfigAdapter{store: cfg.ToolRouterStore}
|
||||
// 启动时拉一次作为初始 cfg;失败或为空时让 toolrouter.NewState 走内置默认值。
|
||||
if loaded, loadErr := adapter.LoadToolRouterConfig(); loadErr == nil && loaded != nil {
|
||||
toolRouterCfg = loaded
|
||||
}
|
||||
toolRouterOptions = append(toolRouterOptions, toolrouter.WithConfigStore(adapter))
|
||||
}
|
||||
toolRouter, err := toolrouter.NewState(toolRouterCfg, llmState, toolRouterOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize tool router: %w", err)
|
||||
}
|
||||
|
||||
// 初始化话题选择 router:同样优先从 DB 读取已保存配置,支持保存即生效。
|
||||
var (
|
||||
topicRouterCfg *topicrouter.Config
|
||||
topicRouterOptions []topicrouter.Option
|
||||
)
|
||||
if cfg.TopicRouterStore != nil {
|
||||
topicAdapter := &topicRouterConfigAdapter{store: cfg.TopicRouterStore}
|
||||
if loaded, loadErr := topicAdapter.LoadTopicConfig(); loadErr == nil && loaded != nil {
|
||||
topicRouterCfg = loaded
|
||||
}
|
||||
topicRouterOptions = append(topicRouterOptions, topicrouter.WithConfigStore(topicAdapter))
|
||||
}
|
||||
topicRouter, err := topicrouter.NewState(topicRouterCfg, llmState, topicRouterOptions...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize topic router: %w", err)
|
||||
}
|
||||
|
||||
// Load tools
|
||||
loadOptions := agenttool.LoadOptions{Values: map[string]any{}}
|
||||
if cfg.Store != nil {
|
||||
loadOptions.Values["store"] = cfg.Store
|
||||
}
|
||||
toolMgr, err := toolmanager.Load(agentsDir, loadOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tools: %w", err)
|
||||
}
|
||||
|
||||
// Initialize conversation store
|
||||
convStore := conversation.NewStore(convDir)
|
||||
|
||||
// Initialize message queue
|
||||
msgQueue := autoreply.NewDBMessageQueue(db)
|
||||
|
||||
// Initialize auto-reply service
|
||||
autoReply := autoreply.NewService(
|
||||
llmState,
|
||||
toolRouter,
|
||||
topicRouter,
|
||||
toolMgr,
|
||||
convStore,
|
||||
msgQueue,
|
||||
botSender,
|
||||
cfg.ToolConfigStore,
|
||||
cfg.ConsoleLog,
|
||||
)
|
||||
|
||||
return &Service{
|
||||
LLMState: llmState,
|
||||
ToolRouter: toolRouter,
|
||||
TopicRouter: topicRouter,
|
||||
ToolMgr: toolMgr,
|
||||
ConvStore: convStore,
|
||||
AutoReply: autoReply,
|
||||
MsgQueue: msgQueue,
|
||||
enabled: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start starts the AI service
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
if !s.enabled {
|
||||
return nil
|
||||
}
|
||||
return s.AutoReply.Start(ctx)
|
||||
}
|
||||
|
||||
// Stop stops the AI service
|
||||
func (s *Service) Stop() {
|
||||
if !s.enabled {
|
||||
return
|
||||
}
|
||||
s.AutoReply.Stop()
|
||||
s.ToolMgr.Close()
|
||||
}
|
||||
|
||||
// Enabled returns whether the AI service is enabled
|
||||
func (s *Service) Enabled() bool {
|
||||
return s.enabled
|
||||
}
|
||||
|
||||
// ReloadLLMProvider reloads a specific LLM provider configuration
|
||||
func (s *Service) ReloadLLMProvider(config interface{}) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
providerConfig, err := convertToProviderConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.LLMState.UpdateProvider(providerConfig)
|
||||
}
|
||||
|
||||
// AddLLMProvider adds a new LLM provider
|
||||
func (s *Service) AddLLMProvider(config interface{}) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
providerConfig, err := convertToProviderConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.LLMState.AddProvider(providerConfig)
|
||||
}
|
||||
|
||||
// RemoveLLMProvider removes an LLM provider
|
||||
func (s *Service) RemoveLLMProvider(name string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
if !s.enabled || s.LLMState == nil {
|
||||
return nil
|
||||
}
|
||||
return s.LLMState.RemoveProvider(name)
|
||||
}
|
||||
|
||||
// convertToProviderConfig converts a map to llm.ProviderConfig
|
||||
func convertToProviderConfig(config interface{}) (llm.ProviderConfig, error) {
|
||||
m, ok := config.(map[string]interface{})
|
||||
if !ok {
|
||||
return llm.ProviderConfig{}, fmt.Errorf("invalid config type: expected map[string]interface{}")
|
||||
}
|
||||
|
||||
pc := llm.ProviderConfig{}
|
||||
|
||||
if v, ok := m["Name"].(string); ok {
|
||||
pc.Name = v
|
||||
}
|
||||
if v, ok := m["Active"].(bool); ok {
|
||||
pc.Active = v
|
||||
}
|
||||
if v, ok := m["APIKey"].(string); ok {
|
||||
pc.APIKey = v
|
||||
}
|
||||
if v, ok := m["BaseURL"].(string); ok {
|
||||
pc.BaseURL = v
|
||||
}
|
||||
if v, ok := m["Model"].(string); ok {
|
||||
pc.Model = v
|
||||
}
|
||||
if v, ok := m["Timeout"].(int); ok {
|
||||
pc.Timeout = v
|
||||
}
|
||||
if v, ok := m["ContextWindowTokens"].(int); ok {
|
||||
pc.ContextWindowTokens = v
|
||||
}
|
||||
|
||||
return pc, nil
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
package main
|
||||
// Package auth 实现 admin 端 cookie session 与密码散列。
|
||||
//
|
||||
// 拆离自原来 main 包的 auth.go:让所有 admin route 包都可以直接 import 这里的
|
||||
// SessionClaims / Manager / RequireAdmin,而不是被锁在根 main 包里。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
@@ -14,44 +18,56 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"meshtastic_mqtt_server/internal/config"
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRole = "admin"
|
||||
AdminRole = "admin"
|
||||
adminSessionCookie = "mesh_admin_session"
|
||||
|
||||
// AdminClaimsKey 是中间件挂在 gin.Context 上的 key,handler 可用
|
||||
// `c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)` 取出。
|
||||
AdminClaimsKey = "admin_claims"
|
||||
)
|
||||
|
||||
type adminUserDTO struct {
|
||||
// AdminUserDTO 是 /me /login 等接口返回给前端的最小用户视图。
|
||||
type AdminUserDTO struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type sessionClaims struct {
|
||||
// SessionClaims 是 cookie 中持久化的会话内容。
|
||||
type SessionClaims struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Expires int64 `json:"expires"`
|
||||
}
|
||||
|
||||
type sessionManager struct {
|
||||
// Manager 持有签名密钥与 cookie 配置,是发布 / 校验 cookie 的入口。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func newSessionManager(cfg webAdminConfig) (*sessionManager, error) {
|
||||
// NewManager 根据配置构造 Manager。如果 SessionSecret 空,会随机生成 32 字节。
|
||||
func NewManager(cfg config.WebAdminConfig) (*Manager, error) {
|
||||
secret := strings.TrimSpace(cfg.SessionSecret)
|
||||
if secret == "" {
|
||||
generated := make([]byte, 32)
|
||||
if _, err := rand.Read(generated); err != nil {
|
||||
return nil, fmt.Errorf("generate admin session secret: %w", err)
|
||||
}
|
||||
return &sessionManager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
return &Manager{secret: generated, secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
return &sessionManager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
return &Manager{secret: []byte(secret), secure: cfg.SessionSecure, ttl: 24 * time.Hour}, nil
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
// HashPassword 用 bcrypt 默认 cost 散列;用于建账号、改密码。
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -59,16 +75,19 @@ func hashPassword(password string) (string, error) {
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func verifyPassword(hash, password string) bool {
|
||||
// VerifyPassword 校验明文密码是否与散列匹配。
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func adminUserResponse(user userRecord) adminUserDTO {
|
||||
return adminUserDTO{Username: user.Username, Role: user.Role}
|
||||
// AdminUserResponse 把 store.UserRecord 转成对外 DTO。
|
||||
func AdminUserResponse(user store.UserRecord) AdminUserDTO {
|
||||
return AdminUserDTO{Username: user.Username, Role: user.Role}
|
||||
}
|
||||
|
||||
func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) {
|
||||
claims := sessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
||||
// NewCookie 为已登录用户构造一份带签名的 session cookie。
|
||||
func (sm *Manager) NewCookie(user store.UserRecord) (*http.Cookie, error) {
|
||||
claims := SessionClaims{UserID: user.ID, Username: user.Username, Role: user.Role, Expires: time.Now().Add(sm.ttl).Unix()}
|
||||
data, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,7 +105,8 @@ func (sm *sessionManager) newCookie(user userRecord) (*http.Cookie, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (sm *sessionManager) clearCookie() *http.Cookie {
|
||||
// ClearCookie 返回一个把 cookie 立即清掉的 *http.Cookie,供 logout 使用。
|
||||
func (sm *Manager) ClearCookie() *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: adminSessionCookie,
|
||||
Value: "",
|
||||
@@ -98,7 +118,8 @@ func (sm *sessionManager) clearCookie() *http.Cookie {
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, error) {
|
||||
// ClaimsFromRequest 校验请求 cookie 的签名 / 过期 / 角色。
|
||||
func (sm *Manager) ClaimsFromRequest(c *gin.Context) (*SessionClaims, error) {
|
||||
cookie, err := c.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -114,34 +135,35 @@ func (sm *sessionManager) claimsFromRequest(c *gin.Context) (*sessionClaims, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims sessionClaims
|
||||
var claims SessionClaims
|
||||
if err := json.Unmarshal(data, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Expires <= time.Now().Unix() {
|
||||
return nil, errors.New("session expired")
|
||||
}
|
||||
if claims.Role != adminRole {
|
||||
if claims.Role != AdminRole {
|
||||
return nil, errors.New("admin required")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func (sm *sessionManager) sign(payload string) string {
|
||||
func (sm *Manager) sign(payload string) string {
|
||||
mac := hmac.New(sha256.New, sm.secret)
|
||||
mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func requireAdmin(sm *sessionManager) gin.HandlerFunc {
|
||||
// RequireAdmin 是把校验结果挂在 c.Set(AdminClaimsKey, claims) 上的中间件。
|
||||
func RequireAdmin(sm *Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := sm.claimsFromRequest(c)
|
||||
claims, err := sm.ClaimsFromRequest(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "admin login required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("admin_claims", claims)
|
||||
c.Set(AdminClaimsKey, claims)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BotServiceAdapter adapts the bot service to the BotSender interface
|
||||
type BotServiceAdapter struct {
|
||||
sendDirectTextFn func(ctx context.Context, botID uint64, toNodeNum int64, text string) error
|
||||
sendChannelTextFn func(ctx context.Context, botID uint64, channelID string, text string) error
|
||||
}
|
||||
|
||||
// NewBotServiceAdapter creates a new bot service adapter
|
||||
func NewBotServiceAdapter(
|
||||
sendDirectTextFn func(ctx context.Context, botID uint64, toNodeNum int64, text string) error,
|
||||
sendChannelTextFn func(ctx context.Context, botID uint64, channelID string, text string) error,
|
||||
) *BotServiceAdapter {
|
||||
return &BotServiceAdapter{
|
||||
sendDirectTextFn: sendDirectTextFn,
|
||||
sendChannelTextFn: sendChannelTextFn,
|
||||
}
|
||||
}
|
||||
|
||||
// SendDirectText sends a direct/private message to a specific node
|
||||
func (a *BotServiceAdapter) SendDirectText(ctx context.Context, botID uint64, toNodeNum int64, text string) error {
|
||||
if a.sendDirectTextFn == nil {
|
||||
return fmt.Errorf("send direct text function is nil")
|
||||
}
|
||||
return a.sendDirectTextFn(ctx, botID, toNodeNum, text)
|
||||
}
|
||||
|
||||
// SendChannelText sends a channel message to a specific channel
|
||||
func (a *BotServiceAdapter) SendChannelText(ctx context.Context, botID uint64, channelID string, text string) error {
|
||||
if a.sendChannelTextFn == nil {
|
||||
return fmt.Errorf("send channel text function is nil")
|
||||
}
|
||||
return a.sendChannelTextFn(ctx, botID, channelID, text)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
statusPending = "pending"
|
||||
statusProcessing = "processing"
|
||||
statusProcessed = "processed"
|
||||
statusFailed = "error"
|
||||
)
|
||||
|
||||
// DBMessageQueue implements MessageQueue using GORM
|
||||
type DBMessageQueue struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewDBMessageQueue creates a new database-backed message queue
|
||||
func NewDBMessageQueue(db *gorm.DB) *DBMessageQueue {
|
||||
return &DBMessageQueue{db: db}
|
||||
}
|
||||
|
||||
// llmMessageQueueRecord is the database record for LLM messages
|
||||
type llmMessageQueueRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
BotID uint64 `gorm:"column:bot_id;not null;index"`
|
||||
BotNodeID string `gorm:"column:bot_node_id;not null"`
|
||||
BotNodeNum int64 `gorm:"column:bot_node_num;not null"`
|
||||
FromNodeID string `gorm:"column:from_node_id;not null"`
|
||||
FromNodeNum int64 `gorm:"column:from_node_num;not null"`
|
||||
LongName *string `gorm:"column:long_name"`
|
||||
ShortName *string `gorm:"column:short_name"`
|
||||
Text string `gorm:"column:text;type:text;not null"`
|
||||
PacketID int64 `gorm:"column:packet_id;not null"`
|
||||
ChannelID *string `gorm:"column:channel_id"`
|
||||
Topic string `gorm:"column:topic;not null"`
|
||||
MessageType string `gorm:"column:message_type;not null;default:'direct'"` // "channel" 或 "direct"
|
||||
Status string `gorm:"column:status;not null;index"`
|
||||
Error string `gorm:"column:error;type:text"`
|
||||
Reply string `gorm:"column:reply;type:text"`
|
||||
ReceivedAt time.Time `gorm:"column:received_at;not null"`
|
||||
ProcessedAt *time.Time `gorm:"column:processed_at;index"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index"`
|
||||
}
|
||||
|
||||
func (llmMessageQueueRecord) TableName() string {
|
||||
return "llm_message_queue"
|
||||
}
|
||||
|
||||
// GetPendingMessages returns pending messages from the queue
|
||||
func (q *DBMessageQueue) GetPendingMessages(botID uint64, limit int) ([]QueuedMessage, error) {
|
||||
var records []llmMessageQueueRecord
|
||||
query := q.db.Where("status = ?", statusPending).Order("created_at ASC")
|
||||
if botID > 0 {
|
||||
query = query.Where("bot_id = ?", botID)
|
||||
}
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
if err := query.Find(&records).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query pending messages: %w", err)
|
||||
}
|
||||
|
||||
messages := make([]QueuedMessage, 0, len(records))
|
||||
for _, r := range records {
|
||||
messages = append(messages, QueuedMessage{
|
||||
ID: r.ID,
|
||||
BotID: r.BotID,
|
||||
BotNodeID: r.BotNodeID,
|
||||
BotNodeNum: r.BotNodeNum,
|
||||
FromNodeID: r.FromNodeID,
|
||||
FromNodeNum: r.FromNodeNum,
|
||||
LongName: r.LongName,
|
||||
ShortName: r.ShortName,
|
||||
Text: r.Text,
|
||||
PacketID: r.PacketID,
|
||||
ChannelID: r.ChannelID,
|
||||
Topic: r.Topic,
|
||||
MessageType: r.MessageType,
|
||||
ReceivedAt: r.ReceivedAt,
|
||||
})
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// MarkAsProcessing marks a message as being processed
|
||||
func (q *DBMessageQueue) MarkAsProcessing(id uint64) error {
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Update("status", statusProcessing).Error
|
||||
}
|
||||
|
||||
// MarkAsProcessed marks a message as successfully processed and soft-deletes it
|
||||
// 处理完成的消息直接软删除,避免队列页堆积;保留 reply/processed_at 便于查询历史
|
||||
func (q *DBMessageQueue) MarkAsProcessed(id uint64, reply string) error {
|
||||
now := time.Now()
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": statusProcessed,
|
||||
"reply": reply,
|
||||
"processed_at": &now,
|
||||
"deleted_at": &now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// MarkAsFailed marks a message as failed
|
||||
func (q *DBMessageQueue) MarkAsFailed(id uint64, error string) error {
|
||||
return q.db.Model(&llmMessageQueueRecord{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"status": statusFailed,
|
||||
"error": error,
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package autoreply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
"meshtastic_mqtt_server/internal/completion"
|
||||
"meshtastic_mqtt_server/internal/conversation"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
"meshtastic_mqtt_server/internal/stream"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
"meshtastic_mqtt_server/internal/toolrouter"
|
||||
"meshtastic_mqtt_server/internal/topicrouter"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxReplyLength is the maximum length for Meshtastic messages
|
||||
MaxReplyLength = 200
|
||||
// PollInterval is how often to check the queue for new messages
|
||||
PollInterval = 5 * time.Second
|
||||
// MaxProcessingTime is the maximum time to spend processing a single message
|
||||
MaxProcessingTime = 120 * time.Second
|
||||
)
|
||||
|
||||
// MessageQueue is the interface for accessing the LLM message queue
|
||||
type MessageQueue interface {
|
||||
// GetPendingMessages returns pending messages for a bot
|
||||
GetPendingMessages(botID uint64, limit int) ([]QueuedMessage, error)
|
||||
// MarkAsProcessing marks a message as being processed
|
||||
MarkAsProcessing(id uint64) error
|
||||
// MarkAsProcessed marks a message as successfully processed
|
||||
MarkAsProcessed(id uint64, reply string) error
|
||||
// MarkAsFailed marks a message as failed
|
||||
MarkAsFailed(id uint64, error string) error
|
||||
}
|
||||
|
||||
// QueuedMessage represents a message in the LLM queue
|
||||
type QueuedMessage struct {
|
||||
ID uint64
|
||||
BotID uint64
|
||||
BotNodeID string
|
||||
BotNodeNum int64
|
||||
FromNodeID string
|
||||
FromNodeNum int64
|
||||
LongName *string
|
||||
ShortName *string
|
||||
Text string
|
||||
PacketID int64
|
||||
ChannelID *string
|
||||
Topic string
|
||||
MessageType string // "channel" 或 "direct"
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
// BotSender is the interface for sending bot messages
|
||||
type BotSender interface {
|
||||
// SendDirectText 发送私聊消息给指定节点
|
||||
SendDirectText(ctx context.Context, botID uint64, toNodeNum int64, text string) error
|
||||
// SendChannelText 发送频道消息到指定频道
|
||||
SendChannelText(ctx context.Context, botID uint64, channelID string, text string) error
|
||||
}
|
||||
|
||||
// ToolConfigStore is the interface for getting tool configuration
|
||||
type ToolConfigStore interface {
|
||||
GetLLMPrimaryConfigSystemPrompt() (string, error)
|
||||
GetLLMPrimaryConfigEnableTool() (bool, error)
|
||||
}
|
||||
|
||||
// Service manages automatic AI replies for bots
|
||||
type Service struct {
|
||||
llmState *llm.State
|
||||
toolRouter *toolrouter.State
|
||||
topicRouter *topicrouter.State
|
||||
toolMgr *toolmanager.Manager
|
||||
convStore *conversation.Store
|
||||
msgQueue MessageQueue
|
||||
botSender BotSender
|
||||
toolConfigStore ToolConfigStore
|
||||
consoleLog bool
|
||||
|
||||
running bool
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService creates a new auto-reply service
|
||||
func NewService(
|
||||
llmState *llm.State,
|
||||
toolRouter *toolrouter.State,
|
||||
topicRouter *topicrouter.State,
|
||||
toolMgr *toolmanager.Manager,
|
||||
convStore *conversation.Store,
|
||||
msgQueue MessageQueue,
|
||||
botSender BotSender,
|
||||
toolConfigStore ToolConfigStore,
|
||||
consoleLog bool,
|
||||
) *Service {
|
||||
return &Service{
|
||||
llmState: llmState,
|
||||
toolRouter: toolRouter,
|
||||
topicRouter: topicRouter,
|
||||
toolMgr: toolMgr,
|
||||
convStore: convStore,
|
||||
msgQueue: msgQueue,
|
||||
botSender: botSender,
|
||||
toolConfigStore: toolConfigStore,
|
||||
consoleLog: consoleLog,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the auto-reply service
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("auto-reply service is already running")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.running = true
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.run(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the auto-reply service
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.wg.Wait()
|
||||
s.running = false
|
||||
}
|
||||
|
||||
// run is the main processing loop
|
||||
func (s *Service) run(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(PollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processQueue(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processQueue processes pending messages in the queue
|
||||
func (s *Service) processQueue(ctx context.Context) {
|
||||
// Get all bots (we'd typically get this from bot store, but for now
|
||||
// we'll rely on the queue to provide messages per bot)
|
||||
// For now, process up to 10 messages at a time
|
||||
messages, err := s.msgQueue.GetPendingMessages(0, 10)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
s.processMessage(ctx, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// logf 仅在 console_log.llm 开启时输出一行可读日志(带 [llm] 前缀)。
|
||||
func (s *Service) logf(format string, args ...any) {
|
||||
if !s.consoleLog {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[llm] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// emit 把 toolrouter.Frame 转成单行日志,区分主 AI / 路由 AI / 工具调用。
|
||||
func (s *Service) emit(msgID uint64, routerModel string) stream.EmitFunc {
|
||||
if !s.consoleLog {
|
||||
return nil
|
||||
}
|
||||
return func(f stream.Frame) {
|
||||
switch f.Stage {
|
||||
case "prepare":
|
||||
tools, _ := f.Data["tools"].([]string)
|
||||
s.logf("msg=%d router=%s prepare tools=%v", msgID, routerModel, tools)
|
||||
case "request":
|
||||
if f.Status == "success" {
|
||||
// 模型未请求工具
|
||||
s.logf("msg=%d router=%s decide → no_tool(直接生成回答)", msgID, routerModel)
|
||||
return
|
||||
}
|
||||
iter, _ := f.Data["iteration"].(int)
|
||||
s.logf("msg=%d router=%s decide iter=%d ...", msgID, routerModel, iter)
|
||||
case "tool_calls":
|
||||
calls, _ := f.Data["tools"].([]string)
|
||||
iter, _ := f.Data["iteration"].(int)
|
||||
s.logf("msg=%d router=%s decide iter=%d → call_tools=%v", msgID, routerModel, iter, calls)
|
||||
case "arguments":
|
||||
args, _ := f.Data["arguments"].(string)
|
||||
s.logf("msg=%d tool=%s args=%s", msgID, f.Tool, truncate(args, 200))
|
||||
case "result":
|
||||
dur, _ := f.Data["duration_ms"].(int64)
|
||||
preview, _ := f.Data["result_preview"].(string)
|
||||
s.logf("msg=%d tool=%s result(%dms)=%s", msgID, f.Tool, dur, truncate(preview, 200))
|
||||
case "execute":
|
||||
if f.Status == "error" {
|
||||
errStr, _ := f.Data["error"].(string)
|
||||
if errStr == "" {
|
||||
errStr = f.Message
|
||||
}
|
||||
s.logf("msg=%d tool=%s ERROR: %s", msgID, f.Tool, errStr)
|
||||
}
|
||||
case "decision":
|
||||
// 中间帧,已被 tool_calls / request(success) 覆盖,跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
|
||||
// ptrStringValue 安全取出 *string 的值,nil 返回空串。
|
||||
func ptrStringValue(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// processMessage processes a single queued message
|
||||
func (s *Service) processMessage(ctx context.Context, msg QueuedMessage) {
|
||||
// Mark message as processing
|
||||
if err := s.msgQueue.MarkAsProcessing(msg.ID); err != nil {
|
||||
s.logf("msg=%d FAIL step=mark_as_processing err=%v", msg.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
s.logf("msg=%d from=%s start text=%q", msg.ID, msg.FromNodeID, msg.Text)
|
||||
|
||||
// Create processing context with timeout
|
||||
procCtx, cancel := context.WithTimeout(ctx, MaxProcessingTime)
|
||||
defer cancel()
|
||||
|
||||
// Get or create conversation for this bot
|
||||
conv, err := s.convStore.GetOrCreateForBot(msg.BotID, msg.BotNodeID, msg.FromNodeID)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to get conversation: %v", err)
|
||||
s.logf("msg=%d FAIL step=get_conversation err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Add the user message to the conversation
|
||||
userMsg := message.ChatMessage{
|
||||
Role: "user",
|
||||
Content: s.formatUserMessage(msg),
|
||||
}
|
||||
if err := s.convStore.AddMessage(conv.ID, userMsg); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to add message: %v", err)
|
||||
s.logf("msg=%d FAIL step=add_message err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the LLM profile
|
||||
profile := s.llmState.ActiveProfile()
|
||||
if profile == nil {
|
||||
errMsg := "no active LLM profile - check if LLM providers are configured"
|
||||
s.logf("msg=%d FAIL step=get_profile err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
s.logf("msg=%d main_model=%s base=%s", msg.ID, profile.Config.Model, profile.Config.BaseURL)
|
||||
|
||||
// Reload conversation to get updated messages
|
||||
conv, err = s.convStore.Get(conv.ID)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to reload conversation: %v", err)
|
||||
s.logf("msg=%d FAIL step=reload_conversation err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Get system prompt and tool enable flag from primary config
|
||||
var systemPrompt string
|
||||
enableTool := false
|
||||
if s.toolConfigStore != nil {
|
||||
systemPrompt, err = s.toolConfigStore.GetLLMPrimaryConfigSystemPrompt()
|
||||
if err != nil {
|
||||
s.logf("msg=%d WARN system_prompt err=%v", msg.ID, err)
|
||||
}
|
||||
enableTool, err = s.toolConfigStore.GetLLMPrimaryConfigEnableTool()
|
||||
if err != nil {
|
||||
s.logf("msg=%d WARN enable_tool err=%v", msg.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Print tool manager status for debugging
|
||||
toolCount := 0
|
||||
if s.toolMgr != nil {
|
||||
tools := s.toolMgr.Tools()
|
||||
toolCount = len(tools)
|
||||
toolNames := make([]string, 0, toolCount)
|
||||
for _, t := range tools {
|
||||
toolNames = append(toolNames, t.Name())
|
||||
}
|
||||
s.logf("msg=%d tools_loaded=%v enable_tool=%t", msg.ID, toolNames, enableTool)
|
||||
}
|
||||
|
||||
// Run the tool loop to get augmented messages - pass system prompt to tool router
|
||||
// Tool loop will handle system prompt and tool calling
|
||||
var augmentedMessages []*model.ChatCompletionMessage
|
||||
toolUsed := false
|
||||
if enableTool && toolCount > 0 {
|
||||
routerProfile := s.toolRouter.RouterProfile(profile)
|
||||
routerModel := profile.Config.Model
|
||||
if routerProfile != nil {
|
||||
routerModel = routerProfile.Config.Model
|
||||
}
|
||||
s.logf("msg=%d router_model=%s tool_loop start", msg.ID, routerModel)
|
||||
// 把发送节点身份注入 ctx,供需要识别节点的工具(如签到)使用
|
||||
nodeCtx := agenttool.WithNodeContext(procCtx, agenttool.NodeContext{
|
||||
NodeID: msg.FromNodeID,
|
||||
LongName: ptrStringValue(msg.LongName),
|
||||
ShortName: ptrStringValue(msg.ShortName),
|
||||
})
|
||||
augmentedMessages, toolUsed, err = toolrouter.RunAgentToolLoop(nodeCtx, s.toolRouter, profile, systemPrompt, conv.Messages, s.toolMgr, s.emit(msg.ID, routerModel))
|
||||
if err != nil {
|
||||
s.logf("msg=%d WARN tool_loop err=%v", msg.ID, err)
|
||||
// Continue with original messages if tool loop fails
|
||||
}
|
||||
}
|
||||
|
||||
s.logf("msg=%d completion start has_system_prompt=%t augmented=%d", msg.ID, systemPrompt != "", len(augmentedMessages))
|
||||
|
||||
// 若工具路由未实际调用任何工具,则进入话题选择判定:
|
||||
// 命中(REPLY/放行)才进入主回复,未命中则丢弃不回复。
|
||||
if !toolUsed {
|
||||
shouldReply, judgeErr := topicrouter.Judge(procCtx, s.topicRouter, profile, conv.Messages)
|
||||
if judgeErr != nil {
|
||||
s.logf("msg=%d WARN topic_judge err=%v (放行)", msg.ID, judgeErr)
|
||||
}
|
||||
if !shouldReply {
|
||||
s.logf("msg=%d topic_judge=IGNORE → 丢弃不回复", msg.ID)
|
||||
// 把刚加入会话的用户消息弹出,避免它残留在上下文里被下一次回复附带回答。
|
||||
if popped, popErr := s.convStore.PopLastMessage(conv.ID); popErr != nil {
|
||||
s.logf("msg=%d WARN pop_discarded_message err=%v", msg.ID, popErr)
|
||||
} else if popped.Content != "" {
|
||||
s.logf("msg=%d pop_discarded_message content=%q", msg.ID, truncate(popped.Content, 200))
|
||||
}
|
||||
_ = s.msgQueue.MarkAsProcessed(msg.ID, "")
|
||||
return
|
||||
}
|
||||
s.logf("msg=%d topic_judge=REPLY → 进入主回复", msg.ID)
|
||||
}
|
||||
|
||||
// Use augmented messages from tool loop (already includes system prompt and tool results)
|
||||
// If augmented messages is empty or nil, fallback to original messages with system prompt
|
||||
var reply string
|
||||
if len(augmentedMessages) > 0 {
|
||||
// Use augmented messages from tool loop (already converted to model.ChatCompletionMessage)
|
||||
reply, err = completion.CompleteTextWithArkMessages(procCtx, profile, augmentedMessages, 512)
|
||||
} else {
|
||||
// Fallback to simple completion
|
||||
reply, err = completion.CompleteText(procCtx, profile, systemPrompt, conv.Messages, 512)
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("LLM completion failed: %v", err)
|
||||
s.logf("msg=%d FAIL step=llm_completion err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
s.logf("msg=%d main=%s reply_len=%d reply=%q", msg.ID, profile.Config.Model, len(reply), truncate(reply, 200))
|
||||
|
||||
// Clean and validate reply text
|
||||
reply = cleanReplyText(reply)
|
||||
|
||||
// Truncate reply for Meshtastic (UTF-8 safe truncation)
|
||||
if len([]byte(reply)) > MaxReplyLength {
|
||||
reply = truncateUTF8(reply, MaxReplyLength-3) + "..."
|
||||
s.logf("msg=%d reply truncated to %d bytes", msg.ID, len(reply))
|
||||
}
|
||||
|
||||
// Final UTF-8 validation before sending
|
||||
if !utf8.ValidString(reply) {
|
||||
s.logf("msg=%d WARN final text invalid utf8, using fallback", msg.ID)
|
||||
reply = "抱歉,我暂时无法回复。请稍后再试。"
|
||||
}
|
||||
|
||||
// Add assistant reply to conversation
|
||||
assistantMsg := message.ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: reply,
|
||||
}
|
||||
if err := s.convStore.AddMessage(conv.ID, assistantMsg); err != nil {
|
||||
// Non-fatal, continue
|
||||
}
|
||||
|
||||
// Send the reply via the bot - 根据消息类型决定发送方式
|
||||
// 频道消息:回复到原频道;私聊消息:回复给发送节点
|
||||
var sendErr error
|
||||
if msg.MessageType == "channel" && msg.ChannelID != nil && *msg.ChannelID != "" {
|
||||
// 频道消息 - 回复到原频道
|
||||
s.logf("msg=%d send → channel=%s", msg.ID, *msg.ChannelID)
|
||||
sendErr = s.botSender.SendChannelText(procCtx, msg.BotID, *msg.ChannelID, reply)
|
||||
} else {
|
||||
// 私聊消息 - 回复给发送节点
|
||||
s.logf("msg=%d send → direct to_node_num=%d", msg.ID, msg.FromNodeNum)
|
||||
sendErr = s.botSender.SendDirectText(procCtx, msg.BotID, msg.FromNodeNum, reply)
|
||||
}
|
||||
if sendErr != nil {
|
||||
errMsg := fmt.Sprintf("failed to send reply: %v", sendErr)
|
||||
s.logf("msg=%d FAIL step=send_reply err=%s", msg.ID, errMsg)
|
||||
_ = s.msgQueue.MarkAsFailed(msg.ID, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark message as processed
|
||||
_ = s.msgQueue.MarkAsProcessed(msg.ID, reply)
|
||||
s.logf("msg=%d done", msg.ID)
|
||||
}
|
||||
|
||||
// formatUserMessage formats the incoming message for the LLM
|
||||
func (s *Service) formatUserMessage(msg QueuedMessage) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if msg.LongName != nil && *msg.LongName != "" {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.LongName, msg.FromNodeID))
|
||||
} else if msg.ShortName != nil && *msg.ShortName != "" {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s (%s)] ", *msg.ShortName, msg.FromNodeID))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("[来自 %s] ", msg.FromNodeID))
|
||||
}
|
||||
|
||||
sb.WriteString(msg.Text)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// cleanReplyText cleans LLM reply text to ensure it's valid for Meshtastic
|
||||
func cleanReplyText(text string) string {
|
||||
// Force convert to valid UTF-8 using rune-by-rune processing
|
||||
v := make([]rune, 0, len(text))
|
||||
for i, r := range text {
|
||||
if r == utf8.RuneError {
|
||||
_, size := utf8.DecodeRuneInString(text[i:])
|
||||
if size == 1 {
|
||||
continue // Skip invalid runes
|
||||
}
|
||||
}
|
||||
// Skip all problematic characters
|
||||
if r < 32 && r != '\n' && r != '\r' && r != '\t' {
|
||||
continue
|
||||
}
|
||||
if r == 65533 || r == 0xfffd { // Unicode replacement character
|
||||
continue
|
||||
}
|
||||
v = append(v, r)
|
||||
}
|
||||
text = string(v)
|
||||
|
||||
// Additional cleanup - use only explicitly allowed ASCII printable + CJK
|
||||
var sb strings.Builder
|
||||
for _, r := range text {
|
||||
switch {
|
||||
case r == '\n':
|
||||
sb.WriteRune(' ') // Replace newlines with space for Meshtastic
|
||||
case r == '\r' || r == '\t':
|
||||
continue
|
||||
case r >= 32 && r <= 126: // Printable ASCII
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x20000 && r <= 0x2A6DF: // CJK Extension B
|
||||
sb.WriteRune(r)
|
||||
case r >= 0xFF01 && r <= 0xFF5E: // Fullwidth ASCII variants
|
||||
sb.WriteRune(r)
|
||||
case r == 0x3002 || r == 0xFF1F || r == 0xFF01 || r == 0xFF0C || r == 0xFF1A: // Fullwidth punctuation
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x1F600 && r <= 0x1F64F: // Emoticons
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x1F300 && r <= 0x1F5FF: // Misc Symbols and Pictographs
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x1F680 && r <= 0x1F6FF: // Transport and Map Symbols
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x1F900 && r <= 0x1F9FF: // Supplemental Symbols and Pictographs
|
||||
sb.WriteRune(r)
|
||||
case r >= 0x2600 && r <= 0x27BF: // Misc Symbols + Dingbats
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
continue // Skip all other characters
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(sb.String())
|
||||
if result == "" {
|
||||
result = "抱歉,我无法回复此消息。"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// truncateUTF8 safely truncates a UTF-8 string to max bytes without breaking in the middle of a rune
|
||||
func truncateUTF8(s string, maxBytes int) string {
|
||||
if len([]byte(s)) <= maxBytes {
|
||||
return s
|
||||
}
|
||||
bytes := []byte(s)
|
||||
for i := maxBytes; i > 0; i-- {
|
||||
if utf8.RuneStart(bytes[i]) {
|
||||
return string(bytes[:i])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type nodeBlockingRequest struct {
|
||||
@@ -30,7 +33,7 @@ type forbiddenWordBlockingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blockingCache) {
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, blocking *Cache) {
|
||||
reloadBlocking := func() error {
|
||||
if blocking == nil {
|
||||
return nil
|
||||
@@ -39,17 +42,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
||||
}
|
||||
|
||||
r.GET("/blocking/nodes", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListNodeBlocking(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, nodeBlockingDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, nodeBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountNodeBlocking(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, nodeBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/nodes", func(c *gin.Context) {
|
||||
var req nodeBlockingRequest
|
||||
@@ -82,17 +85,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
||||
})
|
||||
|
||||
r.GET("/blocking/ips", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListIPBlocking(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, ipBlockingDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, ipBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountIPBlocking(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, ipBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/ips", func(c *gin.Context) {
|
||||
var req ipBlockingRequest
|
||||
@@ -125,17 +128,17 @@ func registerAdminBlockingRoutes(r gin.IRouter, store *store, blocking *blocking
|
||||
})
|
||||
|
||||
r.GET("/blocking/words", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListForbiddenWordBlocking(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, forbiddenWordBlockingDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountForbiddenWordBlocking(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, forbiddenWordBlockingDTO)
|
||||
})
|
||||
r.POST("/blocking/words", func(c *gin.Context) {
|
||||
var req forbiddenWordBlockingRequest
|
||||
@@ -178,7 +181,7 @@ func parseBlockingID(c *gin.Context) (uint64, bool) {
|
||||
}
|
||||
|
||||
func writeBlockingMutationResponse[T any](c *gin.Context, status int, row *T, err error, convert func(T) gin.H, afterSuccess func() error) {
|
||||
if errors.Is(err, errBlockingAlreadyExists) {
|
||||
if errors.Is(err, storepkg.ErrBlockingAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "blocking rule already exists"})
|
||||
return
|
||||
}
|
||||
@@ -217,14 +220,14 @@ func writeBlockingDeleteResponse(c *gin.Context, err error, afterSuccess func()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func nodeBlockingDTO(row nodeBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": ptrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
func nodeBlockingDTO(row storepkg.NodeBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": webutil.PtrInt64(row.NodeNum), "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func ipBlockingDTO(row ipBlockingRecord) gin.H {
|
||||
func ipBlockingDTO(row storepkg.IPBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "ip_value": row.IPValue, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func forbiddenWordBlockingDTO(row forbiddenWordBlockingRecord) gin.H {
|
||||
func forbiddenWordBlockingDTO(row storepkg.ForbiddenWordBlockingRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "word": row.Word, "match_type": row.MatchType, "case_sensitive": row.CaseSensitive, "reason": row.Reason, "enabled": row.Enabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package blocking
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
type blockingCache struct {
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
nodes map[string]struct{}
|
||||
nodeNums map[int64]struct{}
|
||||
@@ -24,15 +26,15 @@ type forbiddenWordRule struct {
|
||||
caseSensitive bool
|
||||
}
|
||||
|
||||
func newBlockingCache(store *store) (*blockingCache, error) {
|
||||
cache := &blockingCache{}
|
||||
func New(store *storepkg.Store) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
if err := cache.Reload(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *blockingCache) Reload(store *store) error {
|
||||
func (c *Cache) Reload(store *storepkg.Store) error {
|
||||
if store == nil {
|
||||
return fmt.Errorf("store is required")
|
||||
}
|
||||
@@ -81,7 +83,7 @@ func (c *blockingCache) Reload(store *store) error {
|
||||
words := make([]forbiddenWordRule, 0, len(wordRows))
|
||||
for _, row := range wordRows {
|
||||
word := strings.TrimSpace(row.Word)
|
||||
if word == "" || row.MatchType != forbiddenWordMatchContains {
|
||||
if word == "" || row.MatchType != storepkg.ForbiddenWordMatchContains {
|
||||
continue
|
||||
}
|
||||
words = append(words, forbiddenWordRule{word: word, foldedWord: strings.ToLower(word), matchType: row.MatchType, caseSensitive: row.CaseSensitive})
|
||||
@@ -97,7 +99,7 @@ func (c *blockingCache) Reload(store *store) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
||||
func (c *Cache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
@@ -118,7 +120,7 @@ func (c *blockingCache) IsNodeBlocked(nodeID any, nodeNum any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *blockingCache) IsIPBlocked(host string) bool {
|
||||
func (c *Cache) IsIPBlocked(host string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
@@ -144,7 +146,7 @@ func (c *blockingCache) IsIPBlocked(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *blockingCache) FindForbiddenWord(text any) (string, bool) {
|
||||
func (c *Cache) FindForbiddenWord(text any) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -157,7 +159,7 @@ func (c *blockingCache) FindForbiddenWord(text any) (string, bool) {
|
||||
defer c.mu.RUnlock()
|
||||
foldedText := ""
|
||||
for _, rule := range c.words {
|
||||
if rule.matchType != forbiddenWordMatchContains {
|
||||
if rule.matchType != storepkg.ForbiddenWordMatchContains {
|
||||
continue
|
||||
}
|
||||
if rule.caseSensitive {
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package bot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +7,10 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"meshtastic_mqtt_server/internal/auth"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type botNodeRequest struct {
|
||||
@@ -19,6 +23,8 @@ type botNodeRequest struct {
|
||||
PSK string `json:"psk"`
|
||||
NodeInfoBroadcastEnabled bool `json:"nodeinfo_broadcast_enabled"`
|
||||
NodeInfoBroadcastIntervalSeconds int64 `json:"nodeinfo_broadcast_interval_seconds"`
|
||||
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||
}
|
||||
|
||||
type botSendMessageRequest struct {
|
||||
@@ -30,19 +36,19 @@ type botSendMessageRequest struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, sender TextSender) {
|
||||
r.GET("/bot/nodes", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListBotNodes(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, botNodeDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, botNodeDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotNodes(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botNodeDTO)
|
||||
})
|
||||
r.POST("/bot/nodes", func(c *gin.Context) {
|
||||
var req botNodeRequest
|
||||
@@ -123,14 +129,14 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
}
|
||||
rows, err := store.ListBotMessages(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts.listOptions, err, botMessageDTO)
|
||||
webutil.WriteListResponse(c, rows, opts.ListOptions, err, botMessageDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotMessages(opts)
|
||||
writeListResponseWithTotal(c, rows, opts.listOptions, total, err, botMessageDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts.ListOptions, total, err, botMessageDTO)
|
||||
})
|
||||
r.GET("/bot/direct-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -151,19 +157,19 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target node num"})
|
||||
return
|
||||
}
|
||||
dmOpts := botDirectMessageListOptions{listOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
|
||||
dmOpts := storepkg.BotDirectMessageListOptions{ListOptions: opts, BotID: botID, PeerNodeNum: target, Direction: c.Query("direction")}
|
||||
rows, err := store.ListBotDirectMessagesByConversation(dmOpts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, botDirectMessageDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, botDirectMessageDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountBotDirectMessagesByConversation(dmOpts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, botDirectMessageDTO)
|
||||
})
|
||||
// /bot/conversations 返回某个 bot 下所有会话的概要(最后一条消息 + 未读数),
|
||||
// 给前端侧边栏渲染会话列表使用。
|
||||
r.GET("/bot/conversations", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -233,8 +239,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot message request"})
|
||||
return
|
||||
}
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
row, err := sender.SendText(c.Request.Context(), botSendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
|
||||
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||
row, err := sender.SendText(c.Request.Context(), SendTextRequest{BotID: req.BotID, MessageType: req.MessageType, ChannelID: req.ChannelID, ToNodeID: req.ToNodeID, ToNodeNum: req.ToNodeNum, Text: req.Text, CreatedBy: claims.Username})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "bot node not found"})
|
||||
return
|
||||
@@ -252,8 +258,8 @@ func registerAdminBotRoutes(r gin.IRouter, store *store, sender botTextSender) {
|
||||
})
|
||||
}
|
||||
|
||||
func botNodeInputFromRequest(req botNodeRequest) botNodeInput {
|
||||
return botNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds}
|
||||
func botNodeInputFromRequest(req botNodeRequest) storepkg.BotNodeInput {
|
||||
return storepkg.BotNodeInput{NodeNum: req.NodeNum, LongName: req.LongName, ShortName: req.ShortName, Enabled: req.Enabled, DefaultChannelID: req.DefaultChannelID, TopicPrefix: req.TopicPrefix, PSK: req.PSK, NodeInfoBroadcastEnabled: req.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: req.NodeInfoBroadcastIntervalSeconds, LLMQueueEnabled: req.LLMQueueEnabled, LLMIncludeChannelMessages: req.LLMIncludeChannelMessages}
|
||||
}
|
||||
|
||||
func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
||||
@@ -265,25 +271,25 @@ func parseBotID(c *gin.Context, message string) (uint64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseBotMessageListOptions(c *gin.Context) (botMessageListOptions, bool) {
|
||||
listOpts, ok := parseListOptions(c)
|
||||
func parseBotMessageListOptions(c *gin.Context) (storepkg.BotMessageListOptions, bool) {
|
||||
listOpts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return botMessageListOptions{}, false
|
||||
return storepkg.BotMessageListOptions{}, false
|
||||
}
|
||||
opts := botMessageListOptions{listOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
|
||||
opts := storepkg.BotMessageListOptions{ListOptions: listOpts, MessageType: c.Query("message_type"), ChannelID: c.Query("channel_id")}
|
||||
if value := c.Query("bot_id"); value != "" {
|
||||
id, err := strconv.ParseUint(value, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||
return botMessageListOptions{}, false
|
||||
return storepkg.BotMessageListOptions{}, false
|
||||
}
|
||||
opts.BotID = id
|
||||
}
|
||||
return opts, true
|
||||
}
|
||||
|
||||
func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord, err error) {
|
||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||
func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotNodeRecord, err error) {
|
||||
if errors.Is(err, storepkg.ErrBotNodeAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "bot node already exists or conflicts with existing node"})
|
||||
return
|
||||
}
|
||||
@@ -298,15 +304,15 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *botNodeRecord
|
||||
c.JSON(status, gin.H{"item": botNodeDTO(*row)})
|
||||
}
|
||||
|
||||
func botNodeDTO(row botNodeRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
func botNodeDTO(row storepkg.BotNodeRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func botMessageDTO(row botMessageRecord) gin.H {
|
||||
func botMessageDTO(row storepkg.BotMessageRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "bot_id": row.BotID, "bot_node_id": row.BotNodeID, "bot_node_num": row.BotNodeNum, "message_type": row.MessageType, "channel_id": row.ChannelID, "to_node_id": row.ToNodeID, "to_node_num": row.ToNodeNum, "topic": row.Topic, "packet_id": row.PacketID, "text": row.Text, "payload_len": row.PayloadLen, "encrypted": row.Encrypted, "status": row.Status, "error": row.Error, "published_at": row.PublishedAt, "created_by": row.CreatedBy, "created_at": row.CreatedAt}
|
||||
}
|
||||
|
||||
func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
|
||||
func botDirectMessageDTO(row storepkg.BotDirectMessageRecord) gin.H {
|
||||
return gin.H{
|
||||
"id": row.ID,
|
||||
"bot_id": row.BotID,
|
||||
@@ -333,7 +339,7 @@ func botDirectMessageDTO(row botDirectMessageRecord) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
func botDirectConversationDTO(row botDirectConversation) gin.H {
|
||||
func botDirectConversationDTO(row storepkg.BotDirectConversation) gin.H {
|
||||
return gin.H{
|
||||
"bot_id": row.BotID,
|
||||
"peer_node_id": row.PeerNodeID,
|
||||
@@ -0,0 +1,36 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// NewPKIKeyResolver 返回 mqtpp 在解密 PKI 加密包时使用的回调:根据接收者
|
||||
// 节点号查找受管 bot 的私钥,并根据发送者节点号在 nodeinfo 表中查找其公钥。
|
||||
// 返回 ok=false 时调用方会跳过 PKI 路径并回落到 channel PSK 解密。
|
||||
func NewPKIKeyResolver(s *storepkg.Store) func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool) {
|
||||
bot, err := s.GetBotNodeByNodeNum(int64(toNodeNum))
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
privateKey, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil || len(privateKey) != 32 {
|
||||
return nil, nil, false
|
||||
}
|
||||
fromPublic, ok := s.LookupNodeInfoPublicKey(fromNodeNum)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return privateKey, fromPublic, true
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,15 +12,16 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/mqtpp"
|
||||
)
|
||||
|
||||
const botMaxTextBytes = 200
|
||||
|
||||
type botSendTextRequest struct {
|
||||
type SendTextRequest struct {
|
||||
BotID uint64
|
||||
MessageType string
|
||||
ChannelID string
|
||||
@@ -30,22 +31,22 @@ type botSendTextRequest struct {
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type botTextSender interface {
|
||||
SendText(ctx context.Context, req botSendTextRequest) (*botMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error)
|
||||
type TextSender interface {
|
||||
SendText(ctx context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error)
|
||||
PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error)
|
||||
}
|
||||
|
||||
type botService struct {
|
||||
store *store
|
||||
type Service struct {
|
||||
store *storepkg.Store
|
||||
server *mqtt.Server
|
||||
key []byte
|
||||
}
|
||||
|
||||
func newBotService(store *store, server *mqtt.Server, key []byte) *botService {
|
||||
return &botService{store: store, server: server, key: key}
|
||||
func NewService(store *storepkg.Store, server *mqtt.Server, key []byte) *Service {
|
||||
return &Service{store: store, server: server, key: key}
|
||||
}
|
||||
|
||||
func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
func (s *Service) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
if s == nil || s.store == nil || s.server == nil {
|
||||
return
|
||||
}
|
||||
@@ -59,7 +60,7 @@ func (s *botService) StartNodeInfoBroadcaster(ctx context.Context) {
|
||||
// - 其它情况用原 channel + bot PSK 加密
|
||||
//
|
||||
// 解析失败、目标不是受管 bot、或缺少必要的密钥时,安静返回不报错——这条路径只是“尽力”。
|
||||
func (s *botService) MaybeAutoAck(record map[string]any) {
|
||||
func (s *Service) MaybeAutoAck(record map[string]any) {
|
||||
if s == nil || s.store == nil || s.server == nil || record == nil {
|
||||
return
|
||||
}
|
||||
@@ -110,7 +111,7 @@ func (s *botService) MaybeAutoAck(record map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
||||
func (s *Service) buildPKIAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32) ([]byte, error) {
|
||||
privateKeyB64 := strings.TrimSpace(bot.PrivateKey)
|
||||
if privateKeyB64 == "" {
|
||||
return nil, fmt.Errorf("bot has no private key")
|
||||
@@ -119,11 +120,11 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
senderPublic, err := decodeBotPublicKey(*bot)
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipientPublic, ok := lookupNodeInfoPublicKey(s.store, toNum)
|
||||
recipientPublic, ok := s.store.LookupNodeInfoPublicKey(toNum)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("recipient %s has no public key on file", mqtpp.NodeNumToID(toNum))
|
||||
}
|
||||
@@ -140,14 +141,14 @@ func (s *botService) buildPKIAck(bot *botNodeRecord, toNum, ackPacketID, request
|
||||
})
|
||||
}
|
||||
|
||||
func (s *botService) buildPSKAck(bot *botNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
||||
func (s *Service) buildPSKAck(bot *storepkg.BotNodeRecord, toNum, ackPacketID, requestID uint32, channelID string) ([]byte, error) {
|
||||
channel := fallbackChannelID(channelID, false, bot.DefaultChannelID)
|
||||
if channel == "" || channel == mqtpp.PKIChannelID {
|
||||
return nil, fmt.Errorf("no channel id available for psk ack")
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -208,7 +209,7 @@ func errString(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMessageRecord, error) {
|
||||
func (s *Service) SendText(_ context.Context, req SendTextRequest) (*storepkg.BotMessageRecord, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("bot service is not configured")
|
||||
}
|
||||
@@ -244,7 +245,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
fromNodeNum := uint32(bot.NodeNum)
|
||||
|
||||
// direct 私聊走 PKI;channel 群聊保留旧的 AES-CTR + PSK 路径
|
||||
if messageType == botMessageTypeDirect {
|
||||
if messageType == storepkg.BotMessageTypeDirect {
|
||||
return s.sendPKIDirect(bot, fromNodeNum, uint32(toNodeNum), toNodeID, packetID, text, req.CreatedBy)
|
||||
}
|
||||
|
||||
@@ -257,7 +258,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -280,7 +281,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, channelID, bot.NodeID)
|
||||
row := &botMessageRecord{
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
@@ -293,7 +294,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: botMessageStatusPending,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(req.CreatedBy),
|
||||
}
|
||||
return s.persistAndPublish(row, topic, raw)
|
||||
@@ -303,7 +304,7 @@ func (s *botService) SendText(_ context.Context, req botSendTextRequest) (*botMe
|
||||
// - 从 nodeinfo 中查目标节点的 X25519 公钥
|
||||
// - 用 bot 自身私钥与对端公钥派生共享密钥,AES-CCM(M=8,L=2) 加密
|
||||
// - ServiceEnvelope.channel_id = "PKI",topic 也用 "PKI"
|
||||
func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*botMessageRecord, error) {
|
||||
func (s *Service) sendPKIDirect(bot *storepkg.BotNodeRecord, fromNodeNum, toNodeNum uint32, toNodeID *string, packetID uint32, text, createdBy string) (*storepkg.BotMessageRecord, error) {
|
||||
if toNodeID == nil {
|
||||
return nil, fmt.Errorf("target node id is required for pki direct message")
|
||||
}
|
||||
@@ -315,7 +316,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bot private key: %w", err)
|
||||
}
|
||||
senderPublic, err := decodeBotPublicKey(*bot)
|
||||
senderPublic, err := storepkg.DecodeBotPublicKey(*bot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -339,11 +340,11 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
return nil, err
|
||||
}
|
||||
topic := botMQTTTopic(bot.TopicPrefix, mqtpp.PKIChannelID, bot.NodeID)
|
||||
row := &botMessageRecord{
|
||||
row := &storepkg.BotMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
MessageType: botMessageTypeDirect,
|
||||
MessageType: storepkg.BotMessageTypeDirect,
|
||||
ChannelID: mqtpp.PKIChannelID,
|
||||
ToNodeID: toNodeID,
|
||||
ToNodeNum: int64PtrOrNil(int64(toNodeNum), true),
|
||||
@@ -352,7 +353,7 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
Text: text,
|
||||
PayloadLen: int64(len(raw)),
|
||||
Encrypted: true,
|
||||
Status: botMessageStatusPending,
|
||||
Status: storepkg.BotMessageStatusPending,
|
||||
CreatedBy: strings.TrimSpace(createdBy),
|
||||
}
|
||||
result, err := s.persistAndPublish(row, topic, raw)
|
||||
@@ -364,16 +365,16 @@ func (s *botService) sendPKIDirect(bot *botNodeRecord, fromNodeNum, toNodeNum ui
|
||||
}
|
||||
|
||||
// recordOutboundDirectMessage 把出向 PKI DM 写入 bot_direct_messages。失败仅打日志。
|
||||
func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
||||
func (s *Service) recordOutboundDirectMessage(bot *storepkg.BotNodeRecord, msg *storepkg.BotMessageRecord, peerNodeID string, peerNodeNum uint32, text string, payloadLen int, sendErr error) {
|
||||
if s == nil || s.store == nil || msg == nil || bot == nil {
|
||||
return
|
||||
}
|
||||
status := msg.Status
|
||||
if status == "" {
|
||||
if sendErr != nil {
|
||||
status = botMessageStatusFailed
|
||||
status = storepkg.BotMessageStatusFailed
|
||||
} else {
|
||||
status = botMessageStatusPublished
|
||||
status = storepkg.BotMessageStatusPublished
|
||||
}
|
||||
}
|
||||
errText := msg.Error
|
||||
@@ -396,13 +397,13 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
||||
botMessageID = &id
|
||||
}
|
||||
now := time.Now()
|
||||
dm := &botDirectMessageRecord{
|
||||
dm := &storepkg.BotDirectMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
PeerNodeID: peerNodeID,
|
||||
PeerNodeNum: int64(peerNodeNum),
|
||||
Direction: botDirectMessageDirectionOutbound,
|
||||
Direction: storepkg.BotDirectMessageDirectionOutbound,
|
||||
Topic: msg.Topic,
|
||||
PacketID: msg.PacketID,
|
||||
Text: text,
|
||||
@@ -415,8 +416,9 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
||||
BotMessageID: botMessageID,
|
||||
CreatedBy: createdByPtr,
|
||||
PublishedAt: msg.PublishedAt,
|
||||
// 出向消息从产生那一刻起就视为“已读”,未读计数只关心 inbound。
|
||||
// 出向消息从产生那一刻起就视为"已读",未读计数只关心 inbound。
|
||||
ReadAt: &now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.store.InsertBotDirectMessage(dm); err != nil {
|
||||
printJSON(map[string]any{
|
||||
@@ -430,7 +432,7 @@ func (s *botService) recordOutboundDirectMessage(bot *botNodeRecord, msg *botMes
|
||||
}
|
||||
|
||||
// lookupRecipientPublicKey 从 nodeinfo 表中按 node_id 查询目标节点的 X25519 公钥(hex 编码)。
|
||||
func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
func (s *Service) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
node, err := s.store.GetNodeInfo(nodeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -458,33 +460,33 @@ func (s *botService) lookupRecipientPublicKey(nodeID string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// persistAndPublish 把消息记录入库后发布到 MQTT,统一处理失败状态写回。
|
||||
func (s *botService) persistAndPublish(row *botMessageRecord, topic string, raw []byte) (*botMessageRecord, error) {
|
||||
func (s *Service) persistAndPublish(row *storepkg.BotMessageRecord, topic string, raw []byte) (*storepkg.BotMessageRecord, error) {
|
||||
if err := s.store.InsertBotMessage(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.server == nil {
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, "mqtt server is not configured", nil)
|
||||
row.Status = botMessageStatusFailed
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, "mqtt server is not configured", nil)
|
||||
row.Status = storepkg.BotMessageStatusFailed
|
||||
row.Error = "mqtt server is not configured"
|
||||
return row, fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
if err := s.server.Publish(topic, raw, false, 0); err != nil {
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, botMessageStatusFailed, err.Error(), nil)
|
||||
row.Status = botMessageStatusFailed
|
||||
_ = s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusFailed, err.Error(), nil)
|
||||
row.Status = storepkg.BotMessageStatusFailed
|
||||
row.Error = err.Error()
|
||||
return row, err
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.store.UpdateBotMessageStatus(row.ID, botMessageStatusPublished, "", &now); err != nil {
|
||||
if err := s.store.UpdateBotMessageStatus(row.ID, storepkg.BotMessageStatusPublished, "", &now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Status = botMessageStatusPublished
|
||||
row.Status = storepkg.BotMessageStatusPublished
|
||||
row.Error = ""
|
||||
row.PublishedAt = &now
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
func (s *Service) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
s.broadcastDueNodeInfo(ctx)
|
||||
@@ -498,8 +500,8 @@ func (s *botService) runNodeInfoBroadcaster(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
rows, err := s.store.ListBotNodes(listOptions{Limit: 500})
|
||||
func (s *Service) broadcastDueNodeInfo(ctx context.Context) {
|
||||
rows, err := s.store.ListBotNodes(storepkg.ListOptions{Limit: 500})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{"event": "bot_nodeinfo_broadcast_failed", "error": err.Error()})
|
||||
return
|
||||
@@ -514,7 +516,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
}
|
||||
interval := time.Duration(bot.NodeInfoBroadcastIntervalSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Duration(botDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
interval = time.Duration(storepkg.BotDefaultNodeInfoBroadcastSeconds) * time.Second
|
||||
}
|
||||
if bot.LastNodeInfoBroadcastAt != nil && now.Sub(*bot.LastNodeInfoBroadcastAt) < interval {
|
||||
continue
|
||||
@@ -525,7 +527,7 @@ func (s *botService) broadcastDueNodeInfo(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNodeRecord, error) {
|
||||
func (s *Service) PublishNodeInfoByID(ctx context.Context, id uint64) (*storepkg.BotNodeRecord, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return nil, fmt.Errorf("bot service is not configured")
|
||||
}
|
||||
@@ -546,7 +548,7 @@ func (s *botService) PublishNodeInfoByID(ctx context.Context, id uint64) (*botNo
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error {
|
||||
func (s *Service) PublishNodeInfo(_ context.Context, bot storepkg.BotNodeRecord) error {
|
||||
if s == nil || s.server == nil {
|
||||
return fmt.Errorf("mqtt server is not configured")
|
||||
}
|
||||
@@ -559,7 +561,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
}
|
||||
psk := strings.TrimSpace(bot.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = storepkg.BotDefaultPSK
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(psk)
|
||||
if err != nil {
|
||||
@@ -569,7 +571,7 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKey, err := decodeBotPublicKey(bot)
|
||||
publicKey, err := storepkg.DecodeBotPublicKey(bot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -612,21 +614,21 @@ func (s *botService) PublishNodeInfo(_ context.Context, bot botNodeRecord) error
|
||||
|
||||
func normalizeBotMessageType(value string) (string, error) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", botMessageTypeChannel:
|
||||
return botMessageTypeChannel, nil
|
||||
case botMessageTypeDirect:
|
||||
return botMessageTypeDirect, nil
|
||||
case "", storepkg.BotMessageTypeChannel:
|
||||
return storepkg.BotMessageTypeChannel, nil
|
||||
case storepkg.BotMessageTypeDirect:
|
||||
return storepkg.BotMessageTypeDirect, nil
|
||||
default:
|
||||
return "", fmt.Errorf("message type must be channel or direct")
|
||||
}
|
||||
}
|
||||
|
||||
func botMessageTarget(messageType string, req botSendTextRequest) (int64, *string, error) {
|
||||
if messageType == botMessageTypeChannel {
|
||||
func botMessageTarget(messageType string, req SendTextRequest) (int64, *string, error) {
|
||||
if messageType == storepkg.BotMessageTypeChannel {
|
||||
return int64(mqtpp.NodeNumBroadcast), nil, nil
|
||||
}
|
||||
if req.ToNodeNum != nil && *req.ToNodeNum > 0 {
|
||||
if err := validateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||
if err := storepkg.ValidateBotNodeNum(*req.ToNodeNum); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
nodeID := mqtpp.NodeNumToID(uint32(*req.ToNodeNum))
|
||||
@@ -640,7 +642,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := validateBotNodeNum(int64(nodeNum)); err != nil {
|
||||
if err := storepkg.ValidateBotNodeNum(int64(nodeNum)); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
normalized := mqtpp.NodeNumToID(nodeNum)
|
||||
@@ -650,7 +652,7 @@ func botMessageTarget(messageType string, req botSendTextRequest) (int64, *strin
|
||||
func botMQTTTopic(topicPrefix, channelID, nodeID string) string {
|
||||
prefix := strings.Trim(strings.TrimSpace(topicPrefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = botDefaultTopicPrefix
|
||||
prefix = storepkg.BotDefaultTopicPrefix
|
||||
}
|
||||
if strings.HasSuffix(prefix, "/2/e") {
|
||||
return prefix + "/" + channelID + "/" + nodeID
|
||||
@@ -0,0 +1,8 @@
|
||||
package bot
|
||||
|
||||
// printJSON 是 bot service 的内部诊断输出钩子;当前为 noop,与重构前
|
||||
// main.go 中的 printJSON 行为一致(注释掉了实际写出)。
|
||||
// 如需调试可直接替换实现。
|
||||
func printJSON(record map[string]any) {
|
||||
_ = record
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package completion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// ChatCompleter is a function that completes a chat
|
||||
type ChatCompleter func(ctx context.Context, profile *llm.Profile, req model.CreateChatCompletionRequest, timeout time.Duration) (model.ChatCompletionResponse, error)
|
||||
|
||||
// CompleteChat completes a chat conversation
|
||||
func CompleteChat(ctx context.Context, profile *llm.Profile, req model.CreateChatCompletionRequest, timeout time.Duration) (model.ChatCompletionResponse, error) {
|
||||
if profile == nil || profile.Client == nil {
|
||||
return model.ChatCompletionResponse{}, fmt.Errorf("llm profile or client is nil")
|
||||
}
|
||||
|
||||
// Use context with timeout if provided
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := profile.Client.CreateChatCompletion(ctx, req)
|
||||
if err != nil {
|
||||
return model.ChatCompletionResponse{}, fmt.Errorf("chat completion failed: %w", err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// CompleteText completes a text prompt using conversation messages
|
||||
// If systemPrompt is not empty, it will be added as the first message
|
||||
func CompleteText(ctx context.Context, profile *llm.Profile, systemPrompt string, messages []message.ChatMessage, maxTokens int) (string, error) {
|
||||
if profile == nil || profile.Client == nil {
|
||||
return "", fmt.Errorf("llm profile or client is nil")
|
||||
}
|
||||
|
||||
arkMessages := make([]*model.ChatCompletionMessage, 0, len(messages)+1)
|
||||
|
||||
// Add system prompt if provided
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
content := &model.ChatCompletionMessageContent{
|
||||
StringValue: &systemPrompt,
|
||||
}
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
content := &model.ChatCompletionMessageContent{
|
||||
StringValue: &msg.Content,
|
||||
}
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: msg.Role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
req := model.CreateChatCompletionRequest{
|
||||
Model: profile.Config.Model,
|
||||
Messages: arkMessages,
|
||||
MaxTokens: &maxTokens,
|
||||
}
|
||||
|
||||
resp, err := profile.Client.CreateChatCompletion(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("text completion failed: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no completion choices returned")
|
||||
}
|
||||
|
||||
if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil {
|
||||
return *resp.Choices[0].Message.Content.StringValue, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// CompleteTextWithArkMessages completes a text prompt using already converted Ark messages
|
||||
// This is used when messages have already been converted (e.g. after tool loop)
|
||||
func CompleteTextWithArkMessages(ctx context.Context, profile *llm.Profile, arkMessages []*model.ChatCompletionMessage, maxTokens int) (string, error) {
|
||||
if profile == nil || profile.Client == nil {
|
||||
return "", fmt.Errorf("llm profile or client is nil")
|
||||
}
|
||||
|
||||
req := model.CreateChatCompletionRequest{
|
||||
Model: profile.Config.Model,
|
||||
Messages: arkMessages,
|
||||
MaxTokens: &maxTokens,
|
||||
}
|
||||
|
||||
resp, err := profile.Client.CreateChatCompletion(ctx, req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("text completion failed: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no completion choices returned")
|
||||
}
|
||||
|
||||
if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil {
|
||||
return *resp.Choices[0].Message.Content.StringValue, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package config
|
||||
|
||||
import (
|
||||
cryptotls "crypto/tls"
|
||||
@@ -10,68 +10,106 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const configFileName = "config.yaml"
|
||||
const FileName = "config.yaml"
|
||||
|
||||
type config struct {
|
||||
MQTT mqttConfig `yaml:"mqtt"`
|
||||
Meshtastic meshtasticConfig `yaml:"meshtastic"`
|
||||
Database databaseConfig `yaml:"database"`
|
||||
Web webConfig `yaml:"web"`
|
||||
key []byte
|
||||
const (
|
||||
DriverSQLite = "sqlite"
|
||||
DriverMySQL = "mysql"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MQTT MQTTConfig `yaml:"mqtt"`
|
||||
Meshtastic MeshtasticConfig `yaml:"meshtastic"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
AI AIConfig `yaml:"ai"`
|
||||
ConsoleLog ConsoleLogConfig `yaml:"console_log"`
|
||||
Key []byte `yaml:"-"`
|
||||
}
|
||||
|
||||
type mqttConfig struct {
|
||||
type MQTTConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
TLS tlsConfig `yaml:"tls"`
|
||||
TLS TLSConfig `yaml:"tls"`
|
||||
}
|
||||
|
||||
type tlsConfig struct {
|
||||
type TLSConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
}
|
||||
|
||||
type meshtasticConfig struct {
|
||||
type MeshtasticConfig struct {
|
||||
PSK string `yaml:"psk"`
|
||||
}
|
||||
|
||||
type databaseConfig struct {
|
||||
type DatabaseConfig struct {
|
||||
Driver string `yaml:"driver"`
|
||||
SQLite sqliteConfig `yaml:"sqlite"`
|
||||
MySQL mysqlConfig `yaml:"mysql"`
|
||||
SQLite SQLiteConfig `yaml:"sqlite"`
|
||||
MySQL MySQLConfig `yaml:"mysql"`
|
||||
}
|
||||
|
||||
type sqliteConfig struct {
|
||||
type SQLiteConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type mysqlConfig struct {
|
||||
type MySQLConfig struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
}
|
||||
|
||||
type webConfig struct {
|
||||
type WebConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PortEnabled bool `yaml:"port_enabled"`
|
||||
SocketEnabled bool `yaml:"socket_enabled"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
SocketPath string `yaml:"socket_path"`
|
||||
StaticDir string `yaml:"static_dir"`
|
||||
MapTileCacheDir string `yaml:"map_tile_cache_dir"`
|
||||
Admin webAdminConfig `yaml:"admin"`
|
||||
Admin WebAdminConfig `yaml:"admin"`
|
||||
}
|
||||
|
||||
type webAdminConfig struct {
|
||||
type WebAdminConfig struct {
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
SessionSecure bool `yaml:"session_secure"`
|
||||
}
|
||||
|
||||
type AIConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
}
|
||||
|
||||
// ConsoleLogConfig 控制各模块是否在控制台打印日志。后续若新增模块,按需扩展。
|
||||
type ConsoleLogConfig struct {
|
||||
Web bool `yaml:"web"`
|
||||
MQTT bool `yaml:"mqtt"`
|
||||
LLM bool `yaml:"llm"`
|
||||
SQL bool `yaml:"sql"`
|
||||
Meshtastic bool `yaml:"meshtastic"`
|
||||
}
|
||||
|
||||
type rawConfig struct {
|
||||
MQTT *rawMQTTConfig `yaml:"mqtt"`
|
||||
Meshtastic *rawMeshtasticConfig `yaml:"meshtastic"`
|
||||
Database *rawDatabaseConfig `yaml:"database"`
|
||||
Web *rawWebConfig `yaml:"web"`
|
||||
AI *rawAIConfig `yaml:"ai"`
|
||||
ConsoleLog *rawConsoleLogConfig `yaml:"console_log"`
|
||||
}
|
||||
|
||||
type rawConsoleLogConfig struct {
|
||||
Web *bool `yaml:"web"`
|
||||
MQTT *bool `yaml:"mqtt"`
|
||||
LLM *bool `yaml:"llm"`
|
||||
SQL *bool `yaml:"sql"`
|
||||
Meshtastic *bool `yaml:"meshtastic"`
|
||||
}
|
||||
|
||||
type rawAIConfig struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
DataDir *string `yaml:"data_dir"`
|
||||
}
|
||||
|
||||
type rawMQTTConfig struct {
|
||||
@@ -106,6 +144,8 @@ type rawMySQLConfig struct {
|
||||
|
||||
type rawWebConfig struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
PortEnabled *bool `yaml:"port_enabled"`
|
||||
SocketEnabled *bool `yaml:"socket_enabled"`
|
||||
Host *string `yaml:"host"`
|
||||
Port *int `yaml:"port"`
|
||||
SocketPath *string `yaml:"socket_path"`
|
||||
@@ -121,54 +161,75 @@ type rawWebAdminConfig struct {
|
||||
SessionSecure *bool `yaml:"session_secure"`
|
||||
}
|
||||
|
||||
// defaultConfig 返回内置默认配置。
|
||||
func defaultConfig() *config {
|
||||
return &config{
|
||||
MQTT: mqttConfig{
|
||||
// Default 返回内置默认配置。
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
MQTT: MQTTConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 1883,
|
||||
TLS: tlsConfig{
|
||||
TLS: TLSConfig{
|
||||
Enabled: false,
|
||||
CertFile: "",
|
||||
KeyFile: "",
|
||||
},
|
||||
},
|
||||
Meshtastic: meshtasticConfig{
|
||||
Meshtastic: MeshtasticConfig{
|
||||
PSK: "AQ==",
|
||||
},
|
||||
Database: databaseConfig{
|
||||
Driver: "sqlite",
|
||||
SQLite: sqliteConfig{Path: defaultSQLitePath()},
|
||||
MySQL: mysqlConfig{DSN: ""},
|
||||
Database: DatabaseConfig{
|
||||
Driver: DriverSQLite,
|
||||
SQLite: SQLiteConfig{Path: defaultSQLitePath()},
|
||||
MySQL: MySQLConfig{DSN: ""},
|
||||
},
|
||||
Web: webConfig{
|
||||
Web: WebConfig{
|
||||
Enabled: true,
|
||||
PortEnabled: true,
|
||||
SocketEnabled: defaultWebSocketPath() != "",
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
SocketPath: defaultWebSocketPath(),
|
||||
StaticDir: "./dist",
|
||||
MapTileCacheDir: defaultMapTileCacheDir(),
|
||||
Admin: webAdminConfig{
|
||||
Admin: WebAdminConfig{
|
||||
Username: "admin",
|
||||
Password: "admin",
|
||||
SessionSecret: "",
|
||||
SessionSecure: false,
|
||||
},
|
||||
},
|
||||
AI: AIConfig{
|
||||
Enabled: false,
|
||||
DataDir: defaultDataDir(),
|
||||
},
|
||||
ConsoleLog: ConsoleLogConfig{
|
||||
Web: true,
|
||||
MQTT: true,
|
||||
LLM: true,
|
||||
SQL: true,
|
||||
Meshtastic: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultConfigDir 根据操作系统返回配置目录。
|
||||
func defaultConfigDir() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
// DefaultDir 根据操作系统返回配置目录。
|
||||
func DefaultDir() string {
|
||||
return defaultConfigDirForGOOS(runtime.GOOS)
|
||||
}
|
||||
|
||||
func defaultConfigDirForGOOS(goos string) string {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "etc", "mesh_mqtt_go")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "etc", "mesh_mqtt_go")
|
||||
}
|
||||
|
||||
// defaultConfigPath 返回默认配置文件路径。
|
||||
func defaultConfigPath() string {
|
||||
return filepath.Join(defaultConfigDir(), configFileName)
|
||||
func useRelativeDefaultPath(goos string) bool {
|
||||
return goos == "windows" || goos == "darwin"
|
||||
}
|
||||
|
||||
// DefaultPath 返回默认配置文件路径。
|
||||
func DefaultPath() string {
|
||||
return filepath.Join(DefaultDir(), FileName)
|
||||
}
|
||||
|
||||
func defaultSQLitePath() string {
|
||||
@@ -184,7 +245,7 @@ func defaultMapTileCacheDir() string {
|
||||
}
|
||||
|
||||
func defaultMapTileCacheDirForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
|
||||
@@ -194,28 +255,50 @@ func defaultWebSocketPathForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
return ""
|
||||
}
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "opt", "mesh_mqtt_go", "web.sock")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "opt", "mesh_mqtt_go", "web.sock")
|
||||
}
|
||||
|
||||
func clearWebSocketPathOnUnsupportedGOOS(cfg *config, goos string) bool {
|
||||
if goos != "windows" || cfg.Web.SocketPath == "" {
|
||||
func ClearWebSocketPathOnUnsupportedGOOS(cfg *Config, goos string) bool {
|
||||
if goos != "windows" {
|
||||
return false
|
||||
}
|
||||
cfg.Web.SocketPath = ""
|
||||
return true
|
||||
changed := false
|
||||
if cfg.Web.SocketPath != "" {
|
||||
cfg.Web.SocketPath = ""
|
||||
changed = true
|
||||
}
|
||||
if cfg.Web.SocketEnabled {
|
||||
cfg.Web.SocketEnabled = false
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func defaultSQLitePathForGOOS(goos string) string {
|
||||
if goos == "windows" {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "etc", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go", "mesh_mqtt_go.db")
|
||||
}
|
||||
|
||||
// loadConfig 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。
|
||||
func loadConfig(path string) (*config, error) {
|
||||
func defaultDataDir() string {
|
||||
return defaultDataDirForGOOS(runtime.GOOS)
|
||||
}
|
||||
|
||||
func defaultDataDirForGOOS(goos string) string {
|
||||
if useRelativeDefaultPath(goos) {
|
||||
return filepath.Join(".", "win", "srv", "mesh_mqtt_go")
|
||||
}
|
||||
return filepath.Join(string(filepath.Separator), "srv", "mesh_mqtt_go")
|
||||
}
|
||||
|
||||
// Load 加载配置文件;文件不存在时生成,字段缺失时自动补全并写回。
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
path = defaultConfigPath()
|
||||
path = DefaultPath()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("create config directory %s: %w", filepath.Dir(path), err)
|
||||
@@ -225,8 +308,8 @@ func loadConfig(path string) (*config, error) {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("stat config file %s: %w", path, err)
|
||||
}
|
||||
cfg := defaultConfig()
|
||||
if err := writeConfig(path, cfg); err != nil {
|
||||
cfg := Default()
|
||||
if err := Write(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
@@ -242,24 +325,24 @@ func loadConfig(path string) (*config, error) {
|
||||
return nil, fmt.Errorf("parse config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg, changed := normalizeConfig(raw)
|
||||
if clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) {
|
||||
cfg, changed := normalize(raw)
|
||||
if ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS) {
|
||||
changed = true
|
||||
}
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changed {
|
||||
if err := writeConfig(path, cfg); err != nil {
|
||||
if err := Write(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// normalizeConfig 将原始配置合并到默认配置,并标记是否补齐了缺失项。
|
||||
func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
cfg := defaultConfig()
|
||||
// normalize 将原始配置合并到默认配置,并标记是否补齐了缺失项。
|
||||
func normalize(raw rawConfig) (*Config, bool) {
|
||||
cfg := Default()
|
||||
changed := false
|
||||
|
||||
if raw.MQTT == nil {
|
||||
@@ -336,6 +419,16 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
} else {
|
||||
cfg.Web.Enabled = *raw.Web.Enabled
|
||||
}
|
||||
if raw.Web.PortEnabled == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.PortEnabled = *raw.Web.PortEnabled
|
||||
}
|
||||
if raw.Web.SocketEnabled == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.Web.SocketEnabled = *raw.Web.SocketEnabled
|
||||
}
|
||||
if raw.Web.Host == nil {
|
||||
changed = true
|
||||
} else {
|
||||
@@ -387,19 +480,64 @@ func normalizeConfig(raw rawConfig) (*config, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
if raw.AI == nil {
|
||||
changed = true
|
||||
} else {
|
||||
if raw.AI.Enabled == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.AI.Enabled = *raw.AI.Enabled
|
||||
}
|
||||
if raw.AI.DataDir == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.AI.DataDir = *raw.AI.DataDir
|
||||
}
|
||||
}
|
||||
|
||||
if raw.ConsoleLog == nil {
|
||||
changed = true
|
||||
} else {
|
||||
if raw.ConsoleLog.Web == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.ConsoleLog.Web = *raw.ConsoleLog.Web
|
||||
}
|
||||
if raw.ConsoleLog.MQTT == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.ConsoleLog.MQTT = *raw.ConsoleLog.MQTT
|
||||
}
|
||||
if raw.ConsoleLog.LLM == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.ConsoleLog.LLM = *raw.ConsoleLog.LLM
|
||||
}
|
||||
if raw.ConsoleLog.SQL == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.ConsoleLog.SQL = *raw.ConsoleLog.SQL
|
||||
}
|
||||
if raw.ConsoleLog.Meshtastic == nil {
|
||||
changed = true
|
||||
} else {
|
||||
cfg.ConsoleLog.Meshtastic = *raw.ConsoleLog.Meshtastic
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, changed
|
||||
}
|
||||
|
||||
func validateConfig(cfg *config) error {
|
||||
func Validate(cfg *Config) error {
|
||||
if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 {
|
||||
return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port)
|
||||
}
|
||||
switch cfg.Database.Driver {
|
||||
case "sqlite":
|
||||
case DriverSQLite:
|
||||
if cfg.Database.SQLite.Path == "" {
|
||||
return fmt.Errorf("database.sqlite.path is required when database.driver is sqlite")
|
||||
}
|
||||
case "mysql":
|
||||
case DriverMySQL:
|
||||
if cfg.Database.MySQL.DSN == "" {
|
||||
return fmt.Errorf("database.mysql.dsn is required when database.driver is mysql")
|
||||
}
|
||||
@@ -407,9 +545,15 @@ func validateConfig(cfg *config) error {
|
||||
return fmt.Errorf("invalid database.driver %q: must be sqlite or mysql", cfg.Database.Driver)
|
||||
}
|
||||
if cfg.Web.Enabled {
|
||||
if cfg.Web.SocketPath == "" && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) {
|
||||
if !cfg.Web.PortEnabled && !cfg.Web.SocketEnabled {
|
||||
return fmt.Errorf("web.port_enabled and web.socket_enabled cannot both be false when web is enabled")
|
||||
}
|
||||
if cfg.Web.PortEnabled && (cfg.Web.Port <= 0 || cfg.Web.Port > 65535) {
|
||||
return fmt.Errorf("invalid web port %d: must be 1-65535", cfg.Web.Port)
|
||||
}
|
||||
if cfg.Web.SocketEnabled && cfg.Web.SocketPath == "" {
|
||||
return fmt.Errorf("web.socket_path is required when web.socket_enabled is true")
|
||||
}
|
||||
if cfg.Web.StaticDir == "" {
|
||||
return fmt.Errorf("web.static_dir is required when web is enabled")
|
||||
}
|
||||
@@ -426,7 +570,7 @@ func validateConfig(cfg *config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConfig(path string, cfg *config) error {
|
||||
func Write(path string, cfg *Config) error {
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode config file %s: %w", path, err)
|
||||
@@ -437,8 +581,8 @@ func writeConfig(path string, cfg *config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTLSConfig 根据配置构造 mochi listener 使用的 TLS 设置。
|
||||
func buildTLSConfig(cfg tlsConfig) (*cryptotls.Config, error) {
|
||||
// BuildTLS 根据配置构造 mochi listener 使用的 TLS 设置。
|
||||
func BuildTLS(cfg TLSConfig) (*cryptotls.Config, error) {
|
||||
if !cfg.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
)
|
||||
|
||||
// Store manages conversations stored as JSON files
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewStore creates a new conversation store
|
||||
func NewStore(dir string) *Store {
|
||||
os.MkdirAll(dir, 0755)
|
||||
return &Store{dir: dir}
|
||||
}
|
||||
|
||||
// path returns the file path for a conversation ID
|
||||
func (s *Store) path(id string) string {
|
||||
return filepath.Join(s.dir, id+".json")
|
||||
}
|
||||
|
||||
// botPath returns the directory path for a specific bot
|
||||
func (s *Store) botPath(botID uint64) string {
|
||||
return filepath.Join(s.dir, fmt.Sprintf("bot_%d", botID))
|
||||
}
|
||||
|
||||
// Create creates a new conversation for a bot
|
||||
func (s *Store) Create(botID uint64, botNodeID string) (*message.Conversation, error) {
|
||||
return s.CreateWithPreset(botID, botNodeID, "")
|
||||
}
|
||||
|
||||
// CreateWithPreset creates a new conversation with a preset prompt
|
||||
func (s *Store) CreateWithPreset(botID uint64, botNodeID string, preset string) (*message.Conversation, error) {
|
||||
conv := &message.Conversation{
|
||||
ID: generateConversationID(botID),
|
||||
BotID: botID,
|
||||
BotNodeID: botNodeID,
|
||||
Title: "新对话",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
PresetPrompt: strings.TrimSpace(preset),
|
||||
}
|
||||
if err := s.Save(conv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
// Save saves a conversation to disk
|
||||
func (s *Store) Save(conv *message.Conversation) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
conv.UpdatedAt = time.Now()
|
||||
return atomicWriteJSON(s.path(conv.ID), conv)
|
||||
}
|
||||
|
||||
// Get retrieves a conversation by ID
|
||||
func (s *Store) Get(id string) (*message.Conversation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
data, err := os.ReadFile(s.path(id))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, errors.New("对话不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("读取对话失败: %w", err)
|
||||
}
|
||||
var conv message.Conversation
|
||||
if err := json.Unmarshal(data, &conv); err != nil {
|
||||
return nil, fmt.Errorf("解析对话失败: %w", err)
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
// GetOrCreateForBot gets or creates a conversation for a bot
|
||||
func (s *Store) GetOrCreateForBot(botID uint64, botNodeID string, peerNodeID string) (*message.Conversation, error) {
|
||||
// Try to find an existing conversation with this peer
|
||||
convs, err := s.ListForBot(botID)
|
||||
if err == nil && len(convs) > 0 {
|
||||
// Use the most recent conversation (List already sorts by UpdatedAt desc)
|
||||
// Note: List returns convs with Messages = nil, so we need to reload
|
||||
return s.Get(convs[0].ID)
|
||||
}
|
||||
// Create a new conversation
|
||||
return s.Create(botID, botNodeID)
|
||||
}
|
||||
|
||||
// List returns all conversations
|
||||
func (s *Store) List() ([]message.Conversation, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取对话目录失败: %w", err)
|
||||
}
|
||||
|
||||
var list []message.Conversation
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var conv message.Conversation
|
||||
if err := json.Unmarshal(data, &conv); err != nil {
|
||||
continue
|
||||
}
|
||||
conv.Messages = nil // Don't return messages in list
|
||||
list = append(list, conv)
|
||||
}
|
||||
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].UpdatedAt.After(list[j].UpdatedAt)
|
||||
})
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// ListForBot returns all conversations for a specific bot
|
||||
func (s *Store) ListForBot(botID uint64) ([]message.Conversation, error) {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var filtered []message.Conversation
|
||||
for _, conv := range all {
|
||||
if conv.BotID == botID {
|
||||
filtered = append(filtered, conv)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Delete deletes a conversation by ID
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := os.Remove(s.path(id)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除对话失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteForBot deletes all conversations for a bot
|
||||
func (s *Store) DeleteForBot(botID uint64) error {
|
||||
convs, err := s.ListForBot(botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, conv := range convs {
|
||||
_ = s.Delete(conv.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMessage adds a message to a conversation
|
||||
func (s *Store) AddMessage(convID string, msg message.ChatMessage) error {
|
||||
conv, err := s.Get(convID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conv.Messages = append(conv.Messages, msg)
|
||||
if conv.Title == "" || conv.Title == "新对话" {
|
||||
conv.Title = GenerateTitle(conv.Messages)
|
||||
}
|
||||
return s.Save(conv)
|
||||
}
|
||||
|
||||
// PopLastMessage 移除会话中最后一条消息(例如被话题选择丢弃、不应保留在上下文里的用户消息)。
|
||||
// 若会话没有消息则不做任何改动。返回移除的消息内容,便于调用方记录日志。
|
||||
func (s *Store) PopLastMessage(convID string) (message.ChatMessage, error) {
|
||||
conv, err := s.Get(convID)
|
||||
if err != nil {
|
||||
return message.ChatMessage{}, err
|
||||
}
|
||||
if len(conv.Messages) == 0 {
|
||||
return message.ChatMessage{}, nil
|
||||
}
|
||||
last := conv.Messages[len(conv.Messages)-1]
|
||||
conv.Messages = conv.Messages[:len(conv.Messages)-1]
|
||||
// 若弹出后没有消息了,重置标题,避免残留被丢弃消息的文本。
|
||||
if len(conv.Messages) == 0 {
|
||||
conv.Title = "新对话"
|
||||
}
|
||||
return last, s.Save(conv)
|
||||
}
|
||||
|
||||
// atomicWriteJSON writes JSON to a file atomically
|
||||
func atomicWriteJSON(path string, v any) error {
|
||||
tmp := path + ".tmp"
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// GenerateTitle generates a title from conversation messages
|
||||
func GenerateTitle(messages []message.ChatMessage) string {
|
||||
for _, m := range messages {
|
||||
if m.Hidden {
|
||||
continue
|
||||
}
|
||||
if m.Role == "user" && strings.TrimSpace(m.Content) != "" {
|
||||
title := strings.TrimSpace(m.Content)
|
||||
title = strings.ReplaceAll(title, "\r\n", " ")
|
||||
title = strings.ReplaceAll(title, "\n", " ")
|
||||
runes := []rune(title)
|
||||
if len(runes) > 30 {
|
||||
return string(runes[:30]) + "..."
|
||||
}
|
||||
return title
|
||||
}
|
||||
}
|
||||
return "新对话"
|
||||
}
|
||||
|
||||
// generateConversationID generates a unique conversation ID
|
||||
func generateConversationID(botID uint64) string {
|
||||
return fmt.Sprintf("bot_%d_%d", botID, time.Now().UnixNano())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package help
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"meshtastic_mqtt_server/internal/auth"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
type helpContentRequest struct {
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
func registerHelpRoutes(r gin.IRouter, store *store) {
|
||||
// RegisterPublicRoutes 把对外可见的 GET /help 挂到给定路由组下。
|
||||
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
@@ -24,7 +28,8 @@ func registerHelpRoutes(r gin.IRouter, store *store) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
||||
// RegisterAdminRoutes 注册管理员侧 /help、/help、/help/preview 这三条路由。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/help", func(c *gin.Context) {
|
||||
item, err := latestHelpContentDTO(store)
|
||||
if err != nil {
|
||||
@@ -39,7 +44,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help content request"})
|
||||
return
|
||||
}
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
claims := c.MustGet(auth.AdminClaimsKey).(*auth.SessionClaims)
|
||||
row, err := store.InsertHelpContent(req.Markdown, claims.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -58,7 +63,7 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid help preview request"})
|
||||
return
|
||||
}
|
||||
html, err := renderHelpMarkdown(req.Markdown)
|
||||
html, err := RenderMarkdown(req.Markdown)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -67,10 +72,10 @@ func registerAdminHelpRoutes(r gin.IRouter, store *store) {
|
||||
})
|
||||
}
|
||||
|
||||
func latestHelpContentDTO(store *store) (gin.H, error) {
|
||||
func latestHelpContentDTO(store *storepkg.Store) (gin.H, error) {
|
||||
row, err := store.GetLatestHelpContent()
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return helpContentDTO(0, defaultHelpMarkdown, "", nil)
|
||||
return helpContentDTO(0, storepkg.DefaultHelpMarkdown, "", nil)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -79,7 +84,7 @@ func latestHelpContentDTO(store *store) (gin.H, error) {
|
||||
}
|
||||
|
||||
func helpContentDTO(id uint64, markdown, createdBy string, createdAt *time.Time) (gin.H, error) {
|
||||
html, err := renderHelpMarkdown(markdown)
|
||||
html, err := RenderMarkdown(markdown)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package help
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
|
||||
func renderHelpMarkdown(markdown string) (string, error) {
|
||||
// RenderMarkdown 把 GFM markdown 转成净化后的 HTML,供 admin 编辑器预览
|
||||
// 与 /help 路由直接渲染。
|
||||
func RenderMarkdown(markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
@@ -0,0 +1,271 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ark "github.com/volcengine/volcengine-go-sdk/service/arkruntime"
|
||||
)
|
||||
|
||||
// Profile represents an LLM provider configuration with client
|
||||
type Profile struct {
|
||||
Config ProviderConfig
|
||||
Client *ark.Client
|
||||
}
|
||||
|
||||
// ProviderConfig holds the configuration for an LLM provider
|
||||
type ProviderConfig struct {
|
||||
Name string
|
||||
Active bool
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
Timeout int
|
||||
ContextWindowTokens int
|
||||
}
|
||||
|
||||
// State manages LLM profiles
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*Profile
|
||||
order []string
|
||||
activeName string
|
||||
}
|
||||
|
||||
// NewState creates a new LLM state from provider configurations
|
||||
func NewState(configs []ProviderConfig) (*State, error) {
|
||||
state := &State{
|
||||
profiles: make(map[string]*Profile, len(configs)),
|
||||
order: make([]string, 0, len(configs)),
|
||||
}
|
||||
for _, item := range configs {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(item.APIKey) == "" {
|
||||
return nil, fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(item.Model) == "" {
|
||||
return nil, fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(item.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if item.Timeout <= 0 {
|
||||
item.Timeout = 120
|
||||
}
|
||||
if _, ok := state.profiles[name]; ok {
|
||||
return nil, fmt.Errorf("duplicate llm provider name: %s", name)
|
||||
}
|
||||
state.profiles[name] = &Profile{
|
||||
Config: item,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
item.APIKey,
|
||||
ark.WithBaseUrl(item.BaseURL),
|
||||
ark.WithTimeout(time.Duration(item.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
state.order = append(state.order, name)
|
||||
if item.Active && state.activeName == "" {
|
||||
state.activeName = name
|
||||
}
|
||||
}
|
||||
if len(state.order) == 0 {
|
||||
return nil, errors.New("at least one llm provider is required")
|
||||
}
|
||||
if state.activeName == "" {
|
||||
state.activeName = state.order[0]
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// ActiveProfile returns the currently active LLM profile
|
||||
func (s *State) ActiveProfile() *Profile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.profiles[s.activeName]
|
||||
}
|
||||
|
||||
// GetProfile returns a profile by name, or the active profile if name is empty
|
||||
func (s *State) GetProfile(name string) (*Profile, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return s.profiles[s.activeName], nil
|
||||
}
|
||||
profile, ok := s.profiles[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// SwitchActive changes the active LLM profile
|
||||
func (s *State) SwitchActive(name string) (*Profile, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
profile, ok := s.profiles[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
s.activeName = name
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// ListProfiles returns all LLM profiles
|
||||
func (s *State) ListProfiles() []ProviderConfig {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
profiles := make([]ProviderConfig, 0, len(s.order))
|
||||
for _, name := range s.order {
|
||||
profile := s.profiles[name]
|
||||
cfg := profile.Config
|
||||
cfg.APIKey = "" // Hide API key in listings
|
||||
cfg.Active = name == s.activeName
|
||||
profiles = append(profiles, cfg)
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
// UpdateProvider updates an existing provider's configuration
|
||||
func (s *State) UpdateProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Create new client with updated config
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
|
||||
// Update active status if needed
|
||||
if config.Active && s.activeName != name {
|
||||
s.activeName = name
|
||||
} else if !config.Active && s.activeName == name {
|
||||
// If we're deactivating the current active provider, switch to the first available
|
||||
for _, otherName := range s.order {
|
||||
if otherName != name {
|
||||
s.activeName = otherName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProvider adds a new provider to the state
|
||||
func (s *State) AddProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; ok {
|
||||
return fmt.Errorf("llm provider already exists: %s", name)
|
||||
}
|
||||
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
s.order = append(s.order, name)
|
||||
|
||||
// Set as active if it's the first one or explicitly marked active
|
||||
if len(s.profiles) == 1 || config.Active {
|
||||
s.activeName = name
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProvider removes a provider from the state
|
||||
func (s *State) RemoveProvider(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Don't allow removing the last provider
|
||||
if len(s.profiles) == 1 {
|
||||
return errors.New("cannot remove the last llm provider")
|
||||
}
|
||||
|
||||
delete(s.profiles, name)
|
||||
|
||||
// Remove from order
|
||||
newOrder := make([]string, 0, len(s.order)-1)
|
||||
for _, n := range s.order {
|
||||
if n != name {
|
||||
newOrder = append(newOrder, n)
|
||||
}
|
||||
}
|
||||
s.order = newOrder
|
||||
|
||||
// If we removed the active provider, switch to the first available
|
||||
if s.activeName == name {
|
||||
s.activeName = s.order[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
package llmadmin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
aipkg "meshtastic_mqtt_server/internal/ai"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||
type LLMProviderReloader interface {
|
||||
ReloadLLMProvider(config interface{}) error
|
||||
AddLLMProvider(config interface{}) error
|
||||
RemoveLLMProvider(name string) error
|
||||
AIServiceStatus() aipkg.AIServiceStatus
|
||||
RestartAIService() error
|
||||
}
|
||||
|
||||
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProviderReloader) {
|
||||
group := r.Group("/llm")
|
||||
{
|
||||
// LLM Message Queue
|
||||
group.GET("/messages", handleListLLMMessages(store))
|
||||
group.GET("/messages/:id", handleGetLLMMessage(store))
|
||||
group.PUT("/messages/:id/status", handleUpdateLLMMessageStatus(store))
|
||||
group.DELETE("/messages/:id", handleDeleteLLMMessage(store))
|
||||
group.DELETE("/bots/:bot_id/messages", handleDeleteLLMMessagesByBot(store))
|
||||
group.POST("/messages/cleanup", handleCleanupDeletedLLMMessages(store))
|
||||
|
||||
// LLM Providers
|
||||
group.GET("/providers", handleListLLMProviders(store))
|
||||
group.GET("/providers/:name", handleGetLLMProvider(store))
|
||||
group.POST("/providers", handleCreateLLMProvider(store, aiService))
|
||||
group.PUT("/providers/:name", handleUpdateLLMProvider(store, aiService))
|
||||
group.DELETE("/providers/:name", handleDeleteLLMProvider(store, aiService))
|
||||
|
||||
// LLM Tool Router
|
||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||
group.PUT("/tool-router", handleUpdateLLMToolRouter(store))
|
||||
|
||||
// LLM Topic Config - 话题选择配置
|
||||
group.GET("/topic-config", handleGetLLMTopicConfig(store))
|
||||
group.PUT("/topic-config", handleUpdateLLMTopicConfig(store))
|
||||
|
||||
// LLM Primary Config - 主 AI 回复配置
|
||||
group.GET("/primary-config", handleGetLLMPrimaryConfig(store))
|
||||
group.PUT("/primary-config", handleUpdateLLMPrimaryConfig(store))
|
||||
|
||||
// AI Service Status
|
||||
group.GET("/status", handleGetAIServiceStatus(aiService))
|
||||
group.POST("/restart", handleRestartAIService(aiService))
|
||||
}
|
||||
}
|
||||
|
||||
func handleListLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var botID uint64
|
||||
if botIDStr := c.Query("bot_id"); botIDStr != "" {
|
||||
id, err := strconv.ParseUint(botIDStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot_id"})
|
||||
return
|
||||
}
|
||||
botID = id
|
||||
}
|
||||
|
||||
includeDeleted := c.Query("include_deleted") == "true"
|
||||
|
||||
rows, total, err := store.ListLLMMessages(opts, botID, includeDeleted)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, storepkg.LLMMessageDTO(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"limit": opts.Limit,
|
||||
"offset": opts.Offset,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetLLMMessage(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMMessage(id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": storepkg.LLMMessageDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMMessageStatus(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证状态值
|
||||
validStatus := map[string]bool{
|
||||
storepkg.LLMMessageStatusPending: true,
|
||||
storepkg.LLMMessageStatusProcessing: true,
|
||||
storepkg.LLMMessageStatusProcessed: true,
|
||||
storepkg.LLMMessageStatusError: true,
|
||||
}
|
||||
if !validStatus[req.Status] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status value"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.UpdateLLMMessageStatus(id, req.Status, req.Error); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteLLMMessage(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid message id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SoftDeleteLLMMessage(id); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteLLMMessagesByBot(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
botID, err := strconv.ParseUint(c.Param("bot_id"), 10, 64)
|
||||
if err != nil || botID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bot id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SoftDeleteLLMMessagesByBot(botID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCleanupDeletedLLMMessages(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req.Days = 7 // 默认清理 7 天前删除的消息
|
||||
}
|
||||
if req.Days <= 0 {
|
||||
req.Days = 7
|
||||
}
|
||||
|
||||
before := time.Now().AddDate(0, 0, -req.Days)
|
||||
count, err := store.CleanupDeletedLLMMessages(before)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Provider Handlers
|
||||
// ============================================
|
||||
|
||||
func handleListLLMProviders(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
rows, err := store.ListLLMProviders(includeInactive)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, llmProviderDTO(row))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMProvider(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
Timeout int `json:"timeout"`
|
||||
ContextWindowTokens int `json:"context_window_tokens"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
record := &storepkg.LLMProviderRecord{
|
||||
Name: req.Name,
|
||||
Active: req.Active,
|
||||
APIKey: req.APIKey,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
Timeout: req.Timeout,
|
||||
ContextWindowTokens: req.ContextWindowTokens,
|
||||
}
|
||||
|
||||
if err := store.CreateLLMProvider(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload AI service with new provider
|
||||
if aiService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "AI 服务未运行,配置已保存但需重启服务后生效",
|
||||
})
|
||||
return
|
||||
}
|
||||
providerConfig := map[string]interface{}{
|
||||
"Name": record.Name,
|
||||
"Active": record.Active,
|
||||
"APIKey": record.APIKey,
|
||||
"BaseURL": record.BaseURL,
|
||||
"Model": record.Model,
|
||||
"Timeout": record.Timeout,
|
||||
"ContextWindowTokens": record.ContextWindowTokens,
|
||||
}
|
||||
if err := aiService.AddLLMProvider(providerConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "provider created but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Active *bool `json:"active"`
|
||||
APIKey *string `json:"api_key"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Timeout *int `json:"timeout"`
|
||||
ContextWindowTokens *int `json:"context_window_tokens"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Active != nil {
|
||||
updates["active"] = *req.Active
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
updates["api_key"] = *req.APIKey
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
updates["base_url"] = *req.BaseURL
|
||||
}
|
||||
if req.Model != nil {
|
||||
updates["model"] = *req.Model
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.ContextWindowTokens != nil {
|
||||
updates["context_window_tokens"] = *req.ContextWindowTokens
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.UpdateLLMProvider(name, updates); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
record, err := store.GetLLMProvider(name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload AI service with updated provider
|
||||
if aiService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "AI 服务未运行,配置已保存但需重启服务后生效",
|
||||
})
|
||||
return
|
||||
}
|
||||
providerConfig := map[string]interface{}{
|
||||
"Name": record.Name,
|
||||
"Active": record.Active,
|
||||
"APIKey": record.APIKey,
|
||||
"BaseURL": record.BaseURL,
|
||||
"Model": record.Model,
|
||||
"Timeout": record.Timeout,
|
||||
"ContextWindowTokens": record.ContextWindowTokens,
|
||||
}
|
||||
if err := aiService.ReloadLLMProvider(providerConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"item": llmProviderDTO(*record),
|
||||
"warning": "provider updated but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.DeleteLLMProvider(name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove provider from AI service
|
||||
if aiService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"warning": "AI 服务未运行,配置已删除但需重启服务后生效",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := aiService.RemoveLLMProvider(name); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"warning": "provider deleted but failed to reload AI service: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
func llmProviderDTO(row storepkg.LLMProviderRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"name": row.Name,
|
||||
"active": row.Active,
|
||||
"base_url": row.BaseURL,
|
||||
"model": row.Model,
|
||||
"timeout": row.Timeout,
|
||||
"context_window_tokens": row.ContextWindowTokens,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Tool Router Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMToolRouter()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tool router config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmToolRouterDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMToolRouter(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMToolRouter()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
OpenAIName *string `json:"openai_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
updates["openai_name"] = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMToolRouterRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "",
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
newRecord.OpenAIName = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if err := store.CreateLLMToolRouter(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMToolRouter(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMToolRouter()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmToolRouterDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmToolRouterDTO(row storepkg.LLMToolRouterRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"openai_name": row.OpenAIName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Topic Config Handlers - 话题选择配置
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "topic config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmTopicConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMTopicConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMTopicConfig()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
OpenAIName *string `json:"openai_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
updates["openai_name"] = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMTopicConfigRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "",
|
||||
}
|
||||
if req.OpenAIName != nil {
|
||||
newRecord.OpenAIName = *req.OpenAIName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if err := store.CreateLLMTopicConfig(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMTopicConfig(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMTopicConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmTopicConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmTopicConfigDTO(row storepkg.LLMTopicConfigRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"openai_name": row.OpenAIName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "primary config not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateLLMPrimaryConfig(store *storepkg.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
record, err := store.GetLLMPrimaryConfig()
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
ProviderName *string `json:"provider_name"`
|
||||
Timeout *int `json:"timeout"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
EnableTool *bool `json:"enable_tool"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]any)
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
updates["provider_name"] = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
updates["timeout"] = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
updates["max_tokens"] = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
updates["system_prompt"] = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
updates["enable_tool"] = *req.EnableTool
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
// 创建新配置
|
||||
newRecord := &storepkg.LLMPrimaryConfigRecord{
|
||||
Enabled: req.Enabled != nil && *req.Enabled,
|
||||
ProviderName: "",
|
||||
Timeout: 120,
|
||||
MaxTokens: 1024,
|
||||
SystemPrompt: "",
|
||||
EnableTool: false,
|
||||
}
|
||||
if req.ProviderName != nil {
|
||||
newRecord.ProviderName = *req.ProviderName
|
||||
}
|
||||
if req.Timeout != nil {
|
||||
newRecord.Timeout = *req.Timeout
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
newRecord.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
newRecord.SystemPrompt = *req.SystemPrompt
|
||||
}
|
||||
if req.EnableTool != nil {
|
||||
newRecord.EnableTool = *req.EnableTool
|
||||
}
|
||||
if err := store.CreateLLMPrimaryConfig(newRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record = newRecord
|
||||
} else {
|
||||
// 更新现有配置
|
||||
if err := store.UpdateLLMPrimaryConfig(record.ID, updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err = store.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmPrimaryConfigDTO(*record)})
|
||||
}
|
||||
}
|
||||
|
||||
func llmPrimaryConfigDTO(row storepkg.LLMPrimaryConfigRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"enabled": row.Enabled,
|
||||
"provider_name": row.ProviderName,
|
||||
"timeout": row.Timeout,
|
||||
"max_tokens": row.MaxTokens,
|
||||
"system_prompt": row.SystemPrompt,
|
||||
"enable_tool": row.EnableTool,
|
||||
"created_at": row.CreatedAt,
|
||||
"updated_at": row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AI Service Status & Restart Handlers
|
||||
// ============================================
|
||||
|
||||
func handleGetAIServiceStatus(aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if aiService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"running": false,
|
||||
"enabled": false,
|
||||
"provider_count": 0,
|
||||
"message": "AI 服务未初始化",
|
||||
})
|
||||
return
|
||||
}
|
||||
status := aiService.AIServiceStatus()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"running": status.Running,
|
||||
"enabled": status.Enabled,
|
||||
"provider_count": status.ProviderCount,
|
||||
"message": status.Message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRestartAIService(aiService LLMProviderReloader) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if aiService == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 服务未初始化,无法重启"})
|
||||
return
|
||||
}
|
||||
if err := aiService.RestartAIService(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重启 AI 服务失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
status := aiService.AIServiceStatus()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"running": status.Running,
|
||||
"enabled": status.Enabled,
|
||||
"provider_count": status.ProviderCount,
|
||||
"message": "AI 服务已重启",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
package main
|
||||
// Package mapsource 提供地图瓦片源的 admin 与公开路由。
|
||||
package mapsource
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +8,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type mapTileSourceRequest struct {
|
||||
@@ -19,14 +23,15 @@ type mapTileSourceRequest struct {
|
||||
ProxyEnabled bool `json:"proxy_enabled"`
|
||||
}
|
||||
|
||||
func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
||||
// RegisterPublicRoutes 把对外可见的 GET /map-source/{default,enabled} 挂上去。
|
||||
func RegisterPublicRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/map-source/default", func(c *gin.Context) {
|
||||
row, err := store.GetDefaultMapTileSource()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": publicMapTileSourceDTO(*row)})
|
||||
c.JSON(http.StatusOK, gin.H{"item": PublicDTO(*row)})
|
||||
})
|
||||
r.GET("/map-source/enabled", func(c *gin.Context) {
|
||||
rows, err := store.ListEnabledMapTileSources()
|
||||
@@ -36,25 +41,26 @@ func registerMapSourceRoutes(r gin.IRouter, store *store) {
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, publicMapTileSourceDTO(row))
|
||||
items = append(items, PublicDTO(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
||||
// RegisterAdminRoutes 注册管理员侧 CRUD 与设默认。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/map-source", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMapTileSources(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, mapTileSourceDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, AdminDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMapTileSources(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, mapTileSourceDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, AdminDTO)
|
||||
})
|
||||
r.POST("/map-source", func(c *gin.Context) {
|
||||
var req mapTileSourceRequest
|
||||
@@ -95,8 +101,8 @@ func registerAdminMapSourceRoutes(r gin.IRouter, store *store) {
|
||||
})
|
||||
}
|
||||
|
||||
func mapTileSourceInputFromRequest(req mapTileSourceRequest) mapTileSourceInput {
|
||||
return mapTileSourceInput{
|
||||
func mapTileSourceInputFromRequest(req mapTileSourceRequest) storepkg.MapTileSourceInput {
|
||||
return storepkg.MapTileSourceInput{
|
||||
Name: req.Name,
|
||||
URLTemplate: req.URLTemplate,
|
||||
Attribution: req.Attribution,
|
||||
@@ -116,8 +122,8 @@ func parseMapTileSourceID(c *gin.Context) (uint64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTileSourceRecord, err error) {
|
||||
if errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||
func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *storepkg.MapTileSourceRecord, err error) {
|
||||
if errors.Is(err, storepkg.ErrMapTileSourceAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "map source already exists"})
|
||||
return
|
||||
}
|
||||
@@ -125,7 +131,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errMapTileSourceCannotDeleteDefault) || errors.Is(err, errMapTileSourceCannotDisableDefault) || errors.Is(err, errMapTileSourceDefaultMustBeEnabled) {
|
||||
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) || errors.Is(err, storepkg.ErrMapTileSourceCannotDisableDefault) || errors.Is(err, storepkg.ErrMapTileSourceDefaultMustBeEnabled) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -133,7 +139,7 @@ func writeMapTileSourceMutationResponse(c *gin.Context, status int, row *mapTile
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"item": mapTileSourceDTO(*row)})
|
||||
c.JSON(status, gin.H{"item": AdminDTO(*row)})
|
||||
}
|
||||
|
||||
func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||
@@ -141,7 +147,7 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "map source not found"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
||||
if errors.Is(err, storepkg.ErrMapTileSourceCannotDeleteDefault) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -152,16 +158,19 @@ func writeMapTileSourceDeleteResponse(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func mapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
||||
// AdminDTO 是管理后台展示的全字段视图。
|
||||
func AdminDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func publicMapTileSourceDTO(row mapTileSourceRecord) gin.H {
|
||||
// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为
|
||||
// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。
|
||||
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||
urlTemplate := row.URLTemplate
|
||||
if row.ProxyEnabled {
|
||||
hash := row.URLTemplateHash
|
||||
if hash == "" {
|
||||
hash = mapTileSourceHash(row.URLTemplate)
|
||||
hash = storepkg.MapTileSourceHash(row.URLTemplate)
|
||||
}
|
||||
urlTemplate = "/api/map/" + hash + "?x={x}&y={y}&z={z}"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package message
|
||||
|
||||
import "time"
|
||||
|
||||
// ChatMessage represents a single message in a conversation
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ImageURLAlias string `json:"image_url,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
// Conversation represents a chat conversation with a bot
|
||||
type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
BotID uint64 `json:"bot_id"`
|
||||
BotNodeID string `json:"bot_node_id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PresetPrompt string `json:"preset_prompt,omitempty"`
|
||||
Messages []ChatMessage `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
// ConversationPreset represents a preset configuration for a bot's conversation
|
||||
type ConversationPreset struct {
|
||||
ID uint64 `json:"id"`
|
||||
BotID uint64 `json:"bot_id"`
|
||||
Name string `json:"name"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type PacketBuildOptions struct {
|
||||
PSK []byte
|
||||
Encrypt bool
|
||||
ViaMQTT bool
|
||||
HopLimit uint32
|
||||
}
|
||||
|
||||
type TextMessageBuildOptions struct {
|
||||
@@ -252,6 +253,14 @@ func buildMeshPacket(opts PacketBuildOptions, data []byte) ([]byte, error) {
|
||||
out = protowire.AppendTag(out, 14, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, 1)
|
||||
}
|
||||
hop := opts.HopLimit
|
||||
if hop == 0 {
|
||||
hop = 7
|
||||
}
|
||||
out = protowire.AppendTag(out, 9, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(hop))
|
||||
out = protowire.AppendTag(out, 15, protowire.VarintType)
|
||||
out = protowire.AppendVarint(out, uint64(hop))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package mqttforward
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type mqttForwarderRequest struct {
|
||||
@@ -38,19 +41,19 @@ type mqttForwardTopicRequest struct {
|
||||
Retain bool `json:"retain"`
|
||||
}
|
||||
|
||||
func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttForwardReloader) {
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, forwarder Reloader) {
|
||||
r.GET("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMQTTForwarders(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, mqttForwarderDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, mqttForwarderDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMQTTForwarders(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwarderDTO)
|
||||
})
|
||||
r.POST("/mqtt-forward/forwarders", func(c *gin.Context) {
|
||||
var req mqttForwarderRequest
|
||||
@@ -106,17 +109,17 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
opts, ok := parseListOptions(c)
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListMQTTForwardTopics(id, opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
||||
webutil.WriteListResponse(c, rows, opts, err, mqttForwardTopicDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountMQTTForwardTopics(id)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, mqttForwardTopicDTO)
|
||||
})
|
||||
r.POST("/mqtt-forward/forwarders/:id/topics", func(c *gin.Context) {
|
||||
id, ok := parseMQTTForwardID(c, "invalid mqtt forwarder id")
|
||||
@@ -168,7 +171,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
||||
})
|
||||
})
|
||||
r.GET("/mqtt-forward/status", func(c *gin.Context) {
|
||||
items := []mqttForwardRuntimeStatus{}
|
||||
items := []RuntimeStatus{}
|
||||
if forwarder != nil {
|
||||
items = forwarder.Status()
|
||||
}
|
||||
@@ -176,7 +179,7 @@ func registerAdminMQTTForwardRoutes(r gin.IRouter, store *store, forwarder mqttF
|
||||
})
|
||||
}
|
||||
|
||||
func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput {
|
||||
func mqttForwarderInputFromRequest(req mqttForwarderRequest) storepkg.MQTTForwarderInput {
|
||||
sourcePassword := req.SourcePassword
|
||||
if req.SourcePasswordClear {
|
||||
empty := ""
|
||||
@@ -187,11 +190,11 @@ func mqttForwarderInputFromRequest(req mqttForwarderRequest) mqttForwarderInput
|
||||
empty := ""
|
||||
targetPassword = &empty
|
||||
}
|
||||
return mqttForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
||||
return storepkg.MQTTForwarderInput{Name: req.Name, Enabled: req.Enabled, SourceHost: req.SourceHost, SourcePort: req.SourcePort, SourceUsername: req.SourceUsername, SourcePassword: sourcePassword, SourceClientID: req.SourceClientID, SourceTLS: req.SourceTLS, TargetHost: req.TargetHost, TargetPort: req.TargetPort, TargetUsername: req.TargetUsername, TargetPassword: targetPassword, TargetClientID: req.TargetClientID, TargetTLS: req.TargetTLS}
|
||||
}
|
||||
|
||||
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) mqttForwardTopicInput {
|
||||
return mqttForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
||||
func mqttForwardTopicInputFromRequest(req mqttForwardTopicRequest) storepkg.MQTTForwardTopicInput {
|
||||
return storepkg.MQTTForwardTopicInput{Topic: req.Topic, Enabled: req.Enabled, Direction: req.Direction, SourcePrefix: req.SourcePrefix, TargetPrefix: req.TargetPrefix, QoS: req.QoS, Retain: req.Retain}
|
||||
}
|
||||
|
||||
func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
||||
@@ -203,15 +206,15 @@ func parseMQTTForwardID(c *gin.Context, message string) (uint64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
func reloadMQTTForwarder(forwarder mqttForwardReloader, id uint64) error {
|
||||
func reloadMQTTForwarder(forwarder Reloader, id uint64) error {
|
||||
if forwarder == nil {
|
||||
return nil
|
||||
}
|
||||
return forwarder.ReloadForwarder(id)
|
||||
}
|
||||
|
||||
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwarderRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, errMQTTForwarderAlreadyExists) {
|
||||
func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwarderRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, storepkg.ErrMQTTForwarderAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forwarder already exists"})
|
||||
return
|
||||
}
|
||||
@@ -232,8 +235,8 @@ func writeMQTTForwardMutationResponse(c *gin.Context, status int, row *mqttForwa
|
||||
c.JSON(status, gin.H{"item": mqttForwarderDTO(*row)})
|
||||
}
|
||||
|
||||
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *mqttForwardTopicRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, errMQTTForwardTopicAlreadyExists) {
|
||||
func writeMQTTForwardTopicMutationResponse(c *gin.Context, status int, row *storepkg.MQTTForwardTopicRecord, err error, afterSuccess func() error) {
|
||||
if errors.Is(err, storepkg.ErrMQTTForwardTopicAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "mqtt forward topic already exists"})
|
||||
return
|
||||
}
|
||||
@@ -272,10 +275,10 @@ func writeMQTTForwardDeleteResponse(c *gin.Context, err error, afterSuccess func
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func mqttForwarderDTO(row mqttForwarderRecord) gin.H {
|
||||
func mqttForwarderDTO(row storepkg.MQTTForwarderRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "name": row.Name, "enabled": row.Enabled, "source_host": row.SourceHost, "source_port": row.SourcePort, "source_username": row.SourceUsername, "source_password_set": row.SourcePassword != "", "source_client_id": row.SourceClientID, "source_tls": row.SourceTLS, "target_host": row.TargetHost, "target_port": row.TargetPort, "target_username": row.TargetUsername, "target_password_set": row.TargetPassword != "", "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
func mqttForwardTopicDTO(row mqttForwardTopicRecord) gin.H {
|
||||
func mqttForwardTopicDTO(row storepkg.MQTTForwardTopicRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "forwarder_id": row.ForwarderID, "topic": row.Topic, "enabled": row.Enabled, "direction": row.Direction, "source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix, "qos": row.QoS, "retain": row.Retain, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package mqttforward
|
||||
|
||||
import "sync"
|
||||
|
||||
// ClientStats 在内存中维护每个 MQTT 客户端的收/发包数量。
|
||||
// key 取自 mqtt.Client.ID(broker 内部唯一标识),客户端断开时由调用方调用 Delete 清除,
|
||||
// 重新连接同一 client_id 会拿到一份新的零值计数,符合"断链就清空"。
|
||||
type ClientStats struct {
|
||||
mu sync.RWMutex
|
||||
all map[string]*clientCounter
|
||||
}
|
||||
|
||||
type clientCounter struct {
|
||||
In int64 // 客户端 → 服务器(broker 收到的报文数)
|
||||
Out int64 // 服务器 → 客户端(broker 发出的报文数)
|
||||
}
|
||||
|
||||
// NewClientStats 返回一个空的统计器。
|
||||
func NewClientStats() *ClientStats {
|
||||
return &ClientStats{all: make(map[string]*clientCounter)}
|
||||
}
|
||||
|
||||
// IncIn 在 broker 收到客户端报文时调用。clientID 为空直接忽略。
|
||||
func (s *ClientStats) IncIn(clientID string) {
|
||||
if s == nil || clientID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
c, ok := s.all[clientID]
|
||||
if !ok {
|
||||
c = &clientCounter{}
|
||||
s.all[clientID] = c
|
||||
}
|
||||
c.In++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// IncOut 在 broker 向客户端发出报文时调用。
|
||||
func (s *ClientStats) IncOut(clientID string) {
|
||||
if s == nil || clientID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
c, ok := s.all[clientID]
|
||||
if !ok {
|
||||
c = &clientCounter{}
|
||||
s.all[clientID] = c
|
||||
}
|
||||
c.Out++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get 返回指定 clientID 当前的收/发包数量;不存在时返回 0,0。
|
||||
func (s *ClientStats) Get(clientID string) (in, out int64) {
|
||||
if s == nil || clientID == "" {
|
||||
return 0, 0
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if c, ok := s.all[clientID]; ok {
|
||||
return c.In, c.Out
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Delete 在客户端断开连接时清除其计数。重新连接同一 clientID 会从 0 重新计起。
|
||||
func (s *ClientStats) Delete(clientID string) {
|
||||
if s == nil || clientID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
delete(s.all, clientID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mqttforward
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dedupTTL = 15 * time.Second
|
||||
|
||||
type DedupQueue struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]time.Time
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewDedupQueue() *DedupQueue {
|
||||
return &DedupQueue{
|
||||
entries: make(map[string]time.Time),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (dq *DedupQueue) TryForward(topic string, payload []byte) bool {
|
||||
hash := dedupHash(topic, payload)
|
||||
now := time.Now()
|
||||
dq.mu.Lock()
|
||||
defer dq.mu.Unlock()
|
||||
if expiry, ok := dq.entries[hash]; ok && now.Before(expiry) {
|
||||
return false
|
||||
}
|
||||
dq.entries[hash] = now.Add(dedupTTL)
|
||||
return true
|
||||
}
|
||||
|
||||
func (dq *DedupQueue) Start() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(dedupTTL)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
dq.cleanup()
|
||||
case <-dq.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (dq *DedupQueue) Stop() {
|
||||
close(dq.stopCh)
|
||||
}
|
||||
|
||||
func (dq *DedupQueue) Len() int {
|
||||
dq.mu.Lock()
|
||||
defer dq.mu.Unlock()
|
||||
return len(dq.entries)
|
||||
}
|
||||
|
||||
func (dq *DedupQueue) cleanup() {
|
||||
now := time.Now()
|
||||
dq.mu.Lock()
|
||||
defer dq.mu.Unlock()
|
||||
for hash, expiry := range dq.entries {
|
||||
if now.After(expiry) {
|
||||
delete(dq.entries, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dedupHash(topic string, payload []byte) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(topic))
|
||||
h.Write(payload)
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
package main
|
||||
package mqttforward
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
type meshtasticMessageStats struct {
|
||||
type Stats struct {
|
||||
forwarded atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
func (s *meshtasticMessageStats) IncForwarded() {
|
||||
func (s *Stats) IncForwarded() {
|
||||
if s != nil {
|
||||
s.forwarded.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *meshtasticMessageStats) IncDropped() {
|
||||
func (s *Stats) IncDropped() {
|
||||
if s != nil {
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *meshtasticMessageStats) Forwarded() int64 {
|
||||
func (s *Stats) Forwarded() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.forwarded.Load()
|
||||
}
|
||||
|
||||
func (s *meshtasticMessageStats) Dropped() int64 {
|
||||
func (s *Stats) Dropped() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
package mqttforward
|
||||
|
||||
import (
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
@@ -23,19 +24,19 @@ const (
|
||||
mqttForwardLoopMaxEntries = 10000
|
||||
)
|
||||
|
||||
type mqttForwardReloader interface {
|
||||
type Reloader interface {
|
||||
ReloadForwarder(id uint64) error
|
||||
StopForwarder(id uint64)
|
||||
Status() []mqttForwardRuntimeStatus
|
||||
Status() []RuntimeStatus
|
||||
}
|
||||
|
||||
type mqttForwardManager struct {
|
||||
store *store
|
||||
type Manager struct {
|
||||
store *storepkg.Store
|
||||
mu sync.Mutex
|
||||
runners map[uint64]*mqttForwardRunner
|
||||
runners map[uint64]*runner
|
||||
}
|
||||
|
||||
type mqttForwardRuntimeStatus struct {
|
||||
type RuntimeStatus struct {
|
||||
ForwarderID uint64 `json:"forwarder_id"`
|
||||
Running bool `json:"running"`
|
||||
SourceConnected bool `json:"source_connected"`
|
||||
@@ -46,8 +47,8 @@ type mqttForwardRuntimeStatus struct {
|
||||
MessagesDropped uint64 `json:"messages_dropped"`
|
||||
}
|
||||
|
||||
type mqttForwardRunner struct {
|
||||
config mqttForwarderConfig
|
||||
type runner struct {
|
||||
config storepkg.MQTTForwarderConfig
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
source pahomqtt.Client
|
||||
@@ -63,11 +64,11 @@ type mqttForwardRunner struct {
|
||||
loopCache map[string]time.Time
|
||||
}
|
||||
|
||||
func newMQTTForwardManager(store *store) *mqttForwardManager {
|
||||
return &mqttForwardManager{store: store, runners: make(map[uint64]*mqttForwardRunner)}
|
||||
func NewManager(store *storepkg.Store) *Manager {
|
||||
return &Manager{store: store, runners: make(map[uint64]*runner)}
|
||||
}
|
||||
|
||||
func (m *mqttForwardManager) StartFromStore() error {
|
||||
func (m *Manager) StartFromStore() error {
|
||||
configs, err := m.store.ListEnabledMQTTForwarderConfigs()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,7 +77,7 @@ func (m *mqttForwardManager) StartFromStore() error {
|
||||
if len(cfg.Topics) == 0 {
|
||||
continue
|
||||
}
|
||||
runner := newMQTTForwardRunner(cfg)
|
||||
runner := newRunner(cfg)
|
||||
runner.Start()
|
||||
m.mu.Lock()
|
||||
m.runners[cfg.Forwarder.ID] = runner
|
||||
@@ -85,7 +86,7 @@ func (m *mqttForwardManager) StartFromStore() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
||||
func (m *Manager) ReloadForwarder(id uint64) error {
|
||||
m.StopForwarder(id)
|
||||
cfg, err := m.store.GetMQTTForwarderConfig(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -97,7 +98,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
||||
if !cfg.Forwarder.Enabled || len(cfg.Topics) == 0 {
|
||||
return nil
|
||||
}
|
||||
runner := newMQTTForwardRunner(*cfg)
|
||||
runner := newRunner(*cfg)
|
||||
runner.Start()
|
||||
m.mu.Lock()
|
||||
m.runners[id] = runner
|
||||
@@ -105,7 +106,7 @@ func (m *mqttForwardManager) ReloadForwarder(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mqttForwardManager) StopForwarder(id uint64) {
|
||||
func (m *Manager) StopForwarder(id uint64) {
|
||||
m.mu.Lock()
|
||||
runner := m.runners[id]
|
||||
delete(m.runners, id)
|
||||
@@ -115,9 +116,9 @@ func (m *mqttForwardManager) StopForwarder(id uint64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mqttForwardManager) StopAll() {
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
||||
runners := make([]*runner, 0, len(m.runners))
|
||||
for id, runner := range m.runners {
|
||||
runners = append(runners, runner)
|
||||
delete(m.runners, id)
|
||||
@@ -128,14 +129,14 @@ func (m *mqttForwardManager) StopAll() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
|
||||
func (m *Manager) Status() []RuntimeStatus {
|
||||
m.mu.Lock()
|
||||
runners := make([]*mqttForwardRunner, 0, len(m.runners))
|
||||
runners := make([]*runner, 0, len(m.runners))
|
||||
for _, runner := range m.runners {
|
||||
runners = append(runners, runner)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
items := make([]mqttForwardRuntimeStatus, 0, len(runners))
|
||||
items := make([]RuntimeStatus, 0, len(runners))
|
||||
for _, runner := range runners {
|
||||
items = append(items, runner.Status())
|
||||
}
|
||||
@@ -143,19 +144,19 @@ func (m *mqttForwardManager) Status() []mqttForwardRuntimeStatus {
|
||||
return items
|
||||
}
|
||||
|
||||
func newMQTTForwardRunner(config mqttForwarderConfig) *mqttForwardRunner {
|
||||
func newRunner(config storepkg.MQTTForwarderConfig) *runner {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &mqttForwardRunner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
||||
return &runner{config: config, ctx: ctx, cancel: cancel, startedAt: time.Now(), loopCache: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) Start() {
|
||||
func (r *runner) Start() {
|
||||
r.source = r.newClient(true)
|
||||
r.target = r.newClient(false)
|
||||
r.connectClient(r.target, "target")
|
||||
r.connectClient(r.source, "source")
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) Stop() {
|
||||
func (r *runner) Stop() {
|
||||
r.cancel()
|
||||
if r.source != nil && r.source.IsConnected() {
|
||||
r.source.Disconnect(250)
|
||||
@@ -165,11 +166,11 @@ func (r *mqttForwardRunner) Stop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
|
||||
func (r *runner) Status() RuntimeStatus {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
started := r.startedAt
|
||||
return mqttForwardRuntimeStatus{
|
||||
return RuntimeStatus{
|
||||
ForwarderID: r.config.Forwarder.ID,
|
||||
Running: true,
|
||||
SourceConnected: r.sourceConnected,
|
||||
@@ -181,7 +182,7 @@ func (r *mqttForwardRunner) Status() mqttForwardRuntimeStatus {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
|
||||
func (r *runner) newClient(source bool) pahomqtt.Client {
|
||||
forwarder := r.config.Forwarder
|
||||
host, port, username, password, clientID, useTLS := forwarder.SourceHost, forwarder.SourcePort, forwarder.SourceUsername, forwarder.SourcePassword, forwarder.SourceClientID, forwarder.SourceTLS
|
||||
role := "source"
|
||||
@@ -222,7 +223,7 @@ func (r *mqttForwardRunner) newClient(source bool) pahomqtt.Client {
|
||||
return pahomqtt.NewClient(opts)
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string) {
|
||||
func (r *runner) connectClient(client pahomqtt.Client, label string) {
|
||||
token := client.Connect()
|
||||
if !token.WaitTimeout(2 * time.Second) {
|
||||
r.setError(label + " connect pending")
|
||||
@@ -233,11 +234,11 @@ func (r *mqttForwardRunner) connectClient(client pahomqtt.Client, label string)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
|
||||
func (r *runner) subscribe(client pahomqtt.Client, source bool) {
|
||||
for _, topic := range r.config.Topics {
|
||||
filter := topic.Topic
|
||||
if !source {
|
||||
if topic.Direction != mqttForwardDirectionBidirectional {
|
||||
if topic.Direction != storepkg.MQTTForwardDirectionBidirectional {
|
||||
continue
|
||||
}
|
||||
filter = mapMQTTForwardTopic(topic.Topic, topic.SourcePrefix, topic.TargetPrefix)
|
||||
@@ -256,7 +257,7 @@ func (r *mqttForwardRunner) subscribe(client pahomqtt.Client, source bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopicRecord, msg pahomqtt.Message) {
|
||||
func (r *runner) forwardMessage(fromSource bool, rule storepkg.MQTTForwardTopicRecord, msg pahomqtt.Message) {
|
||||
if r.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -269,7 +270,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
||||
return
|
||||
}
|
||||
toTopic := fromTopic
|
||||
forwardDirection := mqttForwardDirectionSourceToTarget
|
||||
forwardDirection := storepkg.MQTTForwardDirectionSourceToTarget
|
||||
if fromSource {
|
||||
toTopic = mapMQTTForwardTopic(fromTopic, rule.SourcePrefix, rule.TargetPrefix)
|
||||
} else {
|
||||
@@ -284,7 +285,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
||||
reverseDirection := mqttForwardDirectionTargetToSource
|
||||
if !fromSource {
|
||||
target = r.source
|
||||
reverseDirection = mqttForwardDirectionSourceToTarget
|
||||
reverseDirection = storepkg.MQTTForwardDirectionSourceToTarget
|
||||
}
|
||||
r.markSuppressed(reverseDirection, toTopic, fromTopic, msg.Payload(), rule.QoS, rule.Retain)
|
||||
token := target.Publish(toTopic, byte(rule.QoS), rule.Retain, msg.Payload())
|
||||
@@ -301,7 +302,7 @@ func (r *mqttForwardRunner) forwardMessage(fromSource bool, rule mqttForwardTopi
|
||||
r.incForwarded()
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
|
||||
func (r *runner) setConnected(source bool, connected bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if source {
|
||||
@@ -311,25 +312,25 @@ func (r *mqttForwardRunner) setConnected(source bool, connected bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) setError(message string) {
|
||||
func (r *runner) setError(message string) {
|
||||
r.mu.Lock()
|
||||
r.lastError = message
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) incForwarded() {
|
||||
func (r *runner) incForwarded() {
|
||||
r.mu.Lock()
|
||||
r.messagesForwarded++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) incDropped() {
|
||||
func (r *runner) incDropped() {
|
||||
r.mu.Lock()
|
||||
r.messagesDropped++
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
||||
func (r *runner) isSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) bool {
|
||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
@@ -346,7 +347,7 @@ func (r *mqttForwardRunner) isSuppressed(direction, fromTopic, toTopic string, p
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *mqttForwardRunner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
||||
func (r *runner) markSuppressed(direction, fromTopic, toTopic string, payload []byte, qos int, retain bool) {
|
||||
key := mqttForwardLoopKey(direction, fromTopic, toTopic, payload, qos, retain)
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
@@ -0,0 +1,71 @@
|
||||
package runtimesettings
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||
const llmQueueEnabledLabel = "Enable LLM message queue"
|
||||
const llmIncludeChannelLabel = "Include channel messages in LLM queue"
|
||||
|
||||
type runtimeSettingsRequest struct {
|
||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||
LLMQueueEnabled bool `json:"llm_queue_enabled"`
|
||||
LLMIncludeChannelMessages bool `json:"llm_include_channel_messages"`
|
||||
}
|
||||
|
||||
// RegisterRoutes 把 GET /runtime-settings 与 PUT /runtime-settings 挂到给定路由组下。
|
||||
func RegisterRoutes(r gin.IRouter, store *storepkg.Store, settings *Cache) {
|
||||
r.GET("/runtime-settings", func(c *gin.Context) {
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
|
||||
r.PUT("/runtime-settings", func(c *gin.Context) {
|
||||
var req runtimeSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueEnabled, req.LLMQueueEnabled, llmQueueEnabledLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(storepkg.RuntimeSettingLLMQueueIncludeChannel, req.LLMIncludeChannelMessages, llmIncludeChannelLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if settings != nil {
|
||||
if err := settings.Reload(store); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
}
|
||||
|
||||
func runtimeSettingsDTO(settings storepkg.RuntimeSettingsSnapshot) gin.H {
|
||||
return gin.H{
|
||||
"allow_encrypted_forwarding": settings.AllowEncryptedForwarding,
|
||||
"llm_queue_enabled": settings.LLMQueueEnabled,
|
||||
"llm_include_channel_messages": settings.LLMIncludeChannel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package runtimesettings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// Cache 把 runtime_settings 表中常用的开关缓存到内存中,避免每次拦截热路径
|
||||
// 都查 DB。AdminRoute 修改后通过 Reload 重新加载。
|
||||
type Cache struct {
|
||||
mu sync.RWMutex
|
||||
settings storepkg.RuntimeSettingsSnapshot
|
||||
}
|
||||
|
||||
// New 从 store 中加载初始快照并返回缓存。
|
||||
func New(s *storepkg.Store) (*Cache, error) {
|
||||
cache := &Cache{}
|
||||
if err := cache.Reload(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
// Reload 重新读取数据库快照覆盖当前值。
|
||||
func (c *Cache) Reload(s *storepkg.Store) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("store is required")
|
||||
}
|
||||
settings, err := s.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.settings = settings
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Snapshot 返回当前快照的副本(结构体拷贝,调用方可以安全持有)。
|
||||
func (c *Cache) Snapshot() storepkg.RuntimeSettingsSnapshot {
|
||||
if c == nil {
|
||||
return storepkg.RuntimeSettingsSnapshot{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.settings
|
||||
}
|
||||
|
||||
// AllowEncryptedForwarding 是 mqtt 转发热路径上常被检查的标志位的快捷读法。
|
||||
func (c *Cache) AllowEncryptedForwarding() bool {
|
||||
return c.Snapshot().AllowEncryptedForwarding
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package sign 提供签到记录的 admin 路由与对应的 DTO/列表查询。
|
||||
//
|
||||
// 拆离自原来 main 包的 admin_sign_routes.go 与 web.go 中的 signDTO /
|
||||
// signDayCountDTO;其它 admin 路由也通过 SignDTO / SignDayCountDTO 复用。
|
||||
package sign
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
type signRequest struct {
|
||||
NodeID string `json:"node_id"`
|
||||
LongName string `json:"long_name"`
|
||||
ShortName string `json:"short_name"`
|
||||
SignText string `json:"sign_text"`
|
||||
SignTime string `json:"sign_time"`
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 在 admin 路由组下挂 sign CRUD 端点。
|
||||
func RegisterAdminRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/signs", func(c *gin.Context) {
|
||||
opts, ok := webutil.ParseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListSigns(opts)
|
||||
if err != nil {
|
||||
webutil.WriteListResponse(c, rows, opts, err, SignDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountSigns(opts)
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, SignDTO)
|
||||
})
|
||||
r.POST("/signs", func(c *gin.Context) {
|
||||
var req signRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||
return
|
||||
}
|
||||
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.CreateSign(req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
|
||||
writeSignMutationResponse(c, http.StatusCreated, row, err)
|
||||
})
|
||||
r.PUT("/signs/:id", func(c *gin.Context) {
|
||||
id, ok := parseSignID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req signRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign request"})
|
||||
return
|
||||
}
|
||||
signTime, ok := parseSignRequestTime(c, req.SignTime)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
row, err := store.UpdateSign(id, req.NodeID, storepkg.NullableString(req.LongName), storepkg.NullableString(req.ShortName), req.SignText, signTime)
|
||||
writeSignMutationResponse(c, http.StatusOK, row, err)
|
||||
})
|
||||
r.DELETE("/signs/:id", func(c *gin.Context) {
|
||||
id, ok := parseSignID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := store.DeleteSign(id)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func parseSignID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign id"})
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseSignRequestTime(c *gin.Context, value string) (time.Time, bool) {
|
||||
if value == "" {
|
||||
return time.Time{}, true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sign_time: use RFC3339"})
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func writeSignMutationResponse(c *gin.Context, status int, row *storepkg.SignRecord, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "sign record not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(status, gin.H{"item": SignDTO(*row)})
|
||||
}
|
||||
|
||||
// SignDTO 把 SignRecord 转成给前端的视图。
|
||||
func SignDTO(row storepkg.SignRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "node_id": row.NodeID, "long_name": webutil.PtrString(row.LongName), "short_name": webutil.PtrString(row.ShortName), "sign_text": row.SignText, "sign_time": row.SignTime}
|
||||
}
|
||||
|
||||
// SignDayCountDTO 把按日聚合的签到数量转成视图。
|
||||
func SignDayCountDTO(row storepkg.SignDayCount) gin.H {
|
||||
return gin.H{"date": row.Date, "count": row.Count}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CountActiveNodes 统计指定时间后有更新记录的节点数量
|
||||
func (s *Store) CountActiveNodes(since time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&NodeInfoRecord{}).
|
||||
Where("updated_at >= ?", since).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// CountActiveUsers 统计指定时间后发送过消息的唯一用户数(按 from_id 去重)
|
||||
func (s *Store) CountActiveUsers(since time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&TextMessageRecord{}).
|
||||
Where("created_at >= ?", since).
|
||||
Distinct("from_id").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const forbiddenWordMatchContains = "contains"
|
||||
const ForbiddenWordMatchContains = "contains"
|
||||
|
||||
var errBlockingAlreadyExists = errors.New("blocking rule already exists")
|
||||
var ErrBlockingAlreadyExists = errors.New("blocking rule already exists")
|
||||
|
||||
func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []nodeBlockingRecord
|
||||
q := s.db.Model(&nodeBlockingRecord{}).
|
||||
func (s *Store) ListNodeBlocking(opts ListOptions) ([]NodeBlockingRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []NodeBlockingRecord
|
||||
q := s.db.Model(&NodeBlockingRecord{}).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
@@ -25,17 +25,17 @@ func (s *store) ListNodeBlocking(opts listOptions) ([]nodeBlockingRecord, error)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountNodeBlocking(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountNodeBlocking(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&nodeBlockingRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&NodeBlockingRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) ListEnabledNodeBlocking() ([]nodeBlockingRecord, error) {
|
||||
var rows []nodeBlockingRecord
|
||||
func (s *Store) ListEnabledNodeBlocking() ([]NodeBlockingRecord, error) {
|
||||
var rows []NodeBlockingRecord
|
||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
|
||||
func (s *Store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node id is required")
|
||||
@@ -43,14 +43,14 @@ func (s *store) CreateNodeBlocking(nodeID string, nodeNum *int64, reason string,
|
||||
if err := s.ensureNodeBlockingUnique(0, nodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := nodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
row := NodeBlockingRecord{NodeID: nodeID, NodeNum: nodeNum, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*nodeBlockingRecord, error) {
|
||||
func (s *Store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, reason string, enabled bool) (*NodeBlockingRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("blocking rule id is required")
|
||||
}
|
||||
@@ -65,14 +65,14 @@ func (s *store) UpdateNodeBlocking(id uint64, nodeID string, nodeNum *int64, rea
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"node_id": nodeID, "node_num": nodeNum, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||
if err := s.db.Model(&nodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&NodeBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getNodeBlockingByID(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteNodeBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&nodeBlockingRecord{})
|
||||
func (s *Store) DeleteNodeBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&NodeBlockingRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -82,10 +82,10 @@ func (s *store) DeleteNodeBlocking(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []ipBlockingRecord
|
||||
q := s.db.Model(&ipBlockingRecord{}).
|
||||
func (s *Store) ListIPBlocking(opts ListOptions) ([]IPBlockingRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []IPBlockingRecord
|
||||
q := s.db.Model(&IPBlockingRecord{}).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
@@ -93,17 +93,17 @@ func (s *store) ListIPBlocking(opts listOptions) ([]ipBlockingRecord, error) {
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountIPBlocking(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountIPBlocking(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&ipBlockingRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&IPBlockingRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) ListEnabledIPBlocking() ([]ipBlockingRecord, error) {
|
||||
var rows []ipBlockingRecord
|
||||
func (s *Store) ListEnabledIPBlocking() ([]IPBlockingRecord, error) {
|
||||
var rows []IPBlockingRecord
|
||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
|
||||
func (s *Store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) {
|
||||
value, err := normalizeIPBlockingValue(ipValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -111,14 +111,14 @@ func (s *store) CreateIPBlocking(ipValue string, reason string, enabled bool) (*
|
||||
if err := s.ensureIPBlockingUnique(0, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := ipBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
row := IPBlockingRecord{IPValue: value, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*ipBlockingRecord, error) {
|
||||
func (s *Store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabled bool) (*IPBlockingRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("blocking rule id is required")
|
||||
}
|
||||
@@ -133,14 +133,14 @@ func (s *store) UpdateIPBlocking(id uint64, ipValue string, reason string, enabl
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"ip_value": value, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||
if err := s.db.Model(&ipBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&IPBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getIPBlockingByID(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteIPBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&ipBlockingRecord{})
|
||||
func (s *Store) DeleteIPBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&IPBlockingRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -150,10 +150,10 @@ func (s *store) DeleteIPBlocking(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBlockingRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []forbiddenWordBlockingRecord
|
||||
q := s.db.Model(&forbiddenWordBlockingRecord{}).
|
||||
func (s *Store) ListForbiddenWordBlocking(opts ListOptions) ([]ForbiddenWordBlockingRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []ForbiddenWordBlockingRecord
|
||||
q := s.db.Model(&ForbiddenWordBlockingRecord{}).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
@@ -161,17 +161,17 @@ func (s *store) ListForbiddenWordBlocking(opts listOptions) ([]forbiddenWordBloc
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountForbiddenWordBlocking(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountForbiddenWordBlocking(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&forbiddenWordBlockingRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&ForbiddenWordBlockingRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) ListEnabledForbiddenWordBlocking() ([]forbiddenWordBlockingRecord, error) {
|
||||
var rows []forbiddenWordBlockingRecord
|
||||
func (s *Store) ListEnabledForbiddenWordBlocking() ([]ForbiddenWordBlockingRecord, error) {
|
||||
var rows []ForbiddenWordBlockingRecord
|
||||
return rows, s.db.Where("enabled = ?", true).Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
|
||||
func (s *Store) CreateForbiddenWordBlocking(word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) {
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
return nil, fmt.Errorf("forbidden word is required")
|
||||
@@ -183,14 +183,14 @@ func (s *store) CreateForbiddenWordBlocking(word, matchType string, caseSensitiv
|
||||
if err := s.ensureForbiddenWordBlockingUnique(0, word); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := forbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
row := ForbiddenWordBlockingRecord{Word: word, MatchType: matchType, CaseSensitive: caseSensitive, Reason: strings.TrimSpace(reason), Enabled: enabled}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*forbiddenWordBlockingRecord, error) {
|
||||
func (s *Store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, caseSensitive bool, reason string, enabled bool) (*ForbiddenWordBlockingRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("blocking rule id is required")
|
||||
}
|
||||
@@ -209,14 +209,14 @@ func (s *store) UpdateForbiddenWordBlocking(id uint64, word, matchType string, c
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"word": word, "match_type": matchType, "case_sensitive": caseSensitive, "reason": strings.TrimSpace(reason), "enabled": enabled, "updated_at": time.Now()}
|
||||
if err := s.db.Model(&forbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&ForbiddenWordBlockingRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getForbiddenWordBlockingByID(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteForbiddenWordBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&forbiddenWordBlockingRecord{})
|
||||
func (s *Store) DeleteForbiddenWordBlocking(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&ForbiddenWordBlockingRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -226,39 +226,39 @@ func (s *store) DeleteForbiddenWordBlocking(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) getNodeBlockingByID(id uint64) (*nodeBlockingRecord, error) {
|
||||
var row nodeBlockingRecord
|
||||
func (s *Store) getNodeBlockingByID(id uint64) (*NodeBlockingRecord, error) {
|
||||
var row NodeBlockingRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) getIPBlockingByID(id uint64) (*ipBlockingRecord, error) {
|
||||
var row ipBlockingRecord
|
||||
func (s *Store) getIPBlockingByID(id uint64) (*IPBlockingRecord, error) {
|
||||
var row IPBlockingRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) getForbiddenWordBlockingByID(id uint64) (*forbiddenWordBlockingRecord, error) {
|
||||
var row forbiddenWordBlockingRecord
|
||||
func (s *Store) getForbiddenWordBlockingByID(id uint64) (*ForbiddenWordBlockingRecord, error) {
|
||||
var row ForbiddenWordBlockingRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
||||
var existing nodeBlockingRecord
|
||||
func (s *Store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
||||
var existing NodeBlockingRecord
|
||||
q := s.db.Where("node_id = ?", nodeID)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errBlockingAlreadyExists
|
||||
return ErrBlockingAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
@@ -266,15 +266,15 @@ func (s *store) ensureNodeBlockingUnique(id uint64, nodeID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
||||
var existing ipBlockingRecord
|
||||
func (s *Store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
||||
var existing IPBlockingRecord
|
||||
q := s.db.Where("ip_value = ?", ipValue)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errBlockingAlreadyExists
|
||||
return ErrBlockingAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
@@ -282,15 +282,15 @@ func (s *store) ensureIPBlockingUnique(id uint64, ipValue string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) ensureForbiddenWordBlockingUnique(id uint64, word string) error {
|
||||
var existing forbiddenWordBlockingRecord
|
||||
func (s *Store) ensureForbiddenWordBlockingUnique(id uint64, word string) error {
|
||||
var existing ForbiddenWordBlockingRecord
|
||||
q := s.db.Where("word = ?", word)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errBlockingAlreadyExists
|
||||
return ErrBlockingAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
@@ -316,9 +316,9 @@ func normalizeIPBlockingValue(value string) (string, error) {
|
||||
func normalizeForbiddenWordMatchType(matchType string) (string, error) {
|
||||
matchType = strings.TrimSpace(matchType)
|
||||
if matchType == "" {
|
||||
return forbiddenWordMatchContains, nil
|
||||
return ForbiddenWordMatchContains, nil
|
||||
}
|
||||
if matchType != forbiddenWordMatchContains {
|
||||
if matchType != ForbiddenWordMatchContains {
|
||||
return "", fmt.Errorf("unsupported forbidden word match type")
|
||||
}
|
||||
return matchType, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,17 +10,17 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type botDirectMessageListOptions struct {
|
||||
listOptions
|
||||
type BotDirectMessageListOptions struct {
|
||||
ListOptions
|
||||
BotID uint64
|
||||
PeerNodeNum int64
|
||||
Direction string
|
||||
}
|
||||
|
||||
// InsertBotDirectMessage 把一条机器人 DM(出向或入向)写入 bot_direct_messages 表。
|
||||
func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error {
|
||||
func (s *Store) InsertBotDirectMessage(row *BotDirectMessageRecord) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("store is not configured")
|
||||
return fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if row == nil {
|
||||
return fmt.Errorf("bot direct message is required")
|
||||
@@ -32,9 +32,9 @@ func (s *store) InsertBotDirectMessage(row *botDirectMessageRecord) error {
|
||||
}
|
||||
|
||||
// UpdateBotDirectMessageStatus 更新一条出向 DM 的发送状态(pending → published/failed)。
|
||||
func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||
func (s *Store) UpdateBotDirectMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("store is not configured")
|
||||
return fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if id == 0 {
|
||||
return fmt.Errorf("bot direct message id is required")
|
||||
@@ -44,7 +44,7 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string,
|
||||
"error": strings.TrimSpace(errText),
|
||||
"published_at": publishedAt,
|
||||
}
|
||||
result := s.db.Model(&botDirectMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||
result := s.db.Model(&BotDirectMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -55,9 +55,9 @@ func (s *store) UpdateBotDirectMessageStatus(id uint64, status, errText string,
|
||||
}
|
||||
|
||||
// ListBotDirectMessagesByConversation 按 (bot, peer) 反序拉取 DM 历史,给 /admin/bot/direct 页面。
|
||||
func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOptions) ([]botDirectMessageRecord, error) {
|
||||
func (s *Store) ListBotDirectMessagesByConversation(opts BotDirectMessageListOptions) ([]BotDirectMessageRecord, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("store is not configured")
|
||||
return nil, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if opts.BotID == 0 {
|
||||
return nil, fmt.Errorf("bot id is required")
|
||||
@@ -65,11 +65,10 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt
|
||||
if opts.PeerNodeNum == 0 {
|
||||
return nil, fmt.Errorf("peer node num is required")
|
||||
}
|
||||
opts.listOptions = normalizeListOptions(opts.listOptions)
|
||||
var rows []botDirectMessageRecord
|
||||
q := s.db.Model(&botDirectMessageRecord{}).
|
||||
opts.ListOptions = NormalizeListOptions(opts.ListOptions)
|
||||
var rows []BotDirectMessageRecord
|
||||
q := s.db.Model(&BotDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum).
|
||||
Order("created_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
@@ -86,15 +85,15 @@ func (s *store) ListBotDirectMessagesByConversation(opts botDirectMessageListOpt
|
||||
}
|
||||
|
||||
// CountBotDirectMessagesByConversation 返回会话总条数(前端无限滚动可用,可选)。
|
||||
func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOptions) (int64, error) {
|
||||
func (s *Store) CountBotDirectMessagesByConversation(opts BotDirectMessageListOptions) (int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return 0, fmt.Errorf("store is not configured")
|
||||
return 0, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if opts.BotID == 0 || opts.PeerNodeNum == 0 {
|
||||
return 0, fmt.Errorf("bot id and peer node num are required")
|
||||
}
|
||||
var total int64
|
||||
q := s.db.Model(&botDirectMessageRecord{}).
|
||||
q := s.db.Model(&BotDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND peer_node_num = ?", opts.BotID, opts.PeerNodeNum)
|
||||
if opts.Direction != "" {
|
||||
q = q.Where("direction = ?", opts.Direction)
|
||||
@@ -110,9 +109,9 @@ func (s *store) CountBotDirectMessagesByConversation(opts botDirectMessageListOp
|
||||
|
||||
// FindBotForIncomingPKIPacket 在 bot_direct_messages 写入路径上判断接收方是否为受管 bot。
|
||||
// 返回的 bot 用于填充 BotID/BotNodeID/BotNodeNum;不命中时返回 ErrRecordNotFound。
|
||||
func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, error) {
|
||||
func (s *Store) FindBotForIncomingPKIPacket(toNodeNum int64) (*BotNodeRecord, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("store is not configured")
|
||||
return nil, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
bot, err := s.GetBotNodeByNodeNum(toNodeNum)
|
||||
if err != nil {
|
||||
@@ -124,10 +123,10 @@ func (s *store) FindBotForIncomingPKIPacket(toNodeNum int64) (*botNodeRecord, er
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
// botDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。
|
||||
// BotDirectConversation 是 /admin/bot/direct 侧边栏需要的会话摘要。
|
||||
// LastMessageAt / LastText / LastDirection 描述会话最后一条消息,便于按时间排序与预览;
|
||||
// UnreadCount 仅对 inbound 计数(即未读消息数)。
|
||||
type botDirectConversation struct {
|
||||
type BotDirectConversation struct {
|
||||
BotID uint64 `gorm:"column:bot_id"`
|
||||
PeerNodeID string `gorm:"column:peer_node_id"`
|
||||
PeerNodeNum int64 `gorm:"column:peer_node_num"`
|
||||
@@ -139,27 +138,26 @@ type botDirectConversation struct {
|
||||
}
|
||||
|
||||
// ListBotDirectConversations 聚合给定 bot 下的所有 (peer) 会话,返回最后一条消息及未读数。
|
||||
// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 listOptions。
|
||||
func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]botDirectConversation, error) {
|
||||
// 按最后一条消息时间倒序(最新会话排前面)。limit/offset 走 ListOptions。
|
||||
func (s *Store) ListBotDirectConversations(botID uint64, opts ListOptions) ([]BotDirectConversation, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("store is not configured")
|
||||
return nil, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if botID == 0 {
|
||||
return nil, fmt.Errorf("bot id is required")
|
||||
}
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []botDirectConversation
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []BotDirectConversation
|
||||
// 先把每对会话的最后一条消息 ID 取出来,再把这条消息的元数据 join 回去;
|
||||
// 同时聚合 unread_count(inbound 且 read_at IS NULL)和 total_count。
|
||||
// 这样的两步 join 避免在 GROUP BY 后引用非聚合列(MySQL 严格模式 / SQLite 兼容)。
|
||||
subLast := s.db.Model(&botDirectMessageRecord{}).
|
||||
Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", botDirectMessageDirectionInbound).
|
||||
subLast := s.db.Model(&BotDirectMessageRecord{}).
|
||||
Select("bot_id, peer_node_id, peer_node_num, MAX(id) AS last_id, COUNT(*) AS total_count, SUM(CASE WHEN direction = ? AND read_at IS NULL THEN 1 ELSE 0 END) AS unread_count", BotDirectMessageDirectionInbound).
|
||||
Where("bot_id = ?", botID).
|
||||
Group("bot_id, peer_node_id, peer_node_num")
|
||||
q := s.db.Table("(?) AS agg", subLast).
|
||||
Select("agg.bot_id AS bot_id, agg.peer_node_id AS peer_node_id, agg.peer_node_num AS peer_node_num, m.created_at AS last_message_at, m.text AS last_text, m.direction AS last_direction, agg.unread_count AS unread_count, agg.total_count AS total_count").
|
||||
Joins("JOIN bot_direct_messages m ON m.id = agg.last_id").
|
||||
Order("m.created_at DESC").
|
||||
Order("m.id DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
@@ -167,16 +165,16 @@ func (s *store) ListBotDirectConversations(botID uint64, opts listOptions) ([]bo
|
||||
}
|
||||
|
||||
// MarkBotDirectMessagesRead 把 (bot, peer) 下未读的 inbound 消息全部标记为已读,返回更新行数。
|
||||
func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) {
|
||||
func (s *Store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return 0, fmt.Errorf("store is not configured")
|
||||
return 0, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if botID == 0 || peerNodeNum == 0 {
|
||||
return 0, fmt.Errorf("bot id and peer node num are required")
|
||||
}
|
||||
now := time.Now()
|
||||
result := s.db.Model(&botDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, botDirectMessageDirectionInbound).
|
||||
result := s.db.Model(&BotDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND peer_node_num = ? AND direction = ? AND read_at IS NULL", botID, peerNodeNum, BotDirectMessageDirectionInbound).
|
||||
Update("read_at", &now)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
@@ -185,16 +183,16 @@ func (s *store) MarkBotDirectMessagesRead(botID uint64, peerNodeNum int64) (int6
|
||||
}
|
||||
|
||||
// CountBotDirectUnread 返回某个 bot 全部未读 inbound 消息总数(用于头部小红点)。
|
||||
func (s *store) CountBotDirectUnread(botID uint64) (int64, error) {
|
||||
func (s *Store) CountBotDirectUnread(botID uint64) (int64, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return 0, fmt.Errorf("store is not configured")
|
||||
return 0, fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if botID == 0 {
|
||||
return 0, fmt.Errorf("bot id is required")
|
||||
}
|
||||
var total int64
|
||||
err := s.db.Model(&botDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, botDirectMessageDirectionInbound).
|
||||
err := s.db.Model(&BotDirectMessageRecord{}).
|
||||
Where("bot_id = ? AND direction = ? AND read_at IS NULL", botID, BotDirectMessageDirectionInbound).
|
||||
Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
@@ -202,7 +200,7 @@ func (s *store) CountBotDirectUnread(botID uint64) (int64, error) {
|
||||
// isInboundBotDirectMessage 判断 record 是否是“PKI 加密、发往受管 bot”的入向 DM。
|
||||
// 仅在 type=text_message、pki_encrypted=true、packet_to_num 命中受管 bot 时返回 true。
|
||||
// 任何步骤失败都返回 false,让记录回落到 text_message 表(与之前行为兼容)。
|
||||
func isInboundBotDirectMessage(s *store, record map[string]any) bool {
|
||||
func isInboundBotDirectMessage(s *Store, record map[string]any) bool {
|
||||
if s == nil || record == nil {
|
||||
return false
|
||||
}
|
||||
@@ -221,10 +219,10 @@ func isInboundBotDirectMessage(s *store, record map[string]any) bool {
|
||||
}
|
||||
|
||||
// insertInboundBotDirectMessage 把一条入向 PKI DM 转写入 bot_direct_messages 表。
|
||||
// 失败时返回错误,由 dbWriteQueue 统一打印 db_error 事件。
|
||||
func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo mqttClientInfo) error {
|
||||
// 失败时返回错误,由 WriteQueue 统一打印 db_error 事件。
|
||||
func insertInboundBotDirectMessage(s *Store, record map[string]any, clientInfo MQTTClientInfo) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("store is not configured")
|
||||
return fmt.Errorf("Store is not configured")
|
||||
}
|
||||
if record == nil {
|
||||
return fmt.Errorf("record is required")
|
||||
@@ -267,13 +265,13 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
||||
contentPtr = &s
|
||||
}
|
||||
now := time.Now()
|
||||
dm := &botDirectMessageRecord{
|
||||
dm := &BotDirectMessageRecord{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
PeerNodeID: peerNodeID,
|
||||
PeerNodeNum: int64(peerNum),
|
||||
Direction: botDirectMessageDirectionInbound,
|
||||
Direction: BotDirectMessageDirectionInbound,
|
||||
Topic: topic,
|
||||
PacketID: int64(packetID),
|
||||
Text: text,
|
||||
@@ -281,13 +279,43 @@ func insertInboundBotDirectMessage(s *store, record map[string]any, clientInfo m
|
||||
PKIEncrypted: true,
|
||||
WantAck: wantAck,
|
||||
GatewayID: gatewayPtr,
|
||||
Status: botMessageStatusPublished,
|
||||
Status: BotMessageStatusPublished,
|
||||
ReceivedAt: &now,
|
||||
ContentJSON: contentPtr,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.InsertBotDirectMessage(dm); err != nil {
|
||||
return fmt.Errorf("insert bot direct message from %s: %w", peerNodeID, err)
|
||||
}
|
||||
_ = clientInfo // mqtt 元数据已经记录在 content_json 里,这里保留参数以保持队列签名一致
|
||||
|
||||
longName := NullableString(record["long_name"])
|
||||
shortName := NullableString(record["short_name"])
|
||||
channelID := NullableString(record["channel_id"])
|
||||
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
FromNodeID: peerNodeID,
|
||||
FromNodeNum: int64(peerNum),
|
||||
LongName: longName,
|
||||
ShortName: shortName,
|
||||
Text: text,
|
||||
PacketID: int64(packetID),
|
||||
ChannelID: channelID,
|
||||
Topic: topic,
|
||||
MessageType: "direct",
|
||||
ContentJSON: contentPtr,
|
||||
})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_queue_enqueue_failed",
|
||||
"bot_id": bot.ID,
|
||||
"from": peerNodeID,
|
||||
"text": text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetBotNodeByNodeNum 按节点号查找受管 bot 节点;用于 PKI 解密时把 to 字段映射回本地私钥。
|
||||
func (s *Store) GetBotNodeByNodeNum(nodeNum int64) (*BotNodeRecord, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store not configured")
|
||||
}
|
||||
var row BotNodeRecord
|
||||
if err := s.db.Where("node_num = ?", nodeNum).Take(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LookupNodeInfoPublicKey 在 nodeinfo 表中按 node_num 查 X25519 公钥,
|
||||
// 兼容 hex 与 base64 两种历史存储格式。
|
||||
func (s *Store) LookupNodeInfoPublicKey(nodeNum uint32) ([]byte, bool) {
|
||||
var row NodeInfoRecord
|
||||
if err := s.db.Where("node_num = ?", int64(nodeNum)).Take(&row).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if row.PublicKey == nil {
|
||||
return nil, false
|
||||
}
|
||||
value := strings.TrimSpace(*row.PublicKey)
|
||||
if value == "" {
|
||||
return nil, false
|
||||
}
|
||||
if decoded, err := hex.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||
return decoded, true
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil && len(decoded) == 32 {
|
||||
return decoded, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
@@ -11,25 +11,25 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
"meshtastic_mqtt_server/internal/mqtpp"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
botDefaultTopicPrefix = "msh/CN"
|
||||
botDefaultPSK = "AQ=="
|
||||
botDefaultNodeInfoBroadcastSeconds = int64(3600)
|
||||
botMessageTypeChannel = "channel"
|
||||
botMessageTypeDirect = "direct"
|
||||
botMessageStatusPending = "pending"
|
||||
botMessageStatusPublished = "published"
|
||||
botMessageStatusFailed = "failed"
|
||||
BotDefaultTopicPrefix = "msh/CN"
|
||||
BotDefaultPSK = "AQ=="
|
||||
BotDefaultNodeInfoBroadcastSeconds = int64(3600)
|
||||
BotMessageTypeChannel = "channel"
|
||||
BotMessageTypeDirect = "direct"
|
||||
BotMessageStatusPending = "pending"
|
||||
BotMessageStatusPublished = "published"
|
||||
BotMessageStatusFailed = "failed"
|
||||
)
|
||||
|
||||
var errBotNodeAlreadyExists = errors.New("bot node already exists")
|
||||
var ErrBotNodeAlreadyExists = errors.New("bot node already exists")
|
||||
|
||||
type botNodeInput struct {
|
||||
type BotNodeInput struct {
|
||||
NodeNum *int64
|
||||
LongName string
|
||||
ShortName string
|
||||
@@ -39,19 +39,21 @@ type botNodeInput struct {
|
||||
PSK string
|
||||
NodeInfoBroadcastEnabled bool
|
||||
NodeInfoBroadcastIntervalSeconds int64
|
||||
LLMQueueEnabled bool
|
||||
LLMIncludeChannelMessages bool
|
||||
}
|
||||
|
||||
type botMessageListOptions struct {
|
||||
listOptions
|
||||
type BotMessageListOptions struct {
|
||||
ListOptions
|
||||
BotID uint64
|
||||
MessageType string
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []botNodeRecord
|
||||
q := s.db.Model(&botNodeRecord{}).
|
||||
func (s *Store) ListBotNodes(opts ListOptions) ([]BotNodeRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []BotNodeRecord
|
||||
q := s.db.Model(&BotNodeRecord{}).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
@@ -59,20 +61,26 @@ func (s *store) ListBotNodes(opts listOptions) ([]botNodeRecord, error) {
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountBotNodes(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountBotNodes(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&botNodeRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&BotNodeRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) GetBotNode(id uint64) (*botNodeRecord, error) {
|
||||
var row botNodeRecord
|
||||
func (s *Store) IsBotNodeID(nodeID string) bool {
|
||||
var count int64
|
||||
s.db.Model(&BotNodeRecord{}).Where("node_id = ?", nodeID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (s *Store) GetBotNode(id uint64) (*BotNodeRecord, error) {
|
||||
var row BotNodeRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
||||
func (s *Store) CreateBotNode(input BotNodeInput) (*BotNodeRecord, error) {
|
||||
row, err := s.normalizedBotNodeRecord(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -92,7 +100,7 @@ func (s *store) CreateBotNode(input botNodeInput) (*botNodeRecord, error) {
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, error) {
|
||||
func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("bot node id is required")
|
||||
}
|
||||
@@ -100,6 +108,13 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新场景下 input 没传 node_num(前端某些保存路径只携带部分字段)应保持原值,
|
||||
// 否则 normalizedBotNodeRecord 会调用 generateBotNodeNum 随机生成一个新号,
|
||||
// 顺带 NodeID 也被重算——表现就是用户每改一次配置机器人 nodeid 就跳一次。
|
||||
if input.NodeNum == nil || *input.NodeNum == 0 {
|
||||
preserved := existing.NodeNum
|
||||
input.NodeNum = &preserved
|
||||
}
|
||||
row, err := s.normalizedBotNodeRecord(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -126,16 +141,18 @@ func (s *store) UpdateBotNode(id uint64, input botNodeInput) (*botNodeRecord, er
|
||||
"psk": row.PSK,
|
||||
"nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled,
|
||||
"nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds,
|
||||
"llm_queue_enabled": row.LLMQueueEnabled,
|
||||
"llm_include_channel_messages": row.LLMIncludeChannelMessages,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetBotNode(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteBotNode(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&botNodeRecord{})
|
||||
func (s *Store) DeleteBotNode(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&BotNodeRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -145,13 +162,13 @@ func (s *store) DeleteBotNode(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) InsertBotMessage(row *botMessageRecord) error {
|
||||
func (s *Store) InsertBotMessage(row *BotMessageRecord) error {
|
||||
return s.db.Create(row).Error
|
||||
}
|
||||
|
||||
func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||
func (s *Store) UpdateBotMessageStatus(id uint64, status, errText string, publishedAt *time.Time) error {
|
||||
updates := map[string]any{"status": status, "error": strings.TrimSpace(errText), "published_at": publishedAt}
|
||||
result := s.db.Model(&botMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||
result := s.db.Model(&BotMessageRecord{}).Where("id = ?", id).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -161,8 +178,8 @@ func (s *store) UpdateBotMessageStatus(id uint64, status, errText string, publis
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
||||
result := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()})
|
||||
func (s *Store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
||||
result := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(map[string]any{"last_nodeinfo_broadcast_at": &t, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -172,7 +189,7 @@ func (s *store) UpdateBotNodeInfoBroadcastAt(id uint64, t time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) {
|
||||
func (s *Store) RegenerateBotNodeKeys(id uint64) (*BotNodeRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("bot node id is required")
|
||||
}
|
||||
@@ -184,30 +201,29 @@ func (s *store) RegenerateBotNodeKeys(id uint64) (*botNodeRecord, error) {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"public_key": row.PublicKey, "private_key": row.PrivateKey, "updated_at": time.Now()}
|
||||
if err := s.db.Model(&botNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&BotNodeRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetBotNode(id)
|
||||
}
|
||||
|
||||
func (s *store) ListBotMessages(opts botMessageListOptions) ([]botMessageRecord, error) {
|
||||
opts.listOptions = normalizeListOptions(opts.listOptions)
|
||||
var rows []botMessageRecord
|
||||
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts).
|
||||
Order("created_at DESC").
|
||||
func (s *Store) ListBotMessages(opts BotMessageListOptions) ([]BotMessageRecord, error) {
|
||||
opts.ListOptions = NormalizeListOptions(opts.ListOptions)
|
||||
var rows []BotMessageRecord
|
||||
q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts).
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountBotMessages(opts botMessageListOptions) (int64, error) {
|
||||
func (s *Store) CountBotMessages(opts BotMessageListOptions) (int64, error) {
|
||||
var total int64
|
||||
q := applyBotMessageFilters(s.db.Model(&botMessageRecord{}), opts)
|
||||
q := applyBotMessageFilters(s.db.Model(&BotMessageRecord{}), opts)
|
||||
return total, q.Count(&total).Error
|
||||
}
|
||||
|
||||
func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB {
|
||||
func applyBotMessageFilters(q *gorm.DB, opts BotMessageListOptions) *gorm.DB {
|
||||
if opts.BotID != 0 {
|
||||
q = q.Where("bot_id = ?", opts.BotID)
|
||||
}
|
||||
@@ -226,20 +242,20 @@ func applyBotMessageFilters(q *gorm.DB, opts botMessageListOptions) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, error) {
|
||||
func (s *Store) normalizedBotNodeRecord(input BotNodeInput) (*BotNodeRecord, error) {
|
||||
longName := strings.TrimSpace(input.LongName)
|
||||
shortName := strings.TrimSpace(input.ShortName)
|
||||
channelID := strings.TrimSpace(input.DefaultChannelID)
|
||||
psk := strings.TrimSpace(input.PSK)
|
||||
if psk == "" {
|
||||
psk = botDefaultPSK
|
||||
psk = BotDefaultPSK
|
||||
}
|
||||
if _, err := mqtpp.ExpandPSK(psk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topicPrefix := strings.Trim(strings.TrimSpace(input.TopicPrefix), "/")
|
||||
if topicPrefix == "" {
|
||||
topicPrefix = botDefaultTopicPrefix
|
||||
topicPrefix = BotDefaultTopicPrefix
|
||||
}
|
||||
if longName == "" {
|
||||
return nil, fmt.Errorf("long name is required")
|
||||
@@ -258,7 +274,7 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
||||
}
|
||||
interval := input.NodeInfoBroadcastIntervalSeconds
|
||||
if interval <= 0 {
|
||||
interval = botDefaultNodeInfoBroadcastSeconds
|
||||
interval = BotDefaultNodeInfoBroadcastSeconds
|
||||
}
|
||||
if interval < 60 {
|
||||
return nil, fmt.Errorf("nodeinfo broadcast interval must be at least 60 seconds")
|
||||
@@ -273,13 +289,13 @@ func (s *store) normalizedBotNodeRecord(input botNodeInput) (*botNodeRecord, err
|
||||
} else {
|
||||
nodeNum = *input.NodeNum
|
||||
}
|
||||
if err := validateBotNodeNum(nodeNum); err != nil {
|
||||
if err := ValidateBotNodeNum(nodeNum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &botNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval}, nil
|
||||
return &BotNodeRecord{NodeID: mqtpp.NodeNumToID(uint32(nodeNum)), NodeNum: nodeNum, LongName: longName, ShortName: shortName, Enabled: input.Enabled, DefaultChannelID: channelID, TopicPrefix: topicPrefix, PSK: psk, NodeInfoBroadcastEnabled: input.NodeInfoBroadcastEnabled, NodeInfoBroadcastIntervalSeconds: interval, LLMQueueEnabled: input.LLMQueueEnabled, LLMIncludeChannelMessages: input.LLMIncludeChannelMessages}, nil
|
||||
}
|
||||
|
||||
func populateBotNodeKeys(row *botNodeRecord) error {
|
||||
func populateBotNodeKeys(row *BotNodeRecord) error {
|
||||
privateKey, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -289,7 +305,7 @@ func populateBotNodeKeys(row *botNodeRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBotPublicKey(row botNodeRecord) ([]byte, error) {
|
||||
func DecodeBotPublicKey(row BotNodeRecord) ([]byte, error) {
|
||||
if strings.TrimSpace(row.PublicKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -300,31 +316,31 @@ func decodeBotPublicKey(row botNodeRecord) ([]byte, error) {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func validateBotNodeNum(nodeNum int64) error {
|
||||
func ValidateBotNodeNum(nodeNum int64) error {
|
||||
if nodeNum <= 0 || nodeNum >= int64(mqtpp.NodeNumBroadcast) {
|
||||
return fmt.Errorf("node num must be between 1 and 4294967294")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) generateBotNodeNum() (int64, error) {
|
||||
func (s *Store) generateBotNodeNum() (int64, error) {
|
||||
for i := 0; i < 32; i++ {
|
||||
var buf [4]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
nodeNum := int64(binary.LittleEndian.Uint32(buf[:]) & 0x7fffffff)
|
||||
if err := validateBotNodeNum(nodeNum); err != nil {
|
||||
if err := ValidateBotNodeNum(nodeNum); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.ensureBotNodeUnique(0, mqtpp.NodeNumToID(uint32(nodeNum)), nodeNum); err != nil {
|
||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||
if errors.Is(err, ErrBotNodeAlreadyExists) {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if err := s.ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum, mqtpp.NodeNumToID(uint32(nodeNum))); err != nil {
|
||||
if errors.Is(err, errBotNodeAlreadyExists) {
|
||||
if errors.Is(err, ErrBotNodeAlreadyExists) {
|
||||
continue
|
||||
}
|
||||
return 0, err
|
||||
@@ -334,15 +350,15 @@ func (s *store) generateBotNodeNum() (int64, error) {
|
||||
return 0, fmt.Errorf("generate bot node num failed")
|
||||
}
|
||||
|
||||
func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error {
|
||||
var existing botNodeRecord
|
||||
func (s *Store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) error {
|
||||
var existing BotNodeRecord
|
||||
q := s.db.Where("node_id = ? OR node_num = ?", nodeID, nodeNum)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errBotNodeAlreadyExists
|
||||
return ErrBotNodeAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
@@ -350,8 +366,8 @@ func (s *store) ensureBotNodeUnique(id uint64, nodeID string, nodeNum int64) err
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error {
|
||||
var existing nodeInfoRecord
|
||||
func (s *Store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNodeID string) error {
|
||||
var existing NodeInfoRecord
|
||||
q := s.db.Where("node_num = ?", nodeNum)
|
||||
if selfNodeID != "" {
|
||||
// 机器人自己广播 NodeInfo 后会以同样的 node_id/node_num 回写 nodeinfo;
|
||||
@@ -360,7 +376,7 @@ func (s *store) ensureBotNodeDoesNotConflictWithNodeInfo(nodeNum int64, selfNode
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errBotNodeAlreadyExists
|
||||
return ErrBotNodeAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
+484
-207
File diff suppressed because it is too large
Load Diff
@@ -1,90 +1,94 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import "sync"
|
||||
|
||||
type dbWriteQueue struct {
|
||||
store *store
|
||||
jobs chan dbWriteJob
|
||||
type WriteQueue struct {
|
||||
store *Store
|
||||
jobs chan writeJob
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type dbWriteJob struct {
|
||||
type writeJob struct {
|
||||
typeName string
|
||||
from any
|
||||
run func() error
|
||||
errorEvent map[string]any
|
||||
}
|
||||
|
||||
func newDBWriteQueue(store *store) *dbWriteQueue {
|
||||
if store == nil {
|
||||
func NewWriteQueue(s *Store) *WriteQueue {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
q := &dbWriteQueue{
|
||||
store: store,
|
||||
jobs: make(chan dbWriteJob, 1024),
|
||||
q := &WriteQueue{
|
||||
store: s,
|
||||
jobs: make(chan writeJob, 1024),
|
||||
}
|
||||
q.wg.Add(1)
|
||||
go q.run()
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) EnqueueRecord(record map[string]any, clientInfo mqttClientInfo) {
|
||||
func (q *WriteQueue) EnqueueRecord(record map[string]any, clientInfo MQTTClientInfo) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
record = cloneDBWriteRecord(record)
|
||||
switch record["type"] {
|
||||
case "nodeinfo":
|
||||
q.enqueue(dbWriteJob{typeName: "nodeinfo", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "nodeinfo", from: record["from"], run: func() error {
|
||||
return q.store.UpsertNodeInfo(record)
|
||||
}})
|
||||
case "map_report":
|
||||
q.enqueue(dbWriteJob{typeName: "map_report", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "map_report", from: record["from"], run: func() error {
|
||||
return q.store.UpsertMapReport(record)
|
||||
}})
|
||||
case "text_message":
|
||||
// 私聊(PKI 加密、发往受管 bot)单独走 bot_direct_messages 表,
|
||||
// 不再写入 text_message 以避免和频道消息混在一起。
|
||||
if isInboundBotDirectMessage(q.store, record) {
|
||||
q.enqueue(dbWriteJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "bot_direct_message_inbound", from: record["from"], run: func() error {
|
||||
return insertInboundBotDirectMessage(q.store, record, clientInfo)
|
||||
}})
|
||||
return
|
||||
}
|
||||
q.enqueue(dbWriteJob{typeName: "text_message", from: record["from"], run: func() error {
|
||||
// 频道消息同时也写入 LLM 队列(如果启用的话)
|
||||
q.enqueue(writeJob{typeName: "llm_channel_message", from: record["from"], run: func() error {
|
||||
return enqueueChannelMessageToLLM(q.store, record)
|
||||
}})
|
||||
q.enqueue(writeJob{typeName: "text_message", from: record["from"], run: func() error {
|
||||
return q.store.InsertTextMessage(record, clientInfo)
|
||||
}})
|
||||
case "position":
|
||||
q.enqueue(dbWriteJob{typeName: "position", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "position", from: record["from"], run: func() error {
|
||||
return q.store.InsertPosition(record, clientInfo)
|
||||
}})
|
||||
case "telemetry":
|
||||
q.enqueue(dbWriteJob{typeName: "telemetry", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "telemetry", from: record["from"], run: func() error {
|
||||
return q.store.InsertTelemetry(record, clientInfo)
|
||||
}})
|
||||
case "routing":
|
||||
q.enqueue(dbWriteJob{typeName: "routing", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "routing", from: record["from"], run: func() error {
|
||||
return q.store.InsertRouting(record, clientInfo)
|
||||
}})
|
||||
case "traceroute":
|
||||
q.enqueue(dbWriteJob{typeName: "traceroute", from: record["from"], run: func() error {
|
||||
q.enqueue(writeJob{typeName: "traceroute", from: record["from"], run: func() error {
|
||||
return q.store.InsertTraceroute(record, clientInfo)
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo mqttClientInfo) {
|
||||
func (q *WriteQueue) EnqueueDiscard(record map[string]any, raw []byte, clientInfo MQTTClientInfo) {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
record = cloneDBWriteRecord(record)
|
||||
raw = append([]byte(nil), raw...)
|
||||
q.enqueue(dbWriteJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error {
|
||||
q.enqueue(writeJob{typeName: "discard_details", from: record["from"], errorEvent: map[string]any{"event": "db_error", "type": "discard_details", "topic": record["topic"]}, run: func() error {
|
||||
return q.store.InsertDiscardDetails(record, raw, clientInfo)
|
||||
}})
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) Close() {
|
||||
func (q *WriteQueue) Close() {
|
||||
if q == nil {
|
||||
return
|
||||
}
|
||||
@@ -92,18 +96,18 @@ func (q *dbWriteQueue) Close() {
|
||||
q.wg.Wait()
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) Len() int {
|
||||
func (q *WriteQueue) Len() int {
|
||||
if q == nil {
|
||||
return 0
|
||||
}
|
||||
return len(q.jobs)
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) enqueue(job dbWriteJob) {
|
||||
func (q *WriteQueue) enqueue(job writeJob) {
|
||||
q.jobs <- job
|
||||
}
|
||||
|
||||
func (q *dbWriteQueue) run() {
|
||||
func (q *WriteQueue) run() {
|
||||
defer q.wg.Done()
|
||||
for job := range q.jobs {
|
||||
if err := job.run(); err != nil {
|
||||
@@ -1,12 +1,13 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo mqttClientInfo) error {
|
||||
func (s *Store) InsertDiscardDetails(record map[string]any, raw []byte, clientInfo MQTTClientInfo) error {
|
||||
details, err := discardDetailsFromRecord(record, raw, clientInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -17,28 +18,29 @@ func (s *store) InsertDiscardDetails(record map[string]any, raw []byte, clientIn
|
||||
return nil
|
||||
}
|
||||
|
||||
func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo mqttClientInfo) (*discardDetailsRecord, error) {
|
||||
func discardDetailsFromRecord(record map[string]any, raw []byte, clientInfo MQTTClientInfo) (*DiscardDetailsRecord, error) {
|
||||
contentJSON, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode discard_details content_json: %w", err)
|
||||
}
|
||||
return &discardDetailsRecord{
|
||||
return &DiscardDetailsRecord{
|
||||
Topic: stringValue(record["topic"]),
|
||||
Error: stringValue(record["error"]),
|
||||
PayloadLen: int64(len(raw)),
|
||||
RawBase64: base64.StdEncoding.EncodeToString(raw),
|
||||
ContentJSON: string(contentJSON),
|
||||
MQTTClientID: nullableStringValue(clientInfo.ClientID),
|
||||
MQTTUsername: nullableStringValue(clientInfo.Username),
|
||||
MQTTListener: nullableStringValue(clientInfo.Listener),
|
||||
MQTTRemoteAddr: nullableStringValue(clientInfo.RemoteAddr),
|
||||
MQTTRemoteHost: nullableStringValue(clientInfo.RemoteHost),
|
||||
MQTTRemotePort: nullableStringValue(clientInfo.RemotePort),
|
||||
MQTTClientID: NullableStringValue(clientInfo.ClientID),
|
||||
MQTTUsername: NullableStringValue(clientInfo.Username),
|
||||
MQTTListener: NullableStringValue(clientInfo.Listener),
|
||||
MQTTRemoteAddr: NullableStringValue(clientInfo.RemoteAddr),
|
||||
MQTTRemoteHost: NullableStringValue(clientInfo.RemoteHost),
|
||||
MQTTRemotePort: NullableStringValue(clientInfo.RemotePort),
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if s := nullableStringValue(value); s != nil {
|
||||
if s := NullableStringValue(value); s != nil {
|
||||
return *s
|
||||
}
|
||||
return ""
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
const maxHelpMarkdownBytes = 200 * 1024
|
||||
|
||||
const defaultHelpMarkdown = `## 连接地址
|
||||
const DefaultHelpMarkdown = `## 连接地址
|
||||
|
||||
将 Meshtastic 设备连接到本服务提供的 MQTT broker。
|
||||
|
||||
@@ -29,15 +29,15 @@ const defaultHelpMarkdown = `## 连接地址
|
||||
|
||||
如果遇到 bug,请在 GitHub [提交 issue](https://github.com/wuwenfengmi1998/meshtastic_mqtt_server),或联系邮箱 [kevin@lmve.net](mailto:kevin@lmve.net)。`
|
||||
|
||||
func (s *store) GetLatestHelpContent() (*helpContentRecord, error) {
|
||||
var row helpContentRecord
|
||||
func (s *Store) GetLatestHelpContent() (*HelpContentRecord, error) {
|
||||
var row HelpContentRecord
|
||||
if err := s.db.Order("id DESC").Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecord, error) {
|
||||
func (s *Store) InsertHelpContent(markdown, createdBy string) (*HelpContentRecord, error) {
|
||||
markdown = strings.TrimSpace(markdown)
|
||||
createdBy = strings.TrimSpace(createdBy)
|
||||
if markdown == "" {
|
||||
@@ -46,7 +46,7 @@ func (s *store) InsertHelpContent(markdown, createdBy string) (*helpContentRecor
|
||||
if len([]byte(markdown)) > maxHelpMarkdownBytes {
|
||||
return nil, fmt.Errorf("markdown exceeds %d bytes", maxHelpMarkdownBytes)
|
||||
}
|
||||
row := helpContentRecord{Markdown: markdown, CreatedBy: createdBy}
|
||||
row := HelpContentRecord{Markdown: markdown, CreatedBy: createdBy}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// AdminRole 是管理员账号在用户表里的角色字符串。其它包通过这个常量与
|
||||
// `users.role` 字段对齐,避免硬编码。
|
||||
const AdminRole = "admin"
|
||||
|
||||
// printJSON 是 store 包内部的诊断输出钩子。当前实现为 noop——保持与
|
||||
// 重构前 main.go 的行为一致;如需启用调试,可在调用方替换。
|
||||
func printJSON(record map[string]any) {
|
||||
_ = record
|
||||
}
|
||||
|
||||
// hashPassword 与 auth.go 中的散列实现保持一致(bcrypt 默认 cost)。
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// uint32FromRecord 把 map[string]any 中的整型字段安全转换为 uint32。
|
||||
func uint32FromRecord(value any) (uint32, bool) {
|
||||
switch v := value.(type) {
|
||||
case uint32:
|
||||
return v, true
|
||||
case int:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
case int64:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
case uint64:
|
||||
return uint32(v), true
|
||||
case float64:
|
||||
if v >= 0 {
|
||||
return uint32(v), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// LLM Provider (llm_providers) - 多 AI API 配置
|
||||
// ============================================
|
||||
|
||||
// ListLLMProviders 列出所有 LLM Provider
|
||||
func (s *Store) ListLLMProviders(includeInactive bool) ([]LLMProviderRecord, error) {
|
||||
var rows []LLMProviderRecord
|
||||
query := s.db.Model(&LLMProviderRecord{})
|
||||
if !includeInactive {
|
||||
query = query.Where("active = ?", true)
|
||||
}
|
||||
if err := query.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("list llm providers: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetLLMProvider 获取单个 LLM Provider
|
||||
func (s *Store) GetLLMProvider(name string) (*LLMProviderRecord, error) {
|
||||
var record LLMProviderRecord
|
||||
if err := s.db.Where("name = ?", name).Take(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm provider %s: %w", name, err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CreateLLMProvider 创建 LLM Provider
|
||||
func (s *Store) CreateLLMProvider(record *LLMProviderRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm provider %s: %w", record.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMProvider 更新 LLM Provider
|
||||
func (s *Store) UpdateLLMProvider(name string, updates map[string]any) error {
|
||||
if err := s.db.Model(&LLMProviderRecord{}).Where("name = ?", name).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm provider %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLLMProvider 删除 LLM Provider
|
||||
func (s *Store) DeleteLLMProvider(name string) error {
|
||||
if err := s.db.Where("name = ?", name).Delete(&LLMProviderRecord{}).Error; err != nil {
|
||||
return fmt.Errorf("delete llm provider %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMProvider 确保存在默认 LLM Provider 配置
|
||||
// 只有当数据库中完全没有任何 provider 配置时,才创建默认配置
|
||||
func (s *Store) EnsureDefaultLLMProvider() error {
|
||||
// 先检查是否已经有任何 provider 配置
|
||||
providers, err := s.ListLLMProviders(true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list llm providers: %w", err)
|
||||
}
|
||||
if len(providers) > 0 {
|
||||
return nil // 已有配置,不创建默认
|
||||
}
|
||||
// 创建默认配置
|
||||
defaultConfig := &LLMProviderRecord{
|
||||
Name: "default",
|
||||
Active: true,
|
||||
APIKey: "",
|
||||
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
Model: "",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 262144,
|
||||
}
|
||||
return s.CreateLLMProvider(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Tool Router (llm_tool_router) - 工具路由配置
|
||||
// ============================================
|
||||
|
||||
// GetLLMToolRouter 获取当前激活的 Tool Router 配置
|
||||
func (s *Store) GetLLMToolRouter() (*LLMToolRouterRecord, error) {
|
||||
var record LLMToolRouterRecord
|
||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm tool router: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CreateLLMToolRouter 创建 Tool Router 配置
|
||||
func (s *Store) CreateLLMToolRouter(record *LLMToolRouterRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm tool router: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMToolRouter 更新 Tool Router 配置
|
||||
func (s *Store) UpdateLLMToolRouter(id uint64, updates map[string]any) error {
|
||||
if err := s.db.Model(&LLMToolRouterRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm tool router %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMToolRouter 确保存在默认 Tool Router 配置
|
||||
func (s *Store) EnsureDefaultLLMToolRouter() error {
|
||||
_, err := s.GetLLMToolRouter()
|
||||
if err == nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 创建默认配置
|
||||
defaultConfig := &LLMToolRouterRecord{
|
||||
Enabled: true,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "你可以按需直接调用可用工具来回答用户问题。\n每个工具的 description 描述了它的适用场景和调用条件。\n工具结果优先于模型内置知识;工具失败时必须如实说明,不要编造结果。\n只调用确实必要的工具。",
|
||||
}
|
||||
return s.CreateLLMToolRouter(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Topic Config (llm_topic_config) - 话题选择配置
|
||||
// ============================================
|
||||
|
||||
// GetLLMTopicConfig 获取当前激活的话题选择配置
|
||||
func (s *Store) GetLLMTopicConfig() (*LLMTopicConfigRecord, error) {
|
||||
var record LLMTopicConfigRecord
|
||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm topic config: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CreateLLMTopicConfig 创建话题选择配置
|
||||
func (s *Store) CreateLLMTopicConfig(record *LLMTopicConfigRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm topic config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMTopicConfig 更新话题选择配置
|
||||
func (s *Store) UpdateLLMTopicConfig(id uint64, updates map[string]any) error {
|
||||
if err := s.db.Model(&LLMTopicConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm topic config %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMTopicConfig 确保存在默认话题选择配置
|
||||
func (s *Store) EnsureDefaultLLMTopicConfig() error {
|
||||
_, err := s.GetLLMTopicConfig()
|
||||
if err == nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 创建默认配置(默认未启用)
|
||||
defaultConfig := &LLMTopicConfigRecord{
|
||||
Enabled: false,
|
||||
OpenAIName: "",
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。",
|
||||
}
|
||||
return s.CreateLLMTopicConfig(defaultConfig)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LLM Primary Config (llm_primary_config) - 主 AI 回复配置
|
||||
// ============================================
|
||||
|
||||
// GetLLMPrimaryConfig 获取当前激活的主 AI 回复配置
|
||||
func (s *Store) GetLLMPrimaryConfig() (*LLMPrimaryConfigRecord, error) {
|
||||
var record LLMPrimaryConfigRecord
|
||||
// 默认取第一条记录(ID 最小的),因为通常只需要一个配置
|
||||
if err := s.db.Order("id ASC").First(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm primary config: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// GetLLMPrimaryConfigSystemPrompt 获取主 AI 回复配置中的系统提示词
|
||||
// 如果没有配置或出错,返回空字符串(autoreply service 会处理这种情况)
|
||||
func (s *Store) GetLLMPrimaryConfigSystemPrompt() (string, error) {
|
||||
record, err := s.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 没有配置时返回空字符串,使用默认行为
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return record.SystemPrompt, nil
|
||||
}
|
||||
|
||||
// GetLLMPrimaryConfigEnableTool 获取是否启用工具调用
|
||||
func (s *Store) GetLLMPrimaryConfigEnableTool() (bool, error) {
|
||||
record, err := s.GetLLMPrimaryConfig()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return record.EnableTool, nil
|
||||
}
|
||||
|
||||
// CreateLLMPrimaryConfig 创建主 AI 回复配置
|
||||
func (s *Store) CreateLLMPrimaryConfig(record *LLMPrimaryConfigRecord) error {
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return fmt.Errorf("create llm primary config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateLLMPrimaryConfig 更新主 AI 回复配置
|
||||
func (s *Store) UpdateLLMPrimaryConfig(id uint64, updates map[string]any) error {
|
||||
if err := s.db.Model(&LLMPrimaryConfigRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm primary config %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureDefaultLLMPrimaryConfig 确保存在默认主 AI 回复配置
|
||||
func (s *Store) EnsureDefaultLLMPrimaryConfig() error {
|
||||
_, err := s.GetLLMPrimaryConfig()
|
||||
if err == nil {
|
||||
return nil // 已存在
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 创建默认配置
|
||||
defaultConfig := &LLMPrimaryConfigRecord{
|
||||
Enabled: false,
|
||||
ProviderName: "",
|
||||
Timeout: 120,
|
||||
MaxTokens: 1024,
|
||||
SystemPrompt: "你是一个 Meshtastic 网络助手。请简洁回答用户问题。\n回答要简短清晰,适合在低带宽无线电环境传输。每次回复限制在200 bytes以内。",
|
||||
EnableTool: false,
|
||||
}
|
||||
return s.CreateLLMPrimaryConfig(defaultConfig)
|
||||
}
|
||||
|
||||
// LLMMessageQueueInput 是添加 LLM 队列消息的输入
|
||||
type LLMMessageQueueInput struct {
|
||||
BotID uint64 // 0 表示频道消息
|
||||
BotNodeID string // 频道消息可为空
|
||||
BotNodeNum int64 // 频道消息可为 0
|
||||
FromNodeID string
|
||||
FromNodeNum int64
|
||||
LongName *string
|
||||
ShortName *string
|
||||
Text string
|
||||
PacketID int64
|
||||
ChannelID *string
|
||||
Topic string
|
||||
MessageType string // "channel" 或 "direct"
|
||||
ContentJSON *string
|
||||
}
|
||||
|
||||
// EnqueueLLMMessage 将消息添加到 LLM 队列
|
||||
func (s *Store) EnqueueLLMMessage(input LLMMessageQueueInput) (*LLMMessageQueueRecord, error) {
|
||||
var err error
|
||||
|
||||
if input.BotID == 0 {
|
||||
return nil, nil // bot_id 为 0 的消息不再入队
|
||||
}
|
||||
|
||||
// 检查机器人级别的 LLM 队列设置
|
||||
bot, err := s.GetBotNode(input.BotID)
|
||||
if err != nil {
|
||||
return nil, nil // 机器人不存在,静默返回
|
||||
}
|
||||
if !bot.LLMQueueEnabled {
|
||||
return nil, nil // 机器人的 LLM 队列未启用,静默返回
|
||||
}
|
||||
|
||||
if s.IsBotNodeID(input.FromNodeID) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if input.FromNodeID == "" {
|
||||
return nil, fmt.Errorf("from_node_id is required")
|
||||
}
|
||||
if input.Text == "" {
|
||||
return nil, fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
// 检查是否存在重复消息
|
||||
// packet_id > 0: 用 bot_id + packet_id 去重(频道消息)
|
||||
// packet_id = 0: 用 bot_id + from_node_id + text 去重(私聊消息,可能没有 packet_id)
|
||||
// 命中条件二选一:
|
||||
// 1. 仍存在 pending/processing 状态的记录(尚未处理完)
|
||||
// 2. 已软删除(processed)但未超过 dedup 窗口——防止网络延迟/重投导致同一包在刚处理完后又被重复入队
|
||||
// error 状态允许重新入队;processed 软删除超过窗口后也允许重新入队。
|
||||
// 阈值在 Go 侧算好作为参数传入,避免依赖 SQLite datetime('now') 的时区行为,与其它 time 字段读写保持一致。
|
||||
processedCutoff := time.Now().Add(-llmQueueProcessedDedupWindow)
|
||||
dupCondition := "(deleted_at IS NULL AND status IN (?, ?)) OR (deleted_at IS NOT NULL AND deleted_at > ?)"
|
||||
|
||||
var existing LLMMessageQueueRecord
|
||||
if input.PacketID > 0 {
|
||||
// 频道消息:用 bot_id + packet_id 去重
|
||||
err = s.db.Where("bot_id = ? AND packet_id = ? AND "+dupCondition,
|
||||
input.BotID, input.PacketID, LLMMessageStatusPending, LLMMessageStatusProcessing, processedCutoff).
|
||||
Take(&existing).Error
|
||||
} else {
|
||||
// 私聊消息:用 bot_id + from_node_id + text 去重(避免同一人连续发相同内容被拒绝)
|
||||
err = s.db.Where("bot_id = ? AND from_node_id = ? AND text = ? AND "+dupCondition,
|
||||
input.BotID, input.FromNodeID, input.Text, LLMMessageStatusPending, LLMMessageStatusProcessing, processedCutoff).
|
||||
Take(&existing).Error
|
||||
}
|
||||
if err == nil {
|
||||
// 存在命中去重的记录(处理中 / 刚处理完未过窗口),直接返回
|
||||
return &existing, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("check duplicate llm message: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
messageType := input.MessageType
|
||||
if messageType == "" {
|
||||
messageType = "direct"
|
||||
}
|
||||
record := &LLMMessageQueueRecord{
|
||||
BotID: input.BotID,
|
||||
BotNodeID: input.BotNodeID,
|
||||
BotNodeNum: input.BotNodeNum,
|
||||
FromNodeID: input.FromNodeID,
|
||||
FromNodeNum: input.FromNodeNum,
|
||||
LongName: input.LongName,
|
||||
ShortName: input.ShortName,
|
||||
Text: input.Text,
|
||||
PacketID: input.PacketID,
|
||||
ChannelID: input.ChannelID,
|
||||
Topic: input.Topic,
|
||||
MessageType: messageType,
|
||||
Status: LLMMessageStatusPending,
|
||||
ReceivedAt: now,
|
||||
ContentJSON: input.ContentJSON,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.db.Create(record).Error; err != nil {
|
||||
return nil, fmt.Errorf("enqueue llm message: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// ListLLMMessages 列出 LLM 队列消息
|
||||
func (s *Store) ListLLMMessages(opts ListOptions, botID uint64, includeDeleted bool) ([]LLMMessageQueueRecord, int64, error) {
|
||||
var rows []LLMMessageQueueRecord
|
||||
query := s.db.Model(&LLMMessageQueueRecord{})
|
||||
|
||||
if botID > 0 {
|
||||
query = query.Where("bot_id = ?", botID)
|
||||
}
|
||||
if !includeDeleted {
|
||||
query = query.Where("deleted_at IS NULL")
|
||||
}
|
||||
|
||||
// 先获取总数
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("count llm messages: %w", err)
|
||||
}
|
||||
|
||||
// 排序和分页
|
||||
query = query.Order("id DESC")
|
||||
if opts.Limit > 0 {
|
||||
query = query.Limit(opts.Limit)
|
||||
}
|
||||
if opts.Offset > 0 {
|
||||
query = query.Offset(opts.Offset)
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("list llm messages: %w", err)
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
|
||||
// GetLLMMessage 获取单条 LLM 消息
|
||||
func (s *Store) GetLLMMessage(id uint64) (*LLMMessageQueueRecord, error) {
|
||||
var record LLMMessageQueueRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&record).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("get llm message %d: %w", id, err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// UpdateLLMMessageStatus 更新 LLM 消息状态
|
||||
func (s *Store) UpdateLLMMessageStatus(id uint64, status string, errorMsg string) error {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"error": errorMsg,
|
||||
}
|
||||
if status == LLMMessageStatusProcessed {
|
||||
now := time.Now()
|
||||
updates["processed_at"] = &now
|
||||
}
|
||||
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update llm message status %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDeleteLLMMessage 软删除 LLM 消息
|
||||
func (s *Store) SoftDeleteLLMMessage(id uint64) error {
|
||||
now := time.Now()
|
||||
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("id = ?", id).Update("deleted_at", &now).Error; err != nil {
|
||||
return fmt.Errorf("soft delete llm message %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDeleteLLMMessagesByBot 软删除指定机器人的所有消息
|
||||
func (s *Store) SoftDeleteLLMMessagesByBot(botID uint64) error {
|
||||
now := time.Now()
|
||||
if err := s.db.Model(&LLMMessageQueueRecord{}).Where("bot_id = ? AND deleted_at IS NULL", botID).Update("deleted_at", &now).Error; err != nil {
|
||||
return fmt.Errorf("soft delete llm messages for bot %d: %w", botID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupDeletedLLMMessages 清理已软删除超过指定时间的消息
|
||||
func (s *Store) CleanupDeletedLLMMessages(before time.Time) (int64, error) {
|
||||
result := s.db.Where("deleted_at IS NOT NULL AND deleted_at < ?", before).Delete(&LLMMessageQueueRecord{})
|
||||
if result.Error != nil {
|
||||
return 0, fmt.Errorf("cleanup deleted llm messages: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// LLMMessageDTO 将数据库记录转换为 API 响应格式
|
||||
func LLMMessageDTO(row LLMMessageQueueRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"id": row.ID,
|
||||
"bot_id": row.BotID,
|
||||
"bot_node_id": row.BotNodeID,
|
||||
"bot_node_num": row.BotNodeNum,
|
||||
"from_node_id": row.FromNodeID,
|
||||
"from_node_num": row.FromNodeNum,
|
||||
"long_name": row.LongName,
|
||||
"short_name": row.ShortName,
|
||||
"text": row.Text,
|
||||
"packet_id": row.PacketID,
|
||||
"channel_id": row.ChannelID,
|
||||
"topic": row.Topic,
|
||||
"status": row.Status,
|
||||
"error": row.Error,
|
||||
"received_at": row.ReceivedAt,
|
||||
"processed_at": row.ProcessedAt,
|
||||
"deleted_at": row.DeletedAt,
|
||||
"created_at": row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueChannelMessageToLLM 将频道消息添加到 LLM 队列
|
||||
// 为每个启用了「包含频道消息」的机器人都创建一条独立的队列记录
|
||||
func enqueueChannelMessageToLLM(s *Store, record map[string]any) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
text, _ := record["text"].(string)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fromNodeID, _ := record["from"].(string)
|
||||
if fromNodeID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fromNodeNum, err := int64FromAny(record["from_num"])
|
||||
if err != nil {
|
||||
fromNodeNum = 0
|
||||
}
|
||||
|
||||
// record 来自 describePacket 直接构造的 map,packet_id 是 uint32,
|
||||
// 并未经过 JSON 往返(不会变成 float64),必须用类型安全的转换兜底各种整型。
|
||||
packetID, _ := int64FromAny(record["packet_id"])
|
||||
|
||||
topic, _ := record["topic"].(string)
|
||||
|
||||
var longName, shortName *string
|
||||
if ln, ok := record["long_name"].(string); ok && ln != "" {
|
||||
longName = &ln
|
||||
}
|
||||
if sn, ok := record["short_name"].(string); ok && sn != "" {
|
||||
shortName = &sn
|
||||
}
|
||||
|
||||
if s.IsBotNodeID(fromNodeID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var channelID *string
|
||||
if cid, ok := record["channel_id"].(string); ok && cid != "" {
|
||||
channelID = &cid
|
||||
}
|
||||
|
||||
contentJSON, err := json.Marshal(record)
|
||||
var contentPtr *string
|
||||
if err == nil {
|
||||
s := string(contentJSON)
|
||||
contentPtr = &s
|
||||
}
|
||||
|
||||
// 查询所有启用了 LLM 队列且包含频道消息的机器人
|
||||
// SQLite 中 numeric 布尔值用 1/0 存储,必须用整数查询
|
||||
var bots []BotNodeRecord
|
||||
err = s.db.Where("llm_queue_enabled = ? AND llm_include_channel_messages = ?", 1, 1).Find(&bots).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("query bots for channel message enqueue: %w", err)
|
||||
}
|
||||
|
||||
for _, bot := range bots {
|
||||
_, err = s.EnqueueLLMMessage(LLMMessageQueueInput{
|
||||
BotID: bot.ID,
|
||||
BotNodeID: bot.NodeID,
|
||||
BotNodeNum: bot.NodeNum,
|
||||
FromNodeID: fromNodeID,
|
||||
FromNodeNum: fromNodeNum,
|
||||
LongName: longName,
|
||||
ShortName: shortName,
|
||||
Text: text,
|
||||
PacketID: packetID,
|
||||
ChannelID: channelID,
|
||||
Topic: topic,
|
||||
MessageType: "channel",
|
||||
ContentJSON: contentPtr,
|
||||
})
|
||||
if err != nil {
|
||||
printJSON(map[string]any{
|
||||
"event": "llm_queue_enqueue_failed",
|
||||
"bot_id": bot.ID,
|
||||
"from": fromNodeID,
|
||||
"text": text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
func (s *Store) InsertLoginLog(log LoginLogRecord) error {
|
||||
if log.CreatedAt.IsZero() {
|
||||
log.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(&log).Error
|
||||
}
|
||||
|
||||
func (s *Store) ListLoginLogs(opts ListOptions) ([]LoginLogRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []LoginLogRecord
|
||||
q := s.db.Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||
if opts.Since != nil {
|
||||
q = q.Where("created_at >= ?", *opts.Since)
|
||||
}
|
||||
if opts.Until != nil {
|
||||
q = q.Where("created_at <= ?", *opts.Until)
|
||||
}
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
@@ -22,13 +22,13 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
errMapTileSourceAlreadyExists = errors.New("map source already exists")
|
||||
errMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
|
||||
errMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
|
||||
errMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
|
||||
ErrMapTileSourceAlreadyExists = errors.New("map source already exists")
|
||||
ErrMapTileSourceCannotDeleteDefault = errors.New("default map source cannot be deleted")
|
||||
ErrMapTileSourceCannotDisableDefault = errors.New("default map source cannot be disabled")
|
||||
ErrMapTileSourceDefaultMustBeEnabled = errors.New("default map source must be enabled")
|
||||
)
|
||||
|
||||
type mapTileSourceInput struct {
|
||||
type MapTileSourceInput struct {
|
||||
Name string
|
||||
URLTemplate string
|
||||
Attribution string
|
||||
@@ -38,10 +38,10 @@ type mapTileSourceInput struct {
|
||||
ProxyEnabled bool
|
||||
}
|
||||
|
||||
func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []mapTileSourceRecord
|
||||
q := s.db.Model(&mapTileSourceRecord{}).
|
||||
func (s *Store) ListMapTileSources(opts ListOptions) ([]MapTileSourceRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []MapTileSourceRecord
|
||||
q := s.db.Model(&MapTileSourceRecord{}).
|
||||
Order("is_default DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
@@ -50,14 +50,14 @@ func (s *store) ListMapTileSources(opts listOptions) ([]mapTileSourceRecord, err
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountMapTileSources(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountMapTileSources(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&mapTileSourceRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&MapTileSourceRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
|
||||
var rows []mapTileSourceRecord
|
||||
if err := s.db.Model(&mapTileSourceRecord{}).
|
||||
func (s *Store) ListEnabledMapTileSources() ([]MapTileSourceRecord, error) {
|
||||
var rows []MapTileSourceRecord
|
||||
if err := s.db.Model(&MapTileSourceRecord{}).
|
||||
Where("enabled = ?", true).
|
||||
Order("is_default DESC").
|
||||
Order("updated_at DESC").
|
||||
@@ -66,13 +66,13 @@ func (s *store) ListEnabledMapTileSources() ([]mapTileSourceRecord, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []mapTileSourceRecord{defaultMapTileSourceRecord()}, nil
|
||||
return []MapTileSourceRecord{defaultMapTileSourceRecord()}, nil
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
||||
var row mapTileSourceRecord
|
||||
func (s *Store) GetDefaultMapTileSource() (*MapTileSourceRecord, error) {
|
||||
var row MapTileSourceRecord
|
||||
err := s.db.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
fallback := defaultMapTileSourceRecord()
|
||||
@@ -84,28 +84,28 @@ func (s *store) GetDefaultMapTileSource() (*mapTileSourceRecord, error) {
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) GetEnabledMapTileSourceByHash(hash string) (*mapTileSourceRecord, error) {
|
||||
var row mapTileSourceRecord
|
||||
func (s *Store) GetEnabledMapTileSourceByHash(hash string) (*MapTileSourceRecord, error) {
|
||||
var row MapTileSourceRecord
|
||||
if err := s.db.Where("enabled = ? AND proxy_enabled = ? AND url_template_hash = ?", true, true, hash).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||
func (s *Store) CreateMapTileSource(input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||
row, err := mapTileSourceFromInput(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.IsDefault && !row.Enabled {
|
||||
return nil, errMapTileSourceDefaultMustBeEnabled
|
||||
return nil, ErrMapTileSourceDefaultMustBeEnabled
|
||||
}
|
||||
if err := s.ensureMapTileSourceUnique(0, row.Name, row.URLTemplate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if row.IsDefault {
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (s *store) CreateMapTileSource(input mapTileSourceInput) (*mapTileSourceRec
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||
func (s *Store) UpdateMapTileSource(id uint64, input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("map source id is required")
|
||||
}
|
||||
@@ -124,17 +124,17 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updated mapTileSourceRecord
|
||||
var updated MapTileSourceRecord
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var existing mapTileSourceRecord
|
||||
var existing MapTileSourceRecord
|
||||
if err := tx.Where("id = ?", id).Take(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing.IsDefault && !row.Enabled {
|
||||
return errMapTileSourceCannotDisableDefault
|
||||
return ErrMapTileSourceCannotDisableDefault
|
||||
}
|
||||
if row.IsDefault && !row.Enabled {
|
||||
return errMapTileSourceDefaultMustBeEnabled
|
||||
return ErrMapTileSourceDefaultMustBeEnabled
|
||||
}
|
||||
if !row.IsDefault && existing.IsDefault {
|
||||
row.IsDefault = true
|
||||
@@ -143,7 +143,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
||||
return err
|
||||
}
|
||||
if row.IsDefault {
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", id, true).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
||||
"proxy_enabled": row.ProxyEnabled,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Take(&updated).Error
|
||||
@@ -168,19 +168,19 @@ func (s *store) UpdateMapTileSource(id uint64, input mapTileSourceInput) (*mapTi
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (s *store) DeleteMapTileSource(id uint64) error {
|
||||
func (s *Store) DeleteMapTileSource(id uint64) error {
|
||||
if id == 0 {
|
||||
return fmt.Errorf("map source id is required")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var row mapTileSourceRecord
|
||||
var row MapTileSourceRecord
|
||||
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if row.IsDefault {
|
||||
return errMapTileSourceCannotDeleteDefault
|
||||
return ErrMapTileSourceCannotDeleteDefault
|
||||
}
|
||||
result := tx.Where("id = ?", id).Delete(&mapTileSourceRecord{})
|
||||
result := tx.Where("id = ?", id).Delete(&MapTileSourceRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -191,22 +191,22 @@ func (s *store) DeleteMapTileSource(id uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error) {
|
||||
func (s *Store) SetDefaultMapTileSource(id uint64) (*MapTileSourceRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("map source id is required")
|
||||
}
|
||||
var row mapTileSourceRecord
|
||||
var row MapTileSourceRecord
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !row.Enabled {
|
||||
return errMapTileSourceDefaultMustBeEnabled
|
||||
return ErrMapTileSourceDefaultMustBeEnabled
|
||||
}
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Where("id = ?", id).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Take(&row).Error
|
||||
@@ -216,10 +216,10 @@ func (s *store) SetDefaultMapTileSource(id uint64) (*mapTileSourceRecord, error)
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) EnsureDefaultMapTileSource() error {
|
||||
func (s *Store) EnsureDefaultMapTileSource() error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&mapTileSourceRecord{}).Count(&count).Error; err != nil {
|
||||
if err := tx.Model(&MapTileSourceRecord{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -227,28 +227,28 @@ func (s *store) EnsureDefaultMapTileSource() error {
|
||||
return tx.Create(&row).Error
|
||||
}
|
||||
|
||||
var defaults []mapTileSourceRecord
|
||||
var defaults []MapTileSourceRecord
|
||||
if err := tx.Where("enabled = ? AND is_default = ?", true, true).Order("id ASC").Find(&defaults).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(defaults) > 0 {
|
||||
return tx.Model(&mapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
|
||||
return tx.Model(&MapTileSourceRecord{}).Where("id <> ? AND is_default = ?", defaults[0].ID, true).Update("is_default", false).Error
|
||||
}
|
||||
|
||||
var enabled mapTileSourceRecord
|
||||
var enabled MapTileSourceRecord
|
||||
err := tx.Where("enabled = ?", true).Order("id ASC").Take(&enabled).Error
|
||||
if err == nil {
|
||||
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
|
||||
return tx.Model(&MapTileSourceRecord{}).Where("id = ?", enabled.ID).Updates(map[string]any{"is_default": true, "updated_at": time.Now()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
row := defaultMapTileSourceRecord()
|
||||
var existing mapTileSourceRecord
|
||||
var existing MapTileSourceRecord
|
||||
err = tx.Where("name = ? OR url_template = ?", row.Name, row.URLTemplate).Order("id ASC").Take(&existing).Error
|
||||
if err == nil {
|
||||
return tx.Model(&mapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
|
||||
return tx.Model(&MapTileSourceRecord{}).Where("id = ?", existing.ID).Updates(map[string]any{"enabled": true, "is_default": true, "updated_at": time.Now()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
@@ -257,16 +257,16 @@ func (s *store) EnsureDefaultMapTileSource() error {
|
||||
})
|
||||
}
|
||||
|
||||
func mapTileSourceHash(urlTemplate string) string {
|
||||
func MapTileSourceHash(urlTemplate string) string {
|
||||
h := sha256.Sum256([]byte(urlTemplate))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func defaultMapTileSourceRecord() mapTileSourceRecord {
|
||||
return mapTileSourceRecord{
|
||||
func defaultMapTileSourceRecord() MapTileSourceRecord {
|
||||
return MapTileSourceRecord{
|
||||
Name: defaultMapTileSourceName,
|
||||
URLTemplate: defaultMapTileSourceURLTemplate,
|
||||
URLTemplateHash: mapTileSourceHash(defaultMapTileSourceURLTemplate),
|
||||
URLTemplateHash: MapTileSourceHash(defaultMapTileSourceURLTemplate),
|
||||
Attribution: defaultMapTileSourceAttribution,
|
||||
MaxZoom: defaultMapTileSourceMaxZoom,
|
||||
Enabled: true,
|
||||
@@ -275,7 +275,7 @@ func defaultMapTileSourceRecord() mapTileSourceRecord {
|
||||
}
|
||||
}
|
||||
|
||||
func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, error) {
|
||||
func mapTileSourceFromInput(input MapTileSourceInput) (*MapTileSourceRecord, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("map source name is required")
|
||||
@@ -291,10 +291,10 @@ func mapTileSourceFromInput(input mapTileSourceInput) (*mapTileSourceRecord, err
|
||||
if maxZoom < 1 || maxZoom > 30 {
|
||||
return nil, fmt.Errorf("max zoom must be between 1 and 30")
|
||||
}
|
||||
return &mapTileSourceRecord{
|
||||
return &MapTileSourceRecord{
|
||||
Name: name,
|
||||
URLTemplate: urlTemplate,
|
||||
URLTemplateHash: mapTileSourceHash(urlTemplate),
|
||||
URLTemplateHash: MapTileSourceHash(urlTemplate),
|
||||
Attribution: strings.TrimSpace(input.Attribution),
|
||||
MaxZoom: maxZoom,
|
||||
Enabled: input.Enabled,
|
||||
@@ -337,19 +337,19 @@ func normalizeMapTileSourceURLTemplate(value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
|
||||
func (s *Store) ensureMapTileSourceUnique(id uint64, name, urlTemplate string) error {
|
||||
return ensureMapTileSourceUniqueTx(s.db, id, name, urlTemplate)
|
||||
}
|
||||
|
||||
func ensureMapTileSourceUniqueTx(tx *gorm.DB, id uint64, name, urlTemplate string) error {
|
||||
var existing mapTileSourceRecord
|
||||
var existing MapTileSourceRecord
|
||||
q := tx.Where("name = ? OR url_template = ?", name, urlTemplate)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
}
|
||||
err := q.Take(&existing).Error
|
||||
if err == nil {
|
||||
return errMapTileSourceAlreadyExists
|
||||
return ErrMapTileSourceAlreadyExists
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -10,16 +10,16 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
mqttForwardDirectionSourceToTarget = "source_to_target"
|
||||
mqttForwardDirectionBidirectional = "bidirectional"
|
||||
MQTTForwardDirectionSourceToTarget = "source_to_target"
|
||||
MQTTForwardDirectionBidirectional = "bidirectional"
|
||||
)
|
||||
|
||||
var (
|
||||
errMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists")
|
||||
errMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists")
|
||||
ErrMQTTForwarderAlreadyExists = errors.New("mqtt forwarder already exists")
|
||||
ErrMQTTForwardTopicAlreadyExists = errors.New("mqtt forward topic already exists")
|
||||
)
|
||||
|
||||
type mqttForwarderInput struct {
|
||||
type MQTTForwarderInput struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
SourceHost string
|
||||
@@ -36,7 +36,7 @@ type mqttForwarderInput struct {
|
||||
TargetTLS bool
|
||||
}
|
||||
|
||||
type mqttForwardTopicInput struct {
|
||||
type MQTTForwardTopicInput struct {
|
||||
Topic string
|
||||
Enabled bool
|
||||
Direction string
|
||||
@@ -46,15 +46,15 @@ type mqttForwardTopicInput struct {
|
||||
Retain bool
|
||||
}
|
||||
|
||||
type mqttForwarderConfig struct {
|
||||
Forwarder mqttForwarderRecord
|
||||
Topics []mqttForwardTopicRecord
|
||||
type MQTTForwarderConfig struct {
|
||||
Forwarder MQTTForwarderRecord
|
||||
Topics []MQTTForwardTopicRecord
|
||||
}
|
||||
|
||||
func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []mqttForwarderRecord
|
||||
q := s.db.Model(&mqttForwarderRecord{}).
|
||||
func (s *Store) ListMQTTForwarders(opts ListOptions) ([]MQTTForwarderRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []MQTTForwarderRecord
|
||||
q := s.db.Model(&MQTTForwarderRecord{}).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
@@ -62,20 +62,20 @@ func (s *store) ListMQTTForwarders(opts listOptions) ([]mqttForwarderRecord, err
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountMQTTForwarders(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountMQTTForwarders(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&mqttForwarderRecord{}).Count(&total).Error
|
||||
return total, s.db.Model(&MQTTForwarderRecord{}).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) GetMQTTForwarder(id uint64) (*mqttForwarderRecord, error) {
|
||||
var row mqttForwarderRecord
|
||||
func (s *Store) GetMQTTForwarder(id uint64) (*MQTTForwarderRecord, error) {
|
||||
var row MQTTForwarderRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
||||
func (s *Store) CreateMQTTForwarder(input MQTTForwarderInput) (*MQTTForwarderRecord, error) {
|
||||
row, err := mqttForwarderFromInput(input, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -89,7 +89,7 @@ func (s *store) CreateMQTTForwarder(input mqttForwarderInput) (*mqttForwarderRec
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttForwarderRecord, error) {
|
||||
func (s *Store) UpdateMQTTForwarder(id uint64, input MQTTForwarderInput) (*MQTTForwarderRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||
}
|
||||
@@ -112,21 +112,21 @@ func (s *store) UpdateMQTTForwarder(id uint64, input mqttForwarderInput) (*mqttF
|
||||
"target_password": row.TargetPassword, "target_client_id": row.TargetClientID, "target_tls": row.TargetTLS,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := s.db.Model(&mqttForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&MQTTForwarderRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetMQTTForwarder(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteMQTTForwarder(id uint64) error {
|
||||
func (s *Store) DeleteMQTTForwarder(id uint64) error {
|
||||
if id == 0 {
|
||||
return fmt.Errorf("mqtt forwarder id is required")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("forwarder_id = ?", id).Delete(&mqttForwardTopicRecord{}).Error; err != nil {
|
||||
if err := tx.Where("forwarder_id = ?", id).Delete(&MQTTForwardTopicRecord{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Where("id = ?", id).Delete(&mqttForwarderRecord{})
|
||||
result := tx.Where("id = ?", id).Delete(&MQTTForwarderRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -137,10 +137,10 @@ func (s *store) DeleteMQTTForwarder(id uint64) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]mqttForwardTopicRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []mqttForwardTopicRecord
|
||||
q := s.db.Model(&mqttForwardTopicRecord{}).
|
||||
func (s *Store) ListMQTTForwardTopics(forwarderID uint64, opts ListOptions) ([]MQTTForwardTopicRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []MQTTForwardTopicRecord
|
||||
q := s.db.Model(&MQTTForwardTopicRecord{}).
|
||||
Where("forwarder_id = ?", forwarderID).
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
@@ -149,20 +149,20 @@ func (s *store) ListMQTTForwardTopics(forwarderID uint64, opts listOptions) ([]m
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) {
|
||||
func (s *Store) CountMQTTForwardTopics(forwarderID uint64) (int64, error) {
|
||||
var total int64
|
||||
return total, s.db.Model(&mqttForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error
|
||||
return total, s.db.Model(&MQTTForwardTopicRecord{}).Where("forwarder_id = ?", forwarderID).Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) GetMQTTForwardTopic(id uint64) (*mqttForwardTopicRecord, error) {
|
||||
var row mqttForwardTopicRecord
|
||||
func (s *Store) GetMQTTForwardTopic(id uint64) (*MQTTForwardTopicRecord, error) {
|
||||
var row MQTTForwardTopicRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||
func (s *Store) CreateMQTTForwardTopic(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||
if _, err := s.GetMQTTForwarder(forwarderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func (s *store) CreateMQTTForwardTopic(forwarderID uint64, input mqttForwardTopi
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||
func (s *Store) UpdateMQTTForwardTopic(id uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("mqtt forward topic id is required")
|
||||
}
|
||||
@@ -199,14 +199,14 @@ func (s *store) UpdateMQTTForwardTopic(id uint64, input mqttForwardTopicInput) (
|
||||
"source_prefix": row.SourcePrefix, "target_prefix": row.TargetPrefix,
|
||||
"qos": row.QoS, "retain": row.Retain, "updated_at": time.Now(),
|
||||
}
|
||||
if err := s.db.Model(&mqttForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
if err := s.db.Model(&MQTTForwardTopicRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetMQTTForwardTopic(id)
|
||||
}
|
||||
|
||||
func (s *store) DeleteMQTTForwardTopic(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&mqttForwardTopicRecord{})
|
||||
func (s *Store) DeleteMQTTForwardTopic(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&MQTTForwardTopicRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -216,39 +216,39 @@ func (s *store) DeleteMQTTForwardTopic(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) GetMQTTForwarderConfig(id uint64) (*mqttForwarderConfig, error) {
|
||||
func (s *Store) GetMQTTForwarderConfig(id uint64) (*MQTTForwarderConfig, error) {
|
||||
forwarder, err := s.GetMQTTForwarder(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var topics []mqttForwardTopicRecord
|
||||
var topics []MQTTForwardTopicRecord
|
||||
if err := s.db.Where("forwarder_id = ? AND enabled = ?", id, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mqttForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil
|
||||
return &MQTTForwarderConfig{Forwarder: *forwarder, Topics: topics}, nil
|
||||
}
|
||||
|
||||
func (s *store) ListEnabledMQTTForwarderConfigs() ([]mqttForwarderConfig, error) {
|
||||
var forwarders []mqttForwarderRecord
|
||||
func (s *Store) ListEnabledMQTTForwarderConfigs() ([]MQTTForwarderConfig, error) {
|
||||
var forwarders []MQTTForwarderRecord
|
||||
if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&forwarders).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs := make([]mqttForwarderConfig, 0, len(forwarders))
|
||||
configs := make([]MQTTForwarderConfig, 0, len(forwarders))
|
||||
for _, forwarder := range forwarders {
|
||||
var topics []mqttForwardTopicRecord
|
||||
var topics []MQTTForwardTopicRecord
|
||||
if err := s.db.Where("forwarder_id = ? AND enabled = ?", forwarder.ID, true).Order("id ASC").Find(&topics).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(topics) == 0 {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, mqttForwarderConfig{Forwarder: forwarder, Topics: topics})
|
||||
configs = append(configs, MQTTForwarderConfig{Forwarder: forwarder, Topics: topics})
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
||||
var existing mqttForwarderRecord
|
||||
func (s *Store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
||||
var existing MQTTForwarderRecord
|
||||
q := s.db.Where("name = ?", name)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
@@ -260,11 +260,11 @@ func (s *store) ensureMQTTForwarderNameUnique(id uint64, name string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errMQTTForwarderAlreadyExists
|
||||
return ErrMQTTForwarderAlreadyExists
|
||||
}
|
||||
|
||||
func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error {
|
||||
var existing mqttForwardTopicRecord
|
||||
func (s *Store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic string) error {
|
||||
var existing MQTTForwardTopicRecord
|
||||
q := s.db.Where("forwarder_id = ? AND topic = ?", forwarderID, topic)
|
||||
if id != 0 {
|
||||
q = q.Where("id <> ?", id)
|
||||
@@ -276,10 +276,10 @@ func (s *store) ensureMQTTForwardTopicUnique(id, forwarderID uint64, topic strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errMQTTForwardTopicAlreadyExists
|
||||
return ErrMQTTForwardTopicAlreadyExists
|
||||
}
|
||||
|
||||
func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRecord) (*mqttForwarderRecord, error) {
|
||||
func mqttForwarderFromInput(input MQTTForwarderInput, existing *MQTTForwarderRecord) (*MQTTForwarderRecord, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("mqtt forwarder name is required")
|
||||
@@ -298,7 +298,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec
|
||||
if err := validateMQTTForwardPort(input.TargetPort, "target port"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := &mqttForwarderRecord{
|
||||
row := &MQTTForwarderRecord{
|
||||
Name: name, Enabled: input.Enabled,
|
||||
SourceHost: sourceHost, SourcePort: input.SourcePort, SourceUsername: strings.TrimSpace(input.SourceUsername), SourceClientID: strings.TrimSpace(input.SourceClientID), SourceTLS: input.SourceTLS,
|
||||
TargetHost: targetHost, TargetPort: input.TargetPort, TargetUsername: strings.TrimSpace(input.TargetUsername), TargetClientID: strings.TrimSpace(input.TargetClientID), TargetTLS: input.TargetTLS,
|
||||
@@ -316,7 +316,7 @@ func mqttForwarderFromInput(input mqttForwarderInput, existing *mqttForwarderRec
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput) (*mqttForwardTopicRecord, error) {
|
||||
func mqttForwardTopicFromInput(forwarderID uint64, input MQTTForwardTopicInput) (*MQTTForwardTopicRecord, error) {
|
||||
if forwarderID == 0 {
|
||||
return nil, fmt.Errorf("mqtt forwarder id is required")
|
||||
}
|
||||
@@ -331,7 +331,7 @@ func mqttForwardTopicFromInput(forwarderID uint64, input mqttForwardTopicInput)
|
||||
if input.QoS < 0 || input.QoS > 2 {
|
||||
return nil, fmt.Errorf("qos must be 0, 1, or 2")
|
||||
}
|
||||
return &mqttForwardTopicRecord{
|
||||
return &MQTTForwardTopicRecord{
|
||||
ForwarderID: forwarderID, Topic: topic, Enabled: input.Enabled, Direction: direction,
|
||||
SourcePrefix: strings.Trim(strings.TrimSpace(input.SourcePrefix), "/"),
|
||||
TargetPrefix: strings.Trim(strings.TrimSpace(input.TargetPrefix), "/"),
|
||||
@@ -349,10 +349,10 @@ func validateMQTTForwardPort(port int, label string) error {
|
||||
func normalizeMQTTForwardDirection(direction string) (string, error) {
|
||||
direction = strings.TrimSpace(direction)
|
||||
if direction == "" {
|
||||
direction = mqttForwardDirectionSourceToTarget
|
||||
direction = MQTTForwardDirectionSourceToTarget
|
||||
}
|
||||
switch direction {
|
||||
case mqttForwardDirectionSourceToTarget, mqttForwardDirectionBidirectional:
|
||||
case MQTTForwardDirectionSourceToTarget, MQTTForwardDirectionBidirectional:
|
||||
return direction, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid mqtt forward direction")
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -12,29 +12,45 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||
RuntimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||
RuntimeSettingLLMQueueEnabled = "llm.queue_enabled"
|
||||
RuntimeSettingLLMQueueIncludeChannel = "llm.include_channel_messages"
|
||||
runtimeSettingTypeBool = "bool"
|
||||
)
|
||||
|
||||
type runtimeSettingsSnapshot struct {
|
||||
type RuntimeSettingsSnapshot struct {
|
||||
AllowEncryptedForwarding bool
|
||||
LLMQueueEnabled bool
|
||||
LLMIncludeChannel bool
|
||||
}
|
||||
|
||||
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
||||
allowEncrypted, err := s.GetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false)
|
||||
func (s *Store) GetRuntimeSettings() (RuntimeSettingsSnapshot, error) {
|
||||
allowEncrypted, err := s.GetBoolRuntimeSetting(RuntimeSettingAllowEncryptedForwarding, false)
|
||||
if err != nil {
|
||||
return runtimeSettingsSnapshot{}, err
|
||||
return RuntimeSettingsSnapshot{}, err
|
||||
}
|
||||
return runtimeSettingsSnapshot{AllowEncryptedForwarding: allowEncrypted}, nil
|
||||
llmQueueEnabled, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueEnabled, true)
|
||||
if err != nil {
|
||||
return RuntimeSettingsSnapshot{}, err
|
||||
}
|
||||
llmIncludeChannel, err := s.GetBoolRuntimeSetting(RuntimeSettingLLMQueueIncludeChannel, false)
|
||||
if err != nil {
|
||||
return RuntimeSettingsSnapshot{}, err
|
||||
}
|
||||
return RuntimeSettingsSnapshot{
|
||||
AllowEncryptedForwarding: allowEncrypted,
|
||||
LLMQueueEnabled: llmQueueEnabled,
|
||||
LLMIncludeChannel: llmIncludeChannel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
||||
func (s *Store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false, fmt.Errorf("runtime setting key is required")
|
||||
}
|
||||
|
||||
var row runtimeSettingRecord
|
||||
var row RuntimeSettingRecord
|
||||
err := s.db.Where("`key` = ?", key).Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return defaultValue, nil
|
||||
@@ -52,13 +68,13 @@ func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, erro
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *store) SetBoolRuntimeSetting(key string, value bool, label string) (*runtimeSettingRecord, error) {
|
||||
func (s *Store) SetBoolRuntimeSetting(key string, value bool, label string) (*RuntimeSettingRecord, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("runtime setting key is required")
|
||||
}
|
||||
|
||||
row := runtimeSettingRecord{
|
||||
row := RuntimeSettingRecord{
|
||||
Key: key,
|
||||
Value: strconv.FormatBool(value),
|
||||
ValueType: runtimeSettingTypeBool,
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"meshtastic_mqtt_server/internal/config"
|
||||
)
|
||||
|
||||
func (s *Store) ListSigns(opts ListOptions) ([]SignRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []SignRecord
|
||||
q := applySignFilters(s.db.Model(&SignRecord{}), opts).
|
||||
Order("sign_time DESC").
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
type SignDayCount struct {
|
||||
Date string `gorm:"column:sign_date"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
}
|
||||
|
||||
func (s *Store) CountSigns(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
q := applySignFilters(s.db.Model(&SignRecord{}), opts)
|
||||
return total, q.Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *Store) CountSignsByDay(opts ListOptions) ([]SignDayCount, error) {
|
||||
var rows []SignDayCount
|
||||
dateExpr := "strftime('%Y-%m-%d', sign_time)"
|
||||
if s.driver == config.DriverMySQL {
|
||||
dateExpr = "DATE_FORMAT(sign_time, '%Y-%m-%d')"
|
||||
}
|
||||
q := applySignFilters(s.db.Model(&SignRecord{}), opts).
|
||||
Select(dateExpr + " AS sign_date, COUNT(*) AS count").
|
||||
Group(dateExpr).
|
||||
Order("sign_date DESC")
|
||||
return rows, q.Scan(&rows).Error
|
||||
}
|
||||
|
||||
// HasSignedOnDay 判断指定节点在 day 所属的自然日(按 day 的时区)是否已有签到记录。
|
||||
// 用 Go 端计算当日起止时间再查询,避免依赖 SQLite/MySQL 各自的日期函数。
|
||||
func (s *Store) HasSignedOnDay(nodeID string, day time.Time) (bool, error) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
if nodeID == "" {
|
||||
return false, fmt.Errorf("node id is required")
|
||||
}
|
||||
loc := day.Location()
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var count int64
|
||||
if err := s.db.Model(&SignRecord{}).
|
||||
Where("node_id = ? AND sign_time >= ? AND sign_time < ?", nodeID, start, end).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, fmt.Errorf("check sign on day: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSignByID(id uint64) (*SignRecord, error) {
|
||||
var row SignRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) {
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
signText = strings.TrimSpace(signText)
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node id is required")
|
||||
}
|
||||
if signText == "" {
|
||||
return nil, fmt.Errorf("sign text is required")
|
||||
}
|
||||
if signTime.IsZero() {
|
||||
signTime = time.Now()
|
||||
}
|
||||
row := SignRecord{NodeID: nodeID, LongName: trimNullableString(longName), ShortName: trimNullableString(shortName), SignText: signText, SignTime: signTime}
|
||||
if err := s.db.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSign(id uint64, nodeID string, longName, shortName *string, signText string, signTime time.Time) (*SignRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("sign id is required")
|
||||
}
|
||||
nodeID = strings.TrimSpace(nodeID)
|
||||
signText = strings.TrimSpace(signText)
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node id is required")
|
||||
}
|
||||
if signText == "" {
|
||||
return nil, fmt.Errorf("sign text is required")
|
||||
}
|
||||
if signTime.IsZero() {
|
||||
signTime = time.Now()
|
||||
}
|
||||
if _, err := s.GetSignByID(id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates := map[string]any{"node_id": nodeID, "long_name": trimNullableString(longName), "short_name": trimNullableString(shortName), "sign_text": signText, "sign_time": signTime}
|
||||
if err := s.db.Model(&SignRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetSignByID(id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSign(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&SignRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySignFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||
if opts.NodeID != "" {
|
||||
q = q.Where("node_id = ?", opts.NodeID)
|
||||
}
|
||||
if opts.Since != nil {
|
||||
q = q.Where("sign_time >= ?", *opts.Since)
|
||||
}
|
||||
if opts.Until != nil {
|
||||
q = q.Where("sign_time <= ?", *opts.Until)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func trimNullableString(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type listOptions struct {
|
||||
type ListOptions struct {
|
||||
Limit int
|
||||
Offset int
|
||||
NodeID string
|
||||
@@ -21,31 +21,31 @@ type listOptions struct {
|
||||
MaxLng *float64
|
||||
}
|
||||
|
||||
type mapReportViewportOptions struct {
|
||||
ListOptions listOptions
|
||||
type MapReportViewportOptions struct {
|
||||
ListOptions ListOptions
|
||||
Zoom int
|
||||
Limit int
|
||||
ClusterThreshold int
|
||||
TargetCells int
|
||||
}
|
||||
|
||||
type mapReportViewportResult struct {
|
||||
type MapReportViewportResult struct {
|
||||
Mode string
|
||||
Total int64
|
||||
Points []mapReportRecord
|
||||
Clusters []mapReportClusterRecord
|
||||
Points []MapReportRecord
|
||||
Clusters []MapReportClusterRecord
|
||||
Limit int
|
||||
Zoom int
|
||||
}
|
||||
|
||||
type mapReportClusterRecord struct {
|
||||
type MapReportClusterRecord struct {
|
||||
ClusterID string
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
Count int64
|
||||
}
|
||||
|
||||
func (s *store) Ping() error {
|
||||
func (s *Store) Ping() error {
|
||||
db, err := s.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -53,7 +53,7 @@ func (s *store) Ping() error {
|
||||
return db.Ping()
|
||||
}
|
||||
|
||||
func normalizeListOptions(opts listOptions) listOptions {
|
||||
func NormalizeListOptions(opts ListOptions) ListOptions {
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = 100
|
||||
}
|
||||
@@ -66,64 +66,64 @@ func normalizeListOptions(opts listOptions) listOptions {
|
||||
return opts
|
||||
}
|
||||
|
||||
func (s *store) ListNodeInfo(opts listOptions) ([]nodeInfoRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []nodeInfoRecord
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts).
|
||||
func (s *Store) ListNodeInfo(opts ListOptions) ([]NodeInfoRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []NodeInfoRecord
|
||||
q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts).
|
||||
Order("updated_at DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountNodeInfo(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountNodeInfo(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
q := applyNodeFilters(s.db.Model(&nodeInfoRecord{}), opts)
|
||||
q := applyNodeFilters(s.db.Model(&NodeInfoRecord{}), opts)
|
||||
return total, q.Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) GetNodeInfo(nodeID string) (*nodeInfoRecord, error) {
|
||||
var row nodeInfoRecord
|
||||
func (s *Store) GetNodeInfo(nodeID string) (*NodeInfoRecord, error) {
|
||||
var row NodeInfoRecord
|
||||
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) ListMapReports(opts listOptions) ([]mapReportRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []mapReportRecord
|
||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts).
|
||||
func (s *Store) ListMapReports(opts ListOptions) ([]MapReportRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []MapReportRecord
|
||||
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts).
|
||||
Order("updated_at DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountMapReports(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountMapReports(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts)
|
||||
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts)
|
||||
return total, q.Count(&total).Error
|
||||
}
|
||||
|
||||
func (s *store) GetMapReport(nodeID string) (*mapReportRecord, error) {
|
||||
var row mapReportRecord
|
||||
func (s *Store) GetMapReport(nodeID string) (*MapReportRecord, error) {
|
||||
var row MapReportRecord
|
||||
if err := s.db.Where("node_id = ?", nodeID).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReportViewportResult, error) {
|
||||
opts = normalizeMapReportViewportOptions(opts)
|
||||
func (s *Store) ListMapReportViewport(opts MapReportViewportOptions) (*MapReportViewportResult, error) {
|
||||
opts = NormalizeMapReportViewportOptions(opts)
|
||||
total, err := s.CountMapReports(opts.ListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &mapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom}
|
||||
result := &MapReportViewportResult{Total: total, Limit: opts.Limit, Zoom: opts.Zoom}
|
||||
if total <= int64(opts.ClusterThreshold) {
|
||||
var points []mapReportRecord
|
||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
||||
var points []MapReportRecord
|
||||
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions).
|
||||
Order("updated_at DESC").
|
||||
Limit(opts.Limit)
|
||||
if err := q.Find(&points).Error; err != nil {
|
||||
@@ -142,8 +142,8 @@ func (s *store) ListMapReportViewport(opts mapReportViewportOptions) (*mapReport
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapReportClusterRecord, error) {
|
||||
opts = normalizeMapReportViewportOptions(opts)
|
||||
func (s *Store) ListMapReportClusters(opts MapReportViewportOptions) ([]MapReportClusterRecord, error) {
|
||||
opts = NormalizeMapReportViewportOptions(opts)
|
||||
cellSize := mapReportClusterCellSize(opts.ListOptions, opts.TargetCells)
|
||||
var rows []struct {
|
||||
LatBucket int64
|
||||
@@ -152,7 +152,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
||||
Longitude float64
|
||||
Count int64
|
||||
}
|
||||
q := applyMapReportFilters(s.db.Model(&mapReportRecord{}), opts.ListOptions).
|
||||
q := applyMapReportFilters(s.db.Model(&MapReportRecord{}), opts.ListOptions).
|
||||
Select("CAST((latitude + 90.0) / ? AS INTEGER) AS lat_bucket, CAST((longitude + 180.0) / ? AS INTEGER) AS lng_bucket, AVG(latitude) AS latitude, AVG(longitude) AS longitude, COUNT(*) AS count", cellSize, cellSize).
|
||||
Group("lat_bucket, lng_bucket").
|
||||
Order("count DESC").
|
||||
@@ -160,9 +160,9 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clusters := make([]mapReportClusterRecord, 0, len(rows))
|
||||
clusters := make([]MapReportClusterRecord, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
clusters = append(clusters, mapReportClusterRecord{
|
||||
clusters = append(clusters, MapReportClusterRecord{
|
||||
ClusterID: fmt.Sprintf("%d:%d", row.LatBucket, row.LngBucket),
|
||||
Latitude: row.Latitude,
|
||||
Longitude: row.Longitude,
|
||||
@@ -172,7 +172,7 @@ func (s *store) ListMapReportClusters(opts mapReportViewportOptions) ([]mapRepor
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportViewportOptions {
|
||||
func NormalizeMapReportViewportOptions(opts MapReportViewportOptions) MapReportViewportOptions {
|
||||
if opts.Limit <= 0 {
|
||||
opts.Limit = 1000
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func normalizeMapReportViewportOptions(opts mapReportViewportOptions) mapReportV
|
||||
return opts
|
||||
}
|
||||
|
||||
func mapReportClusterCellSize(opts listOptions, targetCells int) float64 {
|
||||
func mapReportClusterCellSize(opts ListOptions, targetCells int) float64 {
|
||||
latSpan := 180.0
|
||||
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||
latSpan = *opts.MaxLat - *opts.MinLat
|
||||
@@ -215,13 +215,13 @@ func mapReportClusterCellSize(opts listOptions, targetCells int) float64 {
|
||||
return cellSize
|
||||
}
|
||||
|
||||
func (s *store) DeleteNode(nodeID string) error {
|
||||
func (s *Store) DeleteNode(nodeID string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&nodeInfoRecord{})
|
||||
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&NodeInfoRecord{})
|
||||
if nodeResult.Error != nil {
|
||||
return nodeResult.Error
|
||||
}
|
||||
reportResult := tx.Where("node_id = ?", nodeID).Delete(&mapReportRecord{})
|
||||
reportResult := tx.Where("node_id = ?", nodeID).Delete(&MapReportRecord{})
|
||||
if reportResult.Error != nil {
|
||||
return reportResult.Error
|
||||
}
|
||||
@@ -232,7 +232,54 @@ func (s *store) DeleteNode(nodeID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
// PurgeNode 在「删除节点」菜单触发时执行:除了 nodeinfo + map_report,
|
||||
// 还要把 text_message(频道聊天)以及 position/telemetry/routing/traceroute
|
||||
// 这些以 from_id 关联的数据包记录一起清理。
|
||||
//
|
||||
// 任一表删到记录就视为成功;全部为空才返回 ErrRecordNotFound。
|
||||
func (s *Store) PurgeNode(nodeID string) error {
|
||||
if nodeID == "" {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var totalAffected int64
|
||||
|
||||
nodeResult := tx.Where("node_id = ?", nodeID).Delete(&NodeInfoRecord{})
|
||||
if nodeResult.Error != nil {
|
||||
return nodeResult.Error
|
||||
}
|
||||
totalAffected += nodeResult.RowsAffected
|
||||
|
||||
reportResult := tx.Where("node_id = ?", nodeID).Delete(&MapReportRecord{})
|
||||
if reportResult.Error != nil {
|
||||
return reportResult.Error
|
||||
}
|
||||
totalAffected += reportResult.RowsAffected
|
||||
|
||||
// 以 from_id 关联:聊天消息 + 数据包流水
|
||||
fromIDTargets := []any{
|
||||
&TextMessageRecord{},
|
||||
&PositionRecord{},
|
||||
&TelemetryRecord{},
|
||||
&RoutingRecord{},
|
||||
&TracerouteRecord{},
|
||||
}
|
||||
for _, model := range fromIDTargets {
|
||||
res := tx.Where("from_id = ?", nodeID).Delete(model)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
totalAffected += res.RowsAffected
|
||||
}
|
||||
|
||||
if totalAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func applyNodeFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||
if opts.NodeID != "" {
|
||||
q = q.Where("node_id = ?", opts.NodeID)
|
||||
}
|
||||
@@ -245,7 +292,7 @@ func applyNodeFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
func applyMapReportFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||
q = applyNodeFilters(q, opts)
|
||||
if opts.MinLat != nil && opts.MaxLat != nil {
|
||||
q = q.Where("latitude IS NOT NULL AND latitude >= ? AND latitude <= ?", *opts.MinLat, *opts.MaxLat)
|
||||
@@ -260,29 +307,28 @@ func applyMapReportFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
func (s *store) ListTextMessages(opts listOptions) ([]textMessageRecord, error) {
|
||||
var rows []textMessageRecord
|
||||
func (s *Store) ListTextMessages(opts ListOptions) ([]TextMessageRecord, error) {
|
||||
var rows []TextMessageRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
}
|
||||
|
||||
func (s *store) ListDiscardDetails(opts listOptions) ([]discardDetailsRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []discardDetailsRecord
|
||||
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts).
|
||||
Order("created_at DESC").
|
||||
func (s *Store) ListDiscardDetails(opts ListOptions) ([]DiscardDetailsRecord, error) {
|
||||
opts = NormalizeListOptions(opts)
|
||||
var rows []DiscardDetailsRecord
|
||||
q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts).
|
||||
Order("id DESC").
|
||||
Limit(opts.Limit).
|
||||
Offset(opts.Offset)
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
|
||||
func (s *store) CountDiscardDetails(opts listOptions) (int64, error) {
|
||||
func (s *Store) CountDiscardDetails(opts ListOptions) (int64, error) {
|
||||
var total int64
|
||||
q := applyDiscardDetailsFilters(s.db.Model(&discardDetailsRecord{}), opts)
|
||||
q := applyDiscardDetailsFilters(s.db.Model(&DiscardDetailsRecord{}), opts)
|
||||
return total, q.Count(&total).Error
|
||||
}
|
||||
|
||||
func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
func applyDiscardDetailsFilters(q *gorm.DB, opts ListOptions) *gorm.DB {
|
||||
if opts.Since != nil {
|
||||
q = q.Where("created_at >= ?", *opts.Since)
|
||||
}
|
||||
@@ -292,8 +338,21 @@ func applyDiscardDetailsFilters(q *gorm.DB, opts listOptions) *gorm.DB {
|
||||
return q
|
||||
}
|
||||
|
||||
func (s *store) DeleteTextMessage(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&textMessageRecord{})
|
||||
func (s *Store) DeleteDiscardDetailsByIDs(ids []uint64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result := s.db.Where("id IN ?", ids).Delete(&DiscardDetailsRecord{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAllDiscardDetails() (int64, error) {
|
||||
result := s.db.Where("1 = 1").Delete(&DiscardDetailsRecord{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (s *Store) DeleteTextMessage(id uint64) error {
|
||||
result := s.db.Where("id = ?", id).Delete(&TextMessageRecord{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
@@ -303,29 +362,29 @@ func (s *store) DeleteTextMessage(id uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) ListPositions(opts listOptions) ([]positionRecord, error) {
|
||||
var rows []positionRecord
|
||||
func (s *Store) ListPositions(opts ListOptions) ([]PositionRecord, error) {
|
||||
var rows []PositionRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
}
|
||||
|
||||
func (s *store) ListTelemetry(opts listOptions) ([]telemetryRecord, error) {
|
||||
var rows []telemetryRecord
|
||||
func (s *Store) ListTelemetry(opts ListOptions) ([]TelemetryRecord, error) {
|
||||
var rows []TelemetryRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
}
|
||||
|
||||
func (s *store) ListRouting(opts listOptions) ([]routingRecord, error) {
|
||||
var rows []routingRecord
|
||||
func (s *Store) ListRouting(opts ListOptions) ([]RoutingRecord, error) {
|
||||
var rows []RoutingRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
}
|
||||
|
||||
func (s *store) ListTraceroute(opts listOptions) ([]tracerouteRecord, error) {
|
||||
var rows []tracerouteRecord
|
||||
func (s *Store) ListTraceroute(opts ListOptions) ([]TracerouteRecord, error) {
|
||||
var rows []TracerouteRecord
|
||||
return rows, s.listAppendRows(opts, &rows).Error
|
||||
}
|
||||
|
||||
func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB {
|
||||
opts = normalizeListOptions(opts)
|
||||
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||
func (s *Store) listAppendRows(opts ListOptions, dest any) *gorm.DB {
|
||||
opts = NormalizeListOptions(opts)
|
||||
q := s.db.Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||
if opts.NodeID != "" {
|
||||
q = q.Where("from_id = ?", opts.NodeID)
|
||||
}
|
||||
@@ -340,3 +399,8 @@ func (s *store) listAppendRows(opts listOptions, dest any) *gorm.DB {
|
||||
}
|
||||
return q.Find(dest)
|
||||
}
|
||||
|
||||
func (s *Store) ListChannels() ([]ChannelRecord, error) {
|
||||
var rows []ChannelRecord
|
||||
return rows, s.db.Order("channel_id ASC").Find(&rows).Error
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -9,30 +9,30 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var errUserAlreadyExists = errors.New("user already exists")
|
||||
var ErrUserAlreadyExists = errors.New("user already exists")
|
||||
|
||||
func (s *store) GetUserByUsername(username string) (*userRecord, error) {
|
||||
var user userRecord
|
||||
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
if err := s.db.Where("username = ?", username).Take(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) GetUserByID(id uint64) (*userRecord, error) {
|
||||
var user userRecord
|
||||
func (s *Store) GetUserByID(id uint64) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
if err := s.db.Where("id = ?", id).Take(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) ListUsers() ([]userRecord, error) {
|
||||
var users []userRecord
|
||||
func (s *Store) ListUsers() ([]UserRecord, error) {
|
||||
var users []UserRecord
|
||||
return users, s.db.Order("id ASC").Find(&users).Error
|
||||
}
|
||||
|
||||
func (s *store) CreateAdminUser(username, password string) (*userRecord, error) {
|
||||
func (s *Store) CreateAdminUser(username, password string) (*UserRecord, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return nil, fmt.Errorf("username is required")
|
||||
@@ -41,7 +41,7 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error)
|
||||
return nil, fmt.Errorf("password is required")
|
||||
}
|
||||
if _, err := s.GetUserByUsername(username); err == nil {
|
||||
return nil, errUserAlreadyExists
|
||||
return nil, ErrUserAlreadyExists
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -49,14 +49,14 @@ func (s *store) CreateAdminUser(username, password string) (*userRecord, error)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
||||
user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, error) {
|
||||
func (s *Store) UpdateUserPassword(id uint64, password string) (*UserRecord, error) {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
@@ -71,15 +71,15 @@ func (s *store) UpdateUserPassword(id uint64, password string) (*userRecord, err
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash user password: %w", err)
|
||||
}
|
||||
if err := s.db.Model(&userRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
|
||||
if err := s.db.Model(&UserRecord{}).Where("id = ?", id).Updates(map[string]any{"password_hash": hash, "updated_at": time.Now()}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.PasswordHash = hash
|
||||
return s.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *store) EnsureDefaultAdmin(username, password string) error {
|
||||
var existing userRecord
|
||||
func (s *Store) EnsureDefaultAdmin(username, password string) error {
|
||||
var existing UserRecord
|
||||
err := s.db.Where("username = ?", username).Take(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -91,7 +91,7 @@ func (s *store) EnsureDefaultAdmin(username, password string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
user := userRecord{Username: username, PasswordHash: hash, Role: adminRole}
|
||||
user := UserRecord{Username: username, PasswordHash: hash, Role: AdminRole}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return fmt.Errorf("create default admin user: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package stream
|
||||
|
||||
import "context"
|
||||
|
||||
// Frame represents an event in the stream
|
||||
type Frame struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// EmitFunc is a function that emits frames
|
||||
type EmitFunc func(frame Frame)
|
||||
|
||||
// ContextKey type for context keys
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// TrackerContextKey is the key for the stream tracker in context
|
||||
TrackerContextKey contextKey = "stream_tracker"
|
||||
)
|
||||
|
||||
// Tracker tracks token usage during streaming
|
||||
type Tracker struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// NewTracker creates a new stream tracker
|
||||
func NewTracker() *Tracker {
|
||||
return &Tracker{}
|
||||
}
|
||||
|
||||
// AddTool adds tool call token usage
|
||||
func (t *Tracker) AddTool(promptTokens, completionTokens int) {
|
||||
t.PromptTokens += promptTokens
|
||||
t.CompletionTokens += completionTokens
|
||||
t.ToolCalls++
|
||||
}
|
||||
|
||||
// TrackerFromContext retrieves the tracker from context
|
||||
func TrackerFromContext(ctx context.Context) *Tracker {
|
||||
if tracker, ok := ctx.Value(TrackerContextKey).(*Tracker); ok {
|
||||
return tracker
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithTracker adds a tracker to the context
|
||||
func WithTracker(ctx context.Context, tracker *Tracker) context.Context {
|
||||
return context.WithValue(ctx, TrackerContextKey, tracker)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package toolmanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
)
|
||||
|
||||
// Manager manages loaded AI tools
|
||||
type Manager struct {
|
||||
tools map[string]agenttool.LoadedTool
|
||||
order []string
|
||||
}
|
||||
|
||||
// Load loads tools from the given directory
|
||||
// If directory doesn't exist or is empty, automatically loads all registered tools
|
||||
func Load(root string, options agenttool.LoadOptions) (*Manager, error) {
|
||||
manager := &Manager{tools: map[string]agenttool.LoadedTool{}}
|
||||
|
||||
// Try to read directory
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read tools directory: %w", err)
|
||||
}
|
||||
// Directory doesn't exist, continue to load all registered tools
|
||||
entries = []os.DirEntry{}
|
||||
}
|
||||
|
||||
// Load tools from directory if they exist
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(entry.Name()))
|
||||
descriptor, ok := agenttool.Lookup(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tool, err := descriptor.Load(filepath.Join(root, entry.Name()), options)
|
||||
if err != nil {
|
||||
manager.Close()
|
||||
return nil, fmt.Errorf("failed to load tool %s: %w", name, err)
|
||||
}
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.ToLower(strings.TrimSpace(tool.Name()))
|
||||
if toolName == "" {
|
||||
toolName = name
|
||||
}
|
||||
if _, ok := manager.tools[toolName]; ok {
|
||||
manager.Close()
|
||||
return nil, fmt.Errorf("duplicate tool name: %s", toolName)
|
||||
}
|
||||
manager.tools[toolName] = tool
|
||||
manager.order = append(manager.order, toolName)
|
||||
}
|
||||
|
||||
// If no tools loaded from directory, automatically load all registered tools
|
||||
if len(manager.tools) == 0 {
|
||||
registeredTools := agenttool.Names()
|
||||
for _, name := range registeredTools {
|
||||
descriptor, ok := agenttool.Lookup(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Use empty path for tools that don't require configuration files
|
||||
tool, err := descriptor.Load("", options)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
toolName := strings.ToLower(strings.TrimSpace(tool.Name()))
|
||||
if toolName == "" {
|
||||
toolName = name
|
||||
}
|
||||
if _, ok := manager.tools[toolName]; ok {
|
||||
continue
|
||||
}
|
||||
manager.tools[toolName] = tool
|
||||
manager.order = append(manager.order, toolName)
|
||||
}
|
||||
}
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// NewForTest creates a manager with preloaded tools for testing
|
||||
func NewForTest(tools ...agenttool.LoadedTool) *Manager {
|
||||
manager := &Manager{tools: map[string]agenttool.LoadedTool{}}
|
||||
for _, tool := range tools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(tool.Name()))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := manager.tools[name]; !ok {
|
||||
manager.order = append(manager.order, name)
|
||||
}
|
||||
manager.tools[name] = tool
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
// Tools returns all loaded tools
|
||||
func (m *Manager) Tools() []agenttool.LoadedTool {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
tools := make([]agenttool.LoadedTool, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
if tool := m.tools[name]; tool != nil {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// Get returns a tool by name
|
||||
func (m *Manager) Get(name string) (agenttool.LoadedTool, bool) {
|
||||
if m == nil {
|
||||
return nil, false
|
||||
}
|
||||
tool, ok := m.tools[strings.ToLower(strings.TrimSpace(name))]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
// RawState returns the raw state of a tool
|
||||
func (m *Manager) RawState(name string) (any, bool) {
|
||||
tool, ok := m.Get(name)
|
||||
if !ok || tool == nil {
|
||||
return nil, false
|
||||
}
|
||||
return tool.RawState(), true
|
||||
}
|
||||
|
||||
// Close closes all tools
|
||||
func (m *Manager) Close() error {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
for _, tool := range m.Tools() {
|
||||
if closer, ok := tool.(interface{ Close() error }); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
errs = append(errs, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
sort.Strings(errs)
|
||||
return errors.New(strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package toolrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/completion"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
"meshtastic_mqtt_server/internal/stream"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
const maxAgentToolIterations = 6
|
||||
|
||||
// RunAgentToolLoop runs the agent tool calling loop
|
||||
// systemPrompt is the primary system prompt from LLM config
|
||||
// The third return value toolUsed indicates whether at least one tool was actually
|
||||
// invoked during the loop (i.e. the model selected a tool). Callers use it to decide
|
||||
// whether to skip downstream gating (e.g. topic selection).
|
||||
func RunAgentToolLoop(ctx context.Context, state *State, profile *llm.Profile, systemPrompt string, chatMessages []message.ChatMessage, manager *toolmanager.Manager, emit stream.EmitFunc) ([]*model.ChatCompletionMessage, bool, error) {
|
||||
finalMessages, err := buildArkMessages(chatMessages)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
routerProfile := profile
|
||||
if state != nil {
|
||||
routerProfile = state.RouterProfile(profile)
|
||||
}
|
||||
tools := availableAgentTools(state, routerProfile, manager, emit)
|
||||
if len(tools) == 0 {
|
||||
// No tools available, add system prompt and return
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
systemMessage := &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &systemPrompt,
|
||||
},
|
||||
}
|
||||
finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...)
|
||||
}
|
||||
return finalMessages, false, nil
|
||||
}
|
||||
|
||||
decisionMessages := append([]*model.ChatCompletionMessage(nil), finalMessages...)
|
||||
|
||||
toolByName := make(map[string]AgentTool, len(tools))
|
||||
definitions := make([]*model.Tool, 0, len(tools))
|
||||
availableNames := make([]string, 0, len(tools))
|
||||
toolDescriptions := make([]string, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
toolByName[tool.name] = tool
|
||||
definitions = append(definitions, tool.definition)
|
||||
availableNames = append(availableNames, tool.name)
|
||||
if tool.definition != nil && tool.definition.Function != nil {
|
||||
toolDescriptions = append(toolDescriptions, fmt.Sprintf("%s: %s", tool.name, tool.definition.Function.Description))
|
||||
}
|
||||
}
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "prepare", Status: "success", Message: "已准备可用工具", Data: map[string]any{"tools": availableNames, "tool_descriptions": toolDescriptions}})
|
||||
}
|
||||
if state == nil {
|
||||
// No tool router state, but we have tools - use primary system prompt
|
||||
if strings.TrimSpace(systemPrompt) != "" {
|
||||
systemMessage := &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &systemPrompt,
|
||||
},
|
||||
}
|
||||
finalMessages = append([]*model.ChatCompletionMessage{systemMessage}, finalMessages...)
|
||||
decisionMessages = append([]*model.ChatCompletionMessage{systemMessage}, decisionMessages...)
|
||||
}
|
||||
return finalMessages, false, nil
|
||||
}
|
||||
// 每轮调用都重新加载最新配置,确保管理员在 /admin/llm/api 保存后立即生效
|
||||
cfg := state.effectiveConfig()
|
||||
// 最终回复使用主回复配置的 system prompt(机器人人设/回复指引);
|
||||
// 工具路由决策使用工具路由的 system prompt(指导如何调用工具),
|
||||
// 为空时回退到主回复 prompt。两者分离,避免工具路由 prompt 覆盖主回复 prompt。
|
||||
primaryPrompt := strings.TrimSpace(systemPrompt)
|
||||
routerPrompt := strings.TrimSpace(cfg.SystemPrompt)
|
||||
if routerPrompt == "" {
|
||||
routerPrompt = primaryPrompt
|
||||
}
|
||||
routerPrompt = routerPrompt + "\n当前日期:" + time.Now().Format("2006-01-02")
|
||||
if primaryPrompt != "" {
|
||||
primarySystemMessage := &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &primaryPrompt,
|
||||
},
|
||||
}
|
||||
finalMessages = append([]*model.ChatCompletionMessage{primarySystemMessage}, finalMessages...)
|
||||
}
|
||||
if routerPrompt != "" {
|
||||
routerSystemMessage := &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &routerPrompt,
|
||||
},
|
||||
}
|
||||
decisionMessages = append([]*model.ChatCompletionMessage{routerSystemMessage}, decisionMessages...)
|
||||
}
|
||||
// toolUsed 记录本轮是否真的执行了至少一次工具调用,供调用方决定是否跳过话题选择等后续门控。
|
||||
toolUsed := false
|
||||
// 签到意图强制调用:用户明确想签到(「签到/打卡/上台」等)时,模型却不主动调
|
||||
// sign 工具的话,会被下游话题判定当成噪音丢弃。这里在循环外预判意图,待模型
|
||||
// 该轮未请求任何工具时强制注入一次 sign 调用(用用户原文作为 raw_text),
|
||||
// 保证签到一定落库、且不会被话题判定丢弃。
|
||||
forceSignText := detectSignIntent(chatMessages)
|
||||
_, signAvailable := toolByName["sign"]
|
||||
signInvoked := false // 本轮循环中是否已经调用过 sign(含模型主动调与强制调)
|
||||
for i := 0; i < maxAgentToolIterations; i++ {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "running", Message: fmt.Sprintf("正在进行第 %d 轮工具判断", i+1), Data: map[string]any{"iteration": i + 1, "max_iterations": maxAgentToolIterations, "tools": availableNames}})
|
||||
}
|
||||
resp, err := completion.CompleteChat(ctx, routerProfile, model.CreateChatCompletionRequest{
|
||||
Model: routerProfile.Config.Model,
|
||||
Messages: decisionMessages,
|
||||
MaxTokens: &cfg.MaxTokens,
|
||||
Tools: definitions,
|
||||
ToolChoice: model.ToolChoiceStringTypeAuto,
|
||||
ParallelToolCalls: BoolPtr(false),
|
||||
}, time.Duration(cfg.Timeout)*time.Second)
|
||||
if err != nil {
|
||||
return finalMessages, toolUsed, err
|
||||
}
|
||||
if tracker := stream.TrackerFromContext(ctx); tracker != nil {
|
||||
tracker.AddTool(resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "decision", Status: "success", Message: "工具判断响应已返回", Data: map[string]any{"iteration": i + 1}})
|
||||
}
|
||||
calls := choice.Message.ToolCalls
|
||||
if len(calls) == 0 && choice.Message.FunctionCall != nil {
|
||||
calls = []*model.ToolCall{{ID: "legacy_function_call", Type: model.ToolTypeFunction, Function: *choice.Message.FunctionCall}}
|
||||
}
|
||||
if len(calls) == 0 {
|
||||
// 模型本轮未请求任何工具。若检测到签到意图且 sign 工具可用、本次循环尚未调过 sign,
|
||||
// 则强制注入一次 sign 调用(以用户原文作为 raw_text),保证签到一定落库。
|
||||
if forced := buildForcedSignCall(forceSignText, signAvailable, signInvoked); forced != nil {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "sign", Stage: "tool_calls", Status: "running", Message: "检测到签到意图,模型未调用签到工具,强制调用 sign", Data: map[string]any{"tools": []string{"sign"}, "forced": true, "iteration": i + 1}})
|
||||
}
|
||||
calls = []*model.ToolCall{forced}
|
||||
} else {
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "request", Status: "success", Message: "模型未请求工具,进入回答生成"})
|
||||
}
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
}
|
||||
callNames := make([]string, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
if call != nil {
|
||||
callNames = append(callNames, call.Function.Name)
|
||||
}
|
||||
}
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "tool_calls", Status: "running", Message: fmt.Sprintf("模型请求调用 %d 个工具", len(calls)), Data: map[string]any{"tools": callNames, "iteration": i + 1}})
|
||||
}
|
||||
// 模型确实请求了工具调用,标记 toolUsed=true
|
||||
toolUsed = true
|
||||
assistantMessage := &model.ChatCompletionMessage{Role: "assistant", ToolCalls: calls, Content: choice.Message.Content}
|
||||
finalMessages = append(finalMessages, assistantMessage)
|
||||
decisionMessages = append(decisionMessages, assistantMessage)
|
||||
for _, call := range calls {
|
||||
if call != nil && call.Function.Name == "sign" {
|
||||
signInvoked = true
|
||||
}
|
||||
result := ExecuteAgentToolCall(ctx, call, toolByName, emit)
|
||||
resultContent := &model.ChatCompletionMessageContent{StringValue: &result}
|
||||
toolMessage := &model.ChatCompletionMessage{Role: "tool", ToolCallID: call.ID, Content: resultContent}
|
||||
finalMessages = append(finalMessages, toolMessage)
|
||||
decisionMessages = append(decisionMessages, toolMessage)
|
||||
}
|
||||
}
|
||||
limitText := "工具调用轮数已达到上限。请基于已有工具结果回答,并说明可能未完成全部工具调用。"
|
||||
limitMessage := &model.ChatCompletionMessage{Role: "system", Content: &model.ChatCompletionMessageContent{StringValue: &limitText}}
|
||||
finalMessages = append(finalMessages, limitMessage)
|
||||
return finalMessages, toolUsed, nil
|
||||
}
|
||||
|
||||
func buildArkMessages(chatMessages []message.ChatMessage) ([]*model.ChatCompletionMessage, error) {
|
||||
messages := make([]*model.ChatCompletionMessage, 0, len(chatMessages))
|
||||
for _, msg := range chatMessages {
|
||||
role := msg.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
content := &model.ChatCompletionMessageContent{StringValue: &msg.Content}
|
||||
messages = append(messages, &model.ChatCompletionMessage{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// BoolPtr returns a pointer to the given bool
|
||||
func BoolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
// IntPtr returns a pointer to the given int
|
||||
func IntPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
// signIntentKeywords 是判定签到意图的关键词。命中任一即认为用户想签到。
|
||||
var signIntentKeywords = []string{"签到", "打卡", "上台"}
|
||||
|
||||
// signNegationKeywords 是会否决签到意图的关键词。当消息同时命中签到关键词与
|
||||
// 这些否决词时,说明用户不是要签到,而是要对签到记录做删除/查询/取消等操作,
|
||||
// 此时不应强制签到(否则会把「删除签到信息」这句话本身当签到正文写库)。
|
||||
var signNegationKeywords = []string{"删除", "取消", "撤回", "撤销", "清除", "清空", "查询", "查看", "列表", "统计", "不要", "别"}
|
||||
|
||||
// detectSignIntent 取最后一条用户消息,若包含签到意图关键词(且不含否决词)
|
||||
// 则返回该消息原文(去除前缀的「[来自 ...]」等格式化包装),否则返回空串。
|
||||
func detectSignIntent(chatMessages []message.ChatMessage) string {
|
||||
userText := lastUserMessageText(chatMessages)
|
||||
if strings.TrimSpace(userText) == "" {
|
||||
return ""
|
||||
}
|
||||
hit := false
|
||||
for _, kw := range signIntentKeywords {
|
||||
if strings.Contains(userText, kw) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return ""
|
||||
}
|
||||
// 命中否决词则不视为签到意图
|
||||
for _, kw := range signNegationKeywords {
|
||||
if strings.Contains(userText, kw) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return stripFromPrefix(userText)
|
||||
}
|
||||
|
||||
// stripFromPrefix 去掉 autoreply.formatUserMessage 加上的「[来自 ...] 」前缀,
|
||||
// 让签到正文只保留用户实际发送的内容。
|
||||
func stripFromPrefix(s string) string {
|
||||
if idx := strings.Index(s, "]"); idx >= 0 && strings.HasPrefix(strings.TrimSpace(s), "[") {
|
||||
return strings.TrimSpace(s[idx+1:])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// buildForcedSignCall 在满足条件时构造一次强制 sign 调用。条件:
|
||||
// - 有签到意图原文(signText 非空)
|
||||
// - sign 工具可用
|
||||
// - 本次循环尚未调过 sign(避免重复签到)
|
||||
//
|
||||
// 调用参数仅含 raw_text(用户原文),由 sign 工具回退为签到正文。
|
||||
func buildForcedSignCall(signText string, signAvailable, signInvoked bool) *model.ToolCall {
|
||||
if strings.TrimSpace(signText) == "" || !signAvailable || signInvoked {
|
||||
return nil
|
||||
}
|
||||
args, _ := json.Marshal(map[string]string{"raw_text": signText})
|
||||
argsStr := string(args)
|
||||
return &model.ToolCall{
|
||||
ID: "forced_sign",
|
||||
Type: model.ToolTypeFunction,
|
||||
Function: model.FunctionCall{
|
||||
Name: "sign",
|
||||
Arguments: argsStr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserMessageText 返回消息列表中最后一条 role 为 user 的消息内容。
|
||||
func lastUserMessageText(messages []message.ChatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
role := msg.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role == "user" {
|
||||
return msg.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package toolrouter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
)
|
||||
|
||||
// Config holds the tool router configuration
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
OpenAIName string
|
||||
Timeout int
|
||||
MaxTokens int
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
// ConfigStore 定义从持久化层读取最新 ToolRouter 配置的能力。
|
||||
// 每次 RunAgentToolLoop 都会调用 LoadToolRouterConfig,从而保证管理员
|
||||
// 在 /admin/llm/api 修改配置后立即生效,无需重启。
|
||||
type ConfigStore interface {
|
||||
LoadToolRouterConfig() (*Config, error)
|
||||
}
|
||||
|
||||
// State manages the tool router state
|
||||
type State struct {
|
||||
cfg *Config
|
||||
ai *llm.State
|
||||
store ConfigStore
|
||||
}
|
||||
|
||||
// Option is a function that configures the State
|
||||
type Option func(*State)
|
||||
|
||||
// WithConfigStore 注入运行时配置加载器,State 会在每次需要时拉取最新配置。
|
||||
func WithConfigStore(store ConfigStore) Option {
|
||||
return func(s *State) {
|
||||
s.store = store
|
||||
}
|
||||
}
|
||||
|
||||
// NewState creates a new tool router state
|
||||
func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) {
|
||||
if cfg == nil {
|
||||
cfg = &Config{
|
||||
Enabled: true,
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: "你可以按需直接调用可用工具来回答用户问题。\n每个工具的 description 描述了它的适用场景和调用条件。\n工具结果优先于模型内置知识;工具失败时必须如实说明,不要编造结果。\n只调用确实必要的工具。\n\n重要规则:\n- 当用户问题包含\"今天\"、\"昨天\"、\"最近\"、\"本周\"、\"本月\"等相对时间词时,你必须先调用 time 工具获取当前准确日期,然后再用该日期调用其他工具。绝不可以使用模型内置知识猜测日期。\n- 你不知道\"今天\"是哪一天,必须通过 time 工具查询。",
|
||||
}
|
||||
}
|
||||
if ai == nil {
|
||||
return nil, errors.New("tool router requires an LLM state")
|
||||
}
|
||||
if cfg.Enabled && strings.TrimSpace(cfg.OpenAIName) != "" {
|
||||
if _, err := ai.GetProfile(cfg.OpenAIName); err != nil {
|
||||
return nil, fmt.Errorf("invalid LLM provider name in tool router: %w", err)
|
||||
}
|
||||
}
|
||||
state := &State{cfg: cfg, ai: ai}
|
||||
for _, option := range options {
|
||||
option(state)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// effectiveConfig 返回当前生效的配置:优先从 store 加载最新值,加载失败时回退到内存 cfg。
|
||||
// 调用方拿到的永远是非 nil 指针;内存 cfg 也保持同步以便其它读取点。
|
||||
func (s *State) effectiveConfig() *Config {
|
||||
if s == nil {
|
||||
return &Config{}
|
||||
}
|
||||
if s.store != nil {
|
||||
if latest, err := s.store.LoadToolRouterConfig(); err == nil && latest != nil {
|
||||
s.cfg = latest
|
||||
return latest
|
||||
}
|
||||
}
|
||||
if s.cfg == nil {
|
||||
return &Config{}
|
||||
}
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// RouterProfile returns the LLM profile configured for the tool router
|
||||
func (s *State) RouterProfile(fallback *llm.Profile) *llm.Profile {
|
||||
if s == nil || s.ai == nil {
|
||||
return fallback
|
||||
}
|
||||
cfg := s.effectiveConfig()
|
||||
name := strings.TrimSpace(cfg.OpenAIName)
|
||||
if name == "" {
|
||||
return fallback
|
||||
}
|
||||
profile, err := s.ai.GetProfile(name)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// Config returns a copy of the current configuration
|
||||
func (s *State) Config() Config {
|
||||
if s == nil {
|
||||
return Config{}
|
||||
}
|
||||
return *s.effectiveConfig()
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package toolrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/agenttool"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/stream"
|
||||
"meshtastic_mqtt_server/internal/toolmanager"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// AgentTool represents a tool that can be called by the LLM
|
||||
type AgentTool struct {
|
||||
name string
|
||||
definition *model.Tool
|
||||
execute func(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
// NewAgentTool creates a new agent tool
|
||||
func NewAgentTool(name string, definition *model.Tool, execute func(context.Context, string) (string, error)) AgentTool {
|
||||
return AgentTool{name: name, definition: definition, execute: execute}
|
||||
}
|
||||
|
||||
// Name returns the tool name
|
||||
func (t AgentTool) Name() string { return t.name }
|
||||
|
||||
// Definition returns the tool definition
|
||||
func (t AgentTool) Definition() *model.Tool { return t.definition }
|
||||
|
||||
// AvailableAgentTools returns all available agent tools
|
||||
func AvailableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool {
|
||||
return availableAgentTools(state, profile, manager, emit)
|
||||
}
|
||||
|
||||
func availableAgentTools(state *State, profile *llm.Profile, manager *toolmanager.Manager, emit stream.EmitFunc) []AgentTool {
|
||||
if state == nil || state.cfg == nil || !state.cfg.Enabled || manager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
loaded := manager.Tools()
|
||||
tools := make([]AgentTool, 0, len(loaded))
|
||||
for _, tool := range loaded {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if agentTool, ok := buildAgentTool(tool, "", profile, emit); ok {
|
||||
tools = append(tools, agentTool)
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func buildAgentTool(tool agenttool.LoadedTool, description string, profile *llm.Profile, emit stream.EmitFunc) (AgentTool, bool) {
|
||||
if tool == nil || !tool.Enabled() {
|
||||
return AgentTool{}, false
|
||||
}
|
||||
definition := tool.ToolDefinition(description)
|
||||
if definition == nil || definition.Function == nil {
|
||||
return AgentTool{}, false
|
||||
}
|
||||
name := tool.Name()
|
||||
return AgentTool{
|
||||
name: name,
|
||||
definition: definition,
|
||||
execute: func(ctx context.Context, args string) (string, error) {
|
||||
runtime := agenttool.Runtime{
|
||||
Profile: profile,
|
||||
Now: time.Now(),
|
||||
Emit: wrapAgentEmit(emit),
|
||||
}
|
||||
return tool.Execute(ctx, args, runtime)
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func wrapAgentEmit(emit stream.EmitFunc) agenttool.EmitFunc {
|
||||
if emit == nil {
|
||||
return nil
|
||||
}
|
||||
return func(frame any) {
|
||||
switch value := frame.(type) {
|
||||
case agenttool.Frame:
|
||||
emit(stream.Frame{Type: value.Type, Tool: value.Tool, Stage: value.Stage, Status: value.Status, Message: value.Message, Data: value.Data, Error: value.Error, Text: value.Text})
|
||||
case stream.Frame:
|
||||
emit(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteAgentToolCall executes a tool call and returns the result
|
||||
func ExecuteAgentToolCall(ctx context.Context, call *model.ToolCall, tools map[string]AgentTool, emit stream.EmitFunc) string {
|
||||
if call == nil || call.Type != model.ToolTypeFunction {
|
||||
result := "工具调用无效:仅支持 function 类型工具。"
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: "agent_tools", Stage: "execute", Status: "error", Message: result})
|
||||
}
|
||||
return result
|
||||
}
|
||||
toolName := call.Function.Name
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: toolName, Stage: "arguments", Status: "running", Message: "准备执行工具", Data: map[string]any{"tool_call_id": call.ID, "arguments": call.Function.Arguments}})
|
||||
}
|
||||
tool, ok := tools[toolName]
|
||||
if !ok {
|
||||
result := fmt.Sprintf("工具调用失败:未知工具 %s。", toolName)
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: toolName, Stage: "execute", Status: "error", Message: result})
|
||||
}
|
||||
return result
|
||||
}
|
||||
started := time.Now()
|
||||
result, err := tool.execute(ctx, call.Function.Arguments)
|
||||
durationMs := time.Since(started).Milliseconds()
|
||||
if err != nil {
|
||||
messageText := fmt.Sprintf("工具 %s 执行失败:%v", tool.name, err)
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: tool.name, Stage: "execute", Status: "error", Message: "工具执行失败", Data: map[string]any{"tool_call_id": call.ID, "duration_ms": durationMs, "error": err.Error()}})
|
||||
}
|
||||
return messageText
|
||||
}
|
||||
if strings.TrimSpace(result) == "" {
|
||||
result = fmt.Sprintf("工具 %s 执行完成,但没有返回内容。", tool.name)
|
||||
}
|
||||
if emit != nil {
|
||||
emit(stream.Frame{Type: "trace", Tool: tool.name, Stage: "result", Status: "success", Message: "工具执行完成", Data: map[string]any{"tool_call_id": call.ID, "duration_ms": durationMs, "result_preview": truncateString(result, 1200)}})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func truncateString(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package topicrouter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/internal/completion"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/message"
|
||||
|
||||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||||
)
|
||||
|
||||
// DefaultSystemPrompt 是话题选择判定模型的默认系统提示词。
|
||||
// 模型被要求只输出 REPLY 或 IGNORE:REPLY 表示应当回复,IGNORE 表示应当丢弃。
|
||||
const DefaultSystemPrompt = "你是一个话题过滤器,判断用户最新消息是否属于应当回复的话题范围。\n如果应当回复,请输出 REPLY;如果不应当回复,请输出 IGNORE。\n只输出 REPLY 或 IGNORE,不要输出任何其他内容。"
|
||||
|
||||
// Config holds the topic selection configuration
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
OpenAIName string
|
||||
Timeout int
|
||||
MaxTokens int
|
||||
SystemPrompt string
|
||||
}
|
||||
|
||||
// ConfigStore 定义从持久化层读取最新话题选择配置的能力。
|
||||
// 每次 Judge 都会调用 LoadTopicConfig,从而保证管理员在 /admin/llm/api
|
||||
// 修改配置后立即生效,无需重启。
|
||||
type ConfigStore interface {
|
||||
LoadTopicConfig() (*Config, error)
|
||||
}
|
||||
|
||||
// State manages the topic router state
|
||||
type State struct {
|
||||
cfg *Config
|
||||
ai *llm.State
|
||||
store ConfigStore
|
||||
}
|
||||
|
||||
// Option is a function that configures the State
|
||||
type Option func(*State)
|
||||
|
||||
// WithConfigStore 注入运行时配置加载器,State 会在每次需要时拉取最新配置。
|
||||
func WithConfigStore(store ConfigStore) Option {
|
||||
return func(s *State) {
|
||||
s.store = store
|
||||
}
|
||||
}
|
||||
|
||||
// NewState creates a new topic router state
|
||||
func NewState(cfg *Config, ai *llm.State, options ...Option) (*State, error) {
|
||||
if cfg == nil {
|
||||
cfg = &Config{
|
||||
Enabled: false,
|
||||
Timeout: 30,
|
||||
MaxTokens: 512,
|
||||
SystemPrompt: DefaultSystemPrompt,
|
||||
}
|
||||
}
|
||||
if ai == nil {
|
||||
return nil, errors.New("topic router requires an LLM state")
|
||||
}
|
||||
if cfg.Enabled && strings.TrimSpace(cfg.OpenAIName) != "" {
|
||||
if _, err := ai.GetProfile(cfg.OpenAIName); err != nil {
|
||||
return nil, fmt.Errorf("invalid LLM provider name in topic router: %w", err)
|
||||
}
|
||||
}
|
||||
state := &State{cfg: cfg, ai: ai}
|
||||
for _, option := range options {
|
||||
option(state)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// effectiveConfig 返回当前生效的配置:优先从 store 加载最新值,加载失败时回退到内存 cfg。
|
||||
// 调用方拿到的永远是非 nil 指针;内存 cfg 也保持同步以便其它读取点。
|
||||
func (s *State) effectiveConfig() *Config {
|
||||
if s == nil {
|
||||
return &Config{}
|
||||
}
|
||||
if s.store != nil {
|
||||
if latest, err := s.store.LoadTopicConfig(); err == nil && latest != nil {
|
||||
s.cfg = latest
|
||||
return latest
|
||||
}
|
||||
}
|
||||
if s.cfg == nil {
|
||||
return &Config{}
|
||||
}
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
// Profile 返回话题选择使用的 LLM profile,OpenAIName 为空时回退到 fallback(主 profile)。
|
||||
func (s *State) Profile(fallback *llm.Profile) *llm.Profile {
|
||||
if s == nil || s.ai == nil {
|
||||
return fallback
|
||||
}
|
||||
cfg := s.effectiveConfig()
|
||||
name := strings.TrimSpace(cfg.OpenAIName)
|
||||
if name == "" {
|
||||
return fallback
|
||||
}
|
||||
profile, err := s.ai.GetProfile(name)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// Config returns a copy of the current configuration
|
||||
func (s *State) Config() Config {
|
||||
if s == nil {
|
||||
return Config{}
|
||||
}
|
||||
return *s.effectiveConfig()
|
||||
}
|
||||
|
||||
// Judge 对最近一条用户消息做话题判定。
|
||||
// 返回值 shouldReply:true 表示命中/放行(应进入主回复),false 表示应丢弃不回复。
|
||||
// 当话题选择未启用、未配置提供商,或判定调用失败时,一律放行(返回 true),
|
||||
// 避免判定接口故障导致所有未命中工具的消息被丢弃。
|
||||
func Judge(ctx context.Context, state *State, fallback *llm.Profile, messages []message.ChatMessage) (bool, error) {
|
||||
if state == nil {
|
||||
return true, nil
|
||||
}
|
||||
cfg := state.effectiveConfig()
|
||||
if !cfg.Enabled {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
profile := state.Profile(fallback)
|
||||
if profile == nil || profile.Client == nil {
|
||||
// 未配置话题选择的 AI 提供商,回退到放行
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 取最后一条用户消息作为判定输入
|
||||
userText := lastUserMessage(messages)
|
||||
if strings.TrimSpace(userText) == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
systemPrompt := strings.TrimSpace(cfg.SystemPrompt)
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = DefaultSystemPrompt
|
||||
}
|
||||
|
||||
arkMessages := make([]*model.ChatCompletionMessage, 0, 2)
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &systemPrompt,
|
||||
},
|
||||
})
|
||||
arkMessages = append(arkMessages, &model.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
Content: &model.ChatCompletionMessageContent{
|
||||
StringValue: &userText,
|
||||
},
|
||||
})
|
||||
|
||||
maxTokens := cfg.MaxTokens
|
||||
if maxTokens <= 0 {
|
||||
maxTokens = 512
|
||||
}
|
||||
timeout := time.Duration(cfg.Timeout) * time.Second
|
||||
if cfg.Timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
resp, err := completion.CompleteChat(ctx, profile, model.CreateChatCompletionRequest{
|
||||
Model: profile.Config.Model,
|
||||
Messages: arkMessages,
|
||||
MaxTokens: &maxTokens,
|
||||
}, timeout)
|
||||
if err != nil {
|
||||
// 判定调用失败时放行,避免接口故障导致全部丢消息
|
||||
return true, err
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
text := ""
|
||||
if resp.Choices[0].Message.Content != nil && resp.Choices[0].Message.Content.StringValue != nil {
|
||||
text = *resp.Choices[0].Message.Content.StringValue
|
||||
}
|
||||
// 解析模型输出:包含 REPLY 即命中(忽略大小写)
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
if strings.Contains(upper, "REPLY") {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// lastUserMessage 返回消息列表中最后一条 role 为 user 的消息内容。
|
||||
func lastUserMessage(messages []message.ChatMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
role := msg.Role
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role == "user" {
|
||||
return msg.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -21,12 +23,12 @@ const (
|
||||
)
|
||||
|
||||
type mapTileProxy struct {
|
||||
store *store
|
||||
store *storepkg.Store
|
||||
cacheDir string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func registerMapTileProxyRoutes(r gin.IRouter, store *store, cacheDir string) {
|
||||
func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir string) {
|
||||
proxy := &mapTileProxy{
|
||||
store: store,
|
||||
cacheDir: cacheDir,
|
||||
@@ -0,0 +1,181 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"github.com/mochi-mqtt/server/v2/packets"
|
||||
|
||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// MQTTStatusProvider 是 web 层向上层要的"返回当前 mqtt broker 状态"接口;
|
||||
// 实现一般由 main 包传入(持有真正的 mqtt.Server / 写队列 / 统计器)。
|
||||
type MQTTStatusProvider interface {
|
||||
Status() AdminMQTTStatus
|
||||
// DisconnectClient 立即踢掉指定的 MQTT 客户端。clientID 不存在时返回 false。
|
||||
DisconnectClient(clientID string) bool
|
||||
// LookupClientRemoteHost 根据 clientID 查询当前连接的远端主机(不带端口),
|
||||
// 用于把该 IP 加入屏蔽表。clientID 不存在时返回空字符串与 false。
|
||||
LookupClientRemoteHost(clientID string) (string, bool)
|
||||
}
|
||||
|
||||
// MQTTRuntimeStatus 把 mqtt.Server / 写队列 / 转发统计三个上下文打包成
|
||||
// 实现 MQTTStatusProvider 的具体类型。供 main 包构造后注入 newRouter。
|
||||
type MQTTRuntimeStatus struct {
|
||||
Server *mqtt.Server
|
||||
Address string
|
||||
TLS bool
|
||||
Stats *mqttforwardpkg.Stats
|
||||
ClientStats *mqttforwardpkg.ClientStats
|
||||
DBQueue *storepkg.WriteQueue
|
||||
DedupQueue *mqttforwardpkg.DedupQueue
|
||||
}
|
||||
|
||||
// AdminMQTTStatus 是 admin 路由 GET /admin/mqtt-status 返回的 JSON 视图。
|
||||
type AdminMQTTStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Address string `json:"address"`
|
||||
TLS bool `json:"tls"`
|
||||
Version string `json:"version"`
|
||||
Started int64 `json:"started"`
|
||||
Uptime int64 `json:"uptime"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
ClientsConnected int64 `json:"clients_connected"`
|
||||
ClientsDisconnected int64 `json:"clients_disconnected"`
|
||||
ClientsMaximum int64 `json:"clients_maximum"`
|
||||
ClientsTotal int64 `json:"clients_total"`
|
||||
MessagesReceived int64 `json:"messages_received"`
|
||||
MessagesSent int64 `json:"messages_sent"`
|
||||
MessagesDropped int64 `json:"messages_dropped"`
|
||||
DBWriteQueueLength int `json:"db_write_queue_length"`
|
||||
DedupQueueLength int `json:"dedup_queue_len"`
|
||||
Retained int64 `json:"retained"`
|
||||
Inflight int64 `json:"inflight"`
|
||||
InflightDropped int64 `json:"inflight_dropped"`
|
||||
Subscriptions int64 `json:"subscriptions"`
|
||||
PacketsReceived int64 `json:"packets_received"`
|
||||
PacketsSent int64 `json:"packets_sent"`
|
||||
Clients []AdminMQTTClient `json:"clients"`
|
||||
}
|
||||
|
||||
type AdminMQTTClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
Listener string `json:"listener"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
PacketsIn int64 `json:"packets_in"` // 客户端 → 服务器
|
||||
PacketsOut int64 `json:"packets_out"` // 服务器 → 客户端
|
||||
}
|
||||
|
||||
// Status 实现 MQTTStatusProvider。
|
||||
func (m MQTTRuntimeStatus) Status() AdminMQTTStatus {
|
||||
if m.Server == nil || m.Server.Info == nil {
|
||||
return AdminMQTTStatus{Running: false, Address: m.Address, TLS: m.TLS, DBWriteQueueLength: m.DBQueue.Len(), DedupQueueLength: m.dedupQueueLen()}
|
||||
}
|
||||
info := m.Server.Info.Clone()
|
||||
status := AdminMQTTStatus{
|
||||
Running: true,
|
||||
Address: m.Address,
|
||||
TLS: m.TLS,
|
||||
Version: info.Version,
|
||||
Started: info.Started,
|
||||
Uptime: info.Uptime,
|
||||
BytesReceived: info.BytesReceived,
|
||||
BytesSent: info.BytesSent,
|
||||
ClientsConnected: info.ClientsConnected,
|
||||
ClientsDisconnected: info.ClientsDisconnected,
|
||||
ClientsMaximum: info.ClientsMaximum,
|
||||
ClientsTotal: info.ClientsTotal,
|
||||
MessagesReceived: info.MessagesReceived,
|
||||
MessagesSent: m.Stats.Forwarded(),
|
||||
MessagesDropped: m.Stats.Dropped(),
|
||||
DBWriteQueueLength: m.DBQueue.Len(),
|
||||
DedupQueueLength: m.dedupQueueLen(),
|
||||
Retained: info.Retained,
|
||||
Inflight: info.Inflight,
|
||||
InflightDropped: info.InflightDropped,
|
||||
Subscriptions: info.Subscriptions,
|
||||
PacketsReceived: info.PacketsReceived,
|
||||
PacketsSent: info.PacketsSent,
|
||||
}
|
||||
for _, client := range m.Server.Clients.GetAll() {
|
||||
if client == nil || client.Closed() {
|
||||
continue
|
||||
}
|
||||
info := mqttClientInfo(client)
|
||||
in, out := m.ClientStats.Get(info.ClientID)
|
||||
status.Clients = append(status.Clients, AdminMQTTClient{
|
||||
ClientID: info.ClientID,
|
||||
Username: info.Username,
|
||||
Listener: info.Listener,
|
||||
RemoteAddr: info.RemoteAddr,
|
||||
PacketsIn: in,
|
||||
PacketsOut: out,
|
||||
})
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// 简化版客户端信息——只解析展示所需字段,避免依赖 main 包里的辅助。
|
||||
type mqttClientInfoView struct {
|
||||
ClientID string
|
||||
Username string
|
||||
Listener string
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
func mqttClientInfo(c *mqtt.Client) mqttClientInfoView {
|
||||
if c == nil {
|
||||
return mqttClientInfoView{}
|
||||
}
|
||||
return mqttClientInfoView{
|
||||
ClientID: c.ID,
|
||||
Username: string(c.Properties.Username),
|
||||
Listener: c.Net.Listener,
|
||||
RemoteAddr: c.Net.Remote,
|
||||
}
|
||||
}
|
||||
|
||||
func (m MQTTRuntimeStatus) dedupQueueLen() int {
|
||||
if m.DedupQueue == nil {
|
||||
return 0
|
||||
}
|
||||
return m.DedupQueue.Len()
|
||||
}
|
||||
|
||||
// DisconnectClient 实现 MQTTStatusProvider:发送 Disconnect 报文并关闭连接。
|
||||
// 使用 ErrAdministrativeAction 作为断开理由,便于日志区分。
|
||||
func (m MQTTRuntimeStatus) DisconnectClient(clientID string) bool {
|
||||
if m.Server == nil || clientID == "" {
|
||||
return false
|
||||
}
|
||||
client, ok := m.Server.Clients.Get(clientID)
|
||||
if !ok || client == nil {
|
||||
return false
|
||||
}
|
||||
_ = m.Server.DisconnectClient(client, packets.ErrAdministrativeAction)
|
||||
return true
|
||||
}
|
||||
|
||||
// LookupClientRemoteHost 实现 MQTTStatusProvider:根据 clientID 查询当前连接的远端 IP。
|
||||
// Net.Remote 通常是 "host:port",这里只返回主机部分;解析失败时回退为整个值。
|
||||
func (m MQTTRuntimeStatus) LookupClientRemoteHost(clientID string) (string, bool) {
|
||||
if m.Server == nil || clientID == "" {
|
||||
return "", false
|
||||
}
|
||||
client, ok := m.Server.Clients.Get(clientID)
|
||||
if !ok || client == nil {
|
||||
return "", false
|
||||
}
|
||||
remote := client.Net.Remote
|
||||
if remote == "" {
|
||||
return "", false
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(remote); err == nil && host != "" {
|
||||
return host, true
|
||||
}
|
||||
return remote, true
|
||||
}
|
||||
+233
-236
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -12,16 +12,39 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
aipkg "meshtastic_mqtt_server/internal/ai"
|
||||
"meshtastic_mqtt_server/internal/auth"
|
||||
blockingpkg "meshtastic_mqtt_server/internal/blocking"
|
||||
botpkg "meshtastic_mqtt_server/internal/bot"
|
||||
configpkg "meshtastic_mqtt_server/internal/config"
|
||||
helppkg "meshtastic_mqtt_server/internal/help"
|
||||
llmadminpkg "meshtastic_mqtt_server/internal/llmadmin"
|
||||
mappkg "meshtastic_mqtt_server/internal/mapsource"
|
||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||
signpkg "meshtastic_mqtt_server/internal/sign"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
"meshtastic_mqtt_server/internal/webutil"
|
||||
)
|
||||
|
||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *http.Server {
|
||||
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||
type LLMProviderReloader interface {
|
||||
ReloadLLMProvider(config interface{}) error
|
||||
AddLLMProvider(config interface{}) error
|
||||
RemoveLLMProvider(name string) error
|
||||
AIServiceStatus() aipkg.AIServiceStatus
|
||||
RestartAIService() error
|
||||
}
|
||||
|
||||
func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
||||
Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService),
|
||||
}
|
||||
}
|
||||
|
||||
func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
||||
func ServeUnixSocket(server *http.Server, socketPath string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -47,17 +70,25 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) *gin.Engine {
|
||||
func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
if consoleLog {
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
} else {
|
||||
r.Use(gin.Recovery())
|
||||
}
|
||||
api := r.Group("/api")
|
||||
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
return r
|
||||
}
|
||||
|
||||
func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
||||
const BackendVersion = "1.2.1"
|
||||
|
||||
var CommitVersion = "dev"
|
||||
|
||||
func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir string) {
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
status := gin.H{"status": "ok", "database": "ok"}
|
||||
if err := store.Ping(); err != nil {
|
||||
@@ -69,12 +100,37 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
|
||||
r.GET("/version", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"version": BackendVersion, "commit": CommitVersion})
|
||||
})
|
||||
|
||||
registerNodeInfoRoutes(r, store, "/nodeinfo")
|
||||
registerNodeInfoRoutes(r, store, "/nodes")
|
||||
registerMapReportRoutes(r, store)
|
||||
registerMapSourceRoutes(r, store)
|
||||
mappkg.RegisterPublicRoutes(r, store)
|
||||
registerMapTileProxyRoutes(r, store, mapTileCacheDir)
|
||||
registerHelpRoutes(r, store)
|
||||
helppkg.RegisterPublicRoutes(r, store)
|
||||
r.GET("/signs", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListSigns(opts)
|
||||
if err != nil {
|
||||
writeListResponse(c, rows, opts, err, signpkg.SignDTO)
|
||||
return
|
||||
}
|
||||
total, err := store.CountSigns(opts)
|
||||
writeListResponseWithTotal(c, rows, opts, total, err, signpkg.SignDTO)
|
||||
})
|
||||
r.GET("/signs/daily", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.CountSignsByDay(opts)
|
||||
writeListResponse(c, rows, opts, err, signpkg.SignDayCountDTO)
|
||||
})
|
||||
r.GET("/text-messages", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
@@ -83,6 +139,18 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
||||
rows, err := store.ListTextMessages(opts)
|
||||
writeListResponse(c, rows, opts, err, textMessageDTO)
|
||||
})
|
||||
r.GET("/channels", func(c *gin.Context) {
|
||||
rows, err := store.ListChannels()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, gin.H{"channel_id": row.ChannelID})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
})
|
||||
r.GET("/discard-details", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
@@ -125,7 +193,7 @@ func registerAPIRoutes(r gin.IRouter, store *store, mapTileCacheDir string) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache, botSender botTextSender) {
|
||||
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -137,10 +205,10 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
type updatePasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
userDTO := func(user userRecord) gin.H {
|
||||
userDTO := func(user storepkg.UserRecord) gin.H {
|
||||
return gin.H{"id": user.ID, "username": user.Username, "role": user.Role, "created_at": user.CreatedAt, "updated_at": user.UpdatedAt}
|
||||
}
|
||||
loginLogDTO := func(row loginLogRecord) gin.H {
|
||||
loginLogDTO := func(row storepkg.LoginLogRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "username": row.Username, "user_id": ptrUint64(row.UserID), "success": row.Success, "reason": row.Reason, "remote_addr": row.RemoteAddr, "remote_host": row.RemoteHost, "user_agent": row.UserAgent, "created_at": row.CreatedAt}
|
||||
}
|
||||
remoteInfo := func(c *gin.Context) (string, string) {
|
||||
@@ -153,7 +221,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
}
|
||||
recordLogin := func(c *gin.Context, username string, userID *uint64, success bool, reason string) {
|
||||
remoteAddr, remoteHost := remoteInfo(c)
|
||||
_ = store.InsertLoginLog(loginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
||||
_ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
||||
}
|
||||
|
||||
r.POST("/login", func(c *gin.Context) {
|
||||
@@ -164,44 +232,46 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
return
|
||||
}
|
||||
user, err := store.GetUserByUsername(req.Username)
|
||||
if err != nil || user.Role != adminRole || !verifyPassword(user.PasswordHash, req.Password) {
|
||||
if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
cookie, err := sessions.newCookie(*user)
|
||||
cookie, err := sessions.NewCookie(*user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
recordLogin(c, req.Username, &user.ID, true, "success")
|
||||
http.SetCookie(c.Writer, cookie)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserResponse(*user)})
|
||||
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserResponse(*user)})
|
||||
})
|
||||
r.POST("/logout", func(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, sessions.clearCookie())
|
||||
http.SetCookie(c.Writer, sessions.ClearCookie())
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
protected := r.Group("")
|
||||
protected.Use(requireAdmin(sessions))
|
||||
registerAdminBlockingRoutes(protected, store, blocking)
|
||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||
registerAdminMapSourceRoutes(protected, store)
|
||||
registerAdminHelpRoutes(protected, store)
|
||||
registerAdminBotRoutes(protected, store, botSender)
|
||||
protected.Use(auth.RequireAdmin(sessions))
|
||||
blockingpkg.RegisterRoutes(protected, store, blocking)
|
||||
signpkg.RegisterAdminRoutes(protected, store)
|
||||
mqttforwardpkg.RegisterRoutes(protected, store, forwarder)
|
||||
rspkg.RegisterRoutes(protected, store, settings)
|
||||
mappkg.RegisterAdminRoutes(protected, store)
|
||||
helppkg.RegisterAdminRoutes(protected, store)
|
||||
botpkg.RegisterRoutes(protected, store, botSender)
|
||||
llmadminpkg.RegisterRoutes(protected, store, aiService)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": adminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||
})
|
||||
protected.GET("/mqtt/status", func(c *gin.Context) {
|
||||
if mqttStatus == nil {
|
||||
c.JSON(http.StatusOK, adminMqttStatus{Running: false})
|
||||
c.JSON(http.StatusOK, AdminMQTTStatus{Running: false})
|
||||
return
|
||||
}
|
||||
status := mqttStatus.Status()
|
||||
discardCount, err := store.CountDiscardDetails(listOptions{})
|
||||
discardCount, err := store.CountDiscardDetails(storepkg.ListOptions{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -209,6 +279,67 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
status.MessagesDropped = discardCount
|
||||
c.JSON(http.StatusOK, status)
|
||||
})
|
||||
// 一键断开 MQTT 客户端并把它的远端 IP 加入屏蔽表。reason 由前端传入;写库前先查 IP 避免连接断开后查不到。
|
||||
protected.POST("/mqtt/clients/:client_id/disconnect-and-block", func(c *gin.Context) {
|
||||
if mqttStatus == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "mqtt server not available"})
|
||||
return
|
||||
}
|
||||
clientID := c.Param("client_id")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid client id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
// 请求体允许为空——若没传 reason 就走默认占位。
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "manual disconnect from admin dashboard"
|
||||
}
|
||||
|
||||
host, ok := mqttStatus.LookupClientRemoteHost(clientID)
|
||||
if !ok || host == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "mqtt client not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 先写屏蔽规则,再断开连接:万一断开后客户端立刻重连,新规则已经生效。
|
||||
var ipRule *storepkg.IPBlockingRecord
|
||||
row, err := store.CreateIPBlocking(host, reason, true)
|
||||
switch {
|
||||
case err == nil:
|
||||
ipRule = row
|
||||
case errors.Is(err, storepkg.ErrBlockingAlreadyExists):
|
||||
// 已经存在则忽略——只确保是启用状态。
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if blocking != nil {
|
||||
if err := blocking.Reload(store); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
disconnected := mqttStatus.DisconnectClient(clientID)
|
||||
resp := gin.H{
|
||||
"status": "ok",
|
||||
"client_id": clientID,
|
||||
"ip_value": host,
|
||||
"disconnected": disconnected,
|
||||
}
|
||||
if ipRule != nil {
|
||||
resp["ip_rule_id"] = ipRule.ID
|
||||
resp["ip_rule_created"] = true
|
||||
} else {
|
||||
resp["ip_rule_created"] = false
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
protected.GET("/users", func(c *gin.Context) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
@@ -228,7 +359,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
return
|
||||
}
|
||||
user, err := store.CreateAdminUser(req.Username, req.Password)
|
||||
if errors.Is(err, errUserAlreadyExists) {
|
||||
if errors.Is(err, storepkg.ErrUserAlreadyExists) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already exists"})
|
||||
return
|
||||
}
|
||||
@@ -268,6 +399,33 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
rows, err := store.ListLoginLogs(opts)
|
||||
writeListResponse(c, rows, opts, err, loginLogDTO)
|
||||
})
|
||||
protected.POST("/discard-details/batch-delete", func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is empty"})
|
||||
return
|
||||
}
|
||||
count, err := store.DeleteDiscardDetailsByIDs(req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
|
||||
})
|
||||
protected.DELETE("/discard-details", func(c *gin.Context) {
|
||||
count, err := store.DeleteAllDiscardDetails()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "deleted_count": count})
|
||||
})
|
||||
protected.DELETE("/text-messages/:id", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
@@ -298,9 +456,26 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
// purge:除了 nodeinfo + map_report,还把 text_message 与 position/telemetry/routing/traceroute
|
||||
// 中按 from_id 关联到该节点的记录一并删除。
|
||||
protected.DELETE("/nodes/:id/purge", func(c *gin.Context) {
|
||||
nodeID := c.Param("id")
|
||||
if nodeID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
|
||||
return
|
||||
}
|
||||
if err := store.PurgeNode(nodeID); errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "node not found"})
|
||||
return
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
}
|
||||
|
||||
func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
||||
func registerNodeInfoRoutes(r gin.IRouter, store *storepkg.Store, path string) {
|
||||
r.GET(path, func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
@@ -328,7 +503,7 @@ func registerNodeInfoRoutes(r gin.IRouter, store *store, path string) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerMapReportRoutes(r gin.IRouter, store *store) {
|
||||
func registerMapReportRoutes(r gin.IRouter, store *storepkg.Store) {
|
||||
r.GET("/map-reports/viewport", func(c *gin.Context) {
|
||||
opts, ok := parseMapReportViewportOptions(c)
|
||||
if !ok {
|
||||
@@ -408,218 +583,69 @@ func serveIndex(c *gin.Context, staticDir string) {
|
||||
c.File(indexPath)
|
||||
}
|
||||
|
||||
func parseListOptions(c *gin.Context) (listOptions, bool) {
|
||||
limit, ok := parseIntQuery(c, "limit", 100)
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
offset, ok := parseIntQuery(c, "offset", 0)
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
nodeID := c.Query("node_id")
|
||||
if nodeID == "" {
|
||||
nodeID = c.Query("from")
|
||||
}
|
||||
channelID := c.Query("channel_id")
|
||||
var since, until *time.Time
|
||||
if value := c.Query("since"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
since = &parsed
|
||||
}
|
||||
if value := c.Query("until"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
return normalizeListOptions(listOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
|
||||
func parseListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||
return webutil.ParseListOptions(c)
|
||||
}
|
||||
|
||||
func parseMapReportListOptions(c *gin.Context) (listOptions, bool) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
minLat, hasMinLat, ok := parseOptionalFloatQuery(c, "min_lat")
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
maxLat, hasMaxLat, ok := parseOptionalFloatQuery(c, "max_lat")
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
minLng, hasMinLng, ok := parseOptionalFloatQuery(c, "min_lng")
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
maxLng, hasMaxLng, ok := parseOptionalFloatQuery(c, "max_lng")
|
||||
if !ok {
|
||||
return listOptions{}, false
|
||||
}
|
||||
boundsCount := 0
|
||||
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
||||
if present {
|
||||
boundsCount++
|
||||
}
|
||||
}
|
||||
if boundsCount == 0 {
|
||||
return opts, true
|
||||
}
|
||||
if boundsCount != 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
if minLat > maxLat {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
||||
return listOptions{}, false
|
||||
}
|
||||
opts.MinLat = &minLat
|
||||
opts.MaxLat = &maxLat
|
||||
opts.MinLng = &minLng
|
||||
opts.MaxLng = &maxLng
|
||||
return opts, true
|
||||
func parseMapReportListOptions(c *gin.Context) (storepkg.ListOptions, bool) {
|
||||
return webutil.ParseMapReportListOptions(c)
|
||||
}
|
||||
|
||||
func parseMapReportViewportOptions(c *gin.Context) (mapReportViewportOptions, bool) {
|
||||
opts, ok := parseMapReportListOptions(c)
|
||||
if !ok {
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
zoom, ok := parseIntQuery(c, "zoom", 0)
|
||||
if !ok {
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
if zoom < 0 || zoom > 24 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
limit, ok := parseIntQuery(c, "limit", 1000)
|
||||
if !ok {
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
clusterThreshold, ok := parseIntQuery(c, "cluster_threshold", 500)
|
||||
if !ok {
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
targetCells, ok := parseIntQuery(c, "target_cells", 64)
|
||||
if !ok {
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
||||
return mapReportViewportOptions{}, false
|
||||
}
|
||||
return normalizeMapReportViewportOptions(mapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
||||
}
|
||||
|
||||
func parseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, true, false
|
||||
}
|
||||
return parsed, true, true
|
||||
func parseMapReportViewportOptions(c *gin.Context) (storepkg.MapReportViewportOptions, bool) {
|
||||
return webutil.ParseMapReportViewportOptions(c)
|
||||
}
|
||||
|
||||
func parseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return defaultValue, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
return webutil.ParseIntQuery(c, name, defaultValue)
|
||||
}
|
||||
|
||||
func writeListResponse[T any](c *gin.Context, rows []T, opts listOptions, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
||||
func writeListResponse[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, err error, convert func(T) gin.H) {
|
||||
webutil.WriteListResponse(c, rows, opts, err, convert)
|
||||
}
|
||||
|
||||
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts listOptions, total int64, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||
func writeListResponseWithTotal[T any](c *gin.Context, rows []T, opts storepkg.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||
webutil.WriteListResponseWithTotal(c, rows, opts, total, err, convert)
|
||||
}
|
||||
|
||||
func nodeInfoDTO(row nodeInfoRecord) gin.H {
|
||||
func nodeInfoDTO(row storepkg.NodeInfoRecord) gin.H {
|
||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "user_id": ptrString(row.UserID), "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "is_licensed": ptrBool(row.IsLicensed), "public_key": ptrString(row.PublicKey), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func mapReportDTO(row mapReportRecord) gin.H {
|
||||
func mapReportDTO(row storepkg.MapReportRecord) gin.H {
|
||||
return gin.H{"node_id": row.NodeID, "node_num": row.NodeNum, "long_name": ptrString(row.LongName), "short_name": ptrString(row.ShortName), "hw_model": ptrString(row.HWModel), "role": ptrString(row.Role), "firmware_version": ptrString(row.FirmwareVersion), "region": ptrString(row.Region), "modem_preset": ptrString(row.ModemPreset), "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "position_precision": ptrInt64(row.PositionPrecision), "num_online_local_nodes": ptrInt64(row.NumOnlineLocalNodes), "has_opted_report_location": ptrBool(row.HasOptedReportLocation), "updated_at": row.UpdatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func mapReportViewportPointDTO(row mapReportRecord) gin.H {
|
||||
func mapReportViewportPointDTO(row storepkg.MapReportRecord) gin.H {
|
||||
item := mapReportDTO(row)
|
||||
item["type"] = "point"
|
||||
return item
|
||||
}
|
||||
|
||||
func mapReportClusterDTO(row mapReportClusterRecord) gin.H {
|
||||
func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H {
|
||||
return gin.H{"type": "cluster", "cluster_id": row.ClusterID, "latitude": row.Latitude, "longitude": row.Longitude, "count": row.Count}
|
||||
}
|
||||
|
||||
func textMessageDTO(row textMessageRecord) gin.H {
|
||||
func textMessageDTO(row storepkg.TextMessageRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func discardDetailsDTO(row discardDetailsRecord) gin.H {
|
||||
func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func positionDTO(row positionRecord) gin.H {
|
||||
func positionDTO(row storepkg.PositionRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func telemetryDTO(row telemetryRecord) gin.H {
|
||||
func telemetryDTO(row storepkg.TelemetryRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "telemetry_type": ptrString(row.TelemetryType), "metrics_json": ptrString(row.MetricsJSON), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func routingDTO(row routingRecord) gin.H {
|
||||
func routingDTO(row storepkg.RoutingRecord) gin.H {
|
||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||
}
|
||||
|
||||
func tracerouteDTO(row tracerouteRecord) gin.H {
|
||||
func tracerouteDTO(row storepkg.TracerouteRecord) gin.H {
|
||||
return appendPacketDTO(row.ID, row.FromID, row.FromNum, row.PacketID, row.Portnum, row.CreatedAt, row.ContentJSON)
|
||||
}
|
||||
|
||||
@@ -627,37 +653,8 @@ func appendPacketDTO(id uint64, fromID string, fromNum int64, packetID *int64, p
|
||||
return gin.H{"id": id, "from_id": fromID, "from_num": fromNum, "packet_id": ptrInt64(packetID), "portnum": ptrString(portnum), "created_at": createdAt, "content_json": contentJSON}
|
||||
}
|
||||
|
||||
func ptrString(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrInt64(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrUint64(value *uint64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrFloat64(value *float64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrBool(value *bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
func ptrString(value *string) any { return webutil.PtrString(value) }
|
||||
func ptrInt64(value *int64) any { return webutil.PtrInt64(value) }
|
||||
func ptrUint64(value *uint64) any { return webutil.PtrUint64(value) }
|
||||
func ptrFloat64(value *float64) any { return webutil.PtrFloat64(value) }
|
||||
func ptrBool(value *bool) any { return webutil.PtrBool(value) }
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package webutil 收集 admin/路由层共享的 HTTP 解析与响应工具。
|
||||
//
|
||||
// 这些函数原本散落在 web.go 中(parseListOptions、writeListResponse、
|
||||
// parseMapReportListOptions 等),任何注册 admin 路由的领域包都依赖它们。
|
||||
// 把它们抽离出来可以避免 internal/web 同时被 internal/blocking、
|
||||
// internal/bot 等包反向引用造成循环依赖。
|
||||
package webutil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
// ParseListOptions 从请求中读取 limit / offset / since / until / node_id /
|
||||
// channel_id 等通用过滤参数;解析失败时它会写入 400 响应并返回 false。
|
||||
func ParseListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||
limit, ok := ParseIntQuery(c, "limit", 100)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
offset, ok := ParseIntQuery(c, "offset", 0)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
nodeID := c.Query("node_id")
|
||||
if nodeID == "" {
|
||||
nodeID = c.Query("from")
|
||||
}
|
||||
channelID := c.Query("channel_id")
|
||||
var since, until *time.Time
|
||||
if value := c.Query("since"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid since: use RFC3339"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
since = &parsed
|
||||
}
|
||||
if value := c.Query("until"); value != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid until: use RFC3339"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
until = &parsed
|
||||
}
|
||||
return store.NormalizeListOptions(store.ListOptions{Limit: limit, Offset: offset, NodeID: nodeID, ChannelID: channelID, Since: since, Until: until}), true
|
||||
}
|
||||
|
||||
// ParseMapReportListOptions 在 ParseListOptions 的基础上解析 4 个地图边界。
|
||||
func ParseMapReportListOptions(c *gin.Context) (store.ListOptions, bool) {
|
||||
opts, ok := ParseListOptions(c)
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
minLat, hasMinLat, ok := ParseOptionalFloatQuery(c, "min_lat")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
maxLat, hasMaxLat, ok := ParseOptionalFloatQuery(c, "max_lat")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
minLng, hasMinLng, ok := ParseOptionalFloatQuery(c, "min_lng")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
maxLng, hasMaxLng, ok := ParseOptionalFloatQuery(c, "max_lng")
|
||||
if !ok {
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
boundsCount := 0
|
||||
for _, present := range []bool{hasMinLat, hasMaxLat, hasMinLng, hasMaxLng} {
|
||||
if present {
|
||||
boundsCount++
|
||||
}
|
||||
}
|
||||
if boundsCount == 0 {
|
||||
return opts, true
|
||||
}
|
||||
if boundsCount != 4 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "map bounds require min_lat, max_lat, min_lng, and max_lng"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLat < -90 || minLat > 90 || maxLat < -90 || maxLat > 90 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "latitude bounds must be between -90 and 90"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLat > maxLat {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "min_lat must be <= max_lat"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
if minLng < -180 || minLng > 180 || maxLng < -180 || maxLng > 180 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "longitude bounds must be between -180 and 180"})
|
||||
return store.ListOptions{}, false
|
||||
}
|
||||
opts.MinLat = &minLat
|
||||
opts.MaxLat = &maxLat
|
||||
opts.MinLng = &minLng
|
||||
opts.MaxLng = &maxLng
|
||||
return opts, true
|
||||
}
|
||||
|
||||
// ParseMapReportViewportOptions 在 ParseMapReportListOptions 之上解析 zoom /
|
||||
// cluster_threshold / target_cells 等额外字段。
|
||||
func ParseMapReportViewportOptions(c *gin.Context) (store.MapReportViewportOptions, bool) {
|
||||
opts, ok := ParseMapReportListOptions(c)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if opts.MinLat == nil || opts.MaxLat == nil || opts.MinLng == nil || opts.MaxLng == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "viewport bounds are required"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
zoom, ok := ParseIntQuery(c, "zoom", 0)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if zoom < 0 || zoom > 24 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "zoom must be between 0 and 24"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
limit, ok := ParseIntQuery(c, "limit", 1000)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
clusterThreshold, ok := ParseIntQuery(c, "cluster_threshold", 500)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
targetCells, ok := ParseIntQuery(c, "target_cells", 64)
|
||||
if !ok {
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
if limit <= 0 || clusterThreshold <= 0 || targetCells <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "limit, cluster_threshold, and target_cells must be positive"})
|
||||
return store.MapReportViewportOptions{}, false
|
||||
}
|
||||
return store.NormalizeMapReportViewportOptions(store.MapReportViewportOptions{ListOptions: opts, Zoom: zoom, Limit: limit, ClusterThreshold: clusterThreshold, TargetCells: targetCells}), true
|
||||
}
|
||||
|
||||
// ParseIntQuery 从请求查询字符串解析整数;缺省时返回 defaultValue。
|
||||
func ParseIntQuery(c *gin.Context, name string, defaultValue int) (int, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return defaultValue, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// ParseOptionalFloatQuery 解析可选浮点查询参数;返回 (value, present, ok)。
|
||||
func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) {
|
||||
value := c.Query(name)
|
||||
if value == "" {
|
||||
return 0, false, true
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid " + name})
|
||||
return 0, true, false
|
||||
}
|
||||
return parsed, true, true
|
||||
}
|
||||
|
||||
// WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。
|
||||
func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset})
|
||||
}
|
||||
|
||||
// WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。
|
||||
func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, convert(row))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total})
|
||||
}
|
||||
|
||||
// PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any,
|
||||
// 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。
|
||||
func PtrString(value *string) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrInt64(value *int64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrUint64(value *uint64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrFloat64(value *float64) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func PtrBool(value *bool) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package main
|
||||
|
||||
func (s *store) InsertLoginLog(log loginLogRecord) error {
|
||||
return s.db.Create(&log).Error
|
||||
}
|
||||
|
||||
func (s *store) ListLoginLogs(opts listOptions) ([]loginLogRecord, error) {
|
||||
opts = normalizeListOptions(opts)
|
||||
var rows []loginLogRecord
|
||||
q := s.db.Order("created_at DESC").Order("id DESC").Limit(opts.Limit).Offset(opts.Offset)
|
||||
if opts.Since != nil {
|
||||
q = q.Where("created_at >= ?", *opts.Since)
|
||||
}
|
||||
if opts.Until != nil {
|
||||
q = q.Where("created_at <= ?", *opts.Until)
|
||||
}
|
||||
return rows, q.Find(&rows).Error
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -14,12 +16,23 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"meshtastic_mqtt_server/mqtpp"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"github.com/mochi-mqtt/server/v2/hooks/auth"
|
||||
mqttauth "github.com/mochi-mqtt/server/v2/hooks/auth"
|
||||
"github.com/mochi-mqtt/server/v2/listeners"
|
||||
"github.com/mochi-mqtt/server/v2/packets"
|
||||
|
||||
"meshtastic_mqtt_server/internal/ai"
|
||||
"meshtastic_mqtt_server/internal/auth"
|
||||
"meshtastic_mqtt_server/internal/autoreply"
|
||||
blockingpkg "meshtastic_mqtt_server/internal/blocking"
|
||||
botpkg "meshtastic_mqtt_server/internal/bot"
|
||||
configpkg "meshtastic_mqtt_server/internal/config"
|
||||
"meshtastic_mqtt_server/internal/llm"
|
||||
"meshtastic_mqtt_server/internal/mqtpp"
|
||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
webpkg "meshtastic_mqtt_server/internal/web"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -35,13 +48,18 @@ const (
|
||||
|
||||
type meshtasticFilterHook struct {
|
||||
mqtt.HookBase
|
||||
key []byte
|
||||
dbQueue *dbWriteQueue
|
||||
stats *meshtasticMessageStats
|
||||
blocking *blockingCache
|
||||
settings *runtimeSettingsCache
|
||||
pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool)
|
||||
autoAcker func(record map[string]any)
|
||||
server *mqtt.Server
|
||||
key []byte
|
||||
dbQueue *storepkg.WriteQueue
|
||||
stats *mqttforwardpkg.Stats
|
||||
clientStats *mqttforwardpkg.ClientStats
|
||||
blocking *blockingpkg.Cache
|
||||
settings *rspkg.Cache
|
||||
pkiResolver func(toNodeNum, fromNodeNum uint32) ([]byte, []byte, bool)
|
||||
autoAcker func(record map[string]any)
|
||||
consoleLog bool // 控制台是否打印 MQTT 连接/订阅事件
|
||||
packetConsoleLog bool // 控制台是否打印 Meshtastic 数据包
|
||||
dedupQueue *mqttforwardpkg.DedupQueue
|
||||
}
|
||||
|
||||
// ID 返回用于识别 Meshtastic payload 过滤器的 hook 名称。
|
||||
@@ -49,21 +67,136 @@ func (h *meshtasticFilterHook) ID() string {
|
||||
return "meshtastic-filter"
|
||||
}
|
||||
|
||||
// Provides 声明该 hook 只处理客户端发布消息。
|
||||
// Provides 声明该 hook 处理客户端连接、订阅与发布事件。
|
||||
func (h *meshtasticFilterHook) Provides(b byte) bool {
|
||||
return b == mqtt.OnConnect || b == mqtt.OnPublish
|
||||
return b == mqtt.OnConnect ||
|
||||
b == mqtt.OnPublish ||
|
||||
b == mqtt.OnSessionEstablished ||
|
||||
b == mqtt.OnDisconnect ||
|
||||
b == mqtt.OnSubscribed ||
|
||||
b == mqtt.OnUnsubscribed ||
|
||||
b == mqtt.OnPacketRead ||
|
||||
b == mqtt.OnPacketSent
|
||||
}
|
||||
|
||||
// OnConnect 在 MQTT 会话建立前拒绝命中 IP 屏蔽表的客户端。
|
||||
func (h *meshtasticFilterHook) OnConnect(cl *mqtt.Client, pk packets.Packet) error {
|
||||
// 启用 TCP_NODELAY 禁用 Nagle 算法,确保小数据包(包括 TCP ACK)立即发送
|
||||
// 这对于 MQTT QoS0 消息特别重要,避免设备因为等待 TCP ACK 而重发
|
||||
if cl.Net.Conn != nil {
|
||||
if tcpConn, ok := cl.Net.Conn.(*net.TCPConn); ok {
|
||||
if err := tcpConn.SetNoDelay(true); err != nil {
|
||||
printJSON(map[string]any{"event": "tcp_nodelay_failed", "error": err.Error(), "remote_addr": cl.Net.Remote})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
if h.blocking != nil && h.blocking.IsIPBlocked(info.RemoteHost) {
|
||||
printJSON(map[string]any{"event": "mqtt_client_rejected", "reason": "blocked_ip", "client_id": info.ClientID, "remote_addr": info.RemoteAddr, "remote_host": info.RemoteHost})
|
||||
return packets.ErrNotAuthorized
|
||||
}
|
||||
// 如果该 client_id 当前已经有活动连接(典型场景:同一 Meshtastic 节点被两台 Android
|
||||
// 同时连),broker 默认会按 [MQTT-3.1.4-3] 把旧连接顶下线,导致两边互相踢、日志被刷屏。
|
||||
// 这里给后来者随机加个后缀,避免顶号——CONNACK 不会回传新 ID,但客户端只需要能正常
|
||||
// 收发消息即可,订阅状态在断开后自然清空。
|
||||
if h.server != nil && cl.ID != "" {
|
||||
if existing, ok := h.server.Clients.Get(cl.ID); ok && existing != nil && !existing.Closed() && existing != cl {
|
||||
original := cl.ID
|
||||
cl.ID = original + "-" + randomClientIDSuffix()
|
||||
printJSON(map[string]any{
|
||||
"event": "mqtt_client_id_renamed",
|
||||
"reason": "duplicate_client_id",
|
||||
"original_id": original,
|
||||
"assigned_id": cl.ID,
|
||||
"remote_addr": info.RemoteAddr,
|
||||
"remote_host": info.RemoteHost,
|
||||
"existing_remote": existing.Net.Remote,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// randomClientIDSuffix 生成 4 字符的小写 hex 后缀,用于在 client_id 冲突时给后来的连接重命名。
|
||||
// 用 crypto/rand 是为了避免热路径上 math/rand 的并发锁;2 byte 的熵已经足够区分。
|
||||
func randomClientIDSuffix() string {
|
||||
var b [2]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// 极端情况下退到时间戳低 16 位作为兜底
|
||||
fallback := uint16(time.Now().UnixNano())
|
||||
return fmt.Sprintf("%04x", fallback)
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// OnSessionEstablished 在客户端通过认证、会话建立后打印连接日志。
|
||||
func (h *meshtasticFilterHook) OnSessionEstablished(cl *mqtt.Client, pk packets.Packet) {
|
||||
if !h.consoleLog {
|
||||
return
|
||||
}
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] connect client_id=%s username=%s remote=%s:%s\n",
|
||||
info.ClientID, info.Username, info.RemoteHost, info.RemotePort)
|
||||
}
|
||||
|
||||
// OnDisconnect 在客户端断开时打印日志,含触发原因。
|
||||
func (h *meshtasticFilterHook) OnDisconnect(cl *mqtt.Client, err error, expire bool) {
|
||||
if cl != nil {
|
||||
h.clientStats.Delete(cl.ID)
|
||||
}
|
||||
if !h.consoleLog {
|
||||
return
|
||||
}
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
reason := "client closed"
|
||||
if err != nil {
|
||||
reason = err.Error()
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] disconnect client_id=%s username=%s remote=%s:%s expire=%t reason=%s\n",
|
||||
info.ClientID, info.Username, info.RemoteHost, info.RemotePort, expire, reason)
|
||||
}
|
||||
|
||||
// OnPacketRead 在 broker 收到客户端报文时累计入站计数(客户端 → 服务器)。
|
||||
// 返回原始 packet 不做修改;该 hook 在 packet 校验前触发。
|
||||
func (h *meshtasticFilterHook) OnPacketRead(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
||||
if cl != nil {
|
||||
h.clientStats.IncIn(cl.ID)
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
// OnPacketSent 在 broker 把报文写出后累计出站计数(服务器 → 客户端)。
|
||||
func (h *meshtasticFilterHook) OnPacketSent(cl *mqtt.Client, pk packets.Packet, b []byte) {
|
||||
if cl != nil {
|
||||
h.clientStats.IncOut(cl.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// OnSubscribed 客户端订阅成功后打印订阅的 topic filter 列表。
|
||||
func (h *meshtasticFilterHook) OnSubscribed(cl *mqtt.Client, pk packets.Packet, reasonCodes []byte) {
|
||||
if !h.consoleLog {
|
||||
return
|
||||
}
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
for _, sub := range pk.Filters {
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] subscribe client_id=%s username=%s remote=%s:%s topic=%s\n",
|
||||
info.ClientID, info.Username, info.RemoteHost, info.RemotePort, sub.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
// OnUnsubscribed 客户端取消订阅后打印日志。
|
||||
func (h *meshtasticFilterHook) OnUnsubscribed(cl *mqtt.Client, pk packets.Packet) {
|
||||
if !h.consoleLog {
|
||||
return
|
||||
}
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
for _, sub := range pk.Filters {
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] unsubscribe client_id=%s username=%s remote=%s:%s topic=%s\n",
|
||||
info.ClientID, info.Username, info.RemoteHost, info.RemotePort, sub.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
// OnPublish 在 broker 转发消息前校验 payload;无效消息会被拒绝并丢弃。
|
||||
func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
||||
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key, mqtpp.Options{
|
||||
@@ -71,6 +204,12 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa
|
||||
PKIKeyResolver: h.pkiResolver,
|
||||
})
|
||||
if !valid {
|
||||
// 记录拒绝原因,帮助诊断 QoS0 重发问题
|
||||
if h.consoleLog {
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] PUBLISH rejected: client_id=%s topic=%s qos=%d payload_len=%d error=%v\n",
|
||||
info.ClientID, pk.TopicName, pk.FixedHeader.Qos, len(pk.Payload), record["error"])
|
||||
}
|
||||
h.rejectPublish(cl, pk, record)
|
||||
return pk, packets.ErrRejectPacket
|
||||
}
|
||||
@@ -78,17 +217,27 @@ func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (pa
|
||||
for key, value := range violation {
|
||||
record[key] = value
|
||||
}
|
||||
// 记录屏蔽原因
|
||||
if h.consoleLog {
|
||||
info := mqttClientInfoFromClient(cl)
|
||||
fmt.Fprintf(os.Stderr, "[mqtt] PUBLISH blocked: client_id=%s topic=%s type=%v reason=%v\n",
|
||||
info.ClientID, pk.TopicName, violation["blocking_type"], violation["error"])
|
||||
}
|
||||
h.rejectPublish(cl, pk, record)
|
||||
return pk, packets.ErrRejectPacket
|
||||
}
|
||||
if h.dedupQueue != nil && !h.dedupQueue.TryForward(pk.TopicName, pk.Payload) {
|
||||
h.stats.IncDropped()
|
||||
return pk, packets.ErrRejectPacket
|
||||
}
|
||||
h.stats.IncForwarded()
|
||||
|
||||
h.dbQueue.EnqueueRecord(record, mqttClientInfoFromClient(cl))
|
||||
if h.autoAcker != nil {
|
||||
h.autoAcker(record)
|
||||
}
|
||||
if record["type"] != "empty_packet" {
|
||||
printJSON(record)
|
||||
if h.packetConsoleLog && record["type"] != "empty_packet" {
|
||||
printMeshtasticRecord(record)
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
@@ -104,7 +253,7 @@ func (h *meshtasticFilterHook) rejectPublish(cl *mqtt.Client, pk packets.Packet,
|
||||
h.dbQueue.EnqueueDiscard(record, pk.Payload, mqttClientInfoFromClient(cl))
|
||||
}
|
||||
|
||||
func blockingViolationForRecord(blocking *blockingCache, record map[string]any) map[string]any {
|
||||
func blockingViolationForRecord(blocking *blockingpkg.Cache, record map[string]any) map[string]any {
|
||||
if blocking == nil || record == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -126,12 +275,12 @@ func blockingViolationForRecord(blocking *blockingCache, record map[string]any)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mqttClientInfoFromClient(cl *mqtt.Client) mqttClientInfo {
|
||||
func mqttClientInfoFromClient(cl *mqtt.Client) storepkg.MQTTClientInfo {
|
||||
if cl == nil {
|
||||
return mqttClientInfo{}
|
||||
return storepkg.MQTTClientInfo{}
|
||||
}
|
||||
|
||||
info := mqttClientInfo{
|
||||
info := storepkg.MQTTClientInfo{
|
||||
ClientID: cl.ID,
|
||||
Username: string(cl.Properties.Username),
|
||||
Listener: cl.Net.Listener,
|
||||
@@ -163,8 +312,8 @@ func main() {
|
||||
}
|
||||
|
||||
// parseArgs 加载配置文件、解析命令行覆盖项,并展开 Meshtastic channel PSK。
|
||||
func parseArgs() (*config, error) {
|
||||
cfg, err := loadConfig(defaultConfigPath())
|
||||
func parseArgs() (*configpkg.Config, error) {
|
||||
cfg, err := configpkg.Load(configpkg.DefaultPath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,9 +328,11 @@ func parseArgs() (*config, error) {
|
||||
flag.StringVar(&cfg.Database.SQLite.Path, "sqlite-path", cfg.Database.SQLite.Path, "SQLite database file path")
|
||||
flag.StringVar(&cfg.Database.MySQL.DSN, "mysql-dsn", cfg.Database.MySQL.DSN, "MySQL database DSN")
|
||||
flag.BoolVar(&cfg.Web.Enabled, "web", cfg.Web.Enabled, "Enable Gin web server")
|
||||
flag.BoolVar(&cfg.Web.PortEnabled, "web-port-enabled", cfg.Web.PortEnabled, "Enable web server on TCP host and port")
|
||||
flag.BoolVar(&cfg.Web.SocketEnabled, "web-socket-enabled", cfg.Web.SocketEnabled, "Enable web server on Unix socket; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.Host, "web-host", cfg.Web.Host, "Web server listen host")
|
||||
flag.IntVar(&cfg.Web.Port, "web-port", cfg.Web.Port, "Web server listen port")
|
||||
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; empty uses host and port; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.SocketPath, "web-socket-path", cfg.Web.SocketPath, "Web server Unix socket path; unsupported on Windows")
|
||||
flag.StringVar(&cfg.Web.StaticDir, "web-static-dir", cfg.Web.StaticDir, "Web frontend static files directory")
|
||||
flag.StringVar(&cfg.Web.MapTileCacheDir, "web-map-tile-cache-dir", cfg.Web.MapTileCacheDir, "Map tile disk cache root directory")
|
||||
flag.StringVar(&cfg.Web.Admin.Username, "admin-username", cfg.Web.Admin.Username, "Web admin username")
|
||||
@@ -193,83 +344,162 @@ func parseArgs() (*config, error) {
|
||||
if value := os.Getenv("MESH_ADMIN_SESSION_SECRET"); value != "" {
|
||||
cfg.Web.Admin.SessionSecret = value
|
||||
}
|
||||
clearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
||||
configpkg.ClearWebSocketPathOnUnsupportedGOOS(cfg, runtime.GOOS)
|
||||
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
if err := configpkg.Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := mqtpp.ExpandPSK(cfg.Meshtastic.PSK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.key = key
|
||||
cfg.Key = key
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。
|
||||
func run(cfg *config) error {
|
||||
store, err := openStore(cfg.Database)
|
||||
func run(cfg *configpkg.Config) error {
|
||||
store, err := storepkg.OpenStore(cfg.Database, cfg.ConsoleLog.SQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
dbQueue := newDBWriteQueue(store)
|
||||
dbQueue := storepkg.NewWriteQueue(store)
|
||||
defer dbQueue.Close()
|
||||
if err := store.EnsureDefaultAdmin(cfg.Web.Admin.Username, cfg.Web.Admin.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blocking, err := newBlockingCache(store)
|
||||
blocking, err := blockingpkg.New(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := newRuntimeSettingsCache(store)
|
||||
settings, err := rspkg.New(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messageStats := &meshtasticMessageStats{}
|
||||
server, mqttHook, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, blocking, settings)
|
||||
messageStats := &mqttforwardpkg.Stats{}
|
||||
clientStats := mqttforwardpkg.NewClientStats()
|
||||
server, mqttHook, mqttAddr, err := startMQTTServer(cfg, store, dbQueue, messageStats, clientStats, blocking, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
botSender := newBotService(store, server, cfg.key)
|
||||
botSender := botpkg.NewService(store, server, cfg.Key)
|
||||
mqttHook.autoAcker = botSender.MaybeAutoAck
|
||||
botCtx, stopBotBroadcaster := context.WithCancel(context.Background())
|
||||
defer stopBotBroadcaster()
|
||||
botSender.StartNodeInfoBroadcaster(botCtx)
|
||||
forwardManager := newMQTTForwardManager(store)
|
||||
forwardManager := mqttforwardpkg.NewManager(store)
|
||||
if err := forwardManager.StartFromStore(); err != nil {
|
||||
server.Close()
|
||||
return err
|
||||
}
|
||||
defer forwardManager.StopAll()
|
||||
|
||||
var httpServer *http.Server
|
||||
errCh := make(chan error, 1)
|
||||
// Initialize AI Service
|
||||
// Create bot sender adapter - 支持频道消息和私聊消息两种发送方式
|
||||
botSenderAdapter := autoreply.NewBotServiceAdapter(
|
||||
// SendDirectText: 发送私聊消息
|
||||
func(ctx context.Context, botID uint64, toNodeNum int64, text string) error {
|
||||
_, err := botSender.SendText(ctx, botpkg.SendTextRequest{
|
||||
BotID: botID,
|
||||
MessageType: "direct",
|
||||
ToNodeNum: &toNodeNum,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
},
|
||||
// SendChannelText: 发送频道消息
|
||||
func(ctx context.Context, botID uint64, channelID string, text string) error {
|
||||
_, err := botSender.SendText(ctx, botpkg.SendTextRequest{
|
||||
BotID: botID,
|
||||
MessageType: "channel",
|
||||
ChannelID: channelID,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
aiManager := ai.NewAIManager(ai.Config{
|
||||
DataDir: cfg.AI.DataDir,
|
||||
Enabled: cfg.AI.Enabled,
|
||||
ConsoleLog: cfg.ConsoleLog.LLM,
|
||||
ToolConfigStore: store,
|
||||
ToolRouterStore: store,
|
||||
TopicRouterStore: store,
|
||||
Store: store,
|
||||
}, store.DB(), botSenderAdapter, botCtx, store)
|
||||
|
||||
if cfg.AI.Enabled {
|
||||
aiManager.SetConfigEnabled(true)
|
||||
providers, err := store.ListLLMProviders(true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to load LLM providers: %v\n", err)
|
||||
} else if len(providers) > 0 {
|
||||
providerConfigs := make([]llm.ProviderConfig, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
providerConfigs = append(providerConfigs, llm.ProviderConfig{
|
||||
Name: p.Name,
|
||||
Active: p.Active,
|
||||
APIKey: p.APIKey,
|
||||
BaseURL: p.BaseURL,
|
||||
Model: p.Model,
|
||||
Timeout: p.Timeout,
|
||||
ContextWindowTokens: p.ContextWindowTokens,
|
||||
})
|
||||
}
|
||||
aiManager.SetProviderConfigs(providerConfigs)
|
||||
if err := aiManager.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to initialize AI service: %v\n", err)
|
||||
} else {
|
||||
defer aiManager.Stop()
|
||||
printJSON(map[string]any{"event": "ai_service_started", "providers": len(providerConfigs)})
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Warning: AI service is enabled but no LLM providers configured\n")
|
||||
}
|
||||
}
|
||||
|
||||
var httpServers []*http.Server
|
||||
errCh := make(chan error, 2)
|
||||
if cfg.Web.Enabled {
|
||||
sessions, err := newSessionManager(cfg.Web.Admin)
|
||||
sessions, err := auth.NewManager(cfg.Web.Admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
||||
webAddress := httpServer.Addr
|
||||
go func() {
|
||||
if cfg.Web.SocketPath != "" {
|
||||
if err := serveHTTPUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue, DedupQueue: mqttHook.dedupQueue}
|
||||
handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, aiManager)
|
||||
webAddresses := []string{}
|
||||
if cfg.Web.PortEnabled {
|
||||
httpServer := &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Web.Host, strconv.Itoa(cfg.Web.Port)),
|
||||
Handler: handler,
|
||||
}
|
||||
httpServers = append(httpServers, httpServer)
|
||||
webAddresses = append(webAddresses, httpServer.Addr)
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
if cfg.Web.SocketPath != "" {
|
||||
webAddress = cfg.Web.SocketPath
|
||||
}()
|
||||
}
|
||||
printJSON(map[string]any{"event": "web_started", "address": webAddress, "static_dir": cfg.Web.StaticDir})
|
||||
if cfg.Web.SocketEnabled {
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
httpServers = append(httpServers, httpServer)
|
||||
webAddresses = append(webAddresses, cfg.Web.SocketPath)
|
||||
go func() {
|
||||
if err := webpkg.ServeUnixSocket(httpServer, cfg.Web.SocketPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
webStarted := map[string]any{"event": "web_started", "addresses": webAddresses, "static_dir": cfg.Web.StaticDir}
|
||||
if len(webAddresses) > 0 {
|
||||
webStarted["address"] = webAddresses[0]
|
||||
}
|
||||
printJSON(webStarted)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
@@ -281,38 +511,48 @@ func run(cfg *config) error {
|
||||
case runErr = <-errCh:
|
||||
}
|
||||
|
||||
if httpServer != nil {
|
||||
for _, httpServer := range httpServers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil && runErr == nil {
|
||||
runErr = err
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
if err := server.Close(); err != nil && runErr == nil {
|
||||
runErr = err
|
||||
}
|
||||
if mqttHook.dedupQueue != nil {
|
||||
mqttHook.dedupQueue.Stop()
|
||||
}
|
||||
return runErr
|
||||
}
|
||||
|
||||
func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, *meshtasticFilterHook, string, error) {
|
||||
func startMQTTServer(cfg *configpkg.Config, store *storepkg.Store, dbQueue *storepkg.WriteQueue, stats *mqttforwardpkg.Stats, clientStats *mqttforwardpkg.ClientStats, blocking *blockingpkg.Cache, settings *rspkg.Cache) (*mqtt.Server, *meshtasticFilterHook, string, error) {
|
||||
server := mqtt.New(&mqtt.Options{InlineClient: true})
|
||||
if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
|
||||
if err := server.AddHook(new(mqttauth.AllowHook), nil); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
dedupQueue := mqttforwardpkg.NewDedupQueue()
|
||||
dedupQueue.Start()
|
||||
hook := &meshtasticFilterHook{
|
||||
key: cfg.key,
|
||||
dbQueue: dbQueue,
|
||||
stats: stats,
|
||||
blocking: blocking,
|
||||
settings: settings,
|
||||
pkiResolver: newPKIKeyResolver(store),
|
||||
server: server,
|
||||
key: cfg.Key,
|
||||
dbQueue: dbQueue,
|
||||
stats: stats,
|
||||
clientStats: clientStats,
|
||||
blocking: blocking,
|
||||
settings: settings,
|
||||
pkiResolver: botpkg.NewPKIKeyResolver(store),
|
||||
consoleLog: cfg.ConsoleLog.MQTT,
|
||||
packetConsoleLog: cfg.ConsoleLog.Meshtastic,
|
||||
dedupQueue: dedupQueue,
|
||||
}
|
||||
if err := server.AddHook(hook, nil); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.MQTT.Host, strconv.Itoa(cfg.MQTT.Port))
|
||||
tlsConfig, err := buildTLSConfig(cfg.MQTT.TLS)
|
||||
tlsConfig, err := configpkg.BuildTLS(cfg.MQTT.TLS)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
@@ -329,7 +569,76 @@ func startMQTTServer(cfg *config, store *store, dbQueue *dbWriteQueue, stats *me
|
||||
|
||||
// printJSON 将记录编码为 JSON 后按数据包类型着色输出。
|
||||
func printJSON(record map[string]any) {
|
||||
//printJSONBytes(record, mqtpp.MustJSON(record))
|
||||
printJSONBytes(record, mqtpp.MustJSON(record))
|
||||
}
|
||||
|
||||
// printMeshtasticRecord 把 Meshtastic 解码后的 record 按 type 拼成可读的彩色单行,
|
||||
// 不输出原始 JSON。保留与 printJSONBytes 一致的色码方案。
|
||||
func printMeshtasticRecord(record map[string]any) {
|
||||
if record == nil {
|
||||
return
|
||||
}
|
||||
typ, _ := record["type"].(string)
|
||||
from := stringField(record, "from")
|
||||
channel := stringField(record, "channel_id")
|
||||
gateway := stringField(record, "gateway_id")
|
||||
|
||||
var color string
|
||||
var body string
|
||||
switch typ {
|
||||
case "nodeinfo":
|
||||
color = ansiGreenBGWhiteText
|
||||
body = fmt.Sprintf("nodeinfo from=%s long=%q short=%q hw=%s role=%s",
|
||||
from, stringField(record, "long_name"), stringField(record, "short_name"),
|
||||
stringField(record, "hw_model"), stringField(record, "role"))
|
||||
case "map_report":
|
||||
color = ansiBlueBGWhiteText
|
||||
body = fmt.Sprintf("map_report from=%s long=%q lat=%v lon=%v alt=%v fw=%s region=%s",
|
||||
from, stringField(record, "long_name"),
|
||||
record["latitude"], record["longitude"], record["altitude"],
|
||||
stringField(record, "firmware_version"), stringField(record, "region"))
|
||||
case "text_message":
|
||||
color = ansiPurpleBGWhiteText
|
||||
body = fmt.Sprintf("text from=%s channel=%s text=%q",
|
||||
from, channel, stringField(record, "text"))
|
||||
case "position":
|
||||
color = ansiCyanBGBlackText
|
||||
body = fmt.Sprintf("position from=%s lat=%v lon=%v alt=%v",
|
||||
from, record["latitude"], record["longitude"], record["altitude"])
|
||||
case "telemetry":
|
||||
color = ansiYellowBGBlackText
|
||||
body = fmt.Sprintf("telemetry from=%s tt=%v metrics=%v",
|
||||
from, record["telemetry_type"], record["metrics"])
|
||||
case "routing":
|
||||
color = ansiGrayBGWhiteText
|
||||
body = fmt.Sprintf("routing from=%s pkt_id=%v", from, record["packet_id"])
|
||||
case "traceroute":
|
||||
color = ansiGrayBGWhiteText
|
||||
body = fmt.Sprintf("traceroute from=%s pkt_id=%v", from, record["packet_id"])
|
||||
default:
|
||||
if record["error"] != nil {
|
||||
color = ansiRedBGWhiteText
|
||||
body = fmt.Sprintf("%-10s from=%s error=%v topic=%s", typ, from,
|
||||
record["error"], stringField(record, "topic"))
|
||||
} else {
|
||||
body = fmt.Sprintf("%-10s from=%s", typ, from)
|
||||
}
|
||||
}
|
||||
if gateway != "" {
|
||||
body += " gw=" + gateway
|
||||
}
|
||||
if color != "" {
|
||||
fmt.Printf("%s%s%s\n", color, body, ansiReset)
|
||||
return
|
||||
}
|
||||
fmt.Println(body)
|
||||
}
|
||||
|
||||
func stringField(record map[string]any, key string) string {
|
||||
if v, ok := record[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printJSONBytes 使用已编码好的 JSON 文本,并根据记录 type 选择控制台颜色。
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
)
|
||||
|
||||
func TestMQTTClientInfoFromClientNil(t *testing.T) {
|
||||
info := mqttClientInfoFromClient(nil)
|
||||
if info != (mqttClientInfo{}) {
|
||||
t.Fatalf("info = %#v, want zero value", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientInfoFromClientIPv4(t *testing.T) {
|
||||
info := mqttClientInfoFromClient(&mqtt.Client{
|
||||
ID: "client-1",
|
||||
Properties: mqtt.ClientProperties{Username: []byte("user-1")},
|
||||
Net: mqtt.ClientConnection{Listener: "tcp", Remote: "127.0.0.1:1234"},
|
||||
})
|
||||
|
||||
if info.ClientID != "client-1" || info.Username != "user-1" || info.Listener != "tcp" {
|
||||
t.Fatalf("client fields = %#v", info)
|
||||
}
|
||||
if info.RemoteAddr != "127.0.0.1:1234" || info.RemoteHost != "127.0.0.1" || info.RemotePort != "1234" {
|
||||
t.Fatalf("remote fields = %#v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientInfoFromClientIPv6(t *testing.T) {
|
||||
info := mqttClientInfoFromClient(&mqtt.Client{Net: mqtt.ClientConnection{Remote: "[::1]:1234"}})
|
||||
if info.RemoteHost != "::1" || info.RemotePort != "1234" {
|
||||
t.Fatalf("remote fields = %#v, want host ::1 and port 1234", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientInfoFromClientUnsplitRemote(t *testing.T) {
|
||||
info := mqttClientInfoFromClient(&mqtt.Client{Net: mqtt.ClientConnection{Remote: "localhost"}})
|
||||
if info.RemoteHost != "localhost" || info.RemotePort != "" {
|
||||
t.Fatalf("remote fields = %#v, want host localhost and empty port", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingViolationForRecordNode(t *testing.T) {
|
||||
cache := &blockingCache{nodes: map[string]struct{}{"!12345678": {}}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}}
|
||||
record := map[string]any{"type": "position", "from": "!12345678", "from_num": uint32(305419896)}
|
||||
|
||||
violation := blockingViolationForRecord(cache, record)
|
||||
if violation == nil || violation["blocking_type"] != "node" {
|
||||
t.Fatalf("blockingViolationForRecord() = %#v, want node violation", violation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingViolationForRecordForbiddenWordFields(t *testing.T) {
|
||||
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
record map[string]any
|
||||
field string
|
||||
}{
|
||||
{name: "text", record: map[string]any{"type": "text_message", "from": "!1", "text": "has SPAM"}, field: "text"},
|
||||
{name: "nodeinfo", record: map[string]any{"type": "nodeinfo", "from": "!1", "long_name": "has SPAM"}, field: "long_name"},
|
||||
{name: "map_report", record: map[string]any{"type": "map_report", "from": "!1", "long_name": "has SPAM"}, field: "long_name"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
violation := blockingViolationForRecord(cache, tc.record)
|
||||
if violation == nil || violation["blocking_type"] != "forbidden_word" || violation["blocking_field"] != tc.field || violation["matched_word"] != "spam" {
|
||||
t.Fatalf("blockingViolationForRecord() = %#v, want forbidden word on %s", violation, tc.field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingViolationForRecordAllowed(t *testing.T) {
|
||||
cache := &blockingCache{nodes: map[string]struct{}{}, nodeNums: map[int64]struct{}{}, ips: map[string]struct{}{}, words: []forbiddenWordRule{{word: "spam", foldedWord: "spam", matchType: forbiddenWordMatchContains}}}
|
||||
record := map[string]any{"type": "text_message", "from": "!1", "text": "hello"}
|
||||
if violation := blockingViolationForRecord(cache, record); violation != nil {
|
||||
t.Fatalf("blockingViolationForRecord() = %#v, want nil", violation)
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMapTileSourceDefaultSeeded(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.GetDefaultMapTileSource()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||
}
|
||||
if row.Name != defaultMapTileSourceName || row.URLTemplate != defaultMapTileSourceURLTemplate || !row.Enabled || !row.IsDefault {
|
||||
t.Fatalf("default map source = %+v, want built-in default", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMapTileSourceValidation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://tiles.example.com/{z}/{x}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||
t.Fatal("CreateMapTileSource() missing placeholder error = nil, want error")
|
||||
}
|
||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "javascript:alert(1)/{z}/{x}/{y}", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||
t.Fatal("CreateMapTileSource() invalid scheme error = nil, want error")
|
||||
}
|
||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "bad", URLTemplate: "https://user:pass@tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 19, Enabled: true, ProxyEnabled: true}); err == nil {
|
||||
t.Fatal("CreateMapTileSource() credentials error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListEnabledMapTileSources(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
||||
}
|
||||
custom, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://custom.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(custom) error = %v", err)
|
||||
}
|
||||
if _, err := st.SetDefaultMapTileSource(custom.ID); err != nil {
|
||||
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
rows, err := st.ListEnabledMapTileSources()
|
||||
if err != nil {
|
||||
t.Fatalf("ListEnabledMapTileSources() error = %v", err)
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
t.Fatalf("ListEnabledMapTileSources() length = %d, want at least 2", len(rows))
|
||||
}
|
||||
if rows[0].ID != custom.ID {
|
||||
t.Fatalf("first enabled source id = %d, want default %d", rows[0].ID, custom.ID)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ID == disabled.ID {
|
||||
t.Fatalf("disabled source was returned: %+v", row)
|
||||
}
|
||||
if !row.Enabled {
|
||||
t.Fatalf("disabled row returned: %+v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileSourceDuplicateAndDefaultRules(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
first, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom", URLTemplate: "https://tiles2.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||
t.Fatalf("duplicate name error = %v, want errMapTileSourceAlreadyExists", err)
|
||||
}
|
||||
if _, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Custom 2", URLTemplate: first.URLTemplate, MaxZoom: 18, Enabled: true, ProxyEnabled: true}); !errors.Is(err, errMapTileSourceAlreadyExists) {
|
||||
t.Fatalf("duplicate url error = %v, want errMapTileSourceAlreadyExists", err)
|
||||
}
|
||||
|
||||
updated, err := st.SetDefaultMapTileSource(first.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SetDefaultMapTileSource() error = %v", err)
|
||||
}
|
||||
if !updated.IsDefault {
|
||||
t.Fatalf("updated default = %+v, want is_default", updated)
|
||||
}
|
||||
|
||||
oldDefault, err := st.GetDefaultMapTileSource()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||
}
|
||||
if oldDefault.ID != first.ID {
|
||||
t.Fatalf("default id = %d, want %d", oldDefault.ID, first.ID)
|
||||
}
|
||||
if _, err := st.UpdateMapTileSource(first.ID, mapTileSourceInput{Name: first.Name, URLTemplate: first.URLTemplate, Attribution: first.Attribution, MaxZoom: first.MaxZoom, Enabled: false, IsDefault: true}); !errors.Is(err, errMapTileSourceCannotDisableDefault) {
|
||||
t.Fatalf("disable default error = %v, want errMapTileSourceCannotDisableDefault", err)
|
||||
}
|
||||
if err := st.DeleteMapTileSource(first.ID); !errors.Is(err, errMapTileSourceCannotDeleteDefault) {
|
||||
t.Fatalf("delete default error = %v, want errMapTileSourceCannotDeleteDefault", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileSourceHashIsSetOnCreate(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Hashed", URLTemplate: "https://test.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
want := mapTileSourceHash("https://test.example.com/{z}/{x}/{y}.png")
|
||||
if row.URLTemplateHash != want {
|
||||
t.Fatalf("URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
||||
}
|
||||
if !row.ProxyEnabled {
|
||||
t.Fatal("ProxyEnabled = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileSourceDefaultHasHash(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.GetDefaultMapTileSource()
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefaultMapTileSource() error = %v", err)
|
||||
}
|
||||
want := mapTileSourceHash(defaultMapTileSourceURLTemplate)
|
||||
if row.URLTemplateHash != want {
|
||||
t.Fatalf("default URLTemplateHash = %q, want %q", row.URLTemplateHash, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnabledMapTileSourceByHash(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "HashLookup", URLTemplate: "https://lookup.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
found, err := st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEnabledMapTileSourceByHash() error = %v", err)
|
||||
}
|
||||
if found.ID != row.ID {
|
||||
t.Fatalf("found ID = %d, want %d", found.ID, row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnabledMapTileSourceByHashDisabled(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "DisabledHash", URLTemplate: "https://disabled-hash.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("GetEnabledMapTileSourceByHash(disabled) = %v, want gorm.ErrRecordNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnabledMapTileSourceByHashProxyDisabled(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabledHash", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = st.GetEnabledMapTileSourceByHash(row.URLTemplateHash)
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("GetEnabledMapTileSourceByHash(proxy disabled) = %v, want gorm.ErrRecordNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnabledMapTileSourceByHashUnknown(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
_, err := st.GetEnabledMapTileSourceByHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("GetEnabledMapTileSourceByHash(unknown) = %v, want gorm.ErrRecordNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicMapTileSourceDTOProxyURL(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyTest", URLTemplate: "https://proxy.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
dto := publicMapTileSourceDTO(*row)
|
||||
urlTemplate, ok := dto["url_template"].(string)
|
||||
if !ok {
|
||||
t.Fatal("url_template is not a string")
|
||||
}
|
||||
wantPrefix := "/api/map/" + row.URLTemplateHash + "?x={x}&y={y}&z={z}"
|
||||
if urlTemplate != wantPrefix {
|
||||
t.Fatalf("url_template = %q, want %q", urlTemplate, wantPrefix)
|
||||
}
|
||||
if strings.Contains(urlTemplate, "proxy.example.com") {
|
||||
t.Fatal("url_template should not contain upstream hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicMapTileSourceDTORawURLWhenProxyDisabled(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "RawTest", URLTemplate: "https://raw.example.com/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
dto := publicMapTileSourceDTO(*row)
|
||||
urlTemplate, ok := dto["url_template"].(string)
|
||||
if !ok {
|
||||
t.Fatal("url_template is not a string")
|
||||
}
|
||||
if urlTemplate != row.URLTemplate {
|
||||
t.Fatalf("url_template = %q, want raw %q", urlTemplate, row.URLTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileSourceHashFunction(t *testing.T) {
|
||||
hash1 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
||||
hash2 := mapTileSourceHash("https://tile.openstreetmap.jp/{z}/{x}/{y}.png")
|
||||
hash3 := mapTileSourceHash("https://other.example.com/{z}/{x}/{y}.png")
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Fatal("hash should be deterministic")
|
||||
}
|
||||
if len(hash1) != 64 {
|
||||
t.Fatalf("hash length = %d, want 64", len(hash1))
|
||||
}
|
||||
if hash1 == hash3 {
|
||||
t.Fatal("different URLs should produce different hashes")
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
requests := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
if r.URL.Path != "/3/1/2.png" {
|
||||
t.Fatalf("upstream path = %q, want /3/1/2.png", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("tile-data"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, st, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
||||
for i := 0; i < 2; i++ {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("request %d status = %d, body = %s", i+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder.Body.String() != "tile-data" {
|
||||
t.Fatalf("request %d body = %q, want tile-data", i+1, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("upstream requests = %d, want 1", requests)
|
||||
}
|
||||
|
||||
cachePath := filepath.Join(cacheDir, row.URLTemplateHash, "3", "1", "2.tile")
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read cache file %s: %v", cachePath, err)
|
||||
}
|
||||
if string(data) != "tile-data" {
|
||||
t.Fatalf("cache file = %q, want tile-data", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
row, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Tiles", URLTemplate: "https://tiles.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||
}
|
||||
|
||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []string{
|
||||
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=-1&y=0&z=0",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=0&y=0&z=4",
|
||||
"/api/map/" + row.URLTemplateHash + "?x=2&y=0&z=1",
|
||||
}
|
||||
for _, url := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status = %d, want 400; body = %s", url, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
disabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "Disabled", URLTemplate: "https://disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(disabled) error = %v", err)
|
||||
}
|
||||
proxyDisabled, err := st.CreateMapTileSource(mapTileSourceInput{Name: "ProxyDisabled", URLTemplate: "https://proxy-disabled.example.com/{z}/{x}/{y}.png", MaxZoom: 3, Enabled: true, ProxyEnabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
||||
}
|
||||
|
||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []string{
|
||||
"/api/map/not-a-hash?x=0&y=0&z=0",
|
||||
"/api/map/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa?x=0&y=0&z=0",
|
||||
"/api/map/" + disabled.URLTemplateHash + "?x=0&y=0&z=0",
|
||||
"/api/map/" + proxyDisabled.URLTemplateHash + "?x=0&y=0&z=0",
|
||||
}
|
||||
wantStatus := []int{http.StatusBadRequest, http.StatusNotFound, http.StatusNotFound, http.StatusNotFound}
|
||||
for i, url := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != wantStatus[i] {
|
||||
t.Fatalf("%s status = %d, want %d; body = %s", url, recorder.Code, wantStatus[i], recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileProxyUpstreamStatus(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "/404/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "upstream error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
row404, err := st.CreateMapTileSource(mapTileSourceInput{Name: "NotFoundTiles", URLTemplate: upstream.URL + "/404/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(404) error = %v", err)
|
||||
}
|
||||
row500, err := st.CreateMapTileSource(mapTileSourceInput{Name: "StatusTiles", URLTemplate: upstream.URL + "/{z}/{x}/{y}.png", MaxZoom: 18, Enabled: true, ProxyEnabled: true})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
||||
}
|
||||
|
||||
router := newRouter(webConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, st, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
cases := []struct {
|
||||
url string
|
||||
want int
|
||||
}{
|
||||
{url: "/api/map/" + row404.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusNotFound},
|
||||
{url: "/api/map/" + row500.URLTemplateHash + "?x=0&y=0&z=0", want: http.StatusBadGateway},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
||||
router.ServeHTTP(recorder, req)
|
||||
if recorder.Code != tc.want {
|
||||
t.Fatalf("%s status = %d, want %d; body = %s", tc.url, recorder.Code, tc.want, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
-16
@@ -1,23 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages } from './api'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { adminLogout, createNodeBlockingRule, deleteNode, deleteTextMessage, getAdminMe, getChannels, getHealth, getMapReportViewport, getNodeInfo, getPositions, getTextMessages, purgeNode } from './api'
|
||||
import AdminBlockingManagement from './components/AdminBlockingManagement.vue'
|
||||
import AdminBot from './components/AdminBot.vue'
|
||||
import AdminBotDirect from './components/AdminBotDirect.vue'
|
||||
import AdminDashboard from './components/AdminDashboard.vue'
|
||||
import AdminLLM from './components/AdminLLM.vue'
|
||||
import AdminLLMApi from './components/AdminLLMApi.vue'
|
||||
import AdminDiscardDetails from './components/AdminDiscardDetails.vue'
|
||||
import AdminHelpEdit from './components/AdminHelpEdit.vue'
|
||||
import AdminLogin from './components/AdminLogin.vue'
|
||||
import AdminLoginLogs from './components/AdminLoginLogs.vue'
|
||||
import AdminMapSource from './components/AdminMapSource.vue'
|
||||
import AdminMqttForward from './components/AdminMqttForward.vue'
|
||||
import AdminSignManagement from './components/AdminSignManagement.vue'
|
||||
import AdminUsers from './components/AdminUsers.vue'
|
||||
import AppFooter from './components/AppFooter.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import ConfirmDeleteModal from './components/ConfirmDeleteModal.vue'
|
||||
import HelpPage from './components/HelpPage.vue'
|
||||
import MeshMap from './components/MeshMap.vue'
|
||||
import NodeDetailedPage from './components/NodeDetailedPage.vue'
|
||||
import NodeListPanel from './components/NodeListPanel.vue'
|
||||
import SignedPage from './components/SignedPage.vue'
|
||||
import { fallbackMapSource, loadEnabledMapSources } from './mapSource'
|
||||
import type { AdminUser, HealthStatus, MapBoundsChangePayload, MapBoundsQuery, MapRenderable, MapViewportItem, MapViewportPoint, NodeInfo, NodeInfoById, PositionRecord, PublicMapTileSource, TextMessage } from './types'
|
||||
|
||||
@@ -27,10 +32,14 @@ const isAdminPage = adminPath.startsWith('/admin')
|
||||
const isMqttForwardAdminPage = adminPath === '/admin/mqtt_forward' || adminPath === '/admin/mqtt_forward/'
|
||||
const isBotAdminPage = adminPath === '/admin/bot' || adminPath === '/admin/bot/'
|
||||
const isBotDirectAdminPage = adminPath === '/admin/bot/direct' || adminPath === '/admin/bot/direct/'
|
||||
const isLLMAdminPage = adminPath === '/admin/llm' || adminPath === '/admin/llm/'
|
||||
const isLLMApiAdminPage = adminPath === '/admin/llm/api' || adminPath === '/admin/llm/api/'
|
||||
const isSignAdminPage = adminPath === '/admin/sign' || adminPath === '/admin/sign/'
|
||||
const detailMatch = currentPath.match(/^\/detailed\/(.+)$/)
|
||||
const detailedNodeId = detailMatch ? decodeURIComponent(detailMatch[1]) : ''
|
||||
const isDetailedPage = !!detailedNodeId
|
||||
const isHelpPage = currentPath === '/help'
|
||||
const isSignedPage = currentPath === '/signed'
|
||||
const adminUser = ref<AdminUser | null>(null)
|
||||
const adminChecking = ref(false)
|
||||
|
||||
@@ -48,6 +57,7 @@ const nodePage = ref(1)
|
||||
const nodePageSize = 25
|
||||
const nodeTotal = ref(0)
|
||||
const messages = ref<TextMessage[]>([])
|
||||
const channels = ref<string[]>([])
|
||||
const chatPageSize = 20
|
||||
const chatLoadingOlder = ref(false)
|
||||
const chatHasMore = ref(true)
|
||||
@@ -59,6 +69,8 @@ const mapReportsLoading = ref(false)
|
||||
const mapReportTotal = ref(0)
|
||||
const mapSources = ref<PublicMapTileSource[]>([fallbackMapSource])
|
||||
const mapSource = ref<PublicMapTileSource>(fallbackMapSource)
|
||||
const nodeFilter = ref('')
|
||||
const channelFilter = ref('')
|
||||
const pendingDeleteAction = ref<PendingDeleteAction | null>(null)
|
||||
type DeletableTextMessage = TextMessage & { mergedCount?: number; mergedMessages?: TextMessage[] }
|
||||
type NodeActionRequest = { nodeId: string; nodeNum: number | null; message?: DeletableTextMessage }
|
||||
@@ -66,6 +78,8 @@ type NodeActionPayload = NodeActionRequest & { reason: string }
|
||||
type PendingDeleteAction =
|
||||
| { kind: 'delete-message'; message: DeletableTextMessage }
|
||||
| { kind: 'delete-node'; nodeId: string }
|
||||
| { kind: 'purge-node'; nodeId: string }
|
||||
| { kind: 'delete-displayed-nodes'; nodeIds: string[] }
|
||||
| ({ kind: 'delete-and-block-node' } & NodeActionRequest)
|
||||
let refreshTimer: number | undefined
|
||||
let mapBoundsTimer: number | undefined
|
||||
@@ -82,10 +96,75 @@ const nodesById = computed<NodeInfoById>(() => {
|
||||
return Object.fromEntries(map)
|
||||
})
|
||||
|
||||
const normalizedNodeFilter = computed(() => nodeFilter.value.trim().toLowerCase())
|
||||
const normalizedChannelFilter = computed(() => channelFilter.value.trim())
|
||||
|
||||
function nodeMatchesFilterByInfo(node: NodeInfo | null | undefined, keyword: string): boolean {
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
const fields = [node.node_id, node.long_name, node.short_name, node.hw_model, node.role]
|
||||
return fields.some((value) => (value ?? '').toString().toLowerCase().includes(keyword))
|
||||
}
|
||||
|
||||
function nodeIdMatchesFilter(nodeId: string | null | undefined): boolean {
|
||||
const keyword = normalizedNodeFilter.value
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
if (!nodeId) {
|
||||
return false
|
||||
}
|
||||
if (nodeId.toLowerCase().includes(keyword)) {
|
||||
return true
|
||||
}
|
||||
return nodeMatchesFilterByInfo(nodesById.value[nodeId] ?? null, keyword)
|
||||
}
|
||||
|
||||
function mapReportMatchesFilter(item: MapViewportPoint, keyword: string): boolean {
|
||||
if (!keyword) {
|
||||
return true
|
||||
}
|
||||
const fields = [item.node_id, item.long_name, item.short_name, item.hw_model, item.role]
|
||||
if (fields.some((value) => (value ?? '').toString().toLowerCase().includes(keyword))) {
|
||||
return true
|
||||
}
|
||||
return nodeMatchesFilterByInfo(nodesById.value[item.node_id] ?? null, keyword)
|
||||
}
|
||||
|
||||
const filteredMessages = computed<TextMessage[]>(() => {
|
||||
if (!normalizedNodeFilter.value) {
|
||||
return messages.value
|
||||
}
|
||||
return messages.value.filter((message) => nodeIdMatchesFilter(message.from_id))
|
||||
})
|
||||
|
||||
const filteredPagedNodeInfo = computed<NodeInfo[]>(() => {
|
||||
const keyword = normalizedNodeFilter.value
|
||||
if (!keyword) {
|
||||
return pagedNodeInfo.value
|
||||
}
|
||||
// 过滤启用时,跨整个 nodeInfoSource(最多 500 条)匹配,分页交给前端忽略
|
||||
return nodeInfoSource.value.filter((node) => nodeMatchesFilterByInfo(node, keyword))
|
||||
})
|
||||
|
||||
const filteredNodeTotal = computed(() => {
|
||||
if (!normalizedNodeFilter.value) {
|
||||
return nodeTotal.value
|
||||
}
|
||||
return filteredPagedNodeInfo.value.length
|
||||
})
|
||||
|
||||
const mapItems = computed<MapRenderable[]>(() => {
|
||||
const items = mapViewportItems.value
|
||||
const keyword = normalizedNodeFilter.value
|
||||
const items = keyword
|
||||
? mapViewportItems.value.filter((item) => isMapViewportPoint(item) && mapReportMatchesFilter(item, keyword))
|
||||
: mapViewportItems.value
|
||||
const selectedItem = selectedMapPoint.value
|
||||
const renderItems = selectedItem && selectedItem.type === 'point' && !items.some((item) => item.type === 'point' && item.node_id === selectedItem.node_id)
|
||||
const renderItems = selectedItem && selectedItem.type === 'point' && (!keyword || mapReportMatchesFilter(selectedItem, keyword)) && !items.some((item) => item.type === 'point' && item.node_id === selectedItem.node_id)
|
||||
? [...items, selectedItem]
|
||||
: items
|
||||
|
||||
@@ -129,6 +208,12 @@ const deleteModalTitle = computed(() => {
|
||||
if (action.kind === 'delete-node') {
|
||||
return '确认删除节点'
|
||||
}
|
||||
if (action.kind === 'purge-node') {
|
||||
return '确认删除节点'
|
||||
}
|
||||
if (action.kind === 'delete-displayed-nodes') {
|
||||
return '确认删除所有显示的节点'
|
||||
}
|
||||
return '确认删除并屏蔽节点'
|
||||
})
|
||||
|
||||
@@ -146,6 +231,12 @@ const deleteModalMessage = computed(() => {
|
||||
if (action.kind === 'delete-node') {
|
||||
return '确定要删除这个节点吗?此操作不可撤销。'
|
||||
}
|
||||
if (action.kind === 'purge-node') {
|
||||
return '确定要删除这个节点吗?将同时清理该节点的聊天消息和位置/遥测/路由/路径追踪等数据包记录,此操作不可撤销。'
|
||||
}
|
||||
if (action.kind === 'delete-displayed-nodes') {
|
||||
return `确定要删除当前地图上显示的全部 ${action.nodeIds.length} 个节点吗?将逐个调用删除接口,此操作不可撤销。`
|
||||
}
|
||||
if (!action.message) {
|
||||
return '确定要删除并屏蔽这个节点吗?请输入屏蔽原因。'
|
||||
}
|
||||
@@ -166,8 +257,7 @@ function toChronological(items: TextMessage[]): TextMessage[] {
|
||||
}
|
||||
|
||||
function compareMessages(a: TextMessage, b: TextMessage): number {
|
||||
const timeDiff = Date.parse(a.created_at) - Date.parse(b.created_at)
|
||||
return timeDiff !== 0 ? timeDiff : a.id - b.id
|
||||
return a.id - b.id
|
||||
}
|
||||
|
||||
function mergeMessages(existing: TextMessage[], incoming: TextMessage[]): TextMessage[] {
|
||||
@@ -210,7 +300,7 @@ function clearSelectedNode() {
|
||||
}
|
||||
|
||||
async function loadInitialChatMessages() {
|
||||
const response = await getTextMessages(chatPageSize, 0)
|
||||
const response = await getTextMessages(chatPageSize, 0, normalizedChannelFilter.value ? { channelId: normalizedChannelFilter.value } : '')
|
||||
messages.value = toChronological(response.items)
|
||||
chatHasMore.value = response.items.length === chatPageSize
|
||||
chatInitialized.value = true
|
||||
@@ -223,7 +313,7 @@ async function loadOlderMessages() {
|
||||
|
||||
chatLoadingOlder.value = true
|
||||
try {
|
||||
const response = await getTextMessages(chatPageSize, messages.value.length)
|
||||
const response = await getTextMessages(chatPageSize, messages.value.length, normalizedChannelFilter.value ? { channelId: normalizedChannelFilter.value } : '')
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
chatHasMore.value = response.items.length === chatPageSize
|
||||
} catch (err) {
|
||||
@@ -234,10 +324,23 @@ async function loadOlderMessages() {
|
||||
}
|
||||
|
||||
async function pollLatestMessages() {
|
||||
const response = await getTextMessages(chatPageSize, 0)
|
||||
const response = await getTextMessages(chatPageSize, 0, normalizedChannelFilter.value ? { channelId: normalizedChannelFilter.value } : '')
|
||||
messages.value = mergeMessages(messages.value, toChronological(response.items))
|
||||
}
|
||||
|
||||
let channelFilterTimer: number | undefined
|
||||
watch(normalizedChannelFilter, () => {
|
||||
if (channelFilterTimer !== undefined) {
|
||||
window.clearTimeout(channelFilterTimer)
|
||||
}
|
||||
channelFilterTimer = window.setTimeout(() => {
|
||||
messages.value = []
|
||||
chatHasMore.value = true
|
||||
chatInitialized.value = false
|
||||
loadInitialChatMessages()
|
||||
}, 400)
|
||||
})
|
||||
|
||||
async function loadNodePage(page: number, showLoading = true) {
|
||||
if (showLoading) {
|
||||
nodePageLoading.value = true
|
||||
@@ -369,6 +472,26 @@ function requestDeleteNode(nodeId: string) {
|
||||
pendingDeleteAction.value = { kind: 'delete-node', nodeId }
|
||||
}
|
||||
|
||||
function requestPurgeNode(nodeId: string) {
|
||||
pendingDeleteAction.value = { kind: 'purge-node', nodeId }
|
||||
}
|
||||
|
||||
function requestDeleteDisplayedNodes() {
|
||||
// 收集当前地图上正在显示的节点 ID(仅 type=node,跳过聚合点),并按筛选后的视图顺序去重。
|
||||
const nodeIds = Array.from(
|
||||
new Set(
|
||||
mapItems.value
|
||||
.filter((item): item is Extract<MapRenderable, { type: 'node' }> => item.type === 'node')
|
||||
.map((item) => item.node_id),
|
||||
),
|
||||
)
|
||||
if (nodeIds.length === 0) {
|
||||
error.value = '当前地图上没有可删除的节点。'
|
||||
return
|
||||
}
|
||||
pendingDeleteAction.value = { kind: 'delete-displayed-nodes', nodeIds }
|
||||
}
|
||||
|
||||
function requestDeleteAndBlockNode(payload: NodeActionRequest) {
|
||||
pendingDeleteAction.value = { kind: 'delete-and-block-node', ...payload }
|
||||
}
|
||||
@@ -394,6 +517,16 @@ async function confirmDeleteModal(payload: { reason?: string }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (action.kind === 'purge-node') {
|
||||
await purgeNodeById(action.nodeId)
|
||||
return
|
||||
}
|
||||
|
||||
if (action.kind === 'delete-displayed-nodes') {
|
||||
await deleteDisplayedNodes(action.nodeIds)
|
||||
return
|
||||
}
|
||||
|
||||
const reason = payload.reason?.trim()
|
||||
if (!reason) {
|
||||
return
|
||||
@@ -471,6 +604,40 @@ async function deleteNodeById(nodeId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeNodeById(nodeId: string) {
|
||||
try {
|
||||
await purgeNode(nodeId)
|
||||
// 该节点的本地缓存包括聊天消息,一起从前端状态里清掉。
|
||||
messages.value = messages.value.filter((message) => message.from_id !== nodeId)
|
||||
await removeNodeFromLocalState(nodeId)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 逐个调用 deleteNode 串行删除当前地图上显示的节点;某一个失败不阻断后续,最后汇总错误。
|
||||
async function deleteDisplayedNodes(nodeIds: string[]) {
|
||||
const failures: string[] = []
|
||||
let lastErrorMessage = ''
|
||||
for (const nodeId of nodeIds) {
|
||||
try {
|
||||
await deleteNode(nodeId)
|
||||
await removeNodeFromLocalState(nodeId)
|
||||
} catch (err) {
|
||||
if (isNodeNotFoundError(err)) {
|
||||
// 已经不在了,本地状态也同步移除即可
|
||||
await removeNodeFromLocalState(nodeId)
|
||||
continue
|
||||
}
|
||||
failures.push(nodeId)
|
||||
lastErrorMessage = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
error.value = `部分节点删除失败(${failures.length}/${nodeIds.length}):${lastErrorMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAndBlockNode(payload: NodeActionPayload) {
|
||||
try {
|
||||
if (payload.message) {
|
||||
@@ -509,12 +676,13 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
checkAdminSession()
|
||||
if (isDetailedPage || isHelpPage) {
|
||||
if (isDetailedPage || isHelpPage || isSignedPage) {
|
||||
return
|
||||
}
|
||||
loadMapSource()
|
||||
refresh()
|
||||
refreshTimer = window.setInterval(() => refresh(false), 5000)
|
||||
getChannels().then(res => { channels.value = res.items.map(i => i.channel_id) }).catch(() => {})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -524,6 +692,9 @@ onBeforeUnmount(() => {
|
||||
if (mapBoundsTimer !== undefined) {
|
||||
window.clearTimeout(mapBoundsTimer)
|
||||
}
|
||||
if (channelFilterTimer !== undefined) {
|
||||
window.clearTimeout(channelFilterTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -534,6 +705,7 @@ onBeforeUnmount(() => {
|
||||
<p class="eyebrow">Meshtastic MQTT Server</p>
|
||||
<h1 v-if="isDetailedPage">节点详情</h1>
|
||||
<h1 v-else-if="isHelpPage">使用帮助</h1>
|
||||
<h1 v-else-if="isSignedPage">签到列表</h1>
|
||||
<h1 v-else>{{ isAdminPage ? 'Admin' : 'MeshMap' }}</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
@@ -545,6 +717,9 @@ onBeforeUnmount(() => {
|
||||
<a href="/admin/mqtt_forward/" :class="{ active: isMqttForwardAdminPage }">MQTT转发</a>
|
||||
<a href="/admin/bot" :class="{ active: isBotAdminPage }">机器人</a>
|
||||
<a href="/admin/bot/direct" :class="{ active: isBotDirectAdminPage }">机器人私聊</a>
|
||||
<a href="/admin/llm" :class="{ active: isLLMAdminPage }">LLM消息队列</a>
|
||||
<a href="/admin/llm/api" :class="{ active: isLLMApiAdminPage }">LLM API配置</a>
|
||||
<a href="/admin/sign" :class="{ active: isSignAdminPage }">签到管理</a>
|
||||
<a href="/admin/map_source" :class="{ active: adminPath === '/admin/map_source' }">地图图源</a>
|
||||
<a href="/admin/help_edit" :class="{ active: adminPath === '/admin/help_edit' }">帮助编辑</a>
|
||||
<a href="/admin/log/login" :class="{ active: adminPath === '/admin/log/login' }">登录日志</a>
|
||||
@@ -561,13 +736,33 @@ onBeforeUnmount(() => {
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
</template>
|
||||
<template v-else-if="isSignedPage">
|
||||
<a class="topbar-link" href="/">返回地图</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
<span class="counter">节点 {{ nodeTotal }} · 已加载消息 {{ messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}</span>
|
||||
|
||||
<span class="counter">节点 {{ normalizedNodeFilter ? `${filteredNodeTotal} / ${nodeTotal}` : nodeTotal }} · 已加载消息 {{ normalizedNodeFilter ? `${filteredMessages.length} / ${messages.length}` : messages.length }} · 坐标 {{ mapItems.length }} / {{ mapReportTotal }}{{ mapViewportMode === 'clusters' ? ' · 已聚合' : '' }}{{ mapReportsLoading ? ' · 坐标加载中...' : '' }}{{ normalizedNodeFilter ? ' · 已筛选' : '' }}</span>
|
||||
<div class="topbar-filter">
|
||||
<input
|
||||
type="search"
|
||||
class="topbar-filter-input"
|
||||
v-model="nodeFilter"
|
||||
placeholder="筛选节点"
|
||||
/>
|
||||
<button
|
||||
v-if="normalizedNodeFilter"
|
||||
type="button"
|
||||
class="topbar-filter-clear"
|
||||
@click="nodeFilter = ''"
|
||||
>清除</button>
|
||||
</div>
|
||||
<a class="topbar-link" href="/signed">签到列表</a>
|
||||
<a class="topbar-link" href="/help">使用帮助</a>
|
||||
<a class="topbar-link" href="/admin">管理</a>
|
||||
<button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
|
||||
<!-- <button @click="() => refresh()" :disabled="loading">{{ loading ? '刷新中...' : '刷新' }}</button>
|
||||
-->
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
@@ -587,6 +782,9 @@ onBeforeUnmount(() => {
|
||||
<AdminMqttForward v-else-if="isMqttForwardAdminPage" />
|
||||
<AdminBot v-else-if="isBotAdminPage" />
|
||||
<AdminBotDirect v-else-if="isBotDirectAdminPage" />
|
||||
<AdminLLM v-else-if="isLLMAdminPage" />
|
||||
<AdminLLMApi v-else-if="isLLMApiAdminPage" />
|
||||
<AdminSignManagement v-else-if="isSignAdminPage" />
|
||||
<AdminMapSource v-else-if="adminPath === '/admin/map_source'" />
|
||||
<AdminHelpEdit v-else-if="adminPath === '/admin/help_edit'" />
|
||||
<AdminLoginLogs v-else-if="adminPath === '/admin/log/login'" />
|
||||
@@ -604,12 +802,18 @@ onBeforeUnmount(() => {
|
||||
<HelpPage />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isSignedPage">
|
||||
<SignedPage />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<section class="workspace">
|
||||
<ChatPanel
|
||||
:messages="messages"
|
||||
v-model:channelFilter="channelFilter"
|
||||
:channels="channels"
|
||||
:messages="filteredMessages"
|
||||
:nodes-by-id="nodesById"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:loading-older="chatLoadingOlder"
|
||||
@@ -633,25 +837,31 @@ onBeforeUnmount(() => {
|
||||
@select-node="selectNode"
|
||||
@clear-node="clearSelectedNode"
|
||||
@delete-node="requestDeleteNode"
|
||||
@purge-node="requestPurgeNode"
|
||||
@delete-displayed-nodes="requestDeleteDisplayedNodes"
|
||||
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<NodeListPanel
|
||||
:nodes="pagedNodeInfo"
|
||||
:nodes="filteredPagedNodeInfo"
|
||||
:selected-node-id="selectedNodeId"
|
||||
:page="nodePage"
|
||||
:page-size="nodePageSize"
|
||||
:total="nodeTotal"
|
||||
:total="filteredNodeTotal"
|
||||
:loading="nodePageLoading || loading"
|
||||
:is-admin="!!adminUser"
|
||||
:filter-active="!!normalizedNodeFilter"
|
||||
@select-node="selectNode"
|
||||
@page-change="loadNodePage"
|
||||
@delete-node="requestDeleteNode"
|
||||
@purge-node="requestPurgeNode"
|
||||
@delete-and-block-node="requestDeleteAndBlockNode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<ConfirmDeleteModal
|
||||
:open="!!pendingDeleteAction"
|
||||
:title="deleteModalTitle"
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AdminRuntimeSettingsPayload,
|
||||
AdminRuntimeSettingsResponse,
|
||||
AdminUsersResponse,
|
||||
AIServiceStatus,
|
||||
BotMessage,
|
||||
BotMessageMutationResponse,
|
||||
BotNode,
|
||||
@@ -40,10 +41,24 @@ import type {
|
||||
PositionRecord,
|
||||
PublicMapTileSourceResponse,
|
||||
PublicMapTileSourcesResponse,
|
||||
SignDayCount,
|
||||
SignRecord,
|
||||
SignRecordPayload,
|
||||
TelemetryRecord,
|
||||
TextMessage,
|
||||
BotDirectMessage,
|
||||
BotDirectConversationsResponse,
|
||||
LLMMessage,
|
||||
LLMMessageStatus,
|
||||
LLMProvider,
|
||||
LLMProviderPayload,
|
||||
LLMProviderResponse,
|
||||
LLMPlatformRouterPayload,
|
||||
LLMPlatformRouterResponse,
|
||||
LLMTopicConfigPayload,
|
||||
LLMTopicConfigResponse,
|
||||
LLMPrimaryConfigPayload,
|
||||
LLMPrimaryConfigResponse,
|
||||
} from './types'
|
||||
|
||||
async function requestJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -116,6 +131,14 @@ export function getHealth(): Promise<HealthStatus> {
|
||||
return getJSON<HealthStatus>('/api/health')
|
||||
}
|
||||
|
||||
export function getBackendVersion(): Promise<{ version: string; commit: string }> {
|
||||
return getJSON<{ version: string; commit: string }>('/api/version')
|
||||
}
|
||||
|
||||
export function getChannels(): Promise<{ items: { channel_id: string }[] }> {
|
||||
return getJSON<{ items: { channel_id: string }[] }>('/api/channels')
|
||||
}
|
||||
|
||||
export function getHelpContent(): Promise<HelpContentResponse> {
|
||||
return getJSON<HelpContentResponse>('/api/help')
|
||||
}
|
||||
@@ -167,6 +190,30 @@ export function getTextMessages(limit = 100, offset = 0, nodeIdOrOptions: string
|
||||
return getJSON<ListResponse<TextMessage>>(listPath('/api/text-messages', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
export function getSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignRecord>> {
|
||||
return getJSON<ListResponse<SignRecord>>(listPath('/api/signs', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
export function getSignDailyCounts(nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignDayCount>> {
|
||||
return getJSON<ListResponse<SignDayCount>>(listPath('/api/signs/daily', 500, 0, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
export function getAdminSignRecords(limit = 100, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<SignRecord>> {
|
||||
return getJSON<ListResponse<SignRecord>>(listPath('/api/admin/signs', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
|
||||
export function createAdminSignRecord(payload: SignRecordPayload): Promise<{ item: SignRecord }> {
|
||||
return postJSON<{ item: SignRecord }>('/api/admin/signs', payload)
|
||||
}
|
||||
|
||||
export function updateAdminSignRecord(id: number, payload: SignRecordPayload): Promise<{ item: SignRecord }> {
|
||||
return putJSON<{ item: SignRecord }>(`/api/admin/signs/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteAdminSignRecord(id: number): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/signs/${id}`)
|
||||
}
|
||||
|
||||
export function deleteTextMessage(id: number): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/text-messages/${id}`)
|
||||
}
|
||||
@@ -175,6 +222,11 @@ export function deleteNode(nodeId: string): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}`)
|
||||
}
|
||||
|
||||
// 「删除节点」:除节点信息和地图上报外,再清理聊天消息以及 position/telemetry/routing/traceroute 等数据包记录。
|
||||
export function purgeNode(nodeId: string): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/nodes/${encodeURIComponent(nodeId)}/purge`)
|
||||
}
|
||||
|
||||
export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | ListQueryOptions = ''): Promise<ListResponse<PositionRecord>> {
|
||||
return getJSON<ListResponse<PositionRecord>>(listPath('/api/positions', limit, offset, nodeIdOrOptions))
|
||||
}
|
||||
@@ -183,6 +235,14 @@ export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse
|
||||
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/discard-details', limit, offset))
|
||||
}
|
||||
|
||||
export function deleteDiscardDetailsByIDs(ids: number[]): Promise<{ status: string; deleted_count: number }> {
|
||||
return postJSON<{ status: string; deleted_count: number }>('/api/admin/discard-details/batch-delete', { ids })
|
||||
}
|
||||
|
||||
export function clearDiscardDetails(): Promise<{ status: string; deleted_count: number }> {
|
||||
return deleteJSON<{ status: string; deleted_count: number }>('/api/admin/discard-details')
|
||||
}
|
||||
|
||||
export function getTelemetry(limit = 500, offset = 0, nodeId = ''): Promise<ListResponse<TelemetryRecord>> {
|
||||
return getJSON<ListResponse<TelemetryRecord>>(listPath('/api/telemetry', limit, offset, nodeId))
|
||||
}
|
||||
@@ -203,6 +263,21 @@ export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
|
||||
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
|
||||
}
|
||||
|
||||
// 一键断开 MQTT 客户端并把它的远端 IP 加入屏蔽表。
|
||||
export function disconnectAndBlockMqttClient(
|
||||
clientId: string,
|
||||
payload: { reason?: string } = {},
|
||||
): Promise<{
|
||||
status: string
|
||||
client_id: string
|
||||
ip_value: string
|
||||
disconnected: boolean
|
||||
ip_rule_created: boolean
|
||||
ip_rule_id?: number
|
||||
}> {
|
||||
return postJSON(`/api/admin/mqtt/clients/${encodeURIComponent(clientId)}/disconnect-and-block`, payload)
|
||||
}
|
||||
|
||||
export function getAdminRuntimeSettings(): Promise<AdminRuntimeSettingsResponse> {
|
||||
return getJSON<AdminRuntimeSettingsResponse>('/api/admin/runtime-settings')
|
||||
}
|
||||
@@ -402,3 +477,100 @@ export type { BotDirectConversation } from './types'
|
||||
export function sendBotMessage(payload: BotSendMessagePayload): Promise<BotMessageMutationResponse> {
|
||||
return postJSON<BotMessageMutationResponse>('/api/admin/bot/messages', payload)
|
||||
}
|
||||
|
||||
// LLM 消息队列 API
|
||||
export function getLLMMessages(limit = 100, offset = 0, botId?: number, includeDeleted = false): Promise<ListResponse<LLMMessage>> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||
if (botId !== undefined && botId > 0) {
|
||||
params.set('bot_id', String(botId))
|
||||
}
|
||||
if (includeDeleted) {
|
||||
params.set('include_deleted', 'true')
|
||||
}
|
||||
return getJSON<ListResponse<LLMMessage>>(`/api/admin/llm/messages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export function getLLMMessage(id: number): Promise<{ item: LLMMessage }> {
|
||||
return getJSON<{ item: LLMMessage }>(`/api/admin/llm/messages/${id}`)
|
||||
}
|
||||
|
||||
export function updateLLMMessageStatus(id: number, status: LLMMessageStatus, error?: string): Promise<{ status: string }> {
|
||||
return putJSON<{ status: string }>(`/api/admin/llm/messages/${id}/status`, { status, error })
|
||||
}
|
||||
|
||||
export function deleteLLMMessage(id: number): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/llm/messages/${id}`)
|
||||
}
|
||||
|
||||
export function deleteLLMMessagesByBot(botId: number): Promise<{ status: string }> {
|
||||
return deleteJSON<{ status: string }>(`/api/admin/llm/bots/${botId}/messages`)
|
||||
}
|
||||
|
||||
export function cleanupDeletedLLMMessages(days = 7): Promise<{ status: string; deleted_count: number }> {
|
||||
return postJSON<{ status: string; deleted_count: number }>('/api/admin/llm/messages/cleanup', { days })
|
||||
}
|
||||
|
||||
// LLM Provider API
|
||||
export function getLLMProviders(includeInactive = false): Promise<ListResponse<LLMProvider>> {
|
||||
const params = new URLSearchParams()
|
||||
if (includeInactive) {
|
||||
params.set('include_inactive', 'true')
|
||||
}
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
return getJSON<ListResponse<LLMProvider>>(`/api/admin/llm/providers${query}`)
|
||||
}
|
||||
|
||||
export function getLLMProvider(name: string): Promise<LLMProviderResponse> {
|
||||
return getJSON<LLMProviderResponse>(`/api/admin/llm/providers/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export function createLLMProvider(payload: LLMProviderPayload): Promise<LLMProviderResponse> {
|
||||
return postJSON<LLMProviderResponse>('/api/admin/llm/providers', payload)
|
||||
}
|
||||
|
||||
export function updateLLMProvider(name: string, payload: Partial<LLMProviderPayload>): Promise<LLMProviderResponse> {
|
||||
return putJSON<LLMProviderResponse>(`/api/admin/llm/providers/${encodeURIComponent(name)}`, payload)
|
||||
}
|
||||
|
||||
export function deleteLLMProvider(name: string): Promise<{ status: string; warning?: string }> {
|
||||
return deleteJSON<{ status: string; warning?: string }>(`/api/admin/llm/providers/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
// AI Service Status API
|
||||
export function getAIServiceStatus(): Promise<AIServiceStatus> {
|
||||
return getJSON<AIServiceStatus>('/api/admin/llm/status')
|
||||
}
|
||||
|
||||
export function restartAIService(): Promise<AIServiceStatus & { status: string; message?: string }> {
|
||||
return postJSON<AIServiceStatus & { status: string; message?: string }>('/api/admin/llm/restart')
|
||||
}
|
||||
|
||||
// LLM Tool Router API
|
||||
export function getLLMToolRouter(): Promise<LLMPlatformRouterResponse> {
|
||||
return getJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router')
|
||||
}
|
||||
|
||||
export function updateLLMToolRouter(payload: Partial<LLMPlatformRouterPayload>): Promise<LLMPlatformRouterResponse> {
|
||||
return putJSON<LLMPlatformRouterResponse>('/api/admin/llm/tool-router', payload)
|
||||
}
|
||||
|
||||
// LLM Topic Config API - 话题选择配置
|
||||
export function getLLMTopicConfig(): Promise<LLMTopicConfigResponse> {
|
||||
return getJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config')
|
||||
}
|
||||
|
||||
export function updateLLMTopicConfig(payload: Partial<LLMTopicConfigPayload>): Promise<LLMTopicConfigResponse> {
|
||||
return putJSON<LLMTopicConfigResponse>('/api/admin/llm/topic-config', payload)
|
||||
}
|
||||
|
||||
// LLM Primary Config API - 主 AI 回复配置
|
||||
export function getLLMPrimaryConfig(): Promise<LLMPrimaryConfigResponse> {
|
||||
return getJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config')
|
||||
}
|
||||
|
||||
export function updateLLMPrimaryConfig(payload: Partial<LLMPrimaryConfigPayload>): Promise<LLMPrimaryConfigResponse> {
|
||||
return putJSON<LLMPrimaryConfigResponse>('/api/admin/llm/primary-config', payload)
|
||||
}
|
||||
|
||||
// 静默使用未导出类型,避免 TS6133(未使用的导入)。
|
||||
export type { LLMMessage, LLMMessageStatus, LLMProvider, LLMPlatformRouter, LLMTopicConfig, LLMPrimaryConfig } from './types'
|
||||
@@ -27,8 +27,8 @@ const message = ref('')
|
||||
const targetQuery = ref('')
|
||||
const chatPanelRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const newBot = ref({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true })
|
||||
const edits = ref<Record<number, { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: string; enabled: boolean }>>({})
|
||||
const newBot = ref<{ node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: string | number; enabled: boolean }>({ node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true })
|
||||
const edits = ref<Record<number, { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix: string; psk: string; nodeinfo_broadcast_enabled: boolean; nodeinfo_broadcast_interval_seconds: string | number; enabled: boolean }>>({})
|
||||
const sendForm = ref<{ message_type: BotMessageType; channel_id: string; to_node_id: string; text: string }>({ message_type: 'channel', channel_id: 'LongFast', to_node_id: '', text: '' })
|
||||
|
||||
const selectedBot = computed(() => bots.value.find((bot) => bot.id === selectedBotId.value) ?? null)
|
||||
@@ -98,8 +98,10 @@ watch(currentChannelID, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function botPayload(form: { node_num: string; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }): BotNodePayload {
|
||||
const nodeNumText = form.node_num.trim()
|
||||
function botPayload(form: { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }): BotNodePayload {
|
||||
// <input type="number"> 的 v-model 会把绑定值转成 number,而 number 上没有 trim,
|
||||
// 直接调用会抛 "node_num.trim is not a function" 让保存失败。统一转成 string 再 trim。
|
||||
const nodeNumText = form.node_num == null ? '' : String(form.node_num).trim()
|
||||
const interval = Number(form.nodeinfo_broadcast_interval_seconds || 3600)
|
||||
return {
|
||||
node_num: nodeNumText ? Number(nodeNumText) : null,
|
||||
|
||||
@@ -128,13 +128,18 @@ async function reloadConversations() {
|
||||
if (!selectedBot.value) return
|
||||
try {
|
||||
const response = await getBotConversations(selectedBot.value.id, conversationPageSize, 0)
|
||||
conversations.value = response.items
|
||||
let items = response.items
|
||||
// 用户通过“新建私聊”选中尚未有消息的节点时,本地有占位会话,但后端不会返回它。
|
||||
// 直接覆盖会让轮询自动取消选择,所以这里保留占位并 prepend 回去。
|
||||
if (selectedPeerNum.value != null && !items.some((c) => c.peer_node_num === selectedPeerNum.value)) {
|
||||
const localPlaceholder = conversations.value.find((c) => c.peer_node_num === selectedPeerNum.value)
|
||||
if (localPlaceholder) items = [localPlaceholder, ...items]
|
||||
}
|
||||
conversations.value = items
|
||||
unreadTotal.value = response.unread_total
|
||||
// 第一次进入或目标会话被删除时自动选中第一个会话,避免空白页面。
|
||||
if (selectedPeerNum.value == null && response.items.length > 0) {
|
||||
selectedPeerNum.value = response.items[0].peer_node_num
|
||||
} else if (selectedPeerNum.value != null && !response.items.some((c) => c.peer_node_num === selectedPeerNum.value)) {
|
||||
selectedPeerNum.value = response.items[0]?.peer_node_num ?? null
|
||||
// 第一次进入时自动选中第一个会话,避免空白页面。
|
||||
if (selectedPeerNum.value == null && items.length > 0) {
|
||||
selectedPeerNum.value = items[0].peer_node_num
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
|
||||
import type { AdminMqttStatus, AdminRuntimeSettings } from '../types'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { disconnectAndBlockMqttClient, getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||
import type { AdminMqttClient, AdminMqttStatus, AdminRuntimeSettings } from '../types'
|
||||
|
||||
const status = ref<AdminMqttStatus | null>(null)
|
||||
const runtimeSettings = ref<AdminRuntimeSettings | null>(null)
|
||||
@@ -10,8 +11,47 @@ const settingsLoading = ref(false)
|
||||
const error = ref('')
|
||||
const settingsError = ref('')
|
||||
const settingsMessage = ref('')
|
||||
const clientActionMessage = ref('')
|
||||
const clientActionError = ref('')
|
||||
const pendingClient = ref<AdminMqttClient | null>(null)
|
||||
const clientActionInProgress = ref(false)
|
||||
let timer: number | undefined
|
||||
|
||||
type ClientSortKey = 'client_id' | 'username' | 'listener' | 'remote_addr' | 'packets_in' | 'packets_out'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
const clientSortKey = ref<ClientSortKey>('client_id')
|
||||
const clientSortDir = ref<SortDir>('asc')
|
||||
|
||||
function toggleClientSort(key: ClientSortKey) {
|
||||
if (clientSortKey.value === key) {
|
||||
clientSortDir.value = clientSortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
clientSortKey.value = key
|
||||
clientSortDir.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const sortedClients = computed<AdminMqttClient[]>(() => {
|
||||
const list = status.value?.clients ? [...status.value.clients] : []
|
||||
const key = clientSortKey.value
|
||||
const dir = clientSortDir.value === 'asc' ? 1 : -1
|
||||
list.sort((a, b) => {
|
||||
const av = a[key]
|
||||
const bv = b[key]
|
||||
if (typeof av === 'number' && typeof bv === 'number') {
|
||||
return (av - bv) * dir
|
||||
}
|
||||
return String(av ?? '').localeCompare(String(bv ?? '')) * dir
|
||||
})
|
||||
return list
|
||||
})
|
||||
|
||||
function sortIndicator(key: ClientSortKey): string {
|
||||
if (clientSortKey.value !== key) return ''
|
||||
return clientSortDir.value === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
@@ -65,6 +105,64 @@ async function saveEncryptedForwarding(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestDisconnectAndBlock(client: AdminMqttClient) {
|
||||
clientActionMessage.value = ''
|
||||
clientActionError.value = ''
|
||||
pendingClient.value = client
|
||||
}
|
||||
|
||||
function cancelClientAction() {
|
||||
if (clientActionInProgress.value) {
|
||||
return
|
||||
}
|
||||
pendingClient.value = null
|
||||
}
|
||||
|
||||
async function confirmClientAction(payload: { reason?: string }) {
|
||||
const client = pendingClient.value
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const reason = payload.reason?.trim()
|
||||
if (!reason) {
|
||||
return
|
||||
}
|
||||
clientActionInProgress.value = true
|
||||
clientActionError.value = ''
|
||||
clientActionMessage.value = ''
|
||||
try {
|
||||
const result = await disconnectAndBlockMqttClient(client.client_id, { reason })
|
||||
const ipText = result.ip_value || client.remote_addr || '-'
|
||||
const ruleText = result.ip_rule_created ? '已新增屏蔽规则' : '屏蔽规则已存在'
|
||||
const disconnectText = result.disconnected ? '已断开连接' : '连接已不存在'
|
||||
clientActionMessage.value = `${disconnectText},IP ${ipText} ${ruleText}。`
|
||||
pendingClient.value = null
|
||||
await refreshStatus()
|
||||
} catch (err) {
|
||||
clientActionError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
clientActionInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pendingClientLabel = computed(() => {
|
||||
const client = pendingClient.value
|
||||
if (!client) {
|
||||
return ''
|
||||
}
|
||||
const ip = client.remote_addr || '-'
|
||||
return `Client ID: ${client.client_id || '-'}\n远端: ${ip}`
|
||||
})
|
||||
|
||||
const clientActionMessageText = computed(() => {
|
||||
const client = pendingClient.value
|
||||
if (!client) {
|
||||
return ''
|
||||
}
|
||||
const ip = client.remote_addr ? client.remote_addr.replace(/:\d+$/, '') : '该客户端的 IP'
|
||||
return `确定要立即断开 MQTT 客户端 “${client.client_id || '-'}” 并将 ${ip} 加入 IP 屏蔽表吗?此操作会立即生效,请输入屏蔽原因。\n\n${pendingClientLabel.value}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
refreshRuntimeSettings()
|
||||
@@ -102,6 +200,7 @@ onBeforeUnmount(() => {
|
||||
<div><span>订阅数</span><strong>{{ status.subscriptions }}</strong></div>
|
||||
<div><span>转发消息</span><strong>{{ status.messages_sent }}</strong></div>
|
||||
<div><span>数据库队列</span><strong>{{ status.db_write_queue_length }}</strong></div>
|
||||
<div><span>去重队列</span><strong>{{ status.dedup_queue_len }}</strong></div>
|
||||
<a class="status-card-link" href="/admin/discard_details"><span>丢弃消息</span><strong>{{ status.messages_dropped }}</strong></a>
|
||||
<div><span>收到包</span><strong>{{ status.packets_received }}</strong></div>
|
||||
<div><span>发送包</span><strong>{{ status.packets_sent }}</strong></div>
|
||||
@@ -158,28 +257,51 @@ onBeforeUnmount(() => {
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Username</th>
|
||||
<th>Listener</th>
|
||||
<th>Remote Addr</th>
|
||||
<th>Remote Host</th>
|
||||
<th>Remote Port</th>
|
||||
<th class="sortable" @click="toggleClientSort('client_id')">Client ID <span class="sort-indicator">{{ sortIndicator('client_id') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('username')">Username <span class="sort-indicator">{{ sortIndicator('username') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('listener')">Listener <span class="sort-indicator">{{ sortIndicator('listener') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('remote_addr')">Remote Addr <span class="sort-indicator">{{ sortIndicator('remote_addr') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('packets_in')">客户端→服务器 <span class="sort-indicator">{{ sortIndicator('packets_in') }}</span></th>
|
||||
<th class="sortable" @click="toggleClientSort('packets_out')">服务器→客户端 <span class="sort-indicator">{{ sortIndicator('packets_out') }}</span></th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="client in status?.clients || []" :key="client.client_id">
|
||||
<tr v-for="client in sortedClients" :key="client.client_id">
|
||||
<td>{{ client.client_id || '-' }}</td>
|
||||
<td>{{ client.username || '-' }}</td>
|
||||
<td>{{ client.listener || '-' }}</td>
|
||||
<td>{{ client.remote_addr || '-' }}</td>
|
||||
<td>{{ client.remote_host || '-' }}</td>
|
||||
<td>{{ client.remote_port || '-' }}</td>
|
||||
<td>{{ client.packets_in ?? 0 }}</td>
|
||||
<td>{{ client.packets_out ?? 0 }}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="client-danger-action"
|
||||
:disabled="clientActionInProgress"
|
||||
@click="requestDisconnectAndBlock(client)"
|
||||
>断开并屏蔽 IP</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!status?.clients?.length" class="empty">暂无客户端连接</div>
|
||||
<p v-if="clientActionMessage" class="success client-action-feedback">{{ clientActionMessage }}</p>
|
||||
<p v-if="clientActionError" class="error client-action-feedback">{{ clientActionError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
:open="!!pendingClient"
|
||||
title="确认断开并屏蔽 IP"
|
||||
:message="clientActionMessageText"
|
||||
confirm-text="断开并屏蔽"
|
||||
:require-reason="true"
|
||||
reason-label="屏蔽原因"
|
||||
reason-placeholder="请输入屏蔽原因(必填)"
|
||||
@cancel="cancelClientAction"
|
||||
@confirm="confirmClientAction"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -352,4 +474,47 @@ onBeforeUnmount(() => {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.node-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.node-table th.sortable:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.node-table th.sortable .sort-indicator {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.client-danger-action {
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger, #d04848) 36%, white);
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--color-danger, #d04848) 10%, white);
|
||||
color: var(--color-danger, #d04848);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.16s ease, border-color 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
|
||||
.client-danger-action:hover:not(:disabled) {
|
||||
background: var(--color-danger, #d04848);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.client-danger-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.client-action-feedback {
|
||||
margin: 0.5rem 0 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getDiscardDetails } from '../api'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { clearDiscardDetails, deleteDiscardDetailsByIDs, getDiscardDetails } from '../api'
|
||||
import type { DiscardDetails } from '../types'
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue'
|
||||
|
||||
const items = ref<DiscardDetails[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -9,9 +10,48 @@ const error = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 25
|
||||
|
||||
const selectedIds = ref<Set<number>>(new Set())
|
||||
const pendingAction = ref<'batch' | 'clear' | null>(null)
|
||||
|
||||
const canPrev = () => page.value > 1
|
||||
const canNext = () => items.value.length === pageSize
|
||||
|
||||
const allSelected = computed(
|
||||
() => items.value.length > 0 && items.value.every((item) => selectedIds.value.has(item.id)),
|
||||
)
|
||||
const someSelected = computed(() => selectedIds.value.size > 0 && !allSelected.value)
|
||||
const selectedCount = computed(() => selectedIds.value.size)
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
pendingAction.value === 'clear' ? '清空丢弃数据' : '批量删除丢弃数据',
|
||||
)
|
||||
const modalMessage = computed(() =>
|
||||
pendingAction.value === 'clear'
|
||||
? '确定要清空全部丢弃数据吗?此操作不可撤销。'
|
||||
: `确定要删除选中的 ${selectedCount.value} 条丢弃数据吗?此操作不可撤销。`,
|
||||
)
|
||||
const modalConfirmText = computed(() =>
|
||||
pendingAction.value === 'clear' ? '清空全部' : '删除',
|
||||
)
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
if (checked) {
|
||||
items.value.forEach((item) => selectedIds.value.add(item.id))
|
||||
} else {
|
||||
selectedIds.value.clear()
|
||||
}
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
}
|
||||
|
||||
function toggleRow(id: number, checked: boolean) {
|
||||
if (checked) {
|
||||
selectedIds.value.add(id)
|
||||
} else {
|
||||
selectedIds.value.delete(id)
|
||||
}
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
@@ -31,9 +71,43 @@ async function refreshItems() {
|
||||
|
||||
function changePage(nextPage: number) {
|
||||
page.value = Math.max(1, nextPage)
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
refreshItems()
|
||||
}
|
||||
|
||||
function requestBatchDelete() {
|
||||
if (selectedCount.value === 0) return
|
||||
pendingAction.value = 'batch'
|
||||
}
|
||||
|
||||
function requestClearAll() {
|
||||
pendingAction.value = 'clear'
|
||||
}
|
||||
|
||||
function cancelDeleteModal() {
|
||||
pendingAction.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteModal() {
|
||||
const action = pendingAction.value
|
||||
pendingAction.value = null
|
||||
if (!action) return
|
||||
try {
|
||||
if (action === 'clear') {
|
||||
await clearDiscardDetails()
|
||||
} else {
|
||||
await deleteDiscardDetailsByIDs([...selectedIds.value])
|
||||
}
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value = new Set(selectedIds.value)
|
||||
page.value = 1
|
||||
await refreshItems()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshItems)
|
||||
</script>
|
||||
|
||||
@@ -45,7 +119,19 @@ onMounted(refreshItems)
|
||||
<p class="eyebrow">Discard details</p>
|
||||
<h2>丢弃数据</h2>
|
||||
</div>
|
||||
<button class="admin-button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button
|
||||
class="admin-button admin-button-danger"
|
||||
:disabled="loading || selectedCount === 0"
|
||||
@click="requestBatchDelete"
|
||||
>批量删除{{ selectedCount > 0 ? `(${selectedCount})` : '' }}</button>
|
||||
<button
|
||||
class="admin-button admin-button-danger"
|
||||
:disabled="loading"
|
||||
@click="requestClearAll"
|
||||
>清空全部</button>
|
||||
<button class="admin-button" @click="refreshItems" :disabled="loading">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@@ -53,6 +139,15 @@ onMounted(refreshItems)
|
||||
<table class="node-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
:indeterminate.prop="someSelected"
|
||||
:disabled="items.length === 0"
|
||||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
<th>时间</th>
|
||||
<th>Topic</th>
|
||||
<th>Error</th>
|
||||
@@ -67,6 +162,13 @@ onMounted(refreshItems)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
@change="toggleRow(item.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatTime(item.created_at) }}</td>
|
||||
<td>{{ item.topic || '-' }}</td>
|
||||
<td>{{ item.error || '-' }}</td>
|
||||
@@ -90,5 +192,14 @@ onMounted(refreshItems)
|
||||
<button :disabled="loading || !canNext()" @click="changePage(page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
:open="pendingAction !== null"
|
||||
:title="modalTitle"
|
||||
:message="modalMessage"
|
||||
:confirm-text="modalConfirmText"
|
||||
@cancel="cancelDeleteModal"
|
||||
@confirm="confirmDeleteModal"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,823 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { deleteLLMMessage, deleteLLMMessagesByBot, getBotNodes, getLLMMessages, updateBotNode } from '../api'
|
||||
import type { BotNode, LLMMessage, ListResponse } from '../types'
|
||||
|
||||
const loading = ref(false)
|
||||
const savingBotId = ref<number | null>(null)
|
||||
const error = ref('')
|
||||
const messages = ref<LLMMessage[]>([])
|
||||
const botNodes = ref<BotNode[]>([])
|
||||
const total = ref(0)
|
||||
const limit = 50
|
||||
const offset = ref(0)
|
||||
const selectedBotId = ref<number | ''>('')
|
||||
const includeDeleted = ref(false)
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'background: linear-gradient(135deg, #fef3c7 0%, #fde68a33 100%);',
|
||||
processing: 'background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe33 100%);',
|
||||
processed: 'background: linear-gradient(135deg, #dcfce7 0%, #bbf7d033 100%);',
|
||||
error: 'background: linear-gradient(135deg, #fee2e2 0%, #fecaca33 100%);',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
processing: '处理中',
|
||||
processed: '已处理',
|
||||
error: '错误',
|
||||
}
|
||||
|
||||
function getMessageType(msg: LLMMessage): string {
|
||||
return msg.channel_id ? '频道' : '私聊'
|
||||
}
|
||||
|
||||
function getBotName(botId: number): string {
|
||||
const bot = botNodes.value.find(b => b.id === botId)
|
||||
return bot ? bot.long_name : '-'
|
||||
}
|
||||
|
||||
async function loadBotNodes() {
|
||||
try {
|
||||
const response = await getBotNodes(100, 0)
|
||||
botNodes.value = response.items
|
||||
} catch (err) {
|
||||
console.error('加载机器人列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBotLLMQueue(bot: BotNode) {
|
||||
savingBotId.value = bot.id
|
||||
try {
|
||||
await updateBotNode(bot.id, {
|
||||
long_name: bot.long_name,
|
||||
short_name: bot.short_name,
|
||||
enabled: bot.enabled,
|
||||
default_channel_id: bot.default_channel_id,
|
||||
topic_prefix: bot.topic_prefix,
|
||||
psk: bot.psk,
|
||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
||||
llm_queue_enabled: !bot.llm_queue_enabled,
|
||||
llm_include_channel_messages: bot.llm_include_channel_messages,
|
||||
})
|
||||
bot.llm_queue_enabled = !bot.llm_queue_enabled
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
savingBotId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBotIncludeChannel(bot: BotNode) {
|
||||
savingBotId.value = bot.id
|
||||
try {
|
||||
await updateBotNode(bot.id, {
|
||||
long_name: bot.long_name,
|
||||
short_name: bot.short_name,
|
||||
enabled: bot.enabled,
|
||||
default_channel_id: bot.default_channel_id,
|
||||
topic_prefix: bot.topic_prefix,
|
||||
psk: bot.psk,
|
||||
nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled,
|
||||
nodeinfo_broadcast_interval_seconds: bot.nodeinfo_broadcast_interval_seconds,
|
||||
llm_queue_enabled: bot.llm_queue_enabled,
|
||||
llm_include_channel_messages: !bot.llm_include_channel_messages,
|
||||
})
|
||||
bot.llm_include_channel_messages = !bot.llm_include_channel_messages
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
savingBotId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(resetOffset = false) {
|
||||
if (resetOffset) {
|
||||
offset.value = 0
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
|
||||
const response: ListResponse<LLMMessage> = await getLLMMessages(limit, offset.value, botId, includeDeleted.value)
|
||||
messages.value = response.items
|
||||
total.value = response.total ?? response.items.length
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteMessage(id: number) {
|
||||
if (!confirm('确定要删除这条消息吗?')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteLLMMessage(id)
|
||||
await loadMessages()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAllByBot() {
|
||||
const botId = selectedBotId.value === '' ? undefined : selectedBotId.value
|
||||
if (!botId) {
|
||||
alert('请先选择机器人')
|
||||
return
|
||||
}
|
||||
if (!confirm(`确定要删除该机器人的所有队列消息吗?`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteLLMMessagesByBot(botId)
|
||||
await loadMessages()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string | null): string {
|
||||
if (!timeStr) return '-'
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const displayFrom = computed(() => offset.value + 1)
|
||||
const displayTo = computed(() => Math.min(offset.value + limit, total.value))
|
||||
const hasMore = computed(() => offset.value + limit < total.value)
|
||||
const hasPrev = computed(() => offset.value > 0)
|
||||
|
||||
function goPrev() {
|
||||
if (hasPrev.value) {
|
||||
offset.value = Math.max(0, offset.value - limit)
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (hasMore.value) {
|
||||
offset.value += limit
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBotNodes()
|
||||
loadMessages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-llm">
|
||||
<h2>LLM 消息队列</h2>
|
||||
|
||||
<div class="admin-llm-section">
|
||||
<h3>机器人 LLM 设置</h3>
|
||||
<p class="section-desc">每个机器人可以独立启用或禁用 LLM 消息队列。启用后,该机器人收到的私聊消息将被加入队列。</p>
|
||||
|
||||
<div class="bot-settings-grid">
|
||||
<div v-for="bot in botNodes" :key="bot.id" class="bot-settings-card">
|
||||
<div class="bot-header">
|
||||
<strong>{{ bot.long_name }}</strong>
|
||||
<span class="bot-node-id">{{ bot.node_id }}</span>
|
||||
</div>
|
||||
<div class="bot-settings">
|
||||
<label class="setting-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="bot.llm_queue_enabled"
|
||||
@change="toggleBotLLMQueue(bot)"
|
||||
:disabled="savingBotId === bot.id"
|
||||
/>
|
||||
<span>启用 LLM 队列</span>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="bot.llm_include_channel_messages"
|
||||
@change="toggleBotIncludeChannel(bot)"
|
||||
:disabled="savingBotId === bot.id || !bot.llm_queue_enabled"
|
||||
/>
|
||||
<span>包含频道消息</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="savingBotId === bot.id" class="saving-indicator">保存中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-toolbar">
|
||||
<div class="admin-llm-filter">
|
||||
<label>选择机器人:</label>
|
||||
<select v-model="selectedBotId" @change="loadMessages(true)">
|
||||
<option :value="''">全部</option>
|
||||
<option v-for="bot in botNodes" :key="bot.id" :value="bot.id">
|
||||
{{ bot.long_name }} ({{ bot.node_id }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-filter">
|
||||
<label>
|
||||
<input type="checkbox" v-model="includeDeleted" @change="loadMessages(true)" />
|
||||
包含已删除
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="admin-button admin-button-danger" @click="handleDeleteAllByBot" :disabled="!selectedBotId">
|
||||
删除该机器人所有消息
|
||||
</button>
|
||||
|
||||
<button class="admin-button" @click="() => loadMessages()">刷新</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<div class="admin-llm-stats">
|
||||
共 {{ total }} 条消息,当前显示 {{ displayFrom }} - {{ displayTo }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="admin-loading">
|
||||
<div style="margin-bottom: 1rem;">🔄</div>
|
||||
加载中...
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-llm-table-wrapper">
|
||||
<table class="admin-llm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>机器人</th>
|
||||
<th>类型</th>
|
||||
<th>状态</th>
|
||||
<th>来自节点</th>
|
||||
<th>频道</th>
|
||||
<th>消息内容</th>
|
||||
<th>接收时间</th>
|
||||
<th>处理时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="msg in messages" :key="msg.id" :style="statusColors[msg.status]">
|
||||
<td>{{ msg.id }}</td>
|
||||
<td class="bot-name-cell">
|
||||
<div class="bot-info">
|
||||
<div class="bot-long-name">{{ getBotName(msg.bot_id) }}</div>
|
||||
<div class="bot-node-id" v-if="msg.bot_id !== 0">{{ msg.bot_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge" :class="getMessageType(msg) === '频道' ? 'channel' : 'direct'">
|
||||
{{ getMessageType(msg) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" :style="statusColors[msg.status]">
|
||||
{{ statusLabels[msg.status] || msg.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="node-info">
|
||||
<div class="node-name">{{ msg.long_name || msg.short_name || '-' }}</div>
|
||||
<div class="node-id">{{ msg.from_node_id }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="channel-cell">{{ msg.channel_id || '-' }}</td>
|
||||
<td class="message-text">{{ msg.text }}</td>
|
||||
<td>{{ formatTime(msg.received_at) }}</td>
|
||||
<td>{{ formatTime(msg.processed_at) }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="!msg.deleted_at"
|
||||
class="admin-button admin-button-small admin-button-danger"
|
||||
@click="handleDeleteMessage(msg.id)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<span v-else class="muted-tag">已删除</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="messages.length === 0">
|
||||
<td colspan="10" class="empty-state">暂无消息</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-llm-pagination">
|
||||
<button class="admin-button admin-button-small" @click="goPrev" :disabled="!hasPrev">上一页</button>
|
||||
<span class="pagination-info">第 {{ Math.floor(offset / limit) + 1 }} 页</span>
|
||||
<button class="admin-button admin-button-small" @click="goNext" :disabled="!hasMore">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-llm {
|
||||
padding: 1.5rem;
|
||||
max-width: 100%;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-llm h2 {
|
||||
margin: 0 0 2rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-llm-section {
|
||||
background: white;
|
||||
padding: 1.75rem;
|
||||
border-radius: 16px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 4px 6px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-section h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bot-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.bot-settings-card {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f8fafc 100%);
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bot-settings-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bot-settings-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.bot-settings-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bot-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.bot-header strong {
|
||||
font-size: 1.05rem;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bot-node-id {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.bot-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.setting-item input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #3b82f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.saving-indicator::before {
|
||||
content: '';
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #3b82f6;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-llm-toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-llm-filter label {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-llm-filter select {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
min-width: 220px;
|
||||
font-size: 0.9rem;
|
||||
color: #334155;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-llm-filter select:hover {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.admin-llm-filter select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.admin-llm-filter input[type='checkbox'] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-llm-stats {
|
||||
padding: 1rem 1.25rem;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: #1e40af;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.admin-loading {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.type-badge.channel {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
|
||||
color: #4338ca;
|
||||
border: 1px solid #a5b4fc;
|
||||
}
|
||||
|
||||
.type-badge.direct {
|
||||
background: linear-gradient(135deg, #fce7f3 0%, #fbcfe8 100%);
|
||||
color: #be185d;
|
||||
border: 1px solid #f9a8d4;
|
||||
}
|
||||
|
||||
.channel-cell {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background: #f8fafc;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.bot-name-cell {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.bot-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.bot-long-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace;
|
||||
background: #f1f5f9;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.admin-llm-table-wrapper {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.admin-llm-table th,
|
||||
.admin-llm-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.admin-llm-table th {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.admin-llm-table tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-llm-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.muted-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.status-badge[style*='#fff3cd'] {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%) !important;
|
||||
color: #92400e;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.status-badge[style*='#cfe2ff'] {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%) !important;
|
||||
color: #1e40af;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-badge[style*='#d1e7dd'] {
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%) !important;
|
||||
color: #166534;
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.status-badge[style*='#f8d7da'] {
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%) !important;
|
||||
color: #991b1b;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
max-width: 450px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
color: #334155;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem !important;
|
||||
color: #94a3b8;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state::before {
|
||||
content: '📭';
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-llm-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #991b1b;
|
||||
padding: 1rem 1.25rem;
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid #fca5a5;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.error::before {
|
||||
content: '⚠️';
|
||||
}
|
||||
|
||||
.admin-button {
|
||||
padding: 0.6rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.admin-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.admin-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.admin-button-small {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-button-danger {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.admin-button-danger:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user