facebot
Version:
Facebook messenger integration in slack
609 lines (531 loc) • 22.4 kB
JavaScript
const SlackBot = require('slackbots');
const Q = require('q');
const _ = require('underscore');
const facebook = require('facebook-chat-api');
const fbUtil = require('./util');
const emoji_lib = require('js-emoji');
const emoji = new emoji_lib.EmojiConvertor();
// Load_data: function(callback(err, data))
// Save_data: function(data, callback(err))
// data: { appState: object, channelLinks: [] }
class Facebot extends SlackBot {
constructor(settings, load_data, save_data) {
settings.name = settings.name || 'facebot';
// Slackbot settings
super({
name: settings.name,
token: settings.token,
});
this.settings = settings;
this.user = null;
this.facebookApi = null;
this.load_data = load_data;
this.save_data = save_data;
// array of { slack_channel: string id, fb_thread: string id }
this.channelLinks = [];
this.fb_users = {};
emoji.init_env();
emoji.replace_mode = 'unified';
emoji.allow_native = true;
this.on('start', this.onStart);
this.on('message', this.dispatchBotCommands);
this.on('message', this.postSlackMessagesToFB);
this.on('message', this.postGroupJoinedMessage);
}
async onStart() {
await this.setupUsers();
await this.setupFacebook();
if (!this.facebookApi) throw new Error('Unable to log into Facebook');
}
// Tries to grab the bot user and the authorised (facebook account) user
async setupUsers() {
const findUser = async function(username) {
const user = await this.getUser(username);
if (_.isEmpty(user)) {
throw new Error(`User ${username} not found.`);
}
return user;
}.bind(this);
this.user = await findUser(this.settings.name);
this.authorised_user = await findUser(
this.settings.authorised_username
);
}
// Attempts to log into facebook
async setupFacebook() {
try {
// Try to load the saved data and login to facebook
// using the saved credentials. Otherwise fallback
// to reloggin in with the email and pass
const data = await this.loadData();
this.sendDebugMessage(
`Loaded data, found ${data.channelLinks.length} channel links.`
);
// Load the linked channels
this.channelLinks = data.channelLinks;
return await this.createFBApi(data);
} catch (err) {
this.sendDebugMessage(
`Couldn't log in with any saved data, logging in with email and pass (${err})`
);
const facebookConfig = {
email: this.settings.facebook.email,
password: this.settings.facebook.pass,
};
return await this.createFBApi(facebookConfig);
}
this.saveData();
}
// Loads the facebook tokens and channel links using
// the load_data callback passed into the constructor
loadData() {
return new Promise((resolve, reject) => {
if (!this.load_data)
return reject(new Error('no load data callback provided'));
this.load_data(function(err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}
// Saves the facebook tokens and channel links using
// the save_data callback passed into the constructor
saveData() {
if (this.save_data && this.facebookApi) {
const data = {
appState: this.facebookApi.getAppState(),
channelLinks: this.channelLinks,
};
this.save_data(data, function(err) {
if (err) console.log('Error saving facebot data: ' + err);
else console.log('Saved Facebot settings');
});
}
}
// Creates the FB api using either saved tokens or username
// and password passed in as credentials
async createFBApi(credentials) {
this.facebookApi = await Q.nfcall(facebook, credentials);
this.sendDebugMessage('Logged into facebook');
this.facebookApi.setOptions({
logLevel: 'error',
listenEvents: true,
});
this.facebookApi.listen((err, fbmessage) => {
if (!err) this.handleFBNotification(fbmessage);
});
}
// loop that will continue to send the is typing indicator to a channel
// until we hear back they are not typing, or 10 minutes have past
typingLoop(link) {
if (
!link.is_typing ||
Date.now() / 1000 - link.typing_start_time > 60 * 5 //if they were typing for more than 5 minutes lets assume we missed the typing end event, also mobile devices sometimes don't stay connected to send typing end if they nav away from app
)
return;
this.ws.send(
JSON.stringify({
type: 'typing',
channel: link.slack_channel,
})
);
setTimeout(() => this.typingLoop(link), 3000);
}
handleTypeNotification(fbmessage, link) {
if (fbmessage.isTyping == link.is_typing) return;
link.is_typing = fbmessage.isTyping;
if (link.is_typing) {
link.typing_start_time = Date.now() / 1000;
this.typingLoop(link);
}
}
postFBMessageToSlack(fbmessage, link) {
if (fbmessage.body !== undefined) {
const message_text = emoji.replace_emoticons_with_colons(
fbmessage.body
);
this.postMessage(link.slack_channel, message_text, {
username: link.fb_name,
icon_url: link.icon,
});
}
this.handleFBAttachments(fbmessage, link);
}
// Handles any facebook messages/events received, formats them
// and sends them through to the linked slack channels
handleFBNotification(fbmessage) {
// Facebook typing notifications store the thread ID in a different
// param than normal messages, so first find the threadID
let threadID = undefined;
if (fbmessage.type == 'message')
threadID = fbmessage.threadID.toString();
if (fbmessage.type == 'typ') threadID = fbmessage.from.toString();
if (!threadID) return;
this.channelLinks
.filter(link => link.fb_thread === threadID)
.forEach(link => {
switch (fbmessage.type) {
case 'typ':
this.handleTypeNotification(fbmessage, link);
break;
case 'message':
this.postFBMessageToSlack(fbmessage, link);
break;
}
});
}
// Handles any facebook messages with attachments (stickers etc)
handleFBAttachments(fbmessage, link) {
fbmessage.attachments.forEach(attachment => {
if (attachment.url === undefined)
attachment.url = attachment.facebookUrl;
if (attachment.url && attachment.url.startsWith('/'))
attachment.url = 'https://www.facebook.com' + attachment.url;
switch (attachment.type) {
case 'sticker':
this.handleFBImageMessages(attachment.url, link);
break;
case 'photo': {
const url = attachment.hiresUrl ||
attachment.largePreviewUrl;
this.handleFBImageMessages(url, link);
break;
}
case 'animated_image': {
const url = attachment.rawGifImage || attachment.previewUrl;
this.handleFBImageMessages(url, link);
break;
}
// Sharing urls etc. Post the raw URL and let slack do the preview
case 'share':
const title = attachment.title ||
attachment.source ||
attachment.url;
const params = {
username: link.fb_name,
icon_url: link.icon,
attachments: [
{
image_url: attachment.image,
fallback: attachment.image,
},
],
};
this.postMessage(
link.slack_channel,
`<${attachment.url}|${title}>: ${attachment.description || ''}`,
params
);
break;
case 'file':
if (attachment.name.startsWith('audioclip'))
this.handleFBAudioMessages(attachment, link);
else
this.postMessage(
link.slack_channel,
`<${attachment.url}|${attachment.name}>`,
{
username: link.fb_name,
icon_url: link.icon,
}
);
break;
case 'video':
this.handleFBVideoMessages(attachment, link);
break;
}
});
}
// Posts an image to the slack channel (in link) as the facebook sender
handleFBImageMessages(imgurl, link) {
this.postMessage(link.slack_channel, '', {
username: link.fb_name,
icon_url: link.icon,
attachments: [
{
fallback: imgurl,
image_url: imgurl,
},
],
});
}
// Posts an audio message link to the slack channel (in link) as the facebook sender
handleFBAudioMessages(attachment, link) {
this.postMessage(
link.slack_channel,
`<${attachment.url}|Download Voice Message>`,
{
username: link.fb_name,
icon_url: link.icon,
}
);
}
// Posts a video link and thumbnail to the slack channel (in link) as the facebook sender
handleFBVideoMessages(attachment, link) {
this.postMessage(
link.slack_channel,
`<${attachment.url}|Download Video (${attachment.duration} seconds)>`,
{
username: link.fb_name,
icon_url: link.icon,
// Use the preview image as the attachmentent in slack
attachments: [
{
fallback: attachment.previewUrl,
image_url: attachment.previewUrl,
},
],
}
);
}
// Handles forwarding any slack messages to facebook users
postSlackMessagesToFB(message) {
const attachment = message.type === 'message' &&
message.attachments &&
message.attachments.length > 0
? message.attachments[0]
: undefined;
if (
(this.isChatMessage(message) || attachment) &&
!this.isMessageFromFacebot(message) &&
!this.isMessageMentioningFacebot(message)
) {
this.channelLinks
.filter(link => link.slack_channel === message.channel)
.forEach(link => {
message.text = message.text || '';
// Replace emoji shortnames with their unicode equiv
// Also replace :simple_smile: with :), as it doesnt appear to be
// a legit emoji, and will just send :simple_smile: to fb
let message_text = emoji.replace_colons(message.text);
message_text = message_text.replace(':simple_smile:', ':)');
const msg = {
body: message_text,
};
if (attachment) msg.url = attachment.image_url;
const postErrorToSlack = err => {
if (err)
this.postMessage(
link.slack_channel,
`Error sending last message: ${err.error}`,
{ as_user: true }
);
};
// Send the message
this.facebookApi.sendMessage(
msg,
link.fb_thread,
postErrorToSlack
);
});
}
}
// Attempts to link a slack channel to a facebook user
async respondToCreateChatMessages(message) {
try {
const allowedUsers = [this.user.id, this.authorised_user.id];
const isTruelyPrivate = await this.groupUsersOnlyContains(
message.channel,
allowedUsers
);
if (!isTruelyPrivate)
throw new Error('The channel should only contain you and me.');
// Parse the friend name: "@facebot chat captain planet" becomes "captain planet"
const friendname = message.text
.substring(message.text.indexOf('chat') + 'chat'.length)
.trim();
const friend = await fbUtil.findFBUser(
this.facebookApi,
friendname
);
this.channelLinks.push({
slack_channel: message.channel,
fb_thread: friend.id,
fb_name: friend.name,
is_typing: false,
icon: `http://graph.facebook.com/${friend.id}/picture?type=square`,
});
this.saveData();
return this.postMessage(
message.channel,
`Chat messages between you and ${friend.name} are now synced in this channel.`,
{ as_user: true }
);
} catch (err) {
return this.postMessage(
message.channel,
`Unable to connect the chat: ${err.message}`,
{ as_user: true }
);
}
}
// Unlinks the channel from any facebook friends
respondToUnlinkCommands(message) {
const previousSize = this.channelLinks.length;
this.channelLinks = this.channelLinks.filter(
link => link.slack_channel !== message.channel
);
let response;
if (previousSize === this.channelLinks.length) {
response = 'This channel is not connected to any Facebook friends';
} else {
response = 'This channel is no longer connected to Facebook Messenger';
this.saveData();
}
this.postMessage(message.channel, response, { as_user: true });
}
// Scans all slack messages, and if they appear to be a facebot
// command, gets facebot to run the command
dispatchBotCommands(message) {
if (
this.isChatMessage(message) &&
!this.isMessageFromFacebot(message) &&
!this.isBotMessage(message)
) {
let command = '';
const mention = `<@${this.user.id}>`;
if (message.text.startsWith(mention)) {
command = message.text.substring(mention.length + 1);
} else if (this.isMessageInDirectMessage(message)) {
command = message.text;
}
// command should be single words, so grab the first word
command = command.trim().toLowerCase().split(' ', 1)[0];
if (command) this.respondToCommands(command, message);
}
}
// Handles facebot commands
respondToCommands(command, message) {
if (command === 'list') return this.postListOfLinkedChannels(message);
if (command === 'chat')
return this.respondToCreateChatMessages(message);
if (command == 'unlink') return this.respondToUnlinkCommands(message);
if (command == 'friends')
return this.respondToFriendSearchCommands(message);
let response;
if (command === 'help') {
response = '`@facebot help`: See this text\n' +
'`@facebot chat <friend name>`: Connect a private channel with a facebook friend\n' +
'`@facebot unlink`: Disconnects the current channel from facebook messages\n' +
'`@facebot status`: Show facebook connectivity status\n' +
'`@facebot list`: Shows information about linked chats\n' +
"`@facebot friends <name>`: Display friends who's name contains <name> and their id info\n" +
'_Note: In this Direct Message channel you can send commands without @mentioning facebot. For example:_\n' +
'`list`: list the linked chats in the current channel';
} else if (command == 'status') {
response = 'Facebook is currently *' +
(this.facebookApi ? 'connected*' : 'not connected*');
}
if (response) {
this.postMessage(message.channel, response, { as_user: true });
}
}
// search a user for a specific friend and show them the matching friends details
async respondToFriendSearchCommands(message) {
const search_str = message.text
.substring(message.text.indexOf('friends') + 'friends'.length)
.trim()
.toLowerCase();
const friends = await Q.nfcall(this.facebookApi.getFriendsList);
const response = friends
.filter(friend => {
return friend.isFriend &&
friend.fullName.toLowerCase().includes(search_str);
})
.map(friend => {
return `${friend.fullName} *vanity:* ${friend.vanity} *userID:* ${friend.userID}`;
})
.join('\n');
this.postMessage(message.channel, response, {
as_user: true,
});
}
// Posts a list of the currently linked chats, to the channel the
// message came from
async postListOfLinkedChannels(message) {
if (this.channelLinks.length > 0) {
const groups = (await this.getGroups()).groups;
// build a description of each link
const linkDescriptions = this.channelLinks
.map(link => {
const group = groups.find(
group => group.id === link.slack_channel
);
return `*${group.name}* is linked with *${link.fb_name}*`;
})
.join('\n');
this.postMessage(message.channel, linkDescriptions, {
as_user: true,
});
} else {
this.postMessage(
message.channel,
'There are currently no facebook chats linked to slack channels.',
{ as_user: true }
);
}
}
// Posts a message when facebot is added to any groups, to inform
// the user how to connect the channel to a facebook friend
async postGroupJoinedMessage(message) {
if (message.type == 'group_joined') {
const allowedUsers = [this.user.id, this.authorised_user.id];
const isTruelyPrivate = await this.groupUsersOnlyContains(
message.channel.id,
allowedUsers
);
let join_message = 'To connect a facebook chat type: \n' +
'@facebot chat `<friend name>`';
if (!isTruelyPrivate) {
join_message = 'You can only connect private channels where me and you are the only users.';
}
this.postMessage(message.channel.id, join_message, {
as_user: true,
});
}
}
// Sends a (slack) direct message to the authorised user if
// debug messages are enabled
sendDebugMessage(message) {
if (this.settings.debug_messages) {
this.postMessageToUser(this.settings.authorised_username, message, {
as_user: true,
});
}
}
isChatMessage(message) {
return message.type === 'message' && Boolean(message.text);
}
// Retruns true if the channel with the id only
// contains the users in userids
// users: array of userids
async groupUsersOnlyContains(channelid, userids) {
try {
groupInfo = await this._api('groups.info', { channel: channelid });
return _.isEmpty(_.difference(groupInfo.group.members, userids));
} catch (err) {
throw new Error('This is a not group channel.');
}
}
isMessageInDirectMessage(message) {
return typeof message.channel === 'string' &&
message.channel[0] === 'D';
}
isMessageFromFacebot(message) {
return message.user === this.user.id || this.isBotMessage(message);
}
isMessageMentioningFacebot(message) {
const mention = `<@${this.user.id}>`;
return message.text.indexOf(mention) > -1;
}
isBotMessage(message) {
return message.subtype === 'bot_message';
}
isFromAuthorisedUser(message) {
return message.user === this.authorised_user.id;
}
}
module.exports = Facebot;