commit 508a04cf4c47ee4a2c2d437545b1071353af9ea7 Author: khlieng Date: Sat Jan 17 02:37:21 2015 +0100 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..41772525 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +client/dist/ +client/node_modules/ \ No newline at end of file diff --git a/client/gulpfile.js b/client/gulpfile.js new file mode 100644 index 00000000..c4dde109 --- /dev/null +++ b/client/gulpfile.js @@ -0,0 +1,78 @@ +var gulp = require('gulp'); +var gulpif = require('gulp-if'); +var minifyHTML = require('gulp-minify-html'); +var minifyCSS = require('gulp-minify-css'); +var autoprefixer = require('gulp-autoprefixer'); +var uglify = require('gulp-uglify'); +var browserify = require('browserify'); +var source = require('vinyl-source-stream'); +var streamify = require('gulp-streamify'); +var reactify = require('reactify'); +var strictify = require('strictify'); +var watchify = require('watchify'); + +var argv = require('yargs') + .alias('p', 'production') + .argv; + +if (argv.production) { + process.env['NODE_ENV'] = 'production'; +} + +gulp.task('html', function() { + gulp.src('./src/*.html') + .pipe(minifyHTML()) + .pipe(gulp.dest('./dist')); +}); + +gulp.task('css', function() { + gulp.src('./src/*.css') + .pipe(autoprefixer()) + .pipe(minifyCSS()) + .pipe(gulp.dest('./dist')); +}); + +gulp.task('js', function() { + return js(false); +}); + +function js(watch) { + var bundler, rebundle; + bundler = browserify('./src/js/app.js', { + debug: !argv.production, + cache: {}, + packageCache: {}, + fullPaths: watch + }); + + if (watch) { + bundler = watchify(bundler); + } + + bundler + .transform(reactify) + .transform(strictify); + + rebundle = function() { + var stream = bundler.bundle(); + stream.on('error', console.log); + return stream + .pipe(source('bundle.js')) + .pipe(gulpif(argv.production, streamify(uglify()))) + .pipe(gulp.dest('./dist')); + }; + + bundler.on('time', function(time) { + console.log('JS bundle: ' + time + ' ms'); + }); + bundler.on('update', rebundle); + return rebundle(); +} + +gulp.task('watch', ['default'], function() { + gulp.watch('./src/*.html', ['html']); + gulp.watch('./src/*.css', ['css']); + return js(true); +}); + +gulp.task('default', ['html', 'css', 'js']); \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..ddcc0988 --- /dev/null +++ b/client/package.json @@ -0,0 +1,27 @@ +{ + "name": "irc", + "version": "0.0.0", + "description": "", + "main": "index.js", + "devDependencies": { + "yargs": "~1.3.3", + "strictify": "~0.2.0", + "vinyl-source-stream": "~1.0.0", + "gulp-if": "~1.2.5", + "gulp": "~3.8.10", + "gulp-uglify": "~1.0.2", + "gulp-minify-css": "~0.3.11", + "gulp-streamify": "0.0.5", + "gulp-minify-html": "~0.1.8", + "watchify": "~2.2.1", + "browserify": "~8.0.3", + "gulp-autoprefixer": "~2.0.0", + "reactify": "~0.17.1" + }, + "dependencies": { + "lodash": "~2.4.1", + "reflux": "~0.2.2", + "react-router": "~0.11.6", + "react": "~0.12.2" + } +} diff --git a/client/src/index.html b/client/src/index.html new file mode 100644 index 00000000..48458aed --- /dev/null +++ b/client/src/index.html @@ -0,0 +1,11 @@ + + + + IRC + + + + + + + \ No newline at end of file diff --git a/client/src/js/actions/channel.js b/client/src/js/actions/channel.js new file mode 100644 index 00000000..be6bb2d6 --- /dev/null +++ b/client/src/js/actions/channel.js @@ -0,0 +1,37 @@ +var Reflux = require('reflux'); +var sock = require('../socket.js')('/ws'); + +var channelActions = Reflux.createActions([ + 'join', + 'joined', + 'part', + 'parted', + 'setUsers', + 'load' +]); + +channelActions.join.preEmit = function(data) { + sock.send('join', data); +}; + +channelActions.part.preEmit = function(data) { + sock.send('part', data); +}; + +sock.on('join', function(data) { + channelActions.joined(data.user, data.server, data.channels[0]); +}); + +sock.on('part', function(data) { + channelActions.parted(data.user, data.server, data.channels[0]); +}); + +sock.on('users', function(data) { + channelActions.setUsers(data.users, data.server, data.channel); +}); + +sock.on('channels', function(data) { + channelActions.load(data); +}); + +module.exports = channelActions; \ No newline at end of file diff --git a/client/src/js/actions/message.js b/client/src/js/actions/message.js new file mode 100644 index 00000000..ac2c2606 --- /dev/null +++ b/client/src/js/actions/message.js @@ -0,0 +1,13 @@ +var Reflux = require('reflux'); + +var messageActions = Reflux.createActions([ + 'send', + 'add', + 'selectTab' +]); + +messageActions.send.preEmit = function() { + +}; + +module.exports = messageActions; \ No newline at end of file diff --git a/client/src/js/actions/server.js b/client/src/js/actions/server.js new file mode 100644 index 00000000..51a7d569 --- /dev/null +++ b/client/src/js/actions/server.js @@ -0,0 +1,13 @@ +var Reflux = require('reflux'); +var sock = require('../socket.js')('/ws'); + +var serverActions = Reflux.createActions([ + 'connect', + 'disconnect' +]); + +serverActions.connect.preEmit = function(data) { + sock.send('connect', data); +}; + +module.exports = serverActions; \ No newline at end of file diff --git a/client/src/js/actions/tab.js b/client/src/js/actions/tab.js new file mode 100644 index 00000000..f14eb391 --- /dev/null +++ b/client/src/js/actions/tab.js @@ -0,0 +1,7 @@ +var Reflux = require('reflux'); + +var tabActions = Reflux.createActions([ + 'select' +]); + +module.exports = tabActions; \ No newline at end of file diff --git a/client/src/js/app.js b/client/src/js/app.js new file mode 100644 index 00000000..0f78aef9 --- /dev/null +++ b/client/src/js/app.js @@ -0,0 +1,77 @@ +var React = require('react'); +var _ = require('lodash'); + +var sock = require('./socket')('/ws'); +var util = require('./util'); +var App = require('./components/App.jsx'); +var messageActions = require('./actions/message.js'); +var tabActions = require('./actions/tab.js'); +var serverActions = require('./actions/server.js'); +var channelActions = require('./actions/channel.js'); + +React.render(, document.body); + +var uuid = localStorage.uuid || (localStorage.uuid = util.UUID()); + +tabActions.select('irc.freenode.net'); + +sock.on('connect', function() { + sock.send('uuid', uuid); + + serverActions.connect({ + server: 'irc.freenode.net', + nick: 'test' + Math.floor(Math.random() * 99999), + username: 'user' + }); + + channelActions.join({ + server: 'irc.freenode.net', + channels: [ '#stuff', '#go-nuts' ] + }); +}); + +channelActions.joined.listen(function(user, server, channel) { + messageActions.add({ + server: server, + from: '', + to: channel, + message: user + ' joined the channel' + }); +}); + +channelActions.parted.listen(function(user, server, channel) { + messageActions.add({ + server: server, + from: '', + to: channel, + message: user + ' left the channel' + }); +}); + +sock.on('message', function(data) { + messageActions.add(data); +}); + +sock.on('pm', function(data) { + messageActions.add(data); +}); + +sock.on('topic', function(data) { + messageActions.add({ + server: data.server, + from: '', + to: data.channel, + message: data.topic + }); +}); + +sock.on('motd', function(data) { + _.each(data.content.split('\n'), function(line) { + messageActions.add({ + server: data.server, + from: '', + to: data.server, + message: line + }); + }); +}); \ No newline at end of file diff --git a/client/src/js/components/App.jsx b/client/src/js/components/App.jsx new file mode 100644 index 00000000..10ae083c --- /dev/null +++ b/client/src/js/components/App.jsx @@ -0,0 +1,18 @@ +var React = require('react'); +var TabList = require('./TabList.jsx'); +var MessageBox = require('./MessageBox.jsx'); +var UserList = require('./UserList.jsx'); + +var App = React.createClass({ + render: function() { + return ( +
+ + + +
+ ); + } +}); + +module.exports = App; \ No newline at end of file diff --git a/client/src/js/components/MessageBox.jsx b/client/src/js/components/MessageBox.jsx new file mode 100644 index 00000000..1134b0a2 --- /dev/null +++ b/client/src/js/components/MessageBox.jsx @@ -0,0 +1,44 @@ +var React = require('react'); +var Reflux = require('reflux'); +var _ = require('lodash'); +var messageStore = require('../stores/message.js'); +var selectedTabStore = require('../stores/selectedTab.js'); + +var MessageBox = React.createClass({ + mixins: [ + Reflux.connect(messageStore, 'messages'), + Reflux.connect(selectedTabStore, 'selectedTab') + ], + + getInitialState: function() { + return { + messages: messageStore.getState(), + selectedTab: selectedTabStore.getState() + }; + }, + + componentWillUpdate: function() { + var el = this.getDOMNode(); + this.autoScroll = el.scrollTop + el.offsetHeight === el.scrollHeight; + }, + + componentDidUpdate: function() { + if (this.autoScroll) { + var el = this.getDOMNode(); + el.scrollTop = el.scrollHeight; + } + }, + + render: function() { + var tab = this.state.selectedTab.channel || this.state.selectedTab.server; + var messages = _.map(this.state.messages[tab], function(message) { + return

{message.from ? message.from + ': ' : null}{message.message}

; + }); + + return ( +
{messages}
+ ); + } +}); + +module.exports = MessageBox; \ No newline at end of file diff --git a/client/src/js/components/TabList.jsx b/client/src/js/components/TabList.jsx new file mode 100644 index 00000000..dc54258d --- /dev/null +++ b/client/src/js/components/TabList.jsx @@ -0,0 +1,41 @@ +var React = require('react'); +var Reflux = require('reflux'); +var _ = require('lodash'); + +var serverStore = require('../stores/server.js'); +var channelStore = require('../stores/channel.js'); +var selectedTabStore = require('../stores/selectedTab.js'); +var tabActions = require('../actions/tab.js'); + +var TabList = React.createClass({ + mixins: [ + Reflux.connect(serverStore, 'servers'), + Reflux.connect(channelStore, 'channels'), + Reflux.connect(selectedTabStore, 'selectedTab') + ], + + getInitialState: function() { + return { + servers: serverStore.getState(), + channels: channelStore.getState(), + selectedTab: selectedTabStore.getState() + }; + }, + + render: function() { + var self = this; + var tabs = _.map(this.state.channels, function(server, address) { + var channels = _.map(server, function(channel, name) { + return

{name}

; + }); + channels.unshift(

{address}

); + return channels; + }); + + return ( +
{tabs}
+ ); + } +}); + +module.exports = TabList; \ No newline at end of file diff --git a/client/src/js/components/UserList.jsx b/client/src/js/components/UserList.jsx new file mode 100644 index 00000000..fb1d3a26 --- /dev/null +++ b/client/src/js/components/UserList.jsx @@ -0,0 +1,36 @@ +var React = require('react'); +var Reflux = require('reflux'); +var _ = require('lodash'); +var channelStore = require('../stores/channel.js'); +var selectedTabStore = require('../stores/selectedTab.js'); + +var UserList = React.createClass({ + mixins: [ + Reflux.connect(channelStore, 'channels'), + Reflux.connect(selectedTabStore, 'selectedTab') + ], + + getInitialState: function() { + return { + channels: channelStore.getState(), + selectedTab: selectedTabStore.getState() + }; + }, + + render: function() { + var users = null; + var tab = this.state.selectedTab; + + if (tab.channel) { + users = _.map(this.state.channels[tab.server][tab.channel].users, function(user) { + return

{user}

; + }); + } + + return ( +
{users}
+ ); + } +}); + +module.exports = UserList; \ No newline at end of file diff --git a/client/src/js/socket.js b/client/src/js/socket.js new file mode 100644 index 00000000..dab015a6 --- /dev/null +++ b/client/src/js/socket.js @@ -0,0 +1,40 @@ +var EventEmitter = require('events').EventEmitter; +var _ = require('lodash'); + +var sockets = {}; + +function createSocket(path) { + if (sockets[path]) { + return sockets[path]; + } else { + var ws = new WebSocket('ws://' + window.location.host + path); + + var sock = { + send: function(type, data) { + ws.send(JSON.stringify({ type: type, request: data })); + } + }; + + _.extend(sock, EventEmitter.prototype); + + sockets[path] = sock; + + ws.onopen = function() { + sock.emit('connect'); + }; + + ws.onclose = function() { + sock.emit('disconnect'); + }; + + ws.onmessage = function(e) { + var msg = JSON.parse(e.data); + + sock.emit(msg.type, msg.response); + }; + + return sock; + } +} + +module.exports = createSocket; \ No newline at end of file diff --git a/client/src/js/stores/channel.js b/client/src/js/stores/channel.js new file mode 100644 index 00000000..d5960537 --- /dev/null +++ b/client/src/js/stores/channel.js @@ -0,0 +1,58 @@ +var Reflux = require('reflux'); +var _ = require('lodash'); +var actions = require('../actions/channel.js'); + +var channels = {}; + +function initChannel(server, channel) { + if (!(server in channels)) { + channels[server] = {}; + channels[server][channel] = { users: [] }; + } else if (!(channel in channels[server])) { + channels[server][channel] = { users: [] }; + } +} + +var channelStore = Reflux.createStore({ + init: function() { + this.listenToMany(actions); + }, + + joined: function(user, server, channel) { + initChannel(server, channel); + channels[server][channel].users.push(user); + this.trigger(channels); + }, + + part: function(data) { + _.each(data.channels, function(channel) { + delete channels[data.server][channel]; + }); + this.trigger(channels); + }, + + parted: function(user, server, channel) { + _.pull(channels[server][channel].users, user); + this.trigger(channels); + }, + + setUsers: function(users, server, channel) { + initChannel(server, channel); + channels[server][channel].users = users; + this.trigger(channels); + }, + + load: function(storedChannels) { + _.each(storedChannels, function(channel) { + initChannel(channel.server, channel.name); + channels[channel.server][channel.name].users = channel.users; + }); + this.trigger(channels); + }, + + getState: function() { + return channels; + } +}); + +module.exports = channelStore; \ No newline at end of file diff --git a/client/src/js/stores/message.js b/client/src/js/stores/message.js new file mode 100644 index 00000000..d241630a --- /dev/null +++ b/client/src/js/stores/message.js @@ -0,0 +1,31 @@ +var Reflux = require('reflux'); +var actions = require('../actions/message.js'); + +var messages = {}; + +var messageStore = Reflux.createStore({ + init: function() { + this.listenToMany(actions); + }, + + add: function(message) { + var dest = message.to || message.from; + if (message.from.indexOf('.') !== -1) { + dest = message.server; + } + + if (!(dest in messages)) { + messages[dest] = [message]; + } else { + messages[dest].push(message); + } + + this.trigger(messages); + }, + + getState: function() { + return messages; + } +}); + +module.exports = messageStore; \ No newline at end of file diff --git a/client/src/js/stores/selectedTab.js b/client/src/js/stores/selectedTab.js new file mode 100644 index 00000000..9745e2a1 --- /dev/null +++ b/client/src/js/stores/selectedTab.js @@ -0,0 +1,22 @@ +var Reflux = require('reflux'); +var actions = require('../actions/tab.js'); + +var selectedTab = {}; + +var selectedTabStore = Reflux.createStore({ + init: function() { + this.listenToMany(actions); + }, + + select: function(server, channel) { + selectedTab.server = server; + selectedTab.channel = channel; + this.trigger(selectedTab); + }, + + getState: function() { + return selectedTab; + } +}); + +module.exports = selectedTabStore; \ No newline at end of file diff --git a/client/src/js/stores/server.js b/client/src/js/stores/server.js new file mode 100644 index 00000000..a8063aba --- /dev/null +++ b/client/src/js/stores/server.js @@ -0,0 +1,21 @@ +var Reflux = require('reflux'); +var actions = require('../actions/server.js'); + +var servers = {}; + +var serverStore = Reflux.createStore({ + init: function() { + this.listenToMany(actions); + }, + + connect: function(data) { + servers[data.server] = data; + this.trigger(servers); + }, + + getState: function() { + return servers; + } +}); + +module.exports = serverStore; \ No newline at end of file diff --git a/client/src/js/util.js b/client/src/js/util.js new file mode 100644 index 00000000..cb95ad85 --- /dev/null +++ b/client/src/js/util.js @@ -0,0 +1,6 @@ +exports.UUID = function() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); + return v.toString(16); + }); +}; \ No newline at end of file diff --git a/client/src/style.css b/client/src/style.css new file mode 100644 index 00000000..14b812d7 --- /dev/null +++ b/client/src/style.css @@ -0,0 +1,53 @@ +* { + margin: 0; + padding: 0; +} + +body { + font-family: Inconsolata, sans-serif; + background: #111; + color: #FFF; +} + +p { + line-height: 1.5; +} + +.tablist { + position: fixed; + left: 0; + top: 0; + bottom: 0; + right: 200px; + padding: 20px; + overflow: auto; +} + +.tablist p { + cursor: pointer; +} + +.tablist p:hover { + color: #AAA; +} + +.messagebox { + position: fixed; + left: 200px; + top: 0; + bottom: 0; + right: 200px; + padding: 20px; + overflow: auto; + z-index: 1; +} + +.userlist { + position: fixed; + top: 0; + bottom: 0; + right: 0; + width: 200px; + padding: 20px; + overflow: auto; +} \ No newline at end of file diff --git a/irc.go b/irc.go new file mode 100644 index 00000000..491e6308 --- /dev/null +++ b/irc.go @@ -0,0 +1,207 @@ +package main + +import ( + "bufio" + "crypto/tls" + "fmt" + "net" + "strings" +) + +const ( + PING = "PING" + JOIN = "JOIN" + PART = "PART" + MODE = "MODE" + PRIVMSG = "PRIVMSG" + NOTICE = "NOTICE" + TOPIC = "TOPIC" + QUIT = "QUIT" + + RPL_WELCOME = "001" + RPL_YOURHOST = "002" + RPL_CREATED = "003" + RPL_LUSERCLIENT = "251" + RPL_LUSEROP = "252" + RPL_LUSERUNKNOWN = "253" + RPL_LUSERCHANNELS = "254" + RPL_LUSERME = "255" + + RPL_WHOISUSER = "311" + RPL_WHOISSERVER = "312" + RPL_WHOISOPERATOR = "313" + RPL_WHOISIDLE = "317" + RPL_ENDOFWHOIS = "318" + RPL_WHOISCHANNELS = "319" + + RPL_TOPIC = "332" + + RPL_NAMREPLY = "353" + RPL_ENDOFNAMES = "366" + + RPL_MOTD = "372" + RPL_MOTDSTART = "375" + RPL_ENDOFMOTD = "376" +) + +type Message struct { + Prefix string + Command string + Params []string + Trailing string +} + +type IRC struct { + conn net.Conn + reader *bufio.Reader + + Messages chan *Message + Server string + Host string + TLS bool + TLSConfig *tls.Config + nick string + Username string + Realname string +} + +func NewIRC(nick, username string) *IRC { + return &IRC{ + nick: nick, + Username: username, + Realname: nick, + Messages: make(chan *Message, 32), + } +} + +func (i *IRC) Connect(address string) { + if idx := strings.Index(address, ":"); idx < 0 { + i.Host = address + + if i.TLS { + address += ":6697" + } else { + address += ":6667" + } + } else { + i.Host = address[:idx] + } + i.Server = address + + if i.TLS { + if i.TLSConfig == nil { + i.TLSConfig = &tls.Config{InsecureSkipVerify: true} + } + i.conn, _ = tls.Dial("tcp", address, i.TLSConfig) + } else { + i.conn, _ = net.Dial("tcp", address) + } + + i.reader = bufio.NewReader(i.conn) + + i.Nick(i.nick) + i.User(i.Username, i.Realname) + + go i.recv() +} + +func (i *IRC) Pass(password string) { + i.Write("PASS " + password) +} + +func (i *IRC) Nick(nick string) { + i.Write("NICK " + nick) +} + +func (i *IRC) User(username, realname string) { + i.Writef("USER %s 0 * :%s", username, realname) +} + +func (i *IRC) Join(channels ...string) { + i.Write("JOIN " + strings.Join(channels, ",")) +} + +func (i *IRC) Part(channels ...string) { + i.Write("PART " + strings.Join(channels, ",")) +} + +func (i *IRC) Privmsg(target, msg string) { + i.Writef("PRIVMSG %s :%s", target, msg) +} + +func (i *IRC) Notice(target, msg string) { + i.Writef("NOTICE %s :%s", target, msg) +} + +func (i *IRC) Topic(channel string) { + i.Write("TOPIC " + channel) +} + +func (i *IRC) Whois(nick string) { + i.Write("WHOIS " + nick) +} + +func (i *IRC) Quit() { + i.Write("QUIT") + i.conn.Close() +} + +func (i *IRC) Write(data string) { + fmt.Fprint(i.conn, data+"\r\n") +} + +func (i *IRC) Writef(format string, a ...interface{}) { + fmt.Fprintf(i.conn, format+"\r\n", a...) +} + +func (i *IRC) recv() { + defer i.conn.Close() + for { + line, err := i.reader.ReadString('\n') + if err != nil { + return + } + + msg := parseMessage(line) + msg.Prefix = parseUser(msg.Prefix) + + switch msg.Command { + case PING: + i.Write("PONG :" + msg.Trailing) + } + + i.Messages <- msg + } +} + +func parseMessage(line string) *Message { + line = strings.Trim(line, "\r\n") + msg := Message{} + cmdStart := 0 + cmdEnd := len(line) + + if strings.HasPrefix(line, ":") { + cmdStart = strings.Index(line, " ") + 1 + msg.Prefix = line[1 : cmdStart-1] + } + + if i := strings.LastIndex(line, " :"); i > 0 { + cmdEnd = i + msg.Trailing = line[i+2:] + } + + cmd := strings.Split(line[cmdStart:cmdEnd], " ") + msg.Command = cmd[0] + if len(cmd) > 1 { + msg.Params = cmd[1:] + } + + return &msg +} + +func parseUser(user string) string { + if i := strings.Index(user, "!"); i > 0 { + return user[:i] + } + return user +} diff --git a/json_types.go b/json_types.go new file mode 100644 index 00000000..01b58411 --- /dev/null +++ b/json_types.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" +) + +type WSRequest struct { + Type string `json:"type"` + Request json.RawMessage `json:"request"` +} + +type WSResponse struct { + Type string `json:"type"` + Response *json.RawMessage `json:"response"` +} + +type Connect struct { + Server string `json:"server"` + Nick string `json:"nick"` + Username string `json:"username"` +} + +type Join struct { + Server string `json:"server"` + User string `json:"user"` + Channels []string `json:"channels"` +} + +type Chat struct { + Server string `json:"server"` + From string `json:"from"` + To string `json:"to"` + Message string `json:"message"` +} + +type Topic struct { + Server string `json:"server"` + Channel string `json:"channel"` + Topic string `json:"topic"` +} + +type Userlist struct { + Server string `json:"server"` + Channel string `json:"channel"` + Users []string `json:"users"` +} + +type MOTD struct { + Server string `json:"server"` + Title string `json:"title"` + Content string `json:"content"` +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..50cd2d51 --- /dev/null +++ b/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "log" + "net/http" + "sync" + + "golang.org/x/net/websocket" + + "github.com/khlieng/irc/storage" +) + +var ( + channelStore *storage.ChannelStore + sessions map[string]*Session + sessionLock sync.Mutex +) + +func main() { + defer storage.Cleanup() + + channelStore = storage.NewChannelStore() + sessions = make(map[string]*Session) + + /*for _, user := range storage.LoadUsers() { + channels := user.GetChannels() + + for _, server := range user.GetServers() { + session := NewSession() + session.user = user + sessions[user.UUID] = session + + irc := NewIRC(server.Nick, server.Username) + irc.TLS = true + irc.Connect(server.Address) + + session.setIRC(irc.Host, irc) + + go session.write() + go handleMessages(irc, session) + + var joining []string + for _, channel := range channels { + if channel.Server == server.Address { + joining = append(joining, channel.Name) + } + } + irc.Join(joining...) + } + }*/ + + http.Handle("/", http.FileServer(http.Dir("client/dist"))) + http.Handle("/ws", websocket.Handler(handleWS)) + + log.Println("Listening on port 1337") + http.ListenAndServe(":1337", nil) +} diff --git a/message_handler.go b/message_handler.go new file mode 100644 index 00000000..6bad58b7 --- /dev/null +++ b/message_handler.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "fmt" + "strings" + + "github.com/khlieng/irc/storage" +) + +func handleMessages(irc *IRC, session *Session) { + userBuffers := make(map[string][]string) + var motd MOTD + var motdContent bytes.Buffer + + for msg := range irc.Messages { + switch msg.Command { + case JOIN: + user := parseUser(msg.Prefix) + + session.sendJSON("join", Join{ + Server: irc.Host, + User: user, + Channels: msg.Params, + }) + + channelStore.AddUser(user, irc.Host, msg.Params[0]) + + if user == irc.nick { + session.user.AddChannel(storage.Channel{ + Server: irc.Host, + Name: msg.Params[0], + }) + } + + case PART: + user := parseUser(msg.Prefix) + + session.sendJSON("part", Join{ + Server: irc.Host, + User: user, + Channels: msg.Params, + }) + + channelStore.RemoveUser(user, irc.Host, msg.Params[0]) + + if user == irc.nick { + session.user.RemoveChannel(irc.Host, msg.Params[0]) + } + + case PRIVMSG, NOTICE: + if msg.Params[0] == irc.nick { + session.sendJSON("pm", Chat{ + Server: irc.Host, + From: msg.Prefix, + Message: msg.Trailing, + }) + } else { + session.sendJSON("message", Chat{ + Server: irc.Host, + From: msg.Prefix, + To: msg.Params[0], + Message: msg.Trailing, + }) + } + + case QUIT: + /* + session.sendJSON("quit", Quit{ + Server: irc.Host, + User: user, + }) + */ + + case RPL_WELCOME, + RPL_YOURHOST, + RPL_LUSERCLIENT, + RPL_LUSEROP, + RPL_LUSERUNKNOWN, + RPL_LUSERCHANNELS, + RPL_LUSERME: + session.sendJSON("pm", Chat{ + Server: irc.Host, + From: msg.Prefix, + Message: strings.Join(append(msg.Params[1:], msg.Trailing), " "), + }) + + case RPL_TOPIC: + session.sendJSON("topic", Topic{ + Server: irc.Host, + Channel: msg.Params[1], + Topic: msg.Trailing, + }) + + case RPL_NAMREPLY: + users := strings.Split(msg.Trailing, " ") + + for i, user := range users { + users[i] = strings.TrimLeft(user, "@+") + } + + userBuffer := userBuffers[msg.Params[2]] + userBuffers[msg.Params[2]] = append(userBuffer, users...) + + case RPL_ENDOFNAMES: + channel := msg.Params[1] + users := userBuffers[channel] + + session.sendJSON("users", Userlist{ + Server: irc.Host, + Channel: channel, + Users: users, + }) + + channelStore.SetUsers(users, irc.Host, channel) + delete(userBuffers, channel) + + case RPL_MOTDSTART: + motd.Server = irc.Host + motd.Title = msg.Trailing + + case RPL_MOTD: + motdContent.WriteString(msg.Trailing) + motdContent.WriteRune('\n') + + case RPL_ENDOFMOTD: + motd.Content = motdContent.String() + + session.sendJSON("motd", motd) + + motdContent.Reset() + motd = MOTD{} + + default: + printMessage(msg, irc) + } + } +} + +func printMessage(msg *Message, irc *IRC) { + fmt.Printf("%s: %s %s %s\n", irc.nick, msg.Prefix, msg.Command, msg.Params) + + if msg.Trailing != "" { + fmt.Println(msg.Trailing) + } + fmt.Println() +} diff --git a/session.go b/session.go new file mode 100644 index 00000000..d0e846d8 --- /dev/null +++ b/session.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "sync" + + "golang.org/x/net/websocket" + + "github.com/khlieng/irc/storage" +) + +type Session struct { + irc map[string]*IRC + ircLock sync.Mutex + + ws map[string]*WebSocket + wsLock sync.Mutex + out chan []byte + + user storage.User +} + +func NewSession() *Session { + return &Session{ + irc: make(map[string]*IRC), + ws: make(map[string]*WebSocket), + out: make(chan []byte, 32), + } +} + +func (s *Session) getIRC(server string) (*IRC, bool) { + s.ircLock.Lock() + defer s.ircLock.Unlock() + + irc, ok := s.irc[server] + return irc, ok +} + +func (s *Session) setIRC(server string, irc *IRC) { + s.ircLock.Lock() + s.irc[server] = irc + s.ircLock.Unlock() +} + +func (s *Session) setWS(addr string, ws *websocket.Conn) { + socket := NewWebSocket(ws) + go socket.write() + + s.wsLock.Lock() + s.ws[addr] = socket + s.wsLock.Unlock() +} + +func (s *Session) deleteWS(addr string) { + s.wsLock.Lock() + delete(s.ws, addr) + s.wsLock.Unlock() +} + +func (s *Session) sendJSON(t string, v interface{}) { + data, _ := json.Marshal(v) + raw := json.RawMessage(data) + res, _ := json.Marshal(WSResponse{Type: t, Response: &raw}) + + s.out <- res +} + +func (s *Session) write() { + for res := range s.out { + s.wsLock.Lock() + for _, ws := range s.ws { + ws.In <- res + } + s.wsLock.Unlock() + } +} diff --git a/storage/channel.go b/storage/channel.go new file mode 100644 index 00000000..ec1a1045 --- /dev/null +++ b/storage/channel.go @@ -0,0 +1,68 @@ +package storage + +import ( + "sync" +) + +type ChannelStore struct { + data map[string]map[string][]string + lock sync.Mutex +} + +func NewChannelStore() *ChannelStore { + return &ChannelStore{ + data: make(map[string]map[string][]string), + } +} + +func (c *ChannelStore) GetUsers(server, channel string) []string { + c.lock.Lock() + + users := make([]string, len(c.data[server][channel])) + copy(users, c.data[server][channel]) + + c.lock.Unlock() + + return users +} + +func (c *ChannelStore) SetUsers(users []string, server, channel string) { + c.lock.Lock() + + if _, ok := c.data[server]; !ok { + c.data[server] = make(map[string][]string) + } + + c.data[server][channel] = users + + c.lock.Unlock() +} + +func (c *ChannelStore) AddUser(user, server, channel string) { + c.lock.Lock() + + if _, ok := c.data[server]; !ok { + c.data[server] = make(map[string][]string) + } + + if users, ok := c.data[server][channel]; ok { + c.data[server][channel] = append(users, user) + } else { + c.data[server][channel] = []string{user} + } + + c.lock.Unlock() +} + +func (c *ChannelStore) RemoveUser(user, server, channel string) { + c.lock.Lock() + defer c.lock.Unlock() + + for i, u := range c.data[server][channel] { + if u == user { + users := c.data[server][channel] + c.data[server][channel] = append(users[:i], users[i+1:]...) + return + } + } +} diff --git a/storage/init.go b/storage/init.go new file mode 100644 index 00000000..9c4a73a4 --- /dev/null +++ b/storage/init.go @@ -0,0 +1,22 @@ +package storage + +import ( + "github.com/boltdb/bolt" +) + +var db *bolt.DB + +func init() { + db, _ = bolt.Open("data.db", 0600, nil) + + db.Update(func(tx *bolt.Tx) error { + tx.CreateBucketIfNotExists([]byte("Users")) + tx.CreateBucketIfNotExists([]byte("Servers")) + tx.CreateBucketIfNotExists([]byte("Channels")) + return nil + }) +} + +func Cleanup() { + db.Close() +} diff --git a/storage/user.go b/storage/user.go new file mode 100644 index 00000000..05c0765c --- /dev/null +++ b/storage/user.go @@ -0,0 +1,137 @@ +package storage + +import ( + "bytes" + "encoding/json" + + "github.com/boltdb/bolt" +) + +type Server struct { + Address string + Nick string + Username string + Realname string +} + +type Channel struct { + Server string `json:"server"` + Name string `json:"name"` + Users []string `json:"users"` +} + +type User struct { + UUID string +} + +func NewUser(uuid string) User { + user := User{ + UUID: uuid, + } + + go db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("Users")) + data, _ := json.Marshal(user) + + b.Put([]byte(uuid), data) + + return nil + }) + + return user +} + +func LoadUsers() []User { + var users []User + + db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("Users")) + + b.ForEach(func(k, v []byte) error { + users = append(users, User{string(k)}) + + return nil + }) + + return nil + }) + + return users +} + +func (u User) GetServers() []Server { + var servers []Server + + db.View(func(tx *bolt.Tx) error { + c := tx.Bucket([]byte("Servers")).Cursor() + prefix := []byte(u.UUID) + + for k, v := c.Seek(prefix); bytes.HasPrefix(k, prefix); k, v = c.Next() { + var server Server + json.Unmarshal(v, &server) + servers = append(servers, server) + } + + return nil + }) + + return servers +} + +func (u User) GetChannels() []Channel { + var channels []Channel + + db.View(func(tx *bolt.Tx) error { + c := tx.Bucket([]byte("Channels")).Cursor() + + prefix := []byte(u.UUID) + + for k, v := c.Seek(prefix); bytes.HasPrefix(k, prefix); k, v = c.Next() { + var channel Channel + json.Unmarshal(v, &channel) + channels = append(channels, channel) + } + + return nil + }) + + return channels +} + +func (u User) AddServer(server Server) { + db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("Servers")) + data, _ := json.Marshal(server) + + b.Put([]byte(u.UUID+":"+server.Address), data) + + return nil + }) +} + +func (u User) AddChannel(channel Channel) { + db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("Channels")) + data, _ := json.Marshal(channel) + + b.Put([]byte(u.UUID+":"+channel.Server+":"+channel.Name), data) + + return nil + }) +} + +func (u User) RemoveServer(address string) { + db.Update(func(tx *bolt.Tx) error { + tx.Bucket([]byte("Channels")).Delete([]byte(u.UUID + ":" + address)) + + return nil + }) +} + +func (u User) RemoveChannel(server, channel string) { + db.Update(func(tx *bolt.Tx) error { + tx.Bucket([]byte("Servers")).Delete([]byte(u.UUID + ":" + server + ":" + channel)) + + return nil + }) +} diff --git a/websocket.go b/websocket.go new file mode 100644 index 00000000..a1887cf5 --- /dev/null +++ b/websocket.go @@ -0,0 +1,24 @@ +package main + +import ( + "golang.org/x/net/websocket" +) + +type WebSocket struct { + conn *websocket.Conn + + In chan []byte +} + +func NewWebSocket(ws *websocket.Conn) *WebSocket { + return &WebSocket{ + conn: ws, + In: make(chan []byte, 32), + } +} + +func (w *WebSocket) write() { + for data := range w.In { + w.conn.Write(data) + } +} diff --git a/websocket_handler.go b/websocket_handler.go new file mode 100644 index 00000000..9119eeac --- /dev/null +++ b/websocket_handler.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "log" + + "golang.org/x/net/websocket" + + "github.com/khlieng/irc/storage" +) + +func handleWS(ws *websocket.Conn) { + defer ws.Close() + + var session *Session + var UUID string + var req WSRequest + + addr := ws.Request().RemoteAddr + + log.Println(addr, "connected") + + for { + err := websocket.JSON.Receive(ws, &req) + if err != nil { + if session != nil { + session.deleteWS(addr) + } + + log.Println(addr, "disconnected") + return + } + + switch req.Type { + case "uuid": + json.Unmarshal(req.Request, &UUID) + + log.Println(addr, "set UUID", UUID) + + sessionLock.Lock() + + if storedSession, exists := sessions[UUID]; exists { + sessionLock.Unlock() + session = storedSession + + log.Println(addr, "attached to existing IRC connections") + + channels := session.user.GetChannels() + for i, channel := range channels { + channels[i].Users = channelStore.GetUsers(channel.Server, channel.Name) + } + + session.sendJSON("channels", channels) + } else { + session = NewSession() + session.user = storage.NewUser(UUID) + + sessions[UUID] = session + sessionLock.Unlock() + + go session.write() + } + + session.setWS(addr, ws) + + case "connect": + var data Connect + + json.Unmarshal(req.Request, &data) + + if _, ok := session.getIRC(data.Server); !ok { + log.Println(addr, "connecting to", data.Server) + + irc := NewIRC(data.Nick, data.Username) + irc.TLS = true + irc.Connect(data.Server) + + session.setIRC(irc.Host, irc) + + go handleMessages(irc, session) + + session.user.AddServer(storage.Server{ + Address: irc.Host, + Nick: data.Nick, + Username: data.Username, + }) + } else { + log.Println(addr, "already connected to", data.Server) + } + + case "join": + var data Join + + json.Unmarshal(req.Request, &data) + + if irc, ok := session.getIRC(data.Server); ok { + irc.Join(data.Channels...) + } + + case "part": + var data Join + + json.Unmarshal(req.Request, &data) + + if irc, ok := session.getIRC(data.Server); ok { + irc.Part(data.Channels...) + } + + case "chat": + var data Chat + + json.Unmarshal(req.Request, &data) + + if irc, ok := session.getIRC(data.Server); ok { + irc.Privmsg(data.To, data.Message) + } + } + } +}