9 Commits

Author SHA1 Message Date
dcf117e1dd Add a couple of message properties 2022-01-04 13:34:30 -06:00
a0bec9a12c WIP Introduce Channel class
This is a breaking change, introducing a new Channel class to hold the
channel meta information, and functions to open either the last
DailyArchive, or one for a specified date.
2022-01-04 13:13:21 -06:00
189cd1f86d 2.1.0 2021-11-13 21:59:53 +01:00
8db448c4a0 Make log less verbose 2021-11-13 21:59:34 +01:00
84613ed3a3 Merge pull request 'Add/store channel archive metadata document' (#8) from feature/7-archive_metadata into master
Reviewed-on: #8
2021-11-13 20:56:18 +00:00
f83ef92adc Remove superfluous sort function
#8 (comment)

Co-authored-by: Garret Alfert <alfert@wevelop.de>
2021-11-13 21:53:56 +01:00
54352e09e8 Store meta document with first/last archive ID 2021-11-08 12:55:42 +01:00
cd93cd4167 Add archive metadata schema, RS type 2021-11-04 13:18:55 +01:00
9f477ba14b Update badge link 2021-09-13 14:31:04 +02:00
6 changed files with 301 additions and 79 deletions

View File

@@ -1,4 +1,4 @@
[![Release](https://img.shields.io/npm/v/remotestorage-module-chat-messages.svg?style=flat)](https://github.com/67P/remotestorage-module-chat-messages/releases) [![Release](https://img.shields.io/npm/v/remotestorage-module-chat-messages.svg?style=flat)](https://www.npmjs.com/package/remotestorage-module-chat-messages)
# remoteStorage Module: Chat Messages # remoteStorage Module: Chat Messages

2
dist/build.js vendored

File diff suppressed because one or more lines are too long

2
dist/build.js.map vendored

File diff suppressed because one or more lines are too long

8
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "remotestorage-module-chat-messages", "name": "remotestorage-module-chat-messages",
"version": "2.0.0", "version": "2.1.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -2215,6 +2215,12 @@
"regenerate": "^1.4.0" "regenerate": "^1.4.0"
} }
}, },
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==",
"dev": true
},
"resolve": { "resolve": {
"version": "1.20.0", "version": "1.20.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "remotestorage-module-chat-messages", "name": "remotestorage-module-chat-messages",
"version": "2.0.0", "version": "2.1.0",
"description": "Stores chat messages in daily archive files", "description": "Stores chat messages in daily archive files",
"main": "./dist/build.js", "main": "./dist/build.js",
"scripts": { "scripts": {
@@ -22,6 +22,7 @@
"@babel/preset-env": "^7.14.9", "@babel/preset-env": "^7.14.9",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"webpack": "^5.48.0", "webpack": "^5.48.0",
"webpack-cli": "^4.8.0" "webpack-cli": "^4.8.0",
"regenerator-runtime": "^0.13.9"
} }
} }

View File

@@ -1,3 +1,5 @@
import 'regenerator-runtime/runtime';
function pad (num) { function pad (num) {
num = String(num); num = String(num);
if (num.length === 1) { num = "0" + num; } if (num.length === 1) { num = "0" + num; }
@@ -12,9 +14,17 @@ function parseDate (date) {
}; };
}; };
function lowestNumberInListing(items) {
const sortedNumbers = Object.keys(items)
.map(i => parseInt(i))
.filter(i => !Number.isNaN(i))
.sort();
return sortedNumbers[0];
}
const ChatMessages = function (privateClient, publicClient) { const ChatMessages = function (privateClient, publicClient) {
/** /**
* Schema: chat-messages/daily * Schema: chat-messages/daily-archive
* *
* Represents one calendar day of chat messages * Represents one calendar day of chat messages
* *
@@ -48,8 +58,7 @@ const ChatMessages = function (privateClient, publicClient) {
"properties": { "properties": {
"@context": { "@context": {
"type": "string", "type": "string",
"default": "https://kosmos.org/ns/v2", "default": "https://kosmos.org/ns/v2/chat-channel"
"enum": ["https://kosmos.org/ns/v2"]
}, },
"@id": { "@id": {
"type": "string", "type": "string",
@@ -141,59 +150,94 @@ const ChatMessages = function (privateClient, publicClient) {
"required": [] "required": []
}; };
privateClient.declareType("daily-archive", "https://kosmos.org/ns/v2", archiveSchema); privateClient.declareType("daily-archive", "https://kosmos.org/ns/v2/chat-channel", archiveSchema);
publicClient.declareType("daily-archive", "https://kosmos.org/ns/v2", archiveSchema); publicClient.declareType("daily-archive", "https://kosmos.org/ns/v2/chat-channel", archiveSchema);
/** /**
* A daily archive stores chat messages by calendar day. * Schema: chat-messages/daily-archive-meta
*
* Stores meta information about the daily archives
*
* @example
* {
* "@context": "https://kosmos.org/ns/v2",
* "@id": "chat-messages/irc.libera.chat/channels/kosmos/meta",
* "@type": "ChatChannelMeta",
* "first": "2009/01/03",
* "last": "2021/11/05"
* }
* }
*/
const archiveMetaSchema = {
"type": "object",
"properties": {
"@context": {
"type": "string",
"default": "https://kosmos.org/ns/v2/chat-channel-meta"
},
"@id": {
"type": "string",
},
"@type": {
"type": "string",
"default": "ChatChannelMeta",
"enum": ["ChatChannelMeta"]
},
"first": {
"type": "string",
"pattern": "^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$"
},
"last": {
"type": "string",
"pattern": "^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$"
}
},
"required": ["@id", "first", "last"]
};
privateClient.declareType("daily-archive-meta", "https://kosmos.org/ns/v2/chat-channel-meta", archiveMetaSchema);
publicClient.declareType("daily-archive-meta", "https://kosmos.org/ns/v2/chat-channel-meta", archiveMetaSchema);
/**
* A chat channel
* *
* @class * @class
*/ */
class DailyArchive { class Channel {
/** /**
* @param {object} options * @param {object} options
* @param {object} options.service * @param {object} options.service
* @param {string} options.service.protocol - Type of chat service/protocol (e.g. "IRC", "XMPP", "Campfire", "Slack") * @param {string} options.service.protocol - Type of chat service/protocol (e.g. "IRC", "XMPP", "Campfire", "Slack")
* @param {string} options.service.domain - Domain of the chat service (e.g. "irc.libera.chat", "kosmos.chat") * @param {string} options.service.domain - Domain of the chat service (e.g. "irc.libera.chat", "kosmos.chat")
* @param {string} options.channelName - Name of room/channel (e.g. "#kosmos") * @param {string} options.name - Name of room/channel (e.g. "#kosmos")
* @param {string} [options.channelType] - Type of channel ("room" or "person") * @param {string} [options.type] - Type of channel ("room" or "person")
* @param {date} options.date - Date of archive day
* @param {boolean} [options.isPublic] - Store logs in public folder (defaults to false)
* @param {string} [options.previous] - Date of previous log file as `YYYY/MM/DD`. Looked up automatically when not given
* @param {string} [options.next] - Date of next log file as `YYYY/MM/DD`. looked up automatically when not given
* *
* @example * @example
* // IRC archive: * // IRC channel:
* const archive = new chatMessages.DailyArchive({ * const channel = new remoteStorage.chatMessages.Channel({
* service: { * service: {
* protocol: 'IRC', * protocol: 'IRC',
* domain: 'irc.libera.chat', * domain: 'irc.libera.chat',
* }, * },
* channelName: '#kosmos-dev', * name: '#kosmos-dev',
* channelType: 'room', * type: 'room'
* date: new Date(),
* isPublic: true
* }); * });
* *
* // XMPP archive: * // XMPP channel:
* const archive = new chatMessages.DailyArchive({ * const channel = new remoteStorage.chatMessages.Channel({
* service: { * service: {
* protocol: 'XMPP', * protocol: 'XMPP',
* domain: 'kosmos.chat', * domain: 'kosmos.chat',
* }, * },
* channelName: 'kosmos-dev', * name: 'kosmos-dev',
* channelType: 'room', * type: 'room'
* date: new Date(),
* isPublic: false
* }); * });
*
*/ */
constructor (options) { constructor (options) {
// //
// Defaults // Defaults
// //
options.isPublic = options.isPublic || false; options.type = options.type || "room";
options.channelType = options.channelType || "room";
// //
// Validate options // Validate options
@@ -204,16 +248,10 @@ const ChatMessages = function (privateClient, publicClient) {
if (typeof options.service !== "object" || if (typeof options.service !== "object" ||
typeof options.service.protocol !== "string" || typeof options.service.protocol !== "string" ||
typeof options.service.domain !== "string") { typeof options.service.domain !== "string") {
throw "service must be an object containing at least service \"protocol\" and \"domain\""; throw "options.service must be an object containing at least service \"protocol\" and \"domain\"";
} }
if (typeof options.channelName !== "string") { if (typeof options.name !== "string") {
throw "channelName must be a string"; throw "options.name must be a string";
}
if (!(options.date instanceof Date)) {
throw "date must be a date object";
}
if (typeof options.isPublic !== "boolean") {
throw "isPublic must be a boolean value";
} }
/** /**
@@ -224,14 +262,130 @@ const ChatMessages = function (privateClient, publicClient) {
this.service = options.service; this.service = options.service;
/** /**
* @property {string} channelName - Name of channel (e.g. "#kosmos") * @property {string} name - Name of channel (e.g. "#kosmos")
*/ */
this.channelName = options.channelName; this.name = options.name;
/** /**
* @property {string} channelType - Type of channel ("room" or "person") * @property {string} type - Type of channel ("room" or "person")
*/ */
this.channelType = options.channelType; this.type = options.type;
/**
* @property {string} path - Base directory path of the channel archives
*/
if (this.type === "room") {
// Normal chatroom
const name = this.name.replace(/#/,'');
this.path = `${this.service.domain}/channels/${name}`;
} else {
// User direct messages
this.path = `${this.service.domain}/users/${this.name}`;
}
/**
* @property {string} metaPath - Path of the channel's metadata document
*/
this.metaPath = `${this.path}/meta`;
}
/*
* Returns the last stored DailyArchive, or a new one for the current day.
*
* @param {object} options
* @param {boolean} [options.public] - Public or private archive. Defaults to 'false'.
*
* @returns {Promise}
*/
async openLastDailyArchive (options={}) {
const client = options.public ? publicClient : privateClient;
const meta = await client.getObject(this.metaPath);
if (meta && meta.last) {
return new DailyArchive({
channel: this,
date: new Date(meta.last.replace(/\//g,'-')),
isPublic: options.public
});
} else {
return new DailyArchive({
channel: this,
date: new Date(),
isPublic: options.public
});
}
}
/*
* Returns a DailyArchive for the given date
*
* @param {object} options
* @param {date} options.date - Date of archive day
* @param {boolean} [options.public] - Public or private archive. Defaults to 'false'.
*
* @returns {Promise}
*/
openDailyArchive (options={}) {
if (!(options.date instanceof Date)) {
throw 'options.date must be a date object';
}
return new DailyArchive({
channel: this,
date: options.date,
isPublic: options.public
});
}
}
/**
* A daily archive stores chat messages by calendar day.
*
* @class
*/
class DailyArchive {
/**
* @param {object} options
* @param {string} options.channel - A Channel instance
* @param {date} options.date - Date of archive day
* @param {boolean} [options.isPublic] - Store logs in public folder (defaults to false)
* @param {string} [options.previous] - Date of previous log file as `YYYY/MM/DD`. Looked up automatically when not given
* @param {string} [options.next] - Date of next log file as `YYYY/MM/DD`. Looked up automatically when not given
*
* @example
* const channel = new remoteStorage.chatMessages.Channel({ ...options })
* const archive = new chatMessages.DailyArchive({
* channel: channel,
* date: new Date(),
* isPublic: true
* });
*/
constructor (options) {
//
// Defaults
//
options.isPublic = options.isPublic || false;
//
// Validate options
//
if (typeof options !== "object") {
throw "options must be an object";
}
if (!(options.channel instanceof Channel)) {
throw "options.channel must be a Channel object";
}
if (!(options.date instanceof Date)) {
throw "options.date must be a date object";
}
if (typeof options.isPublic !== "boolean") {
throw "options.isPublic must be a boolean value";
}
/**
* @property {Channel} channel - A Channel instance
*/
this.channel = options.channel;
/** /**
* @property {string} date - Gregorian calendar date of the archive's content * @property {string} date - Gregorian calendar date of the archive's content
@@ -257,16 +411,14 @@ const ChatMessages = function (privateClient, publicClient) {
this.dateId = this.parsedDate.year+'/'+this.parsedDate.month+'/'+this.parsedDate.day; this.dateId = this.parsedDate.year+'/'+this.parsedDate.month+'/'+this.parsedDate.day;
/** /**
* @property {string} path - Document path of the archive file * @property {string} path - Path of the archive document
*/ */
if (this.channelType === "room") { this.path = `${this.channel.path}/${this.dateId}`;
// Normal chatroom
const channelName = this.channelName.replace(/#/,''); /**
this.path = `${this.service.domain}/channels/${channelName}/${this.dateId}`; * @property {string} metaPath - Path of the channel's metadata document
} else { */
// User direct message this.metaPath = `${this.channel.path}/meta`;
this.path = `${this.service.domain}/users/${this.channelName}/${this.dateId}`;
}
/** /**
* @property {object} client - Public or private remoteStorgage.js BaseClient * @property {object} client - Public or private remoteStorgage.js BaseClient
@@ -290,12 +442,14 @@ const ChatMessages = function (privateClient, publicClient) {
* @param {string} message.from - The sender of the message * @param {string} message.from - The sender of the message
* @param {string} message.text - The message itself * @param {string} message.text - The message itself
* @param {string} message.type - Type of message (one of text, join, leave, action) * @param {string} message.type - Type of message (one of text, join, leave, action)
* @param {string} [message.id] - Unique ID of message. TODO implement * @param {string} [message.id] - Unique ID of message.
* @param {string} [message.sid] - Unique stanza ID of message (XMPP only)
* @param {boolean} [message.edited] - If the message has been edited
* *
* @returns {Promise} * @returns {Promise}
*/ */
addMessage (message) { addMessage (message) {
if (this.isPublic && !this.channelName.match(/^#/)) { if (this.isPublic && !this.channel.name.match(/^#/)) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@@ -320,7 +474,7 @@ const ChatMessages = function (privateClient, publicClient) {
* @returns {Promise} * @returns {Promise}
*/ */
addMessages (messages, overwrite) { addMessages (messages, overwrite) {
if (this.isPublic && !this.channelName.match(/^#/)) { if (this.isPublic && !this.channel.name.match(/^#/)) {
return Promise.resolve(false); return Promise.resolve(false);
} }
@@ -349,6 +503,8 @@ const ChatMessages = function (privateClient, publicClient) {
* @returns {Promise} * @returns {Promise}
*/ */
remove () { remove () {
// TODO when removing, if previous is set, but not next, it means the
// removed file is the last archive. Thus, set "last" to previous file.
return this.client.remove(this.path); return this.client.remove(this.path);
} }
@@ -359,7 +515,7 @@ const ChatMessages = function (privateClient, publicClient) {
* *
* @private * @private
*/ */
_updateDocument (archive, messages) { async _updateDocument (archive, messages) {
console.debug('[chat-messages] Updating archive document'); console.debug('[chat-messages] Updating archive document');
if (Array.isArray(messages)) { if (Array.isArray(messages)) {
@@ -380,12 +536,12 @@ const ChatMessages = function (privateClient, publicClient) {
* *
* @private * @private
*/ */
_createDocument (messages) { async _createDocument (messages) {
console.debug('[chat-messages] Creating new archive document'); console.debug('[chat-messages] Creating new archive document');
const archive = this._buildArchiveObject(); const archive = this._buildArchiveObject();
if (Array.isArray(messages)) { if (Array.isArray(messages)) {
messages.forEach((message) => { messages.forEach(message => {
archive.today.messages.push(message); archive.today.messages.push(message);
}); });
} else { } else {
@@ -397,16 +553,22 @@ const ChatMessages = function (privateClient, publicClient) {
// That includes setting 'next' in the previous log file // That includes setting 'next' in the previous log file
if (this.previous) { archive.today.previous = this.previous; } if (this.previous) { archive.today.previous = this.previous; }
if (this.next) { archive.today.next = this.next; } if (this.next) { archive.today.next = this.next; }
return this._sync(archive);
} else { } else {
// Find and update previous archive, set 'previous' on this one // Find and update previous archive, set 'previous' on this one
return this._updatePreviousArchive().then((previous) => { const previous = await this._updatePreviousArchive();
if (typeof previous === 'object') { if (typeof previous === 'object') {
archive.today.previous = previous.today['@id']; archive.today.previous = previous.today['@id'];
} }
return this._sync(archive);
});
} }
await this._sync(archive);
// TODO only write meta doc if argument is set on addMessages. This way
// we can avoid race conditions when syncing remote chat messages all at
// once for multiple days
await this._updateArchiveMetaDocument();
return;
} }
/* /*
@@ -417,14 +579,14 @@ const ChatMessages = function (privateClient, publicClient) {
* @private * @private
*/ */
_buildArchiveObject () { _buildArchiveObject () {
const roomName = this.channelName.replace(/#/,''); const roomName = this.channel.name.replace(/#/,'');
const archive = { const archive = {
"@id": "chat-messages/"+this.service.domain+"/channels/"+roomName+"/", "@id": "chat-messages/"+this.channel.service.domain+"/channels/"+roomName+"/",
"@type": "ChatChannel", "@type": "ChatChannel",
"service": this.service, "service": this.channel.service,
"name": this.channelName, "name": this.channel.name,
"type": this.channelType, "type": this.channel.type,
"today": { "today": {
"@id": this.dateId, "@id": this.dateId,
"@type": "ChatLog", "@type": "ChatLog",
@@ -433,10 +595,10 @@ const ChatMessages = function (privateClient, publicClient) {
} }
}; };
switch (this.service.protocol) { switch (this.channel.service.protocol) {
case 'IRC': case 'IRC':
if (!this.channelName.match(/^#/)) { if (!this.channel.name.match(/^#/)) {
archive["@id"] = "chat-messages/"+this.service.domain+"/users/"+this.channelName+"/"; archive["@id"] = "chat-messages/"+this.channel.service.domain+"/users/"+this.channel.name+"/";
} }
break; break;
case 'XMPP': case 'XMPP':
@@ -461,7 +623,7 @@ const ChatMessages = function (privateClient, publicClient) {
const path = this.path.substring(0, this.path.length-this.dateId.length)+archive.today['@id']; const path = this.path.substring(0, this.path.length-this.dateId.length)+archive.today['@id'];
return this.client.storeObject('daily-archive', path, archive).then(() => { return this.client.storeObject('daily-archive', path, archive).then(() => {
console.debug('[chat-messages] Previous archive written to remote storage', path, archive); console.debug('[chat-messages] Previous archive written to remote storage at', path);
return archive; return archive;
}); });
} else { } else {
@@ -537,6 +699,57 @@ const ChatMessages = function (privateClient, publicClient) {
}); });
} }
async _updateArchiveMetaDocument () {
const meta = await this.client.getObject(this.metaPath);
if (typeof meta !== 'object') {
return this._createArchiveMetaDocument();
}
// Only update document if current date is newer than known "last"
if (Date.parse(meta.last.replace('/','-')) > Date.parse(this.date)) {
console.debug('[chat-messages]', 'Updating meta document for channel');
meta.last = this.dateId;
await this.client.storeObject('daily-archive-meta', this.metaPath, meta);
}
return;
}
async _createArchiveMetaDocument () {
console.debug('[chat-messages]', 'Creating new meta document for channel');
// When creating a new meta doc, we need to find the oldest archive,
// because older versions of the module did not write a meta doc.
const first = await this._findFirstArchive();
const roomName = this.channel.name.replace(/#/,'');
const meta = {
'@id': `chat-messages/${this.channel.service.domain}/channels/${roomName}/meta`,
'@type': 'ChatChannelMeta',
first: first,
last: this.dateId // TODO might have to search for last?
};
return this.client.storeObject('daily-archive-meta', this.metaPath, meta)
.then(() => console.debug('[chat-messages]', 'Meta document written to remote storage'))
.catch(e => {
console.log('[chat-messages]', `Failed to store ${this.metaPath}`);
console.error(e);
});
}
async _findFirstArchive () {
console.debug('[chat-messages]', 'Finding first archive for channel');
const years = await this.client.getListing(`${this.channelPath}/`);
const year = lowestNumberInListing(years);
const months = await this.client.getListing(`${this.channelPath}/${year}/`);
const month = lowestNumberInListing(months);
const days = await this.client.getListing(`${this.channelPath}/${year}/${pad(month)}/`);
const day = lowestNumberInListing(days);
const firstId = `${year}/${pad(month)}/${pad(day)}`;
console.debug('[chat-messages]', 'First is', firstId);
return firstId;
}
/* /*
* Write archive document * Write archive document
* *
@@ -544,7 +757,7 @@ const ChatMessages = function (privateClient, publicClient) {
* *
* @private * @private
*/ */
_sync (obj) { async _sync (obj) {
console.debug(`[chat-messages] Writing archive object with ${obj.today.messages.length} messages`); console.debug(`[chat-messages] Writing archive object with ${obj.today.messages.length} messages`);
return this.client.storeObject('daily-archive', this.path, obj).then(function(){ return this.client.storeObject('daily-archive', this.path, obj).then(function(){
@@ -554,6 +767,8 @@ const ChatMessages = function (privateClient, publicClient) {
console.warn('[chat-messages] Error trying to store object', error); console.warn('[chat-messages] Error trying to store object', error);
return error; return error;
}); });
// TODO await this.client.startSync();
} }
}; };