Plugins are extensions of Miaou, installed on the server, and having those parts:
- some server-side javascript code, executed in the same process than Miaou
- some client-side javascript code, concatenated and minified with Miaou's core javascript code
- some CSS and SCSS
- some static resources, served without modidification
- sql scripts used to install and update plugin's tables
While everything is optional, a server side file is necessary for plugin activation (its path is written in the config). It may be empty though.
There's no sandboxing, plugins can read, change, and break everything. A plugin must thus be validated by the server owner before being installed.
Before messing with plugins, installing them, or making new ones, you should be familiar with the general working of Miaou and have a server installed. While the present document should provide a complete enough description of plugins, looking at the existing standard plugins should be considered the next logical step to acquire familiarity.
Adding an existing plugin into your Miaou installation is a three step process:
- Installation
- Compilation with Miaou
- Enabling
Some plugins are standard, and can be found in the plugins
directory of the miaou repository. They don't have to be installed, they must only be enabled.
Non standard plugins, coming from another repository, have exactly the same structure but aren't distributed with Miaou. In order to be available, they must be installed, that is usually just copied to the ̀"plugins" directory of Miaou.
After a plugin is installed, its scripts, styles and resources can be made available by miaou compilation, which is done using
npm build
The last step is enabling the plugin in the config.js file, which is done by adding the path to the main plugin file:
// list of the plugins you want to activate. You may add your ones
plugins: [
"./plugins/stackoverflow/plugin.js",
"./plugins/wikipedia/plugin.js",
"./plugins/my-plugin/plugin.js",
],
Some plugins may require a specific configuration. The convention is to add it in the "pluginConfig"/"yourPluginName" section of the config.js file.
Now that the plugin is enabled, it will be available next time you restart Miaou:
./restart.sh
Most server side operations of a plugins are exported functions called by Miaou's core. Those functions have conventionnal names (see the "server side hooks" chapter), which avoids the task of registering them: the core calls the function if it is found in the plugin, doesn't call anything otherwise.
Exemples of those functions are
- the
exports.init
function, usually dedicated to fetching some configuration or starting some periodic task - the
exports.registerRoutes
function, in which a plugin can declare Express routes to directly answer HTTP requests - the
exports.registerCommands
function, in which all !! commands of a plugin are declared
Additionnaly to those functions, it's good practice to export also the name of the plugin, as exports.name = "somestring";
.
The client side part of a plugin's javascript code is, comparatively, much more open, there's no real difference with the core code.
Like all client side scripts, plugin modules are declared with the miaou
function whose arguments are all the modules it uses. For example, here's the attention
client-side code:
miaou(function(attention, chat, locals, plugins, ws){
function onNotable(m, $md){
...
}
function onAlert(alert){
...
}
function onRemove(mid){
...
}
plugins.attention = {
start: function(){
chat.on('notable', onNotable);
ws.on('attention.alert', onAlert);
ws.on('attention.remove', onRemove);
}
};
});
Here an object with a ̀start
function is added to the plugins
plugin registry. The start
hook is defined. This function is automatically called when the chat's client code is ready to run. In this function, event listeners are registered using the same registering functions used by the standard core code:
chat.on('notable'
registers a listener wich will be called when a notable message is displayedws.on(
registers a listener which will be called on specific events coming on the user's socket.io socket
More details about those hooks are available in the relevant chat and ws modules.
While a plugin can technically do anything in database using the core API, it should avoid modifying the core structure or querying core records.
It should instead install, update and query its own tables.
The same mechanism used for core table update is available for plugins: A plugin shoud contain a list of SQL scripts named sql/number-name.sql where number starts at 1 and is incremented for every new script.
At server start, if the plugin is enabled, scripts whose number is greater than the last executed script for that plugin are executed, in order.
That behavior is triggered at plugin start in the init function of the plugin:
exports.init = function(miaou){
db = miaou.db;
return db.upgrade(exports.name, path.resolve(__dirname, 'sql'));
}
A command is issued by the user in a message starting with !!
(possibly with a ping or reply mark before).
Command listeners are registered by plugins using the registerCommand
callback they are given if they export the registerCommands
function:
exports.registerCommands = function(registerCommand){
registerCommand({
name: 'broadcast',
fun: broadcast,
help: "send a flake to all rooms",
detailedHelp: "Only server admins can do that"
});
}
The registerCommand
callback accepts an object with the following properties:
name
: how the command will be called by the userfun(commandTask)
: the function which will be called for command executionbotfun()
: the function called for command execution when the message author is a server botfilter(room)
: a function answering true if the function is available in that roomhelp
: a short help displayed in the command listdetailledHelp
: a text displayed when the user requires the help of that specific command
Only the name
, fun
and help
properties are mandatory.
This function is called with context (this
) being the DB transaction handling the message. It is called before the message is saved in database and can thus change its content or prevent the saving. If its action is asynchronous and it's better to have it done before the following steps (saving, ping distribution, boxing, etc.) then it should delay those steps by returning a promise.
The fun
function is passed as argument an instance of CommandTask
whose properties are:
cmd
: the name of the command used (the same used for registration)message
: the message object (whose properties are the content, author, id when it's already saved, etc.)args
: the optional arguments of the command (what comes after!!command
in the message's first lineshoe
: an instance ofShoe
, on which the command implementation can execute socket related actionsnostore
: a boolean that can be set to true to prevent the message storage in databasesilent
: a boolean that can be set to true to prevent the message distribution
Additionnaly, this object offers the following methods:
reply(content, asFlake)
: sends a reply to the command's messagetext
: set or get the content of the command message
Example: a command replying "pong" to every !!ping command would be defined with
exports.registerCommands = function(registerCommand){
registerCommand({
name: 'ping',
fun: function(ct){
ct.reply("pong");
},
help: "pong..."
});
}
(this can be the whole plugin)
Like the fun
callback, botfun
is called with context a DB transaction and it may return a promise. But it is not passed an instance of commandTask but the following arguments:
cmd
: the name of the command used (the same used for registration)args
: the optional arguments of the command (what comes after!!command
in the message's first linebot
: the bot author of the messagemessage
: the message object (whose properties are the content, author, id when it's already saved, etc.)
Bots are the authors of messages that aren't sent from a browser (which is presumed used by a human). The default miaou bot, whose name is miaou, is the natural author of most of them, perfectly suited to any simple message. It's the author of messages sent from server code when no bot is specified (for example ct.reply
in the last example).
But when the bot must have a personnality, for example because he's a Tribo player, or is a proxy for a distant service, for example he notifies of events on GitHub repositories, then a specific bot should be defined and used.
For most purposes, a bot is a normal registered user, with a dedicated record in the player
table of the database, but with the bot
boolean set to true.
A plugin which needs a bot should
- fetch it by name from the database using the
getBot
function which creates it if necessary - update its fields (avatar, description, etc.)
Example:
var me;
exports.init = function(miaou){
var db = miaou.db;
var botConf = miaou.conf("pluginConfig", "myPlugin", "bot");
return db.on(botConf.name).then(db.getBot)
.then(function(bot){
me = bot;
if (botConf.avatar.src!==me.avatarsrc || botConf.avatar.key!==me.avatarkey) {
me.avatarsrc = botConf.avatar.src;
me.avatarkey = botConf.avatar.key;
return this.updateUser(me)
}
}).finally(db.off);
}
The simplest way to send a message as a bot is to use the botMessage
function of the ws
module:
var ws = require("../../libs/ws.js");
ws.botMessage(me, roomId, "Hello World");
A plugin can read any message (see onReceiveMessage) but it's preferable to avoid useless parsings. The standard way for a bot to react to the right messages is to listen for commands but it can also be part of a conversation by listening to pings:
const bots = require('../../libs/bots.js'),
var me;
exports.init = function(miaou){
... bot initialization here ...
.then(function(){
bots.register(me, {
onPing: async function(shoe, message){
shoe.botMessage(me, "Hi");
}
});
})
.finally(db.off);
}
Boxing is the replacement of one line of a message by a different content. Most typical examples are the replacement of the URL to an image by the image itself (i.e. an HTML img element) or the URL of a Wikipedia page by an abstract of that page.
The most usual server-side boxing follows this logic:
- a message is sent to a browser (a new or updated message, or maybe an old one because the user looks to the past)
- the server detects that a line in a sent message matches a specific regular expression
- the server looks in the cache (a LRU cache with a TTL of 30 minutes), to look if that URL has been requested recently. In that case, it goes to 8 if it was a success, or just gives up if it failed
- the server queries that URL
- if that query failed, it gives up, just noting it in the cache so that it isn't fetched again too soon
- if it was a success it builds an abstract, using cheerio (a server-side API similar to jQuery)
- it stores that abstract in the cache
- the server sends a instruction to the browser (and to all other browsers which may have requested that message during the asynchronous fetching of the boxable content), telling it to replace a specific line of a specific message by the new content
Doing this correctly and efficiently is tedious, that's why there's a standard utility in Miaou making it easy for plugins to register boxings. You use it by registering your pattern and abstracting function using the miaou.lib("page-boxers").register
function.
Here's for example how is done the urbandictionary boxing:
exports.init = function(miaou){
miaou.lib("page-boxers").register({
name: "urban",
pattern: /^\s*https?:\/\/(www\.)?urbandictionary\.com\/define\.php\?term=[^ ]*\s*$/,
box: abstract
});
}
function abstract($, line){
var $box = $('<div/>').addClass('urban'),
$abstract = $('<div/>').addClass('abstract'),
$def = $('.def-panel').eq(0);
$box.append($abstract);
if ($def.length) {
$abstract.append($("<h1>").append(
$("<a>").attr("href", line).attr("target", "_blank").text(
"Urban Dictionary: " + $def.find(".def-header").text()
)
));
$abstract.append($("<p>").text($def.find(".meaning").text()));
} else {
$box.append("no definition found on Urban");
}
return $('<div>').append($box).html();
}
Most plugins ensuring the boxing of URLs also provide a companion feature, the building of that URL. For example when the user sends the message
!!urban chat
the plugin should responds to that command with a message containing
http://www.urbandictionary.com/define.php?term=chat
and this message would then be intercepted to be boxed.
Such a command is simple to make, here's the !!urban
implementation:
exports.registerCommands = function(cb){
cb({
name: 'urban',
fun: onCommand,
help: "display the relevant Urban Dictionary page. Example : `!!urban miaou`",
detailedHelp: "You may also simply paste the URL of a page to have it abstracted for you."
});
}
function onCommand(ct){
ct.reply('\nhttp://www.urbandictionary.com/define.php?term='+encodeURIComponent(ct.args))
}
To define styles, a plugin can provide CSS files: plugins/<plugin>/css/*.css
files are automatically merged with the core styles.
A plugin maker must be aware Miaou is themable. As such, he can't define hardcoded colors and just hope they'll be fine with any theme. If coloring is necessary, then the solution is to use SCSS.
plugins/<plugin>/scss/*.scss
files are compiled with core scss files, and may use all the variables defined in themes and whose names are listed in src/main-scss/variables-default.scss
. This makes it possible to reuse theme tuned colors either as is or using one of the color transformation functions of scss.
Static resources that must be served without changes must be present in the rsc
sub-directory of the plugin directory.
A file whose path is
plugins/<plugin-name>/rsc/<filename>
is visible from the chat with this relative URL:
static/plugins/<plugin-name>/rsc/<filename>
All those functions, when declared in the exports of the main plugin file (the one referenced in configuration), are called by the core server as part of its operations. Many examples of those hooks can be found in the standard plugins.
appuse(req, res, next)
Act as an Express middleware, called after authentication middlewares. Note: when the purpose is to add routes, it's better to export registerRoutes
.
Follow the Express API: https://expressjs.com/en/4x/api.html#app.use
init(miaou)
Called before the server goes live, this is typically where plugins fetch their configuration, the global bot, the database facade or launch initializations.
When its action is asynchronous, the init function should return a promise (unless the server can immediately start).
Example:
exports.init = function(miaou){
db = miaou.db; // store locally a reference to the DB facade
miaouBot = miaou.bot; // store a reference to the bot, for later use
threshold = miaou.conf("pluginConfig", "myPlugin", "threshold") || 33; // read a plugin's configuration property
return db.upgrade(exports.name, path.resolve(__dirname, 'sql')); // ensure the plugin's tables are up to date
}
registerCommands(registerCallback)
Let the plugin register commands that the user will be able to issue. See the Commands chapter.
Example:
exports.registerCommands = function(registerCommand){
registerCommand({
name:'broadcast',
fun:broadcast,
help:"send a flake to all rooms",
detailedHelp:"Only server admins can do that"
});
}
registerRoutes(registerCallback)
Let the plugin map routes to Express middleware. This is rarely needed as the chat mostly communicates using sockets but sometimes you may want to implement some data export, or a web-hook, or some upload facility, this is when registerRoutes
is needed.
The registerCallback
takes the following arguments:
- HTTP verb
- route
- Express middleware
- boolean set to true if a complete profile (with a name) isn't needed
- boolean set to true if a valid login isn't needed
Example:
exports.registerRoutes = function(map){
map('get', '/say-hi', function(req, res, next){
res.send("Hi " + req.user.name + "!");
});
}
When a user connects to the Miaou chat, an instance of Shoe
is created and wraps a socket along with a few other information.
As soon as it has completed the room entry process (which implies the room
property of the shoe is set), all plugins exporting the onNewShoe
function are called and passed that shoe so that they can listen for socket.io events.
This is most commonly used for communication between the client and server parts of a plugin.
For example the client side part of the Ludogene plugin (which lets you play Tribo game) does this when a player does a move:
ws.emit('ludo.move', {
mid: panel.m.id,
move: Tribo.encodeMove({p:panel.u, x:i, y:j})
});
And the server part handles it this way:
exports.onNewShoe = function(shoe){
shoe.socket
.on('ludo.move', function(arg){
var messageId = arg.mid;
// here we fetch the message, check the move, update the state of the game, save it in db
// then we propagate that move to all the users currently in the game message's room:
ws.emitToRoom(messageId, "ludo.move", {mid: messageId, move: move});
})
.on('ludo.accept', function(arg){
// start of a game: we do about the same thing than for incoming moves
});
}
This function is called when a message is received from a browser.
The incoming message may be new (no id
) or a modified one (strictly positive id
).
This function is called with arguments
- the shoe wrapping the socket on which the message was received
- the message
This hook is called for all messages sent from the server to a browser, be them new, updated or old ones. It's called with arguments
- the shoe
- the message
- a callback that can be used later to resend the message (for example if it has been asynchronously modified) or another one
Note that all messages emissions aren't guaranteed to go through this so no security solution should be dependant on this hook.