1
0
Fork 0

Compare commits

...

8 Commits

Author SHA1 Message Date
mibi88 35b60ef493 Merge branch 'main' of https://gitea.planet-casio.com/devs/v5shoutbox into style 2023-07-28 11:38:08 +02:00
Lephenixnoir 8bda9f96a9
SharedChat manages IRC client + basic formatting (autolinking) 2023-07-28 00:35:32 +02:00
Lephenixnoir 9131c36d63
(almost) decouple IRC client and shoutbox
IRC client's interface is now by message passing with state updates and
other events. Only missing part is to also use messages to send requests
from the shoutbox to the client, which will come soon.

This setup will allow wrapping the IRC client in a service worker and
service the client's API over the worker's message pipe.

This change also moves the "current channel" tracking to the shoutbox,
since it ended up being unused in the client (only get/set!). This
cleans some of the mess of letting the client know about pseudo-channels
like "\log" and "\login".
2023-07-18 22:55:00 +02:00
Lephenixnoir f8e765018e
service worker setup 2023-07-13 16:29:49 +02:00
Lephenixnoir ca3730b64e
minor style improvements (inspired by gamja) 2023-07-13 16:29:20 +02:00
Lephenixnoir c56030b589
split IRC client off so it can be used by script or worker 2023-07-11 20:47:44 +02:00
Lephenixnoir e8c9c293df
fix colon -> semicolon in style.css 2023-07-04 19:42:00 +02:00
Lephenixnoir d7e929000f
meta: add HTML widget 2023-06-27 22:18:23 +02:00
6 changed files with 766 additions and 409 deletions

View File

@ -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">

View File

@ -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);

View File

@ -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;

377
v5shoutbox_irc.js Normal file
View File

@ -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);
}
}
}

36
v5shoutbox_worker.js Normal file
View File

@ -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));
}
});

24
widget.html Normal file
View File

@ -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>