mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
6c0798ca3f
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error) and MediaSender.SendMedia similarly, so callers can capture platform message IDs for threading, reactions, and history annotation. Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID), Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil because UploadFileV2 does not expose the posted message timestamp in its response. All other adapters return nil IDs. preSend and sendWithRetry in manager.go updated to propagate ([]string, bool). README examples updated for both English and Chinese docs. * style: apply golangci-lint fixes (golines) * docs: fix Send migration guide — restore old error-only signature in before/after example
195 lines
4.6 KiB
Go
195 lines
4.6 KiB
Go
package irc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/ergochat/irc-go/ircevent"
|
|
"github.com/ergochat/irc-go/ircmsg"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// IRCChannel implements the Channel interface for IRC servers.
|
|
type IRCChannel struct {
|
|
*channels.BaseChannel
|
|
config config.IRCConfig
|
|
conn *ircevent.Connection
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewIRCChannel creates a new IRC channel.
|
|
func NewIRCChannel(cfg config.IRCConfig, messageBus *bus.MessageBus) (*IRCChannel, error) {
|
|
if cfg.Server == "" {
|
|
return nil, fmt.Errorf("irc server is required")
|
|
}
|
|
if cfg.Nick == "" {
|
|
return nil, fmt.Errorf("irc nick is required")
|
|
}
|
|
|
|
base := channels.NewBaseChannel("irc", cfg, messageBus, cfg.AllowFrom,
|
|
channels.WithMaxMessageLength(400),
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
|
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
|
|
)
|
|
|
|
return &IRCChannel{
|
|
BaseChannel: base,
|
|
config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// Start connects to the IRC server and begins listening.
|
|
func (c *IRCChannel) Start(ctx context.Context) error {
|
|
logger.InfoC("irc", "Starting IRC channel")
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
|
|
|
user := c.config.User
|
|
if user == "" {
|
|
user = c.config.Nick
|
|
}
|
|
realName := c.config.RealName
|
|
if realName == "" {
|
|
realName = c.config.Nick
|
|
}
|
|
caps := []string(c.config.RequestCaps)
|
|
if len(caps) == 0 {
|
|
caps = []string{"server-time", "message-tags"}
|
|
}
|
|
|
|
conn := &ircevent.Connection{
|
|
Server: c.config.Server,
|
|
Nick: c.config.Nick,
|
|
User: user,
|
|
RealName: realName,
|
|
Password: c.config.Password.String(),
|
|
UseTLS: c.config.TLS,
|
|
RequestCaps: caps,
|
|
QuitMessage: "Goodbye",
|
|
Debug: false,
|
|
Log: nil,
|
|
}
|
|
|
|
if c.config.TLS {
|
|
conn.TLSConfig = &tls.Config{
|
|
ServerName: extractHost(c.config.Server),
|
|
}
|
|
}
|
|
|
|
// SASL auth (takes priority over NickServ)
|
|
if c.config.SASLUser != "" && c.config.SASLPassword.String() != "" {
|
|
conn.SASLLogin = c.config.SASLUser
|
|
conn.SASLPassword = c.config.SASLPassword.String()
|
|
}
|
|
|
|
// Register event handlers
|
|
conn.AddConnectCallback(func(e ircmsg.Message) {
|
|
c.onConnect(conn)
|
|
})
|
|
conn.AddCallback("PRIVMSG", func(e ircmsg.Message) {
|
|
c.onPrivmsg(conn, e)
|
|
})
|
|
|
|
if err := conn.Connect(); err != nil {
|
|
return fmt.Errorf("irc connect failed: %w", err)
|
|
}
|
|
|
|
c.conn = conn
|
|
|
|
// ircevent.Connection.Loop() handles reconnection internally.
|
|
go conn.Loop()
|
|
|
|
c.SetRunning(true)
|
|
logger.InfoCF("irc", "IRC channel started", map[string]any{
|
|
"server": c.config.Server,
|
|
"nick": c.config.Nick,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Stop disconnects from the IRC server.
|
|
func (c *IRCChannel) Stop(ctx context.Context) error {
|
|
logger.InfoC("irc", "Stopping IRC channel")
|
|
c.SetRunning(false)
|
|
|
|
if c.conn != nil {
|
|
c.conn.Quit()
|
|
}
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
}
|
|
|
|
logger.InfoC("irc", "IRC channel stopped")
|
|
return nil
|
|
}
|
|
|
|
// Send sends a message to an IRC channel or user.
|
|
func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
|
if !c.IsRunning() {
|
|
return nil, channels.ErrNotRunning
|
|
}
|
|
|
|
target := msg.ChatID
|
|
if target == "" {
|
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
|
}
|
|
|
|
if strings.TrimSpace(msg.Content) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Send each line separately (IRC is line-oriented)
|
|
lines := strings.Split(msg.Content, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
c.conn.Privmsg(target, line)
|
|
}
|
|
|
|
logger.DebugCF("irc", "Message sent", map[string]any{
|
|
"target": target,
|
|
"lines": len(lines),
|
|
})
|
|
return nil, nil
|
|
}
|
|
|
|
// StartTyping implements channels.TypingCapable using IRCv3 +typing client tag.
|
|
// Requires typing.enabled in config and server support for message-tags capability.
|
|
func (c *IRCChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
noop := func() {}
|
|
|
|
if !c.config.Typing.Enabled || !c.IsRunning() || c.conn == nil {
|
|
return noop, nil
|
|
}
|
|
|
|
// Check if server supports message-tags (required for TAGMSG)
|
|
if _, ok := c.conn.AcknowledgedCaps()["message-tags"]; !ok {
|
|
return noop, nil
|
|
}
|
|
|
|
c.conn.SendWithTags(map[string]string{"+typing": "active"}, "TAGMSG", chatID)
|
|
|
|
return func() {
|
|
if c.IsRunning() && c.conn != nil {
|
|
c.conn.SendWithTags(map[string]string{"+typing": "done"}, "TAGMSG", chatID)
|
|
}
|
|
}, nil
|
|
}
|
|
|
|
// extractHost returns the hostname portion of a host:port string.
|
|
func extractHost(server string) string {
|
|
host, _, found := strings.Cut(server, ":")
|
|
if found {
|
|
return host
|
|
}
|
|
return server
|
|
}
|