├── .gitignore ├── index.js ├── example.png ├── .env.example ├── lib ├── traductor_helpers.js ├── suspender.js ├── index.js ├── lang_codes.js ├── any_to_eng.js ├── message_handler.js └── translator.js ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .env 4 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').load(); 2 | require('./lib'); 3 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roman01la/slack-traductor/HEAD/example.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BOT_NAME=slack_bot_name 2 | BOT_TOKEN=slack_bot_token 3 | CLIENT_ID=microsoft_translator_client_id 4 | CLIENT_SECRET=microsoft_translator_client_secret 5 | TRANSLATE_TO=en 6 | ADD_MESSAGE=_(English, please)_ 7 | SUSPEND_TIMEOUT=600000 8 | -------------------------------------------------------------------------------- /lib/traductor_helpers.js: -------------------------------------------------------------------------------- 1 | export function getUserName(users, userId) { 2 | 3 | return users.filter((usr) => usr.id === userId)[0].name; 4 | } 5 | 6 | export function getChanNameById(channels, chanId) { 7 | 8 | return channels.filter((chan) => chan.id === chanId)[0]; 9 | } 10 | -------------------------------------------------------------------------------- /lib/suspender.js: -------------------------------------------------------------------------------- 1 | const suspendedChans = new Set(); 2 | 3 | function getSuspendedChans() { 4 | 5 | return suspendedChans; 6 | } 7 | 8 | function suspendChan(chan, timeout) { 9 | 10 | suspendedChans.add(chan); 11 | setTimeout(() => suspendedChans.delete(chan), timeout); 12 | } 13 | 14 | export default { 15 | 16 | getSuspendedChans, 17 | suspendChan 18 | }; 19 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import SlackBot from 'slackbots'; 2 | import initialize from './message_handler'; 3 | import langCodes from './lang_codes'; 4 | 5 | if (!langCodes.has(process.env.TRANSLATE_TO)) { 6 | 7 | throw Error('Invalid lang code'); 8 | } 9 | 10 | const traductor = new SlackBot({ 11 | token: process.env.BOT_TOKEN, 12 | name: process.env.BOT_NAME 13 | }); 14 | 15 | traductor.on('start', () => { 16 | 17 | traductor.getUser(process.env.BOT_NAME) 18 | .then(({ id }) => initialize(traductor, id)) 19 | .catch((err) => console.error(err)); 20 | }); 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-traductor", 3 | "version": "1.0.0", 4 | "description": "Slack bot to translate chat messages of any language into specified language", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon --exec babel-node index.js", 8 | "start": "babel-node index.js" 9 | }, 10 | "keywords": [ 11 | "slack", 12 | "bot", 13 | "translate" 14 | ], 15 | "author": "Roman Liutikov ", 16 | "license": "MIT", 17 | "dependencies": { 18 | "babel": "^5.8.23", 19 | "dotenv": "^1.2.0", 20 | "request": "^2.62.0", 21 | "slackbots": "^0.4.0" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^1.5.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/lang_codes.js: -------------------------------------------------------------------------------- 1 | export default new Set([ 2 | 'ar', 3 | 'bs-Latn', 4 | 'bg', 5 | 'ca', 6 | 'zh-CHS', 7 | 'zh-CHT', 8 | 'hr', 9 | 'cs', 10 | 'da', 11 | 'nl', 12 | 'en', 13 | 'et', 14 | 'fi', 15 | 'fr', 16 | 'de', 17 | 'el', 18 | 'ht', 19 | 'he', 20 | 'hi', 21 | 'mww', 22 | 'hu', 23 | 'id', 24 | 'it', 25 | 'ja', 26 | 'tlh', 27 | 'tlh-Qaak', 28 | 'ko', 29 | 'lv', 30 | 'lt', 31 | 'ms', 32 | 'mt', 33 | 'yua', 34 | 'no', 35 | 'otq', 36 | 'fa', 37 | 'pl', 38 | 'pt', 39 | 'ro', 40 | 'ru', 41 | 'sr-Cyrl', 42 | 'sr-Latn', 43 | 'sk', 44 | 'sl', 45 | 'es', 46 | 'sv', 47 | 'th', 48 | 'tr', 49 | 'uk', 50 | 'ur', 51 | 'vi', 52 | 'cy' 53 | ]); 54 | -------------------------------------------------------------------------------- /lib/any_to_eng.js: -------------------------------------------------------------------------------- 1 | import Translator from './translator'; 2 | 3 | const LANG = process.env.TRANSLATE_TO; 4 | 5 | Translator.updateAccess(); 6 | 7 | function maybeTranslate(text) { 8 | 9 | return ({ pred, lang }) => { 10 | 11 | return new Promise((resolve, reject) => { 12 | 13 | if (pred === false) { 14 | 15 | Translator.requestAPICall(() => { 16 | 17 | Translator.translate({ fromLang: lang, toLang: LANG }, text) 18 | .then(resolve) 19 | .catch(reject); 20 | }); 21 | } else { 22 | reject(text); 23 | } 24 | }); 25 | }; 26 | } 27 | 28 | export default function maybeTranslateToEng(text) { 29 | 30 | return new Promise((resolve, reject) => { 31 | 32 | Translator.requestAPICall(() => { 33 | 34 | Translator.isLang(LANG, text) 35 | .then(maybeTranslate(text)) 36 | .then(resolve) 37 | .catch(reject); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Roman Liutikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slack-traductor 2 | Slack bot to translate chat messages of any language into specified language 3 | 4 | ![example](example.png) 5 | 6 | ## Why? 7 | 8 | Because we are an international team and sometimes I don't understand what they all are talking about. 9 | 10 | ## Usage 11 | 12 | Traductor is using Microsoft Translator API, because it's free 😅 13 | 14 | 1. [Get Translator API account](https://datamarket.azure.com/dataset/bing/microsofttranslator) 15 | 2. [Create Slack bot](https://slack.com/apps/build/custom-integration) 16 | 3. Rename `.env.example` file into `.env` and fill in values in fields 17 | 18 | ``` 19 | BOT_NAME=slack_bot_name 20 | BOT_TOKEN=slack_bot_token 21 | BOT_AS_USER=1 22 | CLIENT_ID=microsoft_translator_client_id 23 | CLIENT_SECRET=microsoft_translator_client_secret 24 | TRANSLATE_TO=en 25 | ADD_MESSAGE=message_to_append 26 | SUSPEND_TIMEOUT=600000 27 | ``` 28 | 29 | - BOT_NAME — bot name given when you create a bot 30 | - BOT_TOKEN — a token which you will receive after creating a bot 31 | - BOT_AS_USER — pass true to post the message as the authed user 32 | - CLIENT_ID — take it from your Translator API account 33 | - CLIENT_SECRET — take it from your Translator API account 34 | - TRANSLATE_TO — translate messages to specified language, check lang codes in `lib/lang_codes.js` 35 | - ADD_MESSAGE — specify the message you want to add to every translation, check the screenshot above. 36 | - SUSPEND_TIMEOUT — the time period in ms while the bot will not translate messages 37 | 38 | 4. Install dependencies of a project 39 | ``` 40 | $ npm i 41 | ``` 42 | 43 | 5. Start server 44 | ``` 45 | $ npm start 46 | ``` 47 | 48 | ### Chat commands 49 | 50 | `@traductor: stop` — suspend the bot for 10 minutes in current channel/group 51 | -------------------------------------------------------------------------------- /lib/message_handler.js: -------------------------------------------------------------------------------- 1 | import Suspender from './suspender'; 2 | import maybeTranslateToEng from './any_to_eng'; 3 | import { getUserName, getChanNameById } from './traductor_helpers'; 4 | 5 | export default function initialize(traductor, botId) { 6 | 7 | const ADD_MESSAGE = process.env.ADD_MESSAGE || ''; 8 | const SUSPEND_TIMEOUT = process.env.SUSPEND_TIMEOUT || 600000; 9 | 10 | const suspendedChans = Suspender.getSuspendedChans(); 11 | 12 | traductor.on('message', function({ type, channel, text, username, user, subtype }) { 13 | 14 | if (text === `<@${botId}>: stop` && !suspendedChans.has(channel)) { 15 | 16 | return Suspender.suspendChan(channel, SUSPEND_TIMEOUT); 17 | } 18 | 19 | if (type === 'message' && username !== 'traductor' && subtype !== 'message_changed' && !suspendedChans.has(channel)) { 20 | 21 | const chanId = getChanNameById([...traductor.channels, ...traductor.groups], channel); 22 | const uname = getUserName(traductor.users, user); 23 | 24 | return maybeTranslateToEng(text) 25 | .then(replyToUser(uname, chanId)) 26 | .catch((err) => console.log(err)); 27 | } 28 | }); 29 | 30 | function replyToUser(username, { name, is_group, is_channel }) { 31 | 32 | return (text) => { 33 | 34 | text = `@${username} said: ${text} ${ADD_MESSAGE}`; 35 | 36 | return new Promise((resolve, reject) => { 37 | 38 | if (is_group) { 39 | traductor.postMessageToGroup(name, text, { as_user: process.env.BOT_AS_USER }, resolve); 40 | } else if (is_channel) { 41 | traductor.postMessageToChannel(name, text, { as_user: process.env.BOT_AS_USER }, resolve); 42 | } else { 43 | reject('Neither Channel, nor Group!'); 44 | } 45 | }); 46 | }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/translator.js: -------------------------------------------------------------------------------- 1 | import request from 'request'; 2 | import langCodes from './lang_codes'; 3 | 4 | const CLIENT_ID = process.env.CLIENT_ID; 5 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 6 | 7 | let ACCESS_TOKEN; 8 | let isAccessable = false; 9 | 10 | let queue = []; 11 | 12 | function translate({ fromLang, toLang }, text) { 13 | 14 | if (!langCodes.has(fromLang)) { 15 | 16 | throw Error('Won\'t translate'); 17 | } 18 | 19 | return new Promise((resolve, reject) => { 20 | 21 | request.get( 22 | { 23 | url: 'http://api.microsofttranslator.com/V2/Ajax.svc/Translate?text=' + encodeURIComponent(text) + '&to=' + toLang + '&from=' + fromLang, 24 | auth: { bearer: ACCESS_TOKEN } 25 | }, 26 | (err, res, body) => err ? reject(err) : resolve(body)) 27 | }) 28 | .then(trimMicrosoft); 29 | } 30 | 31 | function isLang(langCode, text) { 32 | 33 | return new Promise((resolve, reject) => { 34 | 35 | request.get( 36 | { 37 | url: 'http://api.microsofttranslator.com/V2/Ajax.svc/Detect?text=' + encodeURIComponent(text), 38 | auth: { bearer: ACCESS_TOKEN } 39 | }, 40 | (err, res, body) => err ? reject(err) : resolve(body)) 41 | }) 42 | .then(trimMicrosoft) 43 | .then((code) => ({ 44 | pred: code === langCode, 45 | lang: code 46 | })); 47 | } 48 | 49 | function requestAPICall(fn) { 50 | 51 | if (isAccessable) { fn(); } 52 | else { queue.push(fn); } 53 | } 54 | 55 | function dispatchQueue() { 56 | 57 | queue.forEach((fn) => fn()); 58 | queue = []; 59 | } 60 | 61 | function updateAccess() { 62 | 63 | getAccessToken(CLIENT_ID, CLIENT_SECRET) 64 | .then(JSON.parse) 65 | .then((res) => { 66 | trackExpiration(res.expires_in); 67 | return res; 68 | }) 69 | .then((res) => res.access_token) 70 | .then((token) => ACCESS_TOKEN = token) 71 | .then(() => isAccessable = true) 72 | .then(dispatchQueue) 73 | .catch(console.error.bind(console)); 74 | } 75 | 76 | function getAccessToken(client_id, client_secret) { 77 | 78 | return new Promise((resolve, reject) => { 79 | 80 | request.post( 81 | 'https://datamarket.accesscontrol.windows.net/v2/OAuth2-13', 82 | { 83 | form: { 84 | scope: 'http://api.microsofttranslator.com', 85 | grant_type: 'client_credentials', 86 | client_id, client_secret 87 | } 88 | }, 89 | (err, res, body) => err ? reject(err) : resolve(body)); 90 | }); 91 | } 92 | 93 | function trackExpiration(time) { 94 | 95 | setTimeout(() => { 96 | 97 | isAccessable = false; 98 | updateAccess(); 99 | }, time * 1000); 100 | } 101 | 102 | function trimMicrosoft(codeStr) { 103 | 104 | return codeStr.match(/"(.*)"/)[1]; 105 | } 106 | 107 | export default { 108 | 109 | updateAccess, 110 | requestAPICall, 111 | isLang, 112 | translate 113 | }; 114 | --------------------------------------------------------------------------------