Compare commits
4 Commits
92d5145ac6
...
change-mod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a515e1505a | ||
| 02279b2654 | |||
| 0250f14fea | |||
| 24362966e0 |
@@ -1,7 +1,7 @@
|
|||||||
// Package main is the entry point for the neoirc-cli client.
|
// Package main is the entry point for the neoirc-cli client.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.eeqj.de/sneak/neoirc/internal/cli"
|
import "sneak.berlin/go/neoirc/internal/cli"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cli.Run()
|
cli.Run()
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
"sneak.berlin/go/neoirc/internal/server"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
|||||||
module git.eeqj.de/sneak/neoirc
|
module sneak.berlin/go/neoirc
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewBroker(t *testing.T) {
|
func TestNewBroker(t *testing.T) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
api "git.eeqj.de/sneak/neoirc/internal/cli/api"
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // loads .env file
|
_ "github.com/joho/godotenv/autoload" // loads .env file
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
|
||||||
_ "github.com/joho/godotenv/autoload" // .env
|
_ "github.com/joho/godotenv/autoload" // .env
|
||||||
_ "modernc.org/sqlite" // driver
|
_ "modernc.org/sqlite" // driver
|
||||||
@@ -135,13 +135,21 @@ type migration struct {
|
|||||||
func (database *Database) runMigrations(
|
func (database *Database) runMigrations(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
) error {
|
) error {
|
||||||
_, err := database.conn.ExecContext(ctx,
|
bootstrap, err := SchemaFiles.ReadFile(
|
||||||
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
"schema/000.sql",
|
||||||
version INTEGER PRIMARY KEY,
|
)
|
||||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"create schema_migrations: %w", err,
|
"read bootstrap migration: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = database.conn.ExecContext(
|
||||||
|
ctx, string(bootstrap),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"execute bootstrap migration: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +278,11 @@ func (database *Database) loadMigrations() (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip bootstrap migration; it is executed separately.
|
||||||
|
if version == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
content, readErr := SchemaFiles.ReadFile(
|
content, readErr := SchemaFiles.ReadFile(
|
||||||
"schema/" + entry.Name(),
|
"schema/" + entry.Name(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -2165,6 +2165,52 @@ func (database *Database) SetChannelSecret(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- No External Messages (+n) ---
|
||||||
|
|
||||||
|
// IsChannelNoExternal checks if a channel has +n mode.
|
||||||
|
func (database *Database) IsChannelNoExternal(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
) (bool, error) {
|
||||||
|
var isNoExternal int
|
||||||
|
|
||||||
|
err := database.conn.QueryRowContext(ctx,
|
||||||
|
`SELECT is_no_external FROM channels
|
||||||
|
WHERE id = ?`,
|
||||||
|
channelID,
|
||||||
|
).Scan(&isNoExternal)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"check no external: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNoExternal != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelNoExternal sets or unsets +n mode.
|
||||||
|
func (database *Database) SetChannelNoExternal(
|
||||||
|
ctx context.Context,
|
||||||
|
channelID int64,
|
||||||
|
noExternal bool,
|
||||||
|
) error {
|
||||||
|
val := 0
|
||||||
|
if noExternal {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.conn.ExecContext(ctx,
|
||||||
|
`UPDATE channels
|
||||||
|
SET is_no_external = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
val, time.Now(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set no external: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListAllChannelsWithCountsFiltered returns all channels
|
// ListAllChannelsWithCountsFiltered returns all channels
|
||||||
// with member counts, excluding secret channels that
|
// with member counts, excluding secret channels that
|
||||||
// the given session is not a member of.
|
// the given session is not a member of.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|||||||
6
internal/db/schema/000.sql
Normal file
6
internal/db/schema/000.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Bootstrap: create the schema_migrations table itself.
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
is_topic_locked INTEGER NOT NULL DEFAULT 1,
|
||||||
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
is_invite_only INTEGER NOT NULL DEFAULT 0,
|
||||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_no_external INTEGER NOT NULL DEFAULT 1,
|
||||||
channel_key TEXT NOT NULL DEFAULT '',
|
channel_key TEXT NOT NULL DEFAULT '',
|
||||||
user_limit INTEGER NOT NULL DEFAULT 0,
|
user_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -1528,87 +1528,6 @@ func (hdlr *Handlers) executeJoin(
|
|||||||
http.StatusOK)
|
http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkJoinAllowed runs Tier 2 restrictions for an
|
|
||||||
// existing channel. Returns true if join is allowed.
|
|
||||||
func (hdlr *Handlers) checkJoinAllowed(
|
|
||||||
writer http.ResponseWriter,
|
|
||||||
request *http.Request,
|
|
||||||
sessionID, clientID int64,
|
|
||||||
nick, channel string,
|
|
||||||
chID int64,
|
|
||||||
suppliedKey string,
|
|
||||||
) bool {
|
|
||||||
ctx := request.Context()
|
|
||||||
|
|
||||||
// 1. Ban check — prevents banned users from joining.
|
|
||||||
isBanned, banErr := hdlr.params.Database.
|
|
||||||
IsSessionBanned(ctx, chID, sessionID)
|
|
||||||
if banErr == nil && isBanned {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrBannedFromChan, nick,
|
|
||||||
[]string{channel},
|
|
||||||
"Cannot join channel (+b)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Invite-only check (+i).
|
|
||||||
isInviteOnly, ioErr := hdlr.params.Database.
|
|
||||||
IsChannelInviteOnly(ctx, chID)
|
|
||||||
if ioErr == nil && isInviteOnly {
|
|
||||||
hasInvite, invErr := hdlr.params.Database.
|
|
||||||
HasChannelInvite(ctx, chID, sessionID)
|
|
||||||
if invErr != nil || !hasInvite {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrInviteOnlyChan, nick,
|
|
||||||
[]string{channel},
|
|
||||||
"Cannot join channel (+i)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Channel key check (+k).
|
|
||||||
key, keyErr := hdlr.params.Database.
|
|
||||||
GetChannelKey(ctx, chID)
|
|
||||||
if keyErr == nil && key != "" {
|
|
||||||
if suppliedKey != key {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrBadChannelKey, nick,
|
|
||||||
[]string{channel},
|
|
||||||
"Cannot join channel (+k)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. User limit check (+l).
|
|
||||||
limit, limErr := hdlr.params.Database.
|
|
||||||
GetChannelUserLimit(ctx, chID)
|
|
||||||
if limErr == nil && limit > 0 {
|
|
||||||
count, cntErr := hdlr.params.Database.
|
|
||||||
CountChannelMembers(ctx, chID)
|
|
||||||
if cntErr == nil && count >= int64(limit) {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrChannelIsFull, nick,
|
|
||||||
[]string{channel},
|
|
||||||
"Cannot join channel (+l)",
|
|
||||||
)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
|
// deliverJoinNumerics sends RPL_TOPIC/RPL_NOTOPIC,
|
||||||
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
|
// RPL_NAMREPLY, and RPL_ENDOFNAMES to the joining client.
|
||||||
func (hdlr *Handlers) deliverJoinNumerics(
|
func (hdlr *Handlers) deliverJoinNumerics(
|
||||||
@@ -2097,62 +2016,14 @@ func (hdlr *Handlers) handleChannelMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildChannelModeString constructs the current mode
|
// buildChannelModeString constructs the current mode
|
||||||
// string for a channel, including +n (always on), +t, +m,
|
// string for a channel by delegating to the service
|
||||||
// +i, +s, +k, +l, and +H with their parameters.
|
// layer's QueryChannelMode, which returns the complete
|
||||||
|
// mode string including all flags and parameters.
|
||||||
func (hdlr *Handlers) buildChannelModeString(
|
func (hdlr *Handlers) buildChannelModeString(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
) string {
|
) string {
|
||||||
modes := "+n"
|
return hdlr.svc.QueryChannelMode(ctx, chID)
|
||||||
|
|
||||||
isInviteOnly, ioErr := hdlr.params.Database.
|
|
||||||
IsChannelInviteOnly(ctx, chID)
|
|
||||||
if ioErr == nil && isInviteOnly {
|
|
||||||
modes += "i"
|
|
||||||
}
|
|
||||||
|
|
||||||
isModerated, modErr := hdlr.params.Database.
|
|
||||||
IsChannelModerated(ctx, chID)
|
|
||||||
if modErr == nil && isModerated {
|
|
||||||
modes += "m"
|
|
||||||
}
|
|
||||||
|
|
||||||
isSecret, secErr := hdlr.params.Database.
|
|
||||||
IsChannelSecret(ctx, chID)
|
|
||||||
if secErr == nil && isSecret {
|
|
||||||
modes += "s"
|
|
||||||
}
|
|
||||||
|
|
||||||
isTopicLocked, tlErr := hdlr.params.Database.
|
|
||||||
IsChannelTopicLocked(ctx, chID)
|
|
||||||
if tlErr == nil && isTopicLocked {
|
|
||||||
modes += "t"
|
|
||||||
}
|
|
||||||
|
|
||||||
var modeParams string
|
|
||||||
|
|
||||||
key, keyErr := hdlr.params.Database.
|
|
||||||
GetChannelKey(ctx, chID)
|
|
||||||
if keyErr == nil && key != "" {
|
|
||||||
modes += "k"
|
|
||||||
modeParams += " " + key
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, limErr := hdlr.params.Database.
|
|
||||||
GetChannelUserLimit(ctx, chID)
|
|
||||||
if limErr == nil && limit > 0 {
|
|
||||||
modes += "l"
|
|
||||||
modeParams += " " + strconv.Itoa(limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
bits, bitsErr := hdlr.params.Database.
|
|
||||||
GetChannelHashcashBits(ctx, chID)
|
|
||||||
if bitsErr == nil && bits > 0 {
|
|
||||||
modes += "H"
|
|
||||||
modeParams += " " + strconv.Itoa(bits)
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes + modeParams
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryChannelMode sends RPL_CHANNELMODEIS and
|
// queryChannelMode sends RPL_CHANNELMODEIS and
|
||||||
@@ -4091,3 +3962,76 @@ func (hdlr *Handlers) deliverWhoisIdle(
|
|||||||
"seconds idle, signon time",
|
"seconds idle, signon time",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fanOut inserts a message and enqueues it to all given
|
||||||
|
// sessions.
|
||||||
|
func (hdlr *Handlers) fanOut(
|
||||||
|
request *http.Request,
|
||||||
|
command, from, target string,
|
||||||
|
body json.RawMessage,
|
||||||
|
meta json.RawMessage,
|
||||||
|
sessionIDs []int64,
|
||||||
|
) (string, error) {
|
||||||
|
dbID, msgUUID, err := hdlr.params.Database.InsertMessage(
|
||||||
|
request.Context(), command, from, target,
|
||||||
|
nil, body, meta,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("insert message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sid := range sessionIDs {
|
||||||
|
enqErr := hdlr.params.Database.EnqueueToSession(
|
||||||
|
request.Context(), sid, dbID,
|
||||||
|
)
|
||||||
|
if enqErr != nil {
|
||||||
|
hdlr.log.Error("enqueue failed",
|
||||||
|
"error", enqErr, "session_id", sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.broker.Notify(sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgUUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fanOutSilent calls fanOut and discards the UUID.
|
||||||
|
func (hdlr *Handlers) fanOutSilent(
|
||||||
|
request *http.Request,
|
||||||
|
command, from, target string,
|
||||||
|
body json.RawMessage,
|
||||||
|
sessionIDs []int64,
|
||||||
|
) error {
|
||||||
|
_, err := hdlr.fanOut(
|
||||||
|
request, command, from, target,
|
||||||
|
body, nil, sessionIDs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireChannelOp checks if the session is a channel
|
||||||
|
// operator and sends ERR_CHANOPRIVSNEEDED if not.
|
||||||
|
func (hdlr *Handlers) requireChannelOp(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
sessionID, clientID int64,
|
||||||
|
nick, channel string,
|
||||||
|
chID int64,
|
||||||
|
) bool {
|
||||||
|
isOp, err := hdlr.params.Database.IsChannelOperator(
|
||||||
|
request.Context(), chID, sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOp {
|
||||||
|
hdlr.respondIRCError(
|
||||||
|
writer, request, clientID, sessionID,
|
||||||
|
irc.ErrChanOpPrivsNeeded, nick,
|
||||||
|
[]string{channel},
|
||||||
|
"You're not channel operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,21 +18,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/server"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
"sneak.berlin/go/neoirc/internal/server"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
const minPasswordLength = 8
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/healthcheck"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
|
"sneak.berlin/go/neoirc/internal/healthcheck"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
var errUnauthorized = errors.New("unauthorized")
|
var errUnauthorized = errors.New("unauthorized")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/hashcash"
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testBits = 2
|
const testBits = 2
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Healthcheck.
|
// Params defines the dependencies for creating a Healthcheck.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||||
@@ -179,7 +179,7 @@ func (c *Conn) joinChannel(
|
|||||||
channel string,
|
channel string,
|
||||||
) {
|
) {
|
||||||
result, err := c.svc.JoinChannel(
|
result, err := c.svc.JoinChannel(
|
||||||
ctx, c.sessionID, c.nick, channel,
|
ctx, c.sessionID, c.nick, channel, "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendIRCError(err)
|
c.sendIRCError(err)
|
||||||
@@ -490,6 +490,124 @@ func (c *Conn) handleChannelMode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modeResult holds the delta strings produced by a
|
||||||
|
// single mode-char application.
|
||||||
|
type modeResult struct {
|
||||||
|
applied string
|
||||||
|
appliedArgs string
|
||||||
|
consumed int
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyHashcashMode handles +H/-H (hashcash difficulty).
|
||||||
|
func (c *Conn) applyHashcashMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
adding bool,
|
||||||
|
args []string,
|
||||||
|
argIdx int,
|
||||||
|
) modeResult {
|
||||||
|
if !adding {
|
||||||
|
_ = c.database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "-H",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if argIdx >= len(args) {
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bitsStr := args[argIdx]
|
||||||
|
|
||||||
|
bits, parseErr := strconv.Atoi(bitsStr)
|
||||||
|
if parseErr != nil ||
|
||||||
|
bits < 1 || bits > maxHashcashBits {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUnknownMode, "H",
|
||||||
|
"is unknown mode char to me",
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 1,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.database.SetChannelHashcashBits(
|
||||||
|
ctx, chID, bits,
|
||||||
|
)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "+H",
|
||||||
|
appliedArgs: " " + bitsStr,
|
||||||
|
consumed: 1,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMemberMode handles +o/-o and +v/-v.
|
||||||
|
func (c *Conn) applyMemberMode(
|
||||||
|
ctx context.Context,
|
||||||
|
chID int64,
|
||||||
|
channel string,
|
||||||
|
modeChar rune,
|
||||||
|
adding bool,
|
||||||
|
args []string,
|
||||||
|
argIdx int,
|
||||||
|
) modeResult {
|
||||||
|
if argIdx >= len(args) {
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 0,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNick := args[argIdx]
|
||||||
|
|
||||||
|
err := c.svc.ApplyMemberMode(
|
||||||
|
ctx, chID, channel,
|
||||||
|
targetNick, modeChar, adding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.sendIRCError(err)
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: "",
|
||||||
|
appliedArgs: "",
|
||||||
|
consumed: 1,
|
||||||
|
skip: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "+"
|
||||||
|
if !adding {
|
||||||
|
prefix = "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return modeResult{
|
||||||
|
applied: prefix + string(modeChar),
|
||||||
|
appliedArgs: " " + targetNick,
|
||||||
|
consumed: 1,
|
||||||
|
skip: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// applyChannelModes applies mode changes using the
|
// applyChannelModes applies mode changes using the
|
||||||
// service for individual mode operations.
|
// service for individual mode operations.
|
||||||
func (c *Conn) applyChannelModes(
|
func (c *Conn) applyChannelModes(
|
||||||
@@ -505,52 +623,57 @@ func (c *Conn) applyChannelModes(
|
|||||||
appliedArgs := ""
|
appliedArgs := ""
|
||||||
|
|
||||||
for _, modeChar := range modeStr {
|
for _, modeChar := range modeStr {
|
||||||
|
var res modeResult
|
||||||
|
|
||||||
switch modeChar {
|
switch modeChar {
|
||||||
case '+':
|
case '+':
|
||||||
adding = true
|
adding = true
|
||||||
|
|
||||||
|
continue
|
||||||
case '-':
|
case '-':
|
||||||
adding = false
|
adding = false
|
||||||
case 'm', 't':
|
|
||||||
|
continue
|
||||||
|
case 'i', 'm', 'n', 's', 't':
|
||||||
_ = c.svc.SetChannelFlag(
|
_ = c.svc.SetChannelFlag(
|
||||||
ctx, chID, modeChar, adding,
|
ctx, chID, modeChar, adding,
|
||||||
)
|
)
|
||||||
|
|
||||||
if adding {
|
prefix := "+"
|
||||||
applied += "+" + string(modeChar)
|
if !adding {
|
||||||
} else {
|
prefix = "-"
|
||||||
applied += "-" + string(modeChar)
|
|
||||||
}
|
|
||||||
case 'o', 'v':
|
|
||||||
if argIdx >= len(args) {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
targetNick := args[argIdx]
|
res = modeResult{
|
||||||
argIdx++
|
applied: prefix + string(modeChar),
|
||||||
|
appliedArgs: "",
|
||||||
err := c.svc.ApplyMemberMode(
|
consumed: 0,
|
||||||
ctx, chID, channel,
|
skip: false,
|
||||||
targetNick, modeChar, adding,
|
}
|
||||||
|
case 'H':
|
||||||
|
res = c.applyHashcashMode(
|
||||||
|
ctx, chID, adding, args, argIdx,
|
||||||
|
)
|
||||||
|
case 'o', 'v':
|
||||||
|
res = c.applyMemberMode(
|
||||||
|
ctx, chID, channel,
|
||||||
|
modeChar, adding, args, argIdx,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
c.sendIRCError(err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if adding {
|
|
||||||
applied += "+" + string(modeChar)
|
|
||||||
} else {
|
|
||||||
applied += "-" + string(modeChar)
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedArgs += " " + targetNick
|
|
||||||
default:
|
default:
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUnknownMode,
|
irc.ErrUnknownMode,
|
||||||
string(modeChar),
|
string(modeChar),
|
||||||
"is unknown mode char to me",
|
"is unknown mode char to me",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
argIdx += res.consumed
|
||||||
|
|
||||||
|
if !res.skip {
|
||||||
|
applied += res.applied
|
||||||
|
appliedArgs += res.appliedArgs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,23 +11,24 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxLineLen = 512
|
maxLineLen = 512
|
||||||
readTimeout = 5 * time.Minute
|
readTimeout = 5 * time.Minute
|
||||||
writeTimeout = 30 * time.Second
|
writeTimeout = 30 * time.Second
|
||||||
dnsTimeout = 3 * time.Second
|
dnsTimeout = 3 * time.Second
|
||||||
pollInterval = 100 * time.Millisecond
|
pollInterval = 100 * time.Millisecond
|
||||||
pingInterval = 90 * time.Second
|
pingInterval = 90 * time.Second
|
||||||
pongDeadline = 30 * time.Second
|
pongDeadline = 30 * time.Second
|
||||||
maxNickLen = 32
|
maxNickLen = 32
|
||||||
minPasswordLen = 8
|
minPasswordLen = 8
|
||||||
|
maxHashcashBits = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdHandler is the signature for registered IRC command
|
// cmdHandler is the signature for registered IRC command
|
||||||
@@ -434,7 +435,7 @@ func (c *Conn) deliverWelcome() {
|
|||||||
"CHANTYPES=#",
|
"CHANTYPES=#",
|
||||||
"NICKLEN=32",
|
"NICKLEN=32",
|
||||||
"PREFIX=(ov)@+",
|
"PREFIX=(ov)@+",
|
||||||
"CHANMODES=,,H,mnst",
|
"CHANMODES=,,H,imnst",
|
||||||
"NETWORK="+c.serverSfx,
|
"NETWORK="+c.serverSfx,
|
||||||
"are supported by this server",
|
"are supported by this server",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestServer creates a Server suitable for testing.
|
// NewTestServer creates a Server suitable for testing.
|
||||||
@@ -19,12 +19,9 @@ func NewTestServer(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
brk *broker.Broker,
|
brk *broker.Broker,
|
||||||
) *Server {
|
) *Server {
|
||||||
svc := &service.Service{
|
svc := service.NewTestService(
|
||||||
DB: database,
|
database, brk, cfg, log,
|
||||||
Broker: brk,
|
)
|
||||||
Config: cfg,
|
|
||||||
Log: log,
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Server{ //nolint:exhaustruct
|
return &Server{ //nolint:exhaustruct
|
||||||
log: log,
|
log: log,
|
||||||
|
|||||||
913
internal/ircserver/integration_test.go
Normal file
913
internal/ircserver/integration_test.go
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
package ircserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationTwoClients is a comprehensive integration
|
||||||
|
// test that spawns the IRC server programmatically, connects
|
||||||
|
// two real TCP clients, and verifies all major IRC features
|
||||||
|
// including cross-client message delivery.
|
||||||
|
//
|
||||||
|
// The test runs sequentially through IRC features because
|
||||||
|
// both clients share the same channel state. Each section
|
||||||
|
// builds on the previous one (e.g. alice and bob must be
|
||||||
|
// JOINed before PRIVMSG can be tested).
|
||||||
|
//
|
||||||
|
//nolint:cyclop,funlen,maintidx // integration test
|
||||||
|
func TestIntegrationTwoClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
bob := env.dial(t)
|
||||||
|
|
||||||
|
// ── Registration ──────────────────────────────────
|
||||||
|
|
||||||
|
aliceWelcome := alice.register("alice")
|
||||||
|
assertContains(
|
||||||
|
t, aliceWelcome, " 001 ", "RPL_WELCOME alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWelcome, " 002 ", "RPL_YOURHOST alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWelcome, " 003 ", "RPL_CREATED alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWelcome, " 004 ", "RPL_MYINFO alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWelcome, "alice",
|
||||||
|
"nick in welcome burst",
|
||||||
|
)
|
||||||
|
|
||||||
|
bobWelcome := bob.register("bob")
|
||||||
|
assertContains(
|
||||||
|
t, bobWelcome, " 001 ", "RPL_WELCOME bob",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWelcome, "bob",
|
||||||
|
"nick in welcome burst",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── JOIN and cross-client visibility ──────────────
|
||||||
|
|
||||||
|
alice.send("JOIN #integration")
|
||||||
|
aliceJoinLines := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceJoinLines, "JOIN",
|
||||||
|
"alice receives JOIN echo",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceJoinLines, " 366 ",
|
||||||
|
"RPL_ENDOFNAMES for alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
bob.send("JOIN #integration")
|
||||||
|
bobJoinLines := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobJoinLines, "JOIN",
|
||||||
|
"bob receives JOIN echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Alice should see bob's JOIN via relay.
|
||||||
|
aliceSeesBob := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceSeesBob, "bob",
|
||||||
|
"alice sees bob's JOIN",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PRIVMSG (channel) — alice to bob ──────────────
|
||||||
|
|
||||||
|
alice.send("PRIVMSG #integration :hello from alice")
|
||||||
|
|
||||||
|
bobGetsMsg := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "hello from alice")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobGetsMsg, "hello from alice",
|
||||||
|
"bob receives alice's channel message",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PRIVMSG (channel) — bob to alice ──────────────
|
||||||
|
|
||||||
|
bob.send("PRIVMSG #integration :hello from bob")
|
||||||
|
|
||||||
|
aliceGetsMsg := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "hello from bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceGetsMsg, "hello from bob",
|
||||||
|
"alice receives bob's channel message",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PRIVMSG (DM) — alice to bob ──────────────────
|
||||||
|
|
||||||
|
alice.send("PRIVMSG bob :secret message")
|
||||||
|
|
||||||
|
bobDM := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "secret message")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobDM, "secret message",
|
||||||
|
"bob receives alice's DM",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobDM, "alice",
|
||||||
|
"DM from field is alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PRIVMSG (DM) — bob to alice ──────────────────
|
||||||
|
|
||||||
|
bob.send("PRIVMSG alice :reply to you")
|
||||||
|
|
||||||
|
aliceDM := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "reply to you")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceDM, "reply to you",
|
||||||
|
"alice receives bob's DM",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── NOTICE (channel) ──────────────────────────────
|
||||||
|
|
||||||
|
alice.send("NOTICE #integration :notice msg")
|
||||||
|
|
||||||
|
bobNotice := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "notice msg")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobNotice, "NOTICE",
|
||||||
|
"bob receives NOTICE command",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobNotice, "notice msg",
|
||||||
|
"bob receives NOTICE text",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── NOTICE (DM) ──────────────────────────────────
|
||||||
|
|
||||||
|
bob.send("NOTICE alice :dm notice")
|
||||||
|
|
||||||
|
aliceNotice := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "dm notice")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNotice, "dm notice",
|
||||||
|
"alice receives DM NOTICE",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── TOPIC ─────────────────────────────────────────
|
||||||
|
// alice is the channel creator so she is +o.
|
||||||
|
|
||||||
|
alice.send("TOPIC #integration :Integration Test Topic")
|
||||||
|
|
||||||
|
aliceTopic := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(
|
||||||
|
l, "Integration Test Topic",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceTopic, "Integration Test Topic",
|
||||||
|
"alice sees TOPIC echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
bobTopic := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(
|
||||||
|
l, "Integration Test Topic",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobTopic, "Integration Test Topic",
|
||||||
|
"bob receives TOPIC change",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── MODE (query) ──────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("MODE #integration")
|
||||||
|
|
||||||
|
aliceMode := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 324 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceMode, " 324 ",
|
||||||
|
"RPL_CHANNELMODEIS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── MODE (+m moderated, then -m) ──────────────────
|
||||||
|
|
||||||
|
alice.send("MODE #integration +m")
|
||||||
|
|
||||||
|
aliceModeM := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "MODE") &&
|
||||||
|
strings.Contains(l, "+m")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceModeM, "+m",
|
||||||
|
"alice sees MODE +m echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
bobModeM := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+m")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobModeM, "+m",
|
||||||
|
"bob sees MODE +m relay",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Revert moderated mode.
|
||||||
|
alice.send("MODE #integration -m")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-m")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-m")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── MODE (+v voice, then -v) ──────────────────────
|
||||||
|
|
||||||
|
alice.send("MODE #integration +v bob")
|
||||||
|
|
||||||
|
aliceVoice := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+v")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceVoice, "+v",
|
||||||
|
"alice sees +v echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
bobVoice := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+v")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobVoice, "+v",
|
||||||
|
"bob receives +v relay",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remove voice.
|
||||||
|
alice.send("MODE #integration -v bob")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-v")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-v")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── NAMES ─────────────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("NAMES #integration")
|
||||||
|
|
||||||
|
aliceNames := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNames, " 353 ",
|
||||||
|
"RPL_NAMREPLY",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceNames, " 366 ",
|
||||||
|
"RPL_ENDOFNAMES",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both nicks should appear in the name list.
|
||||||
|
foundBothNames := false
|
||||||
|
for _, line := range aliceNames {
|
||||||
|
if strings.Contains(line, " 353 ") &&
|
||||||
|
strings.Contains(line, "alice") &&
|
||||||
|
strings.Contains(line, "bob") {
|
||||||
|
foundBothNames = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundBothNames {
|
||||||
|
t.Error("NAMES reply should list both alice and bob")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LIST ──────────────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("LIST")
|
||||||
|
|
||||||
|
aliceList := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 323 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceList, " 322 ",
|
||||||
|
"RPL_LIST entry",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceList, "#integration",
|
||||||
|
"LIST includes #integration",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceList, " 323 ", //nolint:misspell // IRC RPL_LISTEND
|
||||||
|
"RPL_LISTEND", //nolint:misspell // IRC term
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── WHO ───────────────────────────────────────────
|
||||||
|
|
||||||
|
bob.send("WHO #integration")
|
||||||
|
|
||||||
|
bobWho := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 315 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobWho, " 352 ",
|
||||||
|
"RPL_WHOREPLY",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWho, " 315 ",
|
||||||
|
"RPL_ENDOFWHO",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── WHOIS ─────────────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("WHOIS bob")
|
||||||
|
|
||||||
|
aliceWhois := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 318 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceWhois, " 311 ",
|
||||||
|
"RPL_WHOISUSER",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWhois, " 312 ",
|
||||||
|
"RPL_WHOISSERVER",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceWhois, " 318 ",
|
||||||
|
"RPL_ENDOFWHOIS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── WHOIS with channels ───────────────────────────
|
||||||
|
|
||||||
|
bob.send("WHOIS alice")
|
||||||
|
|
||||||
|
bobWhois := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 318 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobWhois, " 319 ",
|
||||||
|
"RPL_WHOISCHANNELS",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWhois, "#integration",
|
||||||
|
"WHOIS shows #integration channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── LUSERS ────────────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("LUSERS")
|
||||||
|
|
||||||
|
aliceLusers := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 255 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceLusers, " 251 ",
|
||||||
|
"RPL_LUSERCLIENT",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceLusers, " 255 ",
|
||||||
|
"RPL_LUSERME",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── NICK change ───────────────────────────────────
|
||||||
|
|
||||||
|
bob.send("NICK bobby")
|
||||||
|
|
||||||
|
bobNick := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "NICK") &&
|
||||||
|
strings.Contains(l, "bobby")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobNick, "bobby",
|
||||||
|
"bob sees NICK change to bobby",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice should see the nick change relayed.
|
||||||
|
aliceNick := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "bobby")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNick, "NICK",
|
||||||
|
"alice sees NICK command",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceNick, "bobby",
|
||||||
|
"alice sees new nick bobby",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Change it back for remaining tests.
|
||||||
|
bob.send("NICK bob")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "NICK") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Duplicate NICK ────────────────────────────────
|
||||||
|
|
||||||
|
bob.send("NICK alice")
|
||||||
|
|
||||||
|
bobDupNick := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 433 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobDupNick, " 433 ",
|
||||||
|
"ERR_NICKNAMEINUSE",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── KICK ──────────────────────────────────────────
|
||||||
|
// alice is op; she kicks bob.
|
||||||
|
|
||||||
|
alice.send("KICK #integration bob :testing kick")
|
||||||
|
|
||||||
|
aliceKick := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "KICK")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceKick, "KICK",
|
||||||
|
"alice sees KICK echo",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceKick, "bob",
|
||||||
|
"KICK mentions bob",
|
||||||
|
)
|
||||||
|
|
||||||
|
bobKick := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "KICK")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobKick, "KICK",
|
||||||
|
"bob receives KICK",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobKick, "testing kick",
|
||||||
|
"KICK reason is relayed",
|
||||||
|
)
|
||||||
|
|
||||||
|
// bob rejoins.
|
||||||
|
bob.joinAndDrain("#integration")
|
||||||
|
|
||||||
|
// Drain alice's view of the rejoin.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── KICK non-op should fail ───────────────────────
|
||||||
|
|
||||||
|
bob.send("KICK #integration alice :nope")
|
||||||
|
|
||||||
|
bobKickFail := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 482 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobKickFail, " 482 ",
|
||||||
|
"ERR_CHANOPRIVSNEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── TOPIC lock (+t default) ───────────────────────
|
||||||
|
// +t is default, so bob should not be able to set
|
||||||
|
// topic.
|
||||||
|
|
||||||
|
bob.send("TOPIC #integration :bob tries topic")
|
||||||
|
|
||||||
|
bobTopicFail := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 482 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobTopicFail, " 482 ",
|
||||||
|
"ERR_CHANOPRIVSNEEDED for topic",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PING / PONG ───────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("PING :testtoken")
|
||||||
|
|
||||||
|
alicePong := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "PONG")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, alicePong, "PONG",
|
||||||
|
"PONG response received",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Unknown command ───────────────────────────────
|
||||||
|
|
||||||
|
bob.send("FOOBAR")
|
||||||
|
|
||||||
|
bobUnknown := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 421 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobUnknown, " 421 ",
|
||||||
|
"ERR_UNKNOWNCOMMAND",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── MOTD ──────────────────────────────────────────
|
||||||
|
|
||||||
|
alice.send("MOTD")
|
||||||
|
|
||||||
|
aliceMOTD := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 376 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceMOTD, " 376 ",
|
||||||
|
"RPL_ENDOFMOTD",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── AWAY (set, check via DM, clear) ───────────────
|
||||||
|
|
||||||
|
alice.send("AWAY :gone fishing")
|
||||||
|
|
||||||
|
aliceAway := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 306 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceAway, " 306 ",
|
||||||
|
"RPL_NOWAWAY",
|
||||||
|
)
|
||||||
|
|
||||||
|
// bob DMs alice — should get RPL_AWAY.
|
||||||
|
bob.send("PRIVMSG alice :are you there?")
|
||||||
|
|
||||||
|
bobAwayReply := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 301 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobAwayReply, " 301 ",
|
||||||
|
"RPL_AWAY for bob when messaging alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobAwayReply, "gone fishing",
|
||||||
|
"away message relayed",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clear away.
|
||||||
|
alice.send("AWAY")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 305 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── PASS (set password post-registration) ─────────
|
||||||
|
|
||||||
|
alice.send("PASS :mypassword123")
|
||||||
|
|
||||||
|
alicePass := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "Password set")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, alicePass, "Password set",
|
||||||
|
"password set confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── MODE -t/+t topic lock toggle ──────────────────
|
||||||
|
|
||||||
|
alice.send("MODE #integration -t")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-t")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "-t")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Now bob should be able to set topic.
|
||||||
|
bob.send("TOPIC #integration :bob sets topic now")
|
||||||
|
|
||||||
|
bobTopicOK := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "bob sets topic now")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobTopicOK, "bob sets topic now",
|
||||||
|
"bob can set topic after -t",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice sees the topic change.
|
||||||
|
aliceTopicRelay := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "bob sets topic now")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceTopicRelay, "bob sets topic now",
|
||||||
|
"alice sees bob's topic after -t",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore +t.
|
||||||
|
alice.send("MODE #integration +t")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+t")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+t")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── DM to nonexistent nick ────────────────────────
|
||||||
|
|
||||||
|
alice.send("PRIVMSG nobody123 :hello")
|
||||||
|
|
||||||
|
aliceNoSuch := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 401 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNoSuch, " 401 ",
|
||||||
|
"ERR_NOSUCHNICK",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PART with reason ──────────────────────────────
|
||||||
|
|
||||||
|
bob.send("PART #integration :bye for now")
|
||||||
|
|
||||||
|
bobPart := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "PART")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobPart, "PART",
|
||||||
|
"bob sees PART echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice sees bob PART via relay.
|
||||||
|
alicePart := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "PART") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, alicePart, "bob",
|
||||||
|
"alice sees bob's PART",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, alicePart, "bye for now",
|
||||||
|
"PART reason is relayed",
|
||||||
|
)
|
||||||
|
|
||||||
|
// bob rejoins for remaining tests.
|
||||||
|
bob.joinAndDrain("#integration")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── PART non-existent channel ─────────────────────
|
||||||
|
|
||||||
|
bob.send("PART #nonexistent")
|
||||||
|
|
||||||
|
bobPartFail := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 403 ") ||
|
||||||
|
strings.Contains(l, " 442 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
foundPartErr := false
|
||||||
|
for _, line := range bobPartFail {
|
||||||
|
if strings.Contains(line, " 403 ") ||
|
||||||
|
strings.Contains(line, " 442 ") {
|
||||||
|
foundPartErr = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundPartErr {
|
||||||
|
t.Error(
|
||||||
|
"expected ERR_NOSUCHCHANNEL or " +
|
||||||
|
"ERR_NOTONCHANNEL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User MODE query ───────────────────────────────
|
||||||
|
|
||||||
|
alice.send("MODE alice")
|
||||||
|
|
||||||
|
aliceUMode := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 221 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceUMode, " 221 ",
|
||||||
|
"RPL_UMODEIS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Multiple channel operation ────────────────────
|
||||||
|
|
||||||
|
alice.send("JOIN #second")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
bob.send("JOIN #second")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 366 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drain alice seeing bob join.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
alice.send("PRIVMSG #second :cross-channel test")
|
||||||
|
|
||||||
|
bobCross := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "cross-channel test")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobCross, "cross-channel test",
|
||||||
|
"bob receives message in #second",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up #second.
|
||||||
|
alice.send("PART #second")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "PART")
|
||||||
|
})
|
||||||
|
bob.send("PART #second")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "PART")
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── QUIT ──────────────────────────────────────────
|
||||||
|
|
||||||
|
bob.send("QUIT :integration test done")
|
||||||
|
|
||||||
|
bobQuit := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "ERROR")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobQuit, "integration test done",
|
||||||
|
"QUIT reason echoed",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice should see bob's QUIT via relay.
|
||||||
|
aliceQuit := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "QUIT") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceQuit, "bob",
|
||||||
|
"alice sees bob's QUIT",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationModeSecret tests +s (secret) channel
|
||||||
|
// mode — verifies that +s can be set and the mode is
|
||||||
|
// reflected in MODE queries.
|
||||||
|
func TestIntegrationModeSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#secretroom")
|
||||||
|
|
||||||
|
// Set +s.
|
||||||
|
alice.send("MODE #secretroom +s")
|
||||||
|
aliceLines := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+s")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceLines, "+s",
|
||||||
|
"alice sees MODE +s confirmation",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify mode is reflected in query.
|
||||||
|
alice.send("MODE #secretroom")
|
||||||
|
modeLines := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 324 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, modeLines, "s",
|
||||||
|
"channel mode includes s",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationModeModerated tests +m (moderated) mode
|
||||||
|
// — non-voiced users cannot send.
|
||||||
|
func TestIntegrationModeModerated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#modtest")
|
||||||
|
bob.joinAndDrain("#modtest")
|
||||||
|
|
||||||
|
// Drain alice's view of bob's join.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set +m.
|
||||||
|
alice.send("MODE #modtest +m")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+m")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+m")
|
||||||
|
})
|
||||||
|
|
||||||
|
// bob should get an error trying to send.
|
||||||
|
bob.send("PRIVMSG #modtest :should fail")
|
||||||
|
bobLines := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 404 ") ||
|
||||||
|
strings.Contains(l, " 482 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
foundModErr := false
|
||||||
|
for _, line := range bobLines {
|
||||||
|
if strings.Contains(line, " 404 ") ||
|
||||||
|
strings.Contains(line, " 482 ") {
|
||||||
|
foundModErr = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundModErr {
|
||||||
|
t.Error(
|
||||||
|
"non-voiced user should not be able to send " +
|
||||||
|
"in +m channel",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant +v to bob, then he should be able to send.
|
||||||
|
alice.send("MODE #modtest +v bob")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+v")
|
||||||
|
})
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "+v")
|
||||||
|
})
|
||||||
|
|
||||||
|
bob.send("PRIVMSG #modtest :voiced message")
|
||||||
|
aliceLines := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "voiced message")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceLines, "voiced message",
|
||||||
|
"alice receives voiced bob's message",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationThirdClientObserver verifies that a third
|
||||||
|
// client observing the same channel receives messages from
|
||||||
|
// the other two.
|
||||||
|
func TestIntegrationThirdClientObserver(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
carol := env.dial(t)
|
||||||
|
carol.register("carol")
|
||||||
|
|
||||||
|
alice.joinAndDrain("#trio")
|
||||||
|
bob.joinAndDrain("#trio")
|
||||||
|
carol.joinAndDrain("#trio")
|
||||||
|
|
||||||
|
// Drain join notifications.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// alice sends; both bob and carol should receive.
|
||||||
|
alice.send("PRIVMSG #trio :hello trio")
|
||||||
|
|
||||||
|
bobLines := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "hello trio")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobLines, "hello trio",
|
||||||
|
"bob receives trio message",
|
||||||
|
)
|
||||||
|
|
||||||
|
carolLines := carol.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "hello trio")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, carolLines, "hello trio",
|
||||||
|
"carol receives trio message",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package ircserver_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:funlen // table-driven test
|
//nolint:funlen // table-driven test
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// relayMessages polls the client output queue and delivers
|
// relayMessages polls the client output queue and delivers
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating an IRC
|
// Params defines the dependencies for creating an IRC
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ircserver"
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Logger.
|
// Params defines the dependencies for creating a Logger.
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
@@ -18,6 +15,9 @@ import (
|
|||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const corsMaxAge = 300
|
const corsMaxAge = 300
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package ratelimit_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/ratelimit"
|
"sneak.berlin/go/neoirc/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewCreatesLimiter(t *testing.T) {
|
func TestNewCreatesLimiter(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/web"
|
"sneak.berlin/go/neoirc/web"
|
||||||
|
|
||||||
sentryhttp "github.com/getsentry/sentry-go/http"
|
sentryhttp "github.com/getsentry/sentry-go/http"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/handlers"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/middleware"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/handlers"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/middleware"
|
||||||
|
|
||||||
"github.com/getsentry/sentry-go"
|
"github.com/getsentry/sentry-go"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Params defines the dependencies for creating a Service.
|
// Params defines the dependencies for creating a Service.
|
||||||
@@ -30,19 +31,35 @@ type Params struct {
|
|||||||
|
|
||||||
// Service provides shared business logic for IRC commands.
|
// Service provides shared business logic for IRC commands.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
DB *db.Database
|
db *db.Database
|
||||||
Broker *broker.Broker
|
broker *broker.Broker
|
||||||
Config *config.Config
|
config *config.Config
|
||||||
Log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Service.
|
// New creates a new Service.
|
||||||
func New(params Params) *Service {
|
func New(params Params) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
DB: params.Database,
|
db: params.Database,
|
||||||
Broker: params.Broker,
|
broker: params.Broker,
|
||||||
Config: params.Config,
|
config: params.Config,
|
||||||
Log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestService creates a Service for use in tests
|
||||||
|
// outside the service package.
|
||||||
|
func NewTestService(
|
||||||
|
database *db.Database,
|
||||||
|
brk *broker.Broker,
|
||||||
|
cfg *config.Config,
|
||||||
|
log *slog.Logger,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: database,
|
||||||
|
broker: brk,
|
||||||
|
config: cfg,
|
||||||
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +93,7 @@ func (s *Service) FanOut(
|
|||||||
params, body, meta json.RawMessage,
|
params, body, meta json.RawMessage,
|
||||||
sessionIDs []int64,
|
sessionIDs []int64,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
dbID, msgUUID, err := s.DB.InsertMessage(
|
dbID, msgUUID, err := s.db.InsertMessage(
|
||||||
ctx, command, from, to, params, body, meta,
|
ctx, command, from, to, params, body, meta,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,8 +101,8 @@ func (s *Service) FanOut(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, sid := range sessionIDs {
|
for _, sid := range sessionIDs {
|
||||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
_ = s.db.EnqueueToSession(ctx, sid, dbID)
|
||||||
s.Broker.Notify(sid)
|
s.broker.Notify(sid)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dbID, msgUUID, nil
|
return dbID, msgUUID, nil
|
||||||
@@ -120,7 +137,7 @@ func (s *Service) SendChannelMessage(
|
|||||||
nick, command, channel string,
|
nick, command, channel string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (int64, string, error) {
|
) (int64, string, error) {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", &IRCError{
|
return 0, "", &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -129,7 +146,7 @@ func (s *Service) SendChannelMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -140,12 +157,24 @@ func (s *Service) SendChannelMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
// Ban check — banned users cannot send messages.
|
||||||
|
isBanned, banErr := s.db.IsSessionBanned(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if banErr == nil && isBanned {
|
||||||
|
return 0, "", &IRCError{
|
||||||
|
irc.ErrCannotSendToChan,
|
||||||
|
[]string{channel},
|
||||||
|
"Cannot send to channel (+b)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||||
if moderated {
|
if moderated {
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
isVoiced, _ := s.DB.IsChannelVoiced(
|
isVoiced, _ := s.db.IsChannelVoiced(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,7 +187,7 @@ func (s *Service) SendChannelMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
|
||||||
dbID, uuid, fanErr := s.FanOut(
|
dbID, uuid, fanErr := s.FanOut(
|
||||||
@@ -181,7 +210,7 @@ func (s *Service) SendDirectMessage(
|
|||||||
nick, command, target string,
|
nick, command, target string,
|
||||||
body, meta json.RawMessage,
|
body, meta json.RawMessage,
|
||||||
) (*DirectMsgResult, error) {
|
) (*DirectMsgResult, error) {
|
||||||
targetSID, err := s.DB.GetSessionByNick(ctx, target)
|
targetSID, err := s.db.GetSessionByNick(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &IRCError{
|
return nil, &IRCError{
|
||||||
irc.ErrNoSuchNick,
|
irc.ErrNoSuchNick,
|
||||||
@@ -190,7 +219,7 @@ func (s *Service) SendDirectMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
away, _ := s.DB.GetAway(ctx, targetSID)
|
away, _ := s.db.GetAway(ctx, targetSID)
|
||||||
|
|
||||||
recipients := []int64{targetSID}
|
recipients := []int64{targetSID}
|
||||||
if targetSID != sessionID {
|
if targetSID != sessionID {
|
||||||
@@ -214,31 +243,43 @@ func (s *Service) SendDirectMessage(
|
|||||||
func (s *Service) JoinChannel(
|
func (s *Service) JoinChannel(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel string,
|
nick, channel, suppliedKey string,
|
||||||
) (*JoinResult, error) {
|
) (*JoinResult, error) {
|
||||||
chID, err := s.DB.GetOrCreateChannel(ctx, channel)
|
chID, err := s.db.GetOrCreateChannel(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get/create channel: %w", err)
|
return nil, fmt.Errorf("get/create channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
memberCount, countErr := s.DB.CountChannelMembers(
|
memberCount, countErr := s.db.CountChannelMembers(
|
||||||
ctx, chID,
|
ctx, chID,
|
||||||
)
|
)
|
||||||
isCreator := countErr == nil && memberCount == 0
|
isCreator := countErr == nil && memberCount == 0
|
||||||
|
|
||||||
|
if !isCreator {
|
||||||
|
if joinErr := checkJoinRestrictions(
|
||||||
|
ctx, s.db, chID, sessionID,
|
||||||
|
channel, suppliedKey, memberCount,
|
||||||
|
); joinErr != nil {
|
||||||
|
return nil, joinErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isCreator {
|
if isCreator {
|
||||||
err = s.DB.JoinChannelAsOperator(
|
err = s.db.JoinChannelAsOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
err = s.DB.JoinChannel(ctx, chID, sessionID)
|
err = s.db.JoinChannel(ctx, chID, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("join channel: %w", err)
|
return nil, fmt.Errorf("join channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
// Clear invite after successful join.
|
||||||
|
_ = s.db.ClearChannelInvite(ctx, chID, sessionID)
|
||||||
|
|
||||||
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{channel}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -260,7 +301,7 @@ func (s *Service) PartChannel(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, reason string,
|
nick, channel, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -269,7 +310,7 @@ func (s *Service) PartChannel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -280,7 +321,7 @@ func (s *Service) PartChannel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
|
|
||||||
@@ -289,8 +330,8 @@ func (s *Service) PartChannel(
|
|||||||
nil, body, nil, recipients,
|
nil, body, nil, recipients,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.DB.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, chID, sessionID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -302,7 +343,7 @@ func (s *Service) SetTopic(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, topic string,
|
nick, channel, topic string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -311,7 +352,7 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -322,9 +363,9 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||||
if topicLocked {
|
if topicLocked {
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -336,15 +377,15 @@ func (s *Service) SetTopic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if setErr := s.DB.SetTopic(
|
if setErr := s.db.SetTopic(
|
||||||
ctx, channel, topic,
|
ctx, channel, topic,
|
||||||
); setErr != nil {
|
); setErr != nil {
|
||||||
return fmt.Errorf("set topic: %w", setErr)
|
return fmt.Errorf("set topic: %w", setErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.DB.SetTopicMeta(ctx, channel, topic, nick)
|
_ = s.db.SetTopicMeta(ctx, channel, topic, nick)
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{topic}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -363,7 +404,7 @@ func (s *Service) KickUser(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, channel, targetNick, reason string,
|
nick, channel, targetNick, reason string,
|
||||||
) error {
|
) error {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &IRCError{
|
return &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -372,7 +413,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -383,7 +424,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targetSID, err := s.DB.GetSessionByNick(
|
targetSID, err := s.db.GetSessionByNick(
|
||||||
ctx, targetNick,
|
ctx, targetNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,7 +435,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, targetSID,
|
ctx, chID, targetSID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -405,7 +446,7 @@ func (s *Service) KickUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||||
params, _ := json.Marshal( //nolint:errchkjson
|
params, _ := json.Marshal( //nolint:errchkjson
|
||||||
[]string{targetNick},
|
[]string{targetNick},
|
||||||
@@ -416,8 +457,8 @@ func (s *Service) KickUser(
|
|||||||
params, body, nil, memberIDs,
|
params, body, nil, memberIDs,
|
||||||
)
|
)
|
||||||
|
|
||||||
s.DB.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, chID, targetSID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, chID) //nolint:errcheck,gosec
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -429,7 +470,7 @@ func (s *Service) ChangeNick(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) error {
|
) error {
|
||||||
err := s.DB.ChangeNick(ctx, sessionID, newNick)
|
err := s.db.ChangeNick(ctx, sessionID, newNick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE") ||
|
if strings.Contains(err.Error(), "UNIQUE") ||
|
||||||
db.IsUniqueConstraintError(err) {
|
db.IsUniqueConstraintError(err) {
|
||||||
@@ -461,7 +502,7 @@ func (s *Service) BroadcastQuit(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
nick, reason string,
|
nick, reason string,
|
||||||
) {
|
) {
|
||||||
channels, err := s.DB.GetSessionChannels(
|
channels, err := s.db.GetSessionChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -471,7 +512,7 @@ func (s *Service) BroadcastQuit(
|
|||||||
notified := make(map[int64]bool)
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||||
ctx, ch.ID,
|
ctx, ch.ID,
|
||||||
)
|
)
|
||||||
if memErr != nil {
|
if memErr != nil {
|
||||||
@@ -502,11 +543,11 @@ func (s *Service) BroadcastQuit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
s.DB.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
s.db.PartChannel(ctx, ch.ID, sessionID) //nolint:errcheck,gosec
|
||||||
s.DB.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
s.db.DeleteChannelIfEmpty(ctx, ch.ID) //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
s.DB.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
s.db.DeleteSession(ctx, sessionID) //nolint:errcheck,gosec
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAway sets or clears the away message. Returns true
|
// SetAway sets or clears the away message. Returns true
|
||||||
@@ -516,7 +557,7 @@ func (s *Service) SetAway(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
message string,
|
message string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
err := s.DB.SetAway(ctx, sessionID, message)
|
err := s.db.SetAway(ctx, sessionID, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("set away: %w", err)
|
return false, fmt.Errorf("set away: %w", err)
|
||||||
}
|
}
|
||||||
@@ -531,8 +572,8 @@ func (s *Service) Oper(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
name, password string,
|
name, password string,
|
||||||
) error {
|
) error {
|
||||||
cfgName := s.Config.OperName
|
cfgName := s.config.OperName
|
||||||
cfgPassword := s.Config.OperPassword
|
cfgPassword := s.config.OperPassword
|
||||||
|
|
||||||
// Use constant-time comparison and return the same
|
// Use constant-time comparison and return the same
|
||||||
// error for all failures to prevent information
|
// error for all failures to prevent information
|
||||||
@@ -551,7 +592,7 @@ func (s *Service) Oper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
_ = s.db.SetSessionOper(ctx, sessionID, true)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -563,7 +604,7 @@ func (s *Service) ValidateChannelOp(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
channel string,
|
channel string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
chID, err := s.db.GetChannelByName(ctx, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, &IRCError{
|
return 0, &IRCError{
|
||||||
irc.ErrNoSuchChannel,
|
irc.ErrNoSuchChannel,
|
||||||
@@ -572,7 +613,7 @@ func (s *Service) ValidateChannelOp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isOp, _ := s.DB.IsChannelOperator(
|
isOp, _ := s.db.IsChannelOperator(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
if !isOp {
|
if !isOp {
|
||||||
@@ -595,7 +636,7 @@ func (s *Service) ApplyMemberMode(
|
|||||||
mode rune,
|
mode rune,
|
||||||
adding bool,
|
adding bool,
|
||||||
) error {
|
) error {
|
||||||
targetSID, err := s.DB.GetSessionByNick(
|
targetSID, err := s.db.GetSessionByNick(
|
||||||
ctx, targetNick,
|
ctx, targetNick,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -606,7 +647,7 @@ func (s *Service) ApplyMemberMode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, _ := s.DB.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, targetSID,
|
ctx, chID, targetSID,
|
||||||
)
|
)
|
||||||
if !isMember {
|
if !isMember {
|
||||||
@@ -619,11 +660,11 @@ func (s *Service) ApplyMemberMode(
|
|||||||
|
|
||||||
switch mode {
|
switch mode {
|
||||||
case 'o':
|
case 'o':
|
||||||
_ = s.DB.SetChannelMemberOperator(
|
_ = s.db.SetChannelMemberOperator(
|
||||||
ctx, chID, targetSID, adding,
|
ctx, chID, targetSID, adding,
|
||||||
)
|
)
|
||||||
case 'v':
|
case 'v':
|
||||||
_ = s.DB.SetChannelMemberVoiced(
|
_ = s.db.SetChannelMemberVoiced(
|
||||||
ctx, chID, targetSID, adding,
|
ctx, chID, targetSID, adding,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -631,7 +672,8 @@ func (s *Service) ApplyMemberMode(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChannelFlag applies +m/-m or +t/-t on a channel.
|
// SetChannelFlag applies a simple boolean channel mode
|
||||||
|
// (+m/-m, +t/-t, +i/-i, +s/-s, +n/-n).
|
||||||
func (s *Service) SetChannelFlag(
|
func (s *Service) SetChannelFlag(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
@@ -640,17 +682,37 @@ func (s *Service) SetChannelFlag(
|
|||||||
) error {
|
) error {
|
||||||
switch flag {
|
switch flag {
|
||||||
case 'm':
|
case 'm':
|
||||||
if err := s.DB.SetChannelModerated(
|
if err := s.db.SetChannelModerated(
|
||||||
ctx, chID, setting,
|
ctx, chID, setting,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("set moderated: %w", err)
|
return fmt.Errorf("set moderated: %w", err)
|
||||||
}
|
}
|
||||||
case 't':
|
case 't':
|
||||||
if err := s.DB.SetChannelTopicLocked(
|
if err := s.db.SetChannelTopicLocked(
|
||||||
ctx, chID, setting,
|
ctx, chID, setting,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("set topic locked: %w", err)
|
return fmt.Errorf("set topic locked: %w", err)
|
||||||
}
|
}
|
||||||
|
case 'i':
|
||||||
|
if err := s.db.SetChannelInviteOnly(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set invite only: %w", err)
|
||||||
|
}
|
||||||
|
case 's':
|
||||||
|
if err := s.db.SetChannelSecret(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set secret: %w", err)
|
||||||
|
}
|
||||||
|
case 'n':
|
||||||
|
if err := s.db.SetChannelNoExternal(
|
||||||
|
ctx, chID, setting,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"set no external: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -664,7 +726,7 @@ func (s *Service) BroadcastMode(
|
|||||||
chID int64,
|
chID int64,
|
||||||
modeText string,
|
modeText string,
|
||||||
) {
|
) {
|
||||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{modeText}) //nolint:errchkjson
|
||||||
|
|
||||||
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
_, _, _ = s.FanOut( //nolint:dogsled // fire-and-forget broadcast
|
||||||
@@ -673,24 +735,60 @@ func (s *Service) BroadcastMode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryChannelMode returns the channel mode string.
|
// QueryChannelMode returns the complete channel mode
|
||||||
|
// string including all flags and parameterized modes.
|
||||||
func (s *Service) QueryChannelMode(
|
func (s *Service) QueryChannelMode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chID int64,
|
chID int64,
|
||||||
) string {
|
) string {
|
||||||
modes := "+"
|
modes := "+"
|
||||||
|
|
||||||
moderated, _ := s.DB.IsChannelModerated(ctx, chID)
|
noExternal, _ := s.db.IsChannelNoExternal(ctx, chID)
|
||||||
|
if noExternal {
|
||||||
|
modes += "n"
|
||||||
|
}
|
||||||
|
|
||||||
|
inviteOnly, _ := s.db.IsChannelInviteOnly(ctx, chID)
|
||||||
|
if inviteOnly {
|
||||||
|
modes += "i"
|
||||||
|
}
|
||||||
|
|
||||||
|
moderated, _ := s.db.IsChannelModerated(ctx, chID)
|
||||||
if moderated {
|
if moderated {
|
||||||
modes += "m"
|
modes += "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
topicLocked, _ := s.DB.IsChannelTopicLocked(ctx, chID)
|
secret, _ := s.db.IsChannelSecret(ctx, chID)
|
||||||
|
if secret {
|
||||||
|
modes += "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
topicLocked, _ := s.db.IsChannelTopicLocked(ctx, chID)
|
||||||
if topicLocked {
|
if topicLocked {
|
||||||
modes += "t"
|
modes += "t"
|
||||||
}
|
}
|
||||||
|
|
||||||
return modes
|
var modeParams string
|
||||||
|
|
||||||
|
key, _ := s.db.GetChannelKey(ctx, chID)
|
||||||
|
if key != "" {
|
||||||
|
modes += "k"
|
||||||
|
modeParams += " " + key
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := s.db.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limit > 0 {
|
||||||
|
modes += "l"
|
||||||
|
modeParams += " " + strconv.Itoa(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
bits, _ := s.db.GetChannelHashcashBits(ctx, chID)
|
||||||
|
if bits > 0 {
|
||||||
|
modes += "H"
|
||||||
|
modeParams += " " + strconv.Itoa(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modes + modeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastNickChange notifies channel peers of a nick
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
@@ -700,7 +798,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
sessionID int64,
|
sessionID int64,
|
||||||
oldNick, newNick string,
|
oldNick, newNick string,
|
||||||
) {
|
) {
|
||||||
channels, err := s.DB.GetSessionChannels(
|
channels, err := s.db.GetSessionChannels(
|
||||||
ctx, sessionID,
|
ctx, sessionID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -710,7 +808,7 @@ func (s *Service) broadcastNickChange(
|
|||||||
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
body, _ := json.Marshal([]string{newNick}) //nolint:errchkjson
|
||||||
notified := make(map[int64]bool)
|
notified := make(map[int64]bool)
|
||||||
|
|
||||||
dbID, _, insErr := s.DB.InsertMessage(
|
dbID, _, insErr := s.db.InsertMessage(
|
||||||
ctx, irc.CmdNick, oldNick, "",
|
ctx, irc.CmdNick, oldNick, "",
|
||||||
nil, body, nil,
|
nil, body, nil,
|
||||||
)
|
)
|
||||||
@@ -719,12 +817,12 @@ func (s *Service) broadcastNickChange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify the user themselves (for multi-client sync).
|
// Notify the user themselves (for multi-client sync).
|
||||||
_ = s.DB.EnqueueToSession(ctx, sessionID, dbID)
|
_ = s.db.EnqueueToSession(ctx, sessionID, dbID)
|
||||||
s.Broker.Notify(sessionID)
|
s.broker.Notify(sessionID)
|
||||||
notified[sessionID] = true
|
notified[sessionID] = true
|
||||||
|
|
||||||
for _, ch := range channels {
|
for _, ch := range channels {
|
||||||
memberIDs, memErr := s.DB.GetChannelMemberIDs(
|
memberIDs, memErr := s.db.GetChannelMemberIDs(
|
||||||
ctx, ch.ID,
|
ctx, ch.ID,
|
||||||
)
|
)
|
||||||
if memErr != nil {
|
if memErr != nil {
|
||||||
@@ -738,8 +836,66 @@ func (s *Service) broadcastNickChange(
|
|||||||
|
|
||||||
notified[mid] = true
|
notified[mid] = true
|
||||||
|
|
||||||
_ = s.DB.EnqueueToSession(ctx, mid, dbID)
|
_ = s.db.EnqueueToSession(ctx, mid, dbID)
|
||||||
s.Broker.Notify(mid)
|
s.broker.Notify(mid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkJoinRestrictions validates Tier 2 join conditions:
|
||||||
|
// bans, invite-only, channel key, and user limit.
|
||||||
|
func checkJoinRestrictions(
|
||||||
|
ctx context.Context,
|
||||||
|
database *db.Database,
|
||||||
|
chID, sessionID int64,
|
||||||
|
channel, suppliedKey string,
|
||||||
|
memberCount int64,
|
||||||
|
) error {
|
||||||
|
isBanned, banErr := database.IsSessionBanned(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if banErr == nil && isBanned {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrBannedFromChan,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+b)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isInviteOnly, ioErr := database.IsChannelInviteOnly(
|
||||||
|
ctx, chID,
|
||||||
|
)
|
||||||
|
if ioErr == nil && isInviteOnly {
|
||||||
|
hasInvite, invErr := database.HasChannelInvite(
|
||||||
|
ctx, chID, sessionID,
|
||||||
|
)
|
||||||
|
if invErr != nil || !hasInvite {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrInviteOnlyChan,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+i)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
key, keyErr := database.GetChannelKey(ctx, chID)
|
||||||
|
if keyErr == nil && key != "" && suppliedKey != key {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrBadChannelKey,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+k)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, limErr := database.GetChannelUserLimit(ctx, chID)
|
||||||
|
if limErr == nil && limit > 0 &&
|
||||||
|
memberCount >= int64(limit) {
|
||||||
|
return &IRCError{
|
||||||
|
Code: irc.ErrChannelIsFull,
|
||||||
|
Params: []string{channel},
|
||||||
|
Message: "Cannot join channel (+l)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
@@ -167,7 +167,7 @@ func TestJoinChannel(t *testing.T) {
|
|||||||
sid := createSession(ctx, t, env.db, "alice")
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
result, err := env.svc.JoinChannel(
|
result, err := env.svc.JoinChannel(
|
||||||
ctx, sid, "alice", "#general",
|
ctx, sid, "alice", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel: %v", err)
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
@@ -185,7 +185,7 @@ func TestJoinChannel(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
result2, err := env.svc.JoinChannel(
|
result2, err := env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#general",
|
ctx, sid2, "bob", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel bob: %v", err)
|
t.Fatalf("JoinChannel bob: %v", err)
|
||||||
@@ -207,7 +207,7 @@ func TestPartChannel(t *testing.T) {
|
|||||||
sid := createSession(ctx, t, env.db, "alice")
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid, "alice", "#general",
|
ctx, sid, "alice", "#general", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("JoinChannel: %v", err)
|
t.Fatalf("JoinChannel: %v", err)
|
||||||
@@ -242,14 +242,14 @@ func TestSendChannelMessage(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#chat",
|
ctx, sid1, "alice", "#chat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#chat",
|
ctx, sid2, "bob", "#chat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
@@ -293,14 +293,14 @@ func TestBroadcastQuit(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
_, err := env.svc.JoinChannel(
|
_, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#room",
|
ctx, sid1, "alice", "#room", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#room",
|
ctx, sid2, "bob", "#room", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
@@ -326,14 +326,14 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
sid2 := createSession(ctx, t, env.db, "bob")
|
sid2 := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
result, err := env.svc.JoinChannel(
|
result, err := env.svc.JoinChannel(
|
||||||
ctx, sid1, "alice", "#modchat",
|
ctx, sid1, "alice", "#modchat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join alice: %v", err)
|
t.Fatalf("join alice: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = env.svc.JoinChannel(
|
_, err = env.svc.JoinChannel(
|
||||||
ctx, sid2, "bob", "#modchat",
|
ctx, sid2, "bob", "#modchat", "",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("join bob: %v", err)
|
t.Fatalf("join bob: %v", err)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package stats_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/stats"
|
"sneak.berlin/go/neoirc/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
func TestNew(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestName(t *testing.T) {
|
func TestName(t *testing.T) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/JOIN.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json",
|
||||||
"title": "JOIN",
|
"title": "JOIN",
|
||||||
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
"description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/KICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json",
|
||||||
"title": "KICK",
|
"title": "KICK",
|
||||||
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
"description": "Kick a user from a channel. RFC 1459 §4.2.8.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/MODE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json",
|
||||||
"title": "MODE",
|
"title": "MODE",
|
||||||
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
"description": "Set or query channel/user modes. RFC 1459 §4.2.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NICK.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json",
|
||||||
"title": "NICK",
|
"title": "NICK",
|
||||||
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
"description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/NOTICE.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json",
|
||||||
"title": "NOTICE",
|
"title": "NOTICE",
|
||||||
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
"description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PART.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json",
|
||||||
"title": "PART",
|
"title": "PART",
|
||||||
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
"description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PING.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json",
|
||||||
"title": "PING",
|
"title": "PING",
|
||||||
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
"description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PONG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json",
|
||||||
"title": "PONG",
|
"title": "PONG",
|
||||||
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
"description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PRIVMSG.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json",
|
||||||
"title": "PRIVMSG",
|
"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.",
|
"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",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/PUBKEY.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json",
|
||||||
"title": "PUBKEY",
|
"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.",
|
"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",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/QUIT.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json",
|
||||||
"title": "QUIT",
|
"title": "QUIT",
|
||||||
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
"description": "User disconnected. S2C only. RFC 1459 §4.1.6.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/commands/TOPIC.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json",
|
||||||
"title": "TOPIC",
|
"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.",
|
"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",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/message.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/message.json",
|
||||||
"title": "IRC Message Envelope",
|
"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.).",
|
"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",
|
"type": "object",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/001.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json",
|
||||||
"title": "001 RPL_WELCOME",
|
"title": "001 RPL_WELCOME",
|
||||||
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
"description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/002.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json",
|
||||||
"title": "002 RPL_YOURHOST",
|
"title": "002 RPL_YOURHOST",
|
||||||
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
"description": "Server host info sent after session creation. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/003.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json",
|
||||||
"title": "003 RPL_CREATED",
|
"title": "003 RPL_CREATED",
|
||||||
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
"description": "Server creation date. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/004.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json",
|
||||||
"title": "004 RPL_MYINFO",
|
"title": "004 RPL_MYINFO",
|
||||||
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
"description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/322.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json",
|
||||||
"title": "322 RPL_LIST",
|
"title": "322 RPL_LIST",
|
||||||
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
"description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/323.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json",
|
||||||
"title": "323 RPL_LISTEND",
|
"title": "323 RPL_LISTEND",
|
||||||
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
"description": "End of channel list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/332.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json",
|
||||||
"title": "332 RPL_TOPIC",
|
"title": "332 RPL_TOPIC",
|
||||||
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
"description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/353.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json",
|
||||||
"title": "353 RPL_NAMREPLY",
|
"title": "353 RPL_NAMREPLY",
|
||||||
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
"description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/366.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json",
|
||||||
"title": "366 RPL_ENDOFNAMES",
|
"title": "366 RPL_ENDOFNAMES",
|
||||||
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
"description": "End of NAMES list. RFC 1459 \u00a76.2.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/372.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json",
|
||||||
"title": "372 RPL_MOTD",
|
"title": "372 RPL_MOTD",
|
||||||
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
"description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/375.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json",
|
||||||
"title": "375 RPL_MOTDSTART",
|
"title": "375 RPL_MOTDSTART",
|
||||||
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
"description": "Start of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/376.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json",
|
||||||
"title": "376 RPL_ENDOFMOTD",
|
"title": "376 RPL_ENDOFMOTD",
|
||||||
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
"description": "End of MOTD. RFC 2812 \u00a75.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/401.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json",
|
||||||
"title": "401 ERR_NOSUCHNICK",
|
"title": "401 ERR_NOSUCHNICK",
|
||||||
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
"description": "No such nick/channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/403.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json",
|
||||||
"title": "403 ERR_NOSUCHCHANNEL",
|
"title": "403 ERR_NOSUCHCHANNEL",
|
||||||
"description": "No such channel. RFC 1459 \u00a76.1.",
|
"description": "No such channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/433.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json",
|
||||||
"title": "433 ERR_NICKNAMEINUSE",
|
"title": "433 ERR_NICKNAMEINUSE",
|
||||||
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
"description": "Nickname is already in use. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/442.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json",
|
||||||
"title": "442 ERR_NOTONCHANNEL",
|
"title": "442 ERR_NOTONCHANNEL",
|
||||||
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
"description": "You're not on that channel. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://git.eeqj.de/sneak/neoirc/schema/numerics/482.json",
|
"$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json",
|
||||||
"title": "482 ERR_CHANOPRIVSNEEDED",
|
"title": "482 ERR_CHANOPRIVSNEEDED",
|
||||||
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
"description": "You're not channel operator. RFC 1459 \u00a76.1.",
|
||||||
"$ref": "../message.json",
|
"$ref": "../message.json",
|
||||||
|
|||||||
Reference in New Issue
Block a user