diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98d0a4c --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +all: + GOPATH=$(CURDIR) go build -v all diff --git a/PROTOCOL b/PROTOCOL new file mode 100644 index 0000000..5710812 --- /dev/null +++ b/PROTOCOL @@ -0,0 +1,64 @@ +wirc protocol +=========== + +This document attempts to describe the wirc protocol. +The source code will always be authoritative, +but this protocol has been around for a while now so should be changed often, if ever. + + +Out Queue +--------- + + +Any files that appear in the directory outq/ are written verbatim to the IRC server. + +You may put multiple lines in a single file. + +Filenames beginning with "." are ignored. + +You are advised to create files beginning with ".", +then rename them on completion of the write, +to avoid race conditions. + + +Log +--- + +### Log Filenames + +TBD + + +### Log Messages + +IRC messages are written to the log, one message per line. +Messages are translated to an easier-to-parse format: + + timestamp fullname command sender forum [args...] :text + +Where: + +* `timestamp` is in Unix epoch time. +* `fullname` is the full name of the message origin (typically `nick!user@host.name`) +* `command` is the IRC command +* `sender` is the IRC name of the entity that sent the message +* `forum` is the IRC name of the audience of the message +* `args` are any additional arguments not otherwise covered +* `text` is the text of the message + +`sender` and `forum` are provided in every message, for the convenience of the client. +A PRIVMSG to `sender` will make it back to whomever sent the message, +a PRIVMSG to `forum` will be seen by everyone in the audience. + +For example, a "private message" will have `sender` equal to `forum`. +But a "channel message" will have `forum` set to the channel. + +See `wirc.go` for details of each message type. + + +### Initial Messages + +Each log file will contain the following initial messages, +to facilitate stateful clients: + +TBD diff --git a/README b/README new file mode 100644 index 0000000..2fa3013 --- /dev/null +++ b/README @@ -0,0 +1,29 @@ +Woozle IRC +========= + +This is a sort of bouncer for clients with transient network connections, +like cell phones and laptops. +It's a lot like [tapchat](https://github.com/tapchat/tapchat) but is a whole lot simpler +while being (at time of writing) much more feature-complete. + +It supports (currently) an JavaScript browser-based client, +and can also be worked from the command-line using Unix tools like "tail" and "echo". + +Ironically, it doesn't currently work with any existing IRC clients, +although we are kicking around ideas for such a thing. +Honestly, though, if you want a bouncer for a traditional IRC client, +you are better off using something like znc. + +We have an [architectural diagram](https://docs.google.com/drawings/d/1am_RTUh89kul-318GoYK73AbjOE_jMYi4vI4NyEgKrY/edit?usp=sharing) if you care about such things. + + +Features +-------- + +* Gracefully handles clients with transient networking, such as cell phones and laptops. + + +Todo +----- + +I need to make this document suck less. diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json new file mode 100644 index 0000000..c0c2c24 --- /dev/null +++ b/app/_locales/en/messages.json @@ -0,0 +1,15 @@ +{ + "appName": { + "message": "wIRC Chat Client", + "description": "Application name" + }, + "appShortName": { + "message": "wIRC", + "description": "Short application name" + }, + "appDesc": { + "message": "Chat client for the wIRC bouncer thingamajiggy", + "description": "Application description for app store listing" + } +} + diff --git a/app/background.js b/app/background.js new file mode 100644 index 0000000..31c2421 --- /dev/null +++ b/app/background.js @@ -0,0 +1,5 @@ +chrome.app.runtime.onLaunched.addListener(function() { + chrome.app.window.create('wirc.html', { + 'state': 'normal' + }) +}) diff --git a/app/example.html b/app/example.html new file mode 100644 index 0000000..0c07b8a --- /dev/null +++ b/app/example.html @@ -0,0 +1,361 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +CIRC undefined + + + +
+ +
+
+
+
-
+
+ +
+ +
  • +
    +
    -
    +
  • + + + +
  • +
    +
  • + + + +
  • +
    +
    +
    +
    +
    +
  • + +
    + + +
    +
    +
    +
    +

    rooms

    +
    +
    +
    us.slashnet.org
    +
    -
    +
    +
    • +
      #tron
      +
      -
      +
    +
    + +
      +
      +
      +
      +

      members

      +
      • +
        also
        +
      • +
        atob
        +
      • +
        Bermuda
        +
      • +
        bit
        +
      • +
        bk
        +
      • +
        bz2
        +
      • +
        caycos
        +
      • +
        ckape
        +
      • +
        clavicle
        +
      • +
        CrackMonkey
        +
      • +
        Dumont
        +
      • +
        dzho
        +
      • +
        emad
        +
      • +
        eythian
        +
      • +
        felixc
        +
      • +
        fo0bar
        +
      • +
        fuzzie
        +
      • +
        GodEater
        +
      • +
        hollow
        +
      • +
        hoylemd
        +
      • +
        jhewl
        +
      • +
        jv
        +
      • +
        kees
        +
      • +
        khmer
        +
      • +
        lamont
        +
      • +
        lexicondal
        +
      • +
        neale
        +
      • +
        nemo
        +
      • +
        nerdtron3000
        +
      • +
        nornagon
        +
      • +
        Octal
        +
      • +
        pdx6
        +
      • +
        pedro
        +
      • +
        Randall
        +
      • +
        sarah
        +
      • +
        Sciri
        +
      • +
        scorche
        +
      • +
        scorche|sh
        +
      • +
        Screwtape
        +
      • +
        sneakums
        +
      • +
        squinky
        +
      • +
        stat
        +
      • +
        teferi
        +
      • +
        tiaz
        +
      • +
        watson
        +
      • +
        wcarss
        +
      • +
        wombat
        +
      • +
        X11R5
        +
      • +
        Zen
        +
      +
      + +
      + +
      +
      + +
      Spel werkt, vertel het door! | hype mismatch error | ERG DRUK | <ginnie> everything is awesome! | Save The Date: 20150212 is Dumont's 15th Birthday | sparrows form Voltron | Deksels! | sysadmin establishment "hanged" by lunatic devop | Levis dehydrates unisexual cleavage. | A fanfare for locked traffic --
      +
      +
      +
      + + + +
      +
      • +
        3:46:12 PM
        +
        +
        +
        Awesome, you've connected to #tron.
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        If you're ever stuck, type /help to see a list of all commands.
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        You can switch windows with alt+[0-9] or click in the channel list on the left.
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        (You joined the channel)
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        The topic is: Spel werkt, vertel het door! | hype mismatch error | ERG DRUK | <ginnie> everything is awesome! | Save The Date: 20150212 is Dumont's 15th Birthday | sparrows form Voltron | Deksels! | sysadmin establishment "hanged" by lunatic devop | Levis dehydrates unisexual cleavage. | A fanfare for locked traffic --
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        Topic set by sneakums on Fri Oct 17 2014 16:05:00 GMT-0600 (MDT).
        +
        +
      • +
        3:46:12 PM
        +
        +
        +
        Received a CTCP VERSION from squinky.
        +
        +
      • +
        3:47:13 PM
        +
        +
        nerdtron3000
        +
        merf
        +
        +
      • +
        3:47:53 PM
        +
        +
        nerdtron3000
        +
        X11R5: say something to me
        +
        +
      • +
        3:47:55 PM
        +
        +
        X11R5
        +
        nerdtron3000: Get me some of those things where 99 times out on to something.
        +
        +
      • +
        3:48:01 PM
        +
        +
        nerdtron3000
        +
        ...
        +
        +
      • +
        3:48:02 PM
        +
        +
        Dumont
        +
        [You have a sad feeling for a moment, then it passes.]
        +
        +
      +
      +
      nerdtron3000
      +
      +
      +
      + + + + \ No newline at end of file diff --git a/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..227be33 --- /dev/null +++ b/app/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "version": "1.0", + + "name": "__MSG_appName__", + "short_name": "__MSG_appShortName__", + "description": "__MSG_appDesc__", + "author": "Neale Pickett ", + "icons": {"128": "wirc.png"}, + "app": { + "background": { + "scripts": ["background.js"] + } + }, + "permissions": [ + "storage", + "fileSystem", + "https://woozle.org/" + ], + "default_locale": "en" +} diff --git a/app/message_style.css b/app/message_style.css new file mode 100644 index 0000000..5da3f16 --- /dev/null +++ b/app/message_style.css @@ -0,0 +1,172 @@ +.message.system { + color: #505053; +} + +.message.notice { + color: #97008B; +} + +.message.error { + color: #B33B2D; +} + +.message.update { + color: #00068F; +} + +.message.update.self { + color: #005816; +} + +.message.update.privmsg { + color: #505053; +} + +.message.update.privmsg.self { + color: gray; +} + +.message.update.privmsg.mention { + color: darkred; +} + +.message.update.privmsg.mention .source { + font-weight: bold; +} + +.message.update.privmsg.direct .source { + color: #488AA8; +} + +.message.update.privmsg.notice .source { + color: #97008B; +} + +/* Nickname Colors. Taken from Textual */ + +.message.update.privmsg.self .source-content-container .source { + color: #ea0d68; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='0'] { + color: #0080ff; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='1'] { + color: #059005; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='2'] { + color: #a80054; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='3'] { + color: #9b0db1; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='4'] { + color: #108860; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='5'] { + color: #7F4FFF; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='6'] { + color: #58701a; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='7'] { + color: #620a8e; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='8'] { + color: #BB0008; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='9'] { + color: #44345f; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='10'] { + color: #2f5353; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='11'] { + color: #904000; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='12'] { + color: #808000; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='13'] { + color: #57797e; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='14'] { + color: #3333dd; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='15'] { + color: #5f4d22; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='16'] { + color: #706616; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='17'] { + color: #46799c; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='18'] { + color: #80372e; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='19'] { + color: #8F478E; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='20'] { + color: #5b9e4c; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='21'] { + color: #13826c; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='22'] { + color: #b13637; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='23'] { + color: #e45d59; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='24'] { + color: #1b51ae; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='25'] { + color: #4855ac; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='26'] { + color: #7f1d86; +} +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='27'] { + color: #73643f; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='28'] { + color: #0b9578; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='29'] { + color: #569c96; +} + +.message.update.privmsg:not(.self) .source-content-container .source[colornumber='30'] { + color: #08465f; +} diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..4749e41 --- /dev/null +++ b/app/server.js @@ -0,0 +1,38 @@ +// Functionality dealing with server-level things + +var maxScrollback = 500; + +function Server(baseURL, network, authtok, messageHandler) { + function handleEventSourceLine(line) { + var lhs = line.split(" :", 1)[0] + var parts = lhs.split(' ') + var timestamp = new Date(parts[0] * 1000); + var fullSender = parts[1]; + var command = parts[2]; + var sender = parts[3]; + var forum = parts[4]; + var args = parts.slice(5); + var txt = line.substr(lhs.length + 2); + + messageHandler(timestamp, fullSender, command, sender, forum, args, txt); + } + + function handleEventSourceMessage(oEvent) { + msgs = oEvent.data.split("\n"); + + var first = Math.min(0, msgs.length - maxScrollback); + for (var i = first; i < msgs.length; i += 1) { + handleEeventSourceLine(msgs[i]); + } + } + + function handleEventSourceError(oEvent) { + timestamp = new Date(); + messageHandler(timestamp, null, "ERROR", null, null, [], null); + } + + var pullURL = baseURL + "?network=" + encodeURIComponent(network) + "&authtok=" + encodeURIComponent(authtok); + this.eventSource = new EventSource(baseURL); + this.eventSource.addEventListener("message", handleEventSourceMessage); + this.eventSource.addEventListener("error", handleEventSourceError); +} diff --git a/app/style.css b/app/style.css new file mode 100644 index 0000000..7ece3ca --- /dev/null +++ b/app/style.css @@ -0,0 +1,510 @@ +html, body { + margin: 0; + padding: 0 +} + +*, *:before, *:after { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +body { + color: #505053; + font-family: sans-serif; + font-size: 14px; +} + +.hidden { + display: none; +} + +#templates { + display: none; +} + +ul { + list-style: none; + margin: 0; + padding: 0; +} + +.footer { + display: none; + font-style: italic; + color: #9493A2; +} + +.help-command { + display: inline-block; + width: 7em; +} + +.content-item { + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; +} + +#main { + display: -webkit-box; + -webkit-box-orient: horizontal; + width: 100%; + height: 100%; + position: absolute; + border-top: 1px solid rgba(0,0,0,0.15); +} + +#main-top-border { + position: absolute; + top: 0; + z-index: 100; + width: 100%; + height: 1px; + background-color: rgba(0,0,0,0.05); + pointer-events: none; +} + +#rooms-and-nicks { + background-color: #F7F5E4; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-box-flex: 0; + width: 150px; + height: inherit; + padding: 4px 0px; + border-right: 1px solid rgba(0, 0, 0, .15); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +#rooms-and-nicks h1 { + color: #406698; + text-transform: uppercase; + font-size: 12px; + padding: 8px 0px 2px 8px; + margin: 0px; +} + +#rooms-and-nicks .nick, +#rooms-and-nicks .room { + padding: 0 8px; + line-height: 26px; +} + +#rooms-and-nicks .room { + cursor: pointer; +} + +#rooms-and-nicks .server.footer { + display: block; +} + +.dragbar:hover { + width: 6px; + transition: width .1s; +} + +.dragbar { + height: 100%; + width: 1px; + cursor: col-resize; +} + +#ghostbar{ + width:3px; + background-color:#000; + opacity:0.8; + position:absolute; + cursor: col-resize; + z-index:999 +} + +#rooms-container { + -webkit-box-flex: 0; + border-bottom: 1px solid #CCC; + padding-bottom: 10px; +} + +.no-nicks #rooms-container { + border-bottom-style: none; + padding-bottom: 0px; +} + +#rooms-and-nicks.hidden { + display: none; +} + +#rooms-and-nicks .room.server { + background-color: #F2EFD3; + position: relative; +} + +#rooms-and-nicks:hover .room.server .content-item { + width: 130px; +} + +#rooms-and-nicks .add-channel { + position: absolute; + right: 2px; + top: -1px; + cursor: pointer; + font-size: 18px; + display: none; +} + +#rooms-and-nicks:hover .add-channel { + display: block; +} + +#rooms-and-nicks .add-channel:hover { + color: #888; +} + +#rooms-and-nicks .room.channel:first-child { + padding-top: 3px; +} + +#rooms-and-nicks .room.channel:first-child .content-item { + padding-bottom: 3px; + line-height: 20px; +} + +#rooms-and-nicks not(.current-server) + .channels .room.channel:nth-last-child(2) { + padding-bottom: 3px; +} +#rooms-and-nicks .room.channel:last-child { + padding-bottom: 3px; +} + +#rooms-and-nicks not(.current-server) + .channels .room.channel:nth-last-child(2) .content-item { + padding-top: 3px; + line-height: 20px; +} +#rooms-and-nicks .room.channel:last-child .content-item { + padding-top: 3px; + line-height: 20px; +} + +#rooms-and-nicks not(.current-server) + .channels .room.channel:nth-last-child(2):first-child .content-item { + line-height: 14px; +} +#rooms-and-nicks .room.channel:last-child:first-child .content-item { + line-height: 14px; +} + +#rooms-and-nicks .current-server:not(.always-empty) + .channels .footer { + display: block; +} + +#rooms-and-nicks .room.channel .content-item { + border-left: 1px solid rgba(0, 0, 0, .5); + padding-left: 7px; +} + +#rooms-and-nicks .room.activity { + font-weight: bold; +} + +#rooms-and-nicks .room.mention { + color: darkred; +} + +#rooms-and-nicks .room.disconnected .content-item { + color: #9493A2; + font-style: italic; +} + +#rooms-and-nicks .room.channel.disconnected .content-item { + border-left: 1px solid #9493A2; +} + +#rooms-and-nicks .room.selected { + background-color: #F0E798; +} + +#rooms-and-nicks .room { + position: relative; +} + +#rooms-and-nicks .room .remove-button { + position: absolute; + right: 6px; + top: -1px; + cursor: pointer; + font-size: 30px; + color: rgba(0, 0, 0, .25); + display: none; +} + +#rooms-and-nicks .room .remove-button:hover { + color: rgba(0, 0, 0, .5); +} + +#rooms-and-nicks .room.selected:not(.footer):not(.always-empty) .content-item { + width: 123px; +} +#rooms-and-nicks .room:hover:not(.footer):not(.always-empty) .content-item { + width: 123px; +} + +#rooms-and-nicks .room.selected:not(.footer):not(.always-empty) .remove-button { + display: inline-block; +} +#rooms-and-nicks .room:hover:not(.footer):not(.always-empty) .remove-button { + display: inline-block; +} + +#nicks-container { + border-top: 1px solid #FFF; + -webkit-box-flex: 1; +} + +.no-nicks #nicks-container { + display: none; +} + +.rooms, +.nicks { + padding-top: 5px; +} + +.nicks li:nth-child(odd) { + background-color: #F2EFD3; +} + +#notice { + background-color: #406698; + box-shadow: 0px 1px 4px #888; + color: #FFF; + position: absolute; + width: 100%; + -webkit-transition: 150ms; + top: 0; + padding: 2px 0; +} + +#notice.hide { + top: -38px; +} + +#notice .content { + display: inline; + padding-left: 14px; + text-overflow: ellipsis; + white-space: nowrap; + overflow-x: hidden; + position: absolute; + top: 50%; + margin-top: -.5em; +} + +#notice button { + float: right; + height: 22px; + font-size: 14px; + padding: 0px 4px; + margin: 4px; + border: none; + background-color: #ECECEC; + color: #505053; + cursor: pointer; + outline: none; +} + +#notice button.close { + margin-right: 8px; + font-size: 12px; + border-radius: 42px; + width: 21px; + height: 21px; +} + +#messages-and-input { + -webkit-box-flex: 1; + display: -webkit-box; + -webkit-box-orient: vertical; + box-shadow: 0px 0px 8px #CCC; + position: relative; +} + +#messages-container { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -webkit-box-flex: 1; + border-bottom: 1px solid #CCC; +} + +.messages { + display: table; +} + +.message { + display: table-row; + -webkit-user-select: initial; +} + +.message.activity-marker .source-content-container { + border-top: 1px solid rgb(224, 179, 179); +} + +.message .timestamp { + color: #6060C0; + font-style: italic; + font-size: smaller; + white-space: nowrap; + display: table-cell; + text-align: right; + padding: 0px 10px 0px 10px; + border-right: 1px solid rgba(0,0,0,0.15); + cursor: text; +} + +.source-content-container { + display: table-cell; + padding: 4px 10px 4px 15px; + width: 100%; +} + +.message .sender { + font-weight: bold; + padding-right: 5px; + margin-left: -5px; + white-space: nowrap; + display: inline; + text-align: right; + cursor: text; +} + +.message .sender.empty { + padding-right: 0; +} + +.message .text { + display: inline; + white-space: pre-wrap; + cursor: text; +} + +#messages-container .messages .message:first-child .source-content-container { + padding-top: 10px; +} + +#messages-container .messages .message:last-child .source-content-container { + padding-bottom: 8px; +} + +.message:not(.privmsg) + .message.privmsg .source-content-container { + padding-top: 4px; + padding-bottom: 1px; +} +.message.privmsg + .message.privmsg .source-content-container { + padding-top: 1px; + padding-bottom: 1px; +} + +.message:not(.group) + .message.group .source-content-container { + padding-top: 4px; + padding-bottom: 0px; +} +.message.group + .message.group .source-content-container { + padding-top: 0px; + padding-bottom: 0px; +} + +.message:not(.notice-group) + .message.notice-group .source-content-container { + padding-top: 4px; + padding-bottom: 0px; +} +.message.notice-group + .message.notice-group .source-content-container { + padding-top: 0px; + padding-bottom: 0px; +} + +.message.list { + /* empty style for now */ +} + +.longword { word-break: break-all; } + +#nick-and-input { + border-top: 1px solid #F9F9F9; + -webkit-box-flex: 0; + display: -webkit-box; + background-color: #ECECEC; + padding: 5px 10px 5px 10px; +} + +#nick { + padding-top: 5px; +} + +#nick > span { + padding-right: 10px; +} + +#nick > .name:before { + font-weight: bold; + content: "[ " +} +#nick > .name:after { + font-weight: bold; + content: " ]" +} + +#nick .away:before { + content: "(" +} +#nick .away:after { + content: ")" +} + +#input-bar { + -webkit-box-flex: 1; + display: -webkit-box; +} + +#input { + background-color: #F9F9F9; + display: block; + -webkit-box-flex: 1; + width: 100%; + height: 30px; + border: 1px; + border-radius: 5px; + -webkit-box-shadow: 0px 0px 3px #888; + color: #505053; + font-size: 14px; + padding: 0px 8px; + outline: 0; +} + +#input.blink { + -webkit-box-shadow: 0px 0px 6px #406698; +} + +::-webkit-scrollbar { + width: 9px; + height: 9px; +} +::-webkit-scrollbar-button:start:decrement, +::-webkit-scrollbar-button:end:increment { + display: block; + height: 0; +} +::-webkit-scrollbar-track-piece { + background-color: rgba(0,0,0,0.1); +} +::-webkit-scrollbar-thumb:vertical { + height: 50px; + background-color: #999; + border-radius: 8px; +} +::-webkit-scrollbar-thumb:vertical:hover { + background-color: #888; +} +::-webkit-scrollbar-thumb:horizontal { + width: 50px; + background-color: #999; + border-radius: 8px; +} diff --git a/app/topbar.css b/app/topbar.css new file mode 100644 index 0000000..8db1003 --- /dev/null +++ b/app/topbar.css @@ -0,0 +1,43 @@ +#hide-channels { + display: inline-block; + font-size: 12px; +} + +#topic-container { + border-bottom: 1px solid #CCC; + -webkit-box-flex: 0; + display: -webkit-box; + background-color: #ECECEC; + padding: 4px 6px; +} + +#status { + -webkit-box-flex: 1; + padding: 4px 5px; +} + +#status .topic { + font-style: italic; +} + +.topbar-button { + background: rgba(0, 0, 0, .08); + border: 0 transparent; + text-decoration: none; + cursor: pointer; + border-radius: 2px; + -webkit-transition: .1s linear -webkit-box-shadow; + margin-right: 4px; +} + +.topbar-button:active { + box-shadow: 0 0 0 1px rgba(0 ,0 ,0 ,.15) inset, 0 0 6px rgba(0, 0, 0, .2) inset; +} + +.topbar-button:hover { + background: rgba(0, 0, 0, .13); +} + +.topbar-button:focus { + outline: none; +} diff --git a/app/wirc.html b/app/wirc.html new file mode 100644 index 0000000..27dd1db --- /dev/null +++ b/app/wirc.html @@ -0,0 +1,79 @@ + + + + + + + + + + + + +
      + +
      +
      +
      +
      -
      +
      +
        +
        + +
      • +
        +
        -
        +
      • + + + +
      • +
        +
      • + + + +
      • +
        +
        +
        +
        +
        +
      • + +
        + + +
        +
        +
        +
        +

        rooms

        +
        +
        +
        +

        members

        +
        +
        + +
        + +
        +
        + +
        +
        +
        +
        + + + +
        +
        +
        +
        +
        +
        +
        + + diff --git a/app/wirc.js b/app/wirc.js new file mode 100644 index 0000000..349e824 --- /dev/null +++ b/app/wirc.js @@ -0,0 +1,237 @@ +var msgRe = /([^ ]+) (<[^>]+>) (.*)/; +var kibozeRe = /[Nn]eal/; +var urlRe = /[a-z]+:\/\/[^ ]*/; + +var nick = "Mme. M"; + +var scrollbackLength = 500; + +if (String.prototype.startsWith == null) { + String.prototype.startsWith = function(needle) { + return this.lastIndexOf(needle, 0) == 0; + } +} + +function getTemplate(className) { + return document.templates.getElementsByClassName(className)[0].cloneNode(true); +} + +function isinView(oObject) { + return (oObject.offsetParent.clientHeight <= oObject.offsetTop); +} + +function selectForum(room) { + var kids = document.rooms_list.childNodes; + + for (i = 0; i < kids.length; i += 1) { + e = kids[i]; + if (e == room) { + e.className = "room selected"; + e.messages.display = "block"; + } else { + e.className = "room"; + e.messages.display = "none"; + } + } + + if (room.lastChild) { + room.lastChild.scrollIntoView(false); + } +} + +fora = {} +function getForumElement(forum) { + var fe = fora[forum]; + + if (! fe) { + var room = getTemplate("channel room"); + room.textContent = forum; + document.rooms_list.appendChild(room); + + fe = getTemplate("messages"); + fe.room = room; + + room.messages = fe; + // XXX: split out into non-anon function + room.addEventListener("click", function() {selectForum(fe)}); + + fora[forum] = fe; + document.getElementById("messages-container").appendChild(fe); + } + + return fe; +} + +function addMessagePart(p, className, text) { + var e = document.createElement("span"); + e.className = className; + e.appendChild(document.createTextNode(text)); + p.appendChild(e); + p.appendChild(document.createTextNode(" ")); +} + +function addText(p, text, kiboze) { + // Look for a URL + var txtElement = document.createElement("span"); + txtElement.className = "text"; + var rhs = text; + var match; + + while ((match = urlRe.exec(rhs)) != null) { + var before = rhs.substr(0, match.index); + var a = document.createElement("a"); + var href = match[0]; + + if (href.indexOf("hxx") == 0) { + href = "htt" + href.substr(3); + } + a.href = href + a.target = "_blank"; + a.appendChild(document.createTextNode(match[0])); + txtElement.appendChild(document.createTextNode(before)); + txtElement.appendChild(a); + rhs = rhs.substr(match.index + match[0].length); + } + txtElement.appendChild(document.createTextNode(rhs)); + p.appendChild(txtElement); + + if ((kiboze) || (-1 != text.search(kibozeRe))) { + var k = document.getElementById("kiboze"); + var p2 = p.cloneNode(true); + + if (k) { + k.insertBefore(p2, k.firstChild); + p2.onclick = function() { focus(p); } + + // Setting title makes the tab flash sorta + document.title = document.title; + } + } +} + +function focus(e) { + var pct = 1; + var timeout; + + selectForum(e.parentNode); + e.scrollIntoView(false); + e.style.backgroundColor = "yellow"; + + timeout = setInterval(function() { + pct = pct - 0.1; + e.style.backgroundColor = "rgba(255, 255, 0, " + pct + ")"; + if (pct <= 0) { + e.style.backgroundColor = "inherit"; + clearInterval(timeout); + } + }, 50) +} + +function addMessage(txt) { + var lhs = txt.split(" :", 1)[0] + var parts = lhs.split(' ') + var ts = new Date(parts[0] * 1000); + var fullSender = parts[1]; + var command = parts[2]; + var sender = parts[3]; + var forum = parts[4]; + var args = parts.slice(5); + var msg = txt.substr(lhs.length + 2) + + var forumElement = getForumElement(forum); + var p = getTemplate("message"); + + addMessagePart(p, "timestamp", ts.toLocaleTimeString()); + + switch (command) { + case "PING": + case "PONG": + return; + break; + case "PRIVMSG": + addMessagePart(p, "forum", forum); + addMessagePart(p, "sender", sender); + addText(p, msg, (sender == forum)); + break; + case "NOTICE": + addMessagePart(p, "forum", forum); + addMessagePart(p, "sender notice", sender); + addText(p, msg, (sender == forum)); + break; + default: + addMessagePart(p, "forum", forum); + addMessagePart(p, "sender", sender); + addMessagePart(p, "raw", command + " " + args + " " + msg); + break; + } + while (forumElement.childNodes.length > scrollbackLength) { + forumElement.removeChild(forumElement.firstChild) + } + forumElement.appendChild(p); + p.scrollIntoView(false); +} + +function newmsg(oEvent) { + msgs = oEvent.data.split("\n"); + + var first = Math.max(0, msgs.length - scrollbackLength); + for (var i = first; i < msgs.length; i += 1) { + addMessage(msgs[i]); + } +} + +function handleInput(oEvent) { + console.log(oEvent); + var oReq = new XMLHttpRequest(); + function reqListener() { + } + + var txt = oEvent.target.value; + if (txt.startsWith("/connect ")) { + // XXX: should allow tokens with spaces + var parts = txt.split(" "); + + connect(parts[1], parts[2], parts[3]); + } else { + oReq.onload = reqListener; + oReq.open("POST", window.postURL, true); + oReq.send(new FormData(event.target)); + } + + oEvent.target.value = ""; + + return false; +} + +function connect(url, server, authtok) { + document.postURL = url; + var pullURL = url + "?server=" + server + "&auth=" + authtok + + if (document.source != null) { + document.source.close(); + } + document.source = new EventSource(pullURL); + document.source.onmessage = newmsg; + + chrome.storage.sync.set({"connections": [[url, server, authtok]]}); +} + +function restore(items) { + var connections = items["connections"]; + + for (var k = 0; k < connections.length; k += 1) { + var conn = connections[k]; + + connect(conn[0], conn[1], conn[2]); + } +} + +function init() { + chrome.storage.sync.get("connections", restore); + document.getElementById("input").addEventListener("change", handleInput); + + document.templates = document.getElementById("templates"); + document.rooms_list = document.getElementById("rooms-container").getElementsByClassName("rooms")[0]; +} + +window.addEventListener("load", init); diff --git a/app/wirc.png b/app/wirc.png new file mode 100644 index 0000000..372b05c Binary files /dev/null and b/app/wirc.png differ diff --git a/chat.svg b/chat.svg new file mode 100644 index 0000000..f8841d1 --- /dev/null +++ b/chat.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + + 2008-02-19T10:10:56 + + https://openclipart.org/detail/14475/callout-chat-by-ericlemerdy + + + ericlemerdy + + + + + balloon + bubble + callout + speech + + + + + + + + + + + diff --git a/rotate b/rotate new file mode 100755 index 0000000..f88beaa --- /dev/null +++ b/rotate @@ -0,0 +1,7 @@ +#! /bin/sh + +nl=$(date +log.%s) +cp log $nl +tail -n 40 $nl > log + +echo "Don't forget to reload the clients" diff --git a/src/wirc.cgi/wirc.cgi.go b/src/wirc.cgi/wirc.cgi.go new file mode 100644 index 0000000..1bc04a4 --- /dev/null +++ b/src/wirc.cgi/wirc.cgi.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "github.com/go-fsnotify/fsnotify" + "io/ioutil" + "log" + "os" + "bufio" + "strconv" + "strings" + "net/http" + "net/http/cgi" + "time" + "path" +) + +type Handler struct { + cgi.Handler +} + +var ServerDir string + +func ReadString(fn string) string { + octets, err := ioutil.ReadFile(fn) + if err != nil { + log.Fatal(err) + } + return strings.TrimSpace(string(octets)) +} + +func tail(w http.ResponseWriter, pos int64) { + logfn := path.Join(ServerDir, "log") + + f, err := os.Open(logfn) + if err != nil { + log.Fatal(err) + } + defer f.Close() + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + watcher.Add(logfn) + + for { + printid := false + + newpos, err := f.Seek(pos, 0) + if err != nil { + log.Fatal(err) + } + + if newpos < pos { + // File has been truncated! + pos = 0 + f.Seek(0, 0) + } + + bf := bufio.NewScanner(f) + for bf.Scan() { + t := bf.Text() + pos += int64(len(t)) + 1 // XXX: this breaks if we ever see \r\n + fmt.Fprintf(w, "data: %s\n", t) + printid = true + } + if printid { + _, err = fmt.Fprintf(w, "id: %d\n\n", pos) + } + if err != nil { + break + } + w.(http.Flusher).Flush() + + select { + case _ = <-watcher.Events: + // Somethin' happened! + case err := <-watcher.Errors: + log.Fatal(err) + } + } +} + +func handleCommand(w http.ResponseWriter, text string, target string) { + fn := path.Join(ServerDir, fmt.Sprintf("outq/cgi.%d", time.Now().Unix())) + f, err := os.Create(fn) + if err != nil { + fmt.Fprintln(w, "NO") + fmt.Fprintln(w, err) + return + } + defer f.Close() + + switch { + case strings.HasPrefix(text, "/quote "): + fmt.Fprintln(f, text[7:]) + case strings.HasPrefix(text, "/me "): + fmt.Fprintf(f, "PRIVMSG %s :\001ACTION %s\001\n", target, text[4:]) + default: + fmt.Fprintf(f, "PRIVMSG %s :%s\n", target, text) + } + + fmt.Fprintln(w, "OK") +} + + +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + BaseDir := "servers" + DefaultDir := path.Join(BaseDir, "default") + ServerDir = path.Join(BaseDir, r.FormValue("server")) + + if path.Dir(DefaultDir) != path.Dir(ServerDir) { + ServerDir = DefaultDir + } + + authtok := ReadString(path.Join(ServerDir, "authtok")) + if r.FormValue("auth") != authtok { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintln(w, "NO") + return + } + switch r.FormValue("type") { + case "command": + w.Header().Set("Content-Type", "text/plain") + handleCommand(w, r.Form.Get("text"), r.FormValue("target")) + default: + w.Header().Set("Content-Type", "text/event-stream") + id, _ := strconv.ParseInt(os.Getenv("HTTP_LAST_EVENT_ID"), 0, 64) + tail(w, id) + } +} + +func main() { + h := Handler{} + if err := cgi.Serve(h); err != nil { + log.Fatal(err) + } +} + diff --git a/src/wirc/wirc.go b/src/wirc/wirc.go new file mode 100644 index 0000000..8ae72ff --- /dev/null +++ b/src/wirc/wirc.go @@ -0,0 +1,267 @@ +package main + +import ( + "bufio" + "crypto/tls" + "flag" + "fmt" + "log" + "net" + "os" + "strings" + "time" +) + +type Message struct { + Command string + FullSender string + Sender string + Forum string + Args []string + Text string +} + +var running bool = true +var nick string +var gecos string +var logq chan Message + +func isChannel(s string) bool { + if (s == "") { + return false + } + + switch s[0] { + case '#', '&', '!', '+', '.', '-': + return true + default: + return false + } +} + +func (m Message) String() string { + args := strings.Join(m.Args, " ") + return fmt.Sprintf("%s %s %s %s %s :%s", m.FullSender, m.Command, m.Sender, m.Forum, args, m.Text) +} + +func logLoop() { + logf, err := os.OpenFile("log", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + log.Fatal(err) + } + defer logf.Close() + for m := range logq { + fmt.Fprintf(logf, "%d %s\n", time.Now().Unix(), m.String()) + } +} + +func nuhost(s string) (string, string, string) { + var parts []string + + parts = strings.SplitN(s, "!", 2) + if len(parts) == 1 { + return s, "", "" + } + n := parts[0] + parts = strings.SplitN(parts[1], "@", 2) + if len(parts) == 1 { + return s, "", "" + } + return n, parts[0], parts[1] +} + +func connect(host string, dotls bool) (net.Conn, error) { + if dotls { + config := &tls.Config{ + InsecureSkipVerify: true, + } + return tls.Dial("tcp", host, config) + } else { + return net.Dial("tcp", host) + } +} + +func readLoop(conn net.Conn, inq chan<- string) { + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + inq <- scanner.Text() + } + close(inq) +} + +func writeLoop(conn net.Conn, outq <-chan string) { + for v := range outq { + m, _ := parse(v) + logq <- m + fmt.Fprintln(conn, v) + } +} + +func parse(v string) (Message, error) { + var m Message + var parts []string + var lhs string + + parts = strings.SplitN(v, " :", 2) + if len(parts) == 2 { + lhs = parts[0] + m.Text = parts[1] + } else { + lhs = v + m.Text = "" + } + + m.FullSender = "." + m.Forum = "." + m.Sender = "." + + parts = strings.Split(lhs, " ") + if parts[0][0] == ':' { + m.FullSender = parts[0][1:] + parts = parts[1:] + + n, u, _ := nuhost(m.FullSender) + if u != "" { + m.Sender = n + } + } + + m.Command = strings.ToUpper(parts[0]) + switch m.Command { + case "PRIVMSG", "NOTICE": + switch { + case isChannel(parts[1]): + m.Forum = parts[1] + case m.FullSender == ".": + m.Forum = parts[1] + default: + m.Forum = m.Sender + } + case "PART", "MODE", "TOPIC", "KICK": + m.Forum = parts[1] + case "JOIN": + if len(parts) == 1 { + m.Forum = m.Text + m.Text = "" + } else { + m.Forum = parts[1] + } + case "INVITE": + if m.Text != "" { + m.Forum = m.Text + m.Text = "" + } else { + m.Forum = parts[2] + } + case "NICK": + if len(parts) > 1 { + m.Sender = parts[1] + } else { + m.Sender = m.Text + m.Text = "" + } + m.Forum = m.Sender + } + + return m, nil +} + +func dispatch(outq chan<- string, m Message) { + logq <- m + switch m.Command { + case "PING": + outq <- "PONG :" + m.Text + case "433": + nick = nick + "_" + outq <- fmt.Sprintf("NICK %s", nick) + } +} + +func handleInfile(path string, outq chan<- string) { + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + os.Remove(path) + inf := bufio.NewScanner(f) + for inf.Scan() { + txt := inf.Text() + outq <- txt + } +} + +func monitorDirectory(dirname string, dir *os.File, outq chan<- string) { + latest := time.Unix(0, 0) + for running { + fi, err := dir.Stat() + if err != nil { + break + } + current := fi.ModTime() + if current.After(latest) { + latest = current + dn, _ := dir.Readdirnames(0) + for _, fn := range dn { + path := dirname + string(os.PathSeparator) + fn + handleInfile(path, outq) + } + _, _ = dir.Seek(0, 0) + } + time.Sleep(500 * time.Millisecond) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] HOST:PORT\n", os.Args[0]) + flag.PrintDefaults() +} + +func main() { + dotls := flag.Bool("notls", true, "Disable TLS security") + outqdir := flag.String("outq", "outq", "Output queue directory") + flag.StringVar(&gecos, "gecos", "Bob The Merry Slug", "Gecos entry (full name)") + + flag.Parse() + if flag.NArg() != 2 { + fmt.Fprintln(os.Stderr, "Error: must specify nickname and host") + os.Exit(69) + } + + dir, err := os.Open(*outqdir) + if err != nil { + log.Fatal(err) + } + defer dir.Close() + + nick := flag.Arg(0) + host := flag.Arg(1) + + conn, err := connect(host, *dotls) + if err != nil { + log.Fatal(err) + } + + inq := make(chan string) + outq := make(chan string) + logq = make(chan Message) + go logLoop() + go readLoop(conn, inq) + go writeLoop(conn, outq) + go monitorDirectory(*outqdir, dir, outq) + + outq <- fmt.Sprintf("NICK %s", nick) + outq <- fmt.Sprintf("USER %s %s %s: %s", nick, nick, nick, gecos) + for v := range inq { + p, err := parse(v) + if err != nil { + continue + } + dispatch(outq, p) + } + + running = false + + close(outq) + close(logq) +}