Forward irc errors to the client, improve command validation and feedback, handle topic changes

This commit is contained in:
Ken-Håvard Lieng 2017-05-28 07:20:43 +02:00
parent 993d29242e
commit aa59e71745
17 changed files with 328 additions and 96 deletions

File diff suppressed because one or more lines are too long

View File

@ -428,11 +428,23 @@ i[class^="icon-"]:before, i[class*=" icon-"]:before {
color: #999;
}
.message-error {
color: #F6546A;
}
.message-prompt {
font-weight: 700;
font-style: italic;
color: #6BB758;
}
.message-action {
color: #FF6698;
}
.message-time {
font-style: normal;
font-weight: 400;
color: #999;
}

View File

@ -1,9 +1,10 @@
import createCommandMiddleware from './middleware/command';
import createCommandMiddleware, { beforeHandler, notFoundHandler } from './middleware/command';
import { COMMAND } from './state/actions';
import { join, part, invite, kick } from './state/channels';
import { sendMessage, addMessage, raw } from './state/messages';
import { join, part, invite, kick, setTopic } from './state/channels';
import { sendMessage, raw } from './state/messages';
import { setNick, disconnect, whois, away } from './state/servers';
import { select } from './state/tab';
import { find } from './util';
const help = [
'/join <channel> - Join a channel',
@ -11,35 +12,50 @@ const help = [
'/nick <nick> - Change nick',
'/quit - Disconnect from the current server',
'/me <message> - Send action message',
'/topic - Show topic for the current channel',
'/topic [topic] - Show or set topic in the current channel',
'/msg <target> <message> - Send message to the specified channel or user',
'/say <message> - Send message to the current chat',
'/invite <user> [channel] - Invite user to the current or specified channel',
'/kick <user> - Kick user from the current channel',
'/whois <user> - Get information about user',
'/invite <nick> [channel] - Invite user to the current or specified channel',
'/kick <nick> - Kick user from the current channel',
'/whois <nick> - Get information about user',
'/away [message] - Set or clear away message',
'/raw [message] - Send raw IRC message to the current server'
'/raw [message] - Send raw IRC message to the current server',
'/help [command]... - Print help for all or the specified command(s)'
];
const text = content => ({ content });
const error = content => ({ content, type: 'error' });
const prompt = content => ({ content, type: 'prompt' });
const findHelp = cmd => find(help, line => line.slice(1, line.indexOf(' ')) === cmd);
export default createCommandMiddleware(COMMAND, {
join({ dispatch, server }, channel) {
if (channel) {
if (channel[0] !== '#') {
return error('Bad channel name');
}
dispatch(join([channel], server));
dispatch(select(server, channel));
} else {
return error('Missing channel');
}
},
part({ dispatch, server, channel }, partChannel) {
part({ dispatch, server, channel, isChannel }, partChannel) {
if (partChannel) {
dispatch(part([partChannel], server));
} else {
} else if (isChannel) {
dispatch(part([channel], server));
} else {
return error('This is not a channel');
}
},
nick({ dispatch, server }, nick) {
if (nick) {
dispatch(setNick(nick, server));
} else {
return error('Missing nick');
}
},
@ -47,74 +63,127 @@ export default createCommandMiddleware(COMMAND, {
dispatch(disconnect(server));
},
me({ dispatch, server, channel }, ...params) {
if (params.length > 0) {
dispatch(sendMessage(`\x01ACTION ${params.join(' ')}\x01`, channel, server));
me({ dispatch, server, channel }, ...message) {
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(`\x01ACTION ${msg}\x01`, channel, server));
} else {
return error('Messages can not be empty');
}
},
topic({ dispatch, getState, server, channel }) {
const topic = getState().channels.getIn([server, channel, 'topic']);
if (topic) {
dispatch(addMessage({
server,
to: channel,
content: topic
}));
topic({ dispatch, getState, server, channel }, ...newTopic) {
if (newTopic.length > 0) {
dispatch(setTopic(newTopic.join(' '), channel, server));
} else {
const topic = getState().channels.getIn([server, channel, 'topic']);
if (topic) {
return text(topic);
}
return 'No topic set';
}
},
msg({ dispatch, server }, target, ...message) {
if (target && message) {
if (!target) {
return error('Missing nick/channel');
}
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(message.join(' '), target, server));
dispatch(select(server, target));
} else {
return error('Messages can not be empty');
}
},
say({ dispatch, server, channel }, ...message) {
if (channel && message) {
if (!channel) {
return error('Messages can only be sent to channels or users');
}
const msg = message.join(' ');
if (msg !== '') {
dispatch(sendMessage(message.join(' '), channel, server));
} else {
return error('Messages can not be empty');
}
},
invite({ dispatch, server, channel }, user, inviteChannel) {
invite({ dispatch, server, channel, isChannel }, user, inviteChannel) {
if (!inviteChannel && !isChannel) {
return error('This is not a channel');
}
if (user && inviteChannel) {
dispatch(invite(user, inviteChannel, server));
} else if (user && channel) {
dispatch(invite(user, channel, server));
} else {
return error('Missing nick');
}
},
kick({ dispatch, server, channel }, user) {
if (user && channel) {
kick({ dispatch, server, channel, isChannel }, user) {
if (!isChannel) {
return error('This is not a channel');
}
if (user) {
dispatch(kick(user, channel, server));
} else {
return error('Missing nick');
}
},
whois({ dispatch, server }, user) {
if (user) {
dispatch(whois(user, server));
} else {
return error('Missing nick');
}
},
away({ dispatch, server }, message) {
dispatch(away(message, server));
away({ dispatch, server }, ...message) {
const msg = message.join(' ');
dispatch(away(msg, server));
if (msg !== '') {
return 'Away message set';
}
return 'Away message cleared';
},
raw({ dispatch, server }, ...message) {
if (message) {
const cmd = message.join(' ');
if (message.length > 0 && message[0] !== '') {
const cmd = `${message[0].toUpperCase()} ${message.slice(1).join(' ')}`;
dispatch(raw(cmd, server));
return `=> ${cmd}`;
return prompt(`=> ${cmd}`);
}
return [
prompt('=> /raw'),
error('Missing message')
];
},
help(_, ...commands) {
if (commands.length > 0) {
const cmdHelp = commands.filter(findHelp).map(findHelp);
if (cmdHelp.length > 0) {
return text(cmdHelp);
}
return error('Unable to find any help :(');
}
return text(help);
},
[beforeHandler](_, command, ...params) {
if (command !== 'raw') {
return prompt(`=> /${command} ${params.join(' ')}`);
}
},
help() {
return help;
},
commandNotFound(_, command) {
return `The command /${command} was not found`;
[notFoundHandler](_, command) {
return error(`=> /${command}: No such command`);
}
});

View File

@ -1,9 +1,26 @@
import { inform } from '../state/messages';
import { addMessages, inform, print } from '../state/messages';
import { isChannel } from '../util';
const notFound = 'commandNotFound';
export const beforeHandler = '_before';
export const notFoundHandler = 'commandNotFound';
function createContext({ dispatch, getState }, { server, channel }) {
return { dispatch, getState, server, channel };
return { dispatch, getState, server, channel, isChannel: isChannel(channel) };
}
// TODO: Pull this out as convenience action
function process({ dispatch, server, channel }, result) {
if (typeof result === 'string') {
dispatch(inform(result, server, channel));
} else if (Array.isArray(result)) {
if (typeof result[0] === 'string') {
dispatch(inform(result, server, channel));
} else if (typeof result[0] === 'object') {
dispatch(addMessages(result, server, channel));
}
} else if (typeof result === 'object' && result) {
dispatch(print(result.content, server, channel, result.type));
}
}
export default function createCommandMiddleware(type, handlers) {
@ -13,16 +30,15 @@ export default function createCommandMiddleware(type, handlers) {
const command = words[0];
const params = words.slice(1);
let result;
if (command in handlers) {
result = handlers[command](createContext(store, action), ...params);
} else if (notFound in handlers) {
result = handlers[notFound](createContext(store, action), command);
}
if (typeof result === 'string' || Array.isArray(result)) {
store.dispatch(inform(result, action.server, action.channel));
const ctx = createContext(store, action);
if (beforeHandler in handlers) {
process(ctx, handlers[beforeHandler](ctx, command, ...params));
}
process(ctx, handlers[command](ctx, ...params));
} else if (notFoundHandler in handlers) {
const ctx = createContext(store, action);
process(ctx, handlers[notFoundHandler](ctx, command));
}
}

View File

@ -1,5 +1,5 @@
import { socketAction } from '../state/actions';
import { broadcast, inform, addMessage, addMessages } from '../state/messages';
import { broadcast, inform, print, addMessage, addMessages } from '../state/messages';
import { select } from '../state/tab';
import { normalizeChannel } from '../util';
import { replace } from '../util/router';
@ -71,25 +71,39 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
dispatch(broadcast(`${data.old} changed nick to ${data.new}`, data.server, channels));
},
topic({ server, channel, topic, nick }) {
if (nick) {
if (topic) {
dispatch(inform(`${nick} changed the topic to:`, server, channel));
dispatch(print(topic, server, channel));
} else {
dispatch(inform(`${nick} cleared the topic`, server, channel));
}
}
},
motd({ content, server }) {
dispatch(addMessages(content.map(line => ({ content: line })), server));
},
whois(data) {
const tab = getState().tab.selected;
if (data.nick) {
const tab = getState().tab.selected;
dispatch(inform([
`Nick: ${data.nick}`,
`Username: ${data.username}`,
`Realname: ${data.realname}`,
`Host: ${data.host}`,
`Server: ${data.server}`,
`Channels: ${data.channels}`
], tab.server, tab.name));
dispatch(print([
`Nick: ${data.nick}`,
`Username: ${data.username}`,
`Realname: ${data.realname}`,
`Host: ${data.host}`,
`Server: ${data.server}`,
`Channels: ${data.channels}`
], tab.server, tab.name));
}
},
print({ server, message }) {
dispatch(inform(message, server));
print(message) {
const tab = getState().tab.selected;
dispatch(addMessage(message, tab.server, tab.name));
}
};

View File

@ -2,6 +2,7 @@ export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const PART = 'PART';
export const SET_TOPIC = 'SET_TOPIC';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';

View File

@ -269,3 +269,16 @@ export function kick(user, channel, server) {
}
};
}
export function setTopic(topic, channel, server) {
return {
type: actions.SET_TOPIC,
topic,
channel,
server,
socket: {
type: 'topic',
data: { topic, channel, server }
}
};
}

View File

@ -223,20 +223,24 @@ export function broadcast(message, server, channels) {
})), server);
}
export function inform(message, server, channel) {
export function print(message, server, channel, type) {
if (Array.isArray(message)) {
return addMessages(message.map(line => ({
content: line,
type: 'info'
type
})), server, channel);
}
return addMessage({
content: message,
type: 'info'
type
}, server, channel);
}
export function inform(message, server, channel) {
return print(message, server, channel, 'info');
}
export function runCommand(command, channel, server) {
return {
type: actions.COMMAND,

View File

@ -11,6 +11,11 @@ export function normalizeChannel(channel) {
return channel.split('#').join('').toLowerCase();
}
export function isChannel(name) {
// TODO: Handle other channel types
return typeof name === 'string' && name[0] === '#';
}
export function timestamp(date = new Date()) {
const h = padStart(date.getHours(), 2, '0');
const m = padStart(date.getMinutes(), 2, '0');

View File

@ -106,8 +106,12 @@ func (c *Client) Part(channels ...string) {
c.Write("PART " + strings.Join(channels, ","))
}
func (c *Client) Topic(channel string) {
c.Write("TOPIC " + channel)
func (c *Client) Topic(channel string, topic ...string) {
msg := "TOPIC " + channel
if len(topic) > 0 {
msg += " :" + topic[0]
}
c.Write(msg)
}
func (c *Client) Invite(nick, channel string) {

View File

@ -96,6 +96,10 @@ func TestTopic(t *testing.T) {
c, out := testClientSend()
c.Topic("#chan")
assert.Equal(t, "TOPIC #chan\r\n", <-out)
c.Topic("#chan", "apple pie")
assert.Equal(t, "TOPIC #chan :apple pie\r\n", <-out)
c.Topic("#chan", "")
assert.Equal(t, "TOPIC #chan :\r\n", <-out)
}
func TestInvite(t *testing.T) {

View File

@ -26,6 +26,7 @@ const (
ReplyWhoisIdle = "317"
ReplyEndOfWhois = "318"
ReplyWhoisChannels = "319"
ReplyNoTopic = "331"
ReplyTopic = "332"
ReplyNamReply = "353"
ReplyEndOfNames = "366"

View File

@ -3,6 +3,7 @@ package server
import (
"log"
"strings"
"unicode"
"github.com/kjk/betterguid"
@ -52,6 +53,10 @@ func (i *ircHandler) run() {
}
func (i *ircHandler) dispatchMessage(msg *irc.Message) {
if msg.Command[0] == '4' {
i.session.printError(formatIRCError(msg))
}
if handler, ok := i.handlers[msg.Command]; ok {
handler(msg)
}
@ -78,14 +83,18 @@ func (i *ircHandler) join(msg *irc.Message) {
Channels: msg.Params,
})
channelStore.AddUser(msg.Nick, i.client.Host, msg.Params[0])
channel := msg.Params[0]
channelStore.AddUser(msg.Nick, i.client.Host, channel)
if msg.Nick == i.client.GetNick() {
i.session.sendLastMessages(i.client.Host, msg.Params[0], 50)
// Incase no topic is set and theres a cached one that needs to be cleared
i.client.Topic(channel)
i.session.sendLastMessages(i.client.Host, channel, 50)
go i.session.user.AddChannel(storage.Channel{
Server: i.client.Host,
Name: msg.Params[0],
Name: channel,
})
}
}
@ -193,13 +202,35 @@ func (i *ircHandler) whoisEnd(msg *irc.Message) {
}
func (i *ircHandler) topic(msg *irc.Message) {
var channel string
var nick string
if msg.Command == irc.Topic {
channel = msg.Params[0]
nick = msg.Nick
} else {
channel = msg.Params[1]
}
i.session.sendJSON("topic", Topic{
Server: i.client.Host,
Channel: msg.Params[1],
Channel: channel,
Topic: msg.LastParam(),
Nick: nick,
})
channelStore.SetTopic(msg.LastParam(), i.client.Host, msg.Params[1])
channelStore.SetTopic(msg.LastParam(), i.client.Host, channel)
}
func (i *ircHandler) noTopic(msg *irc.Message) {
channel := msg.Params[1]
i.session.sendJSON("topic", Topic{
Server: i.client.Host,
Channel: channel,
})
channelStore.SetTopic("", i.client.Host, channel)
}
func (i *ircHandler) names(msg *irc.Message) {
@ -245,6 +276,7 @@ func (i *ircHandler) initHandlers() {
irc.Privmsg: i.message,
irc.Notice: i.message,
irc.Quit: i.quit,
irc.Topic: i.topic,
irc.ReplyWelcome: i.info,
irc.ReplyYourHost: i.info,
irc.ReplyCreated: i.info,
@ -257,6 +289,7 @@ func (i *ircHandler) initHandlers() {
irc.ReplyWhoisServer: i.whoisServer,
irc.ReplyWhoisChannels: i.whoisChannels,
irc.ReplyEndOfWhois: i.whoisEnd,
irc.ReplyNoTopic: i.noTopic,
irc.ReplyTopic: i.topic,
irc.ReplyNamReply: i.names,
irc.ReplyEndOfNames: i.namesEnd,
@ -289,6 +322,19 @@ func isChannel(s string) bool {
return strings.IndexAny(s, "&#+!") == 0
}
func formatIRCError(msg *irc.Message) string {
errMsg := strings.TrimSuffix(msg.LastParam(), ".")
if len(msg.Params) > 2 {
for _, c := range msg.LastParam() {
if unicode.IsLower(c) {
return msg.Params[1] + " " + errMsg
}
return msg.Params[1] + ": " + errMsg
}
}
return errMsg
}
func printMessage(msg *irc.Message, i *irc.Client) {
log.Println(i.GetNick()+":", msg.Prefix, msg.Command, msg.Params, msg.LastParam())
}

View File

@ -225,6 +225,31 @@ func TestHandleIRCTopic(t *testing.T) {
Channel: "#chan",
Topic: "the topic",
}, res)
res = dispatchMessage(&irc.Message{
Command: irc.Topic,
Params: []string{"#chan", "the topic"},
Nick: "bob",
})
checkResponse(t, "topic", Topic{
Server: "host.com",
Channel: "#chan",
Topic: "the topic",
Nick: "bob",
}, res)
}
func TestHandleIRCNoTopic(t *testing.T) {
res := dispatchMessage(&irc.Message{
Command: irc.ReplyNoTopic,
Params: []string{"target", "#chan", "No topic set."},
})
checkResponse(t, "topic", Topic{
Server: "host.com",
Channel: "#chan",
}, res)
}
func TestHandleIRCNames(t *testing.T) {

View File

@ -61,11 +61,12 @@ type Quit struct {
}
type Message struct {
ID string `json:"id"`
Server string `json:"server"`
ID string `json:"id,omitempty"`
Server string `json:"server,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Content string `json:"content"`
Type string `json:"type,omitempty"`
}
type Messages struct {
@ -79,7 +80,8 @@ type Messages struct {
type Topic struct {
Server string `json:"server"`
Channel string `json:"channel"`
Topic string `json:"topic"`
Topic string `json:"topic,omitempty"`
Nick string `json:"nick,omitempty"`
}
type Userlist struct {

View File

@ -162,13 +162,19 @@ func (s *Session) sendMessages(server, channel string, count int, fromID string)
}
}
func (s *Session) print(server string, a ...interface{}) {
func (s *Session) print(a ...interface{}) {
s.sendJSON("print", Message{
Server: server,
Content: fmt.Sprintln(a...),
})
}
func (s *Session) printError(a ...interface{}) {
s.sendJSON("print", Message{
Content: fmt.Sprintln(a...),
Type: "error",
})
}
func (s *Session) resetExpirationIfEmpty() {
if s.numIRC() == 0 && s.numWS() == 0 {
s.reset <- AnonymousSessionExpiration

View File

@ -190,6 +190,15 @@ func (h *wsHandler) nick(b []byte) {
}
}
func (h *wsHandler) topic(b []byte) {
var data Topic
json.Unmarshal(b, &data)
if i, ok := h.session.getIRC(data.Server); ok {
i.Topic(data.Channel, data.Topic)
}
}
func (h *wsHandler) invite(b []byte) {
var data Invite
json.Unmarshal(b, &data)
@ -282,6 +291,7 @@ func (h *wsHandler) initHandlers() {
"quit": h.quit,
"message": h.message,
"nick": h.nick,
"topic": h.topic,
"invite": h.invite,
"kick": h.kick,
"whois": h.whois,