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

17
.codeclimate.yml Normal file
View File

@@ -0,0 +1,17 @@
engines:
eslint:
enabled: true
duplication:
enabled: true
config:
languages:
- javascript:
fixme:
enabled: true
ratings:
paths:
- context/**
- lib/**
- "**.js"
exclude_paths:
- "**/test/**/*"

20
.eslintrc.yml Normal file
View File

@@ -0,0 +1,20 @@
env:
es6: true
node: true
mocha: true
extends: 'eslint:recommended'
rules:
indent:
- error
- 2
linebreak-style:
- error
- unix
quotes:
- error
- single
semi:
- error
- never
no-console:
- 0

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# application config file
context/config.js
# generated data json file
data.json

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2017 Brightscout, Inc.
Copyright (c) 2026 ООО "Виликс"
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

67
README.md Normal file
View File

@@ -0,0 +1,67 @@
# loop-etl-rocketchat
An ETL framework to migrate data from RocketChat to Loop. This utility exports data from a source RocketChat database and generates a Loop import file.
## Install
1. Install [Node.js](https://nodejs.org/en/) Version [6.11.0 LTS](https://nodejs.org/en/download/) or greater
2. Clone this repo
`$ git clone https://git.wilix.dev/loop/loop-etl-rocketchat`
3. Install dependencies
`$ cd loop-etl-rocketchat`
`$ npm install`
4. Run tests
`$ npm test`
## Export RocketChat
Export supports the following entities:
1. Users and roles. Only global roles are supported
2. Custom emoji. File storage: FileSystem and GridFS
3. Channels
4. Direct channels
5. RocketChat Discussions (subchannels) are partially supported, see below
7. User uploads. File storage: FileSystem and GridFS
8. Posts with per user flag. Channel pins are not supported by import specification.
9. Replies and Reactions
## RocketChat exporting
1. Copy the example config file to config.js
```
cp context/config.example.rocketchat.js context/config.js
```
2. Prepare your source and target configuration
1. Set `source.uploadsPath` for file uploads (user avatars and file uploads)
2. Set `source.customEmojiPath` for custom emojies
3. Set `target.filesPath` for Loop output directory
3. If you have LDAP enabled, Community version of Loop doesn't support LDAP.
If you have Community version, consider configuring ldap mapping to gitlab or disable it to use default login.
Specify it in `config.js`. Set `ldap_auth_service` to map ldap to a Loop login service.
If it's GitLab, configure `gitlab` with `host` and `token` registered in Gitlab with User access.
It is used for id mapping, without it Loop won't import the users and throw an error.
Before migrating to Loop, ensure you have Gitlab integration enabled in Loop and all users are present in Gitlab.
4. If you used RocketChat discussions, they will migrate in separate channels with random names.
You can merge discussions in parent channel with `mergeDiscussionIntoParent`
5. Global channel in Rocket Chat is **General** and in Loop - **Town Square**.
To have only 1 global channel, the configuration provides default example in `channels.map`.
You can specify migration for other channels as well.
6. Run migrate script with `npm run start:rocketchat`
7. Configure Loop (DB and Gitlab integration) before running the migration
8. Run the migration in Loop
## Import
1. Run the Loop import command as explained in the [documentation](https://loop.yonote.ru/share/750208a7-e016-4319-8c7e-fb08a91f51f6)
---
Based on [mattermost-etl](https://github.com/Brightscout/mattermost-etl) and [mattermost-etl-rocketchat](https://github.com/lexbritvin/mattermost-etl-rocketchat), both licensed under MIT.

17
circle.yml Normal file
View File

@@ -0,0 +1,17 @@
general:
artifacts:
- coverage
machine:
node:
version: 6.11.0
dependencies:
override:
- yarn
test:
override:
- yarn run test-coverage
post:
- node_modules/codeclimate-test-reporter/bin/codeclimate.js < coverage/lcov.info

36
context/config.example.js Normal file
View File

@@ -0,0 +1,36 @@
module.exports = {
source: {
uri: 'mongodb://rocketchat:rocketchat@localhost:27017/rocketchat?replicaSet=rs0',
uploadsPath: './uploads',
customEmojiPath: './custom_emoji',
},
target: {
filename: './import.jsonl',
filesPath: './data/bulk-export-attachments'
},
define: {
team: {
// Уникальное системное имя команды (используется в URL, только латиница, цифры и дефисы)
name: 'loop',
// Отображаемое название команды, которое видят пользователи
display_name: 'loop',
// Краткое описание команды
description: 'An example of a team',
// Тип команды: 'I' — закрытая (invite-only), 'O' — открытая (open)
type: 'I',
// Разрешить пользователям приглашать других в команду без участия администратора
allow_open_invite: false
},
channels: {
mergeDiscussionIntoParent: true,
},
user: {
globalRoleMap: {
admin: 'system_admin',
user: 'system_user',
},
'auth_service': null,
'auth_data': null,
}
}
}

13
context/index.js Normal file
View File

@@ -0,0 +1,13 @@
const config = require('./config')
const jabber = require('../lib/mssql')
module.exports = {
config,
jabber,
values: {
//
// Cached values can be stored
// here
//
}
}

38
context/rocketchat.js Normal file
View File

@@ -0,0 +1,38 @@
const config = require('./config')
const rocketchat = require('../lib/mongo')
rocketchat.messagesCollection = function () {
return rocketchat.collection('rocketchat_message')
}
rocketchat.roomsCollection = function () {
return rocketchat.collection('rocketchat_room')
}
rocketchat.usersCollection = function () {
return rocketchat.collection('users')
}
rocketchat.uploadsCollection = function () {
return rocketchat.collection('rocketchat_uploads')
}
rocketchat.avatarsCollection = function () {
return rocketchat.collection('rocketchat_avatars')
}
rocketchat.emojiCollection = function () {
return rocketchat.collection('rocketchat_custom_emoji')
}
module.exports = {
config,
rocketchat,
values: {
//
// Cached values can be stored
// here
//
}
}

49
index.js Normal file
View File

@@ -0,0 +1,49 @@
const context = require('./context')
const log = require('./lib/log')
const {
start,
version,
team,
channels,
users,
posts,
directChannels,
directPosts,
end
} = require('./lib/modules')
//
// Common function log errors and
// terminate the process
//
const abort = function(err) {
log.error(err)
//
// We set a timeout here to
// allow the log streams to finish
// writing.
//
setTimeout(function() {
process.exit(1)
}, 3000)
}
//
// Ensure we trap uncaught exceptions
// and properly abort
//
process.on('uncaughtException', abort)
//
// Let's do it
//
start(context)
.then(version)
.then(team)
.then(channels)
.then(users)
.then(posts)
.then(directChannels)
.then(directPosts)
.then(end)
.catch(abort)

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

6330
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "loop-etl-rocketchat",
"version": "1.0.0",
"description": "An ETL framework to migrate data to Loop",
"author": "Michael DeBonis",
"license": "MIT",
"homepage": "https://git.wilix.dev/loop/loop-etl-rocketchat#readme",
"repository": {
"type": "git",
"url": "git+https://git.wilix.dev/loop/loop-etl-rocketchat.git"
},
"bugs": {
"url": "https://git.wilix.dev/loop/loop-etl-rocketchat/issues"
},
"dependencies": {
"@gitbeaker/node": "^35.6.0",
"bunyan": "^1.8.10",
"joi": "^10.6.0",
"lodash": "^4.17.4",
"mongodb": "^4.7.0",
"mssql": "^4.0.4",
"slug": "^0.9.1",
"xmldoc": "^1.1.0"
},
"devDependencies": {
"chai": "^4.0.2",
"codeclimate-test-reporter": "^0.5.0",
"eslint": "^4.1.0",
"istanbul": "^0.4.5",
"mocha": "^3.4.2",
"sinon": "^2.3.4"
},
"main": "index.js",
"scripts": {
"lint": "eslint *.js context lib --ext .js",
"test": "npm run lint && mocha lib --recursive",
"test-watch": "npm run test -- --watch",
"test-coverage": "istanbul cover _mocha lib -- --recursive",
"start": "node index.js | bunyan",
"start:rocketchat": "node rocketchat.js | bunyan"
}
}

46
rocketchat.js Normal file
View File

@@ -0,0 +1,46 @@
const context = require('./context/rocketchat')
const log = require('./lib/log')
const {
start,
version,
emoji,
team,
channels,
users,
posts,
directChannels,
end
} = require('./lib/rocketchat')
//
// Common function log errors and
// terminate the process
//
const abort = function (err) {
log.error(err)
//
// We set a timeout here to
// allow the log streams to finish
// writing.
//
setTimeout(function () {
process.exit(1)
}, 3000)
}
//
// Ensure we trap uncaught exceptions
// and properly abort
//
process.on('uncaughtException', abort)
start(context)
.then(version)
.then(emoji)
.then(team)
.then(channels)
.then(users)
.then(directChannels)
.then(posts)
.then(end)
.catch(abort)

2293
yarn.lock Normal file

File diff suppressed because it is too large Load Diff