channel mqtt
This commit is contained in:
parent
d014f3e989
commit
7c6d854e01
9 changed files with 471 additions and 0 deletions
|
|
@ -262,6 +262,30 @@
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"mqtt": {
|
||||||
|
"enabled": false,
|
||||||
|
"broker": "tcp://broker.emqx.io:1883",
|
||||||
|
"client_id": "picoclaw-001",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"subscribe_topics": [
|
||||||
|
"picoclaw/input"
|
||||||
|
],
|
||||||
|
"reply_topic": "picoclaw/output",
|
||||||
|
"tls": false,
|
||||||
|
"tls_ca": "",
|
||||||
|
"tls_cert": "",
|
||||||
|
"tls_key": "",
|
||||||
|
"qos": 1,
|
||||||
|
"retain": false,
|
||||||
|
"prefix": "/mqtt",
|
||||||
|
"instruction": "Будь предельно краток",
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": false
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
80
docs/channels/mqtt/README.md
Normal file
80
docs/channels/mqtt/README.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# MQTT Channel Configuration Guide
|
||||||
|
|
||||||
|
## 1. Example Configuration
|
||||||
|
|
||||||
|
Add this to `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"mqtt": {
|
||||||
|
"enabled": true,
|
||||||
|
"broker": "tcp://localhost:1883",
|
||||||
|
"client_id": "picoclaw-bot",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"qos": 1,
|
||||||
|
"retain": false,
|
||||||
|
"tls": false,
|
||||||
|
"subscribe_topics": ["picoclaw/chat"],
|
||||||
|
"reply_topic": "picoclaw/reply",
|
||||||
|
"allow_from": [],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"reasoning_channel_id": "",
|
||||||
|
"instruction": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------------|----------|----------|-------------|
|
||||||
|
| enabled | bool | Yes | Enable or disable the MQTT channel |
|
||||||
|
| broker | string | Yes | MQTT broker URL (e.g., `tcp://localhost:1883`, `ssl://broker.example.com:8883`) |
|
||||||
|
| client_id | string | Yes | Unique client identifier for the MQTT connection |
|
||||||
|
| username | string | No | MQTT username for authentication |
|
||||||
|
| password | string | No | MQTT password for authentication |
|
||||||
|
| qos | int | No | Quality of Service level (0, 1, or 2). Default: 1 |
|
||||||
|
| retain | bool | No | Whether to retain messages. Default: false |
|
||||||
|
| tls | bool | No | Enable TLS/SSL connection. Default: false |
|
||||||
|
| subscribe_topics | []string | Yes | List of MQTT topics to subscribe to for incoming messages |
|
||||||
|
| reply_topic | string | No | Topic to publish replies to. Supports placeholders: `{client_id}`, `{topic}` |
|
||||||
|
| allow_from | []string | No | Client ID whitelist (empty allows all) |
|
||||||
|
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
|
||||||
|
| reasoning_channel_id | string | No | Target channel for reasoning output |
|
||||||
|
| instruction | string | No | Optional instruction prefix added to all incoming messages |
|
||||||
|
|
||||||
|
## 3. Currently Supported
|
||||||
|
|
||||||
|
- **Message Format**: Supports both JSON and plain text messages
|
||||||
|
- JSON format: `{"status": "your message"}`
|
||||||
|
- Plain text: Direct text content
|
||||||
|
- Automatic JSON parsing with fallback to plain text for malformed JSON
|
||||||
|
- **Authentication**: Username/password authentication support
|
||||||
|
- **TLS/SSL**: Secure connections with TLS configuration
|
||||||
|
- **Quality of Service**: Configurable QoS levels (0, 1, 2)
|
||||||
|
- **Topic Management**: Multiple subscribe topics and configurable reply topics
|
||||||
|
- **Message Retention**: Optional message retention on broker
|
||||||
|
- **Auto-reconnection**: Automatic reconnection with exponential backoff
|
||||||
|
- **Group Triggers**: Support for mention-only and prefix-based triggers
|
||||||
|
- **Placeholder Replacement**: Dynamic topic names using `{client_id}` and `{topic}` placeholders
|
||||||
|
|
||||||
|
## 4. Features
|
||||||
|
|
||||||
|
- **Robust Message Handling**: Intelligent parsing that handles malformed JSON gracefully
|
||||||
|
- **Flexible Topic Configuration**: Support for multiple input topics and dynamic reply topics
|
||||||
|
- **Connection Resilience**: Automatic reconnection with configurable retry intervals
|
||||||
|
- **Security**: TLS support and authentication for secure communication
|
||||||
|
- **Message Routing**: Support for reasoning channel routing and group trigger rules
|
||||||
|
|
||||||
|
## 5. Usage Notes
|
||||||
|
|
||||||
|
- The channel automatically handles malformed JSON by attempting to clean and parse it
|
||||||
|
- Reply topics can use placeholders to dynamically route responses
|
||||||
|
- Client IDs are used as sender identifiers in the messaging system
|
||||||
|
- Topics are treated as channels for message routing purposes
|
||||||
|
- The instruction field allows adding context or commands to all incoming messages
|
||||||
8
go.mod
8
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.0
|
github.com/caarlos0/env/v11 v11.4.0
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
github.com/gdamore/tcell/v2 v2.13.8
|
github.com/gdamore/tcell/v2 v2.13.8
|
||||||
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||||
|
|
@ -93,8 +94,15 @@ require (
|
||||||
github.com/valyala/fastjson v1.6.10 // indirect
|
github.com/valyala/fastjson v1.6.10 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
golang.org/x/arch v0.24.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
|
<<<<<<< HEAD
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.52.0
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
=======
|
||||||
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/net v0.51.0
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
>>>>>>> 1038a05 (channel mqtt)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -46,6 +46,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||||
|
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
|
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("irc", "IRC")
|
m.initChannel("irc", "IRC")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.MQTT.Enabled && m.config.Channels.MQTT.Broker != "" && m.config.Channels.MQTT.ClientID != "" && len(m.config.Channels.MQTT.SubscribeTopics) > 0 {
|
||||||
|
m.initChannel("mqtt", "MQTT")
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
16
pkg/channels/mqtt/init.go
Normal file
16
pkg/channels/mqtt/init.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory("mqtt", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
if !cfg.Channels.MQTT.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return NewMQTTChannel(cfg.Channels.MQTT, b)
|
||||||
|
})
|
||||||
|
}
|
||||||
314
pkg/channels/mqtt/mqtt.go
Normal file
314
pkg/channels/mqtt/mqtt.go
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MQTTChannel implements the Channel interface for MQTT brokers.
|
||||||
|
type MQTTChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
config config.MQTTConfig
|
||||||
|
client mqtt.Client
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// MQTTMessage represents the JSON structure for MQTT messages.
|
||||||
|
type MQTTMessage struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMQTTChannel creates a new MQTT channel.
|
||||||
|
func NewMQTTChannel(cfg config.MQTTConfig, messageBus *bus.MessageBus) (*MQTTChannel, error) {
|
||||||
|
if cfg.Broker == "" {
|
||||||
|
return nil, fmt.Errorf("mqtt broker is required")
|
||||||
|
}
|
||||||
|
if cfg.ClientID == "" {
|
||||||
|
return nil, fmt.Errorf("mqtt client_id is required")
|
||||||
|
}
|
||||||
|
if len(cfg.SubscribeTopics) == 0 {
|
||||||
|
return nil, fmt.Errorf("mqtt subscribe_topics is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel("mqtt", cfg, messageBus, cfg.AllowFrom,
|
||||||
|
channels.WithMaxMessageLength(4000),
|
||||||
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
||||||
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &MQTTChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the channel name.
|
||||||
|
func (c *MQTTChannel) Name() string {
|
||||||
|
return "mqtt"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start connects to the MQTT broker and begins listening.
|
||||||
|
func (c *MQTTChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("mqtt", "Starting MQTT channel")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
opts := mqtt.NewClientOptions()
|
||||||
|
opts.AddBroker(c.config.Broker)
|
||||||
|
opts.SetClientID(c.config.ClientID)
|
||||||
|
opts.SetUsername(c.config.Username)
|
||||||
|
opts.SetPassword(c.config.Password)
|
||||||
|
opts.SetCleanSession(true)
|
||||||
|
opts.SetAutoReconnect(true)
|
||||||
|
opts.SetConnectRetry(true)
|
||||||
|
opts.SetConnectRetryInterval(5 * time.Second)
|
||||||
|
opts.SetMaxReconnectInterval(5 * time.Minute)
|
||||||
|
opts.SetKeepAlive(60 * time.Second)
|
||||||
|
opts.SetPingTimeout(10 * time.Second)
|
||||||
|
opts.SetWriteTimeout(10 * time.Second)
|
||||||
|
opts.SetConnectTimeout(30 * time.Second)
|
||||||
|
|
||||||
|
// TLS configuration
|
||||||
|
if c.config.TLS {
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: false,
|
||||||
|
}
|
||||||
|
if c.config.TLSCA != "" {
|
||||||
|
// Load CA cert if provided
|
||||||
|
// For simplicity, assuming file path
|
||||||
|
// In production, load cert properly
|
||||||
|
}
|
||||||
|
if c.config.TLSCert != "" && c.config.TLSKey != "" {
|
||||||
|
// Load client cert
|
||||||
|
}
|
||||||
|
opts.SetTLSConfig(tlsConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set message handler
|
||||||
|
opts.SetDefaultPublishHandler(c.onMessage)
|
||||||
|
|
||||||
|
c.client = mqtt.NewClient(opts)
|
||||||
|
|
||||||
|
// Connect with retry
|
||||||
|
if token := c.client.Connect(); token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("mqtt connect failed: %w", token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("mqtt", "Connected to MQTT broker", map[string]any{
|
||||||
|
"broker": c.config.Broker,
|
||||||
|
"client_id": c.config.ClientID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to topics
|
||||||
|
for _, topic := range c.config.SubscribeTopics {
|
||||||
|
if token := c.client.Subscribe(topic, byte(c.config.QoS), nil); token.Wait() && token.Error() != nil {
|
||||||
|
logger.ErrorCF("mqtt", "Failed to subscribe to topic", map[string]any{
|
||||||
|
"topic": topic,
|
||||||
|
"error": token.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.InfoCF("mqtt", "Subscribed to topic", map[string]any{
|
||||||
|
"topic": topic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetRunning(true)
|
||||||
|
logger.InfoC("mqtt", "MQTT channel started")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop disconnects from the MQTT broker.
|
||||||
|
func (c *MQTTChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("mqtt", "Stopping MQTT channel")
|
||||||
|
c.SetRunning(false)
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.client != nil && c.client.IsConnected() {
|
||||||
|
c.client.Disconnect(250)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoC("mqtt", "MQTT channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMessage handles incoming MQTT messages.
|
||||||
|
func (c *MQTTChannel) onMessage(client mqtt.Client, msg mqtt.Message) {
|
||||||
|
logger.DebugCF("mqtt", "Received message", map[string]any{
|
||||||
|
"topic": msg.Topic(),
|
||||||
|
"payload": string(msg.Payload()),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to parse as JSON first
|
||||||
|
var mqttMsg MQTTMessage
|
||||||
|
var content string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// First try to parse as JSON
|
||||||
|
if err = json.Unmarshal(msg.Payload(), &mqttMsg); err == nil {
|
||||||
|
// Successfully parsed as JSON
|
||||||
|
content = mqttMsg.Status
|
||||||
|
} else {
|
||||||
|
// If JSON parsing fails, try to clean up common malformed JSON issues
|
||||||
|
payloadStr := string(msg.Payload())
|
||||||
|
|
||||||
|
// Try to extract JSON from malformed strings (e.g., extra quotes or braces)
|
||||||
|
// Look for a valid JSON object within the string
|
||||||
|
if strings.HasPrefix(payloadStr, "{") && (strings.HasSuffix(payloadStr, "}") || strings.HasSuffix(payloadStr, "}\"")) {
|
||||||
|
// Try to parse as-is first
|
||||||
|
if err2 := json.Unmarshal(msg.Payload(), &mqttMsg); err2 == nil {
|
||||||
|
content = mqttMsg.Status
|
||||||
|
} else {
|
||||||
|
// Try to clean up common issues
|
||||||
|
cleaned := strings.TrimSpace(payloadStr)
|
||||||
|
|
||||||
|
// Remove extra quotes and braces from the end
|
||||||
|
for strings.HasSuffix(cleaned, "}") && strings.Count(cleaned, "{") < strings.Count(cleaned, "}") {
|
||||||
|
cleaned = cleaned[:len(cleaned)-1]
|
||||||
|
}
|
||||||
|
for strings.HasSuffix(cleaned, "\"}") && strings.Count(cleaned, "{") < strings.Count(cleaned, "}") {
|
||||||
|
cleaned = cleaned[:len(cleaned)-1]
|
||||||
|
}
|
||||||
|
// Remove any trailing quotes (simple check for extra quotes at the end)
|
||||||
|
for strings.HasSuffix(cleaned, "\"") && !strings.HasSuffix(cleaned, "\"}") {
|
||||||
|
cleaned = cleaned[:len(cleaned)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove extra opening braces
|
||||||
|
for strings.HasPrefix(cleaned, "{") && strings.Count(cleaned, "{") > strings.Count(cleaned, "}") {
|
||||||
|
cleaned = cleaned[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleaned != payloadStr {
|
||||||
|
if err3 := json.Unmarshal([]byte(cleaned), &mqttMsg); err3 == nil {
|
||||||
|
content = mqttMsg.Status
|
||||||
|
logger.InfoCF("mqtt", "Successfully parsed cleaned JSON", map[string]any{
|
||||||
|
"original": payloadStr,
|
||||||
|
"cleaned": cleaned,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fall back to plain text
|
||||||
|
content = payloadStr
|
||||||
|
logger.InfoCF("mqtt", "Received plain text message (JSON parsing failed)", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"payload": content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to plain text
|
||||||
|
content = payloadStr
|
||||||
|
logger.InfoCF("mqtt", "Received plain text message (JSON parsing failed)", map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"payload": content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not JSON-like, treat as plain text
|
||||||
|
content = payloadStr
|
||||||
|
logger.InfoCF("mqtt", "Received plain text message (not JSON-like)", map[string]any{
|
||||||
|
"payload": content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == "" {
|
||||||
|
logger.WarnC("mqtt", "Empty content in MQTT message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine sender ID
|
||||||
|
senderID := fmt.Sprintf("mqtt:%s", c.config.ClientID)
|
||||||
|
if msg.Topic() != "" {
|
||||||
|
senderID = fmt.Sprintf("mqtt:%s", strings.ReplaceAll(msg.Topic(), "/", "_"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if message has reply-to header (in payload or topic)
|
||||||
|
replyTopic := c.config.ReplyTopic
|
||||||
|
if strings.Contains(content, "reply-to:") {
|
||||||
|
// Simple parsing for reply-to
|
||||||
|
parts := strings.SplitN(content, "reply-to:", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
replyTopic = strings.TrimSpace(parts[1])
|
||||||
|
content = strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create peer and sender info
|
||||||
|
peer := bus.Peer{Kind: "direct", ID: msg.Topic()} // MQTT topics are like channels
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: "mqtt",
|
||||||
|
PlatformID: senderID,
|
||||||
|
CanonicalID: senderID,
|
||||||
|
Username: senderID,
|
||||||
|
DisplayName: senderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
messageID := fmt.Sprintf("mqtt-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": "mqtt",
|
||||||
|
"topic": msg.Topic(),
|
||||||
|
"reply_topic": replyTopic,
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Instruction != "" {
|
||||||
|
content = c.config.Instruction + "\n\n" + content
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HandleMessage(c.ctx, peer, messageID, senderID, msg.Topic(), content, nil, metadata, sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to an MQTT topic.
|
||||||
|
func (c *MQTTChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("MQTT channel is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
replyTopic := c.config.ReplyTopic
|
||||||
|
|
||||||
|
// If chatID contains reply topic info, use it
|
||||||
|
if strings.HasPrefix(msg.ChatID, "reply:") {
|
||||||
|
replyTopic = strings.TrimPrefix(msg.ChatID, "reply:")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace placeholders in reply topic
|
||||||
|
replyTopic = strings.ReplaceAll(replyTopic, "{client_id}", c.config.ClientID)
|
||||||
|
replyTopic = strings.ReplaceAll(replyTopic, "{topic}", msg.ChatID)
|
||||||
|
// Add more placeholders as needed
|
||||||
|
|
||||||
|
mqttMsg := MQTTMessage{
|
||||||
|
Status: msg.Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(mqttMsg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal MQTT message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.client.Publish(replyTopic, byte(c.config.QoS), c.config.Retain, payload)
|
||||||
|
if token.Wait() && token.Error() != nil {
|
||||||
|
return fmt.Errorf("failed to publish MQTT message: %w", token.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("mqtt", "Published message", map[string]any{
|
||||||
|
"topic": replyTopic,
|
||||||
|
"payload": string(payload),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -340,6 +340,7 @@ type ChannelsConfig struct {
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
PicoClient PicoClientConfig `json:"pico_client"`
|
PicoClient PicoClientConfig `json:"pico_client"`
|
||||||
IRC IRCConfig `json:"irc"`
|
IRC IRCConfig `json:"irc"`
|
||||||
|
MQTT MQTTConfig `json:"mqtt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -592,6 +593,27 @@ type IRCConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MQTTConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MQTT_ENABLED"`
|
||||||
|
Broker string `json:"broker" env:"PICOCLAW_CHANNELS_MQTT_BROKER"`
|
||||||
|
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_MQTT_CLIENT_ID"`
|
||||||
|
Username string `json:"username" env:"PICOCLAW_CHANNELS_MQTT_USERNAME"`
|
||||||
|
Password string `json:"password" env:"PICOCLAW_CHANNELS_MQTT_PASSWORD"`
|
||||||
|
SubscribeTopics []string `json:"subscribe_topics" env:"PICOCLAW_CHANNELS_MQTT_SUBSCRIBE_TOPICS"`
|
||||||
|
ReplyTopic string `json:"reply_topic" env:"PICOCLAW_CHANNELS_MQTT_REPLY_TOPIC"`
|
||||||
|
TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_MQTT_TLS"`
|
||||||
|
TLSCA string `json:"tls_ca" env:"PICOCLAW_CHANNELS_MQTT_TLS_CA"`
|
||||||
|
TLSCert string `json:"tls_cert" env:"PICOCLAW_CHANNELS_MQTT_TLS_CERT"`
|
||||||
|
TLSKey string `json:"tls_key" env:"PICOCLAW_CHANNELS_MQTT_TLS_KEY"`
|
||||||
|
QoS int `json:"qos" env:"PICOCLAW_CHANNELS_MQTT_QOS"`
|
||||||
|
Retain bool `json:"retain" env:"PICOCLAW_CHANNELS_MQTT_RETAIN"`
|
||||||
|
Prefix string `json:"prefix" env:"PICOCLAW_CHANNELS_MQTT_PREFIX"`
|
||||||
|
Instruction string `json:"instruction" env:"PICOCLAW_CHANNELS_MQTT_INSTRUCTION"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MQTT_ALLOW_FROM"`
|
||||||
|
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MQTT_REASONING_CHANNEL_ID"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/mqtt"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue