picoclaw/xmpp-channel.patch
2026-02-18 11:26:39 +08:00

973 lines
56 KiB
Diff

diff --git a/go.mod b/go.mod
index 42f7bd2..21deadb 100644
--- a/go.mod
+++ b/go.mod
@@ -18,8 +18,9 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
- mellium.im/xmlstream v0.15.4-0.20211023152852-0ca80a938137
- mellium.im/xmpp v0.21.3
+ mellium.im/sasl v0.3.2
+ mellium.im/xmlstream v0.15.4
+ mellium.im/xmpp v0.22.0
)
require (
@@ -30,7 +31,6 @@ require (
golang.org/x/tools v0.41.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/reader v0.1.0 // indirect
- mellium.im/sasl v0.3.0 // indirect
)
require (
diff --git a/go.sum b/go.sum
index 6d048ee..b90097a 100644
--- a/go.sum
+++ b/go.sum
@@ -267,9 +267,9 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww=
mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI=
-mellium.im/sasl v0.3.0 h1:0qoaTCTo5Py7u/g0cBIQZcMOgG/5LM71nshbXwznBh8=
-mellium.im/sasl v0.3.0/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
-mellium.im/xmlstream v0.15.4-0.20211023152852-0ca80a938137 h1:ALOIzIw0sJLO0Agfwftq7yS7Rk8pWBEdrc3/5lSprzE=
-mellium.im/xmlstream v0.15.4-0.20211023152852-0ca80a938137/go.mod h1:7SUlP7f2qnMczK+Cu/OFgqaIhldMolVjo8np7xG41D0=
-mellium.im/xmpp v0.21.3 h1:yQQPBdOYnd15987UaWL/0eIM2fdaWn9FUpqZHfZTFQI=
-mellium.im/xmpp v0.21.3/go.mod h1:NoTQy2Ry+c5WdkRUK2npXC1SzWTZ8zXIt79wMH0Du7Y=
+mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
+mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
+mellium.im/xmlstream v0.15.4 h1:gLKxcWl4rLMUpKgtzrTBvr4OexPeO/edYus+uK3F6ZI=
+mellium.im/xmlstream v0.15.4/go.mod h1:yXaCW2++fmVO4L9piKVkyLDqnCmictVYF7FDQW8prb4=
+mellium.im/xmpp v0.22.0 h1:UthQVSwEAr7SNrmyc90c2ykGpVHxjn/3yw8Ey4+Im8s=
+mellium.im/xmpp v0.22.0/go.mod h1:WSjq12nhREFD88Vy/0WD6Q8inE8t6a8w7QjzwivWitw=
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 3a1daa6..b47e957 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -176,7 +176,7 @@ func (m *Manager) initChannels() error {
}
}
- if m.config.Channels.XMPP.Enabled && m.config.Channels.XMPP.Server != "" {
+ if m.config.Channels.XMPP.Enabled && m.config.Channels.XMPP.JID != "" && m.config.Channels.XMPP.Password != "" {
logger.DebugC("channels", "Attempting to initialize XMPP channel")
xmppCh, err := NewXMPPChannel(m.config.Channels.XMPP, m.bus)
if err != nil {
diff --git a/pkg/channels/xmpp.go b/pkg/channels/xmpp.go
index 590a5f5..da742ea 100644
--- a/pkg/channels/xmpp.go
+++ b/pkg/channels/xmpp.go
@@ -5,337 +5,562 @@ import (
"crypto/tls"
"encoding/xml"
"fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
"strings"
"sync"
+ "time"
"mellium.im/sasl"
"mellium.im/xmlstream"
"mellium.im/xmpp"
+ "mellium.im/xmpp/disco"
+ "mellium.im/xmpp/disco/items"
"mellium.im/xmpp/jid"
+ "mellium.im/xmpp/mux"
"mellium.im/xmpp/stanza"
+ "mellium.im/xmpp/upload"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type XMPPChannel struct {
*BaseChannel
- config config.XMPPConfig
- session *xmpp.Session
- ctx context.Context
- cancel context.CancelFunc
- mu sync.Mutex
- joinedRooms map[string]bool
+ config config.XMPPConfig
+ session *xmpp.Session
+ ctx context.Context
+ cancel context.CancelFunc
+ httpClient *http.Client
+
+ uploadMu sync.Mutex
+ uploadJID jid.JID
+
+ lastMsgMu sync.Mutex
+ lastFromBare string
+ lastContent string
+ lastTime time.Time
}
-func NewXMPPChannel(cfg config.XMPPConfig, bus *bus.MessageBus) (*XMPPChannel, error) {
- base := NewBaseChannel("xmpp", cfg, bus, cfg.AllowFrom)
+func NewXMPPChannel(cfg config.XMPPConfig, messageBus *bus.MessageBus) (*XMPPChannel, error) {
+ if cfg.JID == "" {
+ return nil, fmt.Errorf("xmpp jid is required")
+ }
+ if cfg.Password == "" {
+ return nil, fmt.Errorf("xmpp password is required")
+ }
+
+ base := NewBaseChannel("xmpp", cfg, messageBus, cfg.AllowFrom)
+
return &XMPPChannel{
BaseChannel: base,
config: cfg,
- joinedRooms: make(map[string]bool),
+ httpClient: newHTTPClient(),
}, nil
}
func (c *XMPPChannel) Start(ctx context.Context) error {
- if !c.config.Enabled {
- return fmt.Errorf("xmpp channel disabled")
- }
- if c.config.Server == "" || c.config.Domain == "" || c.config.Username == "" {
- return fmt.Errorf("xmpp server, domain and username must be configured")
+ if c.IsRunning() {
+ return nil
}
- j, err := jid.Parse(fmt.Sprintf("%s@%s", c.config.Username, c.config.Domain))
+ j, err := jid.Parse(c.config.JID)
+ if err != nil {
+ return fmt.Errorf("invalid xmpp jid: %w", err)
+ }
+
+ logger.InfoCF("xmpp", "Connecting XMPP client", map[string]interface{}{
+ "jid": j.String(),
+ })
+
+ session, err := xmpp.DialClientSession(
+ ctx,
+ j,
+ xmpp.StartTLS(&tls.Config{
+ ServerName: j.Domain().String(),
+ MinVersion: tls.VersionTLS12,
+ }),
+ xmpp.SASL("", c.config.Password, sasl.ScramSha256Plus, sasl.ScramSha1Plus, sasl.ScramSha256, sasl.ScramSha1, sasl.Plain),
+ xmpp.BindResource(),
+ )
if err != nil {
- return fmt.Errorf("invalid jid: %w", err)
+ return fmt.Errorf("failed to establish XMPP session: %w", err)
}
- dialCtx, cancel := context.WithCancel(ctx)
- c.ctx = dialCtx
- c.cancel = cancel
+ handler := mux.MessageHandlerFunc(func(msg stanza.Message, t xmlstream.TokenReadEncoder) error {
+ return c.handleIncomingMessage(msg, t)
+ })
- features := []xmpp.StreamFeature{}
- if c.config.UseTLS {
- features = append(features, xmpp.StartTLS(&tls.Config{
- InsecureSkipVerify: c.config.InsecureSkipVerify,
- ServerName: c.config.Domain,
- }))
- }
- if c.config.Password != "" {
- features = append(features, xmpp.SASL("", c.config.Password, sasl.Plain))
- }
- features = append(features, xmpp.BindResource())
+ m := mux.New(
+ stanza.NSClient,
+ mux.Message(stanza.ChatMessage, xml.Name{}, handler),
+ mux.Message(stanza.MessageType(""), xml.Name{}, handler),
+ )
- session, err := xmpp.DialClientSession(dialCtx, j, features...)
- if err != nil {
- cancel()
- return fmt.Errorf("failed to dial xmpp: %w", err)
- }
-
- c.mu.Lock()
c.session = session
- c.mu.Unlock()
+ c.ctx, c.cancel = context.WithCancel(context.Background())
- for _, room := range c.config.Rooms {
- if room == "" {
- continue
- }
- go c.joinRoom(room)
+ if err := c.sendInitialPresence(); err != nil {
+ logger.WarnCF("xmpp", "Failed to send initial presence", map[string]interface{}{
+ "error": err.Error(),
+ })
}
c.setRunning(true)
- go c.readLoop()
+
+ go func() {
+ err := session.Serve(m)
+ if err != nil {
+ logger.ErrorCF("xmpp", "XMPP session ended with error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ logger.InfoC("xmpp", "XMPP session ended")
+ }
+ c.setRunning(false)
+ }()
return nil
}
func (c *XMPPChannel) Stop(ctx context.Context) error {
- c.setRunning(false)
- c.mu.Lock()
+ if !c.IsRunning() {
+ return nil
+ }
+
if c.cancel != nil {
c.cancel()
}
if c.session != nil {
- _ = c.session.Close()
+ if err := c.session.Close(); err != nil {
+ logger.WarnCF("xmpp", "Error closing XMPP session", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
}
- c.mu.Unlock()
+
+ c.setRunning(false)
+ logger.InfoC("xmpp", "XMPP channel stopped")
return nil
}
func (c *XMPPChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
- if !c.IsRunning() {
+ if !c.IsRunning() || c.session == nil {
return fmt.Errorf("xmpp channel not running")
}
- c.mu.Lock()
- session := c.session
- c.mu.Unlock()
- if session == nil {
- return fmt.Errorf("xmpp session not ready")
- }
-
- toJID, err := jid.Parse(msg.ChatID)
+ to, err := jid.Parse(msg.ChatID)
if err != nil {
- return fmt.Errorf("invalid chat id: %w", err)
+ return fmt.Errorf("invalid xmpp chat id: %w", err)
}
- msgType := stanza.ChatMessage
- if c.isRoomJID(toJID) {
- msgType = stanza.GroupChatMessage
+ content := strings.TrimSpace(msg.Content)
+
+ var mediaLinks []string
+ var inlineParts []string
+ if len(msg.Media) > 0 {
+ uploadJID, discoverErr := c.discoverUploadService(ctx)
+ if discoverErr != nil {
+ logger.ErrorCF("xmpp", "Failed to discover XEP-0363 upload service", map[string]interface{}{
+ "error": discoverErr.Error(),
+ })
+ }
+
+ for _, path := range msg.Media {
+ if discoverErr == nil {
+ link, err := c.uploadFile(ctx, uploadJID, path)
+ if err == nil && link != "" {
+ mediaLinks = append(mediaLinks, link)
+ continue
+ }
+ if err != nil {
+ logger.ErrorCF("xmpp", "Failed to upload media file", map[string]interface{}{
+ "path": path,
+ "error": err.Error(),
+ })
+ }
+ }
+
+ inlineContent, err := c.readSmallFile(path, 32*1024)
+ if err != nil {
+ logger.ErrorCF("xmpp", "Failed to inline media file", map[string]interface{}{
+ "path": path,
+ "error": err.Error(),
+ })
+ continue
+ }
+
+ inlineParts = append(inlineParts, fmt.Sprintf("鏂囦欢 %s:\n%s", filepath.Base(path), inlineContent))
+ }
}
- message := stanza.Message{
- To: toJID,
- Type: msgType,
+ if len(inlineParts) > 0 {
+ inlineText := strings.Join(inlineParts, "\n\n")
+ if content == "" {
+ content = inlineText
+ } else {
+ content = content + "\n\n" + inlineText
+ }
}
- type outgoing struct {
- stanza.Message
- Body string `xml:"body"`
- Request *struct {
- XMLName xml.Name `xml:"urn:xmpp:receipts request"`
- } `xml:"request,omitempty"`
+ if len(mediaLinks) > 0 {
+ body := mediaLinks[0]
+ desc := content
+ return c.sendChatMessage(to, body, desc, mediaLinks)
}
- o := outgoing{
- Message: message,
- Body: msg.Content,
+ if content == "" {
+ return nil
}
- if c.config.EnableReceipts {
- o.Request = &struct {
- XMLName xml.Name `xml:"urn:xmpp:receipts request"`
- }{
- XMLName: xml.Name{Space: "urn:xmpp:receipts", Local: "request"},
+
+ return c.sendChatMessage(to, content, "", nil)
+}
+
+func (c *XMPPChannel) sendInitialPresence() error {
+ return c.sendStanza(stanza.Presence{}.Wrap(nil))
+}
+
+func (c *XMPPChannel) sendChatMessage(to jid.JID, body string, desc string, mediaLinks []string) error {
+ bodyElem := xmlstream.Wrap(
+ xmlstream.Token(xml.CharData([]byte(body))),
+ xml.StartElement{Name: xml.Name{Local: "body"}},
+ )
+
+ children := []xml.TokenReader{bodyElem}
+
+ for _, link := range mediaLinks {
+ urlElem := xmlstream.Wrap(
+ xmlstream.Token(xml.CharData([]byte(link))),
+ xml.StartElement{Name: xml.Name{Local: "url"}},
+ )
+
+ var inner xml.TokenReader = urlElem
+
+ if desc != "" {
+ descElem := xmlstream.Wrap(
+ xmlstream.Token(xml.CharData([]byte(desc))),
+ xml.StartElement{Name: xml.Name{Local: "desc"}},
+ )
+ inner = xmlstream.MultiReader(urlElem, descElem)
}
+
+ xElem := xmlstream.Wrap(
+ inner,
+ xml.StartElement{
+ Name: xml.Name{Local: "x"},
+ Attr: []xml.Attr{
+ {Name: xml.Name{Local: "xmlns"}, Value: "jabber:x:oob"},
+ },
+ },
+ )
+
+ children = append(children, xElem)
+ }
+
+ var payload xml.TokenReader
+ if len(children) == 1 {
+ payload = children[0]
+ } else {
+ payload = xmlstream.MultiReader(children...)
}
- if err := session.Encode(ctx, o); err != nil {
- return fmt.Errorf("failed to send xmpp message: %w", err)
+ st := stanza.Message{
+ Type: stanza.ChatMessage,
+ To: to,
+ }
+
+ return c.sendStanza(st.Wrap(payload))
+}
+
+func (c *XMPPChannel) sendStanza(r xml.TokenReader) error {
+ if c.session == nil {
+ return fmt.Errorf("xmpp session not initialized")
+ }
+
+ w := c.session.TokenWriter()
+ defer w.Close()
+
+ if _, err := xmlstream.Copy(w, r); err != nil {
+ return err
+ }
+
+ type flusher interface {
+ Flush() error
+ }
+
+ if f, ok := w.(flusher); ok {
+ return f.Flush()
}
return nil
}
-func (c *XMPPChannel) readLoop() {
- c.mu.Lock()
- session := c.session
- c.mu.Unlock()
- if session == nil {
- return
+func (c *XMPPChannel) handleIncomingMessage(msg stanza.Message, t xmlstream.TokenReadEncoder) error {
+ var payload struct {
+ Body string `xml:"body"`
}
- type inbound struct {
- stanza.Message
- Body string `xml:"body"`
- Request *struct {
- XMLName xml.Name `xml:"urn:xmpp:receipts request"`
- } `xml:"request"`
+ d := xml.NewTokenDecoder(t)
+ if err := d.Decode(&payload); err != nil {
+ return err
}
- err := session.Serve(xmpp.HandlerFunc(func(t xmlstream.TokenReadEncoder, start *xml.StartElement) error {
- if start.Name.Local != "message" {
- return nil
- }
+ content := strings.TrimSpace(payload.Body)
+ if content == "" {
+ return nil
+ }
- var msg inbound
- dec := xml.NewTokenDecoder(t)
- if err := dec.DecodeElement(&msg, start); err != nil {
- logger.ErrorCF("xmpp", "decode message error", map[string]interface{}{
- "error": err.Error(),
- })
- return nil
- }
+ fromBare := msg.From.Bare().String()
+ chatID := msg.From.String()
- if msg.Body == "" {
- return nil
+ c.lastMsgMu.Lock()
+ if fromBare == c.lastFromBare && content == c.lastContent && time.Since(c.lastTime) < 2*time.Second {
+ c.lastMsgMu.Unlock()
+ return nil
+ }
+ c.lastFromBare = fromBare
+ c.lastContent = content
+ c.lastTime = time.Now()
+ c.lastMsgMu.Unlock()
+
+ logger.DebugCF("xmpp", "Received message", map[string]interface{}{
+ "from": chatID,
+ "from_bare": fromBare,
+ "preview": utils.Truncate(content, 80),
+ })
+
+ c.HandleMessage(fromBare, chatID, content, nil, map[string]string{
+ "stanza_type": "message",
+ "from_full": chatID,
+ })
+
+ return nil
+}
+
+func (c *XMPPChannel) discoverUploadService(ctx context.Context) (jid.JID, error) {
+ c.uploadMu.Lock()
+ defer c.uploadMu.Unlock()
+
+ if !c.uploadJID.Equal(jid.JID{}) {
+ return c.uploadJID, nil
+ }
+
+ if c.config.UploadDomain != "" {
+ j, err := jid.Parse(c.config.UploadDomain)
+ if err == nil {
+ c.uploadJID = j
+ return c.uploadJID, nil
}
+ }
- senderID := c.buildSenderID(msg.From)
- if !c.IsAllowed(senderID) {
- return nil
+ if c.session == nil {
+ return jid.JID{}, fmt.Errorf("xmpp session not initialized")
+ }
+
+ userJID, err := jid.Parse(c.config.JID)
+ if err != nil {
+ return jid.JID{}, fmt.Errorf("invalid xmpp jid in config: %w", err)
+ }
+ domain := userJID.Domain()
+ info, err := disco.GetInfo(ctx, "", domain, c.session)
+ if err == nil {
+ for _, f := range info.Features {
+ if f.Var == upload.NS {
+ c.uploadJID = domain
+ logger.InfoCF("xmpp", "Discovered HTTP upload support on server domain", map[string]interface{}{
+ "jid": domain.String(),
+ })
+ return c.uploadJID, nil
+ }
}
+ }
- chatID := c.buildChatID(msg.Message)
- if chatID == "" {
+ var found jid.JID
+ err = disco.WalkItem(ctx, items.Item{JID: domain}, c.session, func(level int, item items.Item, walkErr error) error {
+ if walkErr != nil {
return nil
}
- metadata := map[string]string{}
- if msg.ID != "" {
- metadata["xmpp_id"] = msg.ID
- }
- if msg.Type == stanza.GroupChatMessage {
- metadata["xmpp_type"] = "groupchat"
- } else {
- metadata["xmpp_type"] = "chat"
+ info, err := disco.GetInfo(ctx, "", item.JID, c.session)
+ if err != nil {
+ return nil
}
- if msg.Request != nil && msg.ID != "" && c.config.EnableReceipts {
- go c.sendReceipt(msg.Message)
+ for _, f := range info.Features {
+ if f.Var == upload.NS {
+ found = item.JID
+ return fmt.Errorf("found")
+ }
}
-
- c.HandleMessage(senderID, chatID, msg.Body, nil, metadata)
return nil
- }))
+ })
+ if err != nil && err.Error() != "found" {
+ return jid.JID{}, fmt.Errorf("service discovery failed: %w", err)
+ }
- if err != nil && c.ctx.Err() == nil {
- logger.ErrorCF("xmpp", "session serve error", map[string]interface{}{
- "error": err.Error(),
- })
+ if found.Equal(jid.JID{}) {
+ return jid.JID{}, fmt.Errorf("no XEP-0363 upload service found via disco")
}
+
+ c.uploadJID = found
+ logger.InfoCF("xmpp", "Discovered HTTP upload service via disco", map[string]interface{}{
+ "jid": found.String(),
+ })
+ return c.uploadJID, nil
}
-func (c *XMPPChannel) sendReceipt(m stanza.Message) {
- c.mu.Lock()
- session := c.session
- c.mu.Unlock()
- if session == nil {
- return
+func (c *XMPPChannel) uploadFile(ctx context.Context, uploadJID jid.JID, path string) (string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", fmt.Errorf("open file: %w", err)
}
+ defer f.Close()
- type receipt struct {
- stanza.Message
- Received struct {
- XMLName xml.Name `xml:"urn:xmpp:receipts received"`
- ID string `xml:"id,attr"`
- } `xml:"received"`
+ info, err := f.Stat()
+ if err != nil {
+ return "", fmt.Errorf("stat file: %w", err)
}
- r := receipt{
- Message: stanza.Message{
- To: m.From,
- Type: m.Type,
- },
+ size := info.Size()
+ if size <= 0 {
+ return "", fmt.Errorf("file is empty")
}
- r.Received.ID = m.ID
- _ = session.Encode(context.Background(), r)
-}
-
-func (c *XMPPChannel) buildSenderID(j jid.JID) string {
- bare := j.Bare().String()
- resource := j.Resourcepart()
- if resource == "" {
- return bare
+ buffer := make([]byte, 512)
+ n, _ := f.Read(buffer)
+ if _, err := f.Seek(0, 0); err != nil {
+ return "", fmt.Errorf("seek file: %w", err)
}
- return bare + "|" + resource
-}
-func (c *XMPPChannel) buildChatID(m stanza.Message) string {
- if m.Type == stanza.GroupChatMessage {
- return m.From.Bare().String()
+ contentType := http.DetectContentType(buffer[:n])
+ if contentType == "" {
+ contentType = "application/octet-stream"
}
- return m.From.Bare().String()
-}
-func (c *XMPPChannel) isRoomJID(j jid.JID) bool {
- for _, room := range c.config.Rooms {
- if room == "" {
- continue
- }
- if strings.EqualFold(j.Bare().String(), room) {
- return true
- }
+ file := upload.File{
+ Name: filepath.Base(path),
+ Size: int(size),
+ Type: contentType,
}
- return false
-}
-func (c *XMPPChannel) effectiveResource() string {
- if strings.TrimSpace(c.config.Resource) != "" {
- return c.config.Resource
+ slot, err := upload.GetSlot(ctx, file, uploadJID, c.session)
+ if err != nil {
+ return "", fmt.Errorf("get upload slot: %w", err)
+ }
+
+ logger.InfoCF("xmpp", "XEP-0363 upload slot acquired", map[string]interface{}{
+ "put_url": func() string {
+ if slot.PutURL != nil {
+ return slot.PutURL.String()
+ }
+ return ""
+ }(),
+ "get_url": func() string {
+ if slot.GetURL != nil {
+ return slot.GetURL.String()
+ }
+ return ""
+ }(),
+ "headers": slot.Header,
+ "mime": contentType,
+ "size": size,
+ "filename": filepath.Base(path),
+ })
+
+ req, err := slot.Put(ctx, f)
+ if err != nil {
+ return "", fmt.Errorf("build put request: %w", err)
}
- return "picoclaw"
-}
-func (c *XMPPChannel) joinRoom(room string) {
- c.mu.Lock()
- session := c.session
- ctx := c.ctx
- c.mu.Unlock()
- if session == nil || ctx == nil {
- return
+ req.ContentLength = size
+ if req.Header == nil {
+ req.Header = make(http.Header)
+ }
+ if req.Header.Get("Content-Type") == "" {
+ req.Header.Set("Content-Type", contentType)
}
- roomJID, err := jid.Parse(room + "/" + c.roomNickname())
+ resp, err := c.httpClient.Do(req)
if err != nil {
- logger.ErrorCF("xmpp", "invalid room jid", map[string]interface{}{
- "room": room,
- "error": err.Error(),
- })
- return
+ return "", fmt.Errorf("http put: %w", err)
}
+ defer resp.Body.Close()
- type mucPresence struct {
- stanza.Presence
- X struct {
- XMLName xml.Name `xml:"http://jabber.org/protocol/muc x"`
- } `xml:"x"`
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
+ bodyText := strings.TrimSpace(string(snippet))
+ if bodyText != "" {
+ return "", fmt.Errorf("upload failed with status %s: %s", resp.Status, bodyText)
+ }
+ return "", fmt.Errorf("upload failed with status %s", resp.Status)
}
- p := mucPresence{
- Presence: stanza.Presence{
- To: roomJID,
- },
+ if slot.GetURL == nil {
+ return "", fmt.Errorf("no download URL returned for upload slot")
}
- if err := session.Encode(ctx, p); err != nil {
- logger.ErrorCF("xmpp", "join room failed", map[string]interface{}{
- "room": room,
- "error": err.Error(),
- })
- return
+ return slot.GetURL.String(), nil
+}
+
+func (c *XMPPChannel) readSmallFile(path string, maxSize int64) (string, error) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return "", err
+ }
+ if !info.Mode().IsRegular() {
+ return "", fmt.Errorf("not a regular file")
+ }
+ if info.Size() <= 0 {
+ return "", fmt.Errorf("file is empty")
+ }
+ if info.Size() > maxSize {
+ return "", fmt.Errorf("file too large to inline")
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
}
- c.mu.Lock()
- c.joinedRooms[roomJID.Bare().String()] = true
- c.mu.Unlock()
+ return string(data), nil
}
-func (c *XMPPChannel) roomNickname() string {
- n := strings.TrimSpace(c.config.Nickname)
- if n == "" {
- return "PicoClaw"
+func newHTTPClient() *http.Client {
+ dialer := &net.Dialer{}
+
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ conn, err := dialer.DialContext(ctx, network, addr)
+ if err == nil {
+ return conn, nil
+ }
+
+ if !strings.Contains(err.Error(), "no such host") {
+ return nil, err
+ }
+
+ host, port, splitErr := net.SplitHostPort(addr)
+ if splitErr != nil {
+ return nil, err
+ }
+
+ parts := strings.SplitN(host, ".", 2)
+ if len(parts) != 2 || parts[1] == "" {
+ return nil, err
+ }
+
+ fallbackHost := parts[1]
+ return dialer.DialContext(ctx, network, net.JoinHostPort(fallbackHost, port))
+ },
+ }
+
+ return &http.Client{
+ Timeout: 60 * time.Second,
+ Transport: transport,
}
- return n
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index df7f1fe..6f5c9e9 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -158,18 +158,12 @@ type OneBotConfig struct {
}
type XMPPConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XMPP_ENABLED"`
- Server string `json:"server" env:"PICOCLAW_CHANNELS_XMPP_SERVER"`
- Domain string `json:"domain" env:"PICOCLAW_CHANNELS_XMPP_DOMAIN"`
- Username string `json:"username" env:"PICOCLAW_CHANNELS_XMPP_USERNAME"`
- Password string `json:"password" env:"PICOCLAW_CHANNELS_XMPP_PASSWORD"`
- Resource string `json:"resource" env:"PICOCLAW_CHANNELS_XMPP_RESOURCE"`
- Rooms []string `json:"rooms" env:"PICOCLAW_CHANNELS_XMPP_ROOMS"`
- Nickname string `json:"nickname" env:"PICOCLAW_CHANNELS_XMPP_NICKNAME"`
- UseTLS bool `json:"use_tls" env:"PICOCLAW_CHANNELS_XMPP_USE_TLS"`
- InsecureSkipVerify bool `json:"insecure_skip_verify" env:"PICOCLAW_CHANNELS_XMPP_INSECURE_SKIP_VERIFY"`
- EnableReceipts bool `json:"enable_receipts" env:"PICOCLAW_CHANNELS_XMPP_ENABLE_RECEIPTS"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_XMPP_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_XMPP_ENABLED"`
+ JID string `json:"jid" env:"PICOCLAW_CHANNELS_XMPP_JID"`
+ Password string `json:"password" env:"PICOCLAW_CHANNELS_XMPP_PASSWORD"`
+ Server string `json:"server" env:"PICOCLAW_CHANNELS_XMPP_SERVER"`
+ UploadDomain string `json:"upload_domain" env:"PICOCLAW_CHANNELS_XMPP_UPLOAD_DOMAIN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_XMPP_ALLOW_FROM"`
}
type HeartbeatConfig struct {
@@ -222,13 +216,25 @@ type DuckDuckGoConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
+ Perplexity PerplexityConfig `json:"perplexity"`
+}
+
+type CronToolsConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
}
type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
+ Web WebToolsConfig `json:"web"`
+ Cron CronToolsConfig `json:"cron"`
}
func DefaultConfig() *Config {
@@ -310,18 +316,12 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
XMPP: XMPPConfig{
- Enabled: false,
- Server: "",
- Domain: "",
- Username: "",
- Password: "",
- Resource: "picoclaw",
- Rooms: []string{},
- Nickname: "PicoClaw",
- UseTLS: true,
- InsecureSkipVerify: false,
- EnableReceipts: true,
- AllowFrom: FlexibleStringSlice{},
+ Enabled: false,
+ JID: "",
+ Password: "",
+ Server: "",
+ UploadDomain: "",
+ AllowFrom: FlexibleStringSlice{},
},
},
Providers: ProvidersConfig{
@@ -351,6 +351,14 @@ func DefaultConfig() *Config {
Enabled: true,
MaxResults: 5,
},
+ Perplexity: PerplexityConfig{
+ Enabled: false,
+ APIKey: "",
+ MaxResults: 5,
+ },
+ },
+ Cron: CronToolsConfig{
+ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
},
},
Heartbeat: HeartbeatConfig{
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 8e0e32f..e79d553 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -201,18 +201,18 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
case "xmpp":
cfg.Channels.XMPP.Enabled = enabled
cfg.Channels.XMPP.AllowFrom = allowFrom
- if v, ok := getString(cMap, "server"); ok {
- cfg.Channels.XMPP.Server = v
- }
- if v, ok := getString(cMap, "domain"); ok {
- cfg.Channels.XMPP.Domain = v
- }
- if v, ok := getString(cMap, "username"); ok {
- cfg.Channels.XMPP.Username = v
+ if v, ok := getString(cMap, "jid"); ok {
+ cfg.Channels.XMPP.JID = v
}
if v, ok := getString(cMap, "password"); ok {
cfg.Channels.XMPP.Password = v
}
+ if v, ok := getString(cMap, "server"); ok {
+ cfg.Channels.XMPP.Server = v
+ }
+ if v, ok := getString(cMap, "upload_domain"); ok {
+ cfg.Channels.XMPP.UploadDomain = v
+ }
}
}
}
@@ -291,6 +291,9 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled {
existing.Channels.MaixCam = incoming.Channels.MaixCam
}
+ if !existing.Channels.XMPP.Enabled && incoming.Channels.XMPP.Enabled {
+ existing.Channels.XMPP = incoming.Channels.XMPP
+ }
if existing.Tools.Web.Brave.APIKey == "" {
existing.Tools.Web.Brave = incoming.Tools.Web.Brave