forked from devs/v5shoutbox
first functional pure-IRC client
* Log in to IRC server with v5 credentials using /AUTH * Read and write messages on all three channels * No history, no CSS, and no v4/v5-posting
This commit is contained in:
commit
e1dfaaab4f
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Planète Casio Websocket IRC</title>
|
||||
<script src="v5shoutbox.js"></script>
|
||||
<link href="style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="v5shoutbox">
|
||||
<div class="status"></div>
|
||||
<div class="channel-buttons">
|
||||
<button data-channel="#annonces">#annonces</button>
|
||||
<button data-channel="#hs">#hs</button>
|
||||
<button data-channel="#projets">#projets</button>
|
||||
</div>
|
||||
<div class="channels">
|
||||
<pre class="channel-annonces">[[[#annonces]]]
|
||||
</pre>
|
||||
<pre class="channel-hs">[[[#hs]]]
|
||||
</pre>
|
||||
<pre class="channel-projets">[[[#projets]]]
|
||||
</pre>
|
||||
</div>
|
||||
<div class="form">
|
||||
<div class="login-form">
|
||||
<input type="text" class="login" placeholder="Utilisateur" />
|
||||
<input type="password" class="password" placeholder="Mot de passe" />
|
||||
<button class="connect">Connect!</button>
|
||||
</div>
|
||||
<form class="shoutbox-form">
|
||||
<input type="text" name="message" class="message" />
|
||||
</form>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Log des commandes IRC</summary>
|
||||
<pre class="log"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,352 @@
|
|||
/*
|
||||
# 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.
|
||||
|
||||
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 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.
|
||||
|
||||
Hence, this client supports different connection modes depending on the
|
||||
authentication setup: normal IRC (with password), PCv4, PCv5.
|
||||
(WIP at time of writing)
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
/* WebSocket IRC with HTTP-based website interface. */
|
||||
let irc = new function() {
|
||||
|
||||
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"),
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/*** 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";
|
||||
}
|
||||
}
|
||||
|
||||
this.currentChannel = function() {
|
||||
return conn.channel;
|
||||
}
|
||||
this.selectChannel = function(name) {
|
||||
conn.channel = name;
|
||||
}
|
||||
|
||||
/*** Overridable hooks and callbacks ***/
|
||||
|
||||
this.onLog = function(message) {};
|
||||
this.onStateChanged = function() {};
|
||||
this.onAuthenticated = function() {};
|
||||
this.onNewMessage = function(channel, author, message) {};
|
||||
|
||||
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);
|
||||
processMessage(parseMessage(net.data));
|
||||
}
|
||||
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 = /(?::([^ ]+) +)?/;
|
||||
// TODO: Check if a-zA-Z is OK for cmd
|
||||
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(""));
|
||||
})();
|
||||
|
||||
function parseMessage(text) {
|
||||
const matches = rMessage.exec(text);
|
||||
if(matches === undefined)
|
||||
return undefined;
|
||||
|
||||
let msg = Object();
|
||||
msg.tags = matches[1];
|
||||
msg.source = matches[2];
|
||||
if(matches[3] !== undefined) {
|
||||
msg.textCommand = matches[3];
|
||||
msg.numCommand = undefined;
|
||||
}
|
||||
else {
|
||||
msg.textCommand = undefined;
|
||||
msg.numCommand = parseInt(matches[4]);
|
||||
}
|
||||
msg.args = [];
|
||||
if(matches[5] !== undefined)
|
||||
msg.args = matches[5].trim().split(" ").filter(s => s);
|
||||
if(matches[6] !== undefined)
|
||||
msg.args.push(matches[6]);
|
||||
return msg;
|
||||
}
|
||||
|
||||
function startExchange() {
|
||||
sendRaw("CAP LS 302");
|
||||
send("NICK", [conn.username]);
|
||||
send("USER", [conn.username, "*", "0", conn.username]);
|
||||
}
|
||||
|
||||
function processMessage(msg) {
|
||||
if(msg.textCommand === "PING")
|
||||
send("PONG", msg.args);
|
||||
|
||||
let sendAuth = false;
|
||||
|
||||
if(msg.textCommand === "CAP" && msg.args[1] === "LS") {
|
||||
const caps = msg.args[2].split(" ");
|
||||
if(caps.includes("draft/chathistory"))
|
||||
sendRaw("CAP REQ draft/chathistory");
|
||||
else
|
||||
sendRaw("CAP END");
|
||||
}
|
||||
if(msg.textCommand === "CAP" && msg.args[1] === "ACK")
|
||||
sendRaw("CAP END");
|
||||
|
||||
if(msg.textCommand === "NOTICE" && msg.args[1].includes("/AUTH")) {
|
||||
log("[v5shoutbox] AUTH command sent (not shown)");
|
||||
conn.socket.send("AUTH " + conn.username + ":" + conn.password);
|
||||
}
|
||||
|
||||
if(msg.numCommand === 900) {
|
||||
log("[v5shoutbox] Authenticated.");
|
||||
setState(State.READY);
|
||||
conn.password = undefined;
|
||||
irc.onAuthenticated();
|
||||
}
|
||||
|
||||
if(msg.textCommand == "PRIVMSG" && msg.args.length == 2) {
|
||||
let source = msg.source;
|
||||
if(source.includes("!"))
|
||||
source = source.substr(0, source.indexOf("!"));
|
||||
irc.onNewMessage(msg.args[0], source, msg.args[1]);
|
||||
}
|
||||
}
|
||||
|
||||
this.postMessage = function(channel, message) {
|
||||
send("PRIVMSG", [channel, message]);
|
||||
irc.onNewMessage(channel, conn.username, message);
|
||||
}
|
||||
};
|
||||
|
||||
/* Shoutbox entry point and DOM manipulation. */
|
||||
let shoutbox = new function() {
|
||||
|
||||
this.init = function(root) {
|
||||
/* Channel views */
|
||||
this.eChannels = root.querySelector(".channels");
|
||||
this.eChannelButtons = root.querySelector(".channel-buttons");
|
||||
/* 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");
|
||||
|
||||
this.eConnect.addEventListener("click", function() {
|
||||
shoutbox.connect();
|
||||
});
|
||||
this.eShoutboxForm.addEventListener("submit", function(e) {
|
||||
e.preventDefault();
|
||||
shoutbox.post();
|
||||
});
|
||||
this.eChannelButtons.querySelectorAll("button").forEach(b => {
|
||||
b.addEventListener("click", () => {
|
||||
shoutbox.selectChannel(b.dataset.channel);
|
||||
});
|
||||
});
|
||||
|
||||
this.refreshView();
|
||||
this.selectChannel("#annonces");
|
||||
}
|
||||
|
||||
/*** IRC callbacks ***/
|
||||
|
||||
function log(message) {
|
||||
/* TODO: Use a better text element x) */
|
||||
this.eLog.innerHTML += message + "\n";
|
||||
}
|
||||
this.log = log.bind(this);
|
||||
irc.onLog = log.bind(this);
|
||||
|
||||
irc.onAuthenticated = function() {
|
||||
this.ePassword.value = "";
|
||||
}.bind(this);
|
||||
|
||||
this.refreshView = function() {
|
||||
const running = irc.isRunning();
|
||||
this.eStatus.textContent = irc.stateString();
|
||||
|
||||
this.eStatus .style.display = running ? "none" : "block";
|
||||
this.eChannelButtons .style.display = running ? "block" : "none";
|
||||
this.eLoginForm .style.display = running ? "none" : "block";
|
||||
this.eShoutboxForm .style.display = running ? "block" : "none";
|
||||
};
|
||||
irc.onStateChanged = this.refreshView.bind(this);
|
||||
|
||||
irc.onNewMessage = function(channel, author, message) {
|
||||
/* Remove the shoutbox bridge's "[s]" suffix */
|
||||
if(author.endsWith("[s]"))
|
||||
author = author.substr(0, author.length - 3);
|
||||
|
||||
if(channel.startsWith("#"))
|
||||
channel = channel.substr(1);
|
||||
|
||||
const eChan = this.eChannels.querySelector(".channel-" + channel);
|
||||
if(eChan.length === null)
|
||||
return;
|
||||
|
||||
/* TODO: Better text element x) */
|
||||
eChan.innerHTML += "[" + channel + "] [" + author + "] " + message + "\n";
|
||||
}.bind(this);
|
||||
|
||||
/*** User interactions ***/
|
||||
|
||||
this.connect = function() {
|
||||
if(irc.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("wss://irc.planet-casio.com:8000", undefined,
|
||||
this.eLogin.value, this.ePassword.value);
|
||||
}
|
||||
|
||||
this.selectChannel = function(name) {
|
||||
irc.selectChannel(name);
|
||||
const cls = "channel-" + name.substr(1);
|
||||
Array.from(this.eChannels.children).forEach(e => {
|
||||
e.style.display = e.classList.contains(cls) ? "block" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
this.post = function() {
|
||||
if(this.eMessage.value === "")
|
||||
return;
|
||||
if(!irc.isRunning())
|
||||
return log("Cannot send message while not connected!");
|
||||
|
||||
const channel = irc.currentChannel();
|
||||
if(channel === "")
|
||||
return log("Cannot send message as no channel is selected!");
|
||||
|
||||
irc.postMessage(channel, this.eMessage.value);
|
||||
this.eMessage.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
shoutbox.init(document.getElementById("v5shoutbox"));
|
||||
});
|
Loading…
Reference in New Issue