improve IRC channel continuity and formatting
This commit is contained in:
@@ -52,6 +52,7 @@ type Config struct {
|
||||
OnEvent func(Event)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
OnRoomsChanged func([]string)
|
||||
}
|
||||
|
||||
// Event is a typed IRC event suitable for JSON delivery to the frontend.
|
||||
@@ -85,6 +86,7 @@ type Client struct {
|
||||
connected bool
|
||||
closing bool
|
||||
listRooms []RoomInfo
|
||||
roomNames []string
|
||||
}
|
||||
|
||||
// New creates an IRC client wrapper.
|
||||
@@ -406,10 +408,14 @@ func (c *Client) Rooms() []RoomInfo {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
}
|
||||
channels := c.client.Channels()
|
||||
rooms := make([]RoomInfo, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
c.mu.Lock()
|
||||
roomNames := append([]string{}, c.roomNames...)
|
||||
c.mu.Unlock()
|
||||
rooms := make([]RoomInfo, 0, len(roomNames))
|
||||
for _, roomName := range roomNames {
|
||||
ch := c.client.LookupChannel(roomName)
|
||||
if ch == nil {
|
||||
rooms = append(rooms, RoomInfo{Name: roomName})
|
||||
continue
|
||||
}
|
||||
rooms = append(rooms, RoomInfo{
|
||||
@@ -445,6 +451,7 @@ func (c *Client) registerHandlers() {
|
||||
c.client.Handlers.Add(girc.NOTICE, c.handleNotice)
|
||||
c.client.Handlers.Add(girc.JOIN, c.handleJoin)
|
||||
c.client.Handlers.Add(girc.PART, c.handlePart)
|
||||
c.client.Handlers.Add(girc.KICK, c.handleKick)
|
||||
c.client.Handlers.Add(girc.QUIT, c.handleQuit)
|
||||
c.client.Handlers.Add(girc.NICK, c.handleNick)
|
||||
c.client.Handlers.Add(girc.TOPIC, c.handleTopic)
|
||||
@@ -499,6 +506,9 @@ func (c *Client) handleJoin(gc *girc.Client, e girc.Event) {
|
||||
return
|
||||
}
|
||||
from := sourceName(e)
|
||||
if strings.EqualFold(from, gc.GetNick()) {
|
||||
c.rememberRoomMembership(room, true)
|
||||
}
|
||||
c.emit(Event{Type: "join", Target: room, From: from, Text: fmt.Sprintf("%s joined %s", from, room), Timestamp: eventTime(e)})
|
||||
c.emitRooms()
|
||||
c.emitUsers(room)
|
||||
@@ -514,11 +524,35 @@ func (c *Client) handlePart(gc *girc.Client, e girc.Event) {
|
||||
if len(e.Params) > 1 {
|
||||
message = strings.TrimSpace(e.Last())
|
||||
}
|
||||
if strings.EqualFold(from, gc.GetNick()) {
|
||||
c.rememberRoomMembership(room, false)
|
||||
}
|
||||
c.emit(Event{Type: "part", Target: room, From: from, Text: message, Timestamp: eventTime(e)})
|
||||
c.emitRooms()
|
||||
c.emitUsers(room)
|
||||
}
|
||||
|
||||
func (c *Client) handleKick(gc *girc.Client, e girc.Event) {
|
||||
if len(e.Params) < 2 {
|
||||
return
|
||||
}
|
||||
room := e.Params[0]
|
||||
kicked := e.Params[1]
|
||||
if strings.EqualFold(kicked, gc.GetNick()) {
|
||||
c.rememberRoomMembership(room, false)
|
||||
}
|
||||
c.emit(Event{
|
||||
Type: "kick",
|
||||
Target: room,
|
||||
From: sourceName(e),
|
||||
Command: kicked,
|
||||
Text: strings.TrimSpace(e.Last()),
|
||||
Timestamp: eventTime(e),
|
||||
})
|
||||
c.emitRooms()
|
||||
c.emitUsers(room)
|
||||
}
|
||||
|
||||
func (c *Client) handleQuit(gc *girc.Client, e girc.Event) {
|
||||
from := sourceName(e)
|
||||
c.emit(Event{Type: "quit", Target: ServerTarget, From: from, Text: strings.TrimSpace(e.Last()), Timestamp: eventTime(e)})
|
||||
@@ -661,7 +695,39 @@ func (c *Client) sshAuthMethods() ([]ssh.AuthMethod, error) {
|
||||
}
|
||||
|
||||
func (c *Client) emitRooms() {
|
||||
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: c.Rooms(), Timestamp: time.Now().UnixMilli()})
|
||||
rooms := c.Rooms()
|
||||
c.emit(Event{Type: "rooms", Target: ServerTarget, Rooms: rooms, Timestamp: time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
func (c *Client) rememberRoomMembership(room string, joined bool) {
|
||||
room = strings.TrimSpace(room)
|
||||
if room == "" {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
position := -1
|
||||
changed := false
|
||||
for i, existing := range c.roomNames {
|
||||
if strings.EqualFold(existing, room) {
|
||||
position = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if joined && position == -1 {
|
||||
c.roomNames = append(c.roomNames, room)
|
||||
changed = true
|
||||
}
|
||||
if !joined && position >= 0 {
|
||||
c.roomNames = append(c.roomNames[:position], c.roomNames[position+1:]...)
|
||||
changed = true
|
||||
}
|
||||
names := append([]string{}, c.roomNames...)
|
||||
c.mu.Unlock()
|
||||
|
||||
if changed && c.cfg.OnRoomsChanged != nil {
|
||||
c.cfg.OnRoomsChanged(names)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) emitUsers(room string) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package ircclient
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRememberRoomMembershipTracksConfirmedChannels(t *testing.T) {
|
||||
var updates []string
|
||||
client := &Client{cfg: Config{
|
||||
OnRoomsChanged: func(rooms []string) {
|
||||
updates = append(updates, strings.Join(rooms, ","))
|
||||
},
|
||||
}}
|
||||
|
||||
client.rememberRoomMembership("#One", true)
|
||||
client.rememberRoomMembership("#two", true)
|
||||
client.rememberRoomMembership("#one", true)
|
||||
client.rememberRoomMembership("#missing", false)
|
||||
client.rememberRoomMembership("#ONE", false)
|
||||
|
||||
if got, want := strings.Join(updates, "|"), "#One|#One,#two|#two"; got != want {
|
||||
t.Fatalf("room updates = %q, want %q", got, want)
|
||||
}
|
||||
if got := strings.Join(client.roomNames, ","); got != "#two" {
|
||||
t.Fatalf("remembered rooms = %q, want %q", got, "#two")
|
||||
}
|
||||
}
|
||||
@@ -1466,6 +1466,15 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
||||
nickServPass = password
|
||||
}
|
||||
|
||||
autoJoinRooms := append([]string{}, ircConf.AutoJoinRooms...)
|
||||
if m.store != nil && profile.ID != "" {
|
||||
lastRooms, err := m.store.GetIRCLastRooms(profile.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading remembered IRC rooms: %w", err)
|
||||
}
|
||||
autoJoinRooms = mergeIRCRooms(autoJoinRooms, lastRooms)
|
||||
}
|
||||
|
||||
cfg := ircclient.Config{
|
||||
Server: profile.Host,
|
||||
Port: profile.Port,
|
||||
@@ -1474,7 +1483,7 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
||||
RealName: ircConf.RealName,
|
||||
UseTLS: ircConf.UseTLS,
|
||||
ServerPass: serverPass,
|
||||
AutoJoinRooms: append([]string{}, ircConf.AutoJoinRooms...),
|
||||
AutoJoinRooms: autoJoinRooms,
|
||||
NickServIdentify: ircConf.NickServIdentify,
|
||||
NickServAccount: ircConf.NickServAccount,
|
||||
NickServPassword: nickServPass,
|
||||
@@ -1490,6 +1499,19 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
||||
SSHAgentAuth: ircConf.SSHAgentAuth,
|
||||
SSHHostKeyCallback: m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk),
|
||||
}
|
||||
if m.store != nil && profile.ID != "" {
|
||||
profileID := profile.ID
|
||||
cfg.OnRoomsChanged = func(rooms []string) {
|
||||
if err := m.store.SaveIRCLastRooms(profileID, rooms); err != nil {
|
||||
m.emitIRCEvent(sess.Info.ID, ircclient.Event{
|
||||
Type: "system",
|
||||
Target: ircclient.ServerTarget,
|
||||
Text: "Could not remember open IRC rooms: " + err.Error(),
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.User == "" {
|
||||
cfg.User = ircConf.Nick
|
||||
@@ -1554,6 +1576,26 @@ func (m *Manager) connectIRC(sess *Session, getSecret func(prompt string) (strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeIRCRooms(groups ...[]string) []string {
|
||||
var rooms []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, group := range groups {
|
||||
for _, room := range group {
|
||||
room = strings.TrimSpace(room)
|
||||
if room == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(room)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
}
|
||||
return rooms
|
||||
}
|
||||
|
||||
func (m *Manager) emitIRCEvent(sessionID string, event ircclient.Event) {
|
||||
m.emitEvent("session:irc:event", map[string]interface{}{
|
||||
"sessionID": sessionID,
|
||||
|
||||
@@ -102,6 +102,16 @@ func TestCreateTabName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIRCRoomsPreservesConfiguredAndRememberedOrder(t *testing.T) {
|
||||
got := mergeIRCRooms(
|
||||
[]string{" #configured ", "#Shared"},
|
||||
[]string{"#shared", "#last-session", "", "#CONFIGURED"},
|
||||
)
|
||||
if joined := strings.Join(got, ","); joined != "#configured,#Shared,#last-session" {
|
||||
t.Fatalf("mergeIRCRooms() = %q, want configured rooms followed by new remembered rooms", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenameSession on non-existent session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration 006: Remember the channels open for each saved IRC profile.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS irc_channel_state (
|
||||
profile_id TEXT NOT NULL,
|
||||
room_name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, room_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_irc_channel_state_profile_position
|
||||
ON irc_channel_state (profile_id, position);
|
||||
|
||||
INSERT INTO schema_version (version, applied_at)
|
||||
VALUES (6, datetime('now'));
|
||||
@@ -33,6 +33,15 @@ func makeSSHProfile(name, host string) *models.Profile {
|
||||
return &p
|
||||
}
|
||||
|
||||
func makeIRCProfile(name, host string) *models.Profile {
|
||||
p := models.NewProfile(models.ProtocolIRC)
|
||||
p.DisplayName = name
|
||||
p.Host = host
|
||||
p.Username = "nexus"
|
||||
p.IRC.Nick = "nexus"
|
||||
return &p
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -104,6 +113,54 @@ func TestDeleteProfileNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIRCLastRoomsRoundTripReplaceAndDelete(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
p := makeIRCProfile("IRC Network", "irc.example.com")
|
||||
if err := store.CreateProfile(p); err != nil {
|
||||
t.Fatalf("CreateProfile() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.SaveIRCLastRooms(p.ID, []string{" #Go-Nuts ", "#nexus", "#go-nuts", ""}); err != nil {
|
||||
t.Fatalf("SaveIRCLastRooms() error = %v", err)
|
||||
}
|
||||
rooms, err := store.GetIRCLastRooms(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIRCLastRooms() error = %v", err)
|
||||
}
|
||||
if got, want := strings.Join(rooms, ","), "#Go-Nuts,#nexus"; got != want {
|
||||
t.Fatalf("GetIRCLastRooms() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if err := store.SaveIRCLastRooms(p.ID, []string{"#new-room"}); err != nil {
|
||||
t.Fatalf("SaveIRCLastRooms(replace) error = %v", err)
|
||||
}
|
||||
rooms, err = store.GetIRCLastRooms(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIRCLastRooms(replaced) error = %v", err)
|
||||
}
|
||||
if got, want := strings.Join(rooms, ","), "#new-room"; got != want {
|
||||
t.Fatalf("GetIRCLastRooms(replaced) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if err := store.DeleteProfile(p.ID); err != nil {
|
||||
t.Fatalf("DeleteProfile() error = %v", err)
|
||||
}
|
||||
rooms, err = store.GetIRCLastRooms(p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIRCLastRooms(after delete) error = %v", err)
|
||||
}
|
||||
if len(rooms) != 0 {
|
||||
t.Fatalf("GetIRCLastRooms(after delete) = %v, want empty", rooms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIRCLastRoomsRejectsEmptyProfileID(t *testing.T) {
|
||||
store := setupTestStore(t)
|
||||
if err := store.SaveIRCLastRooms(" ", []string{"#room"}); err == nil {
|
||||
t.Fatal("SaveIRCLastRooms() accepted an empty profile ID")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List and search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -215,7 +215,16 @@ func (s *Store) UpdateProfile(p *models.Profile) error {
|
||||
|
||||
// DeleteProfile removes a profile by ID.
|
||||
func (s *Store) DeleteProfile(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to begin profile deletion: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", id); err != nil {
|
||||
return fmt.Errorf("storage: failed to delete IRC channel state: %w", err)
|
||||
}
|
||||
res, err := tx.Exec("DELETE FROM profiles WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to delete profile: %w", err)
|
||||
}
|
||||
@@ -223,6 +232,9 @@ func (s *Store) DeleteProfile(id string) error {
|
||||
if n == 0 {
|
||||
return fmt.Errorf("storage: profile %q not found", id)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("storage: failed to commit profile deletion: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -550,6 +562,80 @@ func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IRC channel state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetIRCLastRooms returns the channels that were open the last time the given
|
||||
// saved IRC profile was connected.
|
||||
func (s *Store) GetIRCLastRooms(profileID string) ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT room_name FROM irc_channel_state
|
||||
WHERE profile_id = ? ORDER BY position, room_name`, profileID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to get IRC channel state: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
rooms := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var room string
|
||||
if err := rows.Scan(&room); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to scan IRC channel state: %w", err)
|
||||
}
|
||||
rooms = append(rooms, room)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("storage: failed to read IRC channel state: %w", err)
|
||||
}
|
||||
return rooms, nil
|
||||
}
|
||||
|
||||
// SaveIRCLastRooms atomically replaces the remembered channel set for a saved
|
||||
// IRC profile. Empty names and case-insensitive duplicates are discarded.
|
||||
func (s *Store) SaveIRCLastRooms(profileID string, rooms []string) error {
|
||||
profileID = strings.TrimSpace(profileID)
|
||||
if profileID == "" {
|
||||
return fmt.Errorf("storage: IRC channel state requires a profile ID")
|
||||
}
|
||||
|
||||
normalized := make([]string, 0, len(rooms))
|
||||
seen := make(map[string]struct{}, len(rooms))
|
||||
for _, room := range rooms {
|
||||
room = strings.TrimSpace(room)
|
||||
if room == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(room)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, room)
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: failed to begin IRC channel state update: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec("DELETE FROM irc_channel_state WHERE profile_id = ?", profileID); err != nil {
|
||||
return fmt.Errorf("storage: failed to clear IRC channel state: %w", err)
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for position, room := range normalized {
|
||||
if _, err := tx.Exec(`INSERT INTO irc_channel_state
|
||||
(profile_id, room_name, position, updated_at) VALUES (?, ?, ?, ?)`,
|
||||
profileID, room, position, now); err != nil {
|
||||
return fmt.Errorf("storage: failed to save IRC channel state: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("storage: failed to commit IRC channel state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preferences (key-value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user