forked from devs/v5shoutbox
Compare commits
8 Commits
bcabd9952b
...
35b60ef493
Author | SHA1 | Date |
---|---|---|
mibi88 | 35b60ef493 | |
Lephenixnoir | 8bda9f96a9 | |
Lephenixnoir | 9131c36d63 | |
Lephenixnoir | f8e765018e | |
Lephenixnoir | ca3730b64e | |
Lephenixnoir | c56030b589 | |
Lephenixnoir | e8c9c293df | |
Lephenixnoir | d7e929000f |
|
@ -15,8 +15,10 @@
|
|||
<header>
|
||||
<div class="status"></div>
|
||||
<div class="grow"></div>
|
||||
<div class="channel-buttons form"></div>
|
||||
<a class="show-log" role="button">Log</a>
|
||||
<div class="channel-buttons form">
|
||||
<a class="tab tab-login" role="button" data-channel="\login">login</a>
|
||||
<a class="tab tab-log" role="button" data-channel="\log">log</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="channels">
|
||||
<div data-channel="\login">
|
||||
|
|
69
style.css
69
style.css
|
@ -11,7 +11,9 @@ body {
|
|||
--shoutbox-border-color: rgba(0, 0, 0, .15);
|
||||
--shoutbox-link-color: var(--links, #c61a1a);
|
||||
--shoutbox-tab-activity-color: #ce1919;
|
||||
--shoutbox-tab-current-color: #097ba1;
|
||||
--shoutbox-tab-fg: black;
|
||||
--shoutbox-tab-selected-bg: #808080;
|
||||
--shoutbox-tab-selected-fg: white;
|
||||
--shoutbox-message-date-color: #949494;
|
||||
--shoutbox-message-author-color: #777777;
|
||||
--shoutbox-info: var(--info, #2e7aec);
|
||||
|
@ -20,6 +22,9 @@ body {
|
|||
--shoutbox-warn-text: var(--warn-text, #fff);
|
||||
--shoutbox-ok: var(--ok, #149641);
|
||||
--shoutbox-ok-text: var(--ok-text, #fff);
|
||||
--shoutbox-message-bot-color: gray;
|
||||
--shoutbox-message-bg1: #ffffff;
|
||||
--shoutbox-message-bg2: #f8f8f8;
|
||||
}
|
||||
|
||||
/* Shoutbox-only style */
|
||||
|
@ -62,43 +67,27 @@ body {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#v5shoutbox .channel-buttons button {
|
||||
padding: 1px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
border-radius: 3px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--shoutbox-info);
|
||||
color: var(--shoutbox-info-text);
|
||||
border: var(--border, 1px solid #d8d8d8);
|
||||
#v5shoutbox a.tab {
|
||||
padding: 4px 8px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-family: monospace;
|
||||
color: var(--shoutbox-tab-fg);
|
||||
}
|
||||
|
||||
#v5shoutbox .channel-buttons button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border: 0;
|
||||
#v5shoutbox a.tab:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#v5shoutbox .channel-buttons button:not(:first-child):not(:last-child) {
|
||||
border-radius: 0;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
#v5shoutbox a.tab.current {
|
||||
background: var(--shoutbox-tab-selected-bg);
|
||||
color: var(--shoutbox-tab-selected-fg);
|
||||
}
|
||||
|
||||
#v5shoutbox .channel-buttons button:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#v5shoutbox .channel-buttons *.bg-activity {
|
||||
background-color: var(--shoutbox-warn);
|
||||
color: var(--shoutbox-warn-text);
|
||||
}
|
||||
#v5shoutbox .channel-buttons *.current {
|
||||
background-color: var(--shoutbox-ok);
|
||||
color: var(--shoutbox-ok-text);
|
||||
#v5shoutbox a.tab.bg-activity {
|
||||
color: var(--shoutbox-tab-activity-color);
|
||||
}
|
||||
|
||||
#v5shoutbox .channels {
|
||||
|
@ -132,16 +121,21 @@ body {
|
|||
}
|
||||
|
||||
#v5shoutbox .message {
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
border-top: 1px solid var(--shoutbox-border-color);
|
||||
background: var(--shoutbox-message-bg1);
|
||||
padding: 3px 0 0 0;
|
||||
}
|
||||
|
||||
#v5shoutbox .message:nth-child(2n) {
|
||||
background: var(--shoutbox-message-bg2);
|
||||
}
|
||||
|
||||
#v5shoutbox .message .message-author {
|
||||
margin-right: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--shoutbox-message-author-color);
|
||||
min-width: 70px;
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
@ -153,6 +147,11 @@ body {
|
|||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#v5shoutbox .message[data-author="GLaDOS"] .message-content,
|
||||
#v5shoutbox .message[data-author="Gitea"] .message-content {
|
||||
font-style: italic;
|
||||
color: var(--shoutbox-message-bot-color);
|
||||
}
|
||||
|
||||
#v5shoutbox .message .message-date {
|
||||
color: var(--shoutbox-message-date-color);
|
||||
|
|
663
v5shoutbox.js
663
v5shoutbox.js
|
@ -1,353 +1,140 @@
|
|||
/*
|
||||
# Planète Casio v5 shoutbox
|
||||
|
||||
This script contains all of the client-side logic for the Planète Casio
|
||||
shoutbox. Unlike the legacy v4 shoutbox which was a poll-based HTTP service,
|
||||
this shoutbox is run by an IRC server and this is mostly an IRC client.
|
||||
This script contains all of the interface logic for the Planète Casio shoutbox.
|
||||
It mostly sets up a service worker to use the IRC client and then manages the
|
||||
DOM.
|
||||
|
||||
Support for IRC features is fairly limited as the shoutbox has never been a
|
||||
very fancy chat. For full functionality, use a standard IRC client.
|
||||
The shoutbox as a whole is divided into three modules:
|
||||
- v5shoutbox.js (this file), handling the interface and coordinating pieces.
|
||||
- v5shoutbox_irc.js, which implements the IRC client.
|
||||
- v5shoutbox_worker.js, which is the service worker running the IRC client.
|
||||
|
||||
The one unusual feature in this program is the interaction with the Planète
|
||||
Casio website. This solves an issue of permissions that is difficult to manage
|
||||
with pure IRC. The expectation for the shoutbox is that:
|
||||
- Anyone can read but only community members can write;
|
||||
- It should load immediately when embedded into website pages and allow logged
|
||||
in users to read and write messages with no further interactions.
|
||||
## The service worker
|
||||
|
||||
In the v4's cursed design, the shoutbox was part of the website API, so users
|
||||
logged into their Planète Casio account would be recognized by the shoutbox
|
||||
server (which is just the website) and authenticated by their session cookie.
|
||||
This doesn't work with the IRC server, since the connections are very different
|
||||
and cookies are not a reliable method of authentication. With a standard IRC
|
||||
client, users would need to type in their password to connect to the chat
|
||||
despite already being logged in to the website.
|
||||
One key aspect of the shoutbox is that it tries to run the IRC client in a
|
||||
_service worker_, which is a web worker than runs in the browser's background
|
||||
and can interact with multiple pages on the same site [1]. The purpose of the
|
||||
worker is to share a single connection to the Planète Casio IRC server between
|
||||
all open tabs, avoiding the redundancy of updating each shoutbox separately via
|
||||
different connections.
|
||||
|
||||
The chosen solution is as follows:
|
||||
- Every embedded shoutbox connects to the IRC server as a read-only guest;
|
||||
- Sending a message is done via a website request. The website identifies
|
||||
logged in users by their session cookies and forwards the messages to the
|
||||
IRC server locally;
|
||||
- Messages from the website to the IRC server are attributed to their original
|
||||
authors by whichever nick/plugin trick works best.
|
||||
Because service workers remain active in the background and can be used by
|
||||
multiple pages at once, loading and updating them takes a bit of effort. This
|
||||
results in the complex but well-designed _service worker life cycle_ which is
|
||||
explained very nicely in [2]. Here, we skip the waiting phase by having all
|
||||
versions of the worker claim active shoutboxes because interactions between the
|
||||
shoutbox and the worker are limited in scope and it's reasonably easy to handle
|
||||
the switch.
|
||||
|
||||
Hence, this client supports different connection modes depending on the
|
||||
authentication setup: normal IRC (with password), PCv4, PCv5.
|
||||
(WIP at time of writing)
|
||||
[1] https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
|
||||
[2] https://web.dev/service-worker-lifecycle/
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
import * as IRC from "./v5shoutbox_irc.js";
|
||||
|
||||
/* WebSocket IRC with HTTP-based website interface. */
|
||||
let irc = new function() {
|
||||
/* IRC-backed chat running in a shared context (service worker) if possible,
|
||||
with a default to a local client. This class also provides nice APIs and
|
||||
callbacks around the message-passing interface of the IRC client. */
|
||||
class SharedChat {
|
||||
|
||||
const State = Object.freeze({
|
||||
/* Websocket is disconnected. */
|
||||
DISCONNECTED: Symbol("DISCONNECTED"),
|
||||
/* We just opened a socket to the IRC server and have yet to register. */
|
||||
CONNECTING: Symbol("CONNECTING"),
|
||||
/* We are authenticated and ready to use the server. */
|
||||
READY: Symbol("READY"),
|
||||
});
|
||||
/* Registers the service worker that runs the IRC client. Waits for
|
||||
registration to complete and returns true on success, false on error.
|
||||
If successful, sets up the onControllerChanged callback. */
|
||||
async registerServiceWorker() {
|
||||
if(!("serviceWorker" in navigator)) {
|
||||
console.warn("No service workers, shoutbox will use per-tab IRC client");
|
||||
return false;
|
||||
}
|
||||
|
||||
let conn = {
|
||||
/* IRC server's websocket URL (wss://...). */
|
||||
serverUrl: undefined,
|
||||
/* The websocket connected to serverUrl */
|
||||
socket: undefined,
|
||||
/* HTTP-based message post URL. Undefined when using pure IRC, or a v4/v5
|
||||
URL when using website authentication. */
|
||||
postUrl: undefined,
|
||||
/* Current connection state. */
|
||||
state: State.DISCONNECTED,
|
||||
/* Username with which we are logged in. Undefined when DISCONNECTED. */
|
||||
username: undefined,
|
||||
/* Current channel. This remains defined even when DISCONNECTED. */
|
||||
channel: undefined,
|
||||
/* Password for connection. Erased after authenticating. */
|
||||
password: undefined,
|
||||
/* Capabilities enabled. */
|
||||
caps: [],
|
||||
/* Call the update method once if the worker was already there */
|
||||
if(navigator.serviceWorker.controller !== null)
|
||||
this.onControllerChanged();
|
||||
|
||||
/* Then call it after first install and every update */
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
this.onControllerChanged();
|
||||
});
|
||||
|
||||
navigator.serviceWorker.addEventListener("message", (e) => {
|
||||
this.onMessage(e);
|
||||
});
|
||||
|
||||
return navigator.serviceWorker.register(
|
||||
"v5shoutbox_worker.js", { scope: "./" })
|
||||
.then((registration) => {
|
||||
console.log(registration);
|
||||
return true;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Service worker registration failed with ${error}`);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
let batches = new Map();
|
||||
|
||||
/* List of channels and their information */
|
||||
this.channels = new Map();
|
||||
|
||||
/*** Accessors/mutators ***/
|
||||
|
||||
this.isConnected = function() {
|
||||
return conn.state !== State.DISCONNECTED;
|
||||
}
|
||||
this.isRunning = function() {
|
||||
return conn.state === State.READY;
|
||||
}
|
||||
this.stateString = function() {
|
||||
switch(conn.state) {
|
||||
case State.DISCONNECTED: return "Disconnected";
|
||||
case State.CONNECTED: return "Connecting...";
|
||||
case State.READY: return "Connected";
|
||||
}
|
||||
/* Handler called when the page is taken over by a service worker. Usually
|
||||
the worker manages the page immediately when it starts loading, but this
|
||||
also happens after the first install and during live worker updates. */
|
||||
onControllerChanged() {
|
||||
console.log("onControllerChanged!");
|
||||
console.log(navigator.serviceWorker);
|
||||
}
|
||||
|
||||
this.currentChannel = function() {
|
||||
return conn.channel;
|
||||
}
|
||||
this.selectChannel = function(name) {
|
||||
conn.channel = name;
|
||||
}
|
||||
this.isOnRemoteChannel = function() {
|
||||
const c = conn.channel;
|
||||
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
|
||||
// TODO: Get this via communication
|
||||
getClientState() {
|
||||
if(this.irc == null) {
|
||||
console.error("todo");
|
||||
return null;
|
||||
}
|
||||
else
|
||||
return new IRC.ClientState(this.irc);
|
||||
}
|
||||
|
||||
/*** Overridable hooks and callbacks ***/
|
||||
async init() {
|
||||
const ok = await this.registerServiceWorker(() => {
|
||||
});
|
||||
console.log("SharedChat service worker registration:", ok);
|
||||
|
||||
this.onLog = function(message) {};
|
||||
this.onStateChanged = function() {};
|
||||
this.onAuthenticated = function() {};
|
||||
this.onNewMessage = function(channel, date, author, message) {};
|
||||
this.onChannelChanged = function(channel) {};
|
||||
|
||||
function log(...args) {
|
||||
irc.onLog(...args);
|
||||
}
|
||||
|
||||
/*** Connection management ***/
|
||||
|
||||
function setState(state) {
|
||||
conn.state = state;
|
||||
irc.onStateChanged();
|
||||
}
|
||||
|
||||
this.connect = function(serverUrl, postUrl, username, password) {
|
||||
conn.serverUrl = serverUrl;
|
||||
conn.postUrl = postUrl;
|
||||
conn.socket = new WebSocket(conn.serverUrl);
|
||||
log("[v5shoutbox] Connecting...");
|
||||
|
||||
conn.socket.onopen = function() {
|
||||
log("[v5shoutbox] Connected.");
|
||||
setState(State.CONNECTED);
|
||||
conn.username = username;
|
||||
conn.password = password;
|
||||
startExchange();
|
||||
}
|
||||
conn.socket.onmessage = function(net) {
|
||||
log("[<] " + net.data);
|
||||
let msg = new Message(net.data);
|
||||
if(msg.command === undefined) {
|
||||
log("[v5shoutbox] invalid message");
|
||||
return;
|
||||
}
|
||||
processMessage(msg);
|
||||
}
|
||||
conn.socket.onclose = function() {
|
||||
log("[v5shoutbox] Disconnected.");
|
||||
setState(State.DISCONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
function sendRaw(command) {
|
||||
log("[>] " + command);
|
||||
conn.socket.send(command);
|
||||
}
|
||||
|
||||
function send(command, args) {
|
||||
if(args.slice(0,-1).some(x => x.includes(" ")))
|
||||
return log("invalid spaces in non-last args: " + command.toString() +
|
||||
" " + args.toString());
|
||||
|
||||
if(args.length && args[args.length - 1].includes(" "))
|
||||
args[args.length - 1] = ":" + args[args.length - 1];
|
||||
|
||||
sendRaw(command + " " + args.join(" "));
|
||||
}
|
||||
|
||||
/*** IRC protocol ***/
|
||||
|
||||
/* Regex for parsing messages */
|
||||
const rMessage = (function(){
|
||||
const rTags = /(?:@([^ ]+) +)?/;
|
||||
const rSource = /(?::([^ ]+) +)?/;
|
||||
const rCommand = /(?:([a-zA-Z]+)|(\d{3}))/;
|
||||
const rArgs = /((?: +[^\r\n :][^\r\n ]*)*)/;
|
||||
const rTrailing = /(?: +:([^\r\n]*))?/;
|
||||
return new RegExp([/^/, rTags, rSource, rCommand, rArgs, rTrailing, /$/]
|
||||
.map(r => r.source).join(""));
|
||||
})();
|
||||
|
||||
/* https://stackoverflow.com/questions/30106476 */
|
||||
function base64_encode(str) {
|
||||
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
|
||||
function toSolidBytes(match, p1) {
|
||||
return String.fromCharCode('0x' + p1);
|
||||
}));
|
||||
}
|
||||
|
||||
class Message {
|
||||
constructor(text) {
|
||||
const matches = rMessage.exec(text);
|
||||
if(matches === undefined)
|
||||
return;
|
||||
|
||||
this.tags = new Map();
|
||||
(matches[1] || "").split(";").filter(s => s).forEach(s => {
|
||||
const parts = s.split(/=(.*)/);
|
||||
const value = (parts[1] || "").replaceAll("\\\\", "\\")
|
||||
.replaceAll("\\:", ";")
|
||||
.replaceAll("\\r", "\r")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll(/\\./g, m => m[1]);
|
||||
this.tags.set(parts[0], value);
|
||||
});
|
||||
|
||||
this.source = matches[2];
|
||||
this.command = matches[3] || parseInt(matches[4]);
|
||||
this.args = (matches[5] || "").split(" ").filter(s => s);
|
||||
if(matches[6] !== undefined)
|
||||
this.args.push(matches[6]);
|
||||
}
|
||||
}
|
||||
this.Message = Message;
|
||||
|
||||
function startExchange() {
|
||||
sendRaw("CAP LS 302");
|
||||
send("NICK", [conn.username]);
|
||||
send("USER", [conn.username, "*", "0", conn.username]);
|
||||
}
|
||||
|
||||
function processMessage(msg) {
|
||||
if(msg.command === "PING") {
|
||||
send("PONG", msg.args);
|
||||
return;
|
||||
}
|
||||
if(msg.command === "BATCH") {
|
||||
const [mode, id] = [msg.args[0][0], msg.args[0].substring(1)];
|
||||
if(mode == "+")
|
||||
batches.set(id, []);
|
||||
batches.get(id).push(msg);
|
||||
if(mode == "-") {
|
||||
processBatch(batches.get(id));
|
||||
batches.delete(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(msg.tags.has("batch")) {
|
||||
batches.get(msg.tags.get("batch")).push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if(msg.command === "CAP" && msg.args[1] === "LS") {
|
||||
const caps = msg.args[2].split(" ").filter(x => x);
|
||||
const wishlist =
|
||||
["message-tags", "server-time", "echo-message",
|
||||
"batch", "sasl", "draft/chathistory"];
|
||||
|
||||
let req = wishlist.filter(x => caps.includes(x));
|
||||
if(caps.some(x => x.startsWith("sasl=")))
|
||||
req.push("sasl");
|
||||
|
||||
if(req.length)
|
||||
send("CAP", ["REQ", req.join(" ")]);
|
||||
else {
|
||||
conn.caps = [];
|
||||
sendRaw("CAP END");
|
||||
}
|
||||
}
|
||||
|
||||
if(msg.command === "CAP" && msg.args[1] === "ACK") {
|
||||
conn.caps = msg.args[2].split(" ").filter(x => x);
|
||||
if(conn.caps.includes("sasl"))
|
||||
sendRaw("AUTHENTICATE PLAIN");
|
||||
else
|
||||
sendRaw("CAP END");
|
||||
}
|
||||
if(msg.command === "AUTHENTICATE" && msg.args[0] === "+") {
|
||||
const arg = conn.username + "\0" + conn.username + "\0" + conn.password;
|
||||
const b64 = base64_encode(arg);
|
||||
log("[v5shoutbox] AUTHENTICATE command sent (not shown)");
|
||||
conn.socket.send("AUTHENTICATE " + b64);
|
||||
}
|
||||
|
||||
/* Default authentication if SASL is unavailable or fails */
|
||||
if(msg.command === "NOTICE" && msg.args[1].includes("/AUTH")) {
|
||||
log("[v5shoutbox] AUTH command sent (not shown)");
|
||||
conn.socket.send("AUTH " + conn.username + ":" + conn.password);
|
||||
}
|
||||
|
||||
if(msg.command === 900) {
|
||||
log("[v5shoutbox] Authenticated.");
|
||||
if(conn.caps.includes("sasl"))
|
||||
sendRaw("CAP END");
|
||||
setState(State.READY);
|
||||
conn.password = undefined;
|
||||
irc.onAuthenticated();
|
||||
|
||||
if(conn.caps.includes("draft/chathistory")) {
|
||||
["#annonces", "#projets", "#hs"].forEach(channel => {
|
||||
send("CHATHISTORY", ["LATEST", channel, "*", "100"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if(msg.command === "JOIN" && msg.tags.get("account") === conn.username) {
|
||||
irc.channels.set(msg.args[0], {});
|
||||
irc.onChannelChanged(msg.args[0]);
|
||||
}
|
||||
if(msg.command === 332) {
|
||||
irc.channels.get(msg.args[1]).header = msg.args[2];
|
||||
irc.onChannelChanged(msg.args[1]);
|
||||
}
|
||||
if(msg.command === 353 && msg.args[1] === "=") {
|
||||
irc.channels.get(msg.args[2]).clients = msg.args[3].split(" ");
|
||||
irc.onChannelChanged(msg.args[2]);
|
||||
}
|
||||
|
||||
if(msg.command === "PRIVMSG")
|
||||
processPRIVMSG(msg);
|
||||
}
|
||||
|
||||
function processBatch(batch) {
|
||||
const batchArgs = batch[0].args.slice(1);
|
||||
if(batchArgs.length === 2 && batchArgs[0] === "chathistory") {
|
||||
batch.forEach(msg => {
|
||||
if(msg.command === "PRIVMSG")
|
||||
processPRIVMSG(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function processPRIVMSG(msg) {
|
||||
if(msg.args.length == 2) {
|
||||
let source = msg.source;
|
||||
if(source.includes("!"))
|
||||
source = source.substr(0, source.indexOf("!"));
|
||||
let date = new Date();
|
||||
if(msg.tags.has("time"))
|
||||
date = new Date(msg.tags.get("time"));
|
||||
irc.onNewMessage(msg.args[0], date, source, msg.args[1]);
|
||||
}
|
||||
}
|
||||
|
||||
this.postMessage = function(channel, message) {
|
||||
if(message.startsWith("/")) {
|
||||
sendRaw(message.substring(1));
|
||||
}
|
||||
if(ok && false)
|
||||
this.irc = null;
|
||||
else {
|
||||
send("PRIVMSG", [channel, message]);
|
||||
if(!conn.caps.includes("echo-message"))
|
||||
irc.onNewMessage(channel, new Date(), conn.username, message);
|
||||
this.irc = new IRC.Client("wss://irc.planet-casio.com:443", undefined);
|
||||
this.irc.onMessage = (msg) => this.onMessage(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** API for sending messages to the IRC client **/
|
||||
|
||||
/* Send a message to the IRC client */
|
||||
remoteCall(e) {
|
||||
if(this.irc == null)
|
||||
navigator.serviceWorker.controller.postMessage(e);
|
||||
else
|
||||
this.irc.remoteCall(e);
|
||||
}
|
||||
|
||||
connect(login, password) { this.remoteCall({
|
||||
type: "connect",
|
||||
login: login,
|
||||
password: password,
|
||||
})}
|
||||
|
||||
post(channel, message) { this.remoteCall({
|
||||
type: "post",
|
||||
channel: channel,
|
||||
message: message,
|
||||
})}
|
||||
|
||||
/** Handlers for getting messages from the IRC client **/
|
||||
|
||||
/* Handler called when a message is received from the worker. */
|
||||
onMessage(e) {}
|
||||
}
|
||||
|
||||
/* Shoutbox entry point and DOM manipulation. */
|
||||
let shoutbox = new function() {
|
||||
|
||||
const serverUrl = "wss://irc.planet-casio.com:443";
|
||||
const postUrl = undefined;
|
||||
const availableChannels = ["#annonces", "#projets", "#hs"];
|
||||
|
||||
/* Original tab title */
|
||||
|
@ -356,41 +143,50 @@ let shoutbox = new function() {
|
|||
this.focused = true;
|
||||
/* Number of messages received since last losing focus */
|
||||
this.newMessages = 0;
|
||||
/* Current channel */
|
||||
this.channel = undefined;
|
||||
/* Last observed client state */
|
||||
this.clientState = undefined;
|
||||
|
||||
this.init = function(root) {
|
||||
this.init = function(root, chat) {
|
||||
this.title = document.title;
|
||||
this.chat = chat;
|
||||
this.chat.onMessage = (e) => this.onMessage(e);
|
||||
|
||||
/* Channel views */
|
||||
this.eChannels = root.querySelector(".channels");
|
||||
this.eChannelButtons = root.querySelector(".channel-buttons");
|
||||
this.eLoginTab = root.querySelector(".tab-login");
|
||||
/* Debugging tools */
|
||||
this.eLog = root.querySelector(".log");
|
||||
this.eStatus = root.querySelector(".status");
|
||||
/* Elements of the login form */
|
||||
this.eLoginForm = root.querySelector(".login-form");
|
||||
this.eConnect = root.querySelector(".connect");
|
||||
this.eLogin = root.querySelector(".login");
|
||||
this.ePassword = root.querySelector(".password");
|
||||
/* Elements of the shoutbox form */
|
||||
this.eShoutboxForm = root.querySelector(".shoutbox-form");
|
||||
this.eMessage = root.querySelector(".message");
|
||||
|
||||
/* Do NOT submit a form and reload the page, even if the script fails,
|
||||
thank you. I like to debug things. */
|
||||
this.eLoginForm.action = "javascript:void(0)";
|
||||
this.eShoutboxForm.action = "javascript:void(0)";
|
||||
|
||||
this.eLoginForm.addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
shoutbox.connect(serverUrl, postUrl);
|
||||
shoutbox.connect();
|
||||
});
|
||||
this.eShoutboxForm.addEventListener("submit", e => {
|
||||
e.preventDefault();
|
||||
shoutbox.post();
|
||||
});
|
||||
this.eChannelButtons.querySelectorAll("button").forEach(b => {
|
||||
b.addEventListener("click", () => {
|
||||
shoutbox.selectChannel(b.dataset.channel);
|
||||
});
|
||||
});
|
||||
root.querySelector(".show-log").addEventListener("click", () => {
|
||||
root.querySelector(".tab-log").addEventListener("click", () => {
|
||||
this.selectChannel("\\log");
|
||||
});
|
||||
root.querySelector(".tab-login").addEventListener("click", () => {
|
||||
this.selectChannel("\\login");
|
||||
});
|
||||
|
||||
window.addEventListener("focus", () => {
|
||||
shoutbox.focused = true;
|
||||
|
@ -401,22 +197,48 @@ let shoutbox = new function() {
|
|||
shoutbox.focused = false;
|
||||
});
|
||||
|
||||
this.clientState = this.chat.getClientState();
|
||||
console.log(this.clientState);
|
||||
this.selectChannel("\\login");
|
||||
this.refreshView();
|
||||
}
|
||||
|
||||
/*** IRC callbacks ***/
|
||||
|
||||
this.onMessage = function(e) {
|
||||
/* Update known client state based on info bundled with the message */
|
||||
if(e.state !== undefined)
|
||||
this.clientState = e.state;
|
||||
|
||||
if(e.type == "log") {
|
||||
this.log(e.text);
|
||||
}
|
||||
else if(e.type == "state_changed") {
|
||||
console.log(e);
|
||||
this.refreshView();
|
||||
}
|
||||
else if(e.type == "authenticated") {
|
||||
this.ePassword.value = "";
|
||||
}
|
||||
else if(e.type == "new_message") {
|
||||
this.onNewMessageCallback(e.channel, e.date, e.author, e.message);
|
||||
}
|
||||
else if(e.type == "channel_changed") {
|
||||
for(const [channel, info] of this.clientState.channels) {
|
||||
this.createChannel(channel);
|
||||
if(this.channel === channel)
|
||||
this.refreshView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
/* TODO: Use a better text element x) */
|
||||
this.eLog.innerHTML += message + "\n";
|
||||
/* Most consistent escaping method */
|
||||
const span = document.createElement("span");
|
||||
span.textContent = message + "\n";
|
||||
this.eLog.appendChild(span);
|
||||
}
|
||||
this.log = log.bind(this);
|
||||
irc.onLog = log.bind(this);
|
||||
|
||||
irc.onAuthenticated = function() {
|
||||
this.ePassword.value = "";
|
||||
}.bind(this);
|
||||
|
||||
this.refreshTitle = function() {
|
||||
if(this.newMessages === 0)
|
||||
|
@ -425,20 +247,33 @@ let shoutbox = new function() {
|
|||
document.title = "(" + this.newMessages.toString() + ") " + this.title;
|
||||
}
|
||||
|
||||
this.isOnRemoteChannel = function() {
|
||||
const c = this.channel;
|
||||
return (c !== undefined) && (c.startsWith("&") || c.startsWith("#"));
|
||||
}
|
||||
|
||||
this.refreshView = function() {
|
||||
const running = irc.isRunning();
|
||||
if(irc.isOnRemoteChannel()) {
|
||||
const c = irc.currentChannel();
|
||||
this.eStatus.textContent = c + ": " + irc.channels.get(c).header;
|
||||
const st = this.clientState;
|
||||
const running = st.isRunning();
|
||||
if(this.isOnRemoteChannel()) {
|
||||
const c = this.channel;
|
||||
this.eStatus.textContent = ": " + st.channels.get(c).header;
|
||||
let code = document.createElement("code");
|
||||
code.appendChild(document.createTextNode(c));
|
||||
this.eStatus.insertBefore(code, this.eStatus.childNodes[0]);
|
||||
}
|
||||
else {
|
||||
this.eStatus.textContent = irc.stateString();
|
||||
this.eStatus.textContent = st.stateString();
|
||||
}
|
||||
this.eShoutboxForm.style.display = running ? "flex" : "none";
|
||||
|
||||
const name = irc.currentChannel();
|
||||
for(const e of this.eChannels.children)
|
||||
e.style.display = (e.dataset.channel === name) ? "flex" : "none";
|
||||
this.eShoutboxForm.style.display = running ? "flex" : "none";
|
||||
this.eLoginTab.style.display = running ? "none" : "inline-block";
|
||||
|
||||
const name = this.channel;
|
||||
for(const e of this.eChannels.children) {
|
||||
const visible_mode = (e.dataset.channel == "\\log" ? "block" : "flex");
|
||||
e.style.display = (e.dataset.channel === name) ? visible_mode : "none";
|
||||
}
|
||||
for(const b of this.eChannelButtons.children) {
|
||||
if(b.dataset.channel === name)
|
||||
b.classList.add("current");
|
||||
|
@ -446,28 +281,19 @@ let shoutbox = new function() {
|
|||
b.classList.remove("current");
|
||||
}
|
||||
};
|
||||
irc.onStateChanged = this.refreshView.bind(this);
|
||||
|
||||
irc.onNewMessage = function(channel, date, author, message) {
|
||||
this.onNewMessageCallback = function(channel, date, author, message) {
|
||||
shoutbox.addNewMessage(channel, date, author, message);
|
||||
|
||||
if(channel === irc.currentChannel() && !shoutbox.focused) {
|
||||
if(channel === this.channel && !shoutbox.focused) {
|
||||
shoutbox.newMessages++;
|
||||
shoutbox.refreshTitle();
|
||||
}
|
||||
if(channel !== irc.currentChannel()) {
|
||||
if(channel !== this.channel) {
|
||||
shoutbox.setChannelBackgroundActivity(channel, true);
|
||||
}
|
||||
}
|
||||
|
||||
irc.onChannelChanged = function(channel) {
|
||||
for(const [channel, info] of irc.channels) {
|
||||
this.createChannel(channel);
|
||||
if(irc.currentChannel() === channel)
|
||||
this.refreshView();
|
||||
}
|
||||
}.bind(this);
|
||||
|
||||
/*** DOM manipulation ***/
|
||||
|
||||
this.getChannelView = function(channel) {
|
||||
|
@ -480,6 +306,19 @@ let shoutbox = new function() {
|
|||
.find(e => e.dataset.channel === channel);
|
||||
}
|
||||
|
||||
this.addChannelButton = function(button) {
|
||||
/* Insert new tab button before special tabs */
|
||||
let firstSpecial = Array.from(this.eChannelButtons.children).find(e =>
|
||||
e.dataset.channel.startsWith("\\")) || null;
|
||||
this.eChannelButtons.insertBefore(button, firstSpecial);
|
||||
}
|
||||
|
||||
this.clearChannelButtons = function(button) {
|
||||
let special = Array.from(this.eChannelButtons.children).filter(e =>
|
||||
e.dataset.channel.startsWith("\\"));
|
||||
this.eChannelButtons.replaceChildren(special);
|
||||
}
|
||||
|
||||
this.createChannel = function(channel) {
|
||||
if(!availableChannels.includes(channel))
|
||||
return;
|
||||
|
@ -492,19 +331,88 @@ let shoutbox = new function() {
|
|||
view.style.display = "none";
|
||||
this.eChannels.appendChild(view);
|
||||
|
||||
let button = document.createElement("button");
|
||||
let button = document.createElement("a");
|
||||
button.classList.add("tab");
|
||||
button.appendChild(document.createTextNode(channel));
|
||||
button.dataset.channel = channel;
|
||||
this.eChannelButtons.appendChild(button);
|
||||
this.addChannelButton(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
shoutbox.selectChannel(button.dataset.channel);
|
||||
});
|
||||
|
||||
if(irc.currentChannel() === "\\login")
|
||||
if(this.channel === "\\login")
|
||||
shoutbox.selectChannel(channel);
|
||||
}
|
||||
|
||||
this.messageToNode = function(message, element) {
|
||||
const rURL = /(https?:\/\/|ftp:\/\/|magnet:)/d;
|
||||
const fURL = (match) => {
|
||||
/* We've found the protocol, now read the arguments until whitespace or
|
||||
unbalanced closing parenthesis */
|
||||
let i = match.indices[0][1];
|
||||
let par_depth = 0;
|
||||
|
||||
while(i < match.input.length && !/\s/.test(match.input[i])) {
|
||||
par_depth += (match.input[i] == "(");
|
||||
par_depth -= (match.input[i] == ")");
|
||||
if(par_depth < 0)
|
||||
break;
|
||||
i++;
|
||||
}
|
||||
|
||||
/* Don't count the last character if it's a quote or comma */
|
||||
if(i > 0 && /[",]/.test(match.input[i-1]))
|
||||
i--;
|
||||
|
||||
const url = match.input.substring(match.indices[0][0], i);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.target = "_blank";
|
||||
a.appendChild(document.createTextNode(url));
|
||||
element.appendChild(a);
|
||||
return url.length;
|
||||
};
|
||||
|
||||
/* List of matchers: regex, handling function, match object, index. */
|
||||
let matchers = [
|
||||
[rURL, fURL, null, -1],
|
||||
];
|
||||
|
||||
/* Repeatedly find the next segment to convert. */
|
||||
let i = 0;
|
||||
while(i < message.length) {
|
||||
let next = message.length;
|
||||
let next_matcher = null;
|
||||
|
||||
/* Update the next matches for all regexes and find the one that matches
|
||||
the earliest. */
|
||||
for(const m of matchers) {
|
||||
if(m[3] < 0) {
|
||||
m[0].lastIndex = 0;
|
||||
m[2] = m[0].exec(message.substring(i));
|
||||
m[3] = (m[2] !== null) ? i + m[2].index : message.length;
|
||||
}
|
||||
if(m[3] >= 0 && m[3] < next) {
|
||||
next = m[3];
|
||||
next_matcher = m;
|
||||
}
|
||||
}
|
||||
|
||||
/* Find the closest one. If it's not at offset 0, do a text node. */
|
||||
if(next > i) {
|
||||
const sub = message.substring(i, next);
|
||||
element.appendChild(document.createTextNode(sub));
|
||||
i = next;
|
||||
}
|
||||
if(next_matcher !== null) {
|
||||
i += next_matcher[1](next_matcher[2]);
|
||||
next_matcher[2] = null;
|
||||
next_matcher[3] = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.addNewMessage = function(channel, date, author, message) {
|
||||
const view = this.getChannelView(channel);
|
||||
if(view === undefined)
|
||||
|
@ -516,13 +424,14 @@ let shoutbox = new function() {
|
|||
|
||||
let messageElement = document.createElement("div");
|
||||
messageElement.classList.add("message");
|
||||
messageElement.dataset.author = author;
|
||||
let authorElement = document.createElement("div");
|
||||
authorElement.appendChild(document.createTextNode(author));
|
||||
authorElement.classList.add("message-author");
|
||||
messageElement.appendChild(authorElement);
|
||||
let messageContentElement = document.createElement("p");
|
||||
messageContentElement.classList.add("message-content");
|
||||
messageContentElement.appendChild(document.createTextNode(message));
|
||||
this.messageToNode(message, messageContentElement);
|
||||
messageElement.appendChild(messageContentElement);
|
||||
let dateElement = document.createElement("div");
|
||||
dateElement.classList.add("message-date");
|
||||
|
@ -543,19 +452,19 @@ let shoutbox = new function() {
|
|||
|
||||
/*** User interactions ***/
|
||||
|
||||
this.connect = function(serverUrl, postUrl) {
|
||||
if(irc.isConnected())
|
||||
this.connect = function() {
|
||||
if(this.clientState.isConnected())
|
||||
return this.log("Already connected!");
|
||||
if(this.eLogin.value === "" || this.ePassword.value === "")
|
||||
return this.log("Need login/password to connect!");
|
||||
if(this.eLogin.value.includes(" "))
|
||||
return this.log("Login should not contain a space!");
|
||||
|
||||
irc.connect(serverUrl, postUrl, this.eLogin.value, this.ePassword.value);
|
||||
this.chat.connect(this.eLogin.value, this.ePassword.value);
|
||||
}
|
||||
|
||||
this.selectChannel = function(name) {
|
||||
irc.selectChannel(name);
|
||||
this.channel = name;
|
||||
this.setChannelBackgroundActivity(name, false);
|
||||
this.refreshView();
|
||||
}
|
||||
|
@ -563,16 +472,26 @@ let shoutbox = new function() {
|
|||
this.post = function() {
|
||||
if(this.eMessage.value === "")
|
||||
return;
|
||||
if(!irc.isRunning())
|
||||
if(!this.clientState.isRunning())
|
||||
return log("Cannot send message while not connected!");
|
||||
if(!irc.isOnRemoteChannel())
|
||||
if(!this.isOnRemoteChannel())
|
||||
return log("Cannot send message as no channel is selected!");
|
||||
|
||||
irc.postMessage(irc.currentChannel(), this.eMessage.value);
|
||||
this.chat.post(this.channel, this.eMessage.value);
|
||||
this.eMessage.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
shoutbox.init(document.getElementById("v5shoutbox"));
|
||||
/* We initialize the shoutbox once the SharedChat has finished its async init
|
||||
*and* the DOMContentLoaded even has been fired. */
|
||||
|
||||
let sc = new SharedChat();
|
||||
const sc_init_promise = sc.init();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
sc_init_promise.then(() => {
|
||||
shoutbox.init(document.getElementById("v5shoutbox"), sc);
|
||||
});
|
||||
});
|
||||
|
||||
await sc_init_promise;
|
||||
|
|
|
@ -0,0 +1,377 @@
|
|||
/*
|
||||
# WebSocket IRC with HTTP-based website interface
|
||||
|
||||
This script is the v5 shoutbox IRC client. Unlike the legacy v4 shoutbox which
|
||||
was a poll-based HTTP service, this shoutbox is run by an IRC server, hence the
|
||||
shoutbox is powered by this IRC client.
|
||||
|
||||
Support for IRC features is fairly limited as the shoutbox has never been a
|
||||
very fancy chat. For full functionality, use a standard IRC client.
|
||||
|
||||
|
||||
## Posting via HTTP to use the Planète Casio session cookie
|
||||
|
||||
The one unusual feature in this program is the interaction with the Planète
|
||||
Casio website. This solves an issue of permissions that is difficult to manage
|
||||
with pure IRC. The expectation for the shoutbox is that:
|
||||
- Anyone can read but only community members can write;
|
||||
- It should load immediately when embedded into website pages and allow logged
|
||||
in users to read and write messages with no further interactions.
|
||||
|
||||
In the v4's cursed design, the shoutbox was part of the website API, so users
|
||||
logged into their Planète Casio account would be recognized by the shoutbox
|
||||
server (which is just the website) and authenticated by their session cookie.
|
||||
This doesn't work with the IRC server, since the connections are very different
|
||||
and cookies are not a reliable method of authentication. With a standard IRC
|
||||
client, users would need to type in their password to connect to the chat
|
||||
despite already being logged in to the website.
|
||||
|
||||
The chosen solution is as follows:
|
||||
- Every embedded shoutbox connects to the IRC server as a read-only guest;
|
||||
- Sending a message is done via a website request. The website identifies
|
||||
logged in users by their session cookies and forwards the messages to the
|
||||
IRC server locally;
|
||||
- Messages from the website to the IRC server are attributed to their original
|
||||
authors by whichever nick/plugin trick works best.
|
||||
|
||||
(WIP at time of writing)
|
||||
|
||||
|
||||
## Message protocol
|
||||
|
||||
This IRC client usually runs inside of a service worker to that it can be
|
||||
shared between any number of open Planète Casio tabs. Thus, communication
|
||||
between this client and the user interface uses a message-based interface,
|
||||
which is intended to work like RPC.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
class IRCMessage {
|
||||
/* Regex for parsing messages */
|
||||
static #rMessage = (function(){
|
||||
const rTags = /(?:@([^ ]+) +)?/;
|
||||
const rSource = /(?::([^ ]+) +)?/;
|
||||
const rCommand = /(?:([a-zA-Z]+)|(\d{3}))/;
|
||||
const rArgs = /((?: +[^\r\n :][^\r\n ]*)*)/;
|
||||
const rTrailing = /(?: +:([^\r\n]*))?/;
|
||||
return new RegExp([/^/, rTags, rSource, rCommand, rArgs, rTrailing, /$/]
|
||||
.map(r => r.source).join(""));
|
||||
})();
|
||||
|
||||
constructor(text) {
|
||||
const matches = IRCMessage.#rMessage.exec(text);
|
||||
if(matches === undefined)
|
||||
return;
|
||||
|
||||
this.tags = new Map();
|
||||
(matches[1] || "").split(";").filter(s => s).forEach(s => {
|
||||
const parts = s.split(/=(.*)/);
|
||||
const value = (parts[1] || "").replaceAll("\\\\", "\\")
|
||||
.replaceAll("\\:", ";")
|
||||
.replaceAll("\\r", "\r")
|
||||
.replaceAll("\\n", "\n")
|
||||
.replaceAll(/\\./g, m => m[1]);
|
||||
this.tags.set(parts[0], value);
|
||||
});
|
||||
|
||||
this.source = matches[2];
|
||||
this.command = matches[3] || parseInt(matches[4]);
|
||||
this.args = (matches[5] || "").split(" ").filter(s => s);
|
||||
if(matches[6] !== undefined)
|
||||
this.args.push(matches[6]);
|
||||
}
|
||||
}
|
||||
|
||||
const State = Object.freeze({
|
||||
/* Websocket is disconnected. */
|
||||
DISCONNECTED: Symbol("DISCONNECTED"),
|
||||
/* We just opened a socket to the IRC server and have yet to register. */
|
||||
CONNECTING: Symbol("CONNECTING"),
|
||||
/* We are authenticated and ready to use the server. */
|
||||
READY: Symbol("READY"),
|
||||
});
|
||||
|
||||
/* https://stackoverflow.com/questions/30106476 */
|
||||
function base64_encode(str) {
|
||||
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
|
||||
function toSolidBytes(match, p1) {
|
||||
return String.fromCharCode('0x' + p1);
|
||||
}));
|
||||
}
|
||||
|
||||
/* Clonable IRC state passed to user with most messages. Users keep a copy of
|
||||
this object around so they can display the state without communicating with
|
||||
the IRC client (which is generally in another thread) all the time. */
|
||||
export class ClientState {
|
||||
constructor(client) {
|
||||
this.state = client.state;
|
||||
this.channels = client.channels;
|
||||
}
|
||||
|
||||
/* Connection status */
|
||||
isConnected() {
|
||||
return this.state !== State.DISCONNECTED;
|
||||
}
|
||||
isRunning() {
|
||||
return this.state === State.READY;
|
||||
}
|
||||
stateString() {
|
||||
switch(this.state) {
|
||||
case State.DISCONNECTED: return "Disconnected";
|
||||
case State.CONNECTING: return "Connecting...";
|
||||
case State.READY: return "Connected";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Client {
|
||||
#ws;
|
||||
#password;
|
||||
|
||||
constructor(serverUrl, postUrl) {
|
||||
/* IRC server's websocket URL (wss://...). */
|
||||
this.serverUrl = serverUrl;
|
||||
/* HTTP-based message post URL. Undefined when using pure IRC, or a v4/v5
|
||||
URL when using website authentication. */
|
||||
this.postUrl = postUrl;
|
||||
/* Websocket for IRC server communication. */
|
||||
this.#ws = undefined;
|
||||
/* Current connection state. */
|
||||
this.state = State.DISCONNECTED;
|
||||
/* Capabilities enabled. */
|
||||
this.caps = [];
|
||||
/* List of channels and their information */
|
||||
this.channels = new Map();
|
||||
/* Username with which we are logged in. Undefined when DISCONNECTED. */
|
||||
this.username = undefined;
|
||||
/* Password for connection. Erased after authenticating. */
|
||||
this.#password = undefined;
|
||||
/* Batches currently being processed */
|
||||
this.batches = new Map();
|
||||
}
|
||||
|
||||
/*** Callback messaging ***/
|
||||
|
||||
onMessage() {}
|
||||
sendMessage(e) {
|
||||
e.state = new ClientState(this);
|
||||
this.onMessage(e);
|
||||
}
|
||||
|
||||
log(text) {
|
||||
this.sendMessage({ type: "log", text: text });
|
||||
}
|
||||
onStateChanged() {
|
||||
this.sendMessage({ type: "state_changed" });
|
||||
}
|
||||
onAuthenticated() {
|
||||
this.sendMessage({ type: "authenticated" });
|
||||
}
|
||||
onNewMessage(channel, date, author, message) {
|
||||
this.sendMessage({
|
||||
type: "new_message",
|
||||
channel: channel,
|
||||
date: date,
|
||||
author: author,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
onChannelChanged(channel) {
|
||||
this.sendMessage({ type: "channel_changed", channel: channel });
|
||||
}
|
||||
|
||||
/*** Message-based actions ***/
|
||||
|
||||
remoteCall(e) {
|
||||
if(e.type == "connect")
|
||||
this.connect(e.login, e.password);
|
||||
else if(e.type == "post")
|
||||
this.postMessage(e.channel, e.message);
|
||||
}
|
||||
|
||||
/*** Connection management ***/
|
||||
|
||||
setState(state) {
|
||||
this.state = state;
|
||||
this.onStateChanged();
|
||||
}
|
||||
|
||||
connect(username, password) {
|
||||
let ws = new WebSocket(this.serverUrl);
|
||||
this.#ws = ws;
|
||||
this.log("[v5shoutbox] Connecting...");
|
||||
|
||||
ws.onopen = function() {
|
||||
this.log("[v5shoutbox] Connected.");
|
||||
this.setState(State.CONNECTING);
|
||||
this.username = username;
|
||||
this.#password = password;
|
||||
this.#startExchange();
|
||||
}.bind(this);
|
||||
|
||||
ws.onmessage = function(net) {
|
||||
this.log("[<] " + net.data);
|
||||
let msg = new IRCMessage(net.data);
|
||||
if(msg.command === undefined) {
|
||||
this.log("[v5shoutbox] invalid message");
|
||||
return;
|
||||
}
|
||||
this.processIRCMessage(msg);
|
||||
}.bind(this);
|
||||
|
||||
ws.onclose = function() {
|
||||
this.log("[v5shoutbox] Disconnected.");
|
||||
this.setState(State.DISCONNECTED);
|
||||
}.bind(this);
|
||||
}
|
||||
|
||||
#sendRaw(command) {
|
||||
this.log("[>] " + command);
|
||||
this.#ws.send(command);
|
||||
}
|
||||
|
||||
send(command, args) {
|
||||
if(args.slice(0,-1).some(x => x.includes(" ")))
|
||||
return this.log("invalid spaces in non-last args: " + command.toString() +
|
||||
" " + args.toString());
|
||||
|
||||
if(args.length && args[args.length - 1].includes(" "))
|
||||
args[args.length - 1] = ":" + args[args.length - 1];
|
||||
|
||||
this.#sendRaw(command + " " + args.join(" "));
|
||||
}
|
||||
|
||||
/*** IRC protocol ***/
|
||||
|
||||
#startExchange() {
|
||||
this.#sendRaw("CAP LS 302");
|
||||
this.send("NICK", [this.username]);
|
||||
this.send("USER", [this.username, "*", "0", this.username]);
|
||||
}
|
||||
|
||||
processIRCMessage(msg) {
|
||||
if(msg.command === "PING") {
|
||||
this.send("PONG", msg.args);
|
||||
return;
|
||||
}
|
||||
if(msg.command === "BATCH") {
|
||||
const [mode, id] = [msg.args[0][0], msg.args[0].substring(1)];
|
||||
if(mode == "+")
|
||||
this.batches.set(id, []);
|
||||
this.batches.get(id).push(msg);
|
||||
if(mode == "-") {
|
||||
this.processBatch(this.batches.get(id));
|
||||
this.batches.delete(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(msg.tags.has("batch")) {
|
||||
this.batches.get(msg.tags.get("batch")).push(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if(msg.command === "CAP" && msg.args[1] === "LS") {
|
||||
const caps = msg.args[2].split(" ").filter(x => x);
|
||||
const wishlist =
|
||||
["message-tags", "server-time", "echo-message",
|
||||
"batch", "sasl", "draft/chathistory"];
|
||||
|
||||
let req = wishlist.filter(x => caps.includes(x));
|
||||
if(caps.some(x => x.startsWith("sasl=")))
|
||||
req.push("sasl");
|
||||
|
||||
if(req.length)
|
||||
this.send("CAP", ["REQ", req.join(" ")]);
|
||||
else {
|
||||
this.caps = [];
|
||||
this.#sendRaw("CAP END");
|
||||
}
|
||||
}
|
||||
|
||||
if(msg.command === "CAP" && msg.args[1] === "ACK") {
|
||||
this.caps = msg.args[2].split(" ").filter(x => x);
|
||||
if(this.caps.includes("sasl"))
|
||||
this.#sendRaw("AUTHENTICATE PLAIN");
|
||||
else
|
||||
this.#sendRaw("CAP END");
|
||||
}
|
||||
if(msg.command === "AUTHENTICATE" && msg.args[0] === "+") {
|
||||
const arg = this.username + "\0" + this.username + "\0" + this.#password;
|
||||
const b64 = base64_encode(arg);
|
||||
this.log("[v5shoutbox] AUTHENTICATE command sent (not shown)");
|
||||
this.#ws.send("AUTHENTICATE " + b64);
|
||||
}
|
||||
|
||||
/* Default authentication if SASL is unavailable or fails */
|
||||
if(msg.command === "NOTICE" && msg.args[1].includes("/AUTH")) {
|
||||
this.log("[v5shoutbox] AUTH command sent (not shown)");
|
||||
this.#ws.send("AUTH " + this.username + ":" + this.#password);
|
||||
}
|
||||
|
||||
if(msg.command === 900) {
|
||||
this.log("[v5shoutbox] Authenticated.");
|
||||
if(this.caps.includes("sasl"))
|
||||
this.#sendRaw("CAP END");
|
||||
this.setState(State.READY);
|
||||
this.#password = undefined;
|
||||
this.onAuthenticated();
|
||||
|
||||
if(this.caps.includes("draft/chathistory")) {
|
||||
["#annonces", "#projets", "#hs"].forEach(channel => {
|
||||
this.send("CHATHISTORY", ["LATEST", channel, "*", "100"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if(msg.command === "JOIN"
|
||||
&& msg.tags.get("account") === this.username) {
|
||||
this.channels.set(msg.args[0], {});
|
||||
this.onChannelChanged(msg.args[0]);
|
||||
}
|
||||
if(msg.command === 332) {
|
||||
this.channels.get(msg.args[1]).header = msg.args[2];
|
||||
this.onChannelChanged(msg.args[1]);
|
||||
}
|
||||
if(msg.command === 353 && msg.args[1] === "=") {
|
||||
this.channels.get(msg.args[2]).clients = msg.args[3].split(" ");
|
||||
this.onChannelChanged(msg.args[2]);
|
||||
}
|
||||
|
||||
if(msg.command === "PRIVMSG")
|
||||
this.processPRIVMSG(msg);
|
||||
}
|
||||
|
||||
processBatch(batch) {
|
||||
const batchArgs = batch[0].args.slice(1);
|
||||
if(batchArgs.length === 2 && batchArgs[0] === "chathistory") {
|
||||
batch.forEach(msg => {
|
||||
if(msg.command === "PRIVMSG")
|
||||
this.processPRIVMSG(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processPRIVMSG(msg) {
|
||||
if(msg.args.length == 2) {
|
||||
let source = msg.source;
|
||||
if(source.includes("!"))
|
||||
source = source.substr(0, source.indexOf("!"));
|
||||
let date = new Date();
|
||||
if(msg.tags.has("time"))
|
||||
date = new Date(msg.tags.get("time"));
|
||||
this.onNewMessage(msg.args[0], date, source, msg.args[1]);
|
||||
}
|
||||
}
|
||||
|
||||
postMessage(channel, message) {
|
||||
if(message.startsWith("/")) {
|
||||
this.#sendRaw(message.substring(1));
|
||||
}
|
||||
else {
|
||||
this.send("PRIVMSG", [channel, message]);
|
||||
if(!this.caps.includes("echo-message"))
|
||||
this.onNewMessage(channel, new Date(), conn.username, message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
"use strict";
|
||||
|
||||
function log(...args) {
|
||||
const time = new Date().toISOString();
|
||||
console.log(`[${time}]`, ...args);
|
||||
}
|
||||
|
||||
/* Activate eagerly as soon as the worker gets updated. Normally, when a worker
|
||||
is updated, the previous version still gets to control new pages until all
|
||||
of its controlled pages are closed simultaneously. We skip this, so new
|
||||
pages will get the latest shoutbox service worker as soon as possible. */
|
||||
self.addEventListener("install", (e) => {
|
||||
log("installing!");
|
||||
/* This promise is guarantee OK to ignore by API */
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
/* As soon as the worker activates, we claim existing clients. This means that
|
||||
not only new pages will use the updated worker, but running ones will also
|
||||
transition to it, giving us transparent background updates. We handle
|
||||
protocol differences manually; if the main script cannot handle the new
|
||||
shoutbox version it will simply ask for a reload. Doing this allows us to
|
||||
take control of the shoutbox without a page refresh when it is loaded for
|
||||
the first time. */
|
||||
self.addEventListener("activate", (e) => {
|
||||
log("activating!");
|
||||
e.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("message", (e) => {
|
||||
log(e);
|
||||
if(e.data[0] == "!") {
|
||||
log("responding");
|
||||
e.source.postMessage(e.data.substring(1));
|
||||
}
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
<div id="v5shoutbox">
|
||||
<header>
|
||||
<div class="status"></div>
|
||||
<div class="grow"></div>
|
||||
<div class="channel-buttons">
|
||||
<a class="tab tab-login" role="button" data-channel="\login">login</a>
|
||||
<a class="tab tab-log" role="button" data-channel="\log">log</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="channels">
|
||||
<div data-channel="\login">
|
||||
<form class="login-form">
|
||||
<input type="text" class="login" placeholder="Utilisateur" />
|
||||
<input type="password" class="password" placeholder="Mot de passe" />
|
||||
<input type="submit" class="connect" value="Connect!" />
|
||||
</form>
|
||||
</div>
|
||||
<pre class="log" data-channel="\log" style="display: none"></pre>
|
||||
</div>
|
||||
<form class="shoutbox-form">
|
||||
<input type="text" name="message" class="message" />
|
||||
<button type="submit" name="Envoyer" title="Envoyer">Envoyer</button>
|
||||
</form>
|
||||
</div>
|
Loading…
Reference in New Issue