This commit is contained in:
2026-05-13 19:58:16 +03:00
commit f5adeb292b
78 changed files with 12024 additions and 0 deletions

31
lib/datafile.js Normal file
View File

@@ -0,0 +1,31 @@
const fs = require('fs')
const os = require('os')
const { Transform } = require('stream')
module.exports = function(filename='data.json', onFinish=()=>{}) {
//
// Create an object to string transform
// stream
//
var stream = new Transform({
writableObjectMode: true,
transform(chunk, encoding, callback) {
this.push(JSON.stringify(chunk))
this.push(os.EOL)
return callback()
}
})
//
// Pipe that to the file write
// stream and set up an onFinish
// handler
//
stream.pipe(
fs.createWriteStream(filename).on('finish', onFinish)
)
//
// Return the new write stream
//
return stream
}

24
lib/factory/channel.js Normal file
View File

@@ -0,0 +1,24 @@
const Joi = require('joi')
const validate = require('./validate')
//
// Define the schema
//
const schema = {
team: Joi.string(),
name: Joi.string().regex(/^[a-z0-9_-]+$/),
display_name: Joi.string(),
header: Joi.string().allow('').optional(),
purpose: Joi.string().allow('').optional(),
type: Joi.string().valid('O', 'P')
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'channel',
channel: validate(schema, props)
}
}

View File

@@ -0,0 +1,20 @@
const Joi = require('joi')
const validate = require('./validate')
//
// Define the schema
//
const schema = {
header: Joi.string().allow('').optional(),
members: Joi.array().items(Joi.string()).min(2).max(8)
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'direct_channel',
direct_channel: validate(schema, props)
}
}

21
lib/factory/directPost.js Normal file
View File

@@ -0,0 +1,21 @@
const Joi = require('joi')
const validate = require('./validate')
const postPartial = require('./postPartial')
//
// Define the schema
//
const schema = {
channel_members: Joi.array().items(Joi.string()).min(2),
...postPartial,
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'direct_post',
direct_post: validate(schema, props)
}
}

20
lib/factory/emoji.js Normal file
View File

@@ -0,0 +1,20 @@
const Joi = require('joi')
const validate = require('./validate')
//
// Define the schema
//
const schema = {
name: Joi.string(),
image: Joi.string(),
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'emoji',
emoji: validate(schema, props)
}
}

19
lib/factory/index.js Normal file
View File

@@ -0,0 +1,19 @@
const version = require('./version')
const emoji = require('./emoji')
const team = require('./team')
const channel = require('./channel')
const user = require('./user')
const post = require('./post')
const directChannel = require('./directChannel')
const directPost = require('./directPost')
module.exports = {
version,
emoji,
team,
channel,
user,
post,
directChannel,
directPost
}

22
lib/factory/post.js Normal file
View File

@@ -0,0 +1,22 @@
const Joi = require('joi')
const validate = require('./validate')
const postPartial = require('./postPartial')
//
// Define the schema
//
const schema = {
team: Joi.string(),
channel: Joi.string(),
...postPartial,
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'post',
post: validate(schema, props)
}
}

View File

@@ -0,0 +1,31 @@
const Joi = require('joi')
const attachment = Joi.object({
path: Joi.string()
})
const reaction = Joi.object({
user: Joi.string(),
emoji_name: Joi.string(),
create_at: Joi.number(),
})
const messagePartial = {
user: Joi.string(),
message: Joi.string().allow(''),
attachments: Joi.array().items(attachment).optional(),
flagged_by: Joi.array().items(Joi.string()).optional(),
reactions: Joi.array().items(reaction).optional(),
create_at: Joi.number(),
}
const reply = Joi.object().keys(messagePartial)
//
// Define the schema
//
module.exports = {
...messagePartial,
replies: Joi.array().items(reply).optional(),
}

23
lib/factory/team.js Normal file
View File

@@ -0,0 +1,23 @@
const Joi = require('joi')
const validate = require('./validate')
//
// Define the schema
//
const schema = {
name: Joi.string(),
display_name: Joi.string(),
description: Joi.string(),
type: Joi.string().valid('O', 'I'),
allow_open_invite: Joi.boolean()
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'team',
team: validate(schema, props)
}
}

View File

@@ -0,0 +1,35 @@
const expect = require('chai').expect
const channel = require('../channel')
const basic = {
team: 'test-team',
name: 'test-channel',
display_name: 'Test Channel',
header: 'Test Channel Header',
purpose: 'Test the channel generator',
type: 'P'
}
describe('team factory', function() {
it('should produce a valid object', function() {
var c = channel(basic)
expect(c).to.be.an('object')
expect(c).to.deep.equal({
type: 'channel',
channel: basic
})
})
it('should prevent an invalid type', function() {
try {
channel(Object.assign({}, basic, {
type: 'X'
}))
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"type" must be one of [O, P]')
}
})
})

View File

@@ -0,0 +1,35 @@
const expect = require('chai').expect
const directChannel = require('../directChannel')
const basic = {
members: [
'username1',
'username2'
]
}
describe('team factory', function() {
it('should produce a valid object', function() {
var c = directChannel(basic)
expect(c).to.be.an('object')
expect(c).to.deep.equal({
type: 'direct_channel',
direct_channel: basic
})
})
it('should prevent less than 2 members', function() {
try {
directChannel(Object.assign({}, basic, {
members:[
'username1'
]
}))
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"members" must contain at least 2 items')
}
})
})

View File

@@ -0,0 +1,53 @@
const expect = require('chai').expect
const directPost = require('../directPost')
const basic = {
channel_members: [
'username1',
'username2'
],
user: 'username1',
message: 'carpe diem',
create_at: new Date().getTime()
}
describe('post factory', function() {
it('should produce a valid object', function() {
var p = directPost(basic)
expect(p).to.be.an('object')
expect(p).to.deep.equal({
type: 'direct_post',
direct_post: basic
})
})
it('should ensure message is not empty', function() {
try {
var p = directPost(Object.assign({}, basic, {
message: ''
}))
expect(p).to.be.undefined
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"message" is not allowed to be empty')
}
})
it('should prevent less than 2 members', function() {
try {
var p = directPost(Object.assign({}, basic, {
channel_members:[
'username1'
]
}))
expect(p).to.be.undefined
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"channel_members" must contain at least 2 items')
}
})
})

35
lib/factory/test/post.js Normal file
View File

@@ -0,0 +1,35 @@
const expect = require('chai').expect
const post = require('../post')
const basic = {
team: 'test',
channel: 'channel',
user: 'user',
message: 'carpe diem',
create_at: new Date().getTime()
}
describe('post factory', function() {
it('should produce a valid object', function() {
var p = post(basic)
expect(p).to.be.an('object')
expect(p).to.deep.equal({
type: 'post',
post: basic
})
})
it('should ensure message is not empty', function() {
try {
var p = post(Object.assign({}, basic, {
message: ''
}))
expect(p).to.be.undefined
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"message" is not allowed to be empty')
}
})
})

34
lib/factory/test/team.js Normal file
View File

@@ -0,0 +1,34 @@
const expect = require('chai').expect
const team = require('../team')
const basic = {
name: 'test-team',
display_name: 'Test Team',
description: 'A test team for testing',
type: 'O',
allow_open_invite: true
}
describe('team factory', function() {
it('should produce a valid object', function() {
var t = team(basic)
expect(t).to.be.an('object')
expect(t).to.deep.equal({
type: 'team',
team: basic
})
})
it('should prevent an invalid type', function() {
try {
team(Object.assign({}, basic, {
type: 'X'
}))
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"type" must be one of [O, I]')
}
})
})

42
lib/factory/test/user.js Normal file
View File

@@ -0,0 +1,42 @@
const expect = require('chai').expect
const user = require('../user')
const basic = {
username: 'user.name',
email: 'user@example.gov',
auth_service: 'ldap',
auth_data: 'username-field-reference',
teams: [{
name: 'test',
channels: [{
name: 'channel-1'
}, {
name: 'channel-2'
}]
}]
}
describe('user factory', function() {
it('should produce a valid object', function() {
var u = user(basic)
expect(u).to.be.an('object')
expect(u).to.deep.equal({
type: 'user',
user: basic
})
})
it('should ensure email is valid', function() {
try {
var u = user(Object.assign({}, basic, {
email: 'foo@bar'
}))
expect(u).to.be.undefined
}
catch (e) {
expect(e).to.be.an('error')
expect(e.details[0].message).to.equal('"email" must be a valid email')
}
})
})

View File

@@ -0,0 +1,13 @@
const expect = require('chai').expect
const version = require('../version')
describe('version factory', function() {
it('should produce a valid version', function() {
var v = version()
expect(v).to.be.an('object')
expect(v).to.deep.equal({
type: 'version',
version: 1
})
})
})

72
lib/factory/user.js Normal file
View File

@@ -0,0 +1,72 @@
const Joi = require('joi')
const validate = require('./validate')
const validateRoles = function (value, helper, allowed) {
const roles = value.split(' ')
if (value.length < 8) {
return helper.message('Password must be at least 8 characters long')
} else {
return true
}
}
//
// Define the schema
//
const schema = {
profile_image: Joi.string().optional(),
username: Joi.string(),
email: Joi.string().email({
errorLevel: true,
minDomainAtoms: 2
}),
auth_service: Joi.string().valid(
'',
'gitlab',
'ldap',
'saml',
'google',
'office365'
).optional(),
auth_data: Joi.string().optional().allow(''),
password: Joi.string().optional(),
nickname: Joi.string().optional(),
first_name: Joi.string().optional(),
last_name: Joi.string().optional(),
position: Joi.string().optional(),
roles: Joi.string().optional().valid(
'system_user',
'system_admin system_user'
),
teams: Joi.array().items(
Joi.object({
name: Joi.string(),
roles: Joi.string().optional().valid(
'team_user',
'team_admin team_user'
),
channels: Joi.array().items(
Joi.object({
name: Joi.string(),
roles: Joi.string().optional().valid(
'channel_user',
'channel_user channel_admin'
)
})
)
})
),
notify_props: Joi.object({
mention_keys: Joi.string().optional().allow(''),
}).optional(),
}
//
// Generate a valid object
//
module.exports = function (props) {
return {
type: 'user',
user: validate(schema, props)
}
}

21
lib/factory/validate.js Normal file
View File

@@ -0,0 +1,21 @@
const Joi = require('joi')
module.exports = function (schema, props) {
//
// Validate and remove unknown keys
//
var {error, value} = Joi.validate(props, schema, {
presence: 'required',
stripUnknown: true
})
//
// Throw validation errors
//
if (error) {
throw error
}
//
// Return the value
//
return value
}

11
lib/factory/version.js Normal file
View File

@@ -0,0 +1,11 @@
//
// Generate a version object. This
// is the only valid value as of
// now
//
module.exports = function() {
return {
type: 'version',
version: 1
}
}

15
lib/log.js Normal file
View File

@@ -0,0 +1,15 @@
const bunyan = require('bunyan')
module.exports = bunyan.createLogger({
name: 'mm-etl',
streams: [
{
level: 'info',
stream: process.stdout
},
{
level: 'info',
path: 'mm-etl.log'
}
]
})

114
lib/modules/channels.js Normal file
View File

@@ -0,0 +1,114 @@
const _ = require('lodash')
const slug = require('slug')
const XML = require('xmldoc')
const Factory = require('../factory')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'channels'
})
module.exports = function(context) {
//
// Select all of the rooms
//
return context.jabber.fetch(
'SELECT room_jid, subject, config FROM dbo.tc_rooms'
)
//
// The convert them to channels and
// write them to the datafile
//
.then(function(results) {
log.info(`${results.recordset.length} records found`)
//
// Define a map to hold the channels
// info for downstream modules
//
var channels = {}
//
// Iterate over the record set and
// create a channel for each room
//
results.recordset.forEach(function(room) {
log.debug(room)
//
// Define the channel and add
// it to the context
//
var channel = channels[room.room_jid] = {
team: context.values.team.name,
name: slug(toName(room.room_jid)),
display_name: toDisplayName(room.room_jid),
header: toDescription(room.subject),
purpose: toDescription(room.subject),
type: toType(room.config)
}
//
// Write the channel data to the
// output
//
log.info(`... writing ${channel.name}`)
context.output.write(
Factory.channel(channel)
)
})
//
// Add the channel map to the context
//
context.values.channels = channels
//
// Resolve the promise
//
return context
})
}
//
// Parses the name from a room jid
//
const toName = function (jid='') {
return jid.substr(0, jid.indexOf('@')).replace(/\\20/g, ' ')
}
//
// Formats the display name
//
const toDisplayName = function (jid='') {
return _.startCase(_.toLower(toName(jid)))
}
//
// Returns a description from the subject
//
const toDescription = function (subject='') {
return subject
}
//
// Parses the channel type from the
// config field
//
const toType = function (xml='<x/>') {
//
// Parse the config
//
var config = new XML.XmlDocument(xml)
//
// Extract the value. If it does not exist,
// we default to '1' - private
//
var value = _.get(
config.childWithAttribute('var', 'members-only'),
'firstChild.val',
'1'
)
//
// If members-only is '1', return private
// otherwise, return public
//
return value === '1' ? 'P' : 'O'
}

View File

@@ -0,0 +1,89 @@
const Factory = require('../factory')
const transform = require('./transform')
const Utils = require('./utils')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'directChannels'
})
module.exports = function(context) {
return new Promise(function(resolve /*, reject */) {
log.info('streaming records')
//
// Array to accumulate the channel
// member pairs
//
var channels = {}
//
// Query messages from Jabber and pipe
// through the post transform and
// then to the output. We use pipe to
// handle very large data sets using
// streams
//
context.jabber.pipe(
//
// Define the query
//
`SELECT DISTINCT to_jid, from_jid FROM dbo.jm
WHERE msg_type = 'c'
AND direction = 'I'
AND (body_string != '' or datalength(body_text) > 0)`,
//
// Define the tranform
//
transform(function(result, encoding, callback) {
try {
log.debug(result)
//
// Generate the members array for the
// direct channel
//
let members = Utils.members(
context.values.users,
result.to_jid,
result.from_jid
)
//
// If there at least two members
//
if(Utils.membersAreValid(members)) {
var key = `${members[0]}|${members[1]}`
//
// And we haven't processed this pair
// already
//
if(!channels[key]) {
channels[key] = members
log.info(`... writing ${members}`)
context.output.write(
Factory.directChannel({
members
})
)
}
}
} catch (err) {
log.error(`... ignoring directChannel from: ${result.from_jid} to: ${result.to_jid} on error: ${err.message}.`)
}
//
// Invoke the call to mark that we are
// done with the chunk
//
return callback()
},
//
// Define the callback to be invoked
// on finish
//
function() {
log.info('... finished')
resolve(context)
})
)
})
}

106
lib/modules/directPosts.js Normal file
View File

@@ -0,0 +1,106 @@
const Factory = require('../factory')
const transform = require('./transform')
const Utils = require('./utils')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'directPosts'
})
module.exports = function(context) {
return new Promise(function(resolve /*, reject */) {
log.info('streaming records')
//
// Keep track of the number of posts
// written for logging
//
var written = 0
//
// Query messages from Jabber and pipe
// through the post transform and
// then to the output. We use pipe to
// handle very large data sets using
// streams
//
context.jabber.pipe(
//
// Define the query
//
`SELECT to_jid, from_jid, sent_date, body_string, body_text FROM dbo.jm
WHERE msg_type = 'c'
AND direction = 'I'
AND (body_string != '' or datalength(body_text) > 0)`,
//
// Define the tranform
//
transform(function(message, encoding, callback) {
try {
log.debug(message)
//
// Generate the members array for the
// direct channel
//
let members = Utils.members(
context.values.users,
message.to_jid,
message.from_jid
)
//
// Ensure we have at least two channel
// members before we can write the message
//
if (Utils.membersAreValid(members)) {
//
// Base post props
//
var post = {
channel_members: members,
user: Utils.username(context.values.users, message.from_jid),
create_at: Utils.millis(message.sent_date)
}
//
// Process each chunk
//
Utils.body(message).forEach(function(chunk) {
context.output.write(
Factory.directPost(Object.assign({}, post, {
message: chunk
}))
)
//
// Log progress periodically
//
written += 1
if(written % 1000 == 0) {
log.info(`... wrote ${written} posts`)
}
})
} else {
log.warn({
to_jid: message.to_jid,
from_jid: message.from_jid
}, '... skipping message with invalid channel members')
}
} catch (err) {
log.error(`... ignoring directPost from: ${message.from_jid} to: ${message.to_jid} on error: ${err.message}.`)
}
//
// Invoke the call to mark that we are
// done with the chunk
//
return callback()
},
//
// Define the callback to be invoked
// on finish
//
function() {
log.info(`... finished writing ${written} posts`)
resolve(context)
})
)
})
}

12
lib/modules/end.js Normal file
View File

@@ -0,0 +1,12 @@
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'end'
})
module.exports = function(context) {
log.info('end')
context.output.end()
}

21
lib/modules/index.js Normal file
View File

@@ -0,0 +1,21 @@
const start = require('./start')
const version = require('./version')
const team = require('./team')
const channels = require('./channels')
const users = require('./users')
const posts = require('./posts')
const directChannels = require('./directChannels')
const directPosts = require('./directPosts')
const end = require('./end')
module.exports = {
start,
version,
team,
channels,
users,
posts,
directChannels,
directPosts,
end
}

100
lib/modules/posts.js Normal file
View File

@@ -0,0 +1,100 @@
const Factory = require('../factory')
const transform = require('./transform')
const Utils = require('./utils')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'posts'
})
module.exports = function(context) {
return new Promise(function(resolve /*, reject */) {
log.info('streaming records')
//
// Keep track of the number of posts
// written for logging
//
var written = 0
//
// Query messages from Jabber and pipe
// through the post transform and
// then to the output. We use pipe to
// handle very large data sets using
// streams
//
context.jabber.pipe(
//
// Define the query
//
`SELECT
msg_id,
to_jid,
from_jid,
sent_date,
body_string,
body_text
FROM dbo.tc_msgarchive WHERE msg_type = 'g' AND (body_string != '' OR datalength(body_text) > 0) `,
//
// Define the tranform
//
transform(function(message, encoding, callback) {
try {
log.debug(message)
//
// Base post props
//
var post = {
team: context.values.team.name,
channel: Utils.channelName(
context.values.channels, message.to_jid
),
user: Utils.username(
context.values.users, message.from_jid
),
create_at: Utils.millis(message.sent_date)
}
//
// Process each chunk
//
Utils.body(message).forEach(function(chunk) {
//
// Write the post object to the
// output
//
context.output.write(
Factory.post(Object.assign({}, post, {
message: chunk
}))
)
//
// Log progress periodically
//
written += 1
if(written % 1000 == 0) {
log.info(`... wrote ${written} posts`)
}
})
}
catch(err) {
log.error(`... ignoring message id:${message.msg_id} on error: ${err.message}.`)
}
//
// Invoke the call to mark that we are
// done with the chunk
//
return callback()
},
//
// Define the callback to be invoked
// on finish
//
function() {
log.info(`... finished writing ${written} posts`)
resolve(context)
})
)
})
}

32
lib/modules/start.js Normal file
View File

@@ -0,0 +1,32 @@
const datafile = require('../datafile')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'start'
})
module.exports = function(context) {
log.info('connecting to jabber')
//
// Connect to the jabber database
//
return context.jabber.connect(context.config)
.then(function() {
log.info(`creating file '${context.config.target.filename}'`)
//
// Create the datafile and add
// it to the context
//
context.output = datafile(
context.config.target.filename,
process.exit
)
//
// Resolve the promise
//
return context
})
}

29
lib/modules/team.js Normal file
View File

@@ -0,0 +1,29 @@
const Factory = require('../factory')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'team'
})
module.exports = function(context) {
log.info(`writing team '${context.config.define.team.name}'`)
//
// Store the team object for use
// by other modules
//
context.values.team = context.config.define.team
//
// Write the team object to the
// output
//
context.output.write(Factory.team(
context.config.define.team
))
//
// Return a resolved promise
//
return Promise.resolve(context)
}

View File

@@ -0,0 +1,61 @@
const expect = require('chai').expect
const channels = require('../channels')
const Fixtures = require('./fixtures')
const context = require('./context')()
describe('modules.channels', function() {
it('should process channel objects', function(done) {
context.values.team = context.config.define.team
context.jabber.fetch.returns(Promise.resolve({
recordset: Fixtures.channels
}))
channels(context)
.then(function (c) {
expect(c).to.equal(context)
expect(Object.keys(c.values.channels).length).equals(3)
expect(c.output.write.args[0][0]).to.deep.equal({
type: 'channel',
channel: {
team: 'test',
name: 'admin',
display_name: 'Admin',
header: 'Admin Room',
purpose: 'Admin Room',
type: 'O'
}
})
expect(c.output.write.args[1][0]).to.deep.equal({
type: 'channel',
channel: {
team: 'test',
name: 'test-room',
display_name: 'Test Room',
header: '',
purpose: '',
type: 'O'
}
})
expect(c.output.write.args[2][0]).to.deep.equal({
type: 'channel',
channel: {
team: 'test',
name: 'tonyd_20161206_1432',
display_name: 'Tonyd 20161206 1432',
header: '',
purpose: '',
type: 'O'
}
})
done()
})
})
afterEach(function() {
context.jabber.fetch.reset()
context.output.write.reset()
})
})

View File

@@ -0,0 +1,38 @@
const { spy, stub } = require('sinon')
module.exports = function() {
return {
config: {
source: {
uri: 'mssql://username:password@server:1433/jabber?encrypt=true'
},
target: {
filename: 'data.json'
},
define: {
team: {
name: 'test',
display_name: 'Test Team',
description: 'Our Test Team',
type: 'I',
allow_open_invite: false
},
user: {
auth_service: 'ldap',
}
}
},
jabber: {
connect: stub().returns(Promise.resolve()),
fetch: stub(),
pipe: stub()
},
values: {
},
output: {
write: spy(),
end: spy()
}
}
}

View File

@@ -0,0 +1,70 @@
const expect = require('chai').expect
const directChannels = require('../directChannels')
const Fixtures = require('./fixtures')
const FakeDB = require('./fakedb')
const sink = require('./sink')
const context = require('./context')()
describe('modules.directChannels', function() {
before(function() {
//
// Set up context values
//
context.values = {
users: {
'person.one@example.com': {
username: 'person.one'
},
'person.two@example.com': {
username: 'person.two'
},
'person.three@example.com': {
username: 'person.three'
}
}
}
})
beforeEach(function() {
//
// Stub the output stream
//
context.output = sink()
})
it('should process direct channel objects', function(done) {
//
// Set up the DB
//
context.jabber = new FakeDB(Fixtures.directChannels)
//
// Process the data
//
directChannels(context).then(function(c) {
expect(c).to.equal(context)
expect(c.output.write.args).to.deep.equal(
[
[
{
type: 'direct_channel',
direct_channel: {
members: ['person.one', 'person.two']
}
}
],
[
{
type: 'direct_channel',
direct_channel: {
members: ['person.one', 'person.three']
}
}
]
]
)
done()
})
})
})

View File

@@ -0,0 +1,91 @@
const expect = require('chai').expect
const directPosts = require('../directPosts')
const Fixtures = require('./fixtures')
const FakeDB = require('./fakedb')
const sink = require('./sink')
const context = require('./context')()
describe('modules.directPosts', function() {
before(function() {
//
// Set up context values
//
context.values = {
users: {
'person.one@example.com': {
username: 'person.one'
},
'person.two@example.com': {
username: 'person.two'
},
'person.three@example.com': {
username: 'person.three'
}
}
}
})
beforeEach(function() {
//
// Stub the output stream
//
context.output = sink()
})
it('should process direct channel objects', function(done) {
//
// Set up the DB
//
context.jabber = new FakeDB(Fixtures.directPosts)
//
// Process the data
//
directPosts(context).then(function(c) {
expect(c).to.equal(context)
expect(c.output.write.args).to.deep.equal([
[
{
type: 'direct_post',
direct_post: {
channel_members: [
'person.one', 'person.two'
],
user: 'person.two',
message: 'message 01',
create_at: 1497066628513
}
}
],
[
{
type: 'direct_post',
direct_post: {
channel_members: [
'person.one', 'person.three'
],
user: 'person.three',
message: 'message 02',
create_at: 1497066628514
}
}
],
[
{
type: 'direct_post',
direct_post: {
channel_members: [
'person.one', 'person.two'
],
user: 'person.one',
message: 'message 03',
create_at: 1497066628515
}
}
]
])
done()
})
})
})

14
lib/modules/test/end.js Normal file
View File

@@ -0,0 +1,14 @@
const expect = require('chai').expect
const end = require('../end')
const context = require('./context')()
describe('modules.end', function() {
it('should close the output stream', function() {
end(context)
expect(context.output.end.called).to.be.true
})
afterEach(function() {
context.output.end.reset()
})
})

View File

@@ -0,0 +1,50 @@
//
// Implements a fake db for testing
//
class FakeDB {
//
// Constructor
//
constructor(data) {
this.data = data
this.fetch = this.fetch.bind(this)
this.pipe = this.pipe.bind(this)
}
//
// Simulate a db connection by just returning
// a promise
//
connect() {
return Promise.resolve()
}
//
// Returns all results in one batch
//
fetch() {
return Promise.resolve({
recordset: this.data
})
}
//
// Delivers results to the specified write
// stream
//
pipe(query, writable) {
this.data.forEach(function(record) {
writable.write(record)
})
writable.end()
return writable
}
}
//
// Export the class
//
module.exports = FakeDB

15
lib/modules/test/fixtures/channels.js vendored Normal file
View File

@@ -0,0 +1,15 @@
module.exports = [
{
room_jid: 'admin@room.example.com',
subject: 'Admin Room',
config: '<x type=\'submit\' xmlns=\'jabber:x:data\'><field type=\'boolean\' var=\'persistent\'><value>1</value></field><field type=\'text-single\' var=\'max-persistent-history\'><value>15</value></field><field type=\'boolean\' var=\'open-membership\'><value>1</value></field><field type=\'boolean\' var=\'members-only\'><value>0</value></field><field type=\'boolean\' var=\'allow-gc\'><value>1</value></field><field type=\'boolean\' var=\'anonymous\'><value>1</value></field><field type=\'boolean\' var=\'show-unavailable\'><value>1</value></field><field type=\'list-single\' var=\'invite-role\'><value>participant</value></field><field type=\'boolean\' var=\'password\'><value>0</value></field><field type=\'text-private\' var=\'secret\'><value/></field><field type=\'list-single\' var=\'subject-acl\'><value>moderator</value></field><field type=\'boolean\' var=\'strip-xhtml\'><value>0</value></field><field type=\'boolean\' var=\'moderated-room\'><value>0</value></field></x>',
}, {
room_jid: 'test\\20room@room.example.com',
subject: '',
config: '<x type=\'submit\' xmlns=\'jabber:x:data\'><field type=\'boolean\' var=\'persistent\'><value>1</value></field><field type=\'text-single\' var=\'max-persistent-history\'><value>15</value></field><field type=\'boolean\' var=\'open-membership\'><value>1</value></field><field type=\'boolean\' var=\'members-only\'><value>0</value></field><field type=\'boolean\' var=\'allow-gc\'><value>1</value></field><field type=\'boolean\' var=\'anonymous\'><value>0</value></field><field type=\'boolean\' var=\'show-unavailable\'><value>1</value></field><field type=\'list-single\' var=\'invite-role\'><value>participant</value></field><field type=\'boolean\' var=\'password\'><value>0</value></field><field type=\'text-private\' var=\'secret\'><value/></field><field type=\'list-single\' var=\'subject-acl\'><value>moderator</value></field><field type=\'boolean\' var=\'strip-xhtml\'><value>0</value></field><field type=\'boolean\' var=\'moderated-room\'><value>0</value></field></x>',
}, {
room_jid: 'tonyd_20161206_1432@room.example.com',
subject: '',
config: '<x type=\'submit\' xmlns=\'jabber:x:data\'><field type=\'boolean\' var=\'persistent\'><value>1</value></field><field type=\'text-single\' var=\'max-persistent-history\'><value>15</value></field><field type=\'boolean\' var=\'open-membership\'><value>1</value></field><field type=\'boolean\' var=\'members-only\'><value>0</value></field><field type=\'boolean\' var=\'allow-gc\'><value>1</value></field><field type=\'boolean\' var=\'anonymous\'><value>0</value></field><field type=\'boolean\' var=\'show-unavailable\'><value>1</value></field><field type=\'list-single\' var=\'invite-role\'><value>participant</value></field><field type=\'boolean\' var=\'password\'><value>0</value></field><field type=\'text-private\' var=\'secret\'><value/></field><field type=\'list-single\' var=\'subject-acl\'><value>moderator</value></field><field type=\'boolean\' var=\'strip-xhtml\'><value>0</value></field><field type=\'boolean\' var=\'moderated-room\'><value>0</value></field></x>',
}
]

View File

@@ -0,0 +1,15 @@
module.exports = [
{
to_jid: 'person.one@example.com/rnq1gmague',
from_jid: 'person.two@example.com/objc04v73q'
}, {
to_jid: 'person.one@example.com/rnq1gmague',
from_jid: 'person.three@example.com/w32ugtn6ch'
}, {
to_jid: 'person.two@example.com',
from_jid: 'person.one@example.com/blrjlefa3a'
}, {
to_jid: 'person.two@example.com/lhdms8czsw',
from_jid: 'person.one@example.com/blrjlefa3a'
}
]

View File

@@ -0,0 +1,27 @@
module.exports = [
{
to_jid: 'person.one@example.com/Jabber Messenger Desktop',
from_jid: 'person.two@example.com/g1b9b8hs09',
sent_date: '2017-06-10T03:50:28.513Z',
body_string: 'message 01',
body_text: ''
}, {
to_jid: 'person.one@example.com/Jabber Messenger Desktop',
from_jid: 'person.three@example.com/g1b9b8hs09',
sent_date: '2017-06-10T03:50:28.514Z',
body_string: 'message 02',
body_text: ''
}, {
to_jid: 'person.two@example.com/g1b9b8hs09',
from_jid: 'person.one@example.com/Jabber Messenger Desktop',
sent_date: '2017-06-10T03:50:28.515Z',
body_string: '',
body_text: 'message 03'
}, {
to_jid: 'person.two@example.com/g1b9b8hs09',
from_jid: 'person.two@example.com/g1b9b8hs08',
sent_date: '2017-06-10T03:50:28.515Z',
body_string: 'This should not be exported',
body_text: ''
}
]

13
lib/modules/test/fixtures/index.js vendored Normal file
View File

@@ -0,0 +1,13 @@
const channels = require('./channels')
const users = require('./users')
const posts = require('./posts')
const directChannels = require('./directChannels')
const directPosts = require('./directPosts')
module.exports = {
channels,
users,
posts,
directChannels,
directPosts
}

43
lib/modules/test/fixtures/posts.js vendored Normal file
View File

@@ -0,0 +1,43 @@
module.exports = {
ok: [{
msg_id: '3315',
to_jid: 'uat-appsupport@conference.example.com',
from_jid: 'micahel.cross@example.com',
sent_date: '2017-06-05T20:08:38.263Z',
body_string: 'I meant thick',
body_text: ''
}, {
msg_id: '3334',
to_jid: 'uat-appsupport@conference.example.com',
from_jid: 'micahel.cross@example.com',
sent_date: '2017-06-05T20:08:38.263Z',
body_string: 'that is when I came on again',
body_text: ''
}],
userNotFound: [{
msg_id: '3334',
to_jid: 'uat-appsupport@conference.example.com',
from_jid: 'terrence.flynn@example.com',
sent_date: '2017-06-05T20:08:38.263Z',
body_string: 'that is when I came on again',
body_text: ''
}, {
msg_id: '3315',
to_jid: 'uat-appsupport@conference.example.com',
from_jid: 'micahel.cross@example.com',
sent_date: '2017-06-05T20:08:38.263Z',
body_string: 'I meant thick',
body_text: ''
}],
bodyNotFound: [{
msg_id: '3315',
to_jid: 'uat-appsupport@conference.example.com',
from_jid: 'micahel.cross@example.com',
sent_date: '2017-06-05T20:08:38.263Z',
body_string: '',
body_text: ''
}]
}

57
lib/modules/test/fixtures/users.js vendored Normal file
View File

@@ -0,0 +1,57 @@
module.exports = [
{
room_jid: 'admin@conference.example.com',
real_jid: 'micahel.cross@example.com'
}, {
room_jid: 'admin@conference.example.com',
real_jid: 'sbarclay@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'anthony.brown@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'james.seegar1@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'jarrett.jennings2@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'micahel.cross@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'michael.rhoades3@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'michael.rock@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'terrence.flynn@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'tony.dilisio2@example.com'
}, {
room_jid: 'uat-appsupport@conference.example.com',
real_jid: 'william.fleming2@example.com'
}, {
room_jid: 'test\\20room@conference.example.com',
real_jid: 'lt.u755@example.com'
}, {
room_jid: 'test\\20room@conference.example.com',
real_jid: 'lt.u755@example.com/9fyg1zayoi'
}, {
room_jid: 'the\\20cool\\20room@conference.example.com',
real_jid: 'cody.webb@example.com'
}, {
room_jid: 'tonyd_20161206_1432@conference.example.com',
real_jid: 'tony.dilisio2@example.com'
}, {
room_jid: 'tonyd_20161206_1432@conference.example.com',
real_jid: 'julie..sokol@example.com'
}, {
real_jid: 'mbcross',
room_jid: null
},{
real_jid: 'cross,\\20michael@chat.dhs.gov',
room_jid: null
}
]

131
lib/modules/test/posts.js Normal file
View File

@@ -0,0 +1,131 @@
const expect = require('chai').expect
const posts = require('../posts')
const Fixtures = require('./fixtures')
const FakeDB = require('./fakedb')
const sink = require('./sink')
const context = require('./context')()
describe('modules.posts', function() {
before(function() {
//
// Set up context values
//
context.values = {
team: context.config.define.team,
channels: {
'uat-appsupport@conference.example.com': {
team: 'test',
name: 'uat-appsupport',
display_name: 'Uat Appsupport',
header: '',
purpose: '',
type: 'P'
}
},
users: {
'micahel.cross@example.com': {
username: 'micahel.cross',
email: 'micahel.cross@example.com',
auth_service: 'ldap',
teams: [
{
name: 'test',
channels: [
{
name: 'uat-appsupport'
}
]
}
]
}
}
}
})
beforeEach(function() {
//
// Stub the output stream
//
context.output = sink()
})
it('should process post objects', function(done) {
//
// Set up the DB
//
context.jabber = new FakeDB(Fixtures.posts.ok)
//
// Process the posts
//
posts(context).then(function(c) {
expect(c).to.equal(context)
expect(context.output.write.callCount).to.equal(2)
let post = c.output.write.args[0][0]
expect(post).to.deep.equal({
type: 'post',
post: {
team: 'test',
channel: 'uat-appsupport',
user: 'micahel.cross',
message: 'I meant thick',
create_at: 1496693318263
}
})
expect(new Date(post.post.create_at).toISOString()).to.equal('2017-06-05T20:08:38.263Z')
done()
}).catch(function(e){
expect(e).to.be.undefined
})
})
it('should fail on user not found', function(done) {
//
// Set up the DB
//
context.jabber = new FakeDB(Fixtures.posts.userNotFound)
//
// Process the posts
//
posts(context).then(function(c) {
expect(c).to.equal(context)
expect(c.output.write.callCount).to.equal(1)
let post = c.output.write.args[0][0]
expect(post).to.deep.equal({
type: 'post',
post: {
team: 'test',
channel: 'uat-appsupport',
user: 'micahel.cross',
message: 'I meant thick',
create_at: 1496693318263
}
})
done()
}).catch(function(e){
expect(e).to.be.null
done()
})
})
it('should fail on body not found', function(done) {
//
// Set up the DB
//
context.jabber = new FakeDB(Fixtures.posts.bodyNotFound)
//
// Process the posts
//
posts(context).then(function(c) {
expect(c).to.equal(context)
expect(c.output.write.callCount).to.equal(0)
done()
}).catch(function(e){
expect(e).to.be.null
done()
})
})
})

18
lib/modules/test/sink.js Normal file
View File

@@ -0,0 +1,18 @@
const { spy } = require('sinon')
const { Writable } = require('stream')
//
// Returns a spied writable stream
//
module.exports = function() {
var writable = new Writable({
objectMode: true,
write(chunk, encoding, callback) {
return callback()
}
})
spy(writable, 'write')
return writable
}

30
lib/modules/test/start.js Normal file
View File

@@ -0,0 +1,30 @@
const expect = require('chai').expect
const fs = require('fs')
const start = require('../start')
const context = require('./context')()
describe('modules.start', function() {
var resultHandler = function(err) {
if(err) {
console.log('unlink failed', err)
} else {
console.log('file deleted')
}
}
it('should set up the source and output', function(done) {
start(context)
.then(function (c) {
expect(c).to.equal(context)
expect(fs.existsSync(context.config.target.filename)).to.be.true
done()
})
.catch(function(err){
console.log(err)
})
})
afterEach(function() {
fs.unlink(context.config.target.filename, resultHandler)
})
})

27
lib/modules/test/team.js Normal file
View File

@@ -0,0 +1,27 @@
const expect = require('chai').expect
const team = require('../team')
const context = require('./context')()
describe('modules.team', function() {
it('should write a team object', function(done) {
team(context)
.then(function (c) {
expect(c).to.equal(context)
expect(c.output.write.args[0][0]).to.deep.equal({
type: 'team',
team: {
name: 'test',
display_name: 'Test Team',
description: 'Our Test Team',
type: 'I',
allow_open_invite: false
}
})
done()
})
})
afterEach(function() {
context.output.write.reset()
})
})

88
lib/modules/test/users.js Normal file
View File

@@ -0,0 +1,88 @@
const expect = require('chai').expect
const users = require('../users')
const Fixtures = require('./fixtures')
const context = require('./context')()
describe('modules.users', function() {
before(function(){
context.values = {
team: context.config.define.team,
channels: {
'admin@conference.example.com': {
team: 'test',
name: 'admin',
display_name: 'Admin',
header: 'Admin Test room',
purpose: 'Admin Test room',
type: 'O'
},
'uat-appsupport@conference.example.com': {
team: 'hsin',
name: 'uat-appsupport',
display_name: 'Uat Appsupport',
header: '',
purpose: '',
type: 'P'
}
}
}
})
it('should process user objects', function(done) {
context.jabber.fetch.returns(Promise.resolve({recordset: Fixtures.users}))
users(context).then(function(c) {
expect(c).to.equal(context)
expect(Object.keys(c.values.users).length).equals(13)
expect(c.output.write.args[0][0]).to.deep.equal({
type: 'user',
user: {
username: 'micahel.cross',
email: 'micahel.cross@example.com',
auth_service: 'ldap',
auth_data: 'MICAHEL.CROSS',
teams: [
{
name: 'test',
channels: [
{
name: 'admin'
}, {
name: 'uat-appsupport'
}
]
}
]
}
})
expect(c.output.write.args[1][0]).to.deep.equal({
type: 'user',
user: {
username: 'sbarclay',
email: 'sbarclay@example.com',
auth_service: 'ldap',
auth_data: 'SBARCLAY',
teams: [
{
name: 'test',
channels: [
{
name: 'admin'
}
]
}
]
}
})
done()
})
})
afterEach(function() {
context.jabber.fetch.reset()
context.output.write.reset()
})
})

View File

@@ -0,0 +1,21 @@
const expect = require('chai').expect
const version = require('../version')
const context = require('./context')()
describe('modules.version', function() {
it('should write a version object', function(done) {
version(context)
.then(function (c) {
expect(c).to.equal(context)
expect(c.output.write.args[0][0]).to.deep.equal({
type: 'version',
version: 1
})
done()
})
})
afterEach(function() {
context.output.write.reset()
})
})

9
lib/modules/transform.js Normal file
View File

@@ -0,0 +1,9 @@
const { Transform } = require('stream')
module.exports = function(transform, callback) {
return new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform
}).on('finish', callback)
}

115
lib/modules/users.js Normal file
View File

@@ -0,0 +1,115 @@
const _ = require('lodash')
const Factory = require('../factory')
const Utils = require('./utils')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'users'
})
module.exports = function(context) {
//
// Select all of the users
//
const where = 'msg_type = \'c\''
return context.jabber.fetch(`
SELECT real_jid, room_jid FROM dbo.tc_users
UNION ALL (
SELECT from_jid AS real_jid, NULL AS room_jid FROM dbo.jm WHERE ${where}
UNION
SELECT to_jid AS real_jid, NULL AS room_jid FROM dbo.jm WHERE ${where}
)
`)
//
// Build up the user objects and then
// write them to the output
//
.then(function(results) {
log.info(`${results.recordset.length} records found`)
//
// Map of users
//
var users = {}
//
// Iterate over the record set and
// assemble the user objects
//
results.recordset.forEach(function(record) {
log.debug(record)
//
// Clean the real_jid to ensure we don't
// have duplicates with /<string> suffixes
//
var real_jid = Utils.realJID(record.real_jid)
//
// Generate the username fro the real_jid
//
var username = toUsername(real_jid)
//
// Return a reference to the user in the user map
// or add one if it doesn't yet exist
//
var user = users[real_jid] = _.get(users, real_jid, {
username,
email: real_jid,
auth_service: context.config.define.user.auth_service,
auth_data: username.toUpperCase(),
teams: [{
name: context.values.team.name,
channels: []
}]
})
//
// Look up the channel based on the
// room id. For direct messages, the room_jid
// will be null.
//
var channel = context.values.channels[record.room_jid]
//
// Add it to the user
//
if (channel) {
user.teams[0].channels = _.unionBy(user.teams[0].channels, [{
name: channel.name
}], 'name')
} else {
record.room_jid && log.warn(`... channel not found for ${record.room_jid}`)
}
})
//
// Now that the users are assembled, write
// them to the output
//
_.forEach(users, function(user, key) {
try {
context.output.write(
Factory.user(user)
)
log.info(`... writing ${user.username}`)
}
catch(err) {
log.error(`... ignoring ${user.username} on error: ${err.message}.`)
delete users[key]
}
})
//
// Add the users map to the context
//
context.values.users = users
//
// Return the context
//
return context
})
}
//
// Parse the username
//
const toUsername = function (jid='') {
return jid.split('@')[0]
}

103
lib/modules/utils.js Normal file
View File

@@ -0,0 +1,103 @@
const _ = require('lodash')
//
// Declare utils object
//
const utils = {}
//
//
//
utils.chunk = function(body) {
//
// Use regex to create an array
// of strings of max length
//
return body.match(/[\s\S]{1,4000}/g)
}
//
// Removes trailing /<string> suffixes that
// may exist in the user ids. This happens
// if a user logs in to jabber more than
// once at the same time
//
utils.realJID = function (jid='') {
return jid.split('/')[0]
.replace(/\\20+/g, '.')
.replace(/\.\.+/, '.')
}
//
// Lookup values
//
utils.lookup = function (type, map, key) {
var found = map[key]
if(!found) {
throw new Error(`${type} ${key} not found`)
}
return found
}
//
// Obtain the username from a jid
//
utils.username = function (users, jid) {
return utils.lookup('user', users, utils.realJID(jid)).username
}
//
// Obtain the channel name from a jid
//
utils.channelName = function (channels, jid) {
return utils.lookup('channel', channels, jid).name
}
//
// Find the message body
//
utils.body = function (message) {
var body = message.body_string || message.body_text
if(!body) {
throw new Error(`message ${message.msg_id} body is empty`)
}
return utils.chunk(body)
}
//
// Coverts the to / from JIDs to a members
// array
//
utils.members = function(users, to, from) {
//
// We use uniq to remove duplicate
// members in the same channel
//
return _.uniq(_.sortBy([
utils.username(users, to),
utils.username(users, from),
]))
}
//
// Checks if the members list is valid
//
utils.membersAreValid = function(members) {
return _.isArray(members) && members.length > 1
}
//
// Convert ISO to millis
//
utils.millis = function(date) {
return new Date(date).getTime()
}
//
// Export the functions
//
module.exports = utils

12
lib/modules/version.js Normal file
View File

@@ -0,0 +1,12 @@
const Factory = require('../factory')
module.exports = function(context) {
//
// Write the version object
//
context.output.write(Factory.version())
//
// Return a resolved promise
//
return Promise.resolve(context)
}

28
lib/mongo.js Normal file
View File

@@ -0,0 +1,28 @@
const mongo = require('mongodb')
//
// Declare internals
//
let client = {}
//
// Connect to the db and create a connection
// pool
//
module.exports.connect = function (config) {
client = new mongo.MongoClient(config.source.uri)
return client.connect()
}
module.exports.close = function () {
return client.close()
}
module.exports.collection = function (collection) {
return client.db().collection(collection)
}
module.exports.gridFsBucket = function (bucketName) {
return new mongo.GridFSBucket(client.db(), { bucketName })
}

57
lib/mssql.js Normal file
View File

@@ -0,0 +1,57 @@
const SQL = require('mssql')
const log = require('./log')
//
// Declare internals
//
const internals = {}
//
// Connect to the db and create a connection
// pool
//
module.exports.connect = function(config) {
return SQL.connect(config.source.uri)
.then(function(pool) {
internals.pool = pool
})
}
//
// Returns all results in one batch
//
module.exports.fetch = function(query) {
return internals.pool.request().query(query)
}
//
// Delivers results to the specified write
// stream. Use this for large result sets.
//
module.exports.pipe = function(query, writable) {
//
// Create the request
//
const request = internals.pool.request()
//
// Attach the writable stream
//
request.pipe(writable)
//
// Submit the query
//
request.query(query)
//
// Return the writable stream for
// chaining
//
return writable
}
//
// Set up an error handler
//
SQL.on('error', function(err) {
log.error(err)
throw(err)
})

View File

@@ -0,0 +1,55 @@
const _ = require('lodash')
const Factory = require('../factory')
const slug = require("slug");
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'channels'
})
module.exports = async function (context) {
const collection = context.rocketchat.roomsCollection()
const query = { t: { $in: ['c', 'p'] } }
let channels = {}
const cursor = collection.find(query)
const mergeDiscussions = _.get(context, 'config.define.channels.mergeDiscussionIntoParent', false)
const discussions = {}
while (await cursor.hasNext()) {
const result = await cursor.next()
if (mergeDiscussions && result.prid) {
discussions[result._id] = result.prid
log.info(`... skipping channel ${result._id} to merge discussion into parent ${result.prid}`)
continue;
}
// Define the channel and add it to the context
let channel = channels[result._id] = {
team: context.values.team.name,
name: slug(_.toLower(result.name)),
display_name: result.name,
header: result.topic,
purpose: result.description,
type: result.t === 'p' ? 'P' : 'O'
}
const map = _.get(context, `config.define.channels.map.${result.name}`, false)
if (map) {
channel.name = map.name
channel.display_name = map.display_name
}
// Write the channel data to the output
log.info(`... writing ${channel.name} (${result.name})`)
context.output.write(
Factory.channel(channel)
)
}
if (!context.values.discussions) {
context.values.discussions = {}
}
context.values.discussions = Object.assign(context.values.discussions, discussions)
context.values.channels = channels
return context
}

View File

@@ -0,0 +1,60 @@
const Factory = require('../factory')
const Utils = require('./utils')
const _ = require("lodash");
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'directChannels'
})
module.exports = async function(context) {
// Select all of the rooms
const collection = context.rocketchat.roomsCollection()
const query = { t: 'd' }
const directChannels = {}
const cursor = collection.find(query)
const mergeDiscussions = _.get(context, 'config.define.channels.mergeDiscussionIntoParent', false)
const discussions = {}
while (await cursor.hasNext()) {
const result = await cursor.next()
if (mergeDiscussions && result.prid) {
discussions[result._id] = result.prid
log.info(`... skipping direct channel ${result._id} to merge discussion into parent ${result.prid}`)
continue;
}
try {
// Generate the members array for the direct channel
let members = Utils.members(
context.values.users,
result.uids,
)
if (members.length === 1) {
members.push(members[0])
}
// If there at least two members
if (Utils.membersAreValid(members)) {
log.info(`... writing members (${members.join(', ')})`)
let channel = directChannels[result._id] = {
header: result.topic,
members
}
context.output.write(
Factory.directChannel(channel)
)
}
} catch (err) {
log.error(`... ignoring directChannel members (${result.usernames.join(', ')}) on error: ${err.message}.`)
}
}
if (!context.values.discussions) {
context.values.discussions = {}
}
context.values.discussions = Object.assign(context.values.discussions, discussions)
context.values.directChannels = directChannels
return context
}

75
lib/rocketchat/emoji.js Normal file
View File

@@ -0,0 +1,75 @@
const Factory = require('../factory')
const Utils = require('./utils')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'custom emoji'
})
module.exports = async function (context) {
const collection = context.rocketchat.emojiCollection()
const fileCollection = context.rocketchat.collection('custom_emoji')
const cursor = collection.find()
while (await cursor.hasNext()) {
const result = await cursor.next()
// Prepare destination file
const filename = `${result.name}.${result.extension}`
const dest = `${context.config.target.filesPath}/custom_emoji/${filename}`
// Download emoji file
if (result.store.startsWith('FileSystem:')) {
const src = context.config.source.customEmojiPath
const srcFilename = Utils.srcPath(src, filename)
if (!srcFilename) {
return new Error(`source file "${filename}" not found`)
}
// Copy to output dir
Utils.copyFile(srcFilename, dest)
} else if (result.store.startsWith('GridFS:')) {
await Utils.downloadGridFS(context, fileCollection, filename, dest)
} else {
throw new Error(`file system ${file.store} is not supported. Migrate to FileSystem first, see readme.`)
}
// Export custom emoji
let emoji = {
name: result.name,
image: dest,
}
log.info(`... writing ${emoji.name}`)
context.output.write(
Factory.emoji(emoji)
)
}
return context
}
const exportFile = async function (context, collection, fileId) {
const type = collection.collectionName
const file = await collection.findOne({ _id: fileId })
if (!file) {
throw new Error(`file ${fileId} from collection ${type} is missing`)
}
const dest = context.config.target.filesPath
const destFilename = utils.destPath(dest, collection.collectionName, file)
if (file.store.startsWith('FileSystem:')) {
const src = context.config.source.uploadsPath
const srcFilename = utils.srcPath(src, file._id)
if (!srcFilename) {
return new Error(`source file "${file._id}" not found`)
}
// Copy to output dir
utils.copyFile(srcFilename, destFilename)
} else if (file.store.startsWith('GridFS:')) {
await utils.downloadGridFS(context, collection, file._id, destFilename)
} else {
throw new Error(`file system ${file.store} is not supported. Migrate to FileSystem first, see readme.`)
}
return destFilename
}

13
lib/rocketchat/end.js Normal file
View File

@@ -0,0 +1,13 @@
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'end'
})
module.exports = function(context) {
log.info('end')
context.rocketchat.close()
context.output.end()
}

21
lib/rocketchat/index.js Normal file
View File

@@ -0,0 +1,21 @@
const start = require('./start')
const version = require('./version')
const emoji = require('./emoji')
const team = require('./team')
const channels = require('./channels')
const users = require('./users')
const posts = require('./posts')
const directChannels = require('./directChannels')
const end = require('./end')
module.exports = {
start,
version,
emoji,
team,
channels,
users,
posts,
directChannels,
end
}

162
lib/rocketchat/posts.js Normal file
View File

@@ -0,0 +1,162 @@
const Factory = require('../factory')
const Utils = require('./utils')
const _ = require('lodash')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'posts'
})
module.exports = async function (context) {
const collection = context.rocketchat.messagesCollection()
// Keep track of the number of posts written for logging
let written = 0
let memReplyIds = {}
const total = await collection.count()
const cursor = collection.find()
while (await cursor.hasNext()) {
const result = await cursor.next()
let posts = await collectPostData(context, result, memReplyIds, false)
posts.forEach(function(post) {
context.output.write(post.isDirect ? Factory.directPost(post) : Factory.post(post))
})
// Log progress periodically
written += posts.length
if (written % 1000 == 0) {
log.info(`... wrote ${written} posts`)
}
}
log.info(`... finished exporting ${written} posts, ignored ${total - written}`)
return context
}
async function collectPostData(context, result, memReplyIds, isReply) {
if (memReplyIds.hasOwnProperty(result._id)) {
return []
}
try {
// Try to get discussion channel id
let channelId = result.rid
const mergeDiscussions = _.get(context, 'config.define.channels.mergeDiscussionIntoParent', false)
const parentId = _.get(context, `values.discussions.${channelId}`, false)
if (mergeDiscussions && parentId) {
channelId = parentId
}
// Check if direct message
let isDirect = !context.values.channels[channelId] && !!context.values.directChannels[channelId]
let post = {}
let channelInfo = {}
if (isDirect) {
let { members: channel_members } = context.values.directChannels[channelId]
// Ensure we have at least two channel members before we can write the message
if (Utils.membersAreValid(channel_members)) {
channelInfo = { channel_members }
} else {
log.error(`... ignoring message id:${result._id} on error: directChannel ${channelId} not found.`)
return []
}
} else {
channelInfo = {
team: context.values.team.name,
channel: Utils.channelName(
context.values.channels, channelId
),
}
}
if (!isReply) {
Object.assign(post, channelInfo)
}
const reactions = Object.keys(result.reactions || {}).reduce((prev, code) => {
return result.reactions[code].usernames.map((u) => {
return {
user: u,
emoji_name: _.trim(code, ':'),
create_at: Utils.millis(result.ts),
}
}).concat(prev)
}, [])
const flagged_by = (result.starred || []).map(({ _id: uid }) => {
try {
return Utils.username(context.values.users, uid)
} catch (err) {
return undefined
}
}).filter(v => v)
Object.assign(post, {
user: Utils.username(
context.values.users, result.u._id
),
create_at: Utils.millis(result.ts),
reactions,
flagged_by,
isDirect,
})
// Collect data from attachments
let attachments = Utils.processAttachments(context, result)
let file
let body = result.msg
await Promise.all(attachments.map(async (a) => {
if (a.type === 'file') {
file = await a.data
} else if (a.type === 'quote') {
body = a.data
}
}))
if (file && file instanceof Error) {
throw file
}
if (file && file.description) {
body = `File description: \n${file.description} \n\n ${body}`
}
const chunks = body ? Utils.body(body) : [body]
let posts = chunks.map((chunk) => {
return Object.assign({}, post, {
message: chunk
})
})
if (!posts[0]) {
throw new Error(`Post is empty`)
}
if (file) {
posts[0].attachments = [{
path: file.path
}]
}
const replies = []
if (!isReply) {
const collection = context.rocketchat.messagesCollection()
const cursor = collection.find({tmid: result._id })
while (await cursor.hasNext()) {
const reply = await cursor.next()
const replyData = await collectPostData(context, reply, memReplyIds, true)
replyData.forEach(r => replies.push(r))
memReplyIds[reply._id] = true
}
}
Object.assign(posts[0], {
reactions,
flagged_by,
replies,
})
return posts
} catch (err) {
log.error(`... ignoring message id:${result._id} on error: ${err.message}.`)
return []
}
}

32
lib/rocketchat/start.js Normal file
View File

@@ -0,0 +1,32 @@
const datafile = require('../datafile')
const fs = require('fs');
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'start'
})
module.exports = async function(context) {
log.info('preparing files paths')
const dest = context.config.target.filesPath
if (!dest || !fs.existsSync(dest)) {
fs.mkdirSync(dest)
}
if (!fs.lstatSync(dest).isDirectory() || fs.accessSync(dest, fs.constants.W_OK)) {
throw new Error(`Directory "${dest} is not writable"`)
}
log.info('connecting to rocketchat')
await context.rocketchat.connect(context.config)
log.info(`creating file '${context.config.target.filename}'`)
// Create the datafile and add it to the context
context.output = datafile(
context.config.target.filename,
process.exit
)
return context
}

29
lib/rocketchat/team.js Normal file
View File

@@ -0,0 +1,29 @@
const Factory = require('../factory')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'team'
})
module.exports = function(context) {
log.info(`writing team '${context.config.define.team.name}'`)
//
// Store the team object for use
// by other modules
//
context.values.team = context.config.define.team
//
// Write the team object to the
// output
//
context.output.write(Factory.team(
context.config.define.team
))
//
// Return a resolved promise
//
return Promise.resolve(context)
}

105
lib/rocketchat/users.js Normal file
View File

@@ -0,0 +1,105 @@
const Factory = require('../factory')
const Utils = require('./utils')
const { Gitlab } = require('@gitbeaker/node')
const _ = require('lodash')
//
// Initialize the child logger for
// the module
//
const log = require('../log').child({
module: 'users'
})
module.exports = async function(context) {
const collection = context.rocketchat.usersCollection()
let users = {}
const cursor = collection.find({ type: 'user' })
while (await cursor.hasNext()) {
const result = await cursor.next()
const id = result._id
const [first_name, last_name] = result.name.split(' ')
try {
const ldapAuthService = _.get(context, 'config.define.user.ldap_auth_service', '')
const roleMap = _.get(context, 'config.define.user.globalRoleMap', {})
const auth_service = result.ldap ? ldapAuthService : ''
if (result.roles.indexOf('admin') !== -1 && result.roles.indexOf('user') === -1) {
result.roles.push('user')
}
const highlights = _.get(result, 'settings.preferences.highlights', [])
let user = users[id] = {
username: result.username,
first_name,
last_name,
auth_service,
auth_data: await getAuthData(context, result, auth_service),
email: result.emails[0].address,
roles: result.roles.sort().reduce((roles, role) => {
if (roleMap.hasOwnProperty(role)) {
roles.push(roleMap[role])
}
return roles
}, []).join(' '),
teams: [{
name: context.values.team.name,
channels: result.__rooms.map((id) => {
const name = _.get(context, `values.channels.${id}.name`, false)
return name ? { name } : undefined
}).filter(v => v)
}],
notify_props: {
mention_keys: highlights.map(s => s.replace('@', '')).join(','),
},
}
if (auth_service) {
user.auth_service = auth_service
user.auth_data = await getAuthData(context, result, auth_service)
}
if (!user.roles.length) {
delete user.roles
}
const avatar = await getAvatar(context, result)
if (avatar) {
user.profile_image = avatar
}
context.output.write(
Factory.user(user)
)
log.info(`... writing ${user.username}`)
}
catch(err) {
log.error(`... ignoring ${result.username} on error: ${err.message}.`)
delete users[id]
}
}
context.values.users = users
return context
}
async function getAuthData(context, user, auth_service) {
switch (auth_service) {
case 'gitlab':
const {host, token} = _.get(context, 'config.define.user.gitlab')
const api = new Gitlab({ host, token });
const gitlabUser = await api.Users.username(user.username);
if (!gitlabUser || !gitlabUser.length) {
throw new Error(`user ${user.username} is missing in Gitlab`)
}
return `${gitlabUser[0].id}`
case 'ldap':
return user.username.toUpperCase()
default:
return ''
}
}
async function getAvatar(context, user) {
const collection = context.rocketchat.avatarsCollection()
const avatar = await collection.findOne({userId: user._id}, {sort: {_updatedAt: -1}})
if (!avatar) {
return ""
}
return Utils.exportFile(context, collection, avatar._id)
}

191
lib/rocketchat/utils.js Normal file
View File

@@ -0,0 +1,191 @@
const _ = require('lodash')
const fs = require('fs')
const path = require('path')
//
// Declare utils object
//
const utils = {}
//
//
//
utils.chunk = function(body) {
//
// Use regex to create an array
// of strings of max length
//
return body.match(/[\s\S]{1,4000}/g)
}
//
// Lookup values
//
utils.lookup = function (type, map, key) {
var found = map[key]
if(!found) {
throw new Error(`${type} ${key} not found`)
}
return found
}
//
// Obtain the username from a jid
//
utils.username = function (users, id) {
return utils.lookup('user', users, id).username
}
//
// Obtain the channel name from a jid
//
utils.channelName = function (channels, id) {
return utils.lookup('channel', channels, id).name
}
//
// Find the message body
//
utils.body = function (body) {
return utils.chunk(body)
}
utils.processAttachments = function (context, message) {
if (!message.attachments) {
return []
}
return message.attachments.map((attachment) => {
if (attachment.type && attachment.type === 'file') {
return {
type: 'file',
data: utils.processFileAttachment(context, message, attachment),
}
}
if (attachment.text) {
return {
type: 'quote',
data: utils.processQuoteAttachment(message, attachment)
}
}
return {
type: 'unknown'
}
})
}
utils.processQuoteAttachment = function (message, attachment) {
if (!attachment.text) {
return ''
}
const body = (message.msg || '').replace(/\[ \]\(.*msg=.*?\) /, '')
// Convert quote to markdown
return `@${attachment.author_name}:\n ${attachment.text.replace(/^/, '> ')} \n\n${body}`
}
utils.processFileAttachment = async function (context, message, attachment) {
if (!message.file) {
return new Error(`message ${message._id} file is missing`)
}
const collection = context.rocketchat.uploadsCollection()
return {
description: attachment.description,
path: await utils.exportFile(context, collection, message.file._id),
}
}
utils.srcPath = function (srcDir, filename) {
const files = fs.readdirSync(srcDir).filter((fn) => fn.startsWith(filename));
if (files.length !== 1) {
return false
}
return `${srcDir}/${path.basename(files[0])}`
}
utils.destPath = function (destDir, type, file) {
let dest = `${destDir}/${type}/${file._id}/${file.name}`;
const ext = path.extname(dest)
if (!ext || !/^\.[A-Za-z][A-Za-z0-9]*$/.test(ext)) {
if (file.identify && file.identify.format) {
dest += '.' + file.identify.format
} else {
dest += '.' + file.type.split('/')[1]
}
}
return dest
}
utils.exportFile = async function (context, collection, fileId) {
const type = collection.collectionName
const file = await collection.findOne({ _id: fileId })
if (!file) {
throw new Error(`file ${fileId} from collection ${type} is missing`)
}
const dest = context.config.target.filesPath
const destFilename = utils.destPath(dest, collection.collectionName, file)
if (file.store.startsWith('FileSystem:')) {
const src = context.config.source.uploadsPath
const srcFilename = utils.srcPath(src, file._id)
if (!srcFilename) {
return new Error(`source file "${file._id}" not found`)
}
// Copy to output dir
utils.copyFile(srcFilename, destFilename)
} else if (file.store.startsWith('GridFS:')) {
await utils.downloadGridFS(context, collection, file._id, destFilename)
} else {
throw new Error(`file system ${file.store} is not supported. Migrate to FileSystem first, see readme.`)
}
return destFilename
}
utils.copyFile = function (src, dest) {
if (!fs.existsSync(path.dirname(dest))) {
fs.mkdirSync(path.dirname(dest), { recursive: true })
}
if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest)
}
}
utils.downloadGridFS = async function (context, collection, id, dest) {
if (!fs.existsSync(path.dirname(dest))) {
fs.mkdirSync(path.dirname(dest), { recursive: true })
}
if (fs.existsSync(dest)) {
fs.unlinkSync(dest)
}
const bucket = context.rocketchat.gridFsBucket(collection.collectionName)
const destStream = fs.createWriteStream(dest);
bucket.openDownloadStream(id).pipe(destStream);
return new Promise((resolve, reject) => {
destStream.on('finish', resolve);
})
}
utils.members = function(users, usernames) {
return _.uniq(_.sortBy(usernames.map((username) => utils.username(users, username))))
}
//
// Checks if the members list is valid
//
utils.membersAreValid = function(members) {
return _.isArray(members) && members.length > 0
}
//
// Convert ISO to millis
//
utils.millis = function(date) {
return new Date(date).getTime()
}
//
// Export the functions
//
module.exports = utils

12
lib/rocketchat/version.js Normal file
View File

@@ -0,0 +1,12 @@
const Factory = require('../factory')
module.exports = function(context) {
//
// Write the version object
//
context.output.write(Factory.version())
//
// Return a resolved promise
//
return Promise.resolve(context)
}

33
lib/test/datafile.js Normal file
View File

@@ -0,0 +1,33 @@
const expect = require('chai').expect
const fs = require('fs')
const datafile = require('../datafile')
const filename = 'test.json'
describe('datafile', function() {
var resultHandler = function(err) {
if(err) {
console.log('unlink failed', err)
} else {
console.log('file deleted')
}
}
it('should write objects to a file', function(done) {
var d = datafile(filename, () => {
expect(fs.existsSync(filename)).to.be.true
done()
})
d.write({
foo: 'bar'
})
d.end()
})
after(function() {
fs.unlink(filename, resultHandler)
})
})