From ac933d07d2aa5e2048490d9746490c873c952879 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 09:22:22 -0800 Subject: [PATCH 01/22] Add embedded web chat client with C2S HTTP API - New DB schema: users, channel_members, messages tables (migration 003) - Full C2S HTTP API: register, channels, messages, DMs, polling - Preact SPA embedded via embed.FS, served at GET / - IRC-style UI: tab bar, channel messages, user list, DM tabs, /commands - Dark theme, responsive, esbuild-bundled (~19KB) - Polling-based message delivery (1.5s interval) - Commands: /join, /part, /msg, /nick --- .gitignore | 1 + internal/db/queries.go | 303 ++++++++++++++++++ internal/db/schema/003_users.sql | 31 ++ internal/handlers/api.go | 358 +++++++++++++++++++++ internal/handlers/handlers.go | 2 + internal/server/routes.go | 46 +++ web/build.sh | 41 +++ web/dist/app.js | 1 + web/dist/index.html | 13 + web/dist/style.css | 274 +++++++++++++++++ web/embed.go | 9 + web/package-lock.json | 513 +++++++++++++++++++++++++++++++ web/package.json | 18 ++ web/src/app.jsx | 374 ++++++++++++++++++++++ web/src/index.html | 13 + web/src/style.css | 274 +++++++++++++++++ 16 files changed, 2271 insertions(+) create mode 100644 internal/db/queries.go create mode 100644 internal/db/schema/003_users.sql create mode 100644 internal/handlers/api.go create mode 100755 web/build.sh create mode 100644 web/dist/app.js create mode 100644 web/dist/index.html create mode 100644 web/dist/style.css create mode 100644 web/embed.go create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/app.jsx create mode 100644 web/src/index.html create mode 100644 web/src/style.css diff --git a/.gitignore b/.gitignore index 69f180c..9959171 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ data.db *.out vendor/ debug.log +web/node_modules/ diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..af7b83b --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,303 @@ +package db + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" +) + +func generateToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// CreateUser registers a new user with the given nick and returns the user with token. +func (s *Database) CreateUser(ctx context.Context, nick string) (int64, string, error) { + token := generateToken() + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO users (nick, token, created_at, last_seen) VALUES (?, ?, ?, ?)", + nick, token, now, now) + if err != nil { + return 0, "", fmt.Errorf("create user: %w", err) + } + id, _ := res.LastInsertId() + return id, token, nil +} + +// GetUserByToken returns user id and nick for a given auth token. +func (s *Database) GetUserByToken(ctx context.Context, token string) (int64, string, error) { + var id int64 + var nick string + err := s.db.QueryRowContext(ctx, "SELECT id, nick FROM users WHERE token = ?", token).Scan(&id, &nick) + if err != nil { + return 0, "", err + } + // Update last_seen + _, _ = s.db.ExecContext(ctx, "UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), id) + return id, nick, nil +} + +// GetUserByNick returns user id for a given nick. +func (s *Database) GetUserByNick(ctx context.Context, nick string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM users WHERE nick = ?", nick).Scan(&id) + return id, err +} + +// GetOrCreateChannel returns the channel id, creating it if needed. +func (s *Database) GetOrCreateChannel(ctx context.Context, name string) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, "SELECT id FROM channels WHERE name = ?", name).Scan(&id) + if err == nil { + return id, nil + } + now := time.Now() + res, err := s.db.ExecContext(ctx, + "INSERT INTO channels (name, created_at, updated_at) VALUES (?, ?, ?)", + name, now, now) + if err != nil { + return 0, fmt.Errorf("create channel: %w", err) + } + id, _ = res.LastInsertId() + return id, nil +} + +// JoinChannel adds a user to a channel. +func (s *Database) JoinChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "INSERT OR IGNORE INTO channel_members (channel_id, user_id, joined_at) VALUES (?, ?, ?)", + channelID, userID, time.Now()) + return err +} + +// PartChannel removes a user from a channel. +func (s *Database) PartChannel(ctx context.Context, channelID, userID int64) error { + _, err := s.db.ExecContext(ctx, + "DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?", + channelID, userID) + return err +} + +// ListChannels returns all channels the user has joined. +func (s *Database) ListChannels(ctx context.Context, userID int64) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT c.id, c.name, c.topic FROM channels c + INNER JOIN channel_members cm ON cm.channel_id = c.id + WHERE cm.user_id = ? ORDER BY c.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} + +// ChannelInfo is a lightweight channel representation. +type ChannelInfo struct { + ID int64 `json:"id"` + Name string `json:"name"` + Topic string `json:"topic"` +} + +// ChannelMembers returns all members of a channel. +func (s *Database) ChannelMembers(ctx context.Context, channelID int64) ([]MemberInfo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT u.id, u.nick, u.last_seen FROM users u + INNER JOIN channel_members cm ON cm.user_id = u.id + WHERE cm.channel_id = ? ORDER BY u.nick`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + var members []MemberInfo + for rows.Next() { + var m MemberInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.LastSeen); err != nil { + return nil, err + } + members = append(members, m) + } + if members == nil { + members = []MemberInfo{} + } + return members, nil +} + +// MemberInfo represents a channel member. +type MemberInfo struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + LastSeen time.Time `json:"lastSeen"` +} + +// MessageInfo represents a chat message. +type MessageInfo struct { + ID int64 `json:"id"` + Channel string `json:"channel,omitempty"` + Nick string `json:"nick"` + Content string `json:"content"` + IsDM bool `json:"isDm,omitempty"` + DMTarget string `json:"dmTarget,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// GetMessages returns messages for a channel, optionally after a given ID. +func (s *Database) GetMessages(ctx context.Context, channelID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id > ? + ORDER BY m.id ASC LIMIT ?`, channelID, afterID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// SendMessage inserts a channel message. +func (s *Database) SendMessage(ctx context.Context, channelID, userID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (channel_id, user_id, content, is_dm, created_at) VALUES (?, ?, ?, 0, ?)", + channelID, userID, content, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// SendDM inserts a direct message. +func (s *Database) SendDM(ctx context.Context, fromID, toID int64, content string) (int64, error) { + res, err := s.db.ExecContext(ctx, + "INSERT INTO messages (user_id, content, is_dm, dm_target_id, created_at) VALUES (?, ?, 1, ?, ?)", + fromID, content, toID, time.Now()) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetDMs returns direct messages between two users after a given ID. +func (s *Database) GetDMs(ctx context.Context, userA, userB int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 AND m.id > ? + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id ASC LIMIT ?`, afterID, userA, userB, userB, userA, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = true + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// PollMessages returns all new messages (channel + DM) for a user after a given ID. +func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT m.id, COALESCE(c.name, ''), u.nick, m.content, m.is_dm, COALESCE(t.nick, ''), m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + LEFT JOIN channels c ON c.id = m.channel_id + LEFT JOIN users t ON t.id = m.dm_target_id + WHERE m.id > ? AND ( + (m.is_dm = 0 AND m.channel_id IN (SELECT channel_id FROM channel_members WHERE user_id = ?)) + OR (m.is_dm = 1 AND (m.user_id = ? OR m.dm_target_id = ?)) + ) + ORDER BY m.id ASC LIMIT ?`, afterID, userID, userID, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + var isDM int + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &isDM, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = isDM == 1 + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + return msgs, nil +} + +// GetMOTD returns the server MOTD from config. +func (s *Database) GetServerName() string { + return "" +} + +// ListAllChannels returns all channels. +func (s *Database) ListAllChannels(ctx context.Context) ([]ChannelInfo, error) { + rows, err := s.db.QueryContext(ctx, + "SELECT id, name, topic FROM channels ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + var channels []ChannelInfo + for rows.Next() { + var ch ChannelInfo + if err := rows.Scan(&ch.ID, &ch.Name, &ch.Topic); err != nil { + return nil, err + } + channels = append(channels, ch) + } + if channels == nil { + channels = []ChannelInfo{} + } + return channels, nil +} diff --git a/internal/db/schema/003_users.sql b/internal/db/schema/003_users.sql new file mode 100644 index 0000000..f305aa0 --- /dev/null +++ b/internal/db/schema/003_users.sql @@ -0,0 +1,31 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nick TEXT NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS channel_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(channel_id, user_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + is_dm INTEGER NOT NULL DEFAULT 0, + dm_target_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at); +CREATE INDEX IF NOT EXISTS idx_messages_dm ON messages(user_id, dm_target_id, created_at); +CREATE INDEX IF NOT EXISTS idx_users_token ON users(token); diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..22487d4 --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,358 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" +) + +// authUser extracts the user from the Authorization header (Bearer token). +func (s *Handlers) authUser(r *http.Request) (int64, string, error) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return 0, "", sql.ErrNoRows + } + token := strings.TrimPrefix(auth, "Bearer ") + return s.params.Database.GetUserByToken(r.Context(), token) +} + +func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, string, bool) { + uid, nick, err := s.authUser(r) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "unauthorized"}, http.StatusUnauthorized) + return 0, "", false + } + return uid, nick, true +} + +// HandleRegister creates a new user and returns the auth token. +func (s *Handlers) HandleRegister() http.HandlerFunc { + type request struct { + Nick string `json:"nick"` + } + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + Token string `json:"token"` + } + return func(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Nick = strings.TrimSpace(req.Nick) + if req.Nick == "" || len(req.Nick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + id, token, err := s.params.Database.CreateUser(r.Context(), req.Nick) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already taken"}, http.StatusConflict) + return + } + s.log.Error("create user failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, &response{ID: id, Nick: req.Nick, Token: token}, http.StatusCreated) + } +} + +// HandleMe returns the current user's info. +func (s *Handlers) HandleMe() http.HandlerFunc { + type response struct { + ID int64 `json:"id"` + Nick string `json:"nick"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, nick, ok := s.requireAuth(w, r) + if !ok { + return + } + s.respondJSON(w, r, &response{ID: uid, Nick: nick}, http.StatusOK) + } +} + +// HandleListChannels returns channels the user has joined. +func (s *Handlers) HandleListChannels() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListChannels(r.Context(), uid) + if err != nil { + s.log.Error("list channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, channels, http.StatusOK) + } +} + +// HandleListAllChannels returns all channels on the server. +func (s *Handlers) HandleListAllChannels() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + channels, err := s.params.Database.ListAllChannels(r.Context()) + if err != nil { + s.log.Error("list all channels failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, channels, http.StatusOK) + } +} + +// HandleJoinChannel joins a channel (creates it if needed). +func (s *Handlers) HandleJoinChannel() http.HandlerFunc { + type request struct { + Channel string `json:"channel"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + req.Channel = strings.TrimSpace(req.Channel) + if req.Channel == "" { + s.respondJSON(w, r, map[string]string{"error": "channel name required"}, http.StatusBadRequest) + return + } + if !strings.HasPrefix(req.Channel, "#") { + req.Channel = "#" + req.Channel + } + chID, err := s.params.Database.GetOrCreateChannel(r.Context(), req.Channel) + if err != nil { + s.log.Error("get/create channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + s.log.Error("join channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "joined", "channel": req.Channel}, http.StatusOK) + } +} + +// HandlePartChannel leaves a channel. +func (s *Handlers) HandlePartChannel() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + s.log.Error("part channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "parted", "channel": name}, http.StatusOK) + } +} + +// HandleChannelMembers returns members of a channel. +func (s *Handlers) HandleChannelMembers() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + members, err := s.params.Database.ChannelMembers(r.Context(), chID) + if err != nil { + s.log.Error("channel members failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, members, http.StatusOK) + } +} + +// HandleGetMessages returns messages for a channel. +func (s *Handlers) HandleGetMessages() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.GetMessages(r.Context(), chID, afterID, limit) + if err != nil { + s.log.Error("get messages failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandleSendMessage sends a message to a channel. +func (s *Handlers) HandleSendMessage() http.HandlerFunc { + type request struct { + Content string `json:"content"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + name := "#" + chi.URLParam(r, "channel") + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Content) == "" { + s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) + return + } + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, req.Content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } +} + +// HandleSendDM sends a direct message to a user. +func (s *Handlers) HandleSendDM() http.HandlerFunc { + type request struct { + Content string `json:"content"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + targetNick := chi.URLParam(r, "nick") + targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Content) == "" { + s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) + return + } + msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, req.Content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } +} + +// HandleGetDMs returns direct messages with a user. +func (s *Handlers) HandleGetDMs() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + targetNick := chi.URLParam(r, "nick") + targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.GetDMs(r.Context(), uid, targetID, afterID, limit) + if err != nil { + s.log.Error("get dms failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandlePoll returns all new messages (channels + DMs) for the user. +func (s *Handlers) HandlePoll() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit) + if err != nil { + s.log.Error("poll messages failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } +} + +// HandleServerInfo returns server metadata (MOTD, name). +func (s *Handlers) HandleServerInfo() http.HandlerFunc { + type response struct { + Name string `json:"name"` + MOTD string `json:"motd"` + } + return func(w http.ResponseWriter, r *http.Request) { + s.respondJSON(w, r, &response{ + Name: s.params.Config.ServerName, + MOTD: s.params.Config.MOTD, + }, http.StatusOK) + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8d0816b..11b8942 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" + "git.eeqj.de/sneak/chat/internal/config" "git.eeqj.de/sneak/chat/internal/db" "git.eeqj.de/sneak/chat/internal/globals" "git.eeqj.de/sneak/chat/internal/healthcheck" @@ -20,6 +21,7 @@ type Params struct { Logger *logger.Logger Globals *globals.Globals + Config *config.Config Database *db.Database Healthcheck *healthcheck.Healthcheck } diff --git a/internal/server/routes.go b/internal/server/routes.go index 313f186..228eb9d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -1,9 +1,12 @@ package server import ( + "io/fs" "net/http" "time" + "git.eeqj.de/sneak/chat/web" + sentryhttp "github.com/getsentry/sentry-go/http" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" @@ -45,4 +48,47 @@ func (s *Server) SetupRoutes() { r.Get("/metrics", http.HandlerFunc(promhttp.Handler().ServeHTTP)) }) } + + // API v1 + s.router.Route("/api/v1", func(r chi.Router) { + r.Get("/server", s.h.HandleServerInfo()) + r.Post("/register", s.h.HandleRegister()) + r.Get("/me", s.h.HandleMe()) + + // Channels + r.Get("/channels", s.h.HandleListChannels()) + r.Get("/channels/all", s.h.HandleListAllChannels()) + r.Post("/channels/join", s.h.HandleJoinChannel()) + r.Delete("/channels/{channel}/part", s.h.HandlePartChannel()) + r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) + r.Get("/channels/{channel}/messages", s.h.HandleGetMessages()) + r.Post("/channels/{channel}/messages", s.h.HandleSendMessage()) + + // DMs + r.Get("/dm/{nick}/messages", s.h.HandleGetDMs()) + r.Post("/dm/{nick}/messages", s.h.HandleSendDM()) + + // Polling + r.Get("/poll", s.h.HandlePoll()) + }) + + // Serve embedded SPA + distFS, err := fs.Sub(web.Dist, "dist") + if err != nil { + s.log.Error("failed to get web dist filesystem", "error", err) + } else { + fileServer := http.FileServer(http.FS(distFS)) + s.router.Get("/*", func(w http.ResponseWriter, r *http.Request) { + // Try to serve the file; if not found, serve index.html for SPA routing + f, err := distFS.(fs.ReadFileFS).ReadFile(r.URL.Path[1:]) + if err != nil || len(f) == 0 { + indexHTML, _ := distFS.(fs.ReadFileFS).ReadFile("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(indexHTML) + return + } + fileServer.ServeHTTP(w, r) + }) + } } diff --git a/web/build.sh b/web/build.sh new file mode 100755 index 0000000..655225d --- /dev/null +++ b/web/build.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -e +cd "$(dirname "$0")" + +# Install esbuild if not present +if ! command -v esbuild >/dev/null 2>&1; then + if command -v npx >/dev/null 2>&1; then + NPX="npx" + else + echo "esbuild not found. Install it: npm install -g esbuild" + exit 1 + fi +else + NPX="" +fi + +mkdir -p dist + +# Build JS bundle +${NPX:+$NPX} esbuild src/app.jsx \ + --bundle \ + --minify \ + --jsx-factory=h \ + --jsx-fragment=Fragment \ + --define:process.env.NODE_ENV=\"production\" \ + --external:preact \ + --outfile=dist/app.js \ + 2>/dev/null || \ +${NPX:+$NPX} esbuild src/app.jsx \ + --bundle \ + --minify \ + --jsx-factory=h \ + --jsx-fragment=Fragment \ + --define:process.env.NODE_ENV=\"production\" \ + --outfile=dist/app.js + +# Copy static files +cp src/index.html dist/index.html +cp src/style.css dist/style.css + +echo "Build complete: web/dist/" diff --git a/web/dist/app.js b/web/dist/app.js new file mode 100644 index 0000000..2a5d789 --- /dev/null +++ b/web/dist/app.js @@ -0,0 +1 @@ +(()=>{var te,b,Ce,Ge,O,ge,Se,xe,Te,ae,ie,se,Qe,q={},Ee=[],Xe=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,ne=Array.isArray;function U(e,t){for(var n in t)e[n]=t[n];return e}function le(e){e&&e.parentNode&&e.parentNode.removeChild(e)}function m(e,t,n){var _,i,o,s={};for(o in t)o=="key"?_=t[o]:o=="ref"?i=t[o]:s[o]=t[o];if(arguments.length>2&&(s.children=arguments.length>3?te.call(arguments,2):n),typeof e=="function"&&e.defaultProps!=null)for(o in e.defaultProps)s[o]===void 0&&(s[o]=e.defaultProps[o]);return Y(e,s,_,i,null)}function Y(e,t,n,_,i){var o={type:e,props:t,key:n,ref:_,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:i??++Ce,__i:-1,__u:0};return i==null&&b.vnode!=null&&b.vnode(o),o}function _e(e){return e.children}function Z(e,t){this.props=e,this.context=t}function j(e,t){if(t==null)return e.__?j(e.__,e.__i+1):null;for(var n;tl&&O.sort(xe),e=O.shift(),l=O.length,e.__d&&(n=void 0,_=void 0,i=(_=(t=e).__v).__e,o=[],s=[],t.__P&&((n=U({},_)).__v=_.__v+1,b.vnode&&b.vnode(n),ue(t.__P,n,_,t.__n,t.__P.namespaceURI,32&_.__u?[i]:null,o,i??j(_),!!(32&_.__u),s),n.__v=_.__v,n.__.__k[n.__i]=n,He(o,n,s),_.__e=_.__=null,n.__e!=i&&Pe(n)));ee.__r=0}function Ie(e,t,n,_,i,o,s,l,d,c,h){var r,u,f,k,P,w,g,y=_&&_.__k||Ee,D=t.length;for(d=Ye(n,t,y,d,D),r=0;r0?s=e.__k[o]=Y(s.type,s.props,s.key,s.ref?s.ref:null,s.__v):e.__k[o]=s,d=o+u,s.__=e,s.__b=e.__b+1,l=null,(c=s.__i=Ze(s,n,d,r))!=-1&&(r--,(l=n[c])&&(l.__u|=2)),l==null||l.__v==null?(c==-1&&(i>h?u--:id?u--:u++,s.__u|=4))):e.__k[o]=null;if(r)for(o=0;o(h?1:0)){for(i=n-1,o=n+1;i>=0||o=0?i--:o++])!=null&&(2&c.__u)==0&&l==c.key&&d==c.type)return s}return-1}function ke(e,t,n){t[0]=="-"?e.setProperty(t,n??""):e[t]=n==null?"":typeof n!="number"||Xe.test(t)?n:n+"px"}function X(e,t,n,_,i){var o,s;e:if(t=="style")if(typeof n=="string")e.style.cssText=n;else{if(typeof _=="string"&&(e.style.cssText=_=""),_)for(t in _)n&&t in n||ke(e.style,t,"");if(n)for(t in n)_&&n[t]==_[t]||ke(e.style,t,n[t])}else if(t[0]=="o"&&t[1]=="n")o=t!=(t=t.replace(Te,"$1")),s=t.toLowerCase(),t=s in e||t=="onFocusOut"||t=="onFocusIn"?s.slice(2):t.slice(2),e.l||(e.l={}),e.l[t+o]=n,n?_?n.u=_.u:(n.u=ae,e.addEventListener(t,o?se:ie,o)):e.removeEventListener(t,o?se:ie,o);else{if(i=="http://www.w3.org/2000/svg")t=t.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if(t!="width"&&t!="height"&&t!="href"&&t!="list"&&t!="form"&&t!="tabIndex"&&t!="download"&&t!="rowSpan"&&t!="colSpan"&&t!="role"&&t!="popover"&&t in e)try{e[t]=n??"";break e}catch{}typeof n=="function"||(n==null||n===!1&&t[4]!="-"?e.removeAttribute(t):e.setAttribute(t,t=="popover"&&n==1?"":n))}}function we(e){return function(t){if(this.l){var n=this.l[t.type+e];if(t.t==null)t.t=ae++;else if(t.t0?e:ne(e)?e.map(De):U({},e)}function et(e,t,n,_,i,o,s,l,d){var c,h,r,u,f,k,P,w=n.props||q,g=t.props,y=t.type;if(y=="svg"?i="http://www.w3.org/2000/svg":y=="math"?i="http://www.w3.org/1998/Math/MathML":i||(i="http://www.w3.org/1999/xhtml"),o!=null){for(c=0;c=n.__.length&&n.__.push({}),n.__[e]}function I(e){return K=1,nt(qe,e)}function nt(e,t,n){var _=he(z++,2);if(_.t=e,!_.__c&&(_.__=[n?n(t):qe(void 0,t),function(l){var d=_.__N?_.__N[0]:_.__[0],c=_.t(d,l);d!==c&&(_.__N=[c,_.__[1]],_.__c.setState({}))}],_.__c=S,!S.__f)){var i=function(l,d,c){if(!_.__c.__H)return!0;var h=_.__c.__H.__.filter(function(u){return!!u.__c});if(h.every(function(u){return!u.__N}))return!o||o.call(this,l,d,c);var r=_.__c.props!==l;return h.forEach(function(u){if(u.__N){var f=u.__[0];u.__=u.__N,u.__N=void 0,f!==u.__[0]&&(r=!0)}}),o&&o.call(this,l,d,c)||r};S.__f=!0;var o=S.shouldComponentUpdate,s=S.componentWillUpdate;S.componentWillUpdate=function(l,d,c){if(this.__e){var h=o;o=void 0,i(l,d,c),o=h}s&&s.call(this,l,d,c)},S.shouldComponentUpdate=i}return _.__N||_.__}function W(e,t){var n=he(z++,3);!x.__s&&Ve(n.__H,t)&&(n.__=e,n.u=t,S.__H.__h.push(n))}function G(e){return K=5,Be(function(){return{current:e}},[])}function Be(e,t){var n=he(z++,7);return Ve(n.__H,t)&&(n.__=e(),n.__H=t,n.__h=e),n.__}function oe(e,t){return K=8,Be(function(){return e},t)}function _t(){for(var e;e=Je.shift();)if(e.__P&&e.__H)try{e.__H.__h.forEach(re),e.__H.__h.forEach(de),e.__H.__h=[]}catch(t){e.__H.__h=[],x.__e(t,e.__v)}}x.__b=function(e){S=null,Ue&&Ue(e)},x.__=function(e,t){e&&t.__k&&t.__k.__m&&(e.__m=t.__k.__m),We&&We(e,t)},x.__r=function(e){Le&&Le(e),z=0;var t=(S=e.__c).__H;t&&(pe===S?(t.__h=[],S.__h=[],t.__.forEach(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(t.__h.forEach(re),t.__h.forEach(de),t.__h=[],z=0)),pe=S},x.diffed=function(e){Fe&&Fe(e);var t=e.__c;t&&t.__H&&(t.__H.__h.length&&(Je.push(t)!==1&&Ae===x.requestAnimationFrame||((Ae=x.requestAnimationFrame)||rt)(_t)),t.__H.__.forEach(function(n){n.u&&(n.__H=n.u),n.u=void 0})),pe=S=null},x.__c=function(e,t){t.some(function(n){try{n.__h.forEach(re),n.__h=n.__h.filter(function(_){return!_.__||de(_)})}catch(_){t.some(function(i){i.__h&&(i.__h=[])}),t=[],x.__e(_,n.__v)}}),Oe&&Oe(e,t)},x.unmount=function(e){je&&je(e);var t,n=e.__c;n&&n.__H&&(n.__H.__.forEach(function(_){try{re(_)}catch(i){t=i}}),n.__H=void 0,t&&x.__e(t,n.__v))};var Re=typeof requestAnimationFrame=="function";function rt(e){var t,n=function(){clearTimeout(_),Re&&cancelAnimationFrame(t),setTimeout(e)},_=setTimeout(n,35);Re&&(t=requestAnimationFrame(n))}function re(e){var t=S,n=e.__c;typeof n=="function"&&(e.__c=void 0,n()),S=t}function de(e){var t=S;e.__c=e.__(),S=t}function Ve(e,t){return!e||e.length!==t.length||t.some(function(n,_){return n!==e[_]})}function qe(e,t){return typeof t=="function"?t(e):t}var ot="/api/v1";function H(e,t={}){let n=localStorage.getItem("chat_token"),_={"Content-Type":"application/json",...t.headers||{}};return n&&(_.Authorization=`Bearer ${n}`),fetch(ot+e,{...t,headers:_}).then(async i=>{let o=await i.json().catch(()=>null);if(!i.ok)throw{status:i.status,data:o};return o})}function it(e){return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}function Ke(e){let t=0;for(let _=0;_{H("/server").then(u=>{u.name&&d(u.name),u.motd&&s(u.motd)}).catch(()=>{});let r=localStorage.getItem("chat_token");r&&H("/me").then(u=>e(u.nick,r)).catch(()=>localStorage.removeItem("chat_token")),c.current?.focus()},[]),m("div",{class:"login-screen"},m("h1",null,l),o&&m("div",{class:"motd"},o),m("form",{onSubmit:async r=>{r.preventDefault(),i("");try{let u=await H("/register",{method:"POST",body:JSON.stringify({nick:t.trim()})});localStorage.setItem("chat_token",u.token),e(u.nick,u.token)}catch(u){i(u.data?.error||"Connection failed")}}},m("input",{ref:c,type:"text",placeholder:"Choose a nickname...",value:t,onInput:r=>n(r.target.value),maxLength:32,autoFocus:!0}),m("button",{type:"submit"},"Connect")),_&&m("div",{class:"error"},_))}function ze({msg:e}){return m("div",{class:`message ${e.system?"system":""}`},m("span",{class:"timestamp"},it(e.createdAt)),m("span",{class:"nick",style:{color:e.system?void 0:Ke(e.nick)}},e.nick),m("span",{class:"content"},e.content))}function ct(){let[e,t]=I(!1),[n,_]=I(""),[i,o]=I([{type:"server",name:"Server"}]),[s,l]=I(0),[d,c]=I({server:[]}),[h,r]=I({}),[u,f]=I(""),[k,P]=I(""),[w,g]=I(0),y=G(),D=G(),N=G(),$=oe((a,p)=>{c(v=>({...v,[a]:[...v[a]||[],p]}))},[]),E=oe((a,p)=>{$(a,{id:Date.now(),nick:"*",content:p,createdAt:new Date().toISOString(),system:!0})},[$]),Q=oe((a,p)=>{_(a),t(!0),E("server",`Connected as ${a}`),H("/server").then(v=>{v.motd&&E("server",`MOTD: ${v.motd}`)}).catch(()=>{})},[E]);W(()=>{if(!e)return;let a=!0,p=async()=>{try{let v=await H(`/poll?after=${w}`);if(!a)return;let T=w;for(let C of v)if(C.id>T&&(T=C.id),C.isDm){let B=C.nick===n?C.dmTarget:C.nick;o(V=>V.find(ye=>ye.type==="dm"&&ye.name===B)?V:[...V,{type:"dm",name:B}]),$(B,C)}else C.channel&&$(C.channel,C);T>w&&g(T)}catch{}};return N.current=setInterval(p,1500),p(),()=>{a=!1,clearInterval(N.current)}},[e,w,n,$]),W(()=>{if(!e)return;let a=i[s];if(!a||a.type!=="channel")return;let p=a.name.replace("#","");H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{});let v=setInterval(()=>{H(`/channels/${p}/members`).then(T=>{r(C=>({...C,[a.name]:T}))}).catch(()=>{})},5e3);return()=>clearInterval(v)},[e,s,i]),W(()=>{y.current?.scrollIntoView({behavior:"smooth"})},[d,s]),W(()=>{D.current?.focus()},[s]);let L=async a=>{if(a){a=a.trim(),a.startsWith("#")||(a="#"+a);try{await H("/channels/join",{method:"POST",body:JSON.stringify({channel:a})}),o(p=>p.find(v=>v.type==="channel"&&v.name===a)?p:[...p,{type:"channel",name:a}]),l(i.length),E(a,`Joined ${a}`),P("")}catch(p){E("server",`Failed to join ${a}: ${p.data?.error||"error"}`)}}},F=async a=>{let p=a.replace("#","");try{await H(`/channels/${p}/part`,{method:"DELETE"})}catch{}o(v=>v.filter(C=>!(C.type==="channel"&&C.name===a))),l(0)},R=a=>{let p=i[a];p.type==="channel"?F(p.name):p.type==="dm"&&(o(v=>v.filter((T,C)=>C!==a)),s>=a&&l(Math.max(0,s-1)))},M=a=>{o(p=>p.find(v=>v.type==="dm"&&v.name===a)?p:[...p,{type:"dm",name:a}]),l(i.findIndex(p=>p.type==="dm"&&p.name===a)||i.length)},A=async()=>{let a=u.trim();if(!a)return;f("");let p=i[s];if(!(!p||p.type==="server")){if(a.startsWith("/")){let v=a.split(" "),T=v[0].toLowerCase();if(T==="/join"&&v[1]){L(v[1]);return}if(T==="/part"){p.type==="channel"&&F(p.name);return}if(T==="/msg"&&v[1]&&v.slice(2).join(" ")){let C=v[1],B=v.slice(2).join(" ");try{await H(`/dm/${C}/messages`,{method:"POST",body:JSON.stringify({content:B})}),M(C)}catch(V){E("server",`Failed to send DM: ${V.data?.error||"error"}`)}return}if(T==="/nick"){E("server","Nick changes not yet supported");return}E("server",`Unknown command: ${T}`);return}if(p.type==="channel"){let v=p.name.replace("#","");try{await H(`/channels/${v}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(T){E(p.name,`Send failed: ${T.data?.error||"error"}`)}}else if(p.type==="dm")try{await H(`/dm/${p.name}/messages`,{method:"POST",body:JSON.stringify({content:a})})}catch(v){E(p.name,`Send failed: ${v.data?.error||"error"}`)}}};if(!e)return m(st,{onLogin:Q});let J=i[s]||i[0],me=d[J.name]||[],ve=h[J.name]||[];return m("div",{class:"app"},m("div",{class:"tab-bar"},i.map((a,p)=>m("div",{class:`tab ${p===s?"active":""}`,onClick:()=>l(p)},a.type==="dm"?`\u2192${a.name}`:a.name,a.type!=="server"&&m("span",{class:"close-btn",onClick:v=>{v.stopPropagation(),R(p)}},"\xD7"))),m("div",{class:"join-dialog"},m("input",{placeholder:"#channel",value:k,onInput:a=>P(a.target.value),onKeyDown:a=>a.key==="Enter"&&L(k)}),m("button",{onClick:()=>L(k)},"Join"))),m("div",{class:"content"},m("div",{class:"messages-pane"},J.type==="server"?m("div",{class:"server-messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})):m(Fragment,null,m("div",{class:"messages"},me.map(a=>m(ze,{msg:a})),m("div",{ref:y})),m("div",{class:"input-bar"},m("input",{ref:D,placeholder:`Message ${J.name}...`,value:u,onInput:a=>f(a.target.value),onKeyDown:a=>a.key==="Enter"&&A()}),m("button",{onClick:A},"Send")))),J.type==="channel"&&m("div",{class:"user-list"},m("h3",null,"Users (",ve.length,")"),ve.map(a=>m("div",{class:"user",onClick:()=>M(a.nick),style:{color:Ke(a.nick)}},a.nick)))))}$e(m(ct,null),document.getElementById("root"));})(); diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/dist/style.css b/web/dist/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/dist/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..0daf0e2 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,9 @@ +// Package web embeds the built SPA static files. +package web + +import "embed" + +// Dist contains the built web client files. +// +//go:embed dist/* +var Dist embed.FS diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f45ba3e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,513 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "preact": "^10.28.3" + }, + "devDependencies": { + "esbuild": "^0.27.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/preact": { + "version": "10.28.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz", + "integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..52e2446 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "^0.27.3" + }, + "dependencies": { + "preact": "^10.28.3" + } +} diff --git a/web/src/app.jsx b/web/src/app.jsx new file mode 100644 index 0000000..bf4c9a7 --- /dev/null +++ b/web/src/app.jsx @@ -0,0 +1,374 @@ +import { h, render, Component } from 'preact'; +import { useState, useEffect, useRef, useCallback } from 'preact/hooks'; + +const API = '/api/v1'; + +function api(path, opts = {}) { + const token = localStorage.getItem('chat_token'); + const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(API + path, { ...opts, headers }).then(async r => { + const data = await r.json().catch(() => null); + if (!r.ok) throw { status: r.status, data }; + return data; + }); +} + +function formatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +// Nick color hashing +function nickColor(nick) { + let h = 0; + for (let i = 0; i < nick.length; i++) h = nick.charCodeAt(i) + ((h << 5) - h); + const hue = Math.abs(h) % 360; + return `hsl(${hue}, 70%, 65%)`; +} + +function LoginScreen({ onLogin }) { + const [nick, setNick] = useState(''); + const [error, setError] = useState(''); + const [motd, setMotd] = useState(''); + const [serverName, setServerName] = useState('Chat'); + const inputRef = useRef(); + + useEffect(() => { + api('/server').then(s => { + if (s.name) setServerName(s.name); + if (s.motd) setMotd(s.motd); + }).catch(() => {}); + // Check for saved token + const saved = localStorage.getItem('chat_token'); + if (saved) { + api('/me').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); + } + inputRef.current?.focus(); + }, []); + + const submit = async (e) => { + e.preventDefault(); + setError(''); + try { + const res = await api('/register', { + method: 'POST', + body: JSON.stringify({ nick: nick.trim() }) + }); + localStorage.setItem('chat_token', res.token); + onLogin(res.nick, res.token); + } catch (err) { + setError(err.data?.error || 'Connection failed'); + } + }; + + return ( + + ); +} + +function Message({ msg }) { + return ( +
+ {formatTime(msg.createdAt)} + {msg.nick} + {msg.content} +
+ ); +} + +function App() { + const [loggedIn, setLoggedIn] = useState(false); + const [nick, setNick] = useState(''); + const [tabs, setTabs] = useState([{ type: 'server', name: 'Server' }]); + const [activeTab, setActiveTab] = useState(0); + const [messages, setMessages] = useState({ server: [] }); // keyed by tab name + const [members, setMembers] = useState({}); // keyed by channel name + const [input, setInput] = useState(''); + const [joinInput, setJoinInput] = useState(''); + const [lastMsgId, setLastMsgId] = useState(0); + const messagesEndRef = useRef(); + const inputRef = useRef(); + const pollRef = useRef(); + + const addMessage = useCallback((tabName, msg) => { + setMessages(prev => ({ + ...prev, + [tabName]: [...(prev[tabName] || []), msg] + })); + }, []); + + const addSystemMessage = useCallback((tabName, text) => { + addMessage(tabName, { + id: Date.now(), + nick: '*', + content: text, + createdAt: new Date().toISOString(), + system: true + }); + }, [addMessage]); + + const onLogin = useCallback((userNick, token) => { + setNick(userNick); + setLoggedIn(true); + addSystemMessage('server', `Connected as ${userNick}`); + // Fetch server info + api('/server').then(s => { + if (s.motd) addSystemMessage('server', `MOTD: ${s.motd}`); + }).catch(() => {}); + }, [addSystemMessage]); + + // Poll for new messages + useEffect(() => { + if (!loggedIn) return; + let alive = true; + const poll = async () => { + try { + const msgs = await api(`/poll?after=${lastMsgId}`); + if (!alive) return; + let maxId = lastMsgId; + for (const msg of msgs) { + if (msg.id > maxId) maxId = msg.id; + if (msg.isDm) { + const dmTab = msg.nick === nick ? msg.dmTarget : msg.nick; + // Ensure DM tab exists + setTabs(prev => { + if (!prev.find(t => t.type === 'dm' && t.name === dmTab)) { + return [...prev, { type: 'dm', name: dmTab }]; + } + return prev; + }); + addMessage(dmTab, msg); + } else if (msg.channel) { + addMessage(msg.channel, msg); + } + } + if (maxId > lastMsgId) setLastMsgId(maxId); + } catch (err) { + // silent + } + }; + pollRef.current = setInterval(poll, 1500); + poll(); + return () => { alive = false; clearInterval(pollRef.current); }; + }, [loggedIn, lastMsgId, nick, addMessage]); + + // Fetch members for active channel tab + useEffect(() => { + if (!loggedIn) return; + const tab = tabs[activeTab]; + if (!tab || tab.type !== 'channel') return; + const chName = tab.name.replace('#', ''); + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + const iv = setInterval(() => { + api(`/channels/${chName}/members`).then(m => { + setMembers(prev => ({ ...prev, [tab.name]: m })); + }).catch(() => {}); + }, 5000); + return () => clearInterval(iv); + }, [loggedIn, activeTab, tabs]); + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, activeTab]); + + // Focus input on tab change + useEffect(() => { + inputRef.current?.focus(); + }, [activeTab]); + + const joinChannel = async (name) => { + if (!name) return; + name = name.trim(); + if (!name.startsWith('#')) name = '#' + name; + try { + await api('/channels/join', { method: 'POST', body: JSON.stringify({ channel: name }) }); + setTabs(prev => { + if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; + return [...prev, { type: 'channel', name }]; + }); + setActiveTab(tabs.length); // switch to new tab + addSystemMessage(name, `Joined ${name}`); + setJoinInput(''); + } catch (err) { + addSystemMessage('server', `Failed to join ${name}: ${err.data?.error || 'error'}`); + } + }; + + const partChannel = async (name) => { + const chName = name.replace('#', ''); + try { + await api(`/channels/${chName}/part`, { method: 'DELETE' }); + } catch (err) { /* ignore */ } + setTabs(prev => { + const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); + return next; + }); + setActiveTab(0); + }; + + const closeTab = (idx) => { + const tab = tabs[idx]; + if (tab.type === 'channel') { + partChannel(tab.name); + } else if (tab.type === 'dm') { + setTabs(prev => prev.filter((_, i) => i !== idx)); + if (activeTab >= idx) setActiveTab(Math.max(0, activeTab - 1)); + } + }; + + const openDM = (targetNick) => { + setTabs(prev => { + if (prev.find(t => t.type === 'dm' && t.name === targetNick)) return prev; + return [...prev, { type: 'dm', name: targetNick }]; + }); + setActiveTab(tabs.findIndex(t => t.type === 'dm' && t.name === targetNick) || tabs.length); + }; + + const sendMessage = async () => { + const text = input.trim(); + if (!text) return; + setInput(''); + const tab = tabs[activeTab]; + if (!tab || tab.type === 'server') return; + + // Handle /commands + if (text.startsWith('/')) { + const parts = text.split(' '); + const cmd = parts[0].toLowerCase(); + if (cmd === '/join' && parts[1]) { + joinChannel(parts[1]); + return; + } + if (cmd === '/part') { + if (tab.type === 'channel') partChannel(tab.name); + return; + } + if (cmd === '/msg' && parts[1] && parts.slice(2).join(' ')) { + const target = parts[1]; + const msg = parts.slice(2).join(' '); + try { + await api(`/dm/${target}/messages`, { method: 'POST', body: JSON.stringify({ content: msg }) }); + openDM(target); + } catch (err) { + addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); + } + return; + } + if (cmd === '/nick') { + addSystemMessage('server', 'Nick changes not yet supported'); + return; + } + addSystemMessage('server', `Unknown command: ${cmd}`); + return; + } + + if (tab.type === 'channel') { + const chName = tab.name.replace('#', ''); + try { + await api(`/channels/${chName}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + } + } else if (tab.type === 'dm') { + try { + await api(`/dm/${tab.name}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); + } + } + }; + + if (!loggedIn) return ; + + const currentTab = tabs[activeTab] || tabs[0]; + const currentMessages = messages[currentTab.name] || []; + const currentMembers = members[currentTab.name] || []; + + return ( +
+
+ {tabs.map((tab, i) => ( +
setActiveTab(i)} + > + {tab.type === 'dm' ? `→${tab.name}` : tab.name} + {tab.type !== 'server' && ( + { e.stopPropagation(); closeTab(i); }}>× + )} +
+ ))} +
+ setJoinInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && joinChannel(joinInput)} + /> + +
+
+ +
+
+ {currentTab.type === 'server' ? ( +
+ {currentMessages.map(m => )} +
+
+ ) : ( + <> +
+ {currentMessages.map(m => )} +
+
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && sendMessage()} + /> + +
+ + )} +
+ + {currentTab.type === 'channel' && ( +
+

Users ({currentMembers.length})

+ {currentMembers.map(u => ( +
openDM(u.nick)} style={{ color: nickColor(u.nick) }}> + {u.nick} +
+ ))} +
+ )} +
+
+ ); +} + +render(, document.getElementById('root')); diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..fee4d65 --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,13 @@ + + + + + + Chat + + + +
+ + + diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..929111c --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,274 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +:root { + --bg: #1a1a2e; + --bg-secondary: #16213e; + --bg-input: #0f3460; + --text: #e0e0e0; + --text-muted: #888; + --accent: #e94560; + --accent2: #0f3460; + --border: #2a2a4a; + --nick: #53a8b6; + --timestamp: #666; + --tab-active: #e94560; + --tab-bg: #16213e; + --tab-hover: #1a1a3e; +} + +html, body, #root { + height: 100%; + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + background: var(--bg); + color: var(--text); +} + +/* Login screen */ +.login-screen { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; +} + +.login-screen h1 { + color: var(--accent); + font-size: 2em; +} + +.login-screen input { + padding: 10px 16px; + font-size: 16px; + font-family: inherit; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + width: 280px; +} + +.login-screen button { + padding: 10px 24px; + font-size: 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + border-radius: 4px; + cursor: pointer; +} + +.login-screen .error { + color: var(--accent); +} + +.login-screen .motd { + color: var(--text-muted); + max-width: 400px; + text-align: center; + white-space: pre-wrap; +} + +/* Main layout */ +.app { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Tab bar */ +.tab-bar { + display: flex; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + color: var(--text-muted); + user-select: none; +} + +.tab:hover { + background: var(--tab-hover); +} + +.tab.active { + color: var(--text); + border-bottom-color: var(--tab-active); +} + +.tab .close-btn { + margin-left: 8px; + color: var(--text-muted); + font-size: 12px; +} + +.tab .close-btn:hover { + color: var(--accent); +} + +/* Content area */ +.content { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Messages */ +.messages-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 8px 12px; +} + +.message { + padding: 2px 0; + line-height: 1.4; + word-wrap: break-word; +} + +.message .timestamp { + color: var(--timestamp); + font-size: 12px; + margin-right: 8px; +} + +.message .nick { + color: var(--nick); + font-weight: bold; + margin-right: 8px; +} + +.message .nick::before { content: '<'; } +.message .nick::after { content: '>'; } + +.message.system { + color: var(--text-muted); + font-style: italic; +} + +.message.system .nick { + color: var(--text-muted); +} + +.message.system .nick::before, +.message.system .nick::after { content: ''; } + +/* Input */ +.input-bar { + display: flex; + border-top: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.input-bar input { + flex: 1; + padding: 10px 12px; + font-family: inherit; + font-size: 14px; + background: var(--bg-input); + border: none; + color: var(--text); + outline: none; +} + +.input-bar button { + padding: 10px 16px; + font-family: inherit; + background: var(--accent); + border: none; + color: white; + cursor: pointer; +} + +/* User list */ +.user-list { + width: 160px; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + flex-shrink: 0; +} + +.user-list h3 { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + margin-bottom: 8px; + letter-spacing: 1px; +} + +.user-list .user { + padding: 3px 4px; + color: var(--nick); + font-size: 13px; + cursor: pointer; +} + +.user-list .user:hover { + background: var(--tab-hover); +} + +/* Server tab */ +.server-messages { + color: var(--text-muted); + padding: 12px; + white-space: pre-wrap; + overflow-y: auto; + flex: 1; +} + +/* Channel join dialog */ +.join-dialog { + padding: 12px; + display: flex; + gap: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.join-dialog input { + padding: 6px 10px; + font-family: inherit; + font-size: 13px; + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text); + border-radius: 3px; + width: 200px; +} + +.join-dialog button { + padding: 6px 14px; + font-family: inherit; + font-size: 13px; + background: var(--accent2); + border: none; + color: var(--text); + border-radius: 3px; + cursor: pointer; +} + +/* Responsive */ +@media (max-width: 600px) { + .user-list { display: none; } + .tab { padding: 6px 10px; font-size: 13px; } +} -- 2.49.1 From 7361e8bd9b3a1b696dd71fbd1f81326ffd183588 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:20:00 -0800 Subject: [PATCH 02/22] refactor: merge /me + /channels into /state, unify message endpoints - HandleState returns user info (id, nick) + joined channels in one response - HandleGetMessages now serves unified message stream (was HandlePoll) - HandleSendMessage accepts 'to' field for routing to #channel or nick - HandleGetHistory supports scrollback for channels and DMs - Remove separate HandleMe, HandleListChannels, HandleSendDM, HandleGetDMs, HandlePoll --- internal/db/queries.go | 97 ++++++++++++++ internal/handlers/api.go | 265 ++++++++++++++++++--------------------- 2 files changed, 220 insertions(+), 142 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index af7b83b..b051617 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -275,6 +275,103 @@ func (s *Database) PollMessages(ctx context.Context, userID int64, afterID int64 return msgs, nil } +// GetMessagesBefore returns channel messages before a given ID (for history scrollback). +func (s *Database) GetMessagesBefore(ctx context.Context, channelID int64, beforeID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + var query string + var args []any + if beforeID > 0 { + query = `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 AND m.id < ? + ORDER BY m.id DESC LIMIT ?` + args = []any{channelID, beforeID, limit} + } else { + query = `SELECT m.id, c.name, u.nick, m.content, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN channels c ON c.id = m.channel_id + WHERE m.channel_id = ? AND m.is_dm = 0 + ORDER BY m.id DESC LIMIT ?` + args = []any{channelID, limit} + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Channel, &m.Nick, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + // Reverse to ascending order + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + return msgs, nil +} + +// GetDMsBefore returns DMs between two users before a given ID (for history scrollback). +func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeID int64, limit int) ([]MessageInfo, error) { + if limit <= 0 { + limit = 50 + } + var query string + var args []any + if beforeID > 0 { + query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 AND m.id < ? + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id DESC LIMIT ?` + args = []any{beforeID, userA, userB, userB, userA, limit} + } else { + query = `SELECT m.id, u.nick, m.content, t.nick, m.created_at + FROM messages m + INNER JOIN users u ON u.id = m.user_id + INNER JOIN users t ON t.id = m.dm_target_id + WHERE m.is_dm = 1 + AND ((m.user_id = ? AND m.dm_target_id = ?) OR (m.user_id = ? AND m.dm_target_id = ?)) + ORDER BY m.id DESC LIMIT ?` + args = []any{userA, userB, userB, userA, limit} + } + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var msgs []MessageInfo + for rows.Next() { + var m MessageInfo + if err := rows.Scan(&m.ID, &m.Nick, &m.Content, &m.DMTarget, &m.CreatedAt); err != nil { + return nil, err + } + m.IsDM = true + msgs = append(msgs, m) + } + if msgs == nil { + msgs = []MessageInfo{} + } + // Reverse to ascending order + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + return msgs, nil +} + // GetMOTD returns the server MOTD from config. func (s *Database) GetServerName() string { return "" diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 22487d4..5624f40 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "git.eeqj.de/sneak/chat/internal/db" "github.com/go-chi/chi" ) @@ -64,35 +65,25 @@ func (s *Handlers) HandleRegister() http.HandlerFunc { } } -// HandleMe returns the current user's info. -func (s *Handlers) HandleMe() http.HandlerFunc { +// HandleState returns the current user's info and joined channels. +func (s *Handlers) HandleState() http.HandlerFunc { type response struct { - ID int64 `json:"id"` - Nick string `json:"nick"` + ID int64 `json:"id"` + Nick string `json:"nick"` + Channels []db.ChannelInfo `json:"channels"` } return func(w http.ResponseWriter, r *http.Request) { uid, nick, ok := s.requireAuth(w, r) if !ok { return } - s.respondJSON(w, r, &response{ID: uid, Nick: nick}, http.StatusOK) - } -} - -// HandleListChannels returns channels the user has joined. -func (s *Handlers) HandleListChannels() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } channels, err := s.params.Database.ListChannels(r.Context(), uid) if err != nil { s.log.Error("list channels failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) return } - s.respondJSON(w, r, channels, http.StatusOK) + s.respondJSON(w, r, &response{ID: uid, Nick: nick, Channels: channels}, http.StatusOK) } } @@ -200,132 +191,9 @@ func (s *Handlers) HandleChannelMembers() http.HandlerFunc { } } -// HandleGetMessages returns messages for a channel. +// HandleGetMessages returns all new messages (channel + DM) for the user via long-polling. +// This is the single unified message stream — replaces separate channel/DM/poll endpoints. func (s *Handlers) HandleGetMessages() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, _, ok := s.requireAuth(w, r) - if !ok { - return - } - name := "#" + chi.URLParam(r, "channel") - var chID int64 - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - return - } - afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - msgs, err := s.params.Database.GetMessages(r.Context(), chID, afterID, limit) - if err != nil { - s.log.Error("get messages failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, msgs, http.StatusOK) - } -} - -// HandleSendMessage sends a message to a channel. -func (s *Handlers) HandleSendMessage() http.HandlerFunc { - type request struct { - Content string `json:"content"` - } - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - name := "#" + chi.URLParam(r, "channel") - var chID int64 - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - return - } - var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) - return - } - if strings.TrimSpace(req.Content) == "" { - s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) - return - } - msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, req.Content) - if err != nil { - s.log.Error("send message failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } -} - -// HandleSendDM sends a direct message to a user. -func (s *Handlers) HandleSendDM() http.HandlerFunc { - type request struct { - Content string `json:"content"` - } - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - targetNick := chi.URLParam(r, "nick") - targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) - return - } - var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) - return - } - if strings.TrimSpace(req.Content) == "" { - s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) - return - } - msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, req.Content) - if err != nil { - s.log.Error("send dm failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } -} - -// HandleGetDMs returns direct messages with a user. -func (s *Handlers) HandleGetDMs() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - targetNick := chi.URLParam(r, "nick") - targetID, err := s.params.Database.GetUserByNick(r.Context(), targetNick) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) - return - } - afterID, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - msgs, err := s.params.Database.GetDMs(r.Context(), uid, targetID, afterID, limit) - if err != nil { - s.log.Error("get dms failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, msgs, http.StatusOK) - } -} - -// HandlePoll returns all new messages (channels + DMs) for the user. -func (s *Handlers) HandlePoll() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { uid, _, ok := s.requireAuth(w, r) if !ok { @@ -335,7 +203,7 @@ func (s *Handlers) HandlePoll() http.HandlerFunc { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) msgs, err := s.params.Database.PollMessages(r.Context(), uid, afterID, limit) if err != nil { - s.log.Error("poll messages failed", "error", err) + s.log.Error("get messages failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) return } @@ -343,6 +211,119 @@ func (s *Handlers) HandlePoll() http.HandlerFunc { } } +// HandleSendMessage sends a message to a channel or user. +// The "to" field determines the target: "#channel" for channels, "nick" for DMs. +func (s *Handlers) HandleSendMessage() http.HandlerFunc { + type request struct { + To string `json:"to"` + Content string `json:"content"` + } + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Content) == "" { + s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) + return + } + req.To = strings.TrimSpace(req.To) + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + + if strings.HasPrefix(req.To, "#") { + // Channel message + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, req.Content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } else { + // DM + targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, req.Content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } + } +} + +// HandleGetHistory returns message history for a specific target (channel or DM). +func (s *Handlers) HandleGetHistory() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + uid, _, ok := s.requireAuth(w, r) + if !ok { + return + } + target := r.URL.Query().Get("target") + if target == "" { + s.respondJSON(w, r, map[string]string{"error": "target required"}, http.StatusBadRequest) + return + } + beforeID, _ := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 50 + } + + if strings.HasPrefix(target, "#") { + // Channel history + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", target).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + msgs, err := s.params.Database.GetMessagesBefore(r.Context(), chID, beforeID, limit) + if err != nil { + s.log.Error("get history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } else { + // DM history + targetID, err := s.params.Database.GetUserByNick(r.Context(), target) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + msgs, err := s.params.Database.GetDMsBefore(r.Context(), uid, targetID, beforeID, limit) + if err != nil { + s.log.Error("get dm history failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, msgs, http.StatusOK) + } + } +} + // HandleServerInfo returns server metadata (MOTD, name). func (s *Handlers) HandleServerInfo() http.HandlerFunc { type response struct { -- 2.49.1 From 74437b8372c3450edc71c93ab3ac0fc4069ac2d7 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:20:05 -0800 Subject: [PATCH 03/22] refactor: update routes for unified API endpoints - GET /api/v1/state replaces /me and /channels - GET/POST /api/v1/messages replaces /poll, /channels/{ch}/messages, /dm/{nick}/messages - GET /api/v1/history for scrollback - DELETE /api/v1/channels/{name} replaces /channels/{channel}/part --- internal/server/routes.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/internal/server/routes.go b/internal/server/routes.go index 228eb9d..e5ce9e9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -53,23 +53,18 @@ func (s *Server) SetupRoutes() { s.router.Route("/api/v1", func(r chi.Router) { r.Get("/server", s.h.HandleServerInfo()) r.Post("/register", s.h.HandleRegister()) - r.Get("/me", s.h.HandleMe()) + + // Unified state and message endpoints + r.Get("/state", s.h.HandleState()) + r.Get("/messages", s.h.HandleGetMessages()) + r.Post("/messages", s.h.HandleSendMessage()) + r.Get("/history", s.h.HandleGetHistory()) // Channels - r.Get("/channels", s.h.HandleListChannels()) r.Get("/channels/all", s.h.HandleListAllChannels()) r.Post("/channels/join", s.h.HandleJoinChannel()) - r.Delete("/channels/{channel}/part", s.h.HandlePartChannel()) + r.Delete("/channels/{channel}", s.h.HandlePartChannel()) r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) - r.Get("/channels/{channel}/messages", s.h.HandleGetMessages()) - r.Post("/channels/{channel}/messages", s.h.HandleSendMessage()) - - // DMs - r.Get("/dm/{nick}/messages", s.h.HandleGetDMs()) - r.Post("/dm/{nick}/messages", s.h.HandleSendDM()) - - // Polling - r.Get("/poll", s.h.HandlePoll()) }) // Serve embedded SPA -- 2.49.1 From aabf8e902c2400f021643d4745894b386310ef0b Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:20:09 -0800 Subject: [PATCH 04/22] feat: update web client for unified API endpoints - Use /state instead of /me for auth check - Use /messages instead of /poll for message stream - Use unified POST /messages with 'to' field for all sends - Update part channel URL to DELETE /channels/{name} --- web/src/app.jsx | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index bf4c9a7..2206972 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -42,7 +42,7 @@ function LoginScreen({ onLogin }) { // Check for saved token const saved = localStorage.getItem('chat_token'); if (saved) { - api('/me').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); + api('/state').then(u => onLogin(u.nick, saved)).catch(() => localStorage.removeItem('chat_token')); } inputRef.current?.focus(); }, []); @@ -140,7 +140,7 @@ function App() { let alive = true; const poll = async () => { try { - const msgs = await api(`/poll?after=${lastMsgId}`); + const msgs = await api(`/messages?after=${lastMsgId}`); if (!alive) return; let maxId = lastMsgId; for (const msg of msgs) { @@ -217,7 +217,7 @@ function App() { const partChannel = async (name) => { const chName = name.replace('#', ''); try { - await api(`/channels/${chName}/part`, { method: 'DELETE' }); + await api(`/channels/${chName}`, { method: 'DELETE' }); } catch (err) { /* ignore */ } setTabs(prev => { const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); @@ -267,7 +267,7 @@ function App() { const target = parts[1]; const msg = parts.slice(2).join(' '); try { - await api(`/dm/${target}/messages`, { method: 'POST', body: JSON.stringify({ content: msg }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ to: target, content: msg }) }); openDM(target); } catch (err) { addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); @@ -282,19 +282,11 @@ function App() { return; } - if (tab.type === 'channel') { - const chName = tab.name.replace('#', ''); - try { - await api(`/channels/${chName}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); - } catch (err) { - addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); - } - } else if (tab.type === 'dm') { - try { - await api(`/dm/${tab.name}/messages`, { method: 'POST', body: JSON.stringify({ content: text }) }); - } catch (err) { - addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); - } + const to = tab.type === 'channel' ? tab.name : tab.name; + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ to, content: text }) }); + } catch (err) { + addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); } }; -- 2.49.1 From 16e08c283964c3b9d17481c7c3df7bcf3bfe0bfe Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:20:13 -0800 Subject: [PATCH 05/22] docs: update README with IRC-inspired unified API design - Document simplified endpoint structure - Single message stream (GET /messages) for all message types - Unified send (POST /messages) with 'to' field - GET /state replaces separate /me and /channels - GET /history for scrollback - Update project status --- README.md | 62 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 57bb9d9..67bf231 100644 --- a/README.md +++ b/README.md @@ -87,48 +87,57 @@ Fields: ### API Endpoints -All endpoints accept and return `application/json`. +All endpoints accept and return `application/json`. Authenticated endpoints +require `Authorization: Bearer ` header. -#### Authentication +The API follows an IRC-inspired design: one unified message stream for all +message types (channel messages, DMs, server notices), with a simple `to` field +for addressing. + +#### Registration ``` -POST /api/v1/register — Create account (nick, password) → token -POST /api/v1/login — Authenticate → token -POST /api/v1/logout — Invalidate token +POST /api/v1/register — Create account { "nick": "..." } → { id, nick, token } ``` -#### Session & Messages +#### State ``` -GET /api/v1/messages — Retrieve queued messages (long-poll supported) - Query params: ?after=&timeout=30 -POST /api/v1/messages — Send a message or command -GET /api/v1/history — Retrieve channel/DM history - Query params: ?target=#channel&before=&limit=50 +GET /api/v1/state — User state: nick, id, and list of joined channels + Replaces separate /me and /channels endpoints +``` + +#### Messages (unified stream) + +``` +GET /api/v1/messages — Single message stream (long-poll supported) + All message types: channel, DM, notices, events + Query params: ?after=&timeout=30 +POST /api/v1/messages — Send a message + Body: { "to": "#channel" or "nick", "content": "..." } +``` + +#### History + +``` +GET /api/v1/history — Fetch history for a target (channel or DM) + Query params: ?target=#channel&before=&limit=50 + For DMs: ?target=nick&before=&limit=50 ``` #### Channels ``` -GET /api/v1/channels — List joined channels -POST /api/v1/channels/join — Join a channel -POST /api/v1/channels/part — Leave a channel -GET /api/v1/channels/{name} — Channel info (topic, members, modes) -POST /api/v1/channels/{name}/topic — Set channel topic -``` - -#### Users - -``` -GET /api/v1/users/me — Current user info -POST /api/v1/users/nick — Change nick -GET /api/v1/users/{nick} — User info (online status, idle time) +GET /api/v1/channels/all — List all server channels +POST /api/v1/channels/join — Join a channel { "channel": "#name" } +DELETE /api/v1/channels/{name} — Part (leave) a channel +GET /api/v1/channels/{name}/members — Channel member list ``` #### Server Info ``` -GET /api/v1/server — Server info (name, version, MOTD, user count) +GET /api/v1/server — Server info (name, MOTD) GET /.well-known/healthcheck.json — Health check ``` @@ -265,7 +274,8 @@ Per gohttpserver conventions: ## Status -**Design phase.** This README is the spec. Implementation has not started. +**Implementation in progress.** Core API is functional with SQLite storage and +embedded web client. ## License -- 2.49.1 From 6483670dc7f714a094c2f50d3f2bc0df95152fe3 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:21:10 -0800 Subject: [PATCH 06/22] docs: emphasize API-first design, add curl examples, note web client as reference impl --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 67bf231..5660b06 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ A modern IRC-inspired chat server written in Go. Decouples session state from transport connections, enabling mobile-friendly persistent sessions over HTTP. +The **HTTP API is the primary interface**. It's designed to be simple enough +that writing a terminal IRC-style client against it is straightforward — just +`curl` and `jq` get you surprisingly far. The server also ships an embedded +web client as a convenience/reference implementation, but the API comes first. + ## Motivation IRC is in decline because session state is tied to the TCP connection. In a @@ -12,7 +17,7 @@ or pay for IRCCloud. This project builds a chat server that: - Holds session state server-side (message queues, presence, channel membership) -- Delivers messages over HTTP (JSON-RPC style) +- Exposes a minimal, clean HTTP+JSON API — easy to build clients against - Supports multiple concurrent connections per user session - Provides IRC-like semantics: channels, nicks, topics, modes - Uses structured JSON messages with arbitrary extensibility @@ -24,12 +29,15 @@ This project builds a chat server that: All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. -- **Client polling**: Clients poll for new messages via `GET` with long-polling - support (server holds the connection open until messages arrive or timeout) -- **Client sending**: Clients send messages/commands via `POST` +- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds + the connection until messages arrive or timeout. One endpoint for everything. +- **Client sending**: `POST /api/v1/messages` with a `to` field. That's it. - **Server federation**: Servers exchange messages via HTTP to enable multi-server networks (like IRC server linking) +The entire read/write loop for a client is two endpoints. Everything else is +channel management and history. + ### Core Concepts #### Users @@ -90,9 +98,37 @@ Fields: All endpoints accept and return `application/json`. Authenticated endpoints require `Authorization: Bearer ` header. -The API follows an IRC-inspired design: one unified message stream for all -message types (channel messages, DMs, server notices), with a simple `to` field -for addressing. +The API is the primary interface — designed for IRC-style clients. The entire +client loop is: + +1. `POST /api/v1/register` — get a token +2. `GET /api/v1/state` — see who you are and what channels you're in +3. `GET /api/v1/messages?after=0` — long-poll for all messages (channel, DM, system) +4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"` + +That's the core. Everything else (join, part, history, members) is ancillary. + +#### Quick example (curl) + +```bash +# Register +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \ + -d '{"nick":"alice"}' | jq -r .token) + +# Join a channel +curl -s -X POST http://localhost:8080/api/v1/channels/join \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"channel":"#general"}' + +# Send a message +curl -s -X POST http://localhost:8080/api/v1/messages \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"to":"#general","content":"hello world"}' + +# Poll for messages (long-poll) +curl -s http://localhost:8080/api/v1/messages?after=0 \ + -H "Authorization: Bearer $TOKEN" +``` #### Registration @@ -259,18 +295,29 @@ Per gohttpserver conventions: | Metrics | `github.com/prometheus/client_golang` | | DB | `modernc.org/sqlite` + `database/sql` | +### Web Client + +The server embeds a single-page web client (Preact) served at `/`. This is a +**convenience/reference implementation** — not the primary interface. It +demonstrates the API and provides a quick way to test the server in a browser. + +The primary intended clients are IRC-style terminal applications and bots +talking directly to the HTTP API. + ### Design Principles -1. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol +1. **API-first** — the HTTP API is the product. Clients are thin. If you can't + build a working IRC-style TUI client in an afternoon, the API is too complex. +2. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere. -2. **Server holds state** — clients are stateless. Reconnect, switch devices, +3. **Server holds state** — clients are stateless. Reconnect, switch devices, lose connectivity — your messages are waiting. -3. **Structured messages** — JSON with extensible metadata. Enables signatures, +4. **Structured messages** — JSON with extensible metadata. Enables signatures, rich content, client extensions without protocol changes. -4. **Simple deployment** — single binary, SQLite default, zero mandatory +5. **Simple deployment** — single binary, SQLite default, zero mandatory external dependencies. -5. **No eternal logs** — history rotates. Chat should be ephemeral by default. -6. **Federation optional** — single server works standalone. Linking is opt-in. +6. **No eternal logs** — history rotates. Chat should be ephemeral by default. +7. **Federation optional** — single server works standalone. Linking is opt-in. ## Status -- 2.49.1 From 065b243def84b42c782d38b5dce6ef0ed1c18cb9 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 10 Feb 2026 10:25:42 -0800 Subject: [PATCH 07/22] docs: add JSON Schema definitions for all message types (draft 2020-12) C2S (7): send, join, part, nick, topic, mode, ping S2C (12): message, dm, notice, join, part, quit, nick, topic, mode, system, error, pong S2S (6): relay, link, unlink, sync, ping, pong Each message type has its own schema file under schema/{c2s,s2c,s2s}/. schema/README.md provides an index of all types with descriptions. --- schema/README.md | 80 +++++++++++++++++++++++++++++++++++++++++ schema/c2s/join.json | 17 +++++++++ schema/c2s/mode.json | 26 ++++++++++++++ schema/c2s/nick.json | 18 ++++++++++ schema/c2s/part.json | 22 ++++++++++++ schema/c2s/ping.json | 14 ++++++++ schema/c2s/send.json | 22 ++++++++++++ schema/c2s/topic.json | 21 +++++++++++ schema/s2c/dm.json | 37 +++++++++++++++++++ schema/s2c/error.json | 33 +++++++++++++++++ schema/s2c/join.json | 29 +++++++++++++++ schema/s2c/message.json | 40 +++++++++++++++++++++ schema/s2c/mode.json | 37 +++++++++++++++++++ schema/s2c/nick.json | 28 +++++++++++++++ schema/s2c/notice.json | 32 +++++++++++++++++ schema/s2c/part.json | 32 +++++++++++++++++ schema/s2c/pong.json | 24 +++++++++++++ schema/s2c/quit.json | 28 +++++++++++++++ schema/s2c/system.json | 29 +++++++++++++++ schema/s2c/topic.json | 32 +++++++++++++++++ schema/s2s/link.json | 41 +++++++++++++++++++++ schema/s2s/ping.json | 29 +++++++++++++++ schema/s2s/pong.json | 29 +++++++++++++++ schema/s2s/relay.json | 49 +++++++++++++++++++++++++ schema/s2s/sync.json | 69 +++++++++++++++++++++++++++++++++++ schema/s2s/unlink.json | 30 ++++++++++++++++ 26 files changed, 848 insertions(+) create mode 100644 schema/README.md create mode 100644 schema/c2s/join.json create mode 100644 schema/c2s/mode.json create mode 100644 schema/c2s/nick.json create mode 100644 schema/c2s/part.json create mode 100644 schema/c2s/ping.json create mode 100644 schema/c2s/send.json create mode 100644 schema/c2s/topic.json create mode 100644 schema/s2c/dm.json create mode 100644 schema/s2c/error.json create mode 100644 schema/s2c/join.json create mode 100644 schema/s2c/message.json create mode 100644 schema/s2c/mode.json create mode 100644 schema/s2c/nick.json create mode 100644 schema/s2c/notice.json create mode 100644 schema/s2c/part.json create mode 100644 schema/s2c/pong.json create mode 100644 schema/s2c/quit.json create mode 100644 schema/s2c/system.json create mode 100644 schema/s2c/topic.json create mode 100644 schema/s2s/link.json create mode 100644 schema/s2s/ping.json create mode 100644 schema/s2s/pong.json create mode 100644 schema/s2s/relay.json create mode 100644 schema/s2s/sync.json create mode 100644 schema/s2s/unlink.json diff --git a/schema/README.md b/schema/README.md new file mode 100644 index 0000000..25e2b8d --- /dev/null +++ b/schema/README.md @@ -0,0 +1,80 @@ +# Message Schema Index + +JSON Schema (draft 2020-12) definitions for the IRC-style message protocol. + +All messages share a common envelope defined in [`message.schema.json`](message.schema.json). + +## Base Envelope + +| Field | Type | Required | Description | +|-----------|-----------------|----------|-------------| +| `command` | string | ✓ | IRC command name or numeric reply code | +| `from` | string | | Sender nick or server name | +| `to` | string | | Destination channel or nick | +| `params` | array\ | | Additional IRC-style parameters | +| `body` | array \| object | varies | Message body (never a raw string) | +| `meta` | object | | Extensible metadata (signatures, etc.) | +| `id` | string (uuid) | | Server-assigned message ID | +| `ts` | string (date-time) | | Server-assigned timestamp | + +## Client-to-Server (C2S) + +| Command | Schema | Description | +|----------|--------|-------------| +| PRIVMSG | [`c2s/privmsg.schema.json`](c2s/privmsg.schema.json) | Send message to channel or user | +| NOTICE | [`c2s/notice.schema.json`](c2s/notice.schema.json) | Send a notice | +| JOIN | [`c2s/join.schema.json`](c2s/join.schema.json) | Join a channel | +| PART | [`c2s/part.schema.json`](c2s/part.schema.json) | Leave a channel | +| QUIT | [`c2s/quit.schema.json`](c2s/quit.schema.json) | Disconnect | +| NICK | [`c2s/nick.schema.json`](c2s/nick.schema.json) | Change nick | +| MODE | [`c2s/mode.schema.json`](c2s/mode.schema.json) | Set/query modes | +| TOPIC | [`c2s/topic.schema.json`](c2s/topic.schema.json) | Set/query topic | +| KICK | [`c2s/kick.schema.json`](c2s/kick.schema.json) | Kick user | +| PING | [`c2s/ping.schema.json`](c2s/ping.schema.json) | Client ping | +| PUBKEY | [`c2s/pubkey.schema.json`](c2s/pubkey.schema.json) | Announce public key | + +## Server-to-Client (S2C) + +### Named Commands + +| Command | Schema | Description | +|----------|--------|-------------| +| PRIVMSG | [`s2c/privmsg.schema.json`](s2c/privmsg.schema.json) | Relayed message | +| NOTICE | [`s2c/notice.schema.json`](s2c/notice.schema.json) | Server or user notice | +| JOIN | [`s2c/join.schema.json`](s2c/join.schema.json) | User joined channel | +| PART | [`s2c/part.schema.json`](s2c/part.schema.json) | User left channel | +| QUIT | [`s2c/quit.schema.json`](s2c/quit.schema.json) | User disconnected | +| NICK | [`s2c/nick.schema.json`](s2c/nick.schema.json) | Nick change | +| MODE | [`s2c/mode.schema.json`](s2c/mode.schema.json) | Mode change | +| TOPIC | [`s2c/topic.schema.json`](s2c/topic.schema.json) | Topic change | +| KICK | [`s2c/kick.schema.json`](s2c/kick.schema.json) | User kicked | +| PONG | [`s2c/pong.schema.json`](s2c/pong.schema.json) | Server pong | +| PUBKEY | [`s2c/pubkey.schema.json`](s2c/pubkey.schema.json) | Relayed public key | +| ERROR | [`s2c/error.schema.json`](s2c/error.schema.json) | Server error | + +### Numeric Replies + +| Code | Name | Schema | Description | +|------|--------------------|--------|-------------| +| 001 | RPL_WELCOME | [`s2c/001.schema.json`](s2c/001.schema.json) | Welcome after registration | +| 002 | RPL_YOURHOST | [`s2c/002.schema.json`](s2c/002.schema.json) | Server host info | +| 322 | RPL_LIST | [`s2c/322.schema.json`](s2c/322.schema.json) | Channel list entry | +| 353 | RPL_NAMREPLY | [`s2c/353.schema.json`](s2c/353.schema.json) | Names list | +| 366 | RPL_ENDOFNAMES | [`s2c/366.schema.json`](s2c/366.schema.json) | End of names list | +| 372 | RPL_MOTD | [`s2c/372.schema.json`](s2c/372.schema.json) | MOTD line | +| 375 | RPL_MOTDSTART | [`s2c/375.schema.json`](s2c/375.schema.json) | Start of MOTD | +| 376 | RPL_ENDOFMOTD | [`s2c/376.schema.json`](s2c/376.schema.json) | End of MOTD | +| 401 | ERR_NOSUCHNICK | [`s2c/401.schema.json`](s2c/401.schema.json) | No such nick/channel | +| 403 | ERR_NOSUCHCHANNEL | [`s2c/403.schema.json`](s2c/403.schema.json) | No such channel | +| 433 | ERR_NICKNAMEINUSE | [`s2c/433.schema.json`](s2c/433.schema.json) | Nick in use | + +## Server-to-Server (S2S) + +| Command | Schema | Description | +|---------|--------|-------------| +| RELAY | [`s2s/relay.schema.json`](s2s/relay.schema.json) | Relay message to linked server | +| LINK | [`s2s/link.schema.json`](s2s/link.schema.json) | Establish server link | +| UNLINK | [`s2s/unlink.schema.json`](s2s/unlink.schema.json) | Tear down link | +| SYNC | [`s2s/sync.schema.json`](s2s/sync.schema.json) | Synchronize state | +| PING | [`s2s/ping.schema.json`](s2s/ping.schema.json) | Server ping | +| PONG | [`s2s/pong.schema.json`](s2s/pong.schema.json) | Server pong | diff --git a/schema/c2s/join.json b/schema/c2s/join.json new file mode 100644 index 0000000..a1b569c --- /dev/null +++ b/schema/c2s/join.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/join.json", + "title": "C2S Join", + "description": "Join a channel. Submitted via POST /api/v1/channels/join.", + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Channel name (# prefix optional, server will add it).", + "pattern": "^#?[a-zA-Z0-9_-]+$", + "examples": ["#general", "dev"] + } + }, + "required": ["channel"], + "additionalProperties": false +} diff --git a/schema/c2s/mode.json b/schema/c2s/mode.json new file mode 100644 index 0000000..402d0a5 --- /dev/null +++ b/schema/c2s/mode.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/mode.json", + "title": "C2S Mode", + "description": "Set channel or user mode flags.", + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Target channel.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "mode": { + "type": "string", + "description": "Mode string (e.g. +o, -m, +v).", + "pattern": "^[+-][a-zA-Z]+$", + "examples": ["+o", "-m", "+v", "+i"] + }, + "target": { + "type": "string", + "description": "Target nick for user modes (e.g. +o alice). Omit for channel modes." + } + }, + "required": ["channel", "mode"], + "additionalProperties": false +} diff --git a/schema/c2s/nick.json b/schema/c2s/nick.json new file mode 100644 index 0000000..7eda376 --- /dev/null +++ b/schema/c2s/nick.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/nick.json", + "title": "C2S Nick", + "description": "Change the user's nickname.", + "type": "object", + "properties": { + "nick": { + "type": "string", + "description": "Desired new nickname.", + "minLength": 1, + "maxLength": 32, + "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$" + } + }, + "required": ["nick"], + "additionalProperties": false +} diff --git a/schema/c2s/part.json b/schema/c2s/part.json new file mode 100644 index 0000000..20e0a2f --- /dev/null +++ b/schema/c2s/part.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/part.json", + "title": "C2S Part", + "description": "Leave a channel. Submitted via DELETE /api/v1/channels/{name}.", + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Channel name to leave.", + "pattern": "^#[a-zA-Z0-9_-]+$", + "examples": ["#general"] + }, + "reason": { + "type": "string", + "description": "Optional part reason message.", + "maxLength": 256 + } + }, + "required": ["channel"], + "additionalProperties": false +} diff --git a/schema/c2s/ping.json b/schema/c2s/ping.json new file mode 100644 index 0000000..e604ad5 --- /dev/null +++ b/schema/c2s/ping.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/ping.json", + "title": "C2S Ping", + "description": "Client keepalive. Server responds with a pong.", + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Optional opaque token echoed back in the pong response." + } + }, + "additionalProperties": false +} diff --git a/schema/c2s/send.json b/schema/c2s/send.json new file mode 100644 index 0000000..033717f --- /dev/null +++ b/schema/c2s/send.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/send.json", + "title": "C2S Send", + "description": "Send a message to a channel or user. Submitted via POST /api/v1/messages.", + "type": "object", + "properties": { + "to": { + "type": "string", + "description": "Target: channel name (prefixed with #) or nick for DM.", + "examples": ["#general", "alice"] + }, + "content": { + "type": "string", + "description": "Message body (UTF-8 text).", + "minLength": 1, + "maxLength": 4096 + } + }, + "required": ["to", "content"], + "additionalProperties": false +} diff --git a/schema/c2s/topic.json b/schema/c2s/topic.json new file mode 100644 index 0000000..b6c30a0 --- /dev/null +++ b/schema/c2s/topic.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/topic.json", + "title": "C2S Topic", + "description": "Set a channel's topic.", + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Target channel.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "topic": { + "type": "string", + "description": "New topic text. Empty string clears the topic.", + "maxLength": 512 + } + }, + "required": ["channel", "topic"], + "additionalProperties": false +} diff --git a/schema/s2c/dm.json b/schema/s2c/dm.json new file mode 100644 index 0000000..a47db8d --- /dev/null +++ b/schema/s2c/dm.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/dm.json", + "title": "S2C Direct Message", + "description": "A direct message delivered via the unified message stream.", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Server-assigned message ID." + }, + "type": { + "const": "dm" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "from": { + "type": "string", + "description": "Sender nick." + }, + "to": { + "type": "string", + "description": "Recipient nick." + }, + "content": { + "type": "string", + "description": "Message body." + }, + "meta": { + "type": "object", + "additionalProperties": true + } + }, + "required": ["id", "type", "ts", "from", "to", "content"] +} diff --git a/schema/s2c/error.json b/schema/s2c/error.json new file mode 100644 index 0000000..3eda1d4 --- /dev/null +++ b/schema/s2c/error.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/error.json", + "title": "S2C Error", + "description": "Error message delivered via the message stream.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "error" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "code": { + "type": "string", + "description": "Machine-readable error code.", + "examples": ["nick_in_use", "no_such_channel", "not_on_channel", "permission_denied"] + }, + "content": { + "type": "string", + "description": "Human-readable error message." + }, + "channel": { + "type": "string", + "description": "Related channel, if applicable." + } + }, + "required": ["id", "type", "ts", "code", "content"] +} diff --git a/schema/s2c/join.json b/schema/s2c/join.json new file mode 100644 index 0000000..aa59bc4 --- /dev/null +++ b/schema/s2c/join.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/join.json", + "title": "S2C Join", + "description": "A user joined a channel.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "join" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "nick": { + "type": "string", + "description": "The nick that joined." + }, + "channel": { + "type": "string", + "description": "The channel joined.", + "pattern": "^#[a-zA-Z0-9_-]+$" + } + }, + "required": ["id", "type", "ts", "nick", "channel"] +} diff --git a/schema/s2c/message.json b/schema/s2c/message.json new file mode 100644 index 0000000..8cd4d3b --- /dev/null +++ b/schema/s2c/message.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/message.json", + "title": "S2C Message", + "description": "A channel message delivered via the unified message stream.", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Server-assigned message ID, monotonically increasing." + }, + "type": { + "const": "message" + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Server-assigned timestamp (ISO 8601)." + }, + "from": { + "type": "string", + "description": "Sender nick." + }, + "channel": { + "type": "string", + "description": "Channel the message was sent to.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "content": { + "type": "string", + "description": "Message body." + }, + "meta": { + "type": "object", + "description": "Extensible metadata (signatures, rich content, etc.).", + "additionalProperties": true + } + }, + "required": ["id", "type", "ts", "from", "channel", "content"] +} diff --git a/schema/s2c/mode.json b/schema/s2c/mode.json new file mode 100644 index 0000000..c2d5043 --- /dev/null +++ b/schema/s2c/mode.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/mode.json", + "title": "S2C Mode", + "description": "A channel or user mode was changed.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "mode" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "nick": { + "type": "string", + "description": "The nick that set the mode." + }, + "channel": { + "type": "string", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "mode": { + "type": "string", + "description": "Mode string applied (e.g. +o, -m).", + "pattern": "^[+-][a-zA-Z]+$" + }, + "target": { + "type": "string", + "description": "Target nick for user modes. Absent for channel modes." + } + }, + "required": ["id", "type", "ts", "nick", "channel", "mode"] +} diff --git a/schema/s2c/nick.json b/schema/s2c/nick.json new file mode 100644 index 0000000..32e1940 --- /dev/null +++ b/schema/s2c/nick.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/nick.json", + "title": "S2C Nick", + "description": "A user changed their nickname.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "nick" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "oldNick": { + "type": "string", + "description": "Previous nickname." + }, + "newNick": { + "type": "string", + "description": "New nickname." + } + }, + "required": ["id", "type", "ts", "oldNick", "newNick"] +} diff --git a/schema/s2c/notice.json b/schema/s2c/notice.json new file mode 100644 index 0000000..b9bf1e3 --- /dev/null +++ b/schema/s2c/notice.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/notice.json", + "title": "S2C Notice", + "description": "A server notice. May be targeted to a channel or user, or global.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "notice" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "from": { + "type": "string", + "description": "Origin (server name or nick)." + }, + "channel": { + "type": "string", + "description": "Target channel, if channel-scoped." + }, + "content": { + "type": "string", + "description": "Notice text." + } + }, + "required": ["id", "type", "ts", "content"] +} diff --git a/schema/s2c/part.json b/schema/s2c/part.json new file mode 100644 index 0000000..69930fe --- /dev/null +++ b/schema/s2c/part.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/part.json", + "title": "S2C Part", + "description": "A user left a channel.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "part" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "nick": { + "type": "string", + "description": "The nick that left." + }, + "channel": { + "type": "string", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "reason": { + "type": "string", + "description": "Optional part reason." + } + }, + "required": ["id", "type", "ts", "nick", "channel"] +} diff --git a/schema/s2c/pong.json b/schema/s2c/pong.json new file mode 100644 index 0000000..57ff903 --- /dev/null +++ b/schema/s2c/pong.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pong.json", + "title": "S2C Pong", + "description": "Keepalive response to a client ping.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "pong" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "token": { + "type": "string", + "description": "Echoed token from the client's ping, if provided." + } + }, + "required": ["id", "type", "ts"] +} diff --git a/schema/s2c/quit.json b/schema/s2c/quit.json new file mode 100644 index 0000000..e01d61b --- /dev/null +++ b/schema/s2c/quit.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/quit.json", + "title": "S2C Quit", + "description": "A user disconnected from the server.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "quit" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "nick": { + "type": "string", + "description": "The nick that quit." + }, + "reason": { + "type": "string", + "description": "Optional quit reason." + } + }, + "required": ["id", "type", "ts", "nick"] +} diff --git a/schema/s2c/system.json b/schema/s2c/system.json new file mode 100644 index 0000000..b841d0a --- /dev/null +++ b/schema/s2c/system.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/system.json", + "title": "S2C System", + "description": "Server system message (MOTD, maintenance notices, etc.).", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "system" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string", + "description": "System message text." + }, + "code": { + "type": "string", + "description": "Optional machine-readable system message code.", + "examples": ["motd", "maintenance", "server_restart"] + } + }, + "required": ["id", "type", "ts", "content"] +} diff --git a/schema/s2c/topic.json b/schema/s2c/topic.json new file mode 100644 index 0000000..a48d72d --- /dev/null +++ b/schema/s2c/topic.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/topic.json", + "title": "S2C Topic", + "description": "A channel topic was changed.", + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "type": { + "const": "topic" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "nick": { + "type": "string", + "description": "The nick that changed the topic." + }, + "channel": { + "type": "string", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "topic": { + "type": "string", + "description": "New topic text." + } + }, + "required": ["id", "type", "ts", "nick", "channel", "topic"] +} diff --git a/schema/s2s/link.json b/schema/s2s/link.json new file mode 100644 index 0000000..f588f00 --- /dev/null +++ b/schema/s2s/link.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/link.json", + "title": "S2S Link", + "description": "Server link establishment request/response.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "link" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Requesting server name." + }, + "version": { + "type": "string", + "description": "Protocol version of the requesting server." + }, + "auth": { + "type": "string", + "description": "HMAC signature over the link request using the shared federation key." + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of supported protocol capabilities.", + "examples": [["relay", "sync", "presence"]] + } + }, + "required": ["id", "type", "ts", "origin", "auth"] +} diff --git a/schema/s2s/ping.json b/schema/s2s/ping.json new file mode 100644 index 0000000..1fb7e10 --- /dev/null +++ b/schema/s2s/ping.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/ping.json", + "title": "S2S Ping", + "description": "Inter-server keepalive.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "ping" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Pinging server." + }, + "token": { + "type": "string", + "description": "Opaque token to be echoed in pong." + } + }, + "required": ["id", "type", "ts", "origin"] +} diff --git a/schema/s2s/pong.json b/schema/s2s/pong.json new file mode 100644 index 0000000..7b0d5ac --- /dev/null +++ b/schema/s2s/pong.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/pong.json", + "title": "S2S Pong", + "description": "Inter-server keepalive response.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "pong" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Responding server." + }, + "token": { + "type": "string", + "description": "Echoed token from the ping." + } + }, + "required": ["id", "type", "ts", "origin"] +} diff --git a/schema/s2s/relay.json b/schema/s2s/relay.json new file mode 100644 index 0000000..4f6cf09 --- /dev/null +++ b/schema/s2s/relay.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/relay.json", + "title": "S2S Relay", + "description": "A message relayed from a remote server in the federation.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Message UUID, globally unique across the federation." + }, + "type": { + "const": "relay" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Originating server name." + }, + "message": { + "type": "object", + "description": "The original S2C message being relayed. Preserves the original type, from, channel, content, etc.", + "properties": { + "type": { + "type": "string" + }, + "from": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ts": { + "type": "string", + "format": "date-time" + } + }, + "required": ["type", "from"] + } + }, + "required": ["id", "type", "ts", "origin", "message"] +} diff --git a/schema/s2s/sync.json b/schema/s2s/sync.json new file mode 100644 index 0000000..03b9f4d --- /dev/null +++ b/schema/s2s/sync.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/sync.json", + "title": "S2S Sync", + "description": "State synchronization between federated servers. Sent after link establishment to share channel and user state.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "sync" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Server sending the sync." + }, + "channels": { + "type": "array", + "description": "Channels on the origin server.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "topic": { + "type": "string" + }, + "modes": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of nicks in the channel." + } + }, + "required": ["name"] + } + }, + "users": { + "type": "array", + "description": "Users on the origin server.", + "items": { + "type": "object", + "properties": { + "nick": { + "type": "string" + }, + "server": { + "type": "string", + "description": "Home server for this user." + } + }, + "required": ["nick"] + } + } + }, + "required": ["id", "type", "ts", "origin"] +} diff --git a/schema/s2s/unlink.json b/schema/s2s/unlink.json new file mode 100644 index 0000000..32d669b --- /dev/null +++ b/schema/s2s/unlink.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/unlink.json", + "title": "S2S Unlink", + "description": "Server link teardown notification.", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "const": "unlink" + }, + "ts": { + "type": "string", + "format": "date-time" + }, + "origin": { + "type": "string", + "description": "Server initiating the unlink." + }, + "reason": { + "type": "string", + "description": "Optional reason for the unlink.", + "examples": ["shutdown", "configuration change", "timeout"] + } + }, + "required": ["id", "type", "ts", "origin"] +} -- 2.49.1 From 4645be5f2078fa862e3f322298cffc02aa643382 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:26:32 -0800 Subject: [PATCH 08/22] style: fix whitespace alignment in config.go --- internal/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1092ed2..7820a6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,7 +27,7 @@ type Config struct { Debug bool MaintenanceMode bool MetricsPassword string - MetricsUsername string + MetricsUsername string Port int SentryDSN string MaxHistory int @@ -80,7 +80,7 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) { Port: viper.GetInt("PORT"), SentryDSN: viper.GetString("SENTRY_DSN"), MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), - MetricsUsername: viper.GetString("METRICS_USERNAME"), + MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MaxHistory: viper.GetInt("MAX_HISTORY"), SessionTimeout: viper.GetInt("SESSION_TIMEOUT"), -- 2.49.1 From 909da3cc99e2cf85a9a6230c3376af2f84d8c039 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:26:32 -0800 Subject: [PATCH 09/22] feat: add IRC-style message protocol JSON schemas (draft 2020-12) Add JSON Schema definitions for all message types: - Base message envelope (message.schema.json) - C2S: PRIVMSG, NOTICE, JOIN, PART, QUIT, NICK, MODE, TOPIC, KICK, PING, PUBKEY - S2C: named commands + numeric reply codes (001, 002, 322, 353, 366, 372, 375, 376, 401, 403, 433) - S2S: RELAY, LINK, UNLINK, SYNC, PING, PONG - Schema index (schema/README.md) All messages use IRC command names and numeric codes from RFC 1459/2812. Bodies are always objects or arrays (never raw strings) to support deterministic canonicalization (RFC 8785 JCS) and message signing. --- schema/README.md | 5 ++- schema/c2s/join.json | 17 --------- schema/c2s/join.schema.json | 23 ++++++++++++ schema/c2s/kick.schema.json | 23 ++++++++++++ schema/c2s/mode.json | 26 ------------- schema/c2s/mode.schema.json | 23 ++++++++++++ schema/c2s/nick.json | 18 --------- schema/c2s/nick.schema.json | 23 ++++++++++++ schema/c2s/notice.schema.json | 24 ++++++++++++ schema/c2s/part.json | 22 ----------- schema/c2s/part.schema.json | 23 ++++++++++++ schema/c2s/ping.json | 14 ------- schema/c2s/ping.schema.json | 22 +++++++++++ schema/c2s/privmsg.schema.json | 24 ++++++++++++ schema/c2s/pubkey.schema.json | 33 ++++++++++++++++ schema/c2s/quit.schema.json | 22 +++++++++++ schema/c2s/send.json | 22 ----------- schema/c2s/topic.json | 21 ----------- schema/c2s/topic.schema.json | 23 ++++++++++++ schema/commands/JOIN.json | 23 ++++++++++++ schema/commands/NOTICE.json | 23 ++++++++++++ schema/commands/PART.json | 27 +++++++++++++ schema/commands/PRIVMSG.json | 29 ++++++++++++++ schema/message.json | 46 +++++++++++++++++++++++ schema/message.schema.json | 67 +++++++++++++++++++++++++++++++++ schema/s2c/001.schema.json | 24 ++++++++++++ schema/s2c/002.schema.json | 24 ++++++++++++ schema/s2c/322.schema.json | 24 ++++++++++++ schema/s2c/353.schema.json | 24 ++++++++++++ schema/s2c/366.schema.json | 24 ++++++++++++ schema/s2c/372.schema.json | 24 ++++++++++++ schema/s2c/375.schema.json | 24 ++++++++++++ schema/s2c/376.schema.json | 24 ++++++++++++ schema/s2c/401.schema.json | 24 ++++++++++++ schema/s2c/403.schema.json | 24 ++++++++++++ schema/s2c/433.schema.json | 24 ++++++++++++ schema/s2c/dm.json | 37 ------------------ schema/s2c/error.json | 33 ---------------- schema/s2c/error.schema.json | 23 ++++++++++++ schema/s2c/join.json | 29 -------------- schema/s2c/join.schema.json | 24 ++++++++++++ schema/s2c/kick.schema.json | 24 ++++++++++++ schema/s2c/message.json | 40 -------------------- schema/s2c/mode.json | 37 ------------------ schema/s2c/mode.schema.json | 24 ++++++++++++ schema/s2c/nick.json | 28 -------------- schema/s2c/nick.schema.json | 24 ++++++++++++ schema/s2c/notice.json | 32 ---------------- schema/s2c/notice.schema.json | 23 ++++++++++++ schema/s2c/part.json | 32 ---------------- schema/s2c/part.schema.json | 24 ++++++++++++ schema/s2c/pong.json | 24 ------------ schema/s2c/pong.schema.json | 22 +++++++++++ schema/s2c/privmsg.schema.json | 25 ++++++++++++ schema/s2c/pubkey.schema.json | 34 +++++++++++++++++ schema/s2c/quit.json | 28 -------------- schema/s2c/quit.schema.json | 23 ++++++++++++ schema/s2c/system.json | 29 -------------- schema/s2c/topic.json | 32 ---------------- schema/s2c/topic.schema.json | 25 ++++++++++++ schema/s2s/link.json | 41 -------------------- schema/s2s/link.schema.json | 20 ++++++++++ schema/s2s/ping.json | 29 -------------- schema/s2s/ping.schema.json | 22 +++++++++++ schema/s2s/pong.json | 29 -------------- schema/s2s/pong.schema.json | 22 +++++++++++ schema/s2s/relay.json | 49 ------------------------ schema/s2s/relay.schema.json | 20 ++++++++++ schema/s2s/sync.json | 69 ---------------------------------- schema/s2s/sync.schema.json | 20 ++++++++++ schema/s2s/unlink.json | 30 --------------- schema/s2s/unlink.schema.json | 22 +++++++++++ 72 files changed, 1166 insertions(+), 770 deletions(-) delete mode 100644 schema/c2s/join.json create mode 100644 schema/c2s/join.schema.json create mode 100644 schema/c2s/kick.schema.json delete mode 100644 schema/c2s/mode.json create mode 100644 schema/c2s/mode.schema.json delete mode 100644 schema/c2s/nick.json create mode 100644 schema/c2s/nick.schema.json create mode 100644 schema/c2s/notice.schema.json delete mode 100644 schema/c2s/part.json create mode 100644 schema/c2s/part.schema.json delete mode 100644 schema/c2s/ping.json create mode 100644 schema/c2s/ping.schema.json create mode 100644 schema/c2s/privmsg.schema.json create mode 100644 schema/c2s/pubkey.schema.json create mode 100644 schema/c2s/quit.schema.json delete mode 100644 schema/c2s/send.json delete mode 100644 schema/c2s/topic.json create mode 100644 schema/c2s/topic.schema.json create mode 100644 schema/commands/JOIN.json create mode 100644 schema/commands/NOTICE.json create mode 100644 schema/commands/PART.json create mode 100644 schema/commands/PRIVMSG.json create mode 100644 schema/message.json create mode 100644 schema/message.schema.json create mode 100644 schema/s2c/001.schema.json create mode 100644 schema/s2c/002.schema.json create mode 100644 schema/s2c/322.schema.json create mode 100644 schema/s2c/353.schema.json create mode 100644 schema/s2c/366.schema.json create mode 100644 schema/s2c/372.schema.json create mode 100644 schema/s2c/375.schema.json create mode 100644 schema/s2c/376.schema.json create mode 100644 schema/s2c/401.schema.json create mode 100644 schema/s2c/403.schema.json create mode 100644 schema/s2c/433.schema.json delete mode 100644 schema/s2c/dm.json delete mode 100644 schema/s2c/error.json create mode 100644 schema/s2c/error.schema.json delete mode 100644 schema/s2c/join.json create mode 100644 schema/s2c/join.schema.json create mode 100644 schema/s2c/kick.schema.json delete mode 100644 schema/s2c/message.json delete mode 100644 schema/s2c/mode.json create mode 100644 schema/s2c/mode.schema.json delete mode 100644 schema/s2c/nick.json create mode 100644 schema/s2c/nick.schema.json delete mode 100644 schema/s2c/notice.json create mode 100644 schema/s2c/notice.schema.json delete mode 100644 schema/s2c/part.json create mode 100644 schema/s2c/part.schema.json delete mode 100644 schema/s2c/pong.json create mode 100644 schema/s2c/pong.schema.json create mode 100644 schema/s2c/privmsg.schema.json create mode 100644 schema/s2c/pubkey.schema.json delete mode 100644 schema/s2c/quit.json create mode 100644 schema/s2c/quit.schema.json delete mode 100644 schema/s2c/system.json delete mode 100644 schema/s2c/topic.json create mode 100644 schema/s2c/topic.schema.json delete mode 100644 schema/s2s/link.json create mode 100644 schema/s2s/link.schema.json delete mode 100644 schema/s2s/ping.json create mode 100644 schema/s2s/ping.schema.json delete mode 100644 schema/s2s/pong.json create mode 100644 schema/s2s/pong.schema.json delete mode 100644 schema/s2s/relay.json create mode 100644 schema/s2s/relay.schema.json delete mode 100644 schema/s2s/sync.json create mode 100644 schema/s2s/sync.schema.json delete mode 100644 schema/s2s/unlink.json create mode 100644 schema/s2s/unlink.schema.json diff --git a/schema/README.md b/schema/README.md index 25e2b8d..c40c61a 100644 --- a/schema/README.md +++ b/schema/README.md @@ -2,7 +2,8 @@ JSON Schema (draft 2020-12) definitions for the IRC-style message protocol. -All messages share a common envelope defined in [`message.schema.json`](message.schema.json). +All messages share a common envelope defined in +[`message.schema.json`](message.schema.json). ## Base Envelope @@ -30,7 +31,7 @@ All messages share a common envelope defined in [`message.schema.json`](message. | MODE | [`c2s/mode.schema.json`](c2s/mode.schema.json) | Set/query modes | | TOPIC | [`c2s/topic.schema.json`](c2s/topic.schema.json) | Set/query topic | | KICK | [`c2s/kick.schema.json`](c2s/kick.schema.json) | Kick user | -| PING | [`c2s/ping.schema.json`](c2s/ping.schema.json) | Client ping | +| PING | [`c2s/ping.schema.json`](c2s/ping.schema.json) | Client keepalive | | PUBKEY | [`c2s/pubkey.schema.json`](c2s/pubkey.schema.json) | Announce public key | ## Server-to-Client (S2C) diff --git a/schema/c2s/join.json b/schema/c2s/join.json deleted file mode 100644 index a1b569c..0000000 --- a/schema/c2s/join.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/join.json", - "title": "C2S Join", - "description": "Join a channel. Submitted via POST /api/v1/channels/join.", - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Channel name (# prefix optional, server will add it).", - "pattern": "^#?[a-zA-Z0-9_-]+$", - "examples": ["#general", "dev"] - } - }, - "required": ["channel"], - "additionalProperties": false -} diff --git a/schema/c2s/join.schema.json b/schema/c2s/join.schema.json new file mode 100644 index 0000000..aab9a7c --- /dev/null +++ b/schema/c2s/join.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/join.schema.json", + "title": "JOIN (C2S)", + "description": "Join a channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "JOIN" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Not used" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/c2s/kick.schema.json b/schema/c2s/kick.schema.json new file mode 100644 index 0000000..7042bd6 --- /dev/null +++ b/schema/c2s/kick.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/kick.schema.json", + "title": "KICK (C2S)", + "description": "Kick user from channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "KICK" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Kick reason" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/c2s/mode.json b/schema/c2s/mode.json deleted file mode 100644 index 402d0a5..0000000 --- a/schema/c2s/mode.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/mode.json", - "title": "C2S Mode", - "description": "Set channel or user mode flags.", - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Target channel.", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "mode": { - "type": "string", - "description": "Mode string (e.g. +o, -m, +v).", - "pattern": "^[+-][a-zA-Z]+$", - "examples": ["+o", "-m", "+v", "+i"] - }, - "target": { - "type": "string", - "description": "Target nick for user modes (e.g. +o alice). Omit for channel modes." - } - }, - "required": ["channel", "mode"], - "additionalProperties": false -} diff --git a/schema/c2s/mode.schema.json b/schema/c2s/mode.schema.json new file mode 100644 index 0000000..3f8cc8a --- /dev/null +++ b/schema/c2s/mode.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/mode.schema.json", + "title": "MODE (C2S)", + "description": "Set/query modes", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "MODE" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Mode params" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/c2s/nick.json b/schema/c2s/nick.json deleted file mode 100644 index 7eda376..0000000 --- a/schema/c2s/nick.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/nick.json", - "title": "C2S Nick", - "description": "Change the user's nickname.", - "type": "object", - "properties": { - "nick": { - "type": "string", - "description": "Desired new nickname.", - "minLength": 1, - "maxLength": 32, - "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$" - } - }, - "required": ["nick"], - "additionalProperties": false -} diff --git a/schema/c2s/nick.schema.json b/schema/c2s/nick.schema.json new file mode 100644 index 0000000..0341173 --- /dev/null +++ b/schema/c2s/nick.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/nick.schema.json", + "title": "NICK (C2S)", + "description": "Request nick change", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "NICK" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Not used" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/c2s/notice.schema.json b/schema/c2s/notice.schema.json new file mode 100644 index 0000000..89cb838 --- /dev/null +++ b/schema/c2s/notice.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/notice.schema.json", + "title": "NOTICE (C2S)", + "description": "Send a notice (no auto-reply expected)", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "NOTICE" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/c2s/part.json b/schema/c2s/part.json deleted file mode 100644 index 20e0a2f..0000000 --- a/schema/c2s/part.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/part.json", - "title": "C2S Part", - "description": "Leave a channel. Submitted via DELETE /api/v1/channels/{name}.", - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Channel name to leave.", - "pattern": "^#[a-zA-Z0-9_-]+$", - "examples": ["#general"] - }, - "reason": { - "type": "string", - "description": "Optional part reason message.", - "maxLength": 256 - } - }, - "required": ["channel"], - "additionalProperties": false -} diff --git a/schema/c2s/part.schema.json b/schema/c2s/part.schema.json new file mode 100644 index 0000000..c572558 --- /dev/null +++ b/schema/c2s/part.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/part.schema.json", + "title": "PART (C2S)", + "description": "Leave a channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PART" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Part message" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/c2s/ping.json b/schema/c2s/ping.json deleted file mode 100644 index e604ad5..0000000 --- a/schema/c2s/ping.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/ping.json", - "title": "C2S Ping", - "description": "Client keepalive. Server responds with a pong.", - "type": "object", - "properties": { - "token": { - "type": "string", - "description": "Optional opaque token echoed back in the pong response." - } - }, - "additionalProperties": false -} diff --git a/schema/c2s/ping.schema.json b/schema/c2s/ping.schema.json new file mode 100644 index 0000000..6f6b5fe --- /dev/null +++ b/schema/c2s/ping.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/ping.schema.json", + "title": "PING (C2S)", + "description": "Client keepalive", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PING" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ping token" + } + }, + "required": [ + "command" + ] +} diff --git a/schema/c2s/privmsg.schema.json b/schema/c2s/privmsg.schema.json new file mode 100644 index 0000000..a8032b4 --- /dev/null +++ b/schema/c2s/privmsg.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/privmsg.schema.json", + "title": "PRIVMSG (C2S)", + "description": "Send message to channel or user", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PRIVMSG" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/c2s/pubkey.schema.json b/schema/c2s/pubkey.schema.json new file mode 100644 index 0000000..47093f5 --- /dev/null +++ b/schema/c2s/pubkey.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/pubkey.schema.json", + "title": "PUBKEY (C2S)", + "description": "Announce public signing key", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PUBKEY" + }, + "body": { + "type": "object", + "required": [ + "alg", + "key" + ], + "properties": { + "alg": { + "type": "string", + "description": "Key algorithm (e.g. ed25519)" + }, + "key": { + "type": "string", + "description": "Base64-encoded public key" + } + } + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/c2s/quit.schema.json b/schema/c2s/quit.schema.json new file mode 100644 index 0000000..9fd70e4 --- /dev/null +++ b/schema/c2s/quit.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/quit.schema.json", + "title": "QUIT (C2S)", + "description": "Disconnect from server", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "QUIT" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Quit message" + } + }, + "required": [ + "command" + ] +} diff --git a/schema/c2s/send.json b/schema/c2s/send.json deleted file mode 100644 index 033717f..0000000 --- a/schema/c2s/send.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/send.json", - "title": "C2S Send", - "description": "Send a message to a channel or user. Submitted via POST /api/v1/messages.", - "type": "object", - "properties": { - "to": { - "type": "string", - "description": "Target: channel name (prefixed with #) or nick for DM.", - "examples": ["#general", "alice"] - }, - "content": { - "type": "string", - "description": "Message body (UTF-8 text).", - "minLength": 1, - "maxLength": 4096 - } - }, - "required": ["to", "content"], - "additionalProperties": false -} diff --git a/schema/c2s/topic.json b/schema/c2s/topic.json deleted file mode 100644 index b6c30a0..0000000 --- a/schema/c2s/topic.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/topic.json", - "title": "C2S Topic", - "description": "Set a channel's topic.", - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Target channel.", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "topic": { - "type": "string", - "description": "New topic text. Empty string clears the topic.", - "maxLength": 512 - } - }, - "required": ["channel", "topic"], - "additionalProperties": false -} diff --git a/schema/c2s/topic.schema.json b/schema/c2s/topic.schema.json new file mode 100644 index 0000000..5df67cd --- /dev/null +++ b/schema/c2s/topic.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/topic.schema.json", + "title": "TOPIC (C2S)", + "description": "Set/query topic", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "TOPIC" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Topic lines" + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/commands/JOIN.json b/schema/commands/JOIN.json new file mode 100644 index 0000000..23e9c63 --- /dev/null +++ b/schema/commands/JOIN.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json", + "title": "JOIN", + "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "JOIN" }, + "from": { + "type": "string", + "description": "Nick that joined (S2C only)." + }, + "to": { + "type": "string", + "description": "Channel name.", + "pattern": "^#[a-zA-Z0-9_-]+$" + } + }, + "required": ["command", "to"], + "examples": [ + { "command": "JOIN", "from": "alice", "to": "#general" } + ] +} diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json new file mode 100644 index 0000000..0f5e873 --- /dev/null +++ b/schema/commands/NOTICE.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json", + "title": "NOTICE", + "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "NOTICE" }, + "from": { "type": "string" }, + "to": { + "type": "string", + "description": "Target: #channel, nick, or * (global)." + }, + "body": { + "type": "string", + "description": "Notice text." + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": "Server restarting in 5 minutes" } + ] +} diff --git a/schema/commands/PART.json b/schema/commands/PART.json new file mode 100644 index 0000000..f71a02a --- /dev/null +++ b/schema/commands/PART.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json", + "title": "PART", + "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PART" }, + "from": { + "type": "string", + "description": "Nick that left (S2C only)." + }, + "to": { + "type": "string", + "description": "Channel name.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "body": { + "type": "string", + "description": "Optional part reason." + } + }, + "required": ["command", "to"], + "examples": [ + { "command": "PART", "from": "alice", "to": "#general", "body": "later" } + ] +} diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json new file mode 100644 index 0000000..ea56928 --- /dev/null +++ b/schema/commands/PRIVMSG.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json", + "title": "PRIVMSG", + "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PRIVMSG" }, + "from": { + "type": "string", + "description": "Sender nick (set by server on S2C relay)." + }, + "to": { + "type": "string", + "description": "Target: #channel or nick.", + "examples": ["#general", "alice"] + }, + "body": { + "type": "string", + "description": "Message text.", + "minLength": 1 + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": "hello world" }, + { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": "hey" } + ] +} diff --git a/schema/message.json b/schema/message.json new file mode 100644 index 0000000..286d0ea --- /dev/null +++ b/schema/message.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/message.json", + "title": "IRC Message Envelope", + "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Server-assigned message ID, monotonically increasing. Present on all server-originated messages." + }, + "command": { + "type": "string", + "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", + "examples": ["PRIVMSG", "JOIN", "001", "353", "433"] + }, + "from": { + "type": "string", + "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." + }, + "to": { + "type": "string", + "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." + }, + "body": { + "type": "string", + "description": "Message body / trailing parameter. Equivalent to IRC trailing parameter (after the colon)." + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." + }, + "meta": { + "type": "object", + "description": "Extensible metadata (signatures, rich content hints, etc.). Not present in original IRC.", + "additionalProperties": true + } + }, + "required": ["command"] +} diff --git a/schema/message.schema.json b/schema/message.schema.json new file mode 100644 index 0000000..40d2dae --- /dev/null +++ b/schema/message.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/message.schema.json", + "title": "Chat Message Envelope", + "description": "Base message envelope. Bodies MUST be objects or arrays (never raw strings) for deterministic canonicalization (RFC 8785 JCS) and signing.", + "type": "object", + "required": [ + "command" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Server-assigned UUID" + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Server-assigned timestamp (ISO 8601)" + }, + "command": { + "type": "string", + "description": "IRC command name or numeric reply code" + }, + "from": { + "type": "string", + "description": "Sender nick or server name" + }, + "to": { + "type": "string", + "description": "Destination: channel (#foo) or nick" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Additional IRC-style parameters" + }, + "body": { + "oneOf": [ + { + "type": "array" + }, + { + "type": "object" + } + ], + "description": "Message body (array or object, never raw string)" + }, + "meta": { + "type": "object", + "description": "Extensible metadata", + "properties": { + "sig": { + "type": "string", + "description": "Cryptographic signature (base64)" + }, + "alg": { + "type": "string", + "description": "Signature algorithm (e.g. ed25519)" + } + } + } + }, + "additionalProperties": false +} diff --git a/schema/s2c/001.schema.json b/schema/s2c/001.schema.json new file mode 100644 index 0000000..5786bdd --- /dev/null +++ b/schema/s2c/001.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/001.schema.json", + "title": "001 RPL_WELCOME (S2C)", + "description": "Welcome message after registration", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "001" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/002.schema.json b/schema/s2c/002.schema.json new file mode 100644 index 0000000..ef8571f --- /dev/null +++ b/schema/s2c/002.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/002.schema.json", + "title": "002 RPL_YOURHOST (S2C)", + "description": "Server host information", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "002" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/322.schema.json b/schema/s2c/322.schema.json new file mode 100644 index 0000000..f704405 --- /dev/null +++ b/schema/s2c/322.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/322.schema.json", + "title": "322 RPL_LIST (S2C)", + "description": "Channel list entry", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "322" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/353.schema.json b/schema/s2c/353.schema.json new file mode 100644 index 0000000..d28371f --- /dev/null +++ b/schema/s2c/353.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/353.schema.json", + "title": "353 RPL_NAMREPLY (S2C)", + "description": "Names list for a channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "353" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/366.schema.json b/schema/s2c/366.schema.json new file mode 100644 index 0000000..522a411 --- /dev/null +++ b/schema/s2c/366.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/366.schema.json", + "title": "366 RPL_ENDOFNAMES (S2C)", + "description": "End of names list", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "366" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/372.schema.json b/schema/s2c/372.schema.json new file mode 100644 index 0000000..d4469cd --- /dev/null +++ b/schema/s2c/372.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/372.schema.json", + "title": "372 RPL_MOTD (S2C)", + "description": "Message of the day line", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "372" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/375.schema.json b/schema/s2c/375.schema.json new file mode 100644 index 0000000..bfa7d88 --- /dev/null +++ b/schema/s2c/375.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/375.schema.json", + "title": "375 RPL_MOTDSTART (S2C)", + "description": "Start of MOTD", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "375" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/376.schema.json b/schema/s2c/376.schema.json new file mode 100644 index 0000000..f5d34bb --- /dev/null +++ b/schema/s2c/376.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/376.schema.json", + "title": "376 RPL_ENDOFMOTD (S2C)", + "description": "End of MOTD", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "376" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/401.schema.json b/schema/s2c/401.schema.json new file mode 100644 index 0000000..1278cba --- /dev/null +++ b/schema/s2c/401.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/401.schema.json", + "title": "401 ERR_NOSUCHNICK (S2C)", + "description": "No such nick or channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "401" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/403.schema.json b/schema/s2c/403.schema.json new file mode 100644 index 0000000..2e0870d --- /dev/null +++ b/schema/s2c/403.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/403.schema.json", + "title": "403 ERR_NOSUCHCHANNEL (S2C)", + "description": "No such channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "403" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/433.schema.json b/schema/s2c/433.schema.json new file mode 100644 index 0000000..cef8c52 --- /dev/null +++ b/schema/s2c/433.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/433.schema.json", + "title": "433 ERR_NICKNAMEINUSE (S2C)", + "description": "Nickname already in use", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "433" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Response lines" + } + }, + "required": [ + "command", + "to", + "body" + ] +} diff --git a/schema/s2c/dm.json b/schema/s2c/dm.json deleted file mode 100644 index a47db8d..0000000 --- a/schema/s2c/dm.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/dm.json", - "title": "S2C Direct Message", - "description": "A direct message delivered via the unified message stream.", - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Server-assigned message ID." - }, - "type": { - "const": "dm" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "from": { - "type": "string", - "description": "Sender nick." - }, - "to": { - "type": "string", - "description": "Recipient nick." - }, - "content": { - "type": "string", - "description": "Message body." - }, - "meta": { - "type": "object", - "additionalProperties": true - } - }, - "required": ["id", "type", "ts", "from", "to", "content"] -} diff --git a/schema/s2c/error.json b/schema/s2c/error.json deleted file mode 100644 index 3eda1d4..0000000 --- a/schema/s2c/error.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/error.json", - "title": "S2C Error", - "description": "Error message delivered via the message stream.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "error" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "code": { - "type": "string", - "description": "Machine-readable error code.", - "examples": ["nick_in_use", "no_such_channel", "not_on_channel", "permission_denied"] - }, - "content": { - "type": "string", - "description": "Human-readable error message." - }, - "channel": { - "type": "string", - "description": "Related channel, if applicable." - } - }, - "required": ["id", "type", "ts", "code", "content"] -} diff --git a/schema/s2c/error.schema.json b/schema/s2c/error.schema.json new file mode 100644 index 0000000..fd4a92d --- /dev/null +++ b/schema/s2c/error.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/error.schema.json", + "title": "ERROR (S2C)", + "description": "Server error", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "ERROR" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Error lines" + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/s2c/join.json b/schema/s2c/join.json deleted file mode 100644 index aa59bc4..0000000 --- a/schema/s2c/join.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/join.json", - "title": "S2C Join", - "description": "A user joined a channel.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "join" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "nick": { - "type": "string", - "description": "The nick that joined." - }, - "channel": { - "type": "string", - "description": "The channel joined.", - "pattern": "^#[a-zA-Z0-9_-]+$" - } - }, - "required": ["id", "type", "ts", "nick", "channel"] -} diff --git a/schema/s2c/join.schema.json b/schema/s2c/join.schema.json new file mode 100644 index 0000000..10a0ff9 --- /dev/null +++ b/schema/s2c/join.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/join.schema.json", + "title": "JOIN (S2C)", + "description": "User joined a channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "JOIN" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Not used" + } + }, + "required": [ + "command", + "from", + "to" + ] +} diff --git a/schema/s2c/kick.schema.json b/schema/s2c/kick.schema.json new file mode 100644 index 0000000..b4503a6 --- /dev/null +++ b/schema/s2c/kick.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/kick.schema.json", + "title": "KICK (S2C)", + "description": "User kicked from channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "KICK" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Kick reason" + } + }, + "required": [ + "command", + "from", + "to" + ] +} diff --git a/schema/s2c/message.json b/schema/s2c/message.json deleted file mode 100644 index 8cd4d3b..0000000 --- a/schema/s2c/message.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/message.json", - "title": "S2C Message", - "description": "A channel message delivered via the unified message stream.", - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Server-assigned message ID, monotonically increasing." - }, - "type": { - "const": "message" - }, - "ts": { - "type": "string", - "format": "date-time", - "description": "Server-assigned timestamp (ISO 8601)." - }, - "from": { - "type": "string", - "description": "Sender nick." - }, - "channel": { - "type": "string", - "description": "Channel the message was sent to.", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "content": { - "type": "string", - "description": "Message body." - }, - "meta": { - "type": "object", - "description": "Extensible metadata (signatures, rich content, etc.).", - "additionalProperties": true - } - }, - "required": ["id", "type", "ts", "from", "channel", "content"] -} diff --git a/schema/s2c/mode.json b/schema/s2c/mode.json deleted file mode 100644 index c2d5043..0000000 --- a/schema/s2c/mode.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/mode.json", - "title": "S2C Mode", - "description": "A channel or user mode was changed.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "mode" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "nick": { - "type": "string", - "description": "The nick that set the mode." - }, - "channel": { - "type": "string", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "mode": { - "type": "string", - "description": "Mode string applied (e.g. +o, -m).", - "pattern": "^[+-][a-zA-Z]+$" - }, - "target": { - "type": "string", - "description": "Target nick for user modes. Absent for channel modes." - } - }, - "required": ["id", "type", "ts", "nick", "channel", "mode"] -} diff --git a/schema/s2c/mode.schema.json b/schema/s2c/mode.schema.json new file mode 100644 index 0000000..be04679 --- /dev/null +++ b/schema/s2c/mode.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/mode.schema.json", + "title": "MODE (S2C)", + "description": "Mode change notification", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "MODE" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Mode params" + } + }, + "required": [ + "command", + "from", + "to" + ] +} diff --git a/schema/s2c/nick.json b/schema/s2c/nick.json deleted file mode 100644 index 32e1940..0000000 --- a/schema/s2c/nick.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/nick.json", - "title": "S2C Nick", - "description": "A user changed their nickname.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "nick" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "oldNick": { - "type": "string", - "description": "Previous nickname." - }, - "newNick": { - "type": "string", - "description": "New nickname." - } - }, - "required": ["id", "type", "ts", "oldNick", "newNick"] -} diff --git a/schema/s2c/nick.schema.json b/schema/s2c/nick.schema.json new file mode 100644 index 0000000..8b0cce0 --- /dev/null +++ b/schema/s2c/nick.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/nick.schema.json", + "title": "NICK (S2C)", + "description": "User changed nick", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "NICK" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Not used" + } + }, + "required": [ + "command", + "from", + "to" + ] +} diff --git a/schema/s2c/notice.json b/schema/s2c/notice.json deleted file mode 100644 index b9bf1e3..0000000 --- a/schema/s2c/notice.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/notice.json", - "title": "S2C Notice", - "description": "A server notice. May be targeted to a channel or user, or global.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "notice" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "from": { - "type": "string", - "description": "Origin (server name or nick)." - }, - "channel": { - "type": "string", - "description": "Target channel, if channel-scoped." - }, - "content": { - "type": "string", - "description": "Notice text." - } - }, - "required": ["id", "type", "ts", "content"] -} diff --git a/schema/s2c/notice.schema.json b/schema/s2c/notice.schema.json new file mode 100644 index 0000000..738719a --- /dev/null +++ b/schema/s2c/notice.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/notice.schema.json", + "title": "NOTICE (S2C)", + "description": "Server or user notice", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "NOTICE" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines" + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/s2c/part.json b/schema/s2c/part.json deleted file mode 100644 index 69930fe..0000000 --- a/schema/s2c/part.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/part.json", - "title": "S2C Part", - "description": "A user left a channel.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "part" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "nick": { - "type": "string", - "description": "The nick that left." - }, - "channel": { - "type": "string", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "reason": { - "type": "string", - "description": "Optional part reason." - } - }, - "required": ["id", "type", "ts", "nick", "channel"] -} diff --git a/schema/s2c/part.schema.json b/schema/s2c/part.schema.json new file mode 100644 index 0000000..9c05a99 --- /dev/null +++ b/schema/s2c/part.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/part.schema.json", + "title": "PART (S2C)", + "description": "User left a channel", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PART" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Part message" + } + }, + "required": [ + "command", + "from", + "to" + ] +} diff --git a/schema/s2c/pong.json b/schema/s2c/pong.json deleted file mode 100644 index 57ff903..0000000 --- a/schema/s2c/pong.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pong.json", - "title": "S2C Pong", - "description": "Keepalive response to a client ping.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "pong" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "token": { - "type": "string", - "description": "Echoed token from the client's ping, if provided." - } - }, - "required": ["id", "type", "ts"] -} diff --git a/schema/s2c/pong.schema.json b/schema/s2c/pong.schema.json new file mode 100644 index 0000000..a184887 --- /dev/null +++ b/schema/s2c/pong.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pong.schema.json", + "title": "PONG (S2C)", + "description": "Server keepalive response", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PONG" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pong token" + } + }, + "required": [ + "command" + ] +} diff --git a/schema/s2c/privmsg.schema.json b/schema/s2c/privmsg.schema.json new file mode 100644 index 0000000..3e397d6 --- /dev/null +++ b/schema/s2c/privmsg.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/privmsg.schema.json", + "title": "PRIVMSG (S2C)", + "description": "Relayed message from a user", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PRIVMSG" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines" + } + }, + "required": [ + "command", + "from", + "to", + "body" + ] +} diff --git a/schema/s2c/pubkey.schema.json b/schema/s2c/pubkey.schema.json new file mode 100644 index 0000000..f720b0c --- /dev/null +++ b/schema/s2c/pubkey.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pubkey.schema.json", + "title": "PUBKEY (S2C)", + "description": "Relayed public key announcement", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PUBKEY" + }, + "body": { + "type": "object", + "required": [ + "alg", + "key" + ], + "properties": { + "alg": { + "type": "string", + "description": "Key algorithm (e.g. ed25519)" + }, + "key": { + "type": "string", + "description": "Base64-encoded public key" + } + } + } + }, + "required": [ + "command", + "from", + "body" + ] +} diff --git a/schema/s2c/quit.json b/schema/s2c/quit.json deleted file mode 100644 index e01d61b..0000000 --- a/schema/s2c/quit.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/quit.json", - "title": "S2C Quit", - "description": "A user disconnected from the server.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "quit" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "nick": { - "type": "string", - "description": "The nick that quit." - }, - "reason": { - "type": "string", - "description": "Optional quit reason." - } - }, - "required": ["id", "type", "ts", "nick"] -} diff --git a/schema/s2c/quit.schema.json b/schema/s2c/quit.schema.json new file mode 100644 index 0000000..edb2395 --- /dev/null +++ b/schema/s2c/quit.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/quit.schema.json", + "title": "QUIT (S2C)", + "description": "User disconnected", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "QUIT" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Quit message" + } + }, + "required": [ + "command", + "from" + ] +} diff --git a/schema/s2c/system.json b/schema/s2c/system.json deleted file mode 100644 index b841d0a..0000000 --- a/schema/s2c/system.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/system.json", - "title": "S2C System", - "description": "Server system message (MOTD, maintenance notices, etc.).", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "system" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "content": { - "type": "string", - "description": "System message text." - }, - "code": { - "type": "string", - "description": "Optional machine-readable system message code.", - "examples": ["motd", "maintenance", "server_restart"] - } - }, - "required": ["id", "type", "ts", "content"] -} diff --git a/schema/s2c/topic.json b/schema/s2c/topic.json deleted file mode 100644 index a48d72d..0000000 --- a/schema/s2c/topic.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/topic.json", - "title": "S2C Topic", - "description": "A channel topic was changed.", - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "type": { - "const": "topic" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "nick": { - "type": "string", - "description": "The nick that changed the topic." - }, - "channel": { - "type": "string", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "topic": { - "type": "string", - "description": "New topic text." - } - }, - "required": ["id", "type", "ts", "nick", "channel", "topic"] -} diff --git a/schema/s2c/topic.schema.json b/schema/s2c/topic.schema.json new file mode 100644 index 0000000..144b89c --- /dev/null +++ b/schema/s2c/topic.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/topic.schema.json", + "title": "TOPIC (S2C)", + "description": "Topic change notification", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "TOPIC" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Topic lines" + } + }, + "required": [ + "command", + "from", + "to", + "body" + ] +} diff --git a/schema/s2s/link.json b/schema/s2s/link.json deleted file mode 100644 index f588f00..0000000 --- a/schema/s2s/link.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/link.json", - "title": "S2S Link", - "description": "Server link establishment request/response.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "type": { - "const": "link" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Requesting server name." - }, - "version": { - "type": "string", - "description": "Protocol version of the requesting server." - }, - "auth": { - "type": "string", - "description": "HMAC signature over the link request using the shared federation key." - }, - "capabilities": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of supported protocol capabilities.", - "examples": [["relay", "sync", "presence"]] - } - }, - "required": ["id", "type", "ts", "origin", "auth"] -} diff --git a/schema/s2s/link.schema.json b/schema/s2s/link.schema.json new file mode 100644 index 0000000..1e07586 --- /dev/null +++ b/schema/s2s/link.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/link.schema.json", + "title": "LINK (S2S)", + "description": "Establish server link", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "LINK" + }, + "body": { + "type": "object", + "description": "Link parameters" + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/s2s/ping.json b/schema/s2s/ping.json deleted file mode 100644 index 1fb7e10..0000000 --- a/schema/s2s/ping.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/ping.json", - "title": "S2S Ping", - "description": "Inter-server keepalive.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "type": { - "const": "ping" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Pinging server." - }, - "token": { - "type": "string", - "description": "Opaque token to be echoed in pong." - } - }, - "required": ["id", "type", "ts", "origin"] -} diff --git a/schema/s2s/ping.schema.json b/schema/s2s/ping.schema.json new file mode 100644 index 0000000..14911ed --- /dev/null +++ b/schema/s2s/ping.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/ping.schema.json", + "title": "PING (S2S)", + "description": "Server-to-server keepalive", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PING" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Ping token" + } + }, + "required": [ + "command" + ] +} diff --git a/schema/s2s/pong.json b/schema/s2s/pong.json deleted file mode 100644 index 7b0d5ac..0000000 --- a/schema/s2s/pong.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/pong.json", - "title": "S2S Pong", - "description": "Inter-server keepalive response.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "type": { - "const": "pong" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Responding server." - }, - "token": { - "type": "string", - "description": "Echoed token from the ping." - } - }, - "required": ["id", "type", "ts", "origin"] -} diff --git a/schema/s2s/pong.schema.json b/schema/s2s/pong.schema.json new file mode 100644 index 0000000..ae8c628 --- /dev/null +++ b/schema/s2s/pong.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/pong.schema.json", + "title": "PONG (S2S)", + "description": "Server-to-server keepalive response", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "PONG" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pong token" + } + }, + "required": [ + "command" + ] +} diff --git a/schema/s2s/relay.json b/schema/s2s/relay.json deleted file mode 100644 index 4f6cf09..0000000 --- a/schema/s2s/relay.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/relay.json", - "title": "S2S Relay", - "description": "A message relayed from a remote server in the federation.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Message UUID, globally unique across the federation." - }, - "type": { - "const": "relay" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Originating server name." - }, - "message": { - "type": "object", - "description": "The original S2C message being relayed. Preserves the original type, from, channel, content, etc.", - "properties": { - "type": { - "type": "string" - }, - "from": { - "type": "string" - }, - "channel": { - "type": "string" - }, - "content": { - "type": "string" - }, - "ts": { - "type": "string", - "format": "date-time" - } - }, - "required": ["type", "from"] - } - }, - "required": ["id", "type", "ts", "origin", "message"] -} diff --git a/schema/s2s/relay.schema.json b/schema/s2s/relay.schema.json new file mode 100644 index 0000000..aa6b833 --- /dev/null +++ b/schema/s2s/relay.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/relay.schema.json", + "title": "RELAY (S2S)", + "description": "Relay message to linked server", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "RELAY" + }, + "body": { + "type": "object", + "description": "Wrapped message" + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/s2s/sync.json b/schema/s2s/sync.json deleted file mode 100644 index 03b9f4d..0000000 --- a/schema/s2s/sync.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/sync.json", - "title": "S2S Sync", - "description": "State synchronization between federated servers. Sent after link establishment to share channel and user state.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "type": { - "const": "sync" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Server sending the sync." - }, - "channels": { - "type": "array", - "description": "Channels on the origin server.", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "topic": { - "type": "string" - }, - "modes": { - "type": "string" - }, - "members": { - "type": "array", - "items": { - "type": "string" - }, - "description": "List of nicks in the channel." - } - }, - "required": ["name"] - } - }, - "users": { - "type": "array", - "description": "Users on the origin server.", - "items": { - "type": "object", - "properties": { - "nick": { - "type": "string" - }, - "server": { - "type": "string", - "description": "Home server for this user." - } - }, - "required": ["nick"] - } - } - }, - "required": ["id", "type", "ts", "origin"] -} diff --git a/schema/s2s/sync.schema.json b/schema/s2s/sync.schema.json new file mode 100644 index 0000000..9218714 --- /dev/null +++ b/schema/s2s/sync.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/sync.schema.json", + "title": "SYNC (S2S)", + "description": "Synchronize state between servers", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "SYNC" + }, + "body": { + "type": "object", + "description": "State data" + } + }, + "required": [ + "command", + "body" + ] +} diff --git a/schema/s2s/unlink.json b/schema/s2s/unlink.json deleted file mode 100644 index 32d669b..0000000 --- a/schema/s2s/unlink.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/unlink.json", - "title": "S2S Unlink", - "description": "Server link teardown notification.", - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "type": { - "const": "unlink" - }, - "ts": { - "type": "string", - "format": "date-time" - }, - "origin": { - "type": "string", - "description": "Server initiating the unlink." - }, - "reason": { - "type": "string", - "description": "Optional reason for the unlink.", - "examples": ["shutdown", "configuration change", "timeout"] - } - }, - "required": ["id", "type", "ts", "origin"] -} diff --git a/schema/s2s/unlink.schema.json b/schema/s2s/unlink.schema.json new file mode 100644 index 0000000..dc181d3 --- /dev/null +++ b/schema/s2s/unlink.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/unlink.schema.json", + "title": "UNLINK (S2S)", + "description": "Tear down server link", + "$ref": "../message.schema.json", + "properties": { + "command": { + "const": "UNLINK" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Unlink reason" + } + }, + "required": [ + "command" + ] +} -- 2.49.1 From 02acf1c9198611b1b09439324f659a19f2b54871 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:26:32 -0800 Subject: [PATCH 10/22] docs: document IRC message protocol, signing, and canonicalization - Add IRC command/numeric mapping tables (C2S, S2C, S2S) - Document structured message bodies (array/object, never raw strings) - Document RFC 8785 JCS canonicalization for deterministic hashing - Document Ed25519 signing/verification flow with TOFU key distribution - Document PUBKEY message type for public key announcement - Update message examples to use IRC command format - Update curl examples to use command-based messages - Note web client as convenience UI; primary interface is IRC-style clients - Add schema/ to project structure --- README.md | 248 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 176 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 5660b06..9a941eb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This project builds a chat server that: - Exposes a minimal, clean HTTP+JSON API — easy to build clients against - Supports multiple concurrent connections per user session - Provides IRC-like semantics: channels, nicks, topics, modes -- Uses structured JSON messages with arbitrary extensibility +- Uses structured JSON messages with IRC command names and numeric reply codes ## Architecture @@ -38,6 +38,151 @@ request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. The entire read/write loop for a client is two endpoints. Everything else is channel management and history. +### Message Protocol + +All messages use **IRC command names and numeric reply codes** from RFC 1459/2812. +The `command` field identifies the message type. + +#### Message Envelope + +Every message is a JSON object with these fields: + +| Field | Type | Required | Description | +|-----------|-----------------|----------|-------------| +| `command` | string | ✓ | IRC command name or 3-digit numeric code | +| `from` | string | | Sender nick or server name | +| `to` | string | | Destination: `#channel` or nick | +| `params` | array\ | | Additional IRC-style parameters | +| `body` | array \| object | | Structured body (never a raw string) | +| `meta` | object | | Extensible metadata (signatures, etc.) | +| `id` | string (uuid) | | Server-assigned message ID | +| `ts` | string | | Server-assigned ISO 8601 timestamp | + +**Important:** Message bodies MUST be objects or arrays, never raw strings. +This enables: +- Multiline messages (array of lines) +- Deterministic canonicalization for hashing/signing (RFC 8785 JCS) +- Structured data where needed (e.g. PUBKEY) + +#### IRC Command Mapping + +**Client-to-Server (C2S):** + +| Command | Description | +|----------|-------------| +| PRIVMSG | Send message to channel or user | +| NOTICE | Send notice (no auto-reply expected) | +| JOIN | Join a channel | +| PART | Leave a channel | +| QUIT | Disconnect from server | +| NICK | Change nickname | +| MODE | Set/query channel or user modes | +| TOPIC | Set/query channel topic | +| KICK | Kick a user from a channel | +| PING | Client keepalive | +| PUBKEY | Announce public signing key | + +**Server-to-Client (S2C):** + +All C2S commands may be echoed back as S2C (relayed to other users), plus: + +| Command | Description | +|----------|-------------| +| PONG | Server keepalive response | +| PUBKEY | Relayed public key from another user | +| ERROR | Server error message | + +**Numeric Reply Codes (S2C):** + +| Code | Name | Description | +|------|-------------------|-------------| +| 001 | RPL_WELCOME | Welcome after registration | +| 002 | RPL_YOURHOST | Server host information | +| 322 | RPL_LIST | Channel list entry | +| 353 | RPL_NAMREPLY | Names list for a channel | +| 366 | RPL_ENDOFNAMES | End of names list | +| 372 | RPL_MOTD | Message of the day line | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 401 | ERR_NOSUCHNICK | No such nick or channel | +| 403 | ERR_NOSUCHCHANNEL | No such channel | +| 433 | ERR_NICKNAMEINUSE | Nickname already in use | + +**Server-to-Server (S2S):** + +| Command | Description | +|---------|-------------| +| RELAY | Relay message to linked server | +| LINK | Establish server link | +| UNLINK | Tear down server link | +| SYNC | Synchronize state between servers | +| PING | Server-to-server keepalive | +| PONG | Server-to-server keepalive response | + +#### Message Examples + +```json +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"], "meta": {"sig": "base64...", "alg": "ed25519"}} + +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"]} + +{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]} + +{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["alice", "bob", "@charlie"]} + +{"command": "JOIN", "from": "bob", "to": "#general", "body": []} + +{"command": "ERROR", "body": ["Closing link: connection timeout"]} +``` + +#### JSON Schemas + +Full JSON Schema (draft 2020-12) definitions for all message types are in +[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the +complete index. + +### Canonicalization and Signing + +Messages support optional cryptographic signatures for integrity verification. + +#### Canonicalization (RFC 8785 JCS) + +To produce a deterministic byte representation of a message for signing: + +1. Remove `meta.sig` from the message (the signature itself is not signed) +2. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): + - Object keys sorted lexicographically + - No whitespace + - Numbers in shortest form + - UTF-8 encoding +3. The resulting byte string is the signing input + +This is why `body` must be an object or array — raw strings would be ambiguous +under canonicalization. + +#### Signing Flow + +1. Client generates an Ed25519 keypair +2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}` +3. Server relays PUBKEY to channel members / stores for the session +4. When sending a message, client: + a. Constructs the message without `meta.sig` + b. Canonicalizes per JCS + c. Signs with private key + d. Adds `meta.sig` (base64) and `meta.alg` +5. Recipients verify by repeating steps a–c and checking the signature + against the sender's announced public key + +#### PUBKEY Message + +```json +{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +``` + +Servers SHOULD relay PUBKEY messages to all channel members. Clients SHOULD +cache public keys and use them to verify `meta.sig` on incoming messages. +Key distribution is trust-on-first-use (TOFU) by default. + ### Core Concepts #### Users @@ -63,36 +208,6 @@ channel management and history. - Channel history is stored server-side (configurable depth) - No eternal logging by default — history rotates -#### Messages - -Every message is a structured JSON object: - -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "ts": "2026-02-09T20:00:00.000Z", - "from": "nick", - "to": "#channel", - "type": "message", - "body": "Hello, world!", - "meta": {} -} -``` - -Fields: -- `id` — Server-assigned UUID, globally unique -- `ts` — Server-assigned timestamp (ISO 8601) -- `from` — Sender nick -- `to` — Destination: channel name (`#foo`) or nick (for DMs) -- `type` — Message type: `message`, `action`, `notice`, `join`, `part`, `quit`, - `topic`, `mode`, `nick`, `system` -- `body` — Message content (UTF-8 text) -- `meta` — Arbitrary extensible metadata (JSON object). Can carry: - - Cryptographic signatures - - Rich content hints (URLs, embeds) - - Client-specific extensions - - Reactions, edits, threading references - ### API Endpoints All endpoints accept and return `application/json`. Authenticated endpoints @@ -116,14 +231,14 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \ -d '{"nick":"alice"}' | jq -r .token) # Join a channel -curl -s -X POST http://localhost:8080/api/v1/channels/join \ +curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ - -d '{"channel":"#general"}' + -d '{"command":"JOIN","to":"#general"}' # Send a message curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ - -d '{"to":"#general","content":"hello world"}' + -d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}' # Poll for messages (long-poll) curl -s http://localhost:8080/api/v1/messages?after=0 \ @@ -149,8 +264,8 @@ GET /api/v1/state — User state: nick, id, and list of joined channe GET /api/v1/messages — Single message stream (long-poll supported) All message types: channel, DM, notices, events Query params: ?after=&timeout=30 -POST /api/v1/messages — Send a message - Body: { "to": "#channel" or "nick", "content": "..." } +POST /api/v1/messages — Send a message (IRC command in body) + Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] } ``` #### History @@ -187,8 +302,9 @@ POST /api/v1/federation/relay — Relay messages between linked servers GET /api/v1/federation/status — Link status ``` -Federation uses the same HTTP+JSON transport. Messages are relayed between -servers so users on different servers can share channels. +Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, +UNLINK, SYNC, PING, and PONG commands. Messages are relayed between servers so +users on different servers can share channels. ### Channel Modes @@ -239,44 +355,31 @@ Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/s ``` chat/ ├── cmd/ -│ └── chat/ +│ └── chatd/ │ └── main.go ├── internal/ │ ├── config/ -│ │ └── config.go │ ├── database/ -│ │ └── database.go │ ├── globals/ -│ │ └── globals.go │ ├── handlers/ -│ │ ├── handlers.go -│ │ ├── auth.go -│ │ ├── channels.go -│ │ ├── federation.go -│ │ ├── healthcheck.go -│ │ ├── messages.go -│ │ └── users.go │ ├── healthcheck/ -│ │ └── healthcheck.go │ ├── logger/ -│ │ └── logger.go │ ├── middleware/ -│ │ └── middleware.go │ ├── models/ -│ │ ├── channel.go -│ │ ├── message.go -│ │ └── user.go │ ├── queue/ -│ │ └── queue.go │ └── server/ -│ ├── server.go -│ ├── http.go -│ └── routes.go +├── schema/ +│ ├── message.schema.json +│ ├── c2s/ +│ ├── s2c/ +│ ├── s2s/ +│ └── README.md +├── web/ ├── go.mod ├── go.sum ├── Makefile ├── Dockerfile -├── CONVENTIONS.md → (copy from gohttpserver) +├── CONVENTIONS.md └── README.md ``` @@ -298,26 +401,27 @@ Per gohttpserver conventions: ### Web Client The server embeds a single-page web client (Preact) served at `/`. This is a -**convenience/reference implementation** — not the primary interface. It -demonstrates the API and provides a quick way to test the server in a browser. - -The primary intended clients are IRC-style terminal applications and bots -talking directly to the HTTP API. +**convenience/reference implementation** — not the primary interface. The +primary intended clients are IRC-style terminal applications, bots, and custom +clients talking directly to the HTTP API. ### Design Principles 1. **API-first** — the HTTP API is the product. Clients are thin. If you can't build a working IRC-style TUI client in an afternoon, the API is too complex. -2. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol +2. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. + Familiar to anyone who's built IRC clients or bots. +3. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere. -3. **Server holds state** — clients are stateless. Reconnect, switch devices, +4. **Server holds state** — clients are stateless. Reconnect, switch devices, lose connectivity — your messages are waiting. -4. **Structured messages** — JSON with extensible metadata. Enables signatures, - rich content, client extensions without protocol changes. -5. **Simple deployment** — single binary, SQLite default, zero mandatory +5. **Structured messages** — JSON with extensible metadata. Bodies are always + objects or arrays for deterministic canonicalization (JCS) and signing. +6. **Simple deployment** — single binary, SQLite default, zero mandatory external dependencies. -6. **No eternal logs** — history rotates. Chat should be ephemeral by default. -7. **Federation optional** — single server works standalone. Linking is opt-in. +7. **No eternal logs** — history rotates. Chat should be ephemeral by default. +8. **Federation optional** — single server works standalone. Linking is opt-in. +9. **Signable messages** — optional Ed25519 signatures with TOFU key distribution. ## Status -- 2.49.1 From c8d88de8c521f2e5f29233580060a024f56ec2d0 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:26:44 -0800 Subject: [PATCH 11/22] chore: remove superseded schema files Remove schema/commands/ and schema/message.json, replaced by the new schema/{c2s,s2c,s2s}/*.schema.json structure. --- schema/commands/JOIN.json | 23 ------------------ schema/commands/NOTICE.json | 23 ------------------ schema/commands/PART.json | 27 --------------------- schema/commands/PRIVMSG.json | 29 ----------------------- schema/message.json | 46 ------------------------------------ 5 files changed, 148 deletions(-) delete mode 100644 schema/commands/JOIN.json delete mode 100644 schema/commands/NOTICE.json delete mode 100644 schema/commands/PART.json delete mode 100644 schema/commands/PRIVMSG.json delete mode 100644 schema/message.json diff --git a/schema/commands/JOIN.json b/schema/commands/JOIN.json deleted file mode 100644 index 23e9c63..0000000 --- a/schema/commands/JOIN.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json", - "title": "JOIN", - "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", - "$ref": "../message.json", - "properties": { - "command": { "const": "JOIN" }, - "from": { - "type": "string", - "description": "Nick that joined (S2C only)." - }, - "to": { - "type": "string", - "description": "Channel name.", - "pattern": "^#[a-zA-Z0-9_-]+$" - } - }, - "required": ["command", "to"], - "examples": [ - { "command": "JOIN", "from": "alice", "to": "#general" } - ] -} diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json deleted file mode 100644 index 0f5e873..0000000 --- a/schema/commands/NOTICE.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json", - "title": "NOTICE", - "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", - "$ref": "../message.json", - "properties": { - "command": { "const": "NOTICE" }, - "from": { "type": "string" }, - "to": { - "type": "string", - "description": "Target: #channel, nick, or * (global)." - }, - "body": { - "type": "string", - "description": "Notice text." - } - }, - "required": ["command", "to", "body"], - "examples": [ - { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": "Server restarting in 5 minutes" } - ] -} diff --git a/schema/commands/PART.json b/schema/commands/PART.json deleted file mode 100644 index f71a02a..0000000 --- a/schema/commands/PART.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json", - "title": "PART", - "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", - "$ref": "../message.json", - "properties": { - "command": { "const": "PART" }, - "from": { - "type": "string", - "description": "Nick that left (S2C only)." - }, - "to": { - "type": "string", - "description": "Channel name.", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, - "body": { - "type": "string", - "description": "Optional part reason." - } - }, - "required": ["command", "to"], - "examples": [ - { "command": "PART", "from": "alice", "to": "#general", "body": "later" } - ] -} diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json deleted file mode 100644 index ea56928..0000000 --- a/schema/commands/PRIVMSG.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json", - "title": "PRIVMSG", - "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", - "$ref": "../message.json", - "properties": { - "command": { "const": "PRIVMSG" }, - "from": { - "type": "string", - "description": "Sender nick (set by server on S2C relay)." - }, - "to": { - "type": "string", - "description": "Target: #channel or nick.", - "examples": ["#general", "alice"] - }, - "body": { - "type": "string", - "description": "Message text.", - "minLength": 1 - } - }, - "required": ["command", "to", "body"], - "examples": [ - { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": "hello world" }, - { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": "hey" } - ] -} diff --git a/schema/message.json b/schema/message.json deleted file mode 100644 index 286d0ea..0000000 --- a/schema/message.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/message.json", - "title": "IRC Message Envelope", - "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", - "type": "object", - "properties": { - "id": { - "type": "integer", - "description": "Server-assigned message ID, monotonically increasing. Present on all server-originated messages." - }, - "command": { - "type": "string", - "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", - "examples": ["PRIVMSG", "JOIN", "001", "353", "433"] - }, - "from": { - "type": "string", - "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." - }, - "to": { - "type": "string", - "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." - }, - "params": { - "type": "array", - "items": { "type": "string" }, - "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." - }, - "body": { - "type": "string", - "description": "Message body / trailing parameter. Equivalent to IRC trailing parameter (after the colon)." - }, - "ts": { - "type": "string", - "format": "date-time", - "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." - }, - "meta": { - "type": "object", - "description": "Extensible metadata (signatures, rich content hints, etc.). Not present in original IRC.", - "additionalProperties": true - } - }, - "required": ["command"] -} -- 2.49.1 From dfb1636be5d9ad2d5d1c0c65b0b7648d9def162d Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:31:26 -0800 Subject: [PATCH 12/22] refactor: model message schemas after IRC RFC 1459/2812 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace c2s/s2c/s2s taxonomy with IRC-native structure: - schema/commands/ — IRC command schemas (PRIVMSG, NOTICE, JOIN, PART, QUIT, NICK, TOPIC, MODE, KICK, PING, PONG) - schema/numerics/ — IRC numeric reply codes (001-004, 322-323, 332, 353, 366, 372-376, 401, 403, 433, 442, 482) - schema/message.json — base envelope mapping IRC wire format to JSON Messages use 'command' field with IRC command names or 3-digit numeric codes. 'body' is a string (IRC trailing parameter), not object/array. 'from'/'to' map to IRC prefix and first parameter. Federation uses the same IRC commands (no custom RELAY/LINK/SYNC). Update README message format, command tables, and examples to match. --- README.md | 227 +++++++++++++++++++++++---------- schema/README.md | 142 +++++++++++---------- schema/c2s/join.schema.json | 23 ---- schema/c2s/kick.schema.json | 23 ---- schema/c2s/mode.schema.json | 23 ---- schema/c2s/nick.schema.json | 23 ---- schema/c2s/notice.schema.json | 24 ---- schema/c2s/part.schema.json | 23 ---- schema/c2s/ping.schema.json | 22 ---- schema/c2s/privmsg.schema.json | 24 ---- schema/c2s/pubkey.schema.json | 33 ----- schema/c2s/quit.schema.json | 22 ---- schema/c2s/topic.schema.json | 23 ---- schema/commands/JOIN.json | 16 +++ schema/commands/KICK.json | 34 +++++ schema/commands/MODE.json | 29 +++++ schema/commands/NICK.json | 16 +++ schema/commands/NOTICE.json | 17 +++ schema/commands/PART.json | 17 +++ schema/commands/PING.json | 18 +++ schema/commands/PONG.json | 22 ++++ schema/commands/PRIVMSG.json | 18 +++ schema/commands/QUIT.json | 16 +++ schema/commands/TOPIC.json | 17 +++ schema/message.json | 46 +++++++ schema/message.schema.json | 67 ---------- schema/numerics/001.json | 20 +++ schema/numerics/002.json | 20 +++ schema/numerics/003.json | 36 ++++++ schema/numerics/004.json | 39 ++++++ schema/numerics/322.json | 47 +++++++ schema/numerics/323.json | 13 ++ schema/numerics/332.json | 47 +++++++ schema/numerics/353.json | 48 +++++++ schema/numerics/366.json | 18 +++ schema/numerics/372.json | 36 ++++++ schema/numerics/375.json | 26 ++++ schema/numerics/376.json | 13 ++ schema/numerics/401.json | 21 +++ schema/numerics/403.json | 21 +++ schema/numerics/433.json | 21 +++ schema/numerics/442.json | 18 +++ schema/numerics/482.json | 18 +++ schema/s2c/001.schema.json | 24 ---- schema/s2c/002.schema.json | 24 ---- schema/s2c/322.schema.json | 24 ---- schema/s2c/353.schema.json | 24 ---- schema/s2c/366.schema.json | 24 ---- schema/s2c/372.schema.json | 24 ---- schema/s2c/375.schema.json | 24 ---- schema/s2c/376.schema.json | 24 ---- schema/s2c/401.schema.json | 24 ---- schema/s2c/403.schema.json | 24 ---- schema/s2c/433.schema.json | 24 ---- schema/s2c/error.schema.json | 23 ---- schema/s2c/join.schema.json | 24 ---- schema/s2c/kick.schema.json | 24 ---- schema/s2c/mode.schema.json | 24 ---- schema/s2c/nick.schema.json | 24 ---- schema/s2c/notice.schema.json | 23 ---- schema/s2c/part.schema.json | 24 ---- schema/s2c/pong.schema.json | 22 ---- schema/s2c/privmsg.schema.json | 25 ---- schema/s2c/pubkey.schema.json | 34 ----- schema/s2c/quit.schema.json | 23 ---- schema/s2c/topic.schema.json | 25 ---- schema/s2s/link.schema.json | 20 --- schema/s2s/ping.schema.json | 22 ---- schema/s2s/pong.schema.json | 22 ---- schema/s2s/relay.schema.json | 20 --- schema/s2s/sync.schema.json | 20 --- schema/s2s/unlink.schema.json | 22 ---- 72 files changed, 963 insertions(+), 1149 deletions(-) delete mode 100644 schema/c2s/join.schema.json delete mode 100644 schema/c2s/kick.schema.json delete mode 100644 schema/c2s/mode.schema.json delete mode 100644 schema/c2s/nick.schema.json delete mode 100644 schema/c2s/notice.schema.json delete mode 100644 schema/c2s/part.schema.json delete mode 100644 schema/c2s/ping.schema.json delete mode 100644 schema/c2s/privmsg.schema.json delete mode 100644 schema/c2s/pubkey.schema.json delete mode 100644 schema/c2s/quit.schema.json delete mode 100644 schema/c2s/topic.schema.json create mode 100644 schema/commands/JOIN.json create mode 100644 schema/commands/KICK.json create mode 100644 schema/commands/MODE.json create mode 100644 schema/commands/NICK.json create mode 100644 schema/commands/NOTICE.json create mode 100644 schema/commands/PART.json create mode 100644 schema/commands/PING.json create mode 100644 schema/commands/PONG.json create mode 100644 schema/commands/PRIVMSG.json create mode 100644 schema/commands/QUIT.json create mode 100644 schema/commands/TOPIC.json create mode 100644 schema/message.json delete mode 100644 schema/message.schema.json create mode 100644 schema/numerics/001.json create mode 100644 schema/numerics/002.json create mode 100644 schema/numerics/003.json create mode 100644 schema/numerics/004.json create mode 100644 schema/numerics/322.json create mode 100644 schema/numerics/323.json create mode 100644 schema/numerics/332.json create mode 100644 schema/numerics/353.json create mode 100644 schema/numerics/366.json create mode 100644 schema/numerics/372.json create mode 100644 schema/numerics/375.json create mode 100644 schema/numerics/376.json create mode 100644 schema/numerics/401.json create mode 100644 schema/numerics/403.json create mode 100644 schema/numerics/433.json create mode 100644 schema/numerics/442.json create mode 100644 schema/numerics/482.json delete mode 100644 schema/s2c/001.schema.json delete mode 100644 schema/s2c/002.schema.json delete mode 100644 schema/s2c/322.schema.json delete mode 100644 schema/s2c/353.schema.json delete mode 100644 schema/s2c/366.schema.json delete mode 100644 schema/s2c/372.schema.json delete mode 100644 schema/s2c/375.schema.json delete mode 100644 schema/s2c/376.schema.json delete mode 100644 schema/s2c/401.schema.json delete mode 100644 schema/s2c/403.schema.json delete mode 100644 schema/s2c/433.schema.json delete mode 100644 schema/s2c/error.schema.json delete mode 100644 schema/s2c/join.schema.json delete mode 100644 schema/s2c/kick.schema.json delete mode 100644 schema/s2c/mode.schema.json delete mode 100644 schema/s2c/nick.schema.json delete mode 100644 schema/s2c/notice.schema.json delete mode 100644 schema/s2c/part.schema.json delete mode 100644 schema/s2c/pong.schema.json delete mode 100644 schema/s2c/privmsg.schema.json delete mode 100644 schema/s2c/pubkey.schema.json delete mode 100644 schema/s2c/quit.schema.json delete mode 100644 schema/s2c/topic.schema.json delete mode 100644 schema/s2s/link.schema.json delete mode 100644 schema/s2s/ping.schema.json delete mode 100644 schema/s2s/pong.schema.json delete mode 100644 schema/s2s/relay.schema.json delete mode 100644 schema/s2s/sync.schema.json delete mode 100644 schema/s2s/unlink.schema.json diff --git a/README.md b/README.md index 9a941eb..638213e 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,87 @@ This project builds a chat server that: - Holds session state server-side (message queues, presence, channel membership) - Exposes a minimal, clean HTTP+JSON API — easy to build clients against -- Supports multiple concurrent connections per user session +- Supports multiple concurrent clients per user session - Provides IRC-like semantics: channels, nicks, topics, modes - Uses structured JSON messages with IRC command names and numeric reply codes +## Design Decisions + +### Identity & Sessions — No Accounts + +There are no accounts, no registration, no passwords. Identity is a signing +key; a nick is just a display name. The two are decoupled. + +- **Session creation**: client connects → server assigns a **session UUID** + (user identity for this server), a **client UUID** (this specific device), + and an **opaque auth token** (random bytes, not JWT). +- The auth token implicitly identifies the client. Clients present it via + `Authorization: Bearer `. +- Nicks are changeable; the session UUID is the stable identity. +- Server-assigned UUIDs — clients do not choose their own IDs. + +### Multi-Client Model + +A single user session can have multiple clients (phone, laptop, terminal). + +- Each client gets a **separate server-to-client (S2C) message queue**. +- The server fans out all S2C messages to every active client queue for that + user session. +- `GET /api/v1/messages` delivers from the calling client's specific queue, + identified by the auth token. +- Client queues have **independent expiry/pruning** — one client going offline + doesn't affect others. + +``` +User (session UUID) +├── Client A (client UUID, token, queue) +├── Client B (client UUID, token, queue) +└── Client C (client UUID, token, queue) +``` + +### Message Immutability + +Messages are **immutable** — no editing, no deletion by clients. This is a +deliberate design choice that enables cryptographic signing: if a message could +be modified after signing, signatures would be meaningless. + +### Message Delivery + +- **Long-poll timeout**: 15 seconds +- **Queue depth**: server-configurable, default at least 48 hours worth of + messages +- **No delivery/read receipts** except in DMs +- **Bodies are structured** objects or arrays (never raw strings) — enables + deterministic canonicalization via RFC 8785 JCS for signing + +### Crypto & Signing + +- Servers **relay signatures verbatim** — signatures are key/value metadata on + message objects (`meta.sig`, `meta.alg`). Servers do not verify them. +- Clients handle key authentication via **TOFU** (trust on first use). +- **No key revocation mechanism** — keep your keys safe. +- **PUBKEY** message type for distributing signing keys to channel members. +- **E2E encryption for DMs** is planned for 1.0. + +### Channels + +- **Any user can create channels** — joining a nonexistent channel creates it, + like IRC. +- **Ephemeral** — channels disappear when the last member leaves. +- No channel size limits. +- No channel-level encryption. + +### Federation + +- **Manual server linking only** — no autodiscovery, no mesh. Operators + explicitly configure server links. +- Servers relay messages (including signatures) verbatim. + +### Web Client + +The SPA web client is a **convenience UI**. The primary interface is IRC-style +client apps talking directly to the HTTP API. + ## Architecture ### Transport: HTTP only @@ -30,7 +107,8 @@ All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. - **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds - the connection until messages arrive or timeout. One endpoint for everything. + the connection for up to 15 seconds until messages arrive or timeout. + One endpoint for everything. - **Client sending**: `POST /api/v1/messages` with a `to` field. That's it. - **Server federation**: Servers exchange messages via HTTP to enable multi-server networks (like IRC server linking) @@ -38,6 +116,33 @@ request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. The entire read/write loop for a client is two endpoints. Everything else is channel management and history. +### Session Model + +``` +┌─────────────────────────────────┐ +│ User Session (UUID) │ +│ nick: "alice" │ +│ signing key: ed25519:... │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ Client A │ │ Client B │ ... │ +│ │ UUID │ │ UUID │ │ +│ │ token │ │ token │ │ +│ │ queue │ │ queue │ │ +│ └──────────┘ └──────────┘ │ +└─────────────────────────────────┘ +``` + +- **User session**: server-assigned UUID. Represents a user on this server. + Has a nick (changeable, unique per server at any point in time). +- **Client**: each device/connection gets its own UUID and opaque auth token. + The token is the credential — present it to authenticate. +- **Queue**: each client has an independent S2C message queue. The server fans + out messages to all active client queues for the session. + +Sessions persist across disconnects. Messages queue until retrieved. Client +queues expire independently after a configurable idle timeout. + ### Message Protocol All messages use **IRC command names and numeric reply codes** from RFC 1459/2812. @@ -72,7 +177,7 @@ This enables: |----------|-------------| | PRIVMSG | Send message to channel or user | | NOTICE | Send notice (no auto-reply expected) | -| JOIN | Join a channel | +| JOIN | Join a channel (creates it if nonexistent) | | PART | Leave a channel | | QUIT | Disconnect from server | | NICK | Change nickname | @@ -96,7 +201,7 @@ All C2S commands may be echoed back as S2C (relayed to other users), plus: | Code | Name | Description | |------|-------------------|-------------| -| 001 | RPL_WELCOME | Welcome after registration | +| 001 | RPL_WELCOME | Welcome after session creation | | 002 | RPL_YOURHOST | Server host information | | 322 | RPL_LIST | Channel list entry | | 353 | RPL_NAMREPLY | Names list for a channel | @@ -144,6 +249,8 @@ complete index. ### Canonicalization and Signing Messages support optional cryptographic signatures for integrity verification. +Servers relay signatures verbatim without verifying them — verification is +purely a client-side concern. #### Canonicalization (RFC 8785 JCS) @@ -179,34 +286,9 @@ under canonicalization. {"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} ``` -Servers SHOULD relay PUBKEY messages to all channel members. Clients SHOULD -cache public keys and use them to verify `meta.sig` on incoming messages. -Key distribution is trust-on-first-use (TOFU) by default. - -### Core Concepts - -#### Users - -- Identified by a unique user ID (UUID) -- Authenticate via token (issued at registration or login) -- Have a nick (changeable, unique per server at any point in time) -- Maintain a persistent message queue on the server - -#### Sessions - -- A session represents an authenticated user's connection context -- Session state is **server-held**, not connection-bound -- Multiple devices can share a session (messages delivered to all) -- Sessions persist across disconnects — messages queue until retrieved -- Sessions expire after a configurable idle timeout (default 24h) - -#### Channels - -- Named with `#` prefix (e.g. `#general`) -- Have a topic, mode flags, and member list -- Messages to a channel are queued for all members -- Channel history is stored server-side (configurable depth) -- No eternal logging by default — history rotates +Servers relay PUBKEY messages to all channel members. Clients cache public keys +and use them to verify `meta.sig` on incoming messages. Key distribution is +trust-on-first-use (TOFU). There is no key revocation mechanism. ### API Endpoints @@ -216,9 +298,9 @@ require `Authorization: Bearer ` header. The API is the primary interface — designed for IRC-style clients. The entire client loop is: -1. `POST /api/v1/register` — get a token +1. `POST /api/v1/session` — create a session, get a token 2. `GET /api/v1/state` — see who you are and what channels you're in -3. `GET /api/v1/messages?after=0` — long-poll for all messages (channel, DM, system) +3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system) 4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"` That's the core. Everything else (join, part, history, members) is ancillary. @@ -226,11 +308,11 @@ That's the core. Everything else (join, part, history, members) is ancillary. #### Quick example (curl) ```bash -# Register -TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \ +# Create a session (get session UUID, client UUID, and auth token) +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ -d '{"nick":"alice"}' | jq -r .token) -# Join a channel +# Join a channel (creates it if it doesn't exist) curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ -d '{"command":"JOIN","to":"#general"}' @@ -240,34 +322,41 @@ curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ -d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}' -# Poll for messages (long-poll) -curl -s http://localhost:8080/api/v1/messages?after=0 \ +# Poll for messages (long-poll, 15s timeout) +curl -s "http://localhost:8080/api/v1/messages?timeout=15" \ -H "Authorization: Bearer $TOKEN" ``` -#### Registration +#### Session ``` -POST /api/v1/register — Create account { "nick": "..." } → { id, nick, token } +POST /api/v1/session — Create session { "nick": "..." } + → { session_id, client_id, nick, token } + Token is opaque (random), not JWT. + Token implicitly identifies the client. ``` #### State ``` -GET /api/v1/state — User state: nick, id, and list of joined channels - Replaces separate /me and /channels endpoints +GET /api/v1/state — User state: nick, session_id, client_id, + and list of joined channels ``` #### Messages (unified stream) ``` -GET /api/v1/messages — Single message stream (long-poll supported) +GET /api/v1/messages — Single message stream (long-poll, 15s timeout) All message types: channel, DM, notices, events - Query params: ?after=&timeout=30 + Delivers from the calling client's queue + (identified by auth token) + Query params: ?after=&timeout=15 POST /api/v1/messages — Send a message (IRC command in body) Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] } ``` +Messages are immutable — no edit or delete endpoints. + #### History ``` @@ -281,7 +370,9 @@ GET /api/v1/history — Fetch history for a target (channel or DM) ``` GET /api/v1/channels/all — List all server channels POST /api/v1/channels/join — Join a channel { "channel": "#name" } + Creates the channel if it doesn't exist. DELETE /api/v1/channels/{name} — Part (leave) a channel + Channel is destroyed when last member leaves. GET /api/v1/channels/{name}/members — Channel member list ``` @@ -294,7 +385,8 @@ GET /.well-known/healthcheck.json — Health check ### Federation (Server-to-Server) -Servers can link to form a network, similar to IRC server linking: +Servers can link to form a network, similar to IRC server linking. Links are +**manually configured** — there is no autodiscovery. ``` POST /api/v1/federation/link — Establish server link (mutual auth via shared key) @@ -303,8 +395,8 @@ GET /api/v1/federation/status — Link status ``` Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, -UNLINK, SYNC, PING, and PONG commands. Messages are relayed between servers so -users on different servers can share channels. +UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are +relayed verbatim between servers so users on different servers can share channels. ### Channel Modes @@ -331,7 +423,9 @@ Via environment variables (Viper), following gohttpserver conventions: | `DEBUG` | `false` | Debug mode | | `MAX_HISTORY` | `10000` | Max messages per channel history | | `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) | +| `QUEUE_MAX_AGE` | `172800` | Max client queue age in seconds (default 48h) | | `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) | +| `LONG_POLL_TIMEOUT` | `15` | Long-poll timeout in seconds | | `MOTD` | `""` | Message of the day | | `SERVER_NAME` | hostname | Server display name | | `FEDERATION_KEY` | `""` | Shared key for server linking | @@ -341,11 +435,12 @@ Via environment variables (Viper), following gohttpserver conventions: SQLite by default (single-file, zero-config), with Postgres support for larger deployments. Tables: -- `users` — accounts and auth tokens +- `sessions` — user sessions (UUID, nick, created_at) +- `clients` — client records (UUID, session_id, token_hash, last_seen) - `channels` — channel metadata and modes - `channel_members` — membership and user modes - `messages` — message history (rotated per `MAX_HISTORY`) -- `message_queue` — per-user pending delivery queue +- `client_queues` — per-client pending delivery queues - `server_links` — federation peer configuration ### Project Structure @@ -398,30 +493,30 @@ Per gohttpserver conventions: | Metrics | `github.com/prometheus/client_golang` | | DB | `modernc.org/sqlite` + `database/sql` | -### Web Client - -The server embeds a single-page web client (Preact) served at `/`. This is a -**convenience/reference implementation** — not the primary interface. The -primary intended clients are IRC-style terminal applications, bots, and custom -clients talking directly to the HTTP API. - ### Design Principles 1. **API-first** — the HTTP API is the product. Clients are thin. If you can't build a working IRC-style TUI client in an afternoon, the API is too complex. -2. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. +2. **No accounts** — identity is a signing key, nick is a display name. No + registration, no passwords. Session creation is instant. +3. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. Familiar to anyone who's built IRC clients or bots. -3. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol +4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere. -4. **Server holds state** — clients are stateless. Reconnect, switch devices, - lose connectivity — your messages are waiting. -5. **Structured messages** — JSON with extensible metadata. Bodies are always +5. **Server holds state** — clients are stateless. Reconnect, switch devices, + lose connectivity — your messages are waiting in your client queue. +6. **Structured messages** — JSON with extensible metadata. Bodies are always objects or arrays for deterministic canonicalization (JCS) and signing. -6. **Simple deployment** — single binary, SQLite default, zero mandatory +7. **Immutable messages** — no editing, no deletion. Fits naturally with + cryptographic signatures. +8. **Simple deployment** — single binary, SQLite default, zero mandatory external dependencies. -7. **No eternal logs** — history rotates. Chat should be ephemeral by default. -8. **Federation optional** — single server works standalone. Linking is opt-in. -9. **Signable messages** — optional Ed25519 signatures with TOFU key distribution. +9. **No eternal logs** — history rotates. Chat should be ephemeral by default. + Channels disappear when empty. +10. **Federation optional** — single server works standalone. Linking is manual + and opt-in. +11. **Signable messages** — optional Ed25519 signatures with TOFU key + distribution. Servers relay signatures without verification. ## Status diff --git a/schema/README.md b/schema/README.md index c40c61a..529c331 100644 --- a/schema/README.md +++ b/schema/README.md @@ -1,81 +1,87 @@ -# Message Schema Index +# Message Schemas -JSON Schema (draft 2020-12) definitions for the IRC-style message protocol. +JSON Schema definitions (draft 2020-12) for the chat protocol. Messages use +**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON +over HTTP. -All messages share a common envelope defined in -[`message.schema.json`](message.schema.json). +## Envelope -## Base Envelope +Every message is a JSON object with a `command` field. The format maps directly +to IRC wire format: -| Field | Type | Required | Description | -|-----------|-----------------|----------|-------------| -| `command` | string | ✓ | IRC command name or numeric reply code | -| `from` | string | | Sender nick or server name | -| `to` | string | | Destination channel or nick | -| `params` | array\ | | Additional IRC-style parameters | -| `body` | array \| object | varies | Message body (never a raw string) | -| `meta` | object | | Extensible metadata (signatures, etc.) | -| `id` | string (uuid) | | Server-assigned message ID | -| `ts` | string (date-time) | | Server-assigned timestamp | +``` +IRC: :nick PRIVMSG #channel :hello world +JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": "hello world"} -## Client-to-Server (C2S) +IRC: :server 353 nick = #channel :user1 @op1 +voice1 +JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": "user1 @op1 +voice1"} +``` -| Command | Schema | Description | -|----------|--------|-------------| -| PRIVMSG | [`c2s/privmsg.schema.json`](c2s/privmsg.schema.json) | Send message to channel or user | -| NOTICE | [`c2s/notice.schema.json`](c2s/notice.schema.json) | Send a notice | -| JOIN | [`c2s/join.schema.json`](c2s/join.schema.json) | Join a channel | -| PART | [`c2s/part.schema.json`](c2s/part.schema.json) | Leave a channel | -| QUIT | [`c2s/quit.schema.json`](c2s/quit.schema.json) | Disconnect | -| NICK | [`c2s/nick.schema.json`](c2s/nick.schema.json) | Change nick | -| MODE | [`c2s/mode.schema.json`](c2s/mode.schema.json) | Set/query modes | -| TOPIC | [`c2s/topic.schema.json`](c2s/topic.schema.json) | Set/query topic | -| KICK | [`c2s/kick.schema.json`](c2s/kick.schema.json) | Kick user | -| PING | [`c2s/ping.schema.json`](c2s/ping.schema.json) | Client keepalive | -| PUBKEY | [`c2s/pubkey.schema.json`](c2s/pubkey.schema.json) | Announce public key | +Common fields (see `message.json` for full schema): -## Server-to-Client (S2C) +| Field | Type | Description | +|-----------|----------|-------------------------------------------------------| +| `id` | integer | Server-assigned ID (monotonically increasing) | +| `command` | string | IRC command or 3-digit numeric code | +| `from` | string | Source nick or server name (IRC prefix) | +| `to` | string | Target: #channel or nick | +| `params` | string[] | Middle parameters (mainly for numerics) | +| `body` | string | Trailing parameter (message text) | +| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | +| `meta` | object | Extensible metadata (not in raw IRC) | -### Named Commands +## Commands -| Command | Schema | Description | -|----------|--------|-------------| -| PRIVMSG | [`s2c/privmsg.schema.json`](s2c/privmsg.schema.json) | Relayed message | -| NOTICE | [`s2c/notice.schema.json`](s2c/notice.schema.json) | Server or user notice | -| JOIN | [`s2c/join.schema.json`](s2c/join.schema.json) | User joined channel | -| PART | [`s2c/part.schema.json`](s2c/part.schema.json) | User left channel | -| QUIT | [`s2c/quit.schema.json`](s2c/quit.schema.json) | User disconnected | -| NICK | [`s2c/nick.schema.json`](s2c/nick.schema.json) | Nick change | -| MODE | [`s2c/mode.schema.json`](s2c/mode.schema.json) | Mode change | -| TOPIC | [`s2c/topic.schema.json`](s2c/topic.schema.json) | Topic change | -| KICK | [`s2c/kick.schema.json`](s2c/kick.schema.json) | User kicked | -| PONG | [`s2c/pong.schema.json`](s2c/pong.schema.json) | Server pong | -| PUBKEY | [`s2c/pubkey.schema.json`](s2c/pubkey.schema.json) | Relayed public key | -| ERROR | [`s2c/error.schema.json`](s2c/error.schema.json) | Server error | +IRC commands used for client↔server and server↔server communication. -### Numeric Replies +| Command | File | RFC | Description | +|-----------|---------------------------|-----------|--------------------------------| +| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user | +| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) | +| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel | +| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel | +| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected | +| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname | +| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic | +| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes | +| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | +| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | +| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | -| Code | Name | Schema | Description | -|------|--------------------|--------|-------------| -| 001 | RPL_WELCOME | [`s2c/001.schema.json`](s2c/001.schema.json) | Welcome after registration | -| 002 | RPL_YOURHOST | [`s2c/002.schema.json`](s2c/002.schema.json) | Server host info | -| 322 | RPL_LIST | [`s2c/322.schema.json`](s2c/322.schema.json) | Channel list entry | -| 353 | RPL_NAMREPLY | [`s2c/353.schema.json`](s2c/353.schema.json) | Names list | -| 366 | RPL_ENDOFNAMES | [`s2c/366.schema.json`](s2c/366.schema.json) | End of names list | -| 372 | RPL_MOTD | [`s2c/372.schema.json`](s2c/372.schema.json) | MOTD line | -| 375 | RPL_MOTDSTART | [`s2c/375.schema.json`](s2c/375.schema.json) | Start of MOTD | -| 376 | RPL_ENDOFMOTD | [`s2c/376.schema.json`](s2c/376.schema.json) | End of MOTD | -| 401 | ERR_NOSUCHNICK | [`s2c/401.schema.json`](s2c/401.schema.json) | No such nick/channel | -| 403 | ERR_NOSUCHCHANNEL | [`s2c/403.schema.json`](s2c/403.schema.json) | No such channel | -| 433 | ERR_NICKNAMEINUSE | [`s2c/433.schema.json`](s2c/433.schema.json) | Nick in use | +## Numeric Replies -## Server-to-Server (S2S) +Three-digit codes for server responses, per IRC convention. -| Command | Schema | Description | -|---------|--------|-------------| -| RELAY | [`s2s/relay.schema.json`](s2s/relay.schema.json) | Relay message to linked server | -| LINK | [`s2s/link.schema.json`](s2s/link.schema.json) | Establish server link | -| UNLINK | [`s2s/unlink.schema.json`](s2s/unlink.schema.json) | Tear down link | -| SYNC | [`s2s/sync.schema.json`](s2s/sync.schema.json) | Synchronize state | -| PING | [`s2s/ping.schema.json`](s2s/ping.schema.json) | Server ping | -| PONG | [`s2s/pong.schema.json`](s2s/pong.schema.json) | Server pong | +### Success / Informational (0xx–3xx) + +| Code | Name | File | Description | +|-------|-------------------|-----------------------|--------------------------------| +| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | +| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | +| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | +| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | +| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | +| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | +| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | +| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | +| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | +| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | +| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | +| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | + +### Errors (4xx) + +| Code | Name | File | Description | +|-------|----------------------|-----------------------|--------------------------------| +| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | +| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | +| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | +| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | +| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | + +## Federation (S2S) + +Server-to-server messages use the same command format. Federated servers relay +messages with an additional `origin` field in `meta` to track the source server. +The PING/PONG commands serve as inter-server keepalives. State sync after link +establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands. diff --git a/schema/c2s/join.schema.json b/schema/c2s/join.schema.json deleted file mode 100644 index aab9a7c..0000000 --- a/schema/c2s/join.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/join.schema.json", - "title": "JOIN (C2S)", - "description": "Join a channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "JOIN" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Not used" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/c2s/kick.schema.json b/schema/c2s/kick.schema.json deleted file mode 100644 index 7042bd6..0000000 --- a/schema/c2s/kick.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/kick.schema.json", - "title": "KICK (C2S)", - "description": "Kick user from channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "KICK" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Kick reason" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/c2s/mode.schema.json b/schema/c2s/mode.schema.json deleted file mode 100644 index 3f8cc8a..0000000 --- a/schema/c2s/mode.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/mode.schema.json", - "title": "MODE (C2S)", - "description": "Set/query modes", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "MODE" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Mode params" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/c2s/nick.schema.json b/schema/c2s/nick.schema.json deleted file mode 100644 index 0341173..0000000 --- a/schema/c2s/nick.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/nick.schema.json", - "title": "NICK (C2S)", - "description": "Request nick change", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "NICK" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Not used" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/c2s/notice.schema.json b/schema/c2s/notice.schema.json deleted file mode 100644 index 89cb838..0000000 --- a/schema/c2s/notice.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/notice.schema.json", - "title": "NOTICE (C2S)", - "description": "Send a notice (no auto-reply expected)", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "NOTICE" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Text lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/c2s/part.schema.json b/schema/c2s/part.schema.json deleted file mode 100644 index c572558..0000000 --- a/schema/c2s/part.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/part.schema.json", - "title": "PART (C2S)", - "description": "Leave a channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PART" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Part message" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/c2s/ping.schema.json b/schema/c2s/ping.schema.json deleted file mode 100644 index 6f6b5fe..0000000 --- a/schema/c2s/ping.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/ping.schema.json", - "title": "PING (C2S)", - "description": "Client keepalive", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PING" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Ping token" - } - }, - "required": [ - "command" - ] -} diff --git a/schema/c2s/privmsg.schema.json b/schema/c2s/privmsg.schema.json deleted file mode 100644 index a8032b4..0000000 --- a/schema/c2s/privmsg.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/privmsg.schema.json", - "title": "PRIVMSG (C2S)", - "description": "Send message to channel or user", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PRIVMSG" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Text lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/c2s/pubkey.schema.json b/schema/c2s/pubkey.schema.json deleted file mode 100644 index 47093f5..0000000 --- a/schema/c2s/pubkey.schema.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/pubkey.schema.json", - "title": "PUBKEY (C2S)", - "description": "Announce public signing key", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PUBKEY" - }, - "body": { - "type": "object", - "required": [ - "alg", - "key" - ], - "properties": { - "alg": { - "type": "string", - "description": "Key algorithm (e.g. ed25519)" - }, - "key": { - "type": "string", - "description": "Base64-encoded public key" - } - } - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/c2s/quit.schema.json b/schema/c2s/quit.schema.json deleted file mode 100644 index 9fd70e4..0000000 --- a/schema/c2s/quit.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/quit.schema.json", - "title": "QUIT (C2S)", - "description": "Disconnect from server", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "QUIT" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Quit message" - } - }, - "required": [ - "command" - ] -} diff --git a/schema/c2s/topic.schema.json b/schema/c2s/topic.schema.json deleted file mode 100644 index 5df67cd..0000000 --- a/schema/c2s/topic.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/c2s/topic.schema.json", - "title": "TOPIC (C2S)", - "description": "Set/query topic", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "TOPIC" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Topic lines" - } - }, - "required": [ - "command", - "to" - ] -} diff --git a/schema/commands/JOIN.json b/schema/commands/JOIN.json new file mode 100644 index 0000000..ca7f6c4 --- /dev/null +++ b/schema/commands/JOIN.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/JOIN.json", + "title": "JOIN", + "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "JOIN" }, + "from": { "type": "string", "description": "Nick that joined (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" } + }, + "required": ["command", "to"], + "examples": [ + { "command": "JOIN", "from": "alice", "to": "#general" } + ] +} diff --git a/schema/commands/KICK.json b/schema/commands/KICK.json new file mode 100644 index 0000000..8380f31 --- /dev/null +++ b/schema/commands/KICK.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/KICK.json", + "title": "KICK", + "description": "Kick a user from a channel. RFC 1459 §4.2.8.", + "$ref": "../message.json", + "properties": { + "command": { "const": "KICK" }, + "from": { + "type": "string", + "description": "Nick that performed the kick." + }, + "to": { + "type": "string", + "description": "Channel name.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Kicked nick. e.g. [\"alice\"].", + "minItems": 1, + "maxItems": 1 + }, + "body": { + "type": "string", + "description": "Optional kick reason." + } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": "Behave" } + ] +} diff --git a/schema/commands/MODE.json b/schema/commands/MODE.json new file mode 100644 index 0000000..e2da7f0 --- /dev/null +++ b/schema/commands/MODE.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/MODE.json", + "title": "MODE", + "description": "Set or query channel/user modes. RFC 1459 §4.2.3.", + "$ref": "../message.json", + "properties": { + "command": { "const": "MODE" }, + "from": { + "type": "string", + "description": "Nick that set the mode (S2C only)." + }, + "to": { + "type": "string", + "description": "Channel name.", + "pattern": "^#[a-zA-Z0-9_-]+$" + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].", + "examples": [["+o", "alice"], ["-m"], ["+v", "bob"]] + } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] } + ] +} diff --git a/schema/commands/NICK.json b/schema/commands/NICK.json new file mode 100644 index 0000000..49cf83f --- /dev/null +++ b/schema/commands/NICK.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NICK.json", + "title": "NICK", + "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "NICK" }, + "from": { "type": "string", "description": "Old nick (S2C)." }, + "body": { "type": "string", "description": "New nick.", "minLength": 1, "maxLength": 32, "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$" } + }, + "required": ["command", "body"], + "examples": [ + { "command": "NICK", "from": "oldnick", "body": "newnick" } + ] +} diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json new file mode 100644 index 0000000..1ee166d --- /dev/null +++ b/schema/commands/NOTICE.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/NOTICE.json", + "title": "NOTICE", + "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "NOTICE" }, + "from": { "type": "string" }, + "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." }, + "body": { "type": "string", "description": "Notice text." } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": "Server restarting in 5 minutes" } + ] +} diff --git a/schema/commands/PART.json b/schema/commands/PART.json new file mode 100644 index 0000000..d82eb94 --- /dev/null +++ b/schema/commands/PART.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PART.json", + "title": "PART", + "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PART" }, + "from": { "type": "string", "description": "Nick that left (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "body": { "type": "string", "description": "Optional part reason." } + }, + "required": ["command", "to"], + "examples": [ + { "command": "PART", "from": "alice", "to": "#general", "body": "later" } + ] +} diff --git a/schema/commands/PING.json b/schema/commands/PING.json new file mode 100644 index 0000000..4bb4493 --- /dev/null +++ b/schema/commands/PING.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PING.json", + "title": "PING", + "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PING" }, + "body": { + "type": "string", + "description": "Opaque token to be echoed in PONG." + } + }, + "required": ["command"], + "examples": [ + { "command": "PING", "body": "1707580000" } + ] +} diff --git a/schema/commands/PONG.json b/schema/commands/PONG.json new file mode 100644 index 0000000..400fc0c --- /dev/null +++ b/schema/commands/PONG.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PONG.json", + "title": "PONG", + "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PONG" }, + "from": { + "type": "string", + "description": "Responding server name." + }, + "body": { + "type": "string", + "description": "Echoed token from PING." + } + }, + "required": ["command"], + "examples": [ + { "command": "PONG", "from": "server.example.com", "body": "1707580000" } + ] +} diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json new file mode 100644 index 0000000..877e004 --- /dev/null +++ b/schema/commands/PRIVMSG.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PRIVMSG.json", + "title": "PRIVMSG", + "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PRIVMSG" }, + "from": { "type": "string", "description": "Sender nick (set by server on relay)." }, + "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] }, + "body": { "type": "string", "description": "Message text.", "minLength": 1 } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": "hello world" }, + { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": "hey" } + ] +} diff --git a/schema/commands/QUIT.json b/schema/commands/QUIT.json new file mode 100644 index 0000000..9b66f20 --- /dev/null +++ b/schema/commands/QUIT.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/QUIT.json", + "title": "QUIT", + "description": "User disconnected. S2C only. RFC 1459 §4.1.6.", + "$ref": "../message.json", + "properties": { + "command": { "const": "QUIT" }, + "from": { "type": "string", "description": "Nick that quit." }, + "body": { "type": "string", "description": "Optional quit reason." } + }, + "required": ["command", "from"], + "examples": [ + { "command": "QUIT", "from": "alice", "body": "Connection reset" } + ] +} diff --git a/schema/commands/TOPIC.json b/schema/commands/TOPIC.json new file mode 100644 index 0000000..6ab998c --- /dev/null +++ b/schema/commands/TOPIC.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/TOPIC.json", + "title": "TOPIC", + "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.", + "$ref": "../message.json", + "properties": { + "command": { "const": "TOPIC" }, + "from": { "type": "string", "description": "Nick that changed the topic (S2C)." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "body": { "type": "string", "description": "New topic text. Empty string clears the topic.", "maxLength": 512 } + }, + "required": ["command", "to"], + "examples": [ + { "command": "TOPIC", "from": "alice", "to": "#general", "body": "Welcome to the chat" } + ] +} diff --git a/schema/message.json b/schema/message.json new file mode 100644 index 0000000..286d0ea --- /dev/null +++ b/schema/message.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/message.json", + "title": "IRC Message Envelope", + "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "Server-assigned message ID, monotonically increasing. Present on all server-originated messages." + }, + "command": { + "type": "string", + "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", + "examples": ["PRIVMSG", "JOIN", "001", "353", "433"] + }, + "from": { + "type": "string", + "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." + }, + "to": { + "type": "string", + "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." + }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." + }, + "body": { + "type": "string", + "description": "Message body / trailing parameter. Equivalent to IRC trailing parameter (after the colon)." + }, + "ts": { + "type": "string", + "format": "date-time", + "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." + }, + "meta": { + "type": "object", + "description": "Extensible metadata (signatures, rich content hints, etc.). Not present in original IRC.", + "additionalProperties": true + } + }, + "required": ["command"] +} diff --git a/schema/message.schema.json b/schema/message.schema.json deleted file mode 100644 index 40d2dae..0000000 --- a/schema/message.schema.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/message.schema.json", - "title": "Chat Message Envelope", - "description": "Base message envelope. Bodies MUST be objects or arrays (never raw strings) for deterministic canonicalization (RFC 8785 JCS) and signing.", - "type": "object", - "required": [ - "command" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid", - "description": "Server-assigned UUID" - }, - "ts": { - "type": "string", - "format": "date-time", - "description": "Server-assigned timestamp (ISO 8601)" - }, - "command": { - "type": "string", - "description": "IRC command name or numeric reply code" - }, - "from": { - "type": "string", - "description": "Sender nick or server name" - }, - "to": { - "type": "string", - "description": "Destination: channel (#foo) or nick" - }, - "params": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Additional IRC-style parameters" - }, - "body": { - "oneOf": [ - { - "type": "array" - }, - { - "type": "object" - } - ], - "description": "Message body (array or object, never raw string)" - }, - "meta": { - "type": "object", - "description": "Extensible metadata", - "properties": { - "sig": { - "type": "string", - "description": "Cryptographic signature (base64)" - }, - "alg": { - "type": "string", - "description": "Signature algorithm (e.g. ed25519)" - } - } - } - }, - "additionalProperties": false -} diff --git a/schema/numerics/001.json b/schema/numerics/001.json new file mode 100644 index 0000000..499d686 --- /dev/null +++ b/schema/numerics/001.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json", + "title": "001 RPL_WELCOME", + "description": "Welcome message sent after successful session creation. RFC 2812 §5.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "001" }, + "to": { "type": "string", "description": "Target nick." }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Welcome text lines." + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "001", "to": "alice", "body": ["Welcome to the network, alice"] } + ] +} diff --git a/schema/numerics/002.json b/schema/numerics/002.json new file mode 100644 index 0000000..22166a9 --- /dev/null +++ b/schema/numerics/002.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json", + "title": "002 RPL_YOURHOST", + "description": "Server host info sent after session creation. RFC 2812 §5.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "002" }, + "to": { "type": "string" }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Host info lines." + } + }, + "required": ["command", "to", "body"], + "examples": [ + { "command": "002", "to": "alice", "body": ["Your host is chat.example.com, running version 0.1.0"] } + ] +} diff --git a/schema/numerics/003.json b/schema/numerics/003.json new file mode 100644 index 0000000..d448a8c --- /dev/null +++ b/schema/numerics/003.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/003.json", + "title": "003 RPL_CREATED", + "description": "Server creation date. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "003" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "003", + "to": "alice", + "body": [ + "This server was created 2026-02-01" + ] + } + ] +} diff --git a/schema/numerics/004.json b/schema/numerics/004.json new file mode 100644 index 0000000..2aaf116 --- /dev/null +++ b/schema/numerics/004.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/004.json", + "title": "004 RPL_MYINFO", + "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "004" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[server_name, version, user_modes, channel_modes]." + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "004", + "to": "alice", + "params": [ + "chat.example.com", + "0.1.0", + "o", + "imnst+ov" + ] + } + ] +} diff --git a/schema/numerics/322.json b/schema/numerics/322.json new file mode 100644 index 0000000..3f4d288 --- /dev/null +++ b/schema/numerics/322.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/322.json", + "title": "322 RPL_LIST", + "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "322" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel, visible_count]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Channel topic." + } + }, + "required": [ + "command", + "to", + "params" + ], + "examples": [ + { + "command": "322", + "to": "alice", + "params": [ + "#general", + "12" + ], + "body": [ + "General discussion" + ] + } + ] +} diff --git a/schema/numerics/323.json b/schema/numerics/323.json new file mode 100644 index 0000000..92734b8 --- /dev/null +++ b/schema/numerics/323.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json", + "title": "323 RPL_LISTEND", + "description": "End of channel list. RFC 1459 §6.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "323" }, + "to": { "type": "string" }, + "body": { "const": "End of /LIST" } + }, + "required": ["command", "to"] +} diff --git a/schema/numerics/332.json b/schema/numerics/332.json new file mode 100644 index 0000000..aebb9c6 --- /dev/null +++ b/schema/numerics/332.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/332.json", + "title": "332 RPL_TOPIC", + "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "332" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel]." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Topic text." + } + }, + "required": [ + "command", + "to", + "params", + "body" + ], + "examples": [ + { + "command": "332", + "to": "alice", + "params": [ + "#general" + ], + "body": [ + "Welcome to the chat" + ] + } + ] +} diff --git a/schema/numerics/353.json b/schema/numerics/353.json new file mode 100644 index 0000000..ddf7f4e --- /dev/null +++ b/schema/numerics/353.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/353.json", + "title": "353 RPL_NAMREPLY", + "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "353" + }, + "to": { + "type": "string" + }, + "params": { + "type": "array", + "items": { + "type": "string" + }, + "description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)." + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced." + } + }, + "required": [ + "command", + "to", + "params", + "body" + ], + "examples": [ + { + "command": "353", + "to": "alice", + "params": [ + "=", + "#general" + ], + "body": [ + "@op1 alice bob +voiced1" + ] + } + ] +} diff --git a/schema/numerics/366.json b/schema/numerics/366.json new file mode 100644 index 0000000..a4309c7 --- /dev/null +++ b/schema/numerics/366.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json", + "title": "366 RPL_ENDOFNAMES", + "description": "End of NAMES list. RFC 1459 §6.2.", + "$ref": "../message.json", + "properties": { + "command": { "const": "366" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[channel]." + }, + "body": { "const": "End of /NAMES list" } + }, + "required": ["command", "to", "params"] +} diff --git a/schema/numerics/372.json b/schema/numerics/372.json new file mode 100644 index 0000000..58ddb05 --- /dev/null +++ b/schema/numerics/372.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/372.json", + "title": "372 RPL_MOTD", + "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "372" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "MOTD line text (prefixed with '- ')." + } + }, + "required": [ + "command", + "to", + "body" + ], + "examples": [ + { + "command": "372", + "to": "alice", + "body": [ + "- Welcome to our server!" + ] + } + ] +} diff --git a/schema/numerics/375.json b/schema/numerics/375.json new file mode 100644 index 0000000..4fbe45a --- /dev/null +++ b/schema/numerics/375.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/375.json", + "title": "375 RPL_MOTDSTART", + "description": "Start of MOTD. RFC 2812 \u00a75.1.", + "$ref": "../message.json", + "properties": { + "command": { + "const": "375" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Text lines." + } + }, + "required": [ + "command", + "to" + ] +} diff --git a/schema/numerics/376.json b/schema/numerics/376.json new file mode 100644 index 0000000..7a2ca35 --- /dev/null +++ b/schema/numerics/376.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json", + "title": "376 RPL_ENDOFMOTD", + "description": "End of MOTD. RFC 2812 §5.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "376" }, + "to": { "type": "string" }, + "body": { "const": "End of /MOTD command" } + }, + "required": ["command", "to"] +} diff --git a/schema/numerics/401.json b/schema/numerics/401.json new file mode 100644 index 0000000..be77370 --- /dev/null +++ b/schema/numerics/401.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json", + "title": "401 ERR_NOSUCHNICK", + "description": "No such nick/channel. RFC 1459 §6.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "401" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[target_nick]." + }, + "body": { "const": "No such nick/channel" } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "401", "to": "alice", "params": ["bob"], "body": "No such nick/channel" } + ] +} diff --git a/schema/numerics/403.json b/schema/numerics/403.json new file mode 100644 index 0000000..f3bd5c2 --- /dev/null +++ b/schema/numerics/403.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json", + "title": "403 ERR_NOSUCHCHANNEL", + "description": "No such channel. RFC 1459 §6.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "403" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[channel_name]." + }, + "body": { "const": "No such channel" } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "403", "to": "alice", "params": ["#nonexistent"], "body": "No such channel" } + ] +} diff --git a/schema/numerics/433.json b/schema/numerics/433.json new file mode 100644 index 0000000..6d910af --- /dev/null +++ b/schema/numerics/433.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json", + "title": "433 ERR_NICKNAMEINUSE", + "description": "Nickname is already in use. RFC 1459 §6.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "433" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[requested_nick]." + }, + "body": { "const": "Nickname is already in use" } + }, + "required": ["command", "to", "params"], + "examples": [ + { "command": "433", "to": "*", "params": ["alice"], "body": "Nickname is already in use" } + ] +} diff --git a/schema/numerics/442.json b/schema/numerics/442.json new file mode 100644 index 0000000..9fe8db0 --- /dev/null +++ b/schema/numerics/442.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json", + "title": "442 ERR_NOTONCHANNEL", + "description": "You're not on that channel. RFC 1459 §6.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "442" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[channel]." + }, + "body": { "const": "You're not on that channel" } + }, + "required": ["command", "to", "params"] +} diff --git a/schema/numerics/482.json b/schema/numerics/482.json new file mode 100644 index 0000000..1f0ad75 --- /dev/null +++ b/schema/numerics/482.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json", + "title": "482 ERR_CHANOPRIVSNEEDED", + "description": "You're not channel operator. RFC 1459 §6.1.", + "$ref": "../message.json", + "properties": { + "command": { "const": "482" }, + "to": { "type": "string" }, + "params": { + "type": "array", + "items": { "type": "string" }, + "description": "[channel]." + }, + "body": { "const": "You're not channel operator" } + }, + "required": ["command", "to", "params"] +} diff --git a/schema/s2c/001.schema.json b/schema/s2c/001.schema.json deleted file mode 100644 index 5786bdd..0000000 --- a/schema/s2c/001.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/001.schema.json", - "title": "001 RPL_WELCOME (S2C)", - "description": "Welcome message after registration", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "001" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/002.schema.json b/schema/s2c/002.schema.json deleted file mode 100644 index ef8571f..0000000 --- a/schema/s2c/002.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/002.schema.json", - "title": "002 RPL_YOURHOST (S2C)", - "description": "Server host information", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "002" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/322.schema.json b/schema/s2c/322.schema.json deleted file mode 100644 index f704405..0000000 --- a/schema/s2c/322.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/322.schema.json", - "title": "322 RPL_LIST (S2C)", - "description": "Channel list entry", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "322" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/353.schema.json b/schema/s2c/353.schema.json deleted file mode 100644 index d28371f..0000000 --- a/schema/s2c/353.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/353.schema.json", - "title": "353 RPL_NAMREPLY (S2C)", - "description": "Names list for a channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "353" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/366.schema.json b/schema/s2c/366.schema.json deleted file mode 100644 index 522a411..0000000 --- a/schema/s2c/366.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/366.schema.json", - "title": "366 RPL_ENDOFNAMES (S2C)", - "description": "End of names list", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "366" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/372.schema.json b/schema/s2c/372.schema.json deleted file mode 100644 index d4469cd..0000000 --- a/schema/s2c/372.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/372.schema.json", - "title": "372 RPL_MOTD (S2C)", - "description": "Message of the day line", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "372" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/375.schema.json b/schema/s2c/375.schema.json deleted file mode 100644 index bfa7d88..0000000 --- a/schema/s2c/375.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/375.schema.json", - "title": "375 RPL_MOTDSTART (S2C)", - "description": "Start of MOTD", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "375" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/376.schema.json b/schema/s2c/376.schema.json deleted file mode 100644 index f5d34bb..0000000 --- a/schema/s2c/376.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/376.schema.json", - "title": "376 RPL_ENDOFMOTD (S2C)", - "description": "End of MOTD", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "376" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/401.schema.json b/schema/s2c/401.schema.json deleted file mode 100644 index 1278cba..0000000 --- a/schema/s2c/401.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/401.schema.json", - "title": "401 ERR_NOSUCHNICK (S2C)", - "description": "No such nick or channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "401" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/403.schema.json b/schema/s2c/403.schema.json deleted file mode 100644 index 2e0870d..0000000 --- a/schema/s2c/403.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/403.schema.json", - "title": "403 ERR_NOSUCHCHANNEL (S2C)", - "description": "No such channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "403" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/433.schema.json b/schema/s2c/433.schema.json deleted file mode 100644 index cef8c52..0000000 --- a/schema/s2c/433.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/433.schema.json", - "title": "433 ERR_NICKNAMEINUSE (S2C)", - "description": "Nickname already in use", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "433" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Response lines" - } - }, - "required": [ - "command", - "to", - "body" - ] -} diff --git a/schema/s2c/error.schema.json b/schema/s2c/error.schema.json deleted file mode 100644 index fd4a92d..0000000 --- a/schema/s2c/error.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/error.schema.json", - "title": "ERROR (S2C)", - "description": "Server error", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "ERROR" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Error lines" - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/s2c/join.schema.json b/schema/s2c/join.schema.json deleted file mode 100644 index 10a0ff9..0000000 --- a/schema/s2c/join.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/join.schema.json", - "title": "JOIN (S2C)", - "description": "User joined a channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "JOIN" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Not used" - } - }, - "required": [ - "command", - "from", - "to" - ] -} diff --git a/schema/s2c/kick.schema.json b/schema/s2c/kick.schema.json deleted file mode 100644 index b4503a6..0000000 --- a/schema/s2c/kick.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/kick.schema.json", - "title": "KICK (S2C)", - "description": "User kicked from channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "KICK" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Kick reason" - } - }, - "required": [ - "command", - "from", - "to" - ] -} diff --git a/schema/s2c/mode.schema.json b/schema/s2c/mode.schema.json deleted file mode 100644 index be04679..0000000 --- a/schema/s2c/mode.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/mode.schema.json", - "title": "MODE (S2C)", - "description": "Mode change notification", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "MODE" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Mode params" - } - }, - "required": [ - "command", - "from", - "to" - ] -} diff --git a/schema/s2c/nick.schema.json b/schema/s2c/nick.schema.json deleted file mode 100644 index 8b0cce0..0000000 --- a/schema/s2c/nick.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/nick.schema.json", - "title": "NICK (S2C)", - "description": "User changed nick", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "NICK" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Not used" - } - }, - "required": [ - "command", - "from", - "to" - ] -} diff --git a/schema/s2c/notice.schema.json b/schema/s2c/notice.schema.json deleted file mode 100644 index 738719a..0000000 --- a/schema/s2c/notice.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/notice.schema.json", - "title": "NOTICE (S2C)", - "description": "Server or user notice", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "NOTICE" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Text lines" - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/s2c/part.schema.json b/schema/s2c/part.schema.json deleted file mode 100644 index 9c05a99..0000000 --- a/schema/s2c/part.schema.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/part.schema.json", - "title": "PART (S2C)", - "description": "User left a channel", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PART" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Part message" - } - }, - "required": [ - "command", - "from", - "to" - ] -} diff --git a/schema/s2c/pong.schema.json b/schema/s2c/pong.schema.json deleted file mode 100644 index a184887..0000000 --- a/schema/s2c/pong.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pong.schema.json", - "title": "PONG (S2C)", - "description": "Server keepalive response", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PONG" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Pong token" - } - }, - "required": [ - "command" - ] -} diff --git a/schema/s2c/privmsg.schema.json b/schema/s2c/privmsg.schema.json deleted file mode 100644 index 3e397d6..0000000 --- a/schema/s2c/privmsg.schema.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/privmsg.schema.json", - "title": "PRIVMSG (S2C)", - "description": "Relayed message from a user", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PRIVMSG" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Text lines" - } - }, - "required": [ - "command", - "from", - "to", - "body" - ] -} diff --git a/schema/s2c/pubkey.schema.json b/schema/s2c/pubkey.schema.json deleted file mode 100644 index f720b0c..0000000 --- a/schema/s2c/pubkey.schema.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/pubkey.schema.json", - "title": "PUBKEY (S2C)", - "description": "Relayed public key announcement", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PUBKEY" - }, - "body": { - "type": "object", - "required": [ - "alg", - "key" - ], - "properties": { - "alg": { - "type": "string", - "description": "Key algorithm (e.g. ed25519)" - }, - "key": { - "type": "string", - "description": "Base64-encoded public key" - } - } - } - }, - "required": [ - "command", - "from", - "body" - ] -} diff --git a/schema/s2c/quit.schema.json b/schema/s2c/quit.schema.json deleted file mode 100644 index edb2395..0000000 --- a/schema/s2c/quit.schema.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/quit.schema.json", - "title": "QUIT (S2C)", - "description": "User disconnected", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "QUIT" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Quit message" - } - }, - "required": [ - "command", - "from" - ] -} diff --git a/schema/s2c/topic.schema.json b/schema/s2c/topic.schema.json deleted file mode 100644 index 144b89c..0000000 --- a/schema/s2c/topic.schema.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2c/topic.schema.json", - "title": "TOPIC (S2C)", - "description": "Topic change notification", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "TOPIC" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Topic lines" - } - }, - "required": [ - "command", - "from", - "to", - "body" - ] -} diff --git a/schema/s2s/link.schema.json b/schema/s2s/link.schema.json deleted file mode 100644 index 1e07586..0000000 --- a/schema/s2s/link.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/link.schema.json", - "title": "LINK (S2S)", - "description": "Establish server link", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "LINK" - }, - "body": { - "type": "object", - "description": "Link parameters" - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/s2s/ping.schema.json b/schema/s2s/ping.schema.json deleted file mode 100644 index 14911ed..0000000 --- a/schema/s2s/ping.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/ping.schema.json", - "title": "PING (S2S)", - "description": "Server-to-server keepalive", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PING" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Ping token" - } - }, - "required": [ - "command" - ] -} diff --git a/schema/s2s/pong.schema.json b/schema/s2s/pong.schema.json deleted file mode 100644 index ae8c628..0000000 --- a/schema/s2s/pong.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/pong.schema.json", - "title": "PONG (S2S)", - "description": "Server-to-server keepalive response", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "PONG" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Pong token" - } - }, - "required": [ - "command" - ] -} diff --git a/schema/s2s/relay.schema.json b/schema/s2s/relay.schema.json deleted file mode 100644 index aa6b833..0000000 --- a/schema/s2s/relay.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/relay.schema.json", - "title": "RELAY (S2S)", - "description": "Relay message to linked server", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "RELAY" - }, - "body": { - "type": "object", - "description": "Wrapped message" - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/s2s/sync.schema.json b/schema/s2s/sync.schema.json deleted file mode 100644 index 9218714..0000000 --- a/schema/s2s/sync.schema.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/sync.schema.json", - "title": "SYNC (S2S)", - "description": "Synchronize state between servers", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "SYNC" - }, - "body": { - "type": "object", - "description": "State data" - } - }, - "required": [ - "command", - "body" - ] -} diff --git a/schema/s2s/unlink.schema.json b/schema/s2s/unlink.schema.json deleted file mode 100644 index dc181d3..0000000 --- a/schema/s2s/unlink.schema.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://git.eeqj.de/sneak/chat/schema/s2s/unlink.schema.json", - "title": "UNLINK (S2S)", - "description": "Tear down server link", - "$ref": "../message.schema.json", - "properties": { - "command": { - "const": "UNLINK" - }, - "body": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Unlink reason" - } - }, - "required": [ - "command" - ] -} -- 2.49.1 From ab70f889a602aeb40bf82a385315e12bab577a6d Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:36:02 -0800 Subject: [PATCH 13/22] refactor: structured body (array|object, never string) for canonicalization Message bodies are always arrays of strings (text lines) or objects (structured data like PUBKEY). Never raw strings. This enables: - Multiline messages without escape sequences - Deterministic JSON canonicalization (RFC 8785 JCS) for signing - Structured data where needed Update all schemas: body fields use array type with string items. Update message.json envelope: body is oneOf[array, object], id is UUID. Update README: message envelope table, examples, and canonicalization docs. Update schema/README.md: field types, examples with array bodies. --- README.md | 139 ++++++++++++++++++++--------------- schema/README.md | 33 ++++++--- schema/commands/KICK.json | 19 ++--- schema/commands/NICK.json | 10 ++- schema/commands/NOTICE.json | 8 +- schema/commands/PART.json | 9 ++- schema/commands/PING.json | 8 +- schema/commands/PONG.json | 13 ++-- schema/commands/PRIVMSG.json | 12 ++- schema/commands/QUIT.json | 9 ++- schema/commands/TOPIC.json | 9 ++- schema/message.json | 36 +++++++-- schema/numerics/001.json | 29 ++++++-- schema/numerics/002.json | 28 +++++-- schema/numerics/323.json | 24 ++++-- schema/numerics/366.json | 29 ++++++-- schema/numerics/376.json | 24 ++++-- schema/numerics/401.json | 40 ++++++++-- schema/numerics/403.json | 40 ++++++++-- schema/numerics/433.json | 40 ++++++++-- schema/numerics/442.json | 29 ++++++-- schema/numerics/482.json | 29 ++++++-- 22 files changed, 446 insertions(+), 171 deletions(-) diff --git a/README.md b/README.md index 638213e..a72ee37 100644 --- a/README.md +++ b/README.md @@ -158,86 +158,109 @@ Every message is a JSON object with these fields: | `from` | string | | Sender nick or server name | | `to` | string | | Destination: `#channel` or nick | | `params` | array\ | | Additional IRC-style parameters | -| `body` | array \| object | | Structured body (never a raw string) | -| `meta` | object | | Extensible metadata (signatures, etc.) | -| `id` | string (uuid) | | Server-assigned message ID | +| `body` | array \| object | | Structured body (never a raw string — see below) | +| `id` | string (uuid) | | Server-assigned message UUID | | `ts` | string | | Server-assigned ISO 8601 timestamp | +| `meta` | object | | Extensible metadata (signatures, hashes, etc.) | -**Important:** Message bodies MUST be objects or arrays, never raw strings. -This enables: -- Multiline messages (array of lines) -- Deterministic canonicalization for hashing/signing (RFC 8785 JCS) -- Structured data where needed (e.g. PUBKEY) +**Important:** Message bodies are **structured objects or arrays**, never raw +strings. This is a deliberate departure from IRC wire format that enables: + +- **Multiline messages** — body is a list of lines, no escape sequences +- **Deterministic canonicalization** — for hashing and signing (see below) +- **Structured data** — commands like PUBKEY carry key material as objects + +For text messages, `body` is an array of strings (one per line): + +```json +{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} +{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]} +``` + +For numeric replies with text trailing parameters: + +```json +{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]} +{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]} +``` + +For structured data (keys, etc.), `body` is an object: + +```json +{"command": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}} +``` #### IRC Command Mapping -**Client-to-Server (C2S):** +**Commands (C2S and S2C):** -| Command | Description | -|----------|-------------| -| PRIVMSG | Send message to channel or user | -| NOTICE | Send notice (no auto-reply expected) | -| JOIN | Join a channel (creates it if nonexistent) | -| PART | Leave a channel | -| QUIT | Disconnect from server | -| NICK | Change nickname | -| MODE | Set/query channel or user modes | -| TOPIC | Set/query channel topic | -| KICK | Kick a user from a channel | -| PING | Client keepalive | -| PUBKEY | Announce public signing key | +| Command | RFC | Description | +|-----------|--------------|--------------------------------------| +| `PRIVMSG` | 1459 §4.4.1 | Message to channel or user | +| `NOTICE` | 1459 §4.4.2 | Notice (must not trigger auto-reply) | +| `JOIN` | 1459 §4.2.1 | Join a channel | +| `PART` | 1459 §4.2.2 | Leave a channel | +| `QUIT` | 1459 §4.1.6 | Disconnect from server | +| `NICK` | 1459 §4.1.2 | Change nickname | +| `MODE` | 1459 §4.2.3 | Set/query channel or user modes | +| `TOPIC` | 1459 §4.2.4 | Set/query channel topic | +| `KICK` | 1459 §4.2.8 | Kick user from channel | +| `PING` | 1459 §4.6.2 | Keepalive | +| `PONG` | 1459 §4.6.3 | Keepalive response | +| `PUBKEY` | (extension) | Announce/relay signing public key | -**Server-to-Client (S2C):** - -All C2S commands may be echoed back as S2C (relayed to other users), plus: - -| Command | Description | -|----------|-------------| -| PONG | Server keepalive response | -| PUBKEY | Relayed public key from another user | -| ERROR | Server error message | +All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG). **Numeric Reply Codes (S2C):** -| Code | Name | Description | -|------|-------------------|-------------| -| 001 | RPL_WELCOME | Welcome after session creation | -| 002 | RPL_YOURHOST | Server host information | -| 322 | RPL_LIST | Channel list entry | -| 353 | RPL_NAMREPLY | Names list for a channel | -| 366 | RPL_ENDOFNAMES | End of names list | -| 372 | RPL_MOTD | Message of the day line | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 401 | ERR_NOSUCHNICK | No such nick or channel | -| 403 | ERR_NOSUCHCHANNEL | No such channel | -| 433 | ERR_NICKNAMEINUSE | Nickname already in use | +| Code | Name | Description | +|------|----------------------|-------------| +| 001 | RPL_WELCOME | Welcome after session creation | +| 002 | RPL_YOURHOST | Server host information | +| 003 | RPL_CREATED | Server creation date | +| 004 | RPL_MYINFO | Server info and modes | +| 322 | RPL_LIST | Channel list entry | +| 323 | RPL_LISTEND | End of channel list | +| 332 | RPL_TOPIC | Channel topic | +| 353 | RPL_NAMREPLY | Channel member list | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 372 | RPL_MOTD | MOTD line | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 401 | ERR_NOSUCHNICK | No such nick/channel | +| 403 | ERR_NOSUCHCHANNEL | No such channel | +| 433 | ERR_NICKNAMEINUSE | Nickname already in use | +| 442 | ERR_NOTONCHANNEL | Not on that channel | +| 482 | ERR_CHANOPRIVSNEEDED | Not channel operator | -**Server-to-Server (S2S):** +**Server-to-Server (Federation):** -| Command | Description | -|---------|-------------| -| RELAY | Relay message to linked server | -| LINK | Establish server link | -| UNLINK | Tear down server link | -| SYNC | Synchronize state between servers | -| PING | Server-to-server keepalive | -| PONG | Server-to-server keepalive response | +Federated servers use the same IRC commands. After link establishment, servers +exchange a burst of JOIN, NICK, TOPIC, and MODE commands to sync state. +PING/PONG serve as inter-server keepalives. #### Message Examples ```json -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"], "meta": {"sig": "base64...", "alg": "ed25519"}} +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"]} +{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"], "meta": {"sig": "base64...", "alg": "ed25519"}} + +{"command": "PRIVMSG", "from": "alice", "to": "bob", "body": ["hey, DM"]} + +{"command": "JOIN", "from": "bob", "to": "#general"} + +{"command": "PART", "from": "bob", "to": "#general", "body": ["later"]} + +{"command": "NICK", "from": "oldnick", "body": ["newnick"]} {"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]} -{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["alice", "bob", "@charlie"]} +{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["@op1 alice bob +voiced1"]} -{"command": "JOIN", "from": "bob", "to": "#general", "body": []} +{"command": "433", "to": "*", "params": ["alice"], "body": ["Nickname is already in use"]} -{"command": "ERROR", "body": ["Closing link: connection timeout"]} +{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64..."}} ``` #### JSON Schemas diff --git a/schema/README.md b/schema/README.md index 529c331..65fc6c3 100644 --- a/schema/README.md +++ b/schema/README.md @@ -11,24 +11,33 @@ to IRC wire format: ``` IRC: :nick PRIVMSG #channel :hello world -JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": "hello world"} +JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} IRC: :server 353 nick = #channel :user1 @op1 +voice1 -JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": "user1 @op1 +voice1"} +JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]} + +Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]} +Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}} ``` Common fields (see `message.json` for full schema): -| Field | Type | Description | -|-----------|----------|-------------------------------------------------------| -| `id` | integer | Server-assigned ID (monotonically increasing) | -| `command` | string | IRC command or 3-digit numeric code | -| `from` | string | Source nick or server name (IRC prefix) | -| `to` | string | Target: #channel or nick | -| `params` | string[] | Middle parameters (mainly for numerics) | -| `body` | string | Trailing parameter (message text) | -| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | -| `meta` | object | Extensible metadata (not in raw IRC) | +| Field | Type | Description | +|-----------|----------------|------------------------------------------------------| +| `id` | string (uuid) | Server-assigned message UUID | +| `command` | string | IRC command or 3-digit numeric code | +| `from` | string | Source nick or server name (IRC prefix) | +| `to` | string | Target: #channel or nick | +| `params` | string[] | Middle parameters (mainly for numerics) | +| `body` | array \| object | Structured body — never a raw string (see below) | +| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | +| `meta` | object | Extensible metadata (signatures, hashes, etc.) | + +**Structured bodies:** `body` is always an array of strings (for text) or an +object (for structured data like PUBKEY). Never a raw string. This enables: +- Multiline messages without escape sequences +- Deterministic canonicalization via RFC 8785 JCS for signing +- Structured data where needed ## Commands diff --git a/schema/commands/KICK.json b/schema/commands/KICK.json index 8380f31..25af8a8 100644 --- a/schema/commands/KICK.json +++ b/schema/commands/KICK.json @@ -6,15 +6,8 @@ "$ref": "../message.json", "properties": { "command": { "const": "KICK" }, - "from": { - "type": "string", - "description": "Nick that performed the kick." - }, - "to": { - "type": "string", - "description": "Channel name.", - "pattern": "^#[a-zA-Z0-9_-]+$" - }, + "from": { "type": "string", "description": "Nick that performed the kick." }, + "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, "params": { "type": "array", "items": { "type": "string" }, @@ -23,12 +16,14 @@ "maxItems": 1 }, "body": { - "type": "string", - "description": "Optional kick reason." + "type": "array", + "items": { "type": "string" }, + "description": "Optional kick reason.", + "maxItems": 1 } }, "required": ["command", "to", "params"], "examples": [ - { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": "Behave" } + { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } ] } diff --git a/schema/commands/NICK.json b/schema/commands/NICK.json index 49cf83f..e6e9bef 100644 --- a/schema/commands/NICK.json +++ b/schema/commands/NICK.json @@ -7,10 +7,16 @@ "properties": { "command": { "const": "NICK" }, "from": { "type": "string", "description": "Old nick (S2C)." }, - "body": { "type": "string", "description": "New nick.", "minLength": 1, "maxLength": 32, "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$" } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "New nick (single-element array).", + "minItems": 1, + "maxItems": 1 + } }, "required": ["command", "body"], "examples": [ - { "command": "NICK", "from": "oldnick", "body": "newnick" } + { "command": "NICK", "from": "oldnick", "body": ["newnick"] } ] } diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json index 1ee166d..092e825 100644 --- a/schema/commands/NOTICE.json +++ b/schema/commands/NOTICE.json @@ -8,10 +8,14 @@ "command": { "const": "NOTICE" }, "from": { "type": "string" }, "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." }, - "body": { "type": "string", "description": "Notice text." } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Notice text lines." + } }, "required": ["command", "to", "body"], "examples": [ - { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": "Server restarting in 5 minutes" } + { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] } ] } diff --git a/schema/commands/PART.json b/schema/commands/PART.json index d82eb94..a2bd9ca 100644 --- a/schema/commands/PART.json +++ b/schema/commands/PART.json @@ -8,10 +8,15 @@ "command": { "const": "PART" }, "from": { "type": "string", "description": "Nick that left (S2C)." }, "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, - "body": { "type": "string", "description": "Optional part reason." } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional part reason.", + "maxItems": 1 + } }, "required": ["command", "to"], "examples": [ - { "command": "PART", "from": "alice", "to": "#general", "body": "later" } + { "command": "PART", "from": "alice", "to": "#general", "body": ["later"] } ] } diff --git a/schema/commands/PING.json b/schema/commands/PING.json index 4bb4493..b911dc8 100644 --- a/schema/commands/PING.json +++ b/schema/commands/PING.json @@ -7,12 +7,14 @@ "properties": { "command": { "const": "PING" }, "body": { - "type": "string", - "description": "Opaque token to be echoed in PONG." + "type": "array", + "items": { "type": "string" }, + "description": "Opaque token to be echoed in PONG (single-element array).", + "maxItems": 1 } }, "required": ["command"], "examples": [ - { "command": "PING", "body": "1707580000" } + { "command": "PING", "body": ["1707580000"] } ] } diff --git a/schema/commands/PONG.json b/schema/commands/PONG.json index 400fc0c..6e04620 100644 --- a/schema/commands/PONG.json +++ b/schema/commands/PONG.json @@ -6,17 +6,16 @@ "$ref": "../message.json", "properties": { "command": { "const": "PONG" }, - "from": { - "type": "string", - "description": "Responding server name." - }, + "from": { "type": "string", "description": "Responding server name." }, "body": { - "type": "string", - "description": "Echoed token from PING." + "type": "array", + "items": { "type": "string" }, + "description": "Echoed token from PING (single-element array).", + "maxItems": 1 } }, "required": ["command"], "examples": [ - { "command": "PONG", "from": "server.example.com", "body": "1707580000" } + { "command": "PONG", "from": "server.example.com", "body": ["1707580000"] } ] } diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json index 877e004..9204705 100644 --- a/schema/commands/PRIVMSG.json +++ b/schema/commands/PRIVMSG.json @@ -8,11 +8,17 @@ "command": { "const": "PRIVMSG" }, "from": { "type": "string", "description": "Sender nick (set by server on relay)." }, "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] }, - "body": { "type": "string", "description": "Message text.", "minLength": 1 } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Message lines. One string per line.", + "minItems": 1 + } }, "required": ["command", "to", "body"], "examples": [ - { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": "hello world" }, - { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": "hey" } + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, + { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] }, + { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } } ] } diff --git a/schema/commands/QUIT.json b/schema/commands/QUIT.json index 9b66f20..cfb3a45 100644 --- a/schema/commands/QUIT.json +++ b/schema/commands/QUIT.json @@ -7,10 +7,15 @@ "properties": { "command": { "const": "QUIT" }, "from": { "type": "string", "description": "Nick that quit." }, - "body": { "type": "string", "description": "Optional quit reason." } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional quit reason.", + "maxItems": 1 + } }, "required": ["command", "from"], "examples": [ - { "command": "QUIT", "from": "alice", "body": "Connection reset" } + { "command": "QUIT", "from": "alice", "body": ["Connection reset"] } ] } diff --git a/schema/commands/TOPIC.json b/schema/commands/TOPIC.json index 6ab998c..8ba1365 100644 --- a/schema/commands/TOPIC.json +++ b/schema/commands/TOPIC.json @@ -8,10 +8,15 @@ "command": { "const": "TOPIC" }, "from": { "type": "string", "description": "Nick that changed the topic (S2C)." }, "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, - "body": { "type": "string", "description": "New topic text. Empty string clears the topic.", "maxLength": 512 } + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "New topic text (single-element array). Empty array clears the topic.", + "maxItems": 1 + } }, "required": ["command", "to"], "examples": [ - { "command": "TOPIC", "from": "alice", "to": "#general", "body": "Welcome to the chat" } + { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the chat"] } ] } diff --git a/schema/message.json b/schema/message.json index 286d0ea..875a492 100644 --- a/schema/message.json +++ b/schema/message.json @@ -6,8 +6,9 @@ "type": "object", "properties": { "id": { - "type": "integer", - "description": "Server-assigned message ID, monotonically increasing. Present on all server-originated messages." + "type": "string", + "format": "uuid", + "description": "Server-assigned message UUID. Present on all server-originated messages." }, "command": { "type": "string", @@ -28,8 +29,19 @@ "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." }, "body": { - "type": "string", - "description": "Message body / trailing parameter. Equivalent to IRC trailing parameter (after the colon)." + "oneOf": [ + { + "type": "array", + "items": { "type": "string" }, + "description": "Array of strings (one per line for text messages)." + }, + { + "type": "object", + "description": "Structured data (e.g. PUBKEY key material).", + "additionalProperties": true + } + ], + "description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing." }, "ts": { "type": "string", @@ -38,7 +50,21 @@ }, "meta": { "type": "object", - "description": "Extensible metadata (signatures, rich content hints, etc.). Not present in original IRC.", + "description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.", + "properties": { + "sig": { + "type": "string", + "description": "Base64-encoded cryptographic signature over the canonical message form." + }, + "alg": { + "type": "string", + "description": "Signature algorithm (e.g. 'ed25519')." + }, + "hash": { + "type": "string", + "description": "Hash of the canonical message form (e.g. 'sha256:base64...')." + } + }, "additionalProperties": true } }, diff --git a/schema/numerics/001.json b/schema/numerics/001.json index 499d686..feb2ba0 100644 --- a/schema/numerics/001.json +++ b/schema/numerics/001.json @@ -2,19 +2,36 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/001.json", "title": "001 RPL_WELCOME", - "description": "Welcome message sent after successful session creation. RFC 2812 §5.1.", + "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", "$ref": "../message.json", "properties": { - "command": { "const": "001" }, - "to": { "type": "string", "description": "Target nick." }, + "command": { + "const": "001" + }, + "to": { + "type": "string", + "description": "Target nick." + }, "body": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Welcome text lines." } }, - "required": ["command", "to", "body"], + "required": [ + "command", + "to", + "body" + ], "examples": [ - { "command": "001", "to": "alice", "body": ["Welcome to the network, alice"] } + { + "command": "001", + "to": "alice", + "body": [ + "Welcome to the network, alice" + ] + } ] } diff --git a/schema/numerics/002.json b/schema/numerics/002.json index 22166a9..13fc73c 100644 --- a/schema/numerics/002.json +++ b/schema/numerics/002.json @@ -2,19 +2,35 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/002.json", "title": "002 RPL_YOURHOST", - "description": "Server host info sent after session creation. RFC 2812 §5.1.", + "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", "$ref": "../message.json", "properties": { - "command": { "const": "002" }, - "to": { "type": "string" }, + "command": { + "const": "002" + }, + "to": { + "type": "string" + }, "body": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "Host info lines." } }, - "required": ["command", "to", "body"], + "required": [ + "command", + "to", + "body" + ], "examples": [ - { "command": "002", "to": "alice", "body": ["Your host is chat.example.com, running version 0.1.0"] } + { + "command": "002", + "to": "alice", + "body": [ + "Your host is chat.example.com, running version 0.1.0" + ] + } ] } diff --git a/schema/numerics/323.json b/schema/numerics/323.json index 92734b8..310a061 100644 --- a/schema/numerics/323.json +++ b/schema/numerics/323.json @@ -2,12 +2,26 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/323.json", "title": "323 RPL_LISTEND", - "description": "End of channel list. RFC 1459 §6.2.", + "description": "End of channel list. RFC 1459 \u00a76.2.", "$ref": "../message.json", "properties": { - "command": { "const": "323" }, - "to": { "type": "string" }, - "body": { "const": "End of /LIST" } + "command": { + "const": "323" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /LIST", + "maxItems": 1 + } }, - "required": ["command", "to"] + "required": [ + "command", + "to" + ] } diff --git a/schema/numerics/366.json b/schema/numerics/366.json index a4309c7..2b5d17a 100644 --- a/schema/numerics/366.json +++ b/schema/numerics/366.json @@ -2,17 +2,34 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/366.json", "title": "366 RPL_ENDOFNAMES", - "description": "End of NAMES list. RFC 1459 §6.2.", + "description": "End of NAMES list. RFC 1459 \u00a76.2.", "$ref": "../message.json", "properties": { - "command": { "const": "366" }, - "to": { "type": "string" }, + "command": { + "const": "366" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[channel]." }, - "body": { "const": "End of /NAMES list" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /NAMES list", + "maxItems": 1 + } }, - "required": ["command", "to", "params"] + "required": [ + "command", + "to", + "params" + ] } diff --git a/schema/numerics/376.json b/schema/numerics/376.json index 7a2ca35..5082517 100644 --- a/schema/numerics/376.json +++ b/schema/numerics/376.json @@ -2,12 +2,26 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/376.json", "title": "376 RPL_ENDOFMOTD", - "description": "End of MOTD. RFC 2812 §5.1.", + "description": "End of MOTD. RFC 2812 \u00a75.1.", "$ref": "../message.json", "properties": { - "command": { "const": "376" }, - "to": { "type": "string" }, - "body": { "const": "End of /MOTD command" } + "command": { + "const": "376" + }, + "to": { + "type": "string" + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "End of /MOTD command", + "maxItems": 1 + } }, - "required": ["command", "to"] + "required": [ + "command", + "to" + ] } diff --git a/schema/numerics/401.json b/schema/numerics/401.json index be77370..3213156 100644 --- a/schema/numerics/401.json +++ b/schema/numerics/401.json @@ -2,20 +2,46 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/401.json", "title": "401 ERR_NOSUCHNICK", - "description": "No such nick/channel. RFC 1459 §6.1.", + "description": "No such nick/channel. RFC 1459 \u00a76.1.", "$ref": "../message.json", "properties": { - "command": { "const": "401" }, - "to": { "type": "string" }, + "command": { + "const": "401" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[target_nick]." }, - "body": { "const": "No such nick/channel" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "No such nick/channel", + "maxItems": 1 + } }, - "required": ["command", "to", "params"], + "required": [ + "command", + "to", + "params" + ], "examples": [ - { "command": "401", "to": "alice", "params": ["bob"], "body": "No such nick/channel" } + { + "command": "401", + "to": "alice", + "params": [ + "bob" + ], + "body": [ + "No such nick/channel" + ] + } ] } diff --git a/schema/numerics/403.json b/schema/numerics/403.json index f3bd5c2..bf06774 100644 --- a/schema/numerics/403.json +++ b/schema/numerics/403.json @@ -2,20 +2,46 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/403.json", "title": "403 ERR_NOSUCHCHANNEL", - "description": "No such channel. RFC 1459 §6.1.", + "description": "No such channel. RFC 1459 \u00a76.1.", "$ref": "../message.json", "properties": { - "command": { "const": "403" }, - "to": { "type": "string" }, + "command": { + "const": "403" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[channel_name]." }, - "body": { "const": "No such channel" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "No such channel", + "maxItems": 1 + } }, - "required": ["command", "to", "params"], + "required": [ + "command", + "to", + "params" + ], "examples": [ - { "command": "403", "to": "alice", "params": ["#nonexistent"], "body": "No such channel" } + { + "command": "403", + "to": "alice", + "params": [ + "#nonexistent" + ], + "body": [ + "No such channel" + ] + } ] } diff --git a/schema/numerics/433.json b/schema/numerics/433.json index 6d910af..1f4930e 100644 --- a/schema/numerics/433.json +++ b/schema/numerics/433.json @@ -2,20 +2,46 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/433.json", "title": "433 ERR_NICKNAMEINUSE", - "description": "Nickname is already in use. RFC 1459 §6.1.", + "description": "Nickname is already in use. RFC 1459 \u00a76.1.", "$ref": "../message.json", "properties": { - "command": { "const": "433" }, - "to": { "type": "string" }, + "command": { + "const": "433" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[requested_nick]." }, - "body": { "const": "Nickname is already in use" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Nickname is already in use", + "maxItems": 1 + } }, - "required": ["command", "to", "params"], + "required": [ + "command", + "to", + "params" + ], "examples": [ - { "command": "433", "to": "*", "params": ["alice"], "body": "Nickname is already in use" } + { + "command": "433", + "to": "*", + "params": [ + "alice" + ], + "body": [ + "Nickname is already in use" + ] + } ] } diff --git a/schema/numerics/442.json b/schema/numerics/442.json index 9fe8db0..a36518b 100644 --- a/schema/numerics/442.json +++ b/schema/numerics/442.json @@ -2,17 +2,34 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/442.json", "title": "442 ERR_NOTONCHANNEL", - "description": "You're not on that channel. RFC 1459 §6.1.", + "description": "You're not on that channel. RFC 1459 \u00a76.1.", "$ref": "../message.json", "properties": { - "command": { "const": "442" }, - "to": { "type": "string" }, + "command": { + "const": "442" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[channel]." }, - "body": { "const": "You're not on that channel" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "You're not on that channel", + "maxItems": 1 + } }, - "required": ["command", "to", "params"] + "required": [ + "command", + "to", + "params" + ] } diff --git a/schema/numerics/482.json b/schema/numerics/482.json index 1f0ad75..fb03924 100644 --- a/schema/numerics/482.json +++ b/schema/numerics/482.json @@ -2,17 +2,34 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://git.eeqj.de/sneak/chat/schema/numerics/482.json", "title": "482 ERR_CHANOPRIVSNEEDED", - "description": "You're not channel operator. RFC 1459 §6.1.", + "description": "You're not channel operator. RFC 1459 \u00a76.1.", "$ref": "../message.json", "properties": { - "command": { "const": "482" }, - "to": { "type": "string" }, + "command": { + "const": "482" + }, + "to": { + "type": "string" + }, "params": { "type": "array", - "items": { "type": "string" }, + "items": { + "type": "string" + }, "description": "[channel]." }, - "body": { "const": "You're not channel operator" } + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "You're not channel operator", + "maxItems": 1 + } }, - "required": ["command", "to", "params"] + "required": [ + "command", + "to", + "params" + ] } -- 2.49.1 From 4b074aafd73705af0489f6a7344b5aba7024c510 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 10:36:55 -0800 Subject: [PATCH 14/22] docs: add PUBKEY schema for signing key distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedicated JSON Schema for the PUBKEY command — announces/relays user signing public keys. Body is a structured object with alg and key fields. Already documented in README; this adds the schema file and index entry. --- schema/README.md | 1 + schema/commands/PUBKEY.json | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 schema/commands/PUBKEY.json diff --git a/schema/README.md b/schema/README.md index 65fc6c3..250010b 100644 --- a/schema/README.md +++ b/schema/README.md @@ -56,6 +56,7 @@ IRC commands used for client↔server and server↔server communication. | `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | | `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | | `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | +| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key | ## Numeric Replies diff --git a/schema/commands/PUBKEY.json b/schema/commands/PUBKEY.json new file mode 100644 index 0000000..90fdc30 --- /dev/null +++ b/schema/commands/PUBKEY.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.eeqj.de/sneak/chat/schema/commands/PUBKEY.json", + "title": "PUBKEY", + "description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.", + "$ref": "../message.json", + "properties": { + "command": { "const": "PUBKEY" }, + "from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." }, + "to": { + "type": "string", + "description": "Target: #channel to announce to channel members, or omit for server-wide announcement." + }, + "body": { + "type": "object", + "description": "Key material.", + "properties": { + "alg": { + "type": "string", + "description": "Key algorithm.", + "enum": ["ed25519"] + }, + "key": { + "type": "string", + "description": "Base64-encoded public key." + } + }, + "required": ["alg", "key"], + "additionalProperties": false + } + }, + "required": ["command", "body"], + "examples": [ + { "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }, + { "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } } + ] +} -- 2.49.1 From f7776f8d3f34e4ae08a6c1fb7e03afe4eb5a13ba Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 11:51:01 -0800 Subject: [PATCH 15/22] feat: scaffold IRC-style CLI client (chat-cli) Adds cmd/chat-cli/ with: - tview-based irssi-like TUI (message buffer, status bar, input line) - IRC-style commands: /connect, /nick, /join, /part, /msg, /query, /topic, /names, /list, /window, /quit, /help - Multi-buffer window model with Alt+N switching and unread indicators - Background long-poll goroutine for message delivery - Clean API client wrapper in cmd/chat-cli/api/ - Structured types matching the JSON message protocol --- cmd/chat-cli/api/client.go | 206 +++++++++++++ cmd/chat-cli/api/types.go | 83 ++++++ cmd/chat-cli/main.go | 580 +++++++++++++++++++++++++++++++++++++ cmd/chat-cli/ui.go | 233 +++++++++++++++ go.mod | 10 +- go.sum | 47 +++ 6 files changed, 1157 insertions(+), 2 deletions(-) create mode 100644 cmd/chat-cli/api/client.go create mode 100644 cmd/chat-cli/api/types.go create mode 100644 cmd/chat-cli/main.go create mode 100644 cmd/chat-cli/ui.go diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go new file mode 100644 index 0000000..c2efaec --- /dev/null +++ b/cmd/chat-cli/api/client.go @@ -0,0 +1,206 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client wraps HTTP calls to the chat server API. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +// NewClient creates a new API client. +func NewClient(baseURL string) *Client { + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) do(method, path string, body interface{}) ([]byte, error) { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) + if err != nil { + return nil, fmt.Errorf("request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= 400 { + return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + return data, nil +} + +// CreateSession creates a new session on the server. +func (c *Client) CreateSession(nick string) (*SessionResponse, error) { + data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick}) + if err != nil { + return nil, err + } + var resp SessionResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode session: %w", err) + } + c.Token = resp.Token + return &resp, nil +} + +// GetState returns the current user state. +func (c *Client) GetState() (*StateResponse, error) { + data, err := c.do("GET", "/api/v1/state", nil) + if err != nil { + return nil, err + } + var resp StateResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, fmt.Errorf("decode state: %w", err) + } + return &resp, nil +} + +// SendMessage sends a message (any IRC command). +func (c *Client) SendMessage(msg *Message) error { + _, err := c.do("POST", "/api/v1/messages", msg) + return err +} + +// PollMessages long-polls for new messages. +func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { + // Use a longer HTTP timeout than the server long-poll timeout. + client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second} + + params := url.Values{} + if afterID != "" { + params.Set("after", afterID) + } + params.Set("timeout", fmt.Sprintf("%d", timeout)) + + path := "/api/v1/messages" + if len(params) > 0 { + path += "?" + params.Encode() + } + + req, err := http.NewRequest("GET", c.BaseURL+path, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data)) + } + + // The server may return an array directly or wrapped. + var msgs []Message + if err := json.Unmarshal(data, &msgs); err != nil { + // Try wrapped format. + var wrapped MessagesResponse + if err2 := json.Unmarshal(data, &wrapped); err2 != nil { + return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data)) + } + msgs = wrapped.Messages + } + + return msgs, nil +} + +// JoinChannel joins a channel. +func (c *Client) JoinChannel(channel string) error { + _, err := c.do("POST", "/api/v1/channels/join", map[string]string{"channel": channel}) + return err +} + +// PartChannel leaves a channel. +func (c *Client) PartChannel(channel string) error { + _, err := c.do("DELETE", "/api/v1/channels/"+url.PathEscape(channel), nil) + return err +} + +// ListChannels returns all channels on the server. +func (c *Client) ListChannels() ([]Channel, error) { + data, err := c.do("GET", "/api/v1/channels/all", nil) + if err != nil { + return nil, err + } + var channels []Channel + if err := json.Unmarshal(data, &channels); err != nil { + return nil, err + } + return channels, nil +} + +// GetMembers returns members of a channel. +func (c *Client) GetMembers(channel string) ([]string, error) { + data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil) + if err != nil { + return nil, err + } + var members []string + if err := json.Unmarshal(data, &members); err != nil { + // Try object format. + var obj map[string]interface{} + if err2 := json.Unmarshal(data, &obj); err2 != nil { + return nil, err + } + // Extract member names from whatever format. + return nil, fmt.Errorf("unexpected members format: %s", string(data)) + } + return members, nil +} + +// GetServerInfo returns server info. +func (c *Client) GetServerInfo() (*ServerInfo, error) { + data, err := c.do("GET", "/api/v1/server", nil) + if err != nil { + return nil, err + } + var info ServerInfo + if err := json.Unmarshal(data, &info); err != nil { + return nil, err + } + return &info, nil +} diff --git a/cmd/chat-cli/api/types.go b/cmd/chat-cli/api/types.go new file mode 100644 index 0000000..1655d79 --- /dev/null +++ b/cmd/chat-cli/api/types.go @@ -0,0 +1,83 @@ +package api + +import "time" + +// SessionRequest is the body for POST /api/v1/session. +type SessionRequest struct { + Nick string `json:"nick"` +} + +// SessionResponse is the response from POST /api/v1/session. +type SessionResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Token string `json:"token"` +} + +// StateResponse is the response from GET /api/v1/state. +type StateResponse struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + Nick string `json:"nick"` + Channels []string `json:"channels"` +} + +// Message represents a chat message envelope. +type Message struct { + Command string `json:"command"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Params []string `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` + ID string `json:"id,omitempty"` + TS string `json:"ts,omitempty"` + Meta interface{} `json:"meta,omitempty"` +} + +// BodyLines returns the body as a slice of strings (for text messages). +func (m *Message) BodyLines() []string { + switch v := m.Body.(type) { + case []interface{}: + lines := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + return lines + case []string: + return v + default: + return nil + } +} + +// Channel represents a channel in the list response. +type Channel struct { + Name string `json:"name"` + Topic string `json:"topic"` + Members int `json:"members"` + CreatedAt string `json:"created_at"` +} + +// ServerInfo is the response from GET /api/v1/server. +type ServerInfo struct { + Name string `json:"name"` + MOTD string `json:"motd"` + Version string `json:"version"` +} + +// MessagesResponse wraps polling results. +type MessagesResponse struct { + Messages []Message `json:"messages"` +} + +// ParseTS parses the message timestamp. +func (m *Message) ParseTS() time.Time { + t, err := time.Parse(time.RFC3339Nano, m.TS) + if err != nil { + return time.Now() + } + return t +} diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go new file mode 100644 index 0000000..d0bd2a9 --- /dev/null +++ b/cmd/chat-cli/main.go @@ -0,0 +1,580 @@ +package main + +import ( + "fmt" + "os" + "strings" + "sync" + "time" + + "git.eeqj.de/sneak/chat/cmd/chat-cli/api" +) + +// App holds the application state. +type App struct { + ui *UI + client *api.Client + + mu sync.Mutex + nick string + target string // current target (#channel or nick for DM) + connected bool + lastMsgID string + stopPoll chan struct{} +} + +func main() { + app := &App{ + ui: NewUI(), + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + app.ui.SetStatus(app.nick, "", "disconnected") + + app.ui.AddStatus("Welcome to chat-cli — an IRC-style client") + app.ui.AddStatus("Type [yellow]/connect [white] to begin, or [yellow]/help[white] for commands") + + if err := app.ui.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func (a *App) handleInput(text string) { + if strings.HasPrefix(text, "/") { + a.handleCommand(text) + return + } + + // Plain text → PRIVMSG to current target. + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected. Use /connect ") + return + } + if target == "" { + a.ui.AddStatus("[red]No target. Use /join #channel or /query nick") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send error: %v", err)) + return + } + + // Echo locally. + ts := time.Now().Format("15:04") + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) handleCommand(text string) { + parts := strings.SplitN(text, " ", 2) + cmd := strings.ToLower(parts[0]) + args := "" + if len(parts) > 1 { + args = parts[1] + } + + switch cmd { + case "/connect": + a.cmdConnect(args) + case "/nick": + a.cmdNick(args) + case "/join": + a.cmdJoin(args) + case "/part": + a.cmdPart(args) + case "/msg": + a.cmdMsg(args) + case "/query": + a.cmdQuery(args) + case "/topic": + a.cmdTopic(args) + case "/names": + a.cmdNames() + case "/list": + a.cmdList() + case "/window", "/w": + a.cmdWindow(args) + case "/quit": + a.cmdQuit() + case "/help": + a.cmdHelp() + default: + a.ui.AddStatus(fmt.Sprintf("[red]Unknown command: %s", cmd)) + } +} + +func (a *App) cmdConnect(serverURL string) { + if serverURL == "" { + a.ui.AddStatus("[red]Usage: /connect ") + return + } + serverURL = strings.TrimRight(serverURL, "/") + + a.ui.AddStatus(fmt.Sprintf("Connecting to %s...", serverURL)) + + a.mu.Lock() + nick := a.nick + a.mu.Unlock() + + client := api.NewClient(serverURL) + resp, err := client.CreateSession(nick) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Connection failed: %v", err)) + return + } + + a.mu.Lock() + a.client = client + a.nick = resp.Nick + a.connected = true + a.lastMsgID = "" + a.mu.Unlock() + + a.ui.AddStatus(fmt.Sprintf("[green]Connected! Nick: %s, Session: %s", resp.Nick, resp.SessionID)) + a.ui.SetStatus(resp.Nick, "", "connected") + + // Start polling. + a.stopPoll = make(chan struct{}) + go a.pollLoop() +} + +func (a *App) cmdNick(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /nick ") + return + } + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.mu.Lock() + a.nick = nick + a.mu.Unlock() + a.ui.AddStatus(fmt.Sprintf("Nick set to %s (will be used on connect)", nick)) + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "NICK", + Body: []string{nick}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Nick change failed: %v", err)) + return + } + + a.mu.Lock() + a.nick = nick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(nick, target, "connected") + a.ui.AddStatus(fmt.Sprintf("Nick changed to %s", nick)) +} + +func (a *App) cmdJoin(channel string) { + if channel == "" { + a.ui.AddStatus("[red]Usage: /join #channel") + return + } + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.JoinChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Join failed: %v", err)) + return + } + + a.mu.Lock() + a.target = channel + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(channel) + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Joined %s", channel)) + a.ui.SetStatus(nick, channel, "connected") +} + +func (a *App) cmdPart(channel string) { + a.mu.Lock() + if channel == "" { + channel = a.target + } + connected := a.connected + a.mu.Unlock() + + if channel == "" || !strings.HasPrefix(channel, "#") { + a.ui.AddStatus("[red]No channel to part") + return + } + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.PartChannel(channel) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Part failed: %v", err)) + return + } + + a.ui.AddLine(channel, fmt.Sprintf("[yellow]*** Left %s", channel)) + + a.mu.Lock() + if a.target == channel { + a.target = "" + } + nick := a.nick + a.mu.Unlock() + + a.ui.SwitchBuffer(0) + a.ui.SetStatus(nick, "", "connected") +} + +func (a *App) cmdMsg(args string) { + parts := strings.SplitN(args, " ", 2) + if len(parts) < 2 { + a.ui.AddStatus("[red]Usage: /msg ") + return + } + target, text := parts[0], parts[1] + + a.mu.Lock() + connected := a.connected + nick := a.nick + a.mu.Unlock() + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "PRIVMSG", + To: target, + Body: []string{text}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Send failed: %v", err)) + return + } + + ts := time.Now().Format("15:04") + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, nick, text)) +} + +func (a *App) cmdQuery(nick string) { + if nick == "" { + a.ui.AddStatus("[red]Usage: /query ") + return + } + + a.mu.Lock() + a.target = nick + myNick := a.nick + a.mu.Unlock() + + a.ui.SwitchToBuffer(nick) + a.ui.SetStatus(myNick, nick, "connected") +} + +func (a *App) cmdTopic(args string) { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + if args == "" { + // Query topic. + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic query failed: %v", err)) + } + return + } + + err := a.client.SendMessage(&api.Message{ + Command: "TOPIC", + To: target, + Body: []string{args}, + }) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Topic set failed: %v", err)) + } +} + +func (a *App) cmdNames() { + a.mu.Lock() + target := a.target + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + if !strings.HasPrefix(target, "#") { + a.ui.AddStatus("[red]Not in a channel") + return + } + + members, err := a.client.GetMembers(target) + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]Names failed: %v", err)) + return + } + + a.ui.AddLine(target, fmt.Sprintf("[cyan]*** Members of %s: %s", target, strings.Join(members, " "))) +} + +func (a *App) cmdList() { + a.mu.Lock() + connected := a.connected + a.mu.Unlock() + + if !connected { + a.ui.AddStatus("[red]Not connected") + return + } + + channels, err := a.client.ListChannels() + if err != nil { + a.ui.AddStatus(fmt.Sprintf("[red]List failed: %v", err)) + return + } + + a.ui.AddStatus("[cyan]*** Channel list:") + for _, ch := range channels { + a.ui.AddStatus(fmt.Sprintf(" %s (%d members) %s", ch.Name, ch.Members, ch.Topic)) + } + a.ui.AddStatus("[cyan]*** End of channel list") +} + +func (a *App) cmdWindow(args string) { + if args == "" { + a.ui.AddStatus("[red]Usage: /window ") + return + } + n := 0 + fmt.Sscanf(args, "%d", &n) + a.ui.SwitchBuffer(n) + + a.mu.Lock() + if n < a.ui.BufferCount() && n >= 0 { + // Update target to the buffer name. + // Needs to be done carefully. + } + nick := a.nick + a.mu.Unlock() + + // Update target based on buffer. + if n < a.ui.BufferCount() { + buf := a.ui.buffers[n] + if buf.Name != "(status)" { + a.mu.Lock() + a.target = buf.Name + a.mu.Unlock() + a.ui.SetStatus(nick, buf.Name, "connected") + } else { + a.ui.SetStatus(nick, "", "connected") + } + } +} + +func (a *App) cmdQuit() { + a.mu.Lock() + if a.connected && a.client != nil { + _ = a.client.SendMessage(&api.Message{Command: "QUIT"}) + } + if a.stopPoll != nil { + close(a.stopPoll) + } + a.mu.Unlock() + a.ui.Stop() +} + +func (a *App) cmdHelp() { + help := []string{ + "[cyan]*** chat-cli commands:", + " /connect — Connect to server", + " /nick — Change nickname", + " /join #channel — Join channel", + " /part [#chan] — Leave channel", + " /msg — Send DM", + " /query — Open DM window", + " /topic [text] — View/set topic", + " /names — List channel members", + " /list — List channels", + " /window — Switch buffer (Alt+0-9)", + " /quit — Disconnect and exit", + " /help — This help", + " Plain text sends to current target.", + } + for _, line := range help { + a.ui.AddStatus(line) + } +} + +// pollLoop long-polls for messages in the background. +func (a *App) pollLoop() { + for { + select { + case <-a.stopPoll: + return + default: + } + + a.mu.Lock() + client := a.client + lastID := a.lastMsgID + a.mu.Unlock() + + if client == nil { + return + } + + msgs, err := client.PollMessages(lastID, 15) + if err != nil { + // Transient error — retry after delay. + time.Sleep(2 * time.Second) + continue + } + + for _, msg := range msgs { + a.handleServerMessage(&msg) + if msg.ID != "" { + a.mu.Lock() + a.lastMsgID = msg.ID + a.mu.Unlock() + } + } + } +} + +func (a *App) handleServerMessage(msg *api.Message) { + ts := "" + if msg.TS != "" { + t := msg.ParseTS() + ts = t.Local().Format("15:04") + } else { + ts = time.Now().Format("15:04") + } + + a.mu.Lock() + myNick := a.nick + a.mu.Unlock() + + switch msg.Command { + case "PRIVMSG": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.From == myNick { + // Skip our own echoed messages (already displayed locally). + return + } + target := msg.To + if !strings.HasPrefix(target, "#") { + // DM — use sender's nick as buffer name. + target = msg.From + } + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [green]<%s>[white] %s", ts, msg.From, text)) + + case "JOIN": + target := msg.To + if target != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has joined %s", ts, msg.From, target)) + } + + case "PART": + target := msg.To + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if target != "" { + if reason != "" { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s (%s)", ts, msg.From, target, reason)) + } else { + a.ui.AddLine(target, fmt.Sprintf("[gray]%s [yellow]*** %s has left %s", ts, msg.From, target)) + } + } + + case "QUIT": + lines := msg.BodyLines() + reason := strings.Join(lines, " ") + if reason != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit (%s)", ts, msg.From, reason)) + } else { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s has quit", ts, msg.From)) + } + + case "NICK": + lines := msg.BodyLines() + newNick := "" + if len(lines) > 0 { + newNick = lines[0] + } + if msg.From == myNick && newNick != "" { + a.mu.Lock() + a.nick = newNick + target := a.target + a.mu.Unlock() + a.ui.SetStatus(newNick, target, "connected") + } + a.ui.AddStatus(fmt.Sprintf("[gray]%s [yellow]*** %s is now known as %s", ts, msg.From, newNick)) + + case "NOTICE": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + a.ui.AddStatus(fmt.Sprintf("[gray]%s [magenta]--%s-- %s", ts, msg.From, text)) + + case "TOPIC": + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if msg.To != "" { + a.ui.AddLine(msg.To, fmt.Sprintf("[gray]%s [cyan]*** %s set topic: %s", ts, msg.From, text)) + } + + default: + // Numeric replies and other messages → status window. + lines := msg.BodyLines() + text := strings.Join(lines, " ") + if text != "" { + a.ui.AddStatus(fmt.Sprintf("[gray]%s [white][%s] %s", ts, msg.Command, text)) + } + } +} diff --git a/cmd/chat-cli/ui.go b/cmd/chat-cli/ui.go new file mode 100644 index 0000000..16449f2 --- /dev/null +++ b/cmd/chat-cli/ui.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// Buffer holds messages for a channel/DM/status window. +type Buffer struct { + Name string + Lines []string + Unread int +} + +// UI manages the terminal interface. +type UI struct { + app *tview.Application + messages *tview.TextView + statusBar *tview.TextView + input *tview.InputField + layout *tview.Flex + + buffers []*Buffer + currentBuffer int + + onInput func(string) +} + +// NewUI creates the tview-based IRC-like UI. +func NewUI() *UI { + ui := &UI{ + app: tview.NewApplication(), + buffers: []*Buffer{ + {Name: "(status)", Lines: nil}, + }, + } + + // Message area. + ui.messages = tview.NewTextView(). + SetDynamicColors(true). + SetScrollable(true). + SetWordWrap(true). + SetChangedFunc(func() { + ui.app.Draw() + }) + ui.messages.SetBorder(false) + + // Status bar. + ui.statusBar = tview.NewTextView(). + SetDynamicColors(true) + ui.statusBar.SetBackgroundColor(tcell.ColorNavy) + ui.statusBar.SetTextColor(tcell.ColorWhite) + + // Input field. + ui.input = tview.NewInputField(). + SetFieldBackgroundColor(tcell.ColorBlack). + SetFieldTextColor(tcell.ColorWhite) + ui.input.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + text := ui.input.GetText() + if text == "" { + return + } + ui.input.SetText("") + if ui.onInput != nil { + ui.onInput(text) + } + } + }) + + // Capture Alt+N for window switching. + ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Modifiers()&tcell.ModAlt != 0 { + r := event.Rune() + if r >= '0' && r <= '9' { + idx := int(r - '0') + ui.SwitchBuffer(idx) + return nil + } + } + return event + }) + + // Layout: messages on top, status bar, input at bottom. + ui.layout = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(ui.messages, 0, 1, false). + AddItem(ui.statusBar, 1, 0, false). + AddItem(ui.input, 1, 0, true) + + ui.app.SetRoot(ui.layout, true) + ui.app.SetFocus(ui.input) + + return ui +} + +// Run starts the UI event loop (blocks). +func (ui *UI) Run() error { + return ui.app.Run() +} + +// Stop stops the UI. +func (ui *UI) Stop() { + ui.app.Stop() +} + +// OnInput sets the callback for user input. +func (ui *UI) OnInput(fn func(string)) { + ui.onInput = fn +} + +// AddLine adds a line to the specified buffer. +func (ui *UI) AddLine(bufferName string, line string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(bufferName) + buf.Lines = append(buf.Lines, line) + + // Mark unread if not currently viewing this buffer. + if ui.buffers[ui.currentBuffer] != buf { + buf.Unread++ + ui.refreshStatus() + } + + // If viewing this buffer, append to display. + if ui.buffers[ui.currentBuffer] == buf { + fmt.Fprintln(ui.messages, line) + } + }) +} + +// AddStatus adds a line to the status buffer (buffer 0). +func (ui *UI) AddStatus(line string) { + ts := time.Now().Format("15:04") + ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line)) +} + +// SwitchBuffer switches to the buffer at index n. +func (ui *UI) SwitchBuffer(n int) { + ui.app.QueueUpdateDraw(func() { + if n < 0 || n >= len(ui.buffers) { + return + } + ui.currentBuffer = n + buf := ui.buffers[n] + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SwitchToBuffer switches to the named buffer, creating it if needed. +func (ui *UI) SwitchToBuffer(name string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(name) + for i, b := range ui.buffers { + if b == buf { + ui.currentBuffer = i + break + } + } + buf.Unread = 0 + ui.messages.Clear() + for _, line := range buf.Lines { + fmt.Fprintln(ui.messages, line) + } + ui.messages.ScrollToEnd() + ui.refreshStatus() + }) +} + +// SetStatus updates the status bar text. +func (ui *UI) SetStatus(nick, target, connStatus string) { + ui.app.QueueUpdateDraw(func() { + ui.refreshStatusWith(nick, target, connStatus) + }) +} + +func (ui *UI) refreshStatus() { + // Will be called from the main goroutine via QueueUpdateDraw parent. + // Rebuild status from app state — caller must provide context. +} + +func (ui *UI) refreshStatusWith(nick, target, connStatus string) { + var unreadParts []string + for i, buf := range ui.buffers { + if buf.Unread > 0 { + unreadParts = append(unreadParts, fmt.Sprintf("%d:%s(%d)", i, buf.Name, buf.Unread)) + } + } + unread := "" + if len(unreadParts) > 0 { + unread = " [Act: " + strings.Join(unreadParts, ",") + "]" + } + + bufInfo := fmt.Sprintf("[%d:%s]", ui.currentBuffer, ui.buffers[ui.currentBuffer].Name) + + ui.statusBar.Clear() + fmt.Fprintf(ui.statusBar, " [%s] %s %s %s%s", + connStatus, nick, bufInfo, target, unread) +} + +func (ui *UI) getOrCreateBuffer(name string) *Buffer { + for _, buf := range ui.buffers { + if buf.Name == name { + return buf + } + } + buf := &Buffer{Name: name} + ui.buffers = append(ui.buffers, buf) + return buf +} + +// BufferCount returns the number of buffers. +func (ui *UI) BufferCount() int { + return len(ui.buffers) +} + +// BufferIndex returns the index of a named buffer, or -1. +func (ui *UI) BufferIndex(name string) int { + for i, buf := range ui.buffers { + if buf.Name == name { + return i + } + } + return -1 +} diff --git a/go.mod b/go.mod index 68e8109..b1b1166 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,11 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.8 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -30,6 +33,8 @@ require ( github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/tview v0.42.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -42,8 +47,9 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.8 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 4d55450..7d3b111 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= +github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= @@ -40,6 +44,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -64,6 +70,10 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -86,6 +96,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= @@ -100,19 +111,55 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -- 2.49.1 From 0ee3fd78d2cebe940968157cbee04f2ab0ee20a4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:53:08 -0800 Subject: [PATCH 16/22] refactor: unify all C2S commands through POST /messages All client-to-server commands now go through POST /api/v1/messages with a 'command' field. The server dispatches by command type: - PRIVMSG/NOTICE: send message to channel or user - JOIN: join channel (creates if needed) - PART: leave channel - NICK: change nickname - TOPIC: set channel topic - PING: keepalive (returns PONG) Removed separate routes: - POST /channels/join - DELETE /channels/{channel} - POST /register (renamed to POST /session) - GET /channels/all (moved to GET /channels) Added DB methods: ChangeNick, SetTopic --- internal/db/queries.go | 16 ++- internal/handlers/api.go | 247 ++++++++++++++++++++++++-------------- internal/server/routes.go | 8 +- 3 files changed, 172 insertions(+), 99 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index b051617..974f4db 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -372,7 +372,21 @@ func (s *Database) GetDMsBefore(ctx context.Context, userA, userB int64, beforeI return msgs, nil } -// GetMOTD returns the server MOTD from config. +// ChangeNick updates a user's nickname. +func (s *Database) ChangeNick(ctx context.Context, userID int64, newNick string) error { + _, err := s.db.ExecContext(ctx, + "UPDATE users SET nick = ? WHERE id = ?", newNick, userID) + return err +} + +// SetTopic sets the topic for a channel. +func (s *Database) SetTopic(ctx context.Context, channelName string, _ int64, topic string) error { + _, err := s.db.ExecContext(ctx, + "UPDATE channels SET topic = ? WHERE name = ?", topic, channelName) + return err +} + +// GetServerName returns the server name (unused, config provides this). func (s *Database) GetServerName() string { return "" } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 5624f40..975b7a1 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -30,8 +30,8 @@ func (s *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (int64, s return uid, nick, true } -// HandleRegister creates a new user and returns the auth token. -func (s *Handlers) HandleRegister() http.HandlerFunc { +// HandleCreateSession creates a new user session and returns the auth token. +func (s *Handlers) HandleCreateSession() http.HandlerFunc { type request struct { Nick string `json:"nick"` } @@ -104,68 +104,6 @@ func (s *Handlers) HandleListAllChannels() http.HandlerFunc { } } -// HandleJoinChannel joins a channel (creates it if needed). -func (s *Handlers) HandleJoinChannel() http.HandlerFunc { - type request struct { - Channel string `json:"channel"` - } - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) - return - } - req.Channel = strings.TrimSpace(req.Channel) - if req.Channel == "" { - s.respondJSON(w, r, map[string]string{"error": "channel name required"}, http.StatusBadRequest) - return - } - if !strings.HasPrefix(req.Channel, "#") { - req.Channel = "#" + req.Channel - } - chID, err := s.params.Database.GetOrCreateChannel(r.Context(), req.Channel) - if err != nil { - s.log.Error("get/create channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { - s.log.Error("join channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "joined", "channel": req.Channel}, http.StatusOK) - } -} - -// HandlePartChannel leaves a channel. -func (s *Handlers) HandlePartChannel() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) - if !ok { - return - } - name := "#" + chi.URLParam(r, "channel") - var chID int64 - err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", name).Scan(&chID) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) - return - } - if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { - s.log.Error("part channel failed", "error", err) - s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) - return - } - s.respondJSON(w, r, map[string]string{"status": "parted", "channel": name}, http.StatusOK) - } -} - // HandleChannelMembers returns members of a channel. func (s *Handlers) HandleChannelMembers() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -211,15 +149,17 @@ func (s *Handlers) HandleGetMessages() http.HandlerFunc { } } -// HandleSendMessage sends a message to a channel or user. -// The "to" field determines the target: "#channel" for channels, "nick" for DMs. -func (s *Handlers) HandleSendMessage() http.HandlerFunc { +// HandleSendCommand handles all C2S commands via POST /messages. +// The "command" field dispatches to the appropriate logic. +func (s *Handlers) HandleSendCommand() http.HandlerFunc { type request struct { - To string `json:"to"` - Content string `json:"content"` + Command string `json:"command"` + To string `json:"to"` + Params []string `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` } return func(w http.ResponseWriter, r *http.Request) { - uid, _, ok := s.requireAuth(w, r) + uid, nick, ok := s.requireAuth(w, r) if !ok { return } @@ -228,46 +168,167 @@ func (s *Handlers) HandleSendMessage() http.HandlerFunc { s.respondJSON(w, r, map[string]string{"error": "invalid request"}, http.StatusBadRequest) return } - if strings.TrimSpace(req.Content) == "" { - s.respondJSON(w, r, map[string]string{"error": "content required"}, http.StatusBadRequest) - return - } + req.Command = strings.ToUpper(strings.TrimSpace(req.Command)) req.To = strings.TrimSpace(req.To) - if req.To == "" { - s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) - return + + // Helper to extract body as string lines. + bodyLines := func() []string { + switch v := req.Body.(type) { + case []interface{}: + lines := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + return lines + case []string: + return v + default: + return nil + } } - if strings.HasPrefix(req.To, "#") { - // Channel message + switch req.Command { + case "PRIVMSG", "NOTICE": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required"}, http.StatusBadRequest) + return + } + content := strings.Join(lines, "\n") + + if strings.HasPrefix(req.To, "#") { + // Channel message + var chID int64 + err := s.params.Database.GetDB().QueryRowContext(r.Context(), + "SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, content) + if err != nil { + s.log.Error("send message failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } else { + // DM + targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To) + if err != nil { + s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + return + } + msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, content) + if err != nil { + s.log.Error("send dm failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + } + + case "JOIN": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + chID, err := s.params.Database.GetOrCreateChannel(r.Context(), channel) + if err != nil { + s.log.Error("get/create channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + if err := s.params.Database.JoinChannel(r.Context(), chID, uid); err != nil { + s.log.Error("join channel failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "joined", "channel": channel}, http.StatusOK) + + case "PART": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } var chID int64 err := s.params.Database.GetDB().QueryRowContext(r.Context(), - "SELECT id FROM channels WHERE name = ?", req.To).Scan(&chID) + "SELECT id FROM channels WHERE name = ?", channel).Scan(&chID) if err != nil { s.respondJSON(w, r, map[string]string{"error": "channel not found"}, http.StatusNotFound) return } - msgID, err := s.params.Database.SendMessage(r.Context(), chID, uid, req.Content) - if err != nil { - s.log.Error("send message failed", "error", err) + if err := s.params.Database.PartChannel(r.Context(), chID, uid); err != nil { + s.log.Error("part channel failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) return } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) - } else { - // DM - targetID, err := s.params.Database.GetUserByNick(r.Context(), req.To) - if err != nil { - s.respondJSON(w, r, map[string]string{"error": "user not found"}, http.StatusNotFound) + s.respondJSON(w, r, map[string]string{"status": "parted", "channel": channel}, http.StatusOK) + + case "NICK": + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (new nick)"}, http.StatusBadRequest) return } - msgID, err := s.params.Database.SendDM(r.Context(), uid, targetID, req.Content) - if err != nil { - s.log.Error("send dm failed", "error", err) + newNick := strings.TrimSpace(lines[0]) + if newNick == "" || len(newNick) > 32 { + s.respondJSON(w, r, map[string]string{"error": "nick must be 1-32 characters"}, http.StatusBadRequest) + return + } + if err := s.params.Database.ChangeNick(r.Context(), uid, newNick); err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + s.respondJSON(w, r, map[string]string{"error": "nick already in use"}, http.StatusConflict) + return + } + s.log.Error("change nick failed", "error", err) s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) return } - s.respondJSON(w, r, map[string]any{"id": msgID, "status": "sent"}, http.StatusCreated) + s.respondJSON(w, r, map[string]string{"status": "ok", "nick": newNick}, http.StatusOK) + + case "TOPIC": + if req.To == "" { + s.respondJSON(w, r, map[string]string{"error": "to field required"}, http.StatusBadRequest) + return + } + lines := bodyLines() + if len(lines) == 0 { + s.respondJSON(w, r, map[string]string{"error": "body required (topic text)"}, http.StatusBadRequest) + return + } + topic := strings.Join(lines, " ") + channel := req.To + if !strings.HasPrefix(channel, "#") { + channel = "#" + channel + } + if err := s.params.Database.SetTopic(r.Context(), channel, uid, topic); err != nil { + s.log.Error("set topic failed", "error", err) + s.respondJSON(w, r, map[string]string{"error": "internal error"}, http.StatusInternalServerError) + return + } + s.respondJSON(w, r, map[string]string{"status": "ok", "topic": topic}, http.StatusOK) + + case "PING": + s.respondJSON(w, r, map[string]string{"command": "PONG", "from": s.params.Config.ServerName}, http.StatusOK) + + default: + _ = nick // suppress unused warning + s.respondJSON(w, r, map[string]string{"error": "unknown command: " + req.Command}, http.StatusBadRequest) } } } diff --git a/internal/server/routes.go b/internal/server/routes.go index e5ce9e9..e211492 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -52,18 +52,16 @@ func (s *Server) SetupRoutes() { // API v1 s.router.Route("/api/v1", func(r chi.Router) { r.Get("/server", s.h.HandleServerInfo()) - r.Post("/register", s.h.HandleRegister()) + r.Post("/session", s.h.HandleCreateSession()) // Unified state and message endpoints r.Get("/state", s.h.HandleState()) r.Get("/messages", s.h.HandleGetMessages()) - r.Post("/messages", s.h.HandleSendMessage()) + r.Post("/messages", s.h.HandleSendCommand()) r.Get("/history", s.h.HandleGetHistory()) // Channels - r.Get("/channels/all", s.h.HandleListAllChannels()) - r.Post("/channels/join", s.h.HandleJoinChannel()) - r.Delete("/channels/{channel}", s.h.HandlePartChannel()) + r.Get("/channels", s.h.HandleListAllChannels()) r.Get("/channels/{channel}/members", s.h.HandleChannelMembers()) }) -- 2.49.1 From d06bb5334acb2872360f090d89e429558264996b Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:53:13 -0800 Subject: [PATCH 17/22] feat(cli): update client to use unified command endpoint - JoinChannel/PartChannel now send via POST /messages with command field - ListChannels uses /channels instead of /channels/all - gofmt whitespace fixes --- cmd/chat-cli/api/client.go | 12 +++++------- cmd/chat-cli/main.go | 12 ++++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/cmd/chat-cli/api/client.go b/cmd/chat-cli/api/client.go index c2efaec..a20298c 100644 --- a/cmd/chat-cli/api/client.go +++ b/cmd/chat-cli/api/client.go @@ -148,21 +148,19 @@ func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) { return msgs, nil } -// JoinChannel joins a channel. +// JoinChannel joins a channel via the unified command endpoint. func (c *Client) JoinChannel(channel string) error { - _, err := c.do("POST", "/api/v1/channels/join", map[string]string{"channel": channel}) - return err + return c.SendMessage(&Message{Command: "JOIN", To: channel}) } -// PartChannel leaves a channel. +// PartChannel leaves a channel via the unified command endpoint. func (c *Client) PartChannel(channel string) error { - _, err := c.do("DELETE", "/api/v1/channels/"+url.PathEscape(channel), nil) - return err + return c.SendMessage(&Message{Command: "PART", To: channel}) } // ListChannels returns all channels on the server. func (c *Client) ListChannels() ([]Channel, error) { - data, err := c.do("GET", "/api/v1/channels/all", nil) + data, err := c.do("GET", "/api/v1/channels", nil) if err != nil { return nil, err } diff --git a/cmd/chat-cli/main.go b/cmd/chat-cli/main.go index d0bd2a9..6dfa1f3 100644 --- a/cmd/chat-cli/main.go +++ b/cmd/chat-cli/main.go @@ -15,12 +15,12 @@ type App struct { ui *UI client *api.Client - mu sync.Mutex - nick string - target string // current target (#channel or nick for DM) - connected bool - lastMsgID string - stopPoll chan struct{} + mu sync.Mutex + nick string + target string // current target (#channel or nick for DM) + connected bool + lastMsgID string + stopPoll chan struct{} } func main() { -- 2.49.1 From af3a26fcdfb0e9af9c528189eaaccd0041bbbac7 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:53:17 -0800 Subject: [PATCH 18/22] feat(web): update SPA to use unified command endpoint - Session creation uses /session instead of /register - JOIN/PART via POST /messages with command field - NICK changes via POST /messages with NICK command - Messages sent as PRIVMSG commands with body array - DMs use PRIVMSG command format --- web/src/app.jsx | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 2206972..846dd51 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -51,7 +51,7 @@ function LoginScreen({ onLogin }) { e.preventDefault(); setError(''); try { - const res = await api('/register', { + const res = await api('/session', { method: 'POST', body: JSON.stringify({ nick: nick.trim() }) }); @@ -201,7 +201,7 @@ function App() { name = name.trim(); if (!name.startsWith('#')) name = '#' + name; try { - await api('/channels/join', { method: 'POST', body: JSON.stringify({ channel: name }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'JOIN', to: name }) }); setTabs(prev => { if (prev.find(t => t.type === 'channel' && t.name === name)) return prev; return [...prev, { type: 'channel', name }]; @@ -215,9 +215,8 @@ function App() { }; const partChannel = async (name) => { - const chName = name.replace('#', ''); try { - await api(`/channels/${chName}`, { method: 'DELETE' }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PART', to: name }) }); } catch (err) { /* ignore */ } setTabs(prev => { const next = prev.filter(t => !(t.type === 'channel' && t.name === name)); @@ -267,15 +266,21 @@ function App() { const target = parts[1]; const msg = parts.slice(2).join(' '); try { - await api('/messages', { method: 'POST', body: JSON.stringify({ to: target, content: msg }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to: target, body: [msg] }) }); openDM(target); } catch (err) { addSystemMessage('server', `Failed to send DM: ${err.data?.error || 'error'}`); } return; } - if (cmd === '/nick') { - addSystemMessage('server', 'Nick changes not yet supported'); + if (cmd === '/nick' && parts[1]) { + try { + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'NICK', body: [parts[1]] }) }); + setNick(parts[1]); + addSystemMessage('server', `Nick changed to ${parts[1]}`); + } catch (err) { + addSystemMessage('server', `Nick change failed: ${err.data?.error || 'error'}`); + } return; } addSystemMessage('server', `Unknown command: ${cmd}`); @@ -284,7 +289,7 @@ function App() { const to = tab.type === 'channel' ? tab.name : tab.name; try { - await api('/messages', { method: 'POST', body: JSON.stringify({ to, content: text }) }); + await api('/messages', { method: 'POST', body: JSON.stringify({ command: 'PRIVMSG', to, body: [text] }) }); } catch (err) { addSystemMessage(tab.name, `Send failed: ${err.data?.error || 'error'}`); } -- 2.49.1 From 1a6e929f56ba9d60f8d1d0d9241ae2dc6b8b0bdf Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:53:48 -0800 Subject: [PATCH 19/22] docs: update README API spec for unified command endpoint - Document all C2S commands with required/optional fields table - Remove separate join/part/nick/topic endpoint docs - Update /channels/all to /channels - Update /register to /session --- README.md | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a72ee37..66a6a6b 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ curl -s "http://localhost:8080/api/v1/messages?timeout=15" \ ``` POST /api/v1/session — Create session { "nick": "..." } - → { session_id, client_id, nick, token } + → { id, nick, token } Token is opaque (random), not JWT. Token implicitly identifies the client. ``` @@ -374,8 +374,34 @@ GET /api/v1/messages — Single message stream (long-poll, 15s timeout) Delivers from the calling client's queue (identified by auth token) Query params: ?after=&timeout=15 -POST /api/v1/messages — Send a message (IRC command in body) - Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] } +POST /api/v1/messages — Send any C2S command (dispatched by "command" field) +``` + +All client-to-server commands use `POST /api/v1/messages` with a `command` +field. There are no separate endpoints for join, part, nick, topic, etc. + +| Command | Required Fields | Optional Fields | Description | +|-----------|---------------------|-----------------|-------------| +| `PRIVMSG` | `to`, `body` | `meta` | Message to channel (`#name`) or user (nick) | +| `NOTICE` | `to`, `body` | `meta` | Notice (must not trigger auto-reply) | +| `JOIN` | `to` | | Join a channel (creates if nonexistent) | +| `PART` | `to` | `body` | Leave a channel | +| `NICK` | `body` | | Change nickname — `body: ["newnick"]` | +| `TOPIC` | `to`, `body` | | Set channel topic | +| `MODE` | `to`, `params` | | Set channel/user modes | +| `KICK` | `to`, `params` | `body` | Kick user — `params: ["nick"]`, `body: ["reason"]` | +| `PING` | | | Keepalive (server responds with PONG) | +| `PUBKEY` | `body` | | Announce signing key — `body: {"alg":..., "key":...}` | + +Examples: + +```json +{"command": "PRIVMSG", "to": "#channel", "body": ["hello world"]} +{"command": "JOIN", "to": "#channel"} +{"command": "PART", "to": "#channel"} +{"command": "NICK", "body": ["newnick"]} +{"command": "TOPIC", "to": "#channel", "body": ["new topic text"]} +{"command": "PING"} ``` Messages are immutable — no edit or delete endpoints. @@ -391,14 +417,13 @@ GET /api/v1/history — Fetch history for a target (channel or DM) #### Channels ``` -GET /api/v1/channels/all — List all server channels -POST /api/v1/channels/join — Join a channel { "channel": "#name" } - Creates the channel if it doesn't exist. -DELETE /api/v1/channels/{name} — Part (leave) a channel - Channel is destroyed when last member leaves. +GET /api/v1/channels — List all server channels GET /api/v1/channels/{name}/members — Channel member list ``` +Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART` +commands (see Messages above). + #### Server Info ``` -- 2.49.1 From a2c47f5618aba24d1705098ca3945dbdde95e2fd Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:54:56 -0800 Subject: [PATCH 20/22] docs: add design philosophy section explaining protocol rationale Explains why the protocol is IRC over HTTP (not a new protocol borrowing IRC names), the four specific changes from IRC (HTTP transport, server-held sessions, structured bodies, message metadata), and why the C2S command dispatch resembles but is not JSON-RPC. --- README.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/README.md b/README.md index 66a6a6b..7e7282c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # chat +IRC plus message metadata, a signing system using it, and server-based backlog +queues for multiple connected clients on one nick. All via HTTP. + A modern IRC-inspired chat server written in Go. Decouples session state from transport connections, enabling mobile-friendly persistent sessions over HTTP. @@ -22,6 +25,85 @@ This project builds a chat server that: - Provides IRC-like semantics: channels, nicks, topics, modes - Uses structured JSON messages with IRC command names and numeric reply codes +## Why Not Just Use IRC / XMPP / Matrix? + +This isn't a new protocol that borrows IRC terminology for familiarity. This +**is** IRC — the same command model, the same semantics, the same numeric +reply codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. + +The question isn't "why build something new?" It's "what's the minimum set of +changes to make IRC work on modern devices?" The answer turned out to be four +things: + +### 1. HTTP transport instead of persistent TCP + +IRC requires a persistent TCP connection. That's fine on a desktop. On a phone, +the OS kills your background socket, you lose your session, you miss messages. +Bouncers exist but add complexity and a second point of failure. + +HTTP solves this cleanly: clients poll when they're awake, messages queue when +they're not. Works through firewalls, proxies, CDNs. Every language has an HTTP +client. No custom protocol parsers, no connection state machines. + +### 2. Server-held session state + +In IRC, the TCP connection *is* the session. Disconnect and you're gone — your +nick is released, you leave all channels, messages sent while you're offline +are lost forever. This is IRC's fundamental mobile problem. + +Here, sessions persist independently of connections. Your nick, channel +memberships, and message queue survive disconnects. Multiple devices can share +a session simultaneously, each with its own delivery queue. + +### 3. Structured message bodies + +IRC messages are single lines of text. That's a protocol constraint from 1988, +not a deliberate design choice. It forces multiline content through ugly +workarounds (multiple PRIVMSG commands, paste flood). + +Message bodies here are JSON arrays (one string per line) or objects (for +structured data like key material). This also enables deterministic +canonicalization via RFC 8785 JCS — you can't reliably sign something if the +wire representation is ambiguous. + +### 4. Key/value metadata on messages + +The `meta` field on every message envelope carries extensible attributes — +cryptographic signatures, content hashes, whatever clients want to attach. +IRC has no equivalent; bolting signatures onto IRC requires out-of-band +mechanisms or stuffing data into CTCP. + +### What didn't change + +Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, +`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. +Joining a nonexistent channel creates it. Channels disappear when empty. Nicks +are unique per server. There are no accounts — identity is a key, a nick is a +display name. + +### On the resemblance to JSON-RPC + +All C2S commands go through `POST /messages` with a `command` field that +dispatches the action. This looks like JSON-RPC, but the resemblance is +incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes +`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as +JSON rather than space-delimited text. The command vocabulary is IRC's, not +an invention. + +The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is +a `PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape +as the `JOIN` relayed to channel members. This keeps the protocol simple and +makes signing consistent — you sign the same structure you send. + +### Why not XMPP or Matrix? + +XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented +across incompatible extensions (XEPs). Matrix is a federated append-only event +graph with a spec that runs to hundreds of pages. Both are fine protocols, but +they're solving different problems at different scales. + +This project wants IRC's simplicity with four specific fixes. That's it. + ## Design Decisions ### Identity & Sessions — No Accounts -- 2.49.1 From f4a9ec13bdfe5303a797f8a27d85e947a3b4472b Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:55:13 -0800 Subject: [PATCH 21/22] docs: bold tagline, simplify opening paragraph --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e7282c..9971b1f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # chat -IRC plus message metadata, a signing system using it, and server-based backlog -queues for multiple connected clients on one nick. All via HTTP. +**IRC plus message metadata, a signing system using it, and server-based +backlog queues for multiple connected clients on one nick. All via HTTP.** -A modern IRC-inspired chat server written in Go. Decouples session state from -transport connections, enabling mobile-friendly persistent sessions over HTTP. +A chat server written in Go. Decouples session state from transport +connections, enabling mobile-friendly persistent sessions over HTTP. The **HTTP API is the primary interface**. It's designed to be simple enough that writing a terminal IRC-style client against it is straightforward — just -- 2.49.1 From e60c83f1913f42c6c351eb4316ab4c2dde2f3952 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 17:55:15 -0800 Subject: [PATCH 22/22] docs: add project tagline, ignore built binaries --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9959171..e3ce31c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ data.db vendor/ debug.log web/node_modules/ +chat-cli -- 2.49.1