2026-03-05 10:46:01 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-03-06 19:09:37 +00:00
|
|
|
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"}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:46:01 +00:00
|
|
|
conn := &ircevent.Connection{
|
2026-03-05 15:03:10 +00:00
|
|
|
Server: c.config.Server,
|
|
|
|
|
Nick: c.config.Nick,
|
2026-03-06 19:09:37 +00:00
|
|
|
User: user,
|
|
|
|
|
RealName: realName,
|
2026-03-27 16:03:34 +00:00
|
|
|
Password: c.config.Password.String(),
|
2026-03-05 15:03:10 +00:00
|
|
|
UseTLS: c.config.TLS,
|
2026-03-06 19:09:37 +00:00
|
|
|
RequestCaps: caps,
|
2026-03-05 15:03:10 +00:00
|
|
|
QuitMessage: "Goodbye",
|
|
|
|
|
Debug: false,
|
|
|
|
|
Log: nil,
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.config.TLS {
|
|
|
|
|
conn.TLSConfig = &tls.Config{
|
|
|
|
|
ServerName: extractHost(c.config.Server),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SASL auth (takes priority over NickServ)
|
2026-03-27 16:03:34 +00:00
|
|
|
if c.config.SASLUser != "" && c.config.SASLPassword.String() != "" {
|
2026-03-05 10:46:01 +00:00
|
|
|
conn.SASLLogin = c.config.SASLUser
|
2026-03-27 16:03:34 +00:00
|
|
|
conn.SASLPassword = c.config.SASLPassword.String()
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* 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
2026-03-31 03:07:32 +00:00
|
|
|
func (c *IRCChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-03-05 10:46:01 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* 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
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
target := msg.ChatID
|
|
|
|
|
if target == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* 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
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed)
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.TrimSpace(msg.Content) == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* 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
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* 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
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-03-05 10:46:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|