Add MODE arguments support
- Add RPL_ISUPPORT support with CHANMODES, CHANTYPES, PREFIX parsing - Add support for channel mode state with mode arguments - Add upstream support for RPL_UMODEIS, RPL_CHANNELMODEIS - Request channel MODE on upstream channel JOIN - Use sane default channel mode and channel mode types
This commit is contained in:
parent
b0ab43e5d8
commit
98a95e9955
@ -29,10 +29,7 @@ func forwardChannel(dc *downstreamConn, ch *upstreamChannel) {
|
||||
|
||||
// TODO: send multiple members in each message
|
||||
for nick, membership := range ch.Members {
|
||||
s := dc.marshalNick(ch.conn, nick)
|
||||
if membership != 0 {
|
||||
s = string(membership) + s
|
||||
}
|
||||
s := membership.String() + dc.marshalNick(ch.conn, nick)
|
||||
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.srv.prefix(),
|
||||
|
@ -851,41 +851,7 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error {
|
||||
modeStr = msg.Params[1]
|
||||
}
|
||||
|
||||
uc, upstreamName, err := dc.unmarshalEntity(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if uc.isChannel(upstreamName) {
|
||||
// TODO: handle MODE channel mode arguments
|
||||
if modeStr != "" {
|
||||
uc.SendMessage(&irc.Message{
|
||||
Command: "MODE",
|
||||
Params: []string{upstreamName, modeStr},
|
||||
})
|
||||
} else {
|
||||
ch, ok := uc.channels[upstreamName]
|
||||
if !ok {
|
||||
return ircError{&irc.Message{
|
||||
Command: irc.ERR_NOSUCHCHANNEL,
|
||||
Params: []string{dc.nick, name, "No such channel"},
|
||||
}}
|
||||
}
|
||||
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.srv.prefix(),
|
||||
Command: irc.RPL_CHANNELMODEIS,
|
||||
Params: []string{dc.nick, name, string(ch.modes)},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if name != dc.nick {
|
||||
return ircError{&irc.Message{
|
||||
Command: irc.ERR_USERSDONTMATCH,
|
||||
Params: []string{dc.nick, "Cannot change mode for other users"},
|
||||
}}
|
||||
}
|
||||
|
||||
if name == dc.nick {
|
||||
if modeStr != "" {
|
||||
dc.forEachUpstream(func(uc *upstreamConn) {
|
||||
uc.SendMessage(&irc.Message{
|
||||
@ -900,6 +866,52 @@ func (dc *downstreamConn) handleMessageRegistered(msg *irc.Message) error {
|
||||
Params: []string{dc.nick, ""}, // TODO
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
uc, upstreamName, err := dc.unmarshalEntity(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !uc.isChannel(upstreamName) {
|
||||
return ircError{&irc.Message{
|
||||
Command: irc.ERR_USERSDONTMATCH,
|
||||
Params: []string{dc.nick, "Cannot change mode for other users"},
|
||||
}}
|
||||
}
|
||||
|
||||
if modeStr != "" {
|
||||
params := []string{upstreamName, modeStr}
|
||||
params = append(params, msg.Params[2:]...)
|
||||
uc.SendMessage(&irc.Message{
|
||||
Command: "MODE",
|
||||
Params: params,
|
||||
})
|
||||
} else {
|
||||
ch, ok := uc.channels[upstreamName]
|
||||
if !ok {
|
||||
return ircError{&irc.Message{
|
||||
Command: irc.ERR_NOSUCHCHANNEL,
|
||||
Params: []string{dc.nick, name, "No such channel"},
|
||||
}}
|
||||
}
|
||||
|
||||
if ch.modes == nil {
|
||||
// we haven't received the initial RPL_CHANNELMODEIS yet
|
||||
// ignore the request, we will broadcast the modes later when we receive RPL_CHANNELMODEIS
|
||||
return nil
|
||||
}
|
||||
|
||||
modeStr, modeParams := ch.modes.Format()
|
||||
params := []string{dc.nick, name, modeStr}
|
||||
params = append(params, modeParams...)
|
||||
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.srv.prefix(),
|
||||
Command: irc.RPL_CHANNELMODEIS,
|
||||
Params: params,
|
||||
})
|
||||
}
|
||||
case "WHO":
|
||||
if len(msg.Params) == 0 {
|
||||
|
138
irc.go
138
irc.go
@ -15,26 +15,26 @@ const (
|
||||
err_invalidcapcmd = "410"
|
||||
)
|
||||
|
||||
type modeSet string
|
||||
type userModes string
|
||||
|
||||
func (ms modeSet) Has(c byte) bool {
|
||||
func (ms userModes) Has(c byte) bool {
|
||||
return strings.IndexByte(string(ms), c) >= 0
|
||||
}
|
||||
|
||||
func (ms *modeSet) Add(c byte) {
|
||||
func (ms *userModes) Add(c byte) {
|
||||
if !ms.Has(c) {
|
||||
*ms += modeSet(c)
|
||||
*ms += userModes(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *modeSet) Del(c byte) {
|
||||
func (ms *userModes) Del(c byte) {
|
||||
i := strings.IndexByte(string(*ms), c)
|
||||
if i >= 0 {
|
||||
*ms = (*ms)[:i] + (*ms)[i+1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (ms *modeSet) Apply(s string) error {
|
||||
func (ms *userModes) Apply(s string) error {
|
||||
var plusMinus byte
|
||||
for i := 0; i < len(s); i++ {
|
||||
switch c := s[i]; c {
|
||||
@ -54,6 +54,94 @@ func (ms *modeSet) Apply(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type channelModeType byte
|
||||
|
||||
// standard channel mode types, as explained in https://modern.ircdocs.horse/#mode-message
|
||||
const (
|
||||
// modes that add or remove an address to or from a list
|
||||
modeTypeA channelModeType = iota
|
||||
// modes that change a setting on a channel, and must always have a parameter
|
||||
modeTypeB
|
||||
// modes that change a setting on a channel, and must have a parameter when being set, and no parameter when being unset
|
||||
modeTypeC
|
||||
// modes that change a setting on a channel, and must not have a parameter
|
||||
modeTypeD
|
||||
)
|
||||
|
||||
var stdChannelModes = map[byte]channelModeType{
|
||||
'b': modeTypeA, // ban list
|
||||
'e': modeTypeA, // ban exception list
|
||||
'I': modeTypeA, // invite exception list
|
||||
'k': modeTypeB, // channel key
|
||||
'l': modeTypeC, // channel user limit
|
||||
'i': modeTypeD, // channel is invite-only
|
||||
'm': modeTypeD, // channel is moderated
|
||||
'n': modeTypeD, // channel has no external messages
|
||||
's': modeTypeD, // channel is secret
|
||||
't': modeTypeD, // channel has protected topic
|
||||
}
|
||||
|
||||
type channelModes map[byte]string
|
||||
|
||||
func (cm channelModes) Apply(modeTypes map[byte]channelModeType, modeStr string, arguments ...string) error {
|
||||
nextArgument := 0
|
||||
var plusMinus byte
|
||||
for i := 0; i < len(modeStr); i++ {
|
||||
mode := modeStr[i]
|
||||
if mode == '+' || mode == '-' {
|
||||
plusMinus = mode
|
||||
continue
|
||||
}
|
||||
if plusMinus != '+' && plusMinus != '-' {
|
||||
return fmt.Errorf("malformed modestring %q: missing plus/minus", modeStr)
|
||||
}
|
||||
|
||||
mt, ok := modeTypes[mode]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if mt == modeTypeB || (mt == modeTypeC && plusMinus == '+') {
|
||||
if plusMinus == '+' {
|
||||
var argument string
|
||||
// some sentitive arguments (such as channel keys) can be omitted for privacy
|
||||
// (this will only happen for RPL_CHANNELMODEIS, never for MODE messages)
|
||||
if nextArgument < len(arguments) {
|
||||
argument = arguments[nextArgument]
|
||||
}
|
||||
cm[mode] = argument
|
||||
} else {
|
||||
delete(cm, mode)
|
||||
}
|
||||
nextArgument++
|
||||
} else if mt == modeTypeC || mt == modeTypeD {
|
||||
if plusMinus == '+' {
|
||||
cm[mode] = ""
|
||||
} else {
|
||||
delete(cm, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm channelModes) Format() (modeString string, parameters []string) {
|
||||
var modesWithValues strings.Builder
|
||||
var modesWithoutValues strings.Builder
|
||||
parameters = make([]string, 0, 16)
|
||||
for mode, value := range cm {
|
||||
if value != "" {
|
||||
modesWithValues.WriteString(string(mode))
|
||||
parameters = append(parameters, value)
|
||||
} else {
|
||||
modesWithoutValues.WriteString(string(mode))
|
||||
}
|
||||
}
|
||||
modeString = "+" + modesWithValues.String() + modesWithoutValues.String()
|
||||
return
|
||||
}
|
||||
|
||||
const stdChannelTypes = "#&+!"
|
||||
|
||||
type channelStatus byte
|
||||
|
||||
const (
|
||||
@ -74,32 +162,24 @@ func parseChannelStatus(s string) (channelStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
type membership byte
|
||||
|
||||
const (
|
||||
membershipFounder membership = '~'
|
||||
membershipProtected membership = '&'
|
||||
membershipOperator membership = '@'
|
||||
membershipHalfOp membership = '%'
|
||||
membershipVoice membership = '+'
|
||||
)
|
||||
|
||||
const stdMembershipPrefixes = "~&@%+"
|
||||
|
||||
func (m membership) String() string {
|
||||
if m == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(m)
|
||||
type membership struct {
|
||||
Mode byte
|
||||
Prefix byte
|
||||
}
|
||||
|
||||
func parseMembershipPrefix(s string) (prefix membership, nick string) {
|
||||
// TODO: any prefix from PREFIX RPL_ISUPPORT
|
||||
if strings.IndexByte(stdMembershipPrefixes, s[0]) >= 0 {
|
||||
return membership(s[0]), s[1:]
|
||||
} else {
|
||||
return 0, s
|
||||
var stdMemberships = []membership{
|
||||
{'q', '~'}, // founder
|
||||
{'a', '&'}, // protected
|
||||
{'o', '@'}, // operator
|
||||
{'h', '%'}, // halfop
|
||||
{'v', '+'}, // voice
|
||||
}
|
||||
|
||||
func (m *membership) String() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return string(m.Prefix)
|
||||
}
|
||||
|
||||
func parseMessageParams(msg *irc.Message, out ...*string) error {
|
||||
|
239
upstream.go
239
upstream.go
@ -21,8 +21,8 @@ type upstreamChannel struct {
|
||||
TopicWho string
|
||||
TopicTime time.Time
|
||||
Status channelStatus
|
||||
modes modeSet
|
||||
Members map[string]membership
|
||||
modes channelModes
|
||||
Members map[string]*membership
|
||||
complete bool
|
||||
}
|
||||
|
||||
@ -38,15 +38,16 @@ type upstreamConn struct {
|
||||
|
||||
serverName string
|
||||
availableUserModes string
|
||||
availableChannelModes string
|
||||
channelModesWithParam string
|
||||
availableChannelModes map[byte]channelModeType
|
||||
availableChannelTypes string
|
||||
availableMemberships []membership
|
||||
|
||||
registered bool
|
||||
nick string
|
||||
username string
|
||||
realname string
|
||||
closed bool
|
||||
modes modeSet
|
||||
modes userModes
|
||||
channels map[string]*upstreamChannel
|
||||
caps map[string]string
|
||||
|
||||
@ -72,16 +73,19 @@ func connectToUpstream(network *network) (*upstreamConn, error) {
|
||||
|
||||
outgoing := make(chan *irc.Message, 64)
|
||||
uc := &upstreamConn{
|
||||
network: network,
|
||||
logger: logger,
|
||||
net: netConn,
|
||||
irc: irc.NewConn(netConn),
|
||||
srv: network.user.srv,
|
||||
user: network.user,
|
||||
outgoing: outgoing,
|
||||
ring: NewRing(network.user.srv.RingCap),
|
||||
channels: make(map[string]*upstreamChannel),
|
||||
caps: make(map[string]string),
|
||||
network: network,
|
||||
logger: logger,
|
||||
net: netConn,
|
||||
irc: irc.NewConn(netConn),
|
||||
srv: network.user.srv,
|
||||
user: network.user,
|
||||
outgoing: outgoing,
|
||||
ring: NewRing(network.user.srv.RingCap),
|
||||
channels: make(map[string]*upstreamChannel),
|
||||
caps: make(map[string]string),
|
||||
availableChannelTypes: stdChannelTypes,
|
||||
availableChannelModes: stdChannelModes,
|
||||
availableMemberships: stdMemberships,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@ -130,17 +134,21 @@ func (uc *upstreamConn) getChannel(name string) (*upstreamChannel, error) {
|
||||
}
|
||||
|
||||
func (uc *upstreamConn) isChannel(entity string) bool {
|
||||
for _, r := range entity {
|
||||
switch r {
|
||||
// TODO: support upstream ISUPPORT channel prefixes
|
||||
case '#', '&', '+', '!':
|
||||
return true
|
||||
}
|
||||
break
|
||||
if i := strings.IndexByte(uc.availableChannelTypes, entity[0]); i >= 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (uc *upstreamConn) parseMembershipPrefix(s string) (membership *membership, nick string) {
|
||||
for _, m := range uc.availableMemberships {
|
||||
if m.Prefix == s[0] {
|
||||
return &m, s[1:]
|
||||
}
|
||||
}
|
||||
return nil, s
|
||||
}
|
||||
|
||||
func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
switch msg.Command {
|
||||
case "PING":
|
||||
@ -149,35 +157,6 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
Params: msg.Params,
|
||||
})
|
||||
return nil
|
||||
case "MODE":
|
||||
var name, modeStr string
|
||||
if err := parseMessageParams(msg, &name, &modeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !uc.isChannel(name) { // user mode change
|
||||
if name != uc.nick {
|
||||
return fmt.Errorf("received MODE message for unknown nick %q", name)
|
||||
}
|
||||
return uc.modes.Apply(modeStr)
|
||||
} else { // channel mode change
|
||||
// TODO: handle MODE channel mode arguments
|
||||
ch, err := uc.getChannel(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ch.modes.Apply(modeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uc.forEachDownstream(func(dc *downstreamConn) {
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.marshalUserPrefix(uc, msg.Prefix),
|
||||
Command: "MODE",
|
||||
Params: []string{dc.marshalChannel(uc, name), modeStr},
|
||||
})
|
||||
})
|
||||
}
|
||||
case "NOTICE":
|
||||
uc.logger.Print(msg)
|
||||
|
||||
@ -346,11 +325,67 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
})
|
||||
}
|
||||
case irc.RPL_MYINFO:
|
||||
if err := parseMessageParams(msg, nil, &uc.serverName, nil, &uc.availableUserModes, &uc.availableChannelModes); err != nil {
|
||||
if err := parseMessageParams(msg, nil, &uc.serverName, nil, &uc.availableUserModes, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(msg.Params) > 5 {
|
||||
uc.channelModesWithParam = msg.Params[5]
|
||||
case irc.RPL_ISUPPORT:
|
||||
if err := parseMessageParams(msg, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, token := range msg.Params[1 : len(msg.Params)-1] {
|
||||
negate := false
|
||||
parameter := token
|
||||
value := ""
|
||||
if strings.HasPrefix(token, "-") {
|
||||
negate = true
|
||||
token = token[1:]
|
||||
} else {
|
||||
if i := strings.IndexByte(token, '='); i >= 0 {
|
||||
parameter = token[:i]
|
||||
value = token[i+1:]
|
||||
}
|
||||
}
|
||||
if !negate {
|
||||
switch parameter {
|
||||
case "CHANMODES":
|
||||
parts := strings.SplitN(value, ",", 5)
|
||||
if len(parts) < 4 {
|
||||
return fmt.Errorf("malformed ISUPPORT CHANMODES value: %v", value)
|
||||
}
|
||||
modes := make(map[byte]channelModeType)
|
||||
for i, mt := range []channelModeType{modeTypeA, modeTypeB, modeTypeC, modeTypeD} {
|
||||
for j := 0; j < len(parts[i]); j++ {
|
||||
mode := parts[i][j]
|
||||
modes[mode] = mt
|
||||
}
|
||||
}
|
||||
uc.availableChannelModes = modes
|
||||
case "CHANTYPES":
|
||||
uc.availableChannelTypes = value
|
||||
case "PREFIX":
|
||||
if value == "" {
|
||||
uc.availableMemberships = nil
|
||||
} else {
|
||||
if value[0] != '(' {
|
||||
return fmt.Errorf("malformed ISUPPORT PREFIX value: %v", value)
|
||||
}
|
||||
sep := strings.IndexByte(value, ')')
|
||||
if sep < 0 || len(value) != sep*2 {
|
||||
return fmt.Errorf("malformed ISUPPORT PREFIX value: %v", value)
|
||||
}
|
||||
memberships := make([]membership, len(value)/2-1)
|
||||
for i := range memberships {
|
||||
memberships[i] = membership{
|
||||
Mode: value[i+1],
|
||||
Prefix: value[sep+i+1],
|
||||
}
|
||||
}
|
||||
uc.availableMemberships = memberships
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO: handle ISUPPORT negations
|
||||
}
|
||||
}
|
||||
case "NICK":
|
||||
if msg.Prefix == nil {
|
||||
@ -399,14 +434,19 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
uc.channels[ch] = &upstreamChannel{
|
||||
Name: ch,
|
||||
conn: uc,
|
||||
Members: make(map[string]membership),
|
||||
Members: make(map[string]*membership),
|
||||
}
|
||||
|
||||
uc.SendMessage(&irc.Message{
|
||||
Command: "MODE",
|
||||
Params: []string{ch},
|
||||
})
|
||||
} else {
|
||||
ch, err := uc.getChannel(ch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Members[msg.Prefix.Name] = 0
|
||||
ch.Members[msg.Prefix.Name] = nil
|
||||
}
|
||||
|
||||
uc.forEachDownstream(func(dc *downstreamConn) {
|
||||
@ -508,6 +548,89 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
Params: params,
|
||||
})
|
||||
})
|
||||
case "MODE":
|
||||
var name, modeStr string
|
||||
if err := parseMessageParams(msg, &name, &modeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !uc.isChannel(name) { // user mode change
|
||||
if name != uc.nick {
|
||||
return fmt.Errorf("received MODE message for unknown nick %q", name)
|
||||
}
|
||||
return uc.modes.Apply(modeStr)
|
||||
// TODO: notify downstreams about user mode change?
|
||||
} else { // channel mode change
|
||||
ch, err := uc.getChannel(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ch.modes != nil {
|
||||
if err := ch.modes.Apply(uc.availableChannelModes, modeStr, msg.Params[2:]...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
uc.forEachDownstream(func(dc *downstreamConn) {
|
||||
params := []string{dc.marshalChannel(uc, name), modeStr}
|
||||
params = append(params, msg.Params[2:]...)
|
||||
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.marshalUserPrefix(uc, msg.Prefix),
|
||||
Command: "MODE",
|
||||
Params: params,
|
||||
})
|
||||
})
|
||||
}
|
||||
case irc.RPL_UMODEIS:
|
||||
if err := parseMessageParams(msg, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
modeStr := ""
|
||||
if len(msg.Params) > 1 {
|
||||
modeStr = msg.Params[1]
|
||||
}
|
||||
|
||||
uc.modes = ""
|
||||
if err := uc.modes.Apply(modeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO: send RPL_UMODEIS to downstream connections when applicable
|
||||
case irc.RPL_CHANNELMODEIS:
|
||||
var channel string
|
||||
if err := parseMessageParams(msg, nil, &channel); err != nil {
|
||||
return err
|
||||
}
|
||||
modeStr := ""
|
||||
if len(msg.Params) > 2 {
|
||||
modeStr = msg.Params[2]
|
||||
}
|
||||
|
||||
ch, err := uc.getChannel(channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
firstMode := ch.modes == nil
|
||||
ch.modes = make(map[byte]string)
|
||||
if err := ch.modes.Apply(uc.availableChannelModes, modeStr, msg.Params[3:]...); err != nil {
|
||||
return err
|
||||
}
|
||||
if firstMode {
|
||||
modeStr, modeParams := ch.modes.Format()
|
||||
|
||||
uc.forEachDownstream(func(dc *downstreamConn) {
|
||||
params := []string{dc.nick, dc.marshalChannel(uc, channel), modeStr}
|
||||
params = append(params, modeParams...)
|
||||
|
||||
dc.SendMessage(&irc.Message{
|
||||
Prefix: dc.srv.prefix(),
|
||||
Command: irc.RPL_CHANNELMODEIS,
|
||||
Params: params,
|
||||
})
|
||||
})
|
||||
}
|
||||
case rpl_topicwhotime:
|
||||
var name, who, timeStr string
|
||||
if err := parseMessageParams(msg, nil, &name, &who, &timeStr); err != nil {
|
||||
@ -540,7 +663,7 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
ch.Status = status
|
||||
|
||||
for _, s := range strings.Split(members, " ") {
|
||||
membership, nick := parseMembershipPrefix(s)
|
||||
membership, nick := uc.parseMembershipPrefix(s)
|
||||
ch.Members[nick] = membership
|
||||
}
|
||||
case irc.RPL_ENDOFNAMES:
|
||||
@ -679,7 +802,7 @@ func (uc *upstreamConn) handleMessage(msg *irc.Message) error {
|
||||
nick := dc.marshalNick(uc, nick)
|
||||
channelList := make([]string, len(channels))
|
||||
for i, channel := range channels {
|
||||
prefix, channel := parseMembershipPrefix(channel)
|
||||
prefix, channel := uc.parseMembershipPrefix(channel)
|
||||
channel = dc.marshalChannel(uc, channel)
|
||||
channelList[i] = prefix.String() + channel
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user