Collapse and log join, part and quit, closes #27, log nick and topic changes, move state into irc package

This commit is contained in:
Ken-Håvard Lieng 2020-06-03 03:04:38 +02:00
parent edd4d6eadb
commit ead3b37cf9
37 changed files with 1980 additions and 969 deletions

File diff suppressed because one or more lines are too long

View File

@ -725,6 +725,11 @@ input.chat-title {
cursor: pointer;
}
.message-events-more {
font-weight: 700;
cursor: pointer;
}
.message-input-wrap {
position: absolute;
left: 0;

View File

@ -2,24 +2,23 @@ import React, { memo } from 'react';
import classnames from 'classnames';
import stringToRGB from 'utils/color';
const Message = ({ message, coloredNick, style, onNickClick }) => {
const Message = ({ message, coloredNick, onNickClick }) => {
const className = classnames('message', {
[`message-${message.type}`]: message.type
});
if (message.type === 'date') {
return (
<div className={className} style={style}>
<div className={className}>
{message.content}
<hr />
</div>
);
}
style = {
...style,
paddingLeft: `${window.messageIndent + 15}px`,
textIndent: `-${window.messageIndent}px`
const style = {
paddingLeft: `${message.indent + 15}px`,
textIndent: `-${message.indent}px`
};
const senderStyle = {};

View File

@ -247,12 +247,13 @@ export default class MessageBox extends PureComponent {
const message = messages[index - 1];
return (
<div style={style}>
<Message
message={message}
coloredNick={coloredNicks}
style={style}
onNickClick={onNickClick}
/>
</div>
);
};

View File

@ -1,21 +1,17 @@
import { socketAction } from 'state/actions';
import { setConnected } from 'state/app';
import {
broadcast,
inform,
print,
addMessage,
addMessages
addMessages,
addEvent,
broadcastEvent
} from 'state/messages';
import { openModal } from 'state/modals';
import { reconnect } from 'state/servers';
import { select } from 'state/tab';
import { find } from 'utils';
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
}
function findChannels(state, server, user) {
const channels = [];
@ -46,37 +42,34 @@ export default function handleSocket({
},
join({ user, server, channels }) {
dispatch(inform(`${user} joined the channel`, server, channels[0]));
dispatch(addEvent(server, channels[0], 'join', user));
},
part({ user, server, channel, reason }) {
dispatch(
inform(withReason(`${user} left the channel`, reason), server, channel)
);
dispatch(addEvent(server, channel, 'part', user, reason));
},
quit({ user, server, reason }) {
const channels = findChannels(getState(), server, user);
dispatch(broadcast(withReason(`${user} quit`, reason), server, channels));
dispatch(broadcastEvent(server, channels, 'quit', user, reason));
},
nick({ server, oldNick, newNick }) {
if (oldNick) {
const channels = findChannels(getState(), server, oldNick);
dispatch(
broadcast(`${oldNick} changed nick to ${newNick}`, server, channels)
);
dispatch(broadcastEvent(server, channels, 'nick', oldNick, newNick));
}
},
topic({ server, channel, topic, nick }) {
if (nick) {
if (topic) {
dispatch(addEvent(server, channel, 'topic', nick, topic));
/* 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));
}
} */
}
},

View File

@ -10,7 +10,6 @@ const smallScreen = 600;
export default function widthUpdates({ store }) {
when(store, getCharWidth, charWidth => {
window.messageIndent = 6 * charWidth;
const scrollBarWidth = measureScrollBarWidth();
let prevWrapWidth;

View File

@ -1,6 +1,7 @@
import reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions';
import appReducer from '../app';
import { unix } from 'utils';
describe('message reducer', () => {
it('adds the message on ADD_MESSAGE', () => {
@ -98,7 +99,7 @@ describe('message reducer', () => {
it('adds date markers when prepending messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1999, 0, 1) }]
'#chan1': [{ id: 0, date: new Date(1990, 0, 3) }]
}
};
@ -108,8 +109,8 @@ describe('message reducer', () => {
tab: '#chan1',
prepend: true,
messages: [
{ id: 1, date: new Date(1990, 0, 2) },
{ id: 2, date: new Date(1990, 0, 3) }
{ id: 1, time: unix(new Date(1990, 0, 1)) },
{ id: 2, time: unix(new Date(1990, 0, 2)) }
]
});
@ -150,7 +151,7 @@ describe('message reducer', () => {
it('adds date markers when adding messages', () => {
let state = {
srv: {
'#chan1': [{ id: 0, date: new Date(1999, 0, 1) }]
'#chan1': [{ id: 0, date: new Date(1990, 0, 1) }]
}
};
@ -159,9 +160,9 @@ describe('message reducer', () => {
server: 'srv',
tab: '#chan1',
messages: [
{ id: 1, date: new Date(1990, 0, 2) },
{ id: 2, date: new Date(1990, 0, 3) },
{ id: 3, date: new Date(1990, 0, 3) }
{ id: 1, time: unix(new Date(1990, 0, 2)) },
{ id: 2, time: unix(new Date(1990, 0, 3)) },
{ id: 3, time: unix(new Date(1990, 0, 3)) }
]
});

View File

@ -1,3 +1,4 @@
import React from 'react';
import { createSelector } from 'reselect';
import has from 'lodash/has';
import {
@ -6,8 +7,10 @@ import {
linkify,
timestamp,
isChannel,
formatDate
formatDate,
unix
} from 'utils';
import stringToRGB from 'utils/color';
import colorify from 'utils/colorify';
import createReducer from 'utils/createReducer';
import { getApp } from './app';
@ -45,8 +48,214 @@ function init(state, server, tab) {
}
}
const collapsedEvents = ['join', 'part', 'quit'];
function shouldCollapse(msg1, msg2) {
return (
msg1.events &&
msg2.events &&
collapsedEvents.indexOf(msg1.events[0].type) !== -1 &&
collapsedEvents.indexOf(msg2.events[0].type) !== -1
);
}
const eventVerbs = {
join: 'joined the channel',
part: 'left the channel',
quit: 'quit'
};
function renderNick(nick, type = '') {
const style = {
color: stringToRGB(nick),
fontWeight: 400
};
return (
<span className="message-sender" style={style} key={`${nick} ${type}`}>
{nick}
</span>
);
}
function renderMore(count, type) {
return (
<span
className="message-events-more"
key={`more ${type}`}
>{`${count} more`}</span>
);
}
function renderEvent(event, type, nicks) {
const ending = eventVerbs[type];
if (nicks.length === 1) {
event.push(renderNick(nicks[0], type));
event.push(` ${ending}`);
}
if (nicks.length === 2) {
event.push(renderNick(nicks[0], type));
event.push(' and ');
event.push(renderNick(nicks[1], type));
event.push(` ${ending}`);
}
if (nicks.length > 2) {
event.push(renderNick(nicks[0], type));
event.push(', ');
event.push(renderNick(nicks[1], type));
event.push(' and ');
event.push(renderMore(nicks.length - 2, type));
event.push(` ${ending}`);
}
}
function renderEvents(events) {
const first = events[0];
if (first.type === 'nick') {
const [oldNick, newNick] = first.params;
return [renderNick(oldNick), ' changed nick to ', renderNick(newNick)];
}
if (first.type === 'topic') {
const [nick, newTopic] = first.params;
const topic = colorify(linkify(newTopic));
if (!topic) {
return [renderNick(nick), ' cleared the topic'];
}
const result = [renderNick(nick), ' changed the topic to: '];
if (Array.isArray(topic)) {
result.push(...topic);
} else {
result.push(topic);
}
return result;
}
const byType = {};
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
const [nick] = event.params;
if (!byType[event.type]) {
byType[event.type] = [nick];
} else if (byType[event.type].indexOf(nick) === -1) {
byType[event.type].push(nick);
}
}
const result = [];
if (byType.join) {
renderEvent(result, 'join', byType.join);
}
if (byType.part) {
if (result.length > 1) {
result[result.length - 1] += ', ';
}
renderEvent(result, 'part', byType.part);
}
if (byType.quit) {
if (result.length > 1) {
result[result.length - 1] += ', ';
}
renderEvent(result, 'quit', byType.quit);
}
return result;
}
let nextID = 0;
function initMessage(
state,
message,
server,
tab,
wrapWidth,
charWidth,
windowWidth,
prepend
) {
const messages = state[server][tab];
if (messages.length > 0 && !prepend) {
const lastMessage = messages[messages.length - 1];
if (shouldCollapse(lastMessage, message)) {
lastMessage.events.push(message.events[0]);
lastMessage.content = renderEvents(lastMessage.events);
[lastMessage.breakpoints, lastMessage.length] = findBreakpoints(
lastMessage.content
);
lastMessage.height = messageHeight(
lastMessage,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
return false;
}
}
if (message.time) {
message.date = new Date(message.time * 1000);
} else {
message.date = new Date();
}
message.time = timestamp(message.date);
if (!message.id) {
message.id = nextID;
nextID++;
}
if (tab.charAt(0) === '#') {
message.channel = true;
}
if (message.events) {
message.type = 'info';
message.content = renderEvents(message.events);
} else {
message.content = message.content || '';
// Collapse multiple adjacent spaces into a single one
message.content = message.content.replace(/\s\s+/g, ' ');
if (message.content.indexOf('\x01ACTION') === 0) {
const { from } = message;
message.from = null;
message.type = 'action';
message.content = from + message.content.slice(7, -1);
}
}
if (!message.events) {
message.content = colorify(linkify(message.content));
}
[message.breakpoints, message.length] = findBreakpoints(message.content);
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
message.indent = 6 * charWidth;
return true;
}
function createDateMessage(date) {
const message = {
id: nextID,
@ -68,14 +277,34 @@ function isSameDay(d1, d2) {
);
}
function reducerPrependMessages(messages, server, tab, state) {
function reducerPrependMessages(
state,
messages,
server,
tab,
wrapWidth,
charWidth,
windowWidth
) {
const msgs = [];
for (let i = 0; i < messages.length; i++) {
if (i > 0 && !isSameDay(messages[i - 1].date, messages[i].date)) {
msgs.push(createDateMessage(messages[i].date));
const message = messages[i];
initMessage(
state,
message,
server,
tab,
wrapWidth,
charWidth,
windowWidth,
true
);
if (i > 0 && !isSameDay(messages[i - 1].date, message.date)) {
msgs.push(createDateMessage(message.date));
}
msgs.push(messages[i]);
msgs.push(message);
}
const m = state[server][tab];
@ -110,15 +339,41 @@ function reducerAddMessage(message, server, tab, state) {
export default createReducer(
{},
{
[actions.ADD_MESSAGE](state, { server, tab, message }) {
[actions.ADD_MESSAGE](
state,
{ server, tab, message, wrapWidth, charWidth, windowWidth }
) {
init(state, server, tab);
const shouldAdd = initMessage(
state,
message,
server,
tab,
wrapWidth,
charWidth,
windowWidth
);
if (shouldAdd) {
reducerAddMessage(message, server, tab, state);
}
},
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
[actions.ADD_MESSAGES](
state,
{ server, tab, messages, prepend, wrapWidth, charWidth, windowWidth }
) {
if (prepend) {
init(state, server, tab);
reducerPrependMessages(messages, server, tab, state);
reducerPrependMessages(
state,
messages,
server,
tab,
wrapWidth,
charWidth,
windowWidth
);
} else {
if (!messages[0].tab) {
init(state, server, tab);
@ -128,7 +383,19 @@ export default createReducer(
if (message.tab) {
init(state, server, message.tab);
}
const shouldAdd = initMessage(
state,
message,
server,
message.tab || tab,
wrapWidth,
charWidth,
windowWidth
);
if (shouldAdd) {
reducerAddMessage(message, server, message.tab || tab, state);
}
});
}
},
@ -184,53 +451,6 @@ export default createReducer(
}
);
function initMessage(message, tab, state) {
if (message.time) {
message.date = new Date(message.time * 1000);
} else {
message.date = new Date();
}
message.time = timestamp(message.date);
if (!message.id) {
message.id = nextID;
nextID++;
}
if (tab.charAt(0) === '#') {
message.channel = true;
}
message.content = message.content || '';
// Collapse multiple adjacent spaces into a single one
message.content = message.content.replace(/\s\s+/g, ' ');
if (message.content.indexOf('\x01ACTION') === 0) {
const { from } = message;
message.from = null;
message.type = 'action';
message.content = from + message.content.slice(7, -1);
}
const { wrapWidth, charWidth, windowWidth } = getApp(state);
message.length = message.content.length;
message.breakpoints = findBreakpoints(message.content);
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
message.content = colorify(linkify(message.content));
return message;
}
export function getMessageTab(server, to) {
if (!to || to === '*' || (!isChannel(to) && to.indexOf('.') !== -1)) {
return server;
@ -284,19 +504,19 @@ export function updateMessageHeight(wrapWidth, charWidth, windowWidth) {
export function sendMessage(content, to, server) {
return (dispatch, getState) => {
const state = getState();
const { wrapWidth, charWidth, windowWidth } = getApp(state);
dispatch({
type: actions.ADD_MESSAGE,
server,
tab: to,
message: initMessage(
{
message: {
from: state.servers[server].nick,
content
},
to,
state
),
wrapWidth,
charWidth,
windowWidth,
socket: {
type: 'message',
data: { content, to, server }
@ -308,13 +528,19 @@ export function sendMessage(content, to, server) {
export function addMessage(message, server, to) {
const tab = getMessageTab(server, to);
return (dispatch, getState) =>
return (dispatch, getState) => {
const { wrapWidth, charWidth, windowWidth } = getApp(getState());
dispatch({
type: actions.ADD_MESSAGE,
server,
tab,
message: initMessage(message, tab, getState())
message,
wrapWidth,
charWidth,
windowWidth
});
};
}
export function addMessages(messages, server, to, prepend, next) {
@ -328,20 +554,57 @@ export function addMessages(messages, server, to, prepend, next) {
messages[0].next = true;
}
messages.forEach(message =>
initMessage(message, message.tab || tab, state)
);
const { wrapWidth, charWidth, windowWidth } = getApp(state);
dispatch({
type: actions.ADD_MESSAGES,
server,
tab,
messages,
prepend
prepend,
wrapWidth,
charWidth,
windowWidth
});
};
}
export function addEvent(server, tab, type, ...params) {
return addMessage(
{
type: 'info',
events: [
{
type,
params,
time: unix()
}
]
},
server,
tab
);
}
export function broadcastEvent(server, channels, type, ...params) {
const now = unix();
return addMessages(
channels.map(channel => ({
type: 'info',
tab: channel,
events: [
{
type,
params,
time: now
}
]
})),
server
);
}
export function broadcast(message, server, channels) {
return addMessages(
channels.map(channel => ({

View File

@ -141,6 +141,13 @@ export function timestamp(date = new Date()) {
const dateFmt = new Intl.DateTimeFormat(window.navigator.language);
export const formatDate = dateFmt.format;
export function unix(date) {
if (date) {
return Math.floor(date.getTime() / 1000);
}
return Math.floor(Date.now() / 1000);
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

View File

@ -2,20 +2,40 @@ const lineHeight = 24;
const userListWidth = 200;
const smallScreen = 600;
export function findBreakpoints(text) {
const breakpoints = [];
function findBreakpointsString(text, breakpoints, index) {
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
if (char === ' ') {
breakpoints.push({ end: i, next: i + 1 });
} else if (char === '-' && i !== text.length - 1) {
breakpoints.push({ end: i + 1, next: i + 1 });
breakpoints.push({ end: i + index, next: i + 1 + index });
} else if (i !== text.length - 1 && (char === '-' || char === '?')) {
breakpoints.push({ end: i + 1 + index, next: i + 1 + index });
}
}
}
export function findBreakpoints(text) {
const breakpoints = [];
let length = 0;
if (typeof text === 'string') {
findBreakpointsString(text, breakpoints, length);
length = text.length;
} else if (Array.isArray(text)) {
for (let i = 0; i < text.length; i++) {
const node = text[i];
if (typeof node === 'string') {
findBreakpointsString(node, breakpoints, length);
length += node.length;
} else {
findBreakpointsString(node.props.children, breakpoints, length);
length += node.props.children.length;
}
}
}
return breakpoints;
return [breakpoints, length];
}
export function messageHeight(

View File

@ -48,7 +48,10 @@ var rootCmd = &cobra.Command{
storage.Initialize(viper.GetString("dir"), viper.GetString("data"), viper.GetString("conf"))
initConfig(storage.Path.Config(), viper.GetBool("reset-config"))
err := initConfig(storage.Path.Config(), viper.GetBool("reset-config"))
if err != nil {
log.Fatal(err)
}
},
Run: func(cmd *cobra.Command, args []string) {
@ -63,6 +66,14 @@ var rootCmd = &cobra.Command{
}
defer db.Close()
storage.GetMessageStore = func(user *storage.User) (storage.MessageStore, error) {
return boltdb.New(storage.Path.Log(user.Username))
}
storage.GetMessageSearchProvider = func(user *storage.User) (storage.MessageSearchProvider, error) {
return bleve.New(storage.Path.Index(user.Username))
}
cfg, cfgUpdated := config.LoadConfig()
dispatch := server.New(cfg)
@ -76,14 +87,6 @@ var rootCmd = &cobra.Command{
dispatch.Store = db
dispatch.SessionStore = db
dispatch.GetMessageStore = func(user *storage.User) (storage.MessageStore, error) {
return boltdb.New(storage.Path.Log(user.Username))
}
dispatch.GetMessageSearchProvider = func(user *storage.User) (storage.MessageSearchProvider, error) {
return bleve.New(storage.Path.Index(user.Username))
}
dispatch.Run()
},
}
@ -119,19 +122,16 @@ func init() {
viper.SetDefault("dcc.autoget.delete", true)
}
func initConfig(configPath string, overwrite bool) {
func initConfig(configPath string, overwrite bool) error {
if _, err := os.Stat(configPath); overwrite || os.IsNotExist(err) {
config, err := assets.Asset("config.default.toml")
if err != nil {
log.Println(err)
return
return err
}
log.Println("Writing default config to", configPath)
err = ioutil.WriteFile(configPath, config, 0600)
if err != nil {
log.Println(err)
}
return ioutil.WriteFile(configPath, config, 0600)
}
return nil
}

View File

@ -5,6 +5,9 @@ import (
)
func (c *Client) HasCapability(name string, values ...string) bool {
c.lock.Lock()
defer c.lock.Unlock()
if capValues, ok := c.enabledCapabilities[name]; ok {
if len(values) == 0 || capValues == nil {
return true

View File

@ -35,6 +35,8 @@ type Client struct {
Messages chan *Message
ConnectionChanged chan ConnectionState
Features *Features
state *state
nick string
channels []string
@ -80,18 +82,15 @@ func NewClient(config *Config) *Client {
wantedCapabilities = append(wantedCapabilities, "sasl")
}
return &Client{
client := &Client{
Config: config,
nick: config.Nick,
Features: NewFeatures(),
Messages: make(chan *Message, 32),
ConnectionChanged: make(chan ConnectionState, 4),
Features: NewFeatures(),
nick: config.Nick,
wantedCapabilities: wantedCapabilities,
requestedCapabilities: map[string][]string{},
enabledCapabilities: map[string][]string{},
ConnectionChanged: make(chan ConnectionState, 4),
out: make(chan string, 32),
quit: make(chan struct{}),
reconnect: make(chan struct{}),
dialer: &net.Dialer{Timeout: 10 * time.Second},
recvBuf: make([]byte, 0, 4096),
backoff: &backoff.Backoff{
@ -99,7 +98,13 @@ func NewClient(config *Config) *Client {
Max: 30 * time.Second,
Jitter: true,
},
out: make(chan string, 32),
quit: make(chan struct{}),
reconnect: make(chan struct{}),
}
client.state = newState(client)
return client
}
func (c *Client) GetNick() string {
@ -143,6 +148,18 @@ func (c *Client) Host() string {
return c.Config.Host
}
func (c *Client) MOTD() []string {
return c.state.getMOTD()
}
func (c *Client) ChannelUsers(channel string) []string {
return c.state.getUsers(channel)
}
func (c *Client) ChannelTopic(channel string) string {
return c.state.getTopic(channel)
}
func (c *Client) Nick(nick string) {
c.Write("NICK " + nick)
}

View File

@ -21,7 +21,7 @@ func (c *Client) Connect() {
}
func (c *Client) Reconnect() {
close(c.reconnect)
c.tryConnect()
}
func (c *Client) Write(data string) {
@ -63,6 +63,7 @@ func (c *Client) run() {
c.sendRecv.Wait()
c.reconnect = make(chan struct{})
c.state.reset()
time.Sleep(c.backoff.Duration())
c.tryConnect()
@ -178,7 +179,7 @@ func (c *Client) recv() {
default:
c.connChange(false, nil)
c.Reconnect()
close(c.reconnect)
return
}
}
@ -195,54 +196,7 @@ func (c *Client) recv() {
return
}
switch msg.Command {
case PING:
go c.write("PONG :" + msg.LastParam())
case JOIN:
if c.Is(msg.Sender) {
c.addChannel(msg.Params[0])
}
case NICK:
if c.Is(msg.Sender) {
c.setNick(msg.LastParam())
}
case PRIVMSG:
if ctcp := msg.ToCTCP(); ctcp != nil {
c.handleCTCP(ctcp, msg)
}
case CAP:
c.handleCAP(msg)
case RPL_WELCOME:
c.setNick(msg.Params[0])
c.setRegistered(true)
c.flushChannels()
c.backoff.Reset()
c.sendRecv.Add(1)
go c.send()
case RPL_ISUPPORT:
c.Features.Parse(msg.Params)
case ERR_NICKNAMEINUSE, ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE:
if c.Config.HandleNickInUse != nil {
go c.writeNick(c.Config.HandleNickInUse(msg.Params[1]))
}
case ERROR:
c.Messages <- msg
c.connChange(false, nil)
time.Sleep(5 * time.Second)
close(c.quit)
return
}
c.handleSASL(msg)
c.handleMessage(msg)
c.Messages <- msg
}

View File

@ -147,7 +147,7 @@ func TestRecv(t *testing.T) {
func TestRecvTriggersReconnect(t *testing.T) {
c := NewClient(&Config{})
c.conn = &mockConn{}
c.scan = bufio.NewScanner(bytes.NewBufferString("001 bob\r\n"))
c.scan = bufio.NewScanner(bytes.NewBufferString(""))
done := make(chan struct{})
ok := false
go func() {

158
pkg/irc/internal.go Normal file
View File

@ -0,0 +1,158 @@
package irc
import (
"strings"
"time"
)
func (c *Client) handleMessage(msg *Message) {
switch msg.Command {
case CAP:
c.handleCAP(msg)
case PING:
go c.write("PONG :" + msg.LastParam())
case JOIN:
if len(msg.Params) > 0 {
channel := msg.Params[0]
if c.Is(msg.Sender) {
c.addChannel(channel)
}
c.state.addUser(msg.Sender, channel)
}
case PART:
if len(msg.Params) > 0 {
channel := msg.Params[0]
if c.Is(msg.Sender) {
c.state.removeChannel(channel)
} else {
c.state.removeUser(msg.Sender, channel)
}
}
case QUIT:
msg.meta = c.state.removeUserAll(msg.Sender)
case NICK:
if c.Is(msg.Sender) {
c.setNick(msg.LastParam())
}
msg.meta = c.state.renameUser(msg.Sender, msg.LastParam())
case PRIVMSG:
if ctcp := msg.ToCTCP(); ctcp != nil {
c.handleCTCP(ctcp, msg)
}
case MODE:
if len(msg.Params) > 1 {
target := msg.Params[0]
if len(msg.Params) > 2 && isChannel(target) {
mode := ParseMode(msg.Params[1])
mode.Server = c.Host()
mode.Channel = target
mode.User = msg.Params[2]
c.state.setMode(target, msg.Params[2], mode.Add, mode.Remove)
msg.meta = mode
}
}
case TOPIC, RPL_TOPIC:
chIndex := 0
if msg.Command == RPL_TOPIC {
chIndex = 1
}
if len(msg.Params) > chIndex {
c.state.setTopic(msg.LastParam(), msg.Params[chIndex])
}
case RPL_NOTOPIC:
if len(msg.Params) > 1 {
channel := msg.Params[1]
c.state.setTopic("", channel)
}
case RPL_WELCOME:
if len(msg.Params) > 0 {
c.setNick(msg.Params[0])
}
c.setRegistered(true)
c.flushChannels()
c.backoff.Reset()
c.sendRecv.Add(1)
go c.send()
case RPL_ISUPPORT:
c.Features.Parse(msg.Params)
case ERR_NICKNAMEINUSE, ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE:
if c.Config.HandleNickInUse != nil && len(msg.Params) > 1 {
go c.writeNick(c.Config.HandleNickInUse(msg.Params[1]))
}
case RPL_NAMREPLY:
channel := msg.Params[2]
users := strings.Split(strings.TrimSuffix(msg.LastParam(), " "), " ")
userBuffer := c.state.userBuffers[channel]
c.state.userBuffers[channel] = append(userBuffer, users...)
case RPL_ENDOFNAMES:
channel := msg.Params[1]
users := c.state.userBuffers[channel]
c.state.setUsers(users, channel)
delete(c.state.userBuffers, channel)
msg.meta = users
case ERROR:
c.Messages <- msg
c.connChange(false, nil)
time.Sleep(5 * time.Second)
close(c.quit)
return
}
c.handleSASL(msg)
}
type Mode struct {
Server string
Channel string
User string
Add string
Remove string
}
func ParseMode(mode string) *Mode {
m := Mode{}
add := false
for _, c := range mode {
if c == '+' {
add = true
} else if c == '-' {
add = false
} else if add {
m.Add += string(c)
} else {
m.Remove += string(c)
}
}
return &m
}
func isChannel(s string) bool {
return strings.IndexAny(s, "&#+!") == 0
}

37
pkg/irc/internal_test.go Normal file
View File

@ -0,0 +1,37 @@
package irc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestHandlePing(t *testing.T) {
c, out := testClientSend()
c.handleMessage(&Message{
Command: "PING",
Params: []string{"voi voi"},
})
assert.Equal(t, "PONG :voi voi\r\n", <-out)
}
func TestHandleNamreply(t *testing.T) {
c, _ := testClientSend()
c.handleMessage(&Message{
Command: RPL_NAMREPLY,
Params: []string{"", "", "#chan", "a b c"},
})
c.handleMessage(&Message{
Command: RPL_NAMREPLY,
Params: []string{"", "", "#chan", "d"},
})
endMsg := &Message{
Command: RPL_ENDOFNAMES,
Params: []string{"", "#chan"},
}
c.handleMessage(endMsg)
assert.Equal(t, []string{"a", "b", "c", "d"}, endMsg.meta)
}

View File

@ -11,6 +11,8 @@ type Message struct {
Host string
Command string
Params []string
meta interface{}
}
func (m *Message) LastParam() string {

33
pkg/irc/meta.go Normal file
View File

@ -0,0 +1,33 @@
package irc
// GetNickChannels returns the channels the client has in common with
// the user that changed nick
func GetNickChannels(msg *Message) []string {
return stringListMeta(msg)
}
// GetQuitChannels returns the channels the client has in common with
// the user that quit
func GetQuitChannels(msg *Message) []string {
return stringListMeta(msg)
}
func GetMode(msg *Message) *Mode {
if mode, ok := msg.meta.(*Mode); ok {
return mode
}
return nil
}
// GetNamreplyUsers returns all RPL_NAMREPLY users
// when passed a RPL_ENDOFNAMES message
func GetNamreplyUsers(msg *Message) []string {
return stringListMeta(msg)
}
func stringListMeta(msg *Message) []string {
if list, ok := msg.meta.([]string); ok {
return list
}
return nil
}

236
pkg/irc/state.go Normal file
View File

@ -0,0 +1,236 @@
package irc
import (
"strings"
"sync"
)
type state struct {
client *Client
users map[string][]*User
topic map[string]string
userBuffers map[string][]string
motd []string
lock sync.Mutex
}
const userModePrefixes = "~&@%+"
const userModeChars = "qaohv"
type User struct {
nick string
modes string
prefix string
}
func NewUser(nick string) *User {
user := &User{nick: nick}
if i := strings.IndexAny(nick, userModePrefixes); i == 0 {
i = strings.Index(userModePrefixes, string(nick[0]))
user.modes = string(userModeChars[i])
user.nick = nick[1:]
user.updatePrefix()
}
return user
}
func (u *User) String() string {
return u.prefix + u.nick
}
func (u *User) AddModes(modes string) {
for _, mode := range modes {
if strings.Contains(u.modes, string(mode)) {
continue
}
u.modes += string(mode)
}
u.updatePrefix()
}
func (u *User) RemoveModes(modes string) {
for _, mode := range modes {
u.modes = strings.Replace(u.modes, string(mode), "", 1)
}
u.updatePrefix()
}
func (u *User) updatePrefix() {
for i, mode := range userModeChars {
if strings.Contains(u.modes, string(mode)) {
u.prefix = string(userModePrefixes[i])
return
}
}
u.prefix = ""
}
func newState(client *Client) *state {
return &state{
client: client,
users: make(map[string][]*User),
topic: make(map[string]string),
userBuffers: make(map[string][]string),
}
}
func (s *state) reset() {
s.lock.Lock()
s.users = make(map[string][]*User)
s.topic = make(map[string]string)
s.userBuffers = make(map[string][]string)
s.motd = []string{}
s.lock.Unlock()
}
func (s *state) removeChannel(channel string) {
s.lock.Lock()
delete(s.users, channel)
delete(s.topic, channel)
s.lock.Unlock()
}
func (s *state) getUsers(channel string) []string {
s.lock.Lock()
users := make([]string, len(s.users[channel]))
for i, user := range s.users[channel] {
users[i] = user.String()
}
s.lock.Unlock()
return users
}
func (s *state) setUsers(users []string, channel string) {
s.lock.Lock()
s.users[channel] = make([]*User, len(users))
for i, nick := range users {
s.users[channel][i] = NewUser(nick)
}
s.lock.Unlock()
}
func (s *state) addUser(user, channel string) {
s.lock.Lock()
if users, ok := s.users[channel]; ok {
for _, u := range users {
if u.nick == user {
s.lock.Unlock()
return
}
}
s.users[channel] = append(users, NewUser(user))
} else {
s.users[channel] = []*User{NewUser(user)}
}
s.lock.Unlock()
}
func (s *state) removeUser(user, channel string) {
s.lock.Lock()
s.internalRemoveUser(user, channel)
s.lock.Unlock()
}
func (s *state) removeUserAll(user string) []string {
channels := []string{}
s.lock.Lock()
for channel := range s.users {
if s.internalRemoveUser(user, channel) {
channels = append(channels, channel)
}
}
s.lock.Unlock()
return channels
}
func (s *state) renameUser(oldNick, newNick string) []string {
s.lock.Lock()
channels := s.renameAll(oldNick, newNick)
s.lock.Unlock()
return channels
}
func (s *state) setMode(channel, user, add, remove string) {
s.lock.Lock()
for _, u := range s.users[channel] {
if u.nick == user {
u.AddModes(add)
u.RemoveModes(remove)
s.lock.Unlock()
return
}
}
s.lock.Unlock()
}
func (s *state) getTopic(channel string) string {
s.lock.Lock()
topic := s.topic[channel]
s.lock.Unlock()
return topic
}
func (s *state) setTopic(topic, channel string) {
s.lock.Lock()
s.topic[channel] = topic
s.lock.Unlock()
}
func (s *state) getMOTD() []string {
s.lock.Lock()
motd := s.motd
s.lock.Unlock()
return motd
}
func (s *state) rename(channel, oldNick, newNick string) bool {
for _, user := range s.users[channel] {
if user.nick == oldNick {
user.nick = newNick
return true
}
}
return false
}
func (s *state) renameAll(oldNick, newNick string) []string {
channels := []string{}
for channel := range s.users {
if s.rename(channel, oldNick, newNick) {
channels = append(channels, channel)
}
}
return channels
}
func (s *state) internalRemoveUser(user, channel string) bool {
for i, u := range s.users[channel] {
if u.nick == user {
users := s.users[channel]
s.users[channel] = append(users[:i], users[i+1:]...)
return true
}
}
return false
}

89
pkg/irc/state_test.go Normal file
View File

@ -0,0 +1,89 @@
package irc
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestStateGetSetUsers(t *testing.T) {
state := newState(NewClient(&Config{}))
users := []string{"a", "b"}
state.setUsers(users, "#chan")
assert.Equal(t, users, state.getUsers("#chan"))
state.setUsers(users, "#chan")
assert.Equal(t, users, state.getUsers("#chan"))
}
func TestStateAddRemoveUser(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan")
state.addUser("user", "#chan")
assert.Len(t, state.getUsers("#chan"), 1)
state.addUser("user2", "#chan")
assert.Equal(t, []string{"user", "user2"}, state.getUsers("#chan"))
state.removeUser("user", "#chan")
assert.Equal(t, []string{"user2"}, state.getUsers("#chan"))
}
func TestStateRemoveUserAll(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan1")
state.addUser("user", "#chan2")
state.removeUserAll("user")
assert.Empty(t, state.getUsers("#chan1"))
assert.Empty(t, state.getUsers("#chan2"))
}
func TestStateRenameUser(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("user", "#chan1")
state.addUser("user", "#chan2")
state.renameUser("user", "new")
assert.Equal(t, []string{"new"}, state.getUsers("#chan1"))
assert.Equal(t, []string{"new"}, state.getUsers("#chan2"))
state.addUser("@gotop", "#chan3")
state.renameUser("gotop", "stillgotit")
assert.Equal(t, []string{"@stillgotit"}, state.getUsers("#chan3"))
}
func TestStateMode(t *testing.T) {
state := newState(NewClient(&Config{}))
state.addUser("+user", "#chan")
state.setMode("#chan", "user", "o", "v")
assert.Equal(t, []string{"@user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "v", "")
assert.Equal(t, []string{"@user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "", "o")
assert.Equal(t, []string{"+user"}, state.getUsers("#chan"))
state.setMode("#chan", "user", "q", "")
assert.Equal(t, []string{"~user"}, state.getUsers("#chan"))
}
func TestStateTopic(t *testing.T) {
state := newState(NewClient(&Config{}))
assert.Equal(t, "", state.getTopic("#chan"))
state.setTopic("the topic", "#chan")
assert.Equal(t, "the topic", state.getTopic("#chan"))
}
func TestStateChannelUserMode(t *testing.T) {
user := NewUser("&test")
assert.Equal(t, "test", user.nick)
assert.Equal(t, "a", string(user.modes[0]))
assert.Equal(t, "&test", user.String())
user.RemoveModes("a")
assert.Equal(t, "test", user.String())
user.AddModes("o")
assert.Equal(t, "@test", user.String())
user.AddModes("q")
assert.Equal(t, "~test", user.String())
user.AddModes("v")
assert.Equal(t, "~test", user.String())
user.RemoveModes("qo")
assert.Equal(t, "+test", user.String())
user.RemoveModes("v")
assert.Equal(t, "test", user.String())
}

View File

@ -63,18 +63,6 @@ func (d *Dispatch) newUser(w http.ResponseWriter, r *http.Request) (*State, erro
return nil, err
}
messageStore, err := d.GetMessageStore(user)
if err != nil {
return nil, err
}
user.SetMessageStore(messageStore)
search, err := d.GetMessageSearchProvider(user)
if err != nil {
return nil, err
}
user.SetMessageSearchProvider(search)
log.Println(r.RemoteAddr, "[Auth] New anonymous user | ID:", user.ID)
session, err := session.New(user.ID)

View File

@ -87,7 +87,9 @@ func (d *Dispatch) getIndexData(r *http.Request, state *State) *indexData {
return nil
}
for i, channel := range channels {
channels[i].Topic = channelStore.GetTopic(channel.Server, channel.Name)
if client, ok := state.getIRC(channel.Server); ok {
channels[i].Topic = client.ChannelTopic(channel.Name)
}
}
data.Channels = channels
@ -106,9 +108,8 @@ func (d *Dispatch) getIndexData(r *http.Request, state *State) *indexData {
}
func (d *indexData) addUsersAndMessages(server, name string, state *State) {
if isChannel(name) {
users := channelStore.GetUsers(server, name)
if len(users) > 0 {
if i, ok := state.getIRC(server); ok && isChannel(name) {
if users := i.ChannelUsers(name); len(users) > 0 {
d.Users = &Userlist{
Server: server,
Channel: name,

View File

@ -27,7 +27,6 @@ type ircHandler struct {
state *State
whois WhoisReply
userBuffers map[string][]string
motdBuffer MOTD
listBuffer storage.ChannelListIndex
dccProgress chan irc.DownloadProgress
@ -39,7 +38,6 @@ func newIRCHandler(client *irc.Client, state *State) *ircHandler {
i := &ircHandler{
client: client,
state: state,
userBuffers: make(map[string][]string),
dccProgress: make(chan irc.DownloadProgress, 4),
}
i.initHandlers()
@ -117,41 +115,47 @@ func (i *ircHandler) nick(msg *irc.Message) {
New: msg.LastParam(),
})
channelStore.RenameUser(msg.Sender, msg.LastParam(), i.client.Host())
if i.client.Is(msg.LastParam()) {
go i.state.user.SetNick(msg.LastParam(), i.client.Host())
}
channels := irc.GetNickChannels(msg)
go i.state.user.LogEvent(i.client.Host(), "nick", []string{msg.Sender, msg.LastParam()}, channels...)
}
func (i *ircHandler) join(msg *irc.Message) {
host := i.client.Host()
i.state.sendJSON("join", Join{
Server: i.client.Host(),
Server: host,
User: msg.Sender,
Channels: msg.Params,
})
channel := msg.Params[0]
channelStore.AddUser(msg.Sender, i.client.Host(), channel)
if i.client.Is(msg.Sender) {
// In case no topic is set and there's a cached one that needs to be cleared
i.client.Topic(channel)
i.state.sendLastMessages(i.client.Host(), channel, 50)
i.state.sendLastMessages(host, channel, 50)
go i.state.user.AddChannel(&storage.Channel{
Server: i.client.Host(),
Server: host,
Name: channel,
})
}
go i.state.user.LogEvent(host, "join", []string{msg.Sender}, channel)
}
func (i *ircHandler) part(msg *irc.Message) {
host := i.client.Host()
channel := msg.Params[0]
part := Part{
Server: i.client.Host(),
Server: host,
User: msg.Sender,
Channel: msg.Params[0],
Channel: channel,
}
if len(msg.Params) == 2 {
@ -160,24 +164,18 @@ func (i *ircHandler) part(msg *irc.Message) {
i.state.sendJSON("part", part)
channelStore.RemoveUser(msg.Sender, i.client.Host(), part.Channel)
if i.client.Is(msg.Sender) {
go i.state.user.RemoveChannel(i.client.Host(), part.Channel)
go i.state.user.RemoveChannel(host, part.Channel)
}
go i.state.user.LogEvent(host, "part", []string{msg.Sender}, channel)
}
func (i *ircHandler) mode(msg *irc.Message) {
target := msg.Params[0]
if len(msg.Params) > 2 && isChannel(target) {
mode := parseMode(msg.Params[1])
mode.Server = i.client.Host()
mode.Channel = target
mode.User = msg.Params[2]
i.state.sendJSON("mode", mode)
channelStore.SetMode(i.client.Host(), target, msg.Params[2], mode.Add, mode.Remove)
if mode := irc.GetMode(msg); mode != nil {
i.state.sendJSON("mode", Mode{
Mode: mode,
})
}
}
@ -215,8 +213,13 @@ func (i *ircHandler) message(msg *irc.Message) {
}
if target != "*" && !msg.IsFromServer() {
go i.state.user.LogMessage(message.ID,
i.client.Host(), msg.Sender, target, msg.LastParam())
go i.state.user.LogMessage(&storage.Message{
ID: message.ID,
Server: message.Server,
From: message.From,
To: target,
Content: message.Content,
})
}
}
@ -227,7 +230,9 @@ func (i *ircHandler) quit(msg *irc.Message) {
Reason: msg.LastParam(),
})
channelStore.RemoveUserAll(msg.Sender, i.client.Host())
channels := irc.GetQuitChannels(msg)
go i.state.user.LogEvent(i.client.Host(), "quit", []string{msg.Sender, msg.LastParam()}, channels...)
}
func (i *ircHandler) info(msg *irc.Message) {
@ -298,6 +303,8 @@ func (i *ircHandler) topic(msg *irc.Message) {
if msg.Command == irc.TOPIC {
channel = msg.Params[0]
nick = msg.Sender
go i.state.user.LogEvent(i.client.Host(), "topic", []string{nick, msg.LastParam()}, channel)
} else {
channel = msg.Params[1]
}
@ -308,39 +315,21 @@ func (i *ircHandler) topic(msg *irc.Message) {
Topic: msg.LastParam(),
Nick: nick,
})
channelStore.SetTopic(msg.LastParam(), i.client.Host(), channel)
}
func (i *ircHandler) noTopic(msg *irc.Message) {
channel := msg.Params[1]
i.state.sendJSON("topic", Topic{
Server: i.client.Host(),
Channel: channel,
Channel: msg.Params[1],
})
channelStore.SetTopic("", i.client.Host(), channel)
}
func (i *ircHandler) names(msg *irc.Message) {
users := strings.Split(strings.TrimSuffix(msg.LastParam(), " "), " ")
userBuffer := i.userBuffers[msg.Params[2]]
i.userBuffers[msg.Params[2]] = append(userBuffer, users...)
}
func (i *ircHandler) namesEnd(msg *irc.Message) {
channel := msg.Params[1]
users := i.userBuffers[channel]
i.state.sendJSON("users", Userlist{
Server: i.client.Host(),
Channel: channel,
Users: users,
Channel: msg.Params[1],
Users: irc.GetNamreplyUsers(msg),
})
channelStore.SetUsers(users, i.client.Host(), channel)
delete(i.userBuffers, channel)
}
func (i *ircHandler) motdStart(msg *irc.Message) {
@ -363,10 +352,10 @@ func (i *ircHandler) list(msg *irc.Message) {
}
if i.listBuffer != nil {
c, _ := strconv.Atoi(msg.Params[2])
userCount, _ := strconv.Atoi(msg.Params[2])
i.listBuffer.Add(&storage.ChannelListItem{
Name: msg.Params[1],
UserCount: c,
UserCount: userCount,
Topic: msg.LastParam(),
})
}
@ -463,7 +452,6 @@ func (i *ircHandler) initHandlers() {
irc.RPL_ENDOFWHOIS: i.whoisEnd,
irc.RPL_NOTOPIC: i.noTopic,
irc.RPL_TOPIC: i.topic,
irc.RPL_NAMREPLY: i.names,
irc.RPL_ENDOFNAMES: i.namesEnd,
irc.RPL_MOTDSTART: i.motdStart,
irc.RPL_MOTD: i.motd,
@ -489,29 +477,14 @@ func (i *ircHandler) sendDCCInfo(message string, log bool, a ...interface{}) {
if log {
i.state.user.AddOpenDM(msg.Server, msg.From)
i.state.user.LogMessage(betterguid.New(), msg.Server, msg.From, msg.From, msg.Content)
i.state.user.LogMessage(&storage.Message{
Server: msg.Server,
From: msg.From,
Content: msg.Content,
})
}
}
func parseMode(mode string) *Mode {
m := Mode{}
add := false
for _, c := range mode {
if c == '+' {
add = true
} else if c == '-' {
add = false
} else if add {
m.Add += string(c)
} else {
m.Remove += string(c)
}
}
return &m
}
func isChannel(s string) bool {
return strings.IndexAny(s, "&#+!") == 0
}

View File

@ -6,12 +6,11 @@ import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/khlieng/dispatch/pkg/irc"
"github.com/khlieng/dispatch/storage"
"github.com/khlieng/dispatch/storage/bleve"
"github.com/khlieng/dispatch/storage/boltdb"
"github.com/stretchr/testify/assert"
)
var user *storage.User
@ -29,19 +28,17 @@ func TestMain(m *testing.M) {
log.Fatal(err)
}
storage.GetMessageStore = func(_ *storage.User) (storage.MessageStore, error) {
return db, nil
}
storage.GetMessageSearchProvider = func(user *storage.User) (storage.MessageSearchProvider, error) {
return bleve.New(storage.Path.Index(user.Username))
}
user, err = storage.NewUser(db)
if err != nil {
log.Fatal(err)
}
user.SetMessageStore(db)
search, err := bleve.New(storage.Path.Index(user.Username))
if err != nil {
log.Fatal(err)
}
user.SetMessageSearchProvider(search)
channelStore = storage.NewChannelStore()
code := m.Run()
@ -126,21 +123,6 @@ func TestHandleIRCPart(t *testing.T) {
}, res)
}
func TestHandleIRCMode(t *testing.T) {
res := dispatchMessage(&irc.Message{
Command: irc.MODE,
Params: []string{"#chan", "+o-v", "nick"},
})
checkResponse(t, "mode", &Mode{
Server: "host.com",
Channel: "#chan",
User: "nick",
Add: "o",
Remove: "v",
}, res)
}
func TestHandleIRCMessage(t *testing.T) {
res := dispatchMessage(&irc.Message{
Command: irc.PRIVMSG,
@ -273,35 +255,6 @@ func TestHandleIRCNoTopic(t *testing.T) {
}, res)
}
func TestHandleIRCNames(t *testing.T) {
c := irc.NewClient(&irc.Config{
Nick: "nick",
Username: "user",
Host: "host.com",
})
s := NewState(nil, nil)
i := newIRCHandler(c, s)
i.dispatchMessage(&irc.Message{
Command: irc.RPL_NAMREPLY,
Params: []string{"", "", "#chan", "a b c"},
})
i.dispatchMessage(&irc.Message{
Command: irc.RPL_NAMREPLY,
Params: []string{"", "", "#chan", "d"},
})
i.dispatchMessage(&irc.Message{
Command: irc.RPL_ENDOFNAMES,
Params: []string{"", "#chan"},
})
checkResponse(t, "users", Userlist{
Server: "host.com",
Channel: "#chan",
Users: []string{"a", "b", "c", "d"},
}, <-s.broadcast)
}
func TestHandleIRCMotd(t *testing.T) {
c := irc.NewClient(&irc.Config{
Nick: "nick",

View File

@ -86,11 +86,7 @@ type Part struct {
}
type Mode struct {
Server string
Channel string
User string
Add string
Remove string
*irc.Mode
}
type Quit struct {

View File

@ -1,9 +1,10 @@
//out.Data: false//v7: false//v24: false// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
//out.Data: false//v7: false//v30: false// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT.
package server
import (
json "encoding/json"
irc "github.com/khlieng/dispatch/pkg/irc"
storage "github.com/khlieng/dispatch/storage"
easyjson "github.com/mailru/easyjson"
jlexer "github.com/mailru/easyjson/jlexer"
@ -1200,6 +1201,29 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchStorage(in *jlexer.Lexer, out
out.Content = string(in.String())
case "time":
out.Time = int64(in.Int64())
case "events":
if in.IsNull() {
in.Skip()
out.Events = nil
} else {
in.Delim('[')
if out.Events == nil {
if !in.IsDelim(']') {
out.Events = make([]storage.Event, 0, 1)
} else {
out.Events = []storage.Event{}
}
} else {
out.Events = (out.Events)[:0]
}
for !in.IsDelim(']') {
var v12 storage.Event
easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in, &v12)
out.Events = append(out.Events, v12)
in.WantComma()
}
in.Delim(']')
}
default:
in.SkipRecursive()
}
@ -1244,6 +1268,122 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchStorage(out *jwriter.Writer,
}
out.Int64(int64(in.Time))
}
if len(in.Events) != 0 {
const prefix string = ",\"events\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
{
out.RawByte('[')
for v13, v14 := range in.Events {
if v13 > 0 {
out.RawByte(',')
}
easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out, v14)
}
out.RawByte(']')
}
}
out.RawByte('}')
}
func easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in *jlexer.Lexer, out *storage.Event) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
in.Consumed()
}
in.Skip()
return
}
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
in.WantColon()
if in.IsNull() {
in.Skip()
in.WantComma()
continue
}
switch key {
case "type":
out.Type = string(in.String())
case "params":
if in.IsNull() {
in.Skip()
out.Params = nil
} else {
in.Delim('[')
if out.Params == nil {
if !in.IsDelim(']') {
out.Params = make([]string, 0, 4)
} else {
out.Params = []string{}
}
} else {
out.Params = (out.Params)[:0]
}
for !in.IsDelim(']') {
var v15 string
v15 = string(in.String())
out.Params = append(out.Params, v15)
in.WantComma()
}
in.Delim(']')
}
case "time":
out.Time = int64(in.Int64())
default:
in.SkipRecursive()
}
in.WantComma()
}
in.Delim('}')
if isTopLevel {
in.Consumed()
}
}
func easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out *jwriter.Writer, in storage.Event) {
out.RawByte('{')
first := true
_ = first
if in.Type != "" {
const prefix string = ",\"type\":"
first = false
out.RawString(prefix[1:])
out.String(string(in.Type))
}
if len(in.Params) != 0 {
const prefix string = ",\"params\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
{
out.RawByte('[')
for v16, v17 := range in.Params {
if v16 > 0 {
out.RawByte(',')
}
out.String(string(v17))
}
out.RawByte(']')
}
}
if in.Time != 0 {
const prefix string = ",\"time\":"
if first {
first = false
out.RawString(prefix[1:])
} else {
out.RawString(prefix)
}
out.Int64(int64(in.Time))
}
out.RawByte('}')
}
func easyjson42239ddeDecodeGithubComKhliengDispatchServer10(in *jlexer.Lexer, out *SearchRequest) {
@ -1627,9 +1767,9 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer14(in *jlexer.Lexer, ou
out.Channels = (out.Channels)[:0]
}
for !in.IsDelim(']') {
var v12 string
v12 = string(in.String())
out.Channels = append(out.Channels, v12)
var v18 string
v18 = string(in.String())
out.Channels = append(out.Channels, v18)
in.WantComma()
}
in.Delim(']')
@ -1686,11 +1826,11 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer14(out *jwriter.Writer,
}
{
out.RawByte('[')
for v13, v14 := range in.Channels {
if v13 > 0 {
for v19, v20 := range in.Channels {
if v19 > 0 {
out.RawByte(',')
}
out.String(string(v14))
out.String(string(v20))
}
out.RawByte(']')
}
@ -1898,6 +2038,7 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer17(in *jlexer.Lexer, ou
in.Skip()
return
}
out.Mode = new(irc.Mode)
in.Delim('{')
for !in.IsDelim('}') {
key := in.UnsafeFieldName(false)
@ -2043,9 +2184,9 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer18(in *jlexer.Lexer, ou
out.Messages = (out.Messages)[:0]
}
for !in.IsDelim(']') {
var v15 storage.Message
easyjson42239ddeDecodeGithubComKhliengDispatchStorage(in, &v15)
out.Messages = append(out.Messages, v15)
var v21 storage.Message
easyjson42239ddeDecodeGithubComKhliengDispatchStorage(in, &v21)
out.Messages = append(out.Messages, v21)
in.WantComma()
}
in.Delim(']')
@ -2094,11 +2235,11 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer18(out *jwriter.Writer,
}
{
out.RawByte('[')
for v16, v17 := range in.Messages {
if v16 > 0 {
for v22, v23 := range in.Messages {
if v22 > 0 {
out.RawByte(',')
}
easyjson42239ddeEncodeGithubComKhliengDispatchStorage(out, v17)
easyjson42239ddeEncodeGithubComKhliengDispatchStorage(out, v23)
}
out.RawByte(']')
}
@ -2315,9 +2456,9 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer20(in *jlexer.Lexer, ou
out.Content = (out.Content)[:0]
}
for !in.IsDelim(']') {
var v18 string
v18 = string(in.String())
out.Content = append(out.Content, v18)
var v24 string
v24 = string(in.String())
out.Content = append(out.Content, v24)
in.WantComma()
}
in.Delim(']')
@ -2362,11 +2503,11 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer20(out *jwriter.Writer,
}
{
out.RawByte('[')
for v19, v20 := range in.Content {
if v19 > 0 {
for v25, v26 := range in.Content {
if v25 > 0 {
out.RawByte(',')
}
out.String(string(v20))
out.String(string(v26))
}
out.RawByte(']')
}
@ -2527,9 +2668,9 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer22(in *jlexer.Lexer, ou
out.Channels = (out.Channels)[:0]
}
for !in.IsDelim(']') {
var v21 string
v21 = string(in.String())
out.Channels = append(out.Channels, v21)
var v27 string
v27 = string(in.String())
out.Channels = append(out.Channels, v27)
in.WantComma()
}
in.Delim(']')
@ -2574,11 +2715,11 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer22(out *jwriter.Writer,
}
{
out.RawByte('[')
for v22, v23 := range in.Channels {
if v22 > 0 {
for v28, v29 := range in.Channels {
if v28 > 0 {
out.RawByte(',')
}
out.String(string(v23))
out.String(string(v29))
}
out.RawByte(']')
}
@ -2916,15 +3057,15 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer26(in *jlexer.Lexer, ou
for !in.IsDelim('}') {
key := string(in.String())
in.WantColon()
var v24 interface{}
if m, ok := v24.(easyjson.Unmarshaler); ok {
var v30 interface{}
if m, ok := v30.(easyjson.Unmarshaler); ok {
m.UnmarshalEasyJSON(in)
} else if m, ok := v24.(json.Unmarshaler); ok {
} else if m, ok := v30.(json.Unmarshaler); ok {
_ = m.UnmarshalJSON(in.Raw())
} else {
v24 = in.Interface()
v30 = in.Interface()
}
(out.Features)[key] = v24
(out.Features)[key] = v30
in.WantComma()
}
in.Delim('}')
@ -2959,21 +3100,21 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer26(out *jwriter.Writer,
}
{
out.RawByte('{')
v25First := true
for v25Name, v25Value := range in.Features {
if v25First {
v25First = false
v31First := true
for v31Name, v31Value := range in.Features {
if v31First {
v31First = false
} else {
out.RawByte(',')
}
out.String(string(v25Name))
out.String(string(v31Name))
out.RawByte(':')
if m, ok := v25Value.(easyjson.Marshaler); ok {
if m, ok := v31Value.(easyjson.Marshaler); ok {
m.MarshalEasyJSON(out)
} else if m, ok := v25Value.(json.Marshaler); ok {
} else if m, ok := v31Value.(json.Marshaler); ok {
out.Raw(m.MarshalJSON())
} else {
out.Raw(json.Marshal(v25Value))
out.Raw(json.Marshal(v31Value))
}
}
out.RawByte('}')
@ -3404,17 +3545,17 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer31(in *jlexer.Lexer, ou
out.Results = (out.Results)[:0]
}
for !in.IsDelim(']') {
var v26 *storage.ChannelListItem
var v32 *storage.ChannelListItem
if in.IsNull() {
in.Skip()
v26 = nil
v32 = nil
} else {
if v26 == nil {
v26 = new(storage.ChannelListItem)
if v32 == nil {
v32 = new(storage.ChannelListItem)
}
easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in, v26)
easyjson42239ddeDecodeGithubComKhliengDispatchStorage2(in, v32)
}
out.Results = append(out.Results, v26)
out.Results = append(out.Results, v32)
in.WantComma()
}
in.Delim(']')
@ -3445,14 +3586,14 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer31(out *jwriter.Writer,
out.RawString(prefix[1:])
{
out.RawByte('[')
for v27, v28 := range in.Results {
if v27 > 0 {
for v33, v34 := range in.Results {
if v33 > 0 {
out.RawByte(',')
}
if v28 == nil {
if v34 == nil {
out.RawString("null")
} else {
easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out, *v28)
easyjson42239ddeEncodeGithubComKhliengDispatchStorage2(out, *v34)
}
}
out.RawByte(']')
@ -3514,7 +3655,7 @@ func (v *ChannelSearchResult) UnmarshalJSON(data []byte) error {
func (v *ChannelSearchResult) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjson42239ddeDecodeGithubComKhliengDispatchServer31(l, v)
}
func easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in *jlexer.Lexer, out *storage.ChannelListItem) {
func easyjson42239ddeDecodeGithubComKhliengDispatchStorage2(in *jlexer.Lexer, out *storage.ChannelListItem) {
isTopLevel := in.IsStart()
if in.IsNull() {
if isTopLevel {
@ -3549,7 +3690,7 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in *jlexer.Lexer, ou
in.Consumed()
}
}
func easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out *jwriter.Writer, in storage.ChannelListItem) {
func easyjson42239ddeEncodeGithubComKhliengDispatchStorage2(out *jwriter.Writer, in storage.ChannelListItem) {
out.RawByte('{')
first := true
_ = first

View File

@ -16,16 +16,12 @@ import (
"github.com/khlieng/dispatch/storage"
)
var channelStore = storage.NewChannelStore()
var channelIndexes = storage.NewChannelIndexManager()
type Dispatch struct {
Store storage.Store
SessionStore storage.SessionStore
GetMessageStore func(*storage.User) (storage.MessageStore, error)
GetMessageSearchProvider func(*storage.User) (storage.MessageSearchProvider, error)
cfg *config.Config
upgrader websocket.Upgrader
states *stateStore
@ -87,18 +83,6 @@ func (d *Dispatch) loadUsers() {
}
func (d *Dispatch) loadUser(user *storage.User) {
messageStore, err := d.GetMessageStore(user)
if err != nil {
log.Fatal(err)
}
user.SetMessageStore(messageStore)
search, err := d.GetMessageSearchProvider(user)
if err != nil {
log.Fatal(err)
}
user.SetMessageSearchProvider(search)
state := NewState(user, d)
d.states.set(state)
go state.run()

View File

@ -7,7 +7,7 @@ import (
"strings"
"github.com/gorilla/websocket"
"github.com/kjk/betterguid"
"github.com/khlieng/dispatch/storage"
)
type wsHandler struct {
@ -87,11 +87,13 @@ func (h *wsHandler) init(r *http.Request) {
continue
}
if i, ok := h.state.getIRC(channel.Server); ok {
h.state.sendJSON("users", Userlist{
Server: channel.Server,
Channel: channel.Name,
Users: channelStore.GetUsers(channel.Server, channel.Name),
Users: i.ChannelUsers(channel.Name),
})
}
h.state.sendLastMessages(channel.Server, channel.Name, 50)
}
@ -177,8 +179,12 @@ func (h *wsHandler) message(b []byte) {
if i, ok := h.state.getIRC(data.Server); ok {
i.Privmsg(data.To, data.Content)
go h.state.user.LogMessage(betterguid.New(),
data.Server, i.GetNick(), data.To, data.Content)
go h.state.user.LogMessage(&storage.Message{
Server: data.Server,
From: i.GetNick(),
To: data.To,
Content: data.Content,
})
}
}

View File

@ -278,8 +278,7 @@ func (s *BoltStore) RemoveOpenDM(user *storage.User, server, nick string) error
})
}
func (s *BoltStore) LogMessage(message *storage.Message) error {
return s.db.Batch(func(tx *bolt.Tx) error {
func (s *BoltStore) logMessage(tx *bolt.Tx, message *storage.Message) error {
b, err := tx.Bucket(bucketMessages).CreateBucketIfNotExists([]byte(message.Server + ":" + message.To))
if err != nil {
return err
@ -291,6 +290,24 @@ func (s *BoltStore) LogMessage(message *storage.Message) error {
}
return b.Put([]byte(message.ID), data)
}
func (s *BoltStore) LogMessage(message *storage.Message) error {
return s.db.Batch(func(tx *bolt.Tx) error {
return s.logMessage(tx, message)
})
}
func (s *BoltStore) LogMessages(messages []*storage.Message) error {
return s.db.Batch(func(tx *bolt.Tx) error {
for _, message := range messages {
err := s.logMessage(tx, message)
if err != nil {
return err
}
}
return nil
})
}

View File

@ -1,206 +0,0 @@
package storage
import (
"strings"
"sync"
)
type ChannelStore struct {
users map[string]map[string][]*ChannelStoreUser
userLock sync.Mutex
topic map[string]map[string]string
topicLock sync.Mutex
}
const userModePrefixes = "~&@%+"
const userModeChars = "qaohv"
type ChannelStoreUser struct {
nick string
modes string
prefix string
}
func NewChannelStoreUser(nick string) *ChannelStoreUser {
user := &ChannelStoreUser{nick: nick}
if i := strings.IndexAny(nick, userModePrefixes); i == 0 {
i = strings.Index(userModePrefixes, string(nick[0]))
user.modes = string(userModeChars[i])
user.nick = nick[1:]
user.updatePrefix()
}
return user
}
func (c *ChannelStoreUser) String() string {
return c.prefix + c.nick
}
func (c *ChannelStoreUser) addModes(modes string) {
for _, mode := range modes {
if strings.Contains(c.modes, string(mode)) {
continue
}
c.modes += string(mode)
}
c.updatePrefix()
}
func (c *ChannelStoreUser) removeModes(modes string) {
for _, mode := range modes {
c.modes = strings.Replace(c.modes, string(mode), "", 1)
}
c.updatePrefix()
}
func (c *ChannelStoreUser) updatePrefix() {
for i, mode := range userModeChars {
if strings.Contains(c.modes, string(mode)) {
c.prefix = string(userModePrefixes[i])
return
}
}
c.prefix = ""
}
func NewChannelStore() *ChannelStore {
return &ChannelStore{
users: make(map[string]map[string][]*ChannelStoreUser),
topic: make(map[string]map[string]string),
}
}
func (c *ChannelStore) GetUsers(server, channel string) []string {
c.userLock.Lock()
users := make([]string, len(c.users[server][channel]))
for i, user := range c.users[server][channel] {
users[i] = user.String()
}
c.userLock.Unlock()
return users
}
func (c *ChannelStore) SetUsers(users []string, server, channel string) {
c.userLock.Lock()
if _, ok := c.users[server]; !ok {
c.users[server] = make(map[string][]*ChannelStoreUser)
}
c.users[server][channel] = make([]*ChannelStoreUser, len(users))
for i, nick := range users {
c.users[server][channel][i] = NewChannelStoreUser(nick)
}
c.userLock.Unlock()
}
func (c *ChannelStore) AddUser(user, server, channel string) {
c.userLock.Lock()
if _, ok := c.users[server]; !ok {
c.users[server] = make(map[string][]*ChannelStoreUser)
}
if users, ok := c.users[server][channel]; ok {
for _, u := range users {
if u.nick == user {
c.userLock.Unlock()
return
}
}
c.users[server][channel] = append(users, NewChannelStoreUser(user))
} else {
c.users[server][channel] = []*ChannelStoreUser{NewChannelStoreUser(user)}
}
c.userLock.Unlock()
}
func (c *ChannelStore) RemoveUser(user, server, channel string) {
c.userLock.Lock()
c.removeUser(user, server, channel)
c.userLock.Unlock()
}
func (c *ChannelStore) RemoveUserAll(user, server string) {
c.userLock.Lock()
for channel := range c.users[server] {
c.removeUser(user, server, channel)
}
c.userLock.Unlock()
}
func (c *ChannelStore) RenameUser(oldNick, newNick, server string) {
c.userLock.Lock()
c.renameAll(server, oldNick, newNick)
c.userLock.Unlock()
}
func (c *ChannelStore) SetMode(server, channel, user, add, remove string) {
c.userLock.Lock()
for _, u := range c.users[server][channel] {
if u.nick == user {
u.addModes(add)
u.removeModes(remove)
c.userLock.Unlock()
return
}
}
c.userLock.Unlock()
}
func (c *ChannelStore) GetTopic(server, channel string) string {
c.topicLock.Lock()
topic := c.topic[server][channel]
c.topicLock.Unlock()
return topic
}
func (c *ChannelStore) SetTopic(topic, server, channel string) {
c.topicLock.Lock()
if _, ok := c.topic[server]; !ok {
c.topic[server] = make(map[string]string)
}
c.topic[server][channel] = topic
c.topicLock.Unlock()
}
func (c *ChannelStore) rename(server, channel, oldNick, newNick string) {
for _, user := range c.users[server][channel] {
if user.nick == oldNick {
user.nick = newNick
return
}
}
}
func (c *ChannelStore) renameAll(server, oldNick, newNick string) {
for channel := range c.users[server] {
c.rename(server, channel, oldNick, newNick)
}
}
func (c *ChannelStore) removeUser(user, server, channel string) {
for i, u := range c.users[server][channel] {
if u.nick == user {
users := c.users[server][channel]
c.users[server][channel] = append(users[:i], users[i+1:]...)
return
}
}
}

View File

@ -1,89 +0,0 @@
package storage
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSetUsers(t *testing.T) {
channelStore := NewChannelStore()
users := []string{"a", "b"}
channelStore.SetUsers(users, "srv", "#chan")
assert.Equal(t, users, channelStore.GetUsers("srv", "#chan"))
channelStore.SetUsers(users, "srv", "#chan")
assert.Equal(t, users, channelStore.GetUsers("srv", "#chan"))
}
func TestAddRemoveUser(t *testing.T) {
channelStore := NewChannelStore()
channelStore.AddUser("user", "srv", "#chan")
channelStore.AddUser("user", "srv", "#chan")
assert.Len(t, channelStore.GetUsers("srv", "#chan"), 1)
channelStore.AddUser("user2", "srv", "#chan")
assert.Equal(t, []string{"user", "user2"}, channelStore.GetUsers("srv", "#chan"))
channelStore.RemoveUser("user", "srv", "#chan")
assert.Equal(t, []string{"user2"}, channelStore.GetUsers("srv", "#chan"))
}
func TestRemoveUserAll(t *testing.T) {
channelStore := NewChannelStore()
channelStore.AddUser("user", "srv", "#chan1")
channelStore.AddUser("user", "srv", "#chan2")
channelStore.RemoveUserAll("user", "srv")
assert.Empty(t, channelStore.GetUsers("srv", "#chan1"))
assert.Empty(t, channelStore.GetUsers("srv", "#chan2"))
}
func TestRenameUser(t *testing.T) {
channelStore := NewChannelStore()
channelStore.AddUser("user", "srv", "#chan1")
channelStore.AddUser("user", "srv", "#chan2")
channelStore.RenameUser("user", "new", "srv")
assert.Equal(t, []string{"new"}, channelStore.GetUsers("srv", "#chan1"))
assert.Equal(t, []string{"new"}, channelStore.GetUsers("srv", "#chan2"))
channelStore.AddUser("@gotop", "srv", "#chan3")
channelStore.RenameUser("gotop", "stillgotit", "srv")
assert.Equal(t, []string{"@stillgotit"}, channelStore.GetUsers("srv", "#chan3"))
}
func TestMode(t *testing.T) {
channelStore := NewChannelStore()
channelStore.AddUser("+user", "srv", "#chan")
channelStore.SetMode("srv", "#chan", "user", "o", "v")
assert.Equal(t, []string{"@user"}, channelStore.GetUsers("srv", "#chan"))
channelStore.SetMode("srv", "#chan", "user", "v", "")
assert.Equal(t, []string{"@user"}, channelStore.GetUsers("srv", "#chan"))
channelStore.SetMode("srv", "#chan", "user", "", "o")
assert.Equal(t, []string{"+user"}, channelStore.GetUsers("srv", "#chan"))
channelStore.SetMode("srv", "#chan", "user", "q", "")
assert.Equal(t, []string{"~user"}, channelStore.GetUsers("srv", "#chan"))
}
func TestTopic(t *testing.T) {
channelStore := NewChannelStore()
assert.Equal(t, "", channelStore.GetTopic("srv", "#chan"))
channelStore.SetTopic("the topic", "srv", "#chan")
assert.Equal(t, "the topic", channelStore.GetTopic("srv", "#chan"))
}
func TestChannelUserMode(t *testing.T) {
user := NewChannelStoreUser("&test")
assert.Equal(t, "test", user.nick)
assert.Equal(t, "a", string(user.modes[0]))
assert.Equal(t, "&test", user.String())
user.removeModes("a")
assert.Equal(t, "test", user.String())
user.addModes("o")
assert.Equal(t, "@test", user.String())
user.addModes("q")
assert.Equal(t, "~test", user.String())
user.addModes("v")
assert.Equal(t, "~test", user.String())
user.removeModes("qo")
assert.Equal(t, "+test", user.String())
user.removeModes("v")
assert.Equal(t, "test", user.String())
}

View File

@ -7,7 +7,12 @@ import (
"github.com/khlieng/dispatch/pkg/session"
)
var Path directory
var (
Path directory
GetMessageStore MessageStoreCreator
GetMessageSearchProvider MessageSearchProviderCreator
)
func Initialize(root, dataRoot, configRoot string) {
if root != DefaultDirectory() {
@ -52,13 +57,18 @@ type SessionStore interface {
type MessageStore interface {
LogMessage(message *Message) error
LogMessages(messages []*Message) error
GetMessages(server, channel string, count int, fromID string) ([]Message, bool, error)
GetMessagesByID(server, channel string, ids []string) ([]Message, error)
Close()
}
type MessageStoreCreator func(*User) (MessageStore, error)
type MessageSearchProvider interface {
SearchMessages(server, channel, q string) ([]string, error)
Index(id string, message *Message) error
Close()
}
type MessageSearchProviderCreator func(*User) (MessageSearchProvider, error)

View File

@ -25,7 +25,6 @@ struct Server {
struct Channel {
Server string
Name string
Topic string
}
struct Message {
@ -33,4 +32,11 @@ struct Message {
From string
Content string
Time int64
Events []Event
}
struct Event {
Type string
Params []string
Time int64
}

View File

@ -791,21 +791,6 @@ func (d *Channel) Size() (s uint64) {
}
s += l
}
{
l := uint64(len(d.Topic))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
s += l
}
return
}
func (d *Channel) Marshal(buf []byte) ([]byte, error) {
@ -857,25 +842,6 @@ func (d *Channel) Marshal(buf []byte) ([]byte, error) {
copy(buf[i+0:], d.Name)
i += l
}
{
l := uint64(len(d.Topic))
{
t := uint64(l)
for t >= 0x80 {
buf[i+0] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+0] = byte(t)
i++
}
copy(buf[i+0:], d.Topic)
i += l
}
return buf[:i+0], nil
}
@ -922,26 +888,6 @@ func (d *Channel) Unmarshal(buf []byte) (uint64, error) {
d.Name = string(buf[i+0 : i+0+l])
i += l
}
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+0] & 0x7F)
for buf[i+0]&0x80 == 0x80 {
i++
t |= uint64(buf[i+0]&0x7F) << bs
bs += 7
}
i++
l = t
}
d.Topic = string(buf[i+0 : i+0+l])
i += l
}
return i + 0, nil
}
@ -992,6 +938,29 @@ func (d *Message) Size() (s uint64) {
}
s += l
}
{
l := uint64(len(d.Events))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
for k0 := range d.Events {
{
s += d.Events[k0].Size()
}
}
}
s += 8
return
}
@ -1068,6 +1037,34 @@ func (d *Message) Marshal(buf []byte) ([]byte, error) {
*(*int64)(unsafe.Pointer(&buf[i+0])) = d.Time
}
{
l := uint64(len(d.Events))
{
t := uint64(l)
for t >= 0x80 {
buf[i+8] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+8] = byte(t)
i++
}
for k0 := range d.Events {
{
nbuf, err := d.Events[k0].Marshal(buf[i+8:])
if err != nil {
return nil, err
}
i += uint64(len(nbuf))
}
}
}
return buf[:i+8], nil
}
@ -1138,6 +1135,251 @@ func (d *Message) Unmarshal(buf []byte) (uint64, error) {
d.Time = *(*int64)(unsafe.Pointer(&buf[i+0]))
}
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+8] & 0x7F)
for buf[i+8]&0x80 == 0x80 {
i++
t |= uint64(buf[i+8]&0x7F) << bs
bs += 7
}
i++
l = t
}
if uint64(cap(d.Events)) >= l {
d.Events = d.Events[:l]
} else {
d.Events = make([]Event, l)
}
for k0 := range d.Events {
{
ni, err := d.Events[k0].Unmarshal(buf[i+8:])
if err != nil {
return 0, err
}
i += ni
}
}
}
return i + 8, nil
}
func (d *Event) Size() (s uint64) {
{
l := uint64(len(d.Type))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
s += l
}
{
l := uint64(len(d.Params))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
for k0 := range d.Params {
{
l := uint64(len(d.Params[k0]))
{
t := l
for t >= 0x80 {
t >>= 7
s++
}
s++
}
s += l
}
}
}
s += 8
return
}
func (d *Event) Marshal(buf []byte) ([]byte, error) {
size := d.Size()
{
if uint64(cap(buf)) >= size {
buf = buf[:size]
} else {
buf = make([]byte, size)
}
}
i := uint64(0)
{
l := uint64(len(d.Type))
{
t := uint64(l)
for t >= 0x80 {
buf[i+0] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+0] = byte(t)
i++
}
copy(buf[i+0:], d.Type)
i += l
}
{
l := uint64(len(d.Params))
{
t := uint64(l)
for t >= 0x80 {
buf[i+0] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+0] = byte(t)
i++
}
for k0 := range d.Params {
{
l := uint64(len(d.Params[k0]))
{
t := uint64(l)
for t >= 0x80 {
buf[i+0] = byte(t) | 0x80
t >>= 7
i++
}
buf[i+0] = byte(t)
i++
}
copy(buf[i+0:], d.Params[k0])
i += l
}
}
}
{
*(*int64)(unsafe.Pointer(&buf[i+0])) = d.Time
}
return buf[:i+8], nil
}
func (d *Event) Unmarshal(buf []byte) (uint64, error) {
i := uint64(0)
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+0] & 0x7F)
for buf[i+0]&0x80 == 0x80 {
i++
t |= uint64(buf[i+0]&0x7F) << bs
bs += 7
}
i++
l = t
}
d.Type = string(buf[i+0 : i+0+l])
i += l
}
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+0] & 0x7F)
for buf[i+0]&0x80 == 0x80 {
i++
t |= uint64(buf[i+0]&0x7F) << bs
bs += 7
}
i++
l = t
}
if uint64(cap(d.Params)) >= l {
d.Params = d.Params[:l]
} else {
d.Params = make([]string, l)
}
for k0 := range d.Params {
{
l := uint64(0)
{
bs := uint8(7)
t := uint64(buf[i+0] & 0x7F)
for buf[i+0]&0x80 == 0x80 {
i++
t |= uint64(buf[i+0]&0x7F) << bs
bs += 7
}
i++
l = t
}
d.Params[k0] = string(buf[i+0 : i+0+l])
i += l
}
}
}
{
d.Time = *(*int64)(unsafe.Pointer(&buf[i+0]))
}
return i + 8, nil
}

View File

@ -5,6 +5,8 @@ import (
"os"
"sync"
"time"
"github.com/kjk/betterguid"
)
type User struct {
@ -15,6 +17,7 @@ type User struct {
store Store
messageLog MessageStore
messageIndex MessageSearchProvider
lastMessages map[string]map[string]*Message
clientSettings *ClientSettings
lastIP []byte
certificate *tls.Certificate
@ -25,6 +28,7 @@ func NewUser(store Store) (*User, error) {
user := &User{
store: store,
clientSettings: DefaultClientSettings(),
lastMessages: map[string]map[string]*Message{},
}
err := store.SaveUser(user)
@ -32,11 +36,19 @@ func NewUser(store Store) (*User, error) {
return nil, err
}
user.messageLog, err = GetMessageStore(user)
if err != nil {
return nil, err
}
user.messageIndex, err = GetMessageSearchProvider(user)
if err != nil {
return nil, err
}
err = os.MkdirAll(Path.User(user.Username), 0700)
if err != nil {
return nil, err
}
err = os.Mkdir(Path.Downloads(user.Username), 0700)
if err != nil {
return nil, err
@ -53,20 +65,35 @@ func LoadUsers(store Store) ([]*User, error) {
for _, user := range users {
user.store = store
user.messageLog, err = GetMessageStore(user)
if err != nil {
return nil, err
}
user.messageIndex, err = GetMessageSearchProvider(user)
if err != nil {
return nil, err
}
user.lastMessages = map[string]map[string]*Message{}
user.loadCertificate()
channels, err := user.GetChannels()
if err != nil {
return nil, err
}
for _, channel := range channels {
messages, _, err := user.GetLastMessages(channel.Server, channel.Name, 1)
if err == nil && len(messages) == 1 {
user.lastMessages[channel.Server] = map[string]*Message{
channel.Name: &messages[0],
}
}
}
}
return users, nil
}
func (u *User) SetMessageStore(store MessageStore) {
u.messageLog = store
}
func (u *User) SetMessageSearchProvider(search MessageSearchProvider) {
u.messageIndex = search
}
func (u *User) Remove() {
u.store.DeleteUser(u)
if u.messageLog != nil {
@ -178,7 +205,6 @@ func (u *User) SetServerName(name, address string) error {
return u.AddServer(server)
}
// TODO: Remove topic from disk schema
type Channel struct {
Server string
Name string
@ -221,27 +247,122 @@ type Message struct {
To string `json:"-" bleve:"to"`
Content string `bleve:"content"`
Time int64 `bleve:"-"`
Events []Event `bleve:"-"`
}
func (m Message) Type() string {
return "message"
}
func (u *User) LogMessage(id, server, from, to, content string) error {
message := &Message{
ID: id,
Server: server,
From: from,
To: to,
Content: content,
Time: time.Now().Unix(),
func (u *User) LogMessage(msg *Message) error {
if msg.Time == 0 {
msg.Time = time.Now().Unix()
}
err := u.messageLog.LogMessage(message)
if msg.ID == "" {
msg.ID = betterguid.New()
}
if msg.To == "" {
msg.To = msg.From
}
u.setLastMessage(msg.Server, msg.To, msg)
err := u.messageLog.LogMessage(msg)
if err != nil {
return err
}
return u.messageIndex.Index(id, message)
return u.messageIndex.Index(msg.ID, msg)
}
type Event struct {
Type string
Params []string
Time int64
}
func (u *User) LogEvent(server, name string, params []string, channels ...string) error {
now := time.Now().Unix()
event := Event{
Type: name,
Params: params,
Time: now,
}
for _, channel := range channels {
lastMessage := u.getLastMessage(server, channel)
if lastMessage != nil && shouldCollapse(lastMessage, event) {
lastMessage.Events = append(lastMessage.Events, event)
u.setLastMessage(server, channel, lastMessage)
err := u.messageLog.LogMessage(lastMessage)
if err != nil {
return err
}
} else {
msg := &Message{
ID: betterguid.New(),
Server: server,
To: channel,
Time: now,
Events: []Event{event},
}
u.setLastMessage(server, channel, msg)
err := u.messageLog.LogMessage(msg)
if err != nil {
return err
}
}
}
return nil
}
var collapsed = []string{"join", "part", "quit"}
func shouldCollapse(msg *Message, event Event) bool {
matches := 0
if len(msg.Events) > 0 {
for _, collapseType := range collapsed {
if msg.Events[0].Type == collapseType {
matches++
}
if event.Type == collapseType {
matches++
}
}
}
return matches == 2
}
func (u *User) getLastMessage(server, channel string) *Message {
u.lock.Lock()
defer u.lock.Unlock()
if _, ok := u.lastMessages[server]; !ok {
return nil
}
last := u.lastMessages[server][channel]
if last != nil {
msg := *last
return &msg
}
return nil
}
func (u *User) setLastMessage(server, channel string, msg *Message) {
u.lock.Lock()
if _, ok := u.lastMessages[server]; !ok {
u.lastMessages[server] = map[string]*Message{}
}
u.lastMessages[server][channel] = msg
u.lock.Unlock()
}
func (u *User) GetMessages(server, channel string, count int, fromID string) ([]Message, bool, error) {

View File

@ -24,6 +24,13 @@ func TestUser(t *testing.T) {
db, err := boltdb.New(storage.Path.Database())
assert.Nil(t, err)
storage.GetMessageStore = func(_ *storage.User) (storage.MessageStore, error) {
return db, nil
}
storage.GetMessageSearchProvider = func(_ *storage.User) (storage.MessageSearchProvider, error) {
return nil, nil
}
user, err := storage.NewUser(db)
assert.Nil(t, err)
@ -124,17 +131,18 @@ func TestMessages(t *testing.T) {
db, err := boltdb.New(storage.Path.Database())
assert.Nil(t, err)
storage.GetMessageStore = func(_ *storage.User) (storage.MessageStore, error) {
return db, nil
}
storage.GetMessageSearchProvider = func(user *storage.User) (storage.MessageSearchProvider, error) {
return bleve.New(storage.Path.Index(user.Username))
}
user, err := storage.NewUser(db)
assert.Nil(t, err)
os.MkdirAll(storage.Path.User(user.Username), 0700)
search, err := bleve.New(storage.Path.Index(user.Username))
assert.Nil(t, err)
user.SetMessageStore(db)
user.SetMessageSearchProvider(search)
messages, hasMore, err := user.GetMessages("irc.freenode.net", "#go-nuts", 10, "6")
assert.Nil(t, err)
assert.False(t, hasMore)
@ -153,7 +161,13 @@ func TestMessages(t *testing.T) {
for i := 0; i < 5; i++ {
id := betterguid.New()
ids = append(ids, id)
err = user.LogMessage(id, "irc.freenode.net", "nick", "#go-nuts", "message"+strconv.Itoa(i))
err = user.LogMessage(&storage.Message{
ID: id,
Server: "irc.freenode.net",
From: "nick",
To: "#go-nuts",
Content: "message" + strconv.Itoa(i),
})
assert.Nil(t, err)
}
@ -196,5 +210,42 @@ func TestMessages(t *testing.T) {
assert.Nil(t, err)
assert.True(t, len(messages) > 0)
user.LogEvent("irc.freenode.net", "join", []string{"bob"}, "#go-nuts")
messages, hasMore, err = user.GetLastMessages("irc.freenode.net", "#go-nuts", 1)
assert.Zero(t, messages[0].Content)
assert.Nil(t, err)
assert.True(t, hasMore)
assert.Len(t, messages[0].Events, 1)
assert.Equal(t, "join", messages[0].Events[0].Type)
assert.NotZero(t, messages[0].Events[0].Time)
user.LogEvent("irc.freenode.net", "part", []string{"bob"}, "#go-nuts")
messages, hasMore, err = user.GetLastMessages("irc.freenode.net", "#go-nuts", 1)
assert.Zero(t, messages[0].Content)
assert.Nil(t, err)
assert.True(t, hasMore)
assert.Len(t, messages[0].Events, 2)
assert.Equal(t, "part", messages[0].Events[1].Type)
assert.NotZero(t, messages[0].Events[0].Time)
user.LogEvent("irc.freenode.net", "nick", []string{"bob", "rob"}, "#go-nuts")
messages, hasMore, err = user.GetLastMessages("irc.freenode.net", "#go-nuts", 1)
assert.Zero(t, messages[0].Content)
assert.Nil(t, err)
assert.True(t, hasMore)
assert.Len(t, messages[0].Events, 1)
assert.Equal(t, "nick", messages[0].Events[0].Type)
assert.NotZero(t, messages[0].Events[0].Time)
user.LogEvent("irc.freenode.net", "quit", []string{"rob", "bored"}, "#go-nuts")
messages, hasMore, err = user.GetLastMessages("irc.freenode.net", "#go-nuts", 1)
assert.Zero(t, messages[0].Content)
assert.Nil(t, err)
assert.True(t, hasMore)
assert.Len(t, messages[0].Events, 1)
assert.Equal(t, "quit", messages[0].Events[0].Type)
assert.Equal(t, []string{"rob", "bored"}, messages[0].Events[0].Params)
assert.NotZero(t, messages[0].Events[0].Time)
db.Close()
}