Skip to content

Commit

Permalink
bots, plugins, disconnect
Browse files Browse the repository at this point in the history
  • Loading branch information
atabel committed Nov 13, 2016
1 parent f8dc396 commit 71a8b17
Show file tree
Hide file tree
Showing 8 changed files with 759 additions and 19 deletions.
85 changes: 85 additions & 0 deletions bot-api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const createClient = require('./chat-client');
const md5 = require('md5');
const {v4: createId} = require('uuid');

const getAvatar = (email) =>
`http://www.gravatar.com/avatar/${md5(email)}?s=48&d=identicon&r=PG`;

const createBotUser = (name) => {
const id = createId();
const email = `bot_${id}@chatbot.com`;
const avatar = getAvatar(email);
return {
name,
id,
email,
avatar,
fullName: name,
familyName: name,
};
};

const createBot = (name) => {
const botUser = createBotUser(name);
const client = createClient('BOT/' + encodeURIComponent(JSON.stringify(botUser)));

const sendMessage = (...args) =>
setTimeout(() => client.sendMessage(...args), 200);

const respondWith = (getResponse) => (action, ...rest) => {
const text = typeof getResponse === 'function'
? getResponse(action, ...rest)
: getResponse;
const {receiver, sender} = action;
sendMessage(text, receiver === botUser.id ? sender : receiver);
};

const whenOneToOne = (fn) => (action, ...rest) => {
if (action.receiver === botUser.id) {
fn(action, ...rest);
}
};

return {
data: botUser,
init: client.init,
respondWith,
whenOneToOne,
matches,
sendMessage,
on: client.on,
};
};

const escapeRegExp = (string) =>
string.replace(/([.*+?^${}()|\[\]\/\\])/g, "\\$1");

const textMatchesSelector = (text, selector) => {
const matches = text.startsWith(selector);
const parsedText = matches
? text.replace(new RegExp(escapeRegExp(selector) + '\s*'), '')
: null;
return [matches, parsedText];
};

const matches = (selectors) => (action) => {
const handled = Object.keys(selectors)
.filter(selector => selector !== 'default')
.some(selector => {
const {text} = action.payload;
const [matches, parsedText] = textMatchesSelector(text, selector);
if (matches) {
selectors[selector](action, parsedText);
}
return matches;
});

if (!handled && 'default' in selectors) {
selectors['default'](action);
}
};

module.exports = {
createBot,
matches,
};
10 changes: 10 additions & 0 deletions bots/echo-bot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const {createBot} = require('../bot-api');

const createEchoBot = (name) => {
const bot = createBot(name);
const {whenOneToOne, respondWith} = bot;
bot.on('message', whenOneToOne(respondWith(({payload}) => payload.text)));
return bot;
};

module.exports = createEchoBot;
53 changes: 53 additions & 0 deletions bots/todo-bot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const {createBot, matches} = require('../bot-api');

const createTodoBot = (name) => {
const todos = {};
const bot = createBot(name);
const {whenOneToOne, respondWith} = bot;

const printTodos = todosForUser =>
todosForUser.map((todo, idx) => `${idx + 1}. ${todo}`).join(',\n');

bot.on('message', matches({

'/todos': respondWith(({sender}) => {
const todosForUser = todos[sender];
if (todosForUser) {
return 'These are your todos: \n' + printTodos(todosForUser);
} else {
return "You don't have pending todos!";
}
}),

'/todo': respondWith(({sender}, newTodo) => {
const todosForUser = todos[sender] || [];
todos[sender] = [...todosForUser, newTodo]
return `New todo saved: "${newTodo}" \nPending: \n${printTodos(todos[sender])}`;
}),

'/done': respondWith(({sender}, idx) => {
const doneTodoIdx = parseInt(idx) - 1;
const todosForUser = todos[sender] || [];
const doneTodo = todosForUser[doneTodoIdx];
const pendingTodos = todosForUser.filter((todo, idx) => idx !== doneTodoIdx);
todos[sender] = pendingTodos;
return doneTodo
? `"${doneTodo}" marked as done \nPending: \n${printTodos(pendingTodos)}`
: todosForUser.length
? `You only have ${todosForUser.length} todos`
: "You don't have pending todos!";
}),

'/help': respondWith(() => [
'/todos lists the todos',
'/todo <whatever> adds a new todo',
'/done <number> marks a todo as done'
].join(',\n')),

default: whenOneToOne(respondWith('Type /help')),
}));

return bot;
};

module.exports = createTodoBot;
110 changes: 110 additions & 0 deletions chat-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const WebSocket = require('ws');

const CONNECTING = 0;
const OPEN = 1;

const dev = true;
const PRODUCTION_CHAT_SERVER = 'wss://react-chat-server.herokuapp.com';
const DEV_CHAT_SERVER = 'ws://localhost:8080';
const chatServerUrl = dev ? DEV_CHAT_SERVER : PRODUCTION_CHAT_SERVER;

const createClient = (token) => {
let ws;
const listeners = {};

const handleMessage = m => {
const action = JSON.parse(m.data);
(listeners[action.type] || []).forEach(l => l(action));
};

return {
/**
* Inits the chat connection with the server
* @param {string} token the google auth session token
*/
init() {
if (ws) {
throw new Error('Chat client already initialized');
}
ws = new WebSocket(`${chatServerUrl}/${token}`);
ws.addEventListener('message', handleMessage);
},

/**
* Fetch the users list from server.
* @return {Promise} resolves to an array of users:
* {
* fullName: string,
* avatar: string,
* name: string,
* familyName: string,
* email: string,
* id: string
* }
*/
getUsers() {
this.send({type: 'getUsers', receiver: 'server'});
},

/**
* Sends a chat message to a recipient
* @param {string} messageText
* @param {string} receiver userId of the receiver. When not provided,
* the message is sent to all the users
*/
sendMessage(messageText, receiver = 'all') {
this.send({type: 'message', receiver, payload: {text: messageText}});
},

/**
* Sends an action to the chat server
* @param {object} action an action has the following form:
* {
* type: string, // for example 'message'
* receiver: string, // the userId of the recipient or 'all'
* [sender]: string, // not needed, the server knows you
* [time]: number, // automatically set to current timestamp.
* payload: object, // any data associated with the event
* }
* @return {[type]} [description]
*/
send(action) {
action.time = action.time || Date.now();
if (ws.readyState === CONNECTING) {
setTimeout(() => this.send(action), 100);
} else if (ws.readyState === OPEN) {
ws.send(JSON.stringify(action));
}
},

/**
* Registers a listener for a given event type
* @param {string} event
* @param {Function} listener will be called with every
* chat event of the specified type received from the sever
* @return {Function} dettach function to remove the event
* listener.
*/
on(event, listener) {
if (event in listeners) {
listeners[event].push(listener);
} else {
listeners[event] = [listener];
}
return () => this.off(event, listener);
},

/**
* Dettached a previously registered listener
* @param {string} event the event type
* @param {Function} listenerToRemove
*/
off(event, listenerToRemove) {
if (event in listeners) {
listeners[event] = listeners[event].filter(l => l !== listenerToRemove);
}
},
};
};

module.exports = createClient;
62 changes: 48 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const url = require('url');
const WebSocketServer = require('ws').Server;
const createGoogleTokenVerifier = require('./google-auth/google-id-token-verifier');

const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || require('./config').GOOGLE_CLIENT_ID;
const SERVER_PORT = process.env.PORT || 8080;

Expand All @@ -15,27 +15,54 @@ const createUser = ({name, picture, given_name, email, family_name, sub}) => ({
id: sub
});

const users = [];
const getUsersList = () =>
wss.clients.map(({upgradeReq: {user}}) => user);

const isUserAlreadyLoggedIn = user =>
getUsersList().some(({id}) => id === user.id);

const wss = new WebSocketServer({
port: SERVER_PORT,
verifyClient(info, cb) {
const token = info.req.url.substr(1);
verifyAuthToken(token).then(tokenInfo => {
info.req.user = createUser(tokenInfo);
users.push(info.req.user);
cb(true);
}).catch(e => {
cb(false, 401, `Unauthorized: ${e.message}`);
});
if (token.startsWith('BOT/')) {
const userJson = decodeURIComponent(token.substr('BOT/'.length));
console.log('register new bot: ', userJson);
try {
const user = JSON.parse(userJson)
info.req.user = user;
cb(true);
} catch (e) {
cb(false, 401, 'Bad bot encoding');
}
} else {
verifyAuthToken(token).then(tokenInfo => {
const user = createUser(tokenInfo);
if (isUserAlreadyLoggedIn(user)) {
cb(false, 401, `Unauthorized: already logged!`);
} else {
info.req.user = user;
}
cb(true);
}).catch(e => {
cb(false, 401, `Unauthorized: ${e.message}`);
});
}
},
});

const plugins = [
require('./plugins/metadata-plugin'),
];

const applyPlugins = event =>
plugins.reduce((event, plugin) => Promise.resolve(plugin(event)), event);

const send = event => {
const {receiver, sender} = event;
const receivers = receiver === 'all'
? wss.clients.filter(({upgradeReq: {user}}) => user.id !== sender)
: wss.clients.filter(({upgradeReq: {user}}) => user.id === receiver);
? wss.clients
: wss.clients.filter(({upgradeReq: {user}}) => user.id === receiver || user.id === sender);

receivers.forEach(ws => {
console.log(`> ${ws.upgradeReq.user.email}`, event);
Expand All @@ -45,7 +72,7 @@ const send = event => {

const handleInServer = event => {
if (event.type === 'getUsers') {
users.forEach(user => {
getUsersList().forEach(user => {
send({type: 'user', sender: 'server', receiver: event.sender, payload: user});
});
}
Expand All @@ -58,14 +85,21 @@ const handleEvent = fromUser => eventJson => {
if (event.receiver === 'server') {
handleInServer(event);
} else {
send(event);
applyPlugins(event).then(send);
}
};

const notyfyDisconnection = user => () => {
console.log(`Offline: ${user.email} | #users: ${wss.clients.length}`);
send({type: 'disconnect', sender: 'server', receiver: 'all', payload: user.id});
}

wss.on('connection', ws => {
const {user} = ws.upgradeReq;
console.log(`New user: ${user.name} (${user.email}), num users: ${wss.clients.length}`);
console.log(`Connect: ${user.email} | #users: ${wss.clients.length}`);

ws.on('message', handleEvent(user));
ws.on('close', notyfyDisconnection(user))

send({type: 'user', sender: user.id, receiver: 'all', payload: user});
});
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@
"node": ">=6.0"
},
"scripts": {
"start": "pm2 start index.js --watch"
"start": "pm2 start index.js --watch",
"log": "pm2 log index"
},
"dependencies": {
"get-urls": "^5.0.1",
"md5": "^2.2.1",
"metaphor": "^3.8.1",
"node-fetch": "^1.6.3",
"uuid": "^2.0.3",
"ws": "^1.1.1"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit 71a8b17

Please sign in to comment.