├── .gitignore ├── CHANGELOG.md ├── app ├── components │ ├── index.jsx │ ├── App │ │ ├── Mixins.css │ │ ├── App.css │ │ └── App.jsx │ ├── CircularProgress │ │ ├── CircularProgress.jsx │ │ └── CircularProgress.css │ ├── Telegraph │ │ ├── Telegraph.css │ │ └── Telegraph.jsx │ ├── Conversation │ │ ├── Conversation.css │ │ └── Conversation.jsx │ └── AddBotDialog │ │ └── AddBotDialog.jsx ├── index.html └── main.jsx ├── README.md ├── api ├── tools.js └── server.js ├── webpack.dev.config.js ├── webpack.prod.config.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/ 3 | npm-debug.log 4 | api/store.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | #### v1.0.1 2 | 3 | - UI improvement 4 | - Better list clawing 5 | 6 | #### v1.0.0 7 | 8 | - Search animes by year -------------------------------------------------------------------------------- /app/components/index.jsx: -------------------------------------------------------------------------------- 1 | export AddBotDialog from './AddBotDialog/AddBotDialog' 2 | export App from './App/App' 3 | export CircularProgress from './CircularProgress/CircularProgress' 4 | export Conversation from './Conversation/Conversation' 5 | export Telegraph from './Telegraph/Telegraph' -------------------------------------------------------------------------------- /app/components/App/Mixins.css: -------------------------------------------------------------------------------- 1 | @define-mixin flexColumn { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | @define-mixin flexAlignCenter { 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | 11 | @define-mixin sameHeight $height { 12 | height: $height; 13 | line-height: $height; 14 | } -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | telegraph 4 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/components/CircularProgress/CircularProgress.jsx: -------------------------------------------------------------------------------- 1 | import $ from './CircularProgress.css'; 2 | import React from 'react'; 3 | 4 | export default class CircularProgress extends React.Component { 5 | render() { 6 | return ( 7 | this.props.completed ? null : 8 |
9 | 10 | 11 | 12 |
13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/components/Telegraph/Telegraph.css: -------------------------------------------------------------------------------- 1 | @import '../App/Mixins'; 2 | 3 | .content, .infoWrapper { 4 | display: flex; 5 | flex: 1; 6 | position: relative; 7 | } 8 | 9 | .chatsScrollWrapper { 10 | overflow-x: hidden; 11 | width: 100%; 12 | } 13 | 14 | .chatsContainer { 15 | border-right: 1px solid #eee; 16 | display: flex; 17 | min-width: 320px; 18 | max-width: 320px; 19 | overflow-x: hidden; 20 | position: relative; 21 | z-index: 2; 22 | } 23 | 24 | .infoWrapper { 25 | height: 100%; 26 | @mixin flexAlignCenter; 27 | } 28 | 29 | .fabAdd { 30 | bottom: 20px; 31 | position: absolute; 32 | right: 20px; 33 | z-index: 3; 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | babel 3 |

4 |

5 | telegraph 6 |

7 |

8 | web ui for telegram bots 9 |

10 | 11 | #### Developing 12 | 13 | > Please ensure your machine has enough resources to perform compilation during installation 14 | 15 | ```bash 16 | $ npm install 17 | $ npm start 18 | $ open http://127.0.0.1:8080 19 | ``` 20 | 21 | #### Deploying 22 | 23 | ```bash 24 | $ npm run deploy 25 | $ cd dist && ls 26 | ``` 27 | 28 | #### Screenshots 29 | 30 | ![](https://cloud.githubusercontent.com/assets/8536244/16662994/e47569f4-44ac-11e6-928b-5ec19b87b166.png) 31 | 32 | #### License 33 | 34 | MIT 35 | -------------------------------------------------------------------------------- /app/components/App/App.css: -------------------------------------------------------------------------------- 1 | @import 'https://cdn.materialdesignicons.com/1.6.50/css/materialdesignicons.min.css'; 2 | @import 'https://fonts.googleapis.com/css?family=Roboto:300,500'; 3 | @import 'normalize.css'; 4 | @import './Mixins.css'; 5 | 6 | html *[type] { 7 | -webkit-appearance: none; 8 | } 9 | 10 | [class^="mdi"] { 11 | font-family: 'Material Design Icons'; 12 | } 13 | 14 | .flexWrapper { 15 | @mixin flexAlignCenter; 16 | display: flex; 17 | height: 100%; 18 | } 19 | 20 | .main { 21 | display: flex; 22 | flex-direction: column; 23 | font-weight: 300; 24 | height: 640px; 25 | margin: 0 auto; 26 | width: 960px; 27 | 28 | @media only screen and (max-width:960px) { 29 | & { 30 | height: 100%; 31 | width: 100%; 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/components/CircularProgress/CircularProgress.css: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | to { transform: rotate(360deg); } 3 | } 4 | 5 | @keyframes dash { 6 | 0% { 7 | stroke-dasharray: 1,150; 8 | stroke-dashoffset: 0; 9 | } 10 | 50% { 11 | stroke-dasharray: 90,150; 12 | stroke-dashoffset: -35; 13 | } 14 | to { 15 | stroke-dasharray: 90,150; 16 | stroke-dashoffset: -124; 17 | } 18 | } 19 | 20 | .wrapper { 21 | align-items: center; 22 | display: flex; 23 | height: 100%; 24 | justify-content: center; 25 | left: 0; 26 | position: absolute; 27 | top: 0; 28 | width: 100%; 29 | } 30 | 31 | .spinner { 32 | animation: rotate 2s linear infinite; 33 | z-index: 2; 34 | } 35 | 36 | .spinner > .path { 37 | animation: dash 1.5s ease-in-out infinite; 38 | stroke-dasharray: 1,150; 39 | stroke-dashoffset: 0; 40 | stroke-linecap: round; 41 | stroke-width: 4px; 42 | } 43 | -------------------------------------------------------------------------------- /app/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { MuiThemeProvider, getMuiTheme } from 'material-ui/styles'; 4 | import { App } from './components'; 5 | import injectTapEventPlugin from 'react-tap-event-plugin'; 6 | injectTapEventPlugin(); 7 | 8 | const app = document.querySelector('#app'); 9 | const muiTheme = getMuiTheme({ 10 | palette: { 11 | primary1Color: '#28A5D4', 12 | accent1Color: '#4170B7', 13 | borderColor: '#B5B5B7', 14 | disabledColor: '#CECECF', 15 | pickerHeaderColor: '#E8EAF6', 16 | clockCircleColor: '#E8EAF6', 17 | }, 18 | appBar: { 19 | color: '#FFFFFF', 20 | textColor: '#000000' 21 | } 22 | }); 23 | 24 | ReactDOM.render( 25 | 26 | 27 | , 28 | app 29 | ); 30 | 31 | setTimeout(() => { 32 | app.setAttribute('style', 'height:100%'); 33 | }, 250) 34 | -------------------------------------------------------------------------------- /api/tools.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import mkdirp from 'mkdirp-promise' 4 | 5 | export const initStore = (file, content) => new Promise((resolve, reject) => { 6 | mkdirp(path.dirname(file)).then(() => { 7 | fs.stat(file, (statErr) => { 8 | if (statErr === null) resolve(file) 9 | else { 10 | if (!content) reject('Content parameter is required') 11 | else fs.writeFile(file, content, (writeErr) => { 12 | if (writeErr) reject(writeErr) 13 | else resolve(file) 14 | }) 15 | } 16 | }) 17 | }) 18 | }) 19 | 20 | export const readStore = (file) => new Promise((resolve, reject) => { 21 | fs.readFile(file, (err, data) => { 22 | if (err) reject(err) 23 | else resolve(JSON.parse(data)) 24 | }) 25 | }) 26 | 27 | export const updateStore = (file, store) => new Promise((resolve, reject) => { 28 | fs.writeFile(file, JSON.stringify(store), (writeErr) => { 29 | if (writeErr) reject(writeErr) 30 | else resolve(file) 31 | }) 32 | }) 33 | 34 | export const craftBot = (botProp) => new Promise((resolve, reject) => { 35 | 36 | }) -------------------------------------------------------------------------------- /app/components/App/App.jsx: -------------------------------------------------------------------------------- 1 | import $ from './App.css'; 2 | import React from 'react'; 3 | import { Paper, RaisedButton } from 'material-ui'; 4 | import { CircularProgress, Telegraph } from '../../components'; 5 | import { VelocityComponent } from 'velocity-react'; 6 | 7 | export default class App extends React.Component { 8 | state = { 9 | loaded: false 10 | } 11 | 12 | onLoad = (loaded) => { 13 | this.setState({ loaded }) 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 |
31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | 4 | module.exports = { 5 | devServer: { 6 | historyApiFallback: true, 7 | hot: true, 8 | inline: true, 9 | progress: true, 10 | publicPath: '/', 11 | contentBase: './app', 12 | host: '0.0.0.0', 13 | port: 8081 14 | }, 15 | entry: [ 16 | 'webpack/hot/dev-server', 17 | 'webpack-dev-server/client?http://127.0.0.1:8081', 18 | 'babel-polyfill', 19 | path.resolve(__dirname, 'app/main.jsx'), 20 | ], 21 | output: { 22 | path: path.resolve(__dirname, './dist'), 23 | publicPath: '/dist', 24 | filename: 'app.js' 25 | }, 26 | module: { 27 | loaders:[ 28 | { 29 | test: /\.css$/, 30 | include: path.resolve(__dirname, './app'), 31 | loaders: [ 32 | 'style-loader', 33 | 'css-loader?modules&localIdentName=[hash:base64:8]', 34 | 'postcss-loader' 35 | ] 36 | }, 37 | { 38 | test: /\.js[x]?$/, 39 | include: path.resolve(__dirname, './app'), 40 | exclude: /node_modules/, 41 | loader: 'babel-loader' 42 | }, 43 | { 44 | test: /\.json$/, 45 | loader: 'json-loader' 46 | } 47 | ] 48 | }, 49 | resolve: { 50 | extensions: ['', '.js', '.jsx', '.css'], 51 | }, 52 | plugins: [ 53 | new webpack.HotModuleReplacementPlugin() 54 | ], 55 | postcss: function(bundler) { 56 | return [ 57 | require('postcss-import')({ addDependencyTo: bundler }), 58 | require('postcss-mixins')(), 59 | require('postcss-nested')(), 60 | require('postcss-cssnext')({ browsers: '> 1%, last 3 versions' }) 61 | ] 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /app/components/Conversation/Conversation.css: -------------------------------------------------------------------------------- 1 | @import '../App/Mixins'; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | } 7 | 8 | .paperToolbar { 9 | align-items: center; 10 | box-shadow: 0 0 4px rgba(0, 0, 0, .15); 11 | box-sizing: border-box; 12 | display: flex; 13 | width: 100%; 14 | } 15 | 16 | .header, .messages, .footer { 17 | position: absolute; 18 | } 19 | 20 | .header { 21 | height: 64px; 22 | padding: 0 24px; 23 | top: 0; 24 | 25 | div { 26 | width: 100%; 27 | 28 | span, small { 29 | display: inline-block; 30 | } 31 | span { 32 | font-size: 14pt; 33 | max-width: 100%; 34 | overflow: hidden; 35 | text-overflow: ellipsis; 36 | white-space: nowrap; 37 | @mixin sameHeight 28px; 38 | } 39 | small { 40 | font-size: 10pt; 41 | @mixin sameHeight 20px; 42 | } 43 | } 44 | } 45 | 46 | .messages { 47 | bottom: 54px; 48 | left: 0; 49 | overflow-x: hidden; 50 | right: 0; 51 | top: 64px; 52 | 53 | .messageHolder { 54 | display: flex; 55 | padding: 12px 16px; 56 | 57 | .avatar { 58 | margin-right: 24px; 59 | } 60 | .messageContentContainer { 61 | flex: 1; 62 | word-break: break-word; 63 | 64 | .messageContent { 65 | background: #ddd; 66 | border-radius: 4px; 67 | display: inline-block; 68 | padding: 8px 12px; 69 | } 70 | } 71 | } 72 | } 73 | 74 | .footer { 75 | bottom: 0; 76 | height: 54px; 77 | padding: 0 8px; 78 | 79 | .button { 80 | height: 24px; 81 | line-height: 24px; 82 | text-align: center; 83 | width: 24px; 84 | } 85 | .inputField { 86 | padding: 0 4px; 87 | width: 100%; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const CopyWebpackPlugin = require('copy-webpack-plugin') 4 | 5 | module.exports = { 6 | devtool: 'hidden-source-map', 7 | entry: [ 8 | 'babel-polyfill', 9 | path.resolve(__dirname, 'app/main.jsx'), 10 | ], 11 | output: { 12 | path: path.resolve(__dirname, './dist'), 13 | publicPath: '/dist', 14 | filename: 'app.js' 15 | }, 16 | module: { 17 | loaders:[ 18 | { 19 | test: /\.css$/, 20 | include: path.resolve(__dirname, './app'), 21 | loaders: [ 22 | 'style-loader', 23 | 'css-loader?modules&localIdentName=[hash:base64:8]', 24 | 'postcss-loader' 25 | ] 26 | }, 27 | { 28 | test: /\.js[x]?$/, 29 | include: path.resolve(__dirname, './app'), 30 | exclude: /node_modules/, 31 | loader: 'babel-loader' 32 | }, 33 | { 34 | test: /\.json$/, 35 | loader: 'json-loader' 36 | } 37 | ] 38 | }, 39 | resolve: { 40 | extensions: ['', '.js', '.jsx', '.css'], 41 | }, 42 | plugins: [ 43 | new webpack.optimize.DedupePlugin(), 44 | new webpack.optimize.UglifyJsPlugin({ 45 | compress: { 46 | warnings: false 47 | } 48 | }), 49 | new webpack.optimize.AggressiveMergingPlugin(), 50 | new CopyWebpackPlugin([ 51 | { from: './app/index.html', to: 'index.html' }, 52 | { from: './app/main.css', to: 'main.css' } 53 | ]), 54 | ], 55 | postcss: function(bundler) { 56 | return [ 57 | require('postcss-import')({ addDependencyTo: bundler }), 58 | require('postcss-mixins')(), 59 | require('postcss-nested')(), 60 | require('postcss-cssnext')({ browsers: '> 1%, last 3 versions' }), 61 | require('cssnano')({ 62 | autoprefixer: false 63 | }) 64 | ] 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /app/components/AddBotDialog/AddBotDialog.jsx: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import React from 'react'; 3 | import { 4 | Dialog, 5 | FlatButton, 6 | Snackbar, 7 | TextField, 8 | } from 'material-ui'; 9 | 10 | export default class AddBotDialog extends React.Component { 11 | componentWillReceiveProps() { 12 | /* reset when component was shown / hidden */ 13 | this.setState({ 14 | disabled: true, 15 | error: null, 16 | message: null, 17 | token: '', 18 | }) 19 | } 20 | 21 | handleChange = (e) => { 22 | const token = e.target.value 23 | this.setState({ token }) 24 | if (null !== token.match(/^\d{9}\:[a-zA-Z0-9-_]{35}$/)) { 25 | this.setState({ 26 | disabled: false, 27 | error: null, 28 | message: null, 29 | }) 30 | } else { 31 | this.setState({ 32 | disabled: true, 33 | error: 'Bot Token is not valid', 34 | message: null, 35 | }) 36 | } 37 | } 38 | 39 | async handleSubmit() { 40 | this.setState({ 41 | disabled: true, 42 | message: null, 43 | }) 44 | let result = await fetch(`/parking`, { 45 | method: 'POST', 46 | headers: { 47 | 'accept': 'application/json', 48 | 'content-type': 'application/json', 49 | }, 50 | body: JSON.stringify({ 51 | token: this.state.token, 52 | }), 53 | }) 54 | result = await result.json() 55 | if (result) { 56 | this.props.onClose() 57 | } else { 58 | this.setState({ 59 | disabled: false, 60 | message: 'An error occurred' 61 | }) 62 | } 63 | } 64 | 65 | render() { 66 | return this.props.open ? ( 67 |
68 | , 73 | , 77 | ]} 78 | contentStyle={{ 79 | width: '480px', 80 | }} 81 | modal={false} 82 | open={true}> 83 | New Bot 84 |
85 | 91 |
92 | 100 |
101 | ) : null; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "telegraph", 3 | "version": "1.0.0", 4 | "description": "telegraph - web ui for telegram bots", 5 | "homepage": "https://git.io/telegraph", 6 | "author": "nodegin", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/nodegin/telegraph.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/nodegin/telegraph/issues" 14 | }, 15 | "main": "app/main.jsx", 16 | "scripts": { 17 | "lint": "eslint '**/*.@(js|jsx)'", 18 | "test": "echo \"Error: no test specified\" && exit 1", 19 | "start:api": "NODE_ENV=development nodemon -e js --watch api api/server.js --exec babel-node", 20 | "start:app": "webpack-dev-server --config webpack.dev.config.js --devtool eval --progress --hot --colors --content-base app", 21 | "start": "concurrently --kill-others 'npm run start:api' 'npm run start:app'", 22 | "bundle": "NODE_ENV=production webpack -p --config webpack.prod.config.js", 23 | "deploy": "npm run bundle && NODE_ENV=production babel-node api/server.js", 24 | "validate": "npm ls" 25 | }, 26 | "dependencies": { 27 | "express": "4.x", 28 | "body-parser": "1.x", 29 | "proxy-middleware": "0.x", 30 | "socket.io": "1.x", 31 | "react": "15.x", 32 | "react-dom": "15.x", 33 | "react-tap-event-plugin": "1.x", 34 | "classnames": "2.x", 35 | "velocity-react": "1.x", 36 | "normalize.css": "4.x", 37 | "material-ui": "0.x", 38 | "mkdirp-promise": "2.x", 39 | "node-telegram-bot-api": "0.x", 40 | "monkey-patches-node-telegram-bot-api": "0.x" 41 | }, 42 | "devDependencies": { 43 | "nodemon": "1.x", 44 | "babel-runtime": "6.x", 45 | "babel-polyfill": "6.x", 46 | "babel-cli": "6.x", 47 | "babel-core": "6.x", 48 | "babel-eslint": "6.x", 49 | "babel-loader": "6.x", 50 | "babel-preset-stage-0-promises": "1.x", 51 | "babel-preset-es2015": "6.x", 52 | "babel-preset-react": "6.x", 53 | "copy-webpack-plugin": "3.x", 54 | "css-loader": "0.x", 55 | "eslint": "2.x", 56 | "eslint-config-airbnb": "9.x", 57 | "eslint-plugin-jsx-a11y": "1.x", 58 | "eslint-plugin-react": "5.x", 59 | "eslint-plugin-import": "1.x", 60 | "postcss": "5.x", 61 | "postcss-loader": "0.x", 62 | "postcss-import": "8.x", 63 | "postcss-cssnext": "2.x", 64 | "postcss-mixins": "4.x", 65 | "postcss-nested": "1.x", 66 | "cssnano": "3.x", 67 | "style-loader": "0.x", 68 | "css-loader": "0.x", 69 | "json-loader": "0.x", 70 | "webpack": "1.x", 71 | "webpack-dev-server": "1.x", 72 | "concurrently": "2.x" 73 | }, 74 | "babel": { 75 | "presets": ["react", "es2015", "stage-0-promises"], 76 | "env": { 77 | "build": { 78 | "optional": ["optimisation", "minification"] 79 | } 80 | } 81 | }, 82 | "eslintConfig": { 83 | "parser": "babel-eslint", 84 | "extends": "airbnb", 85 | "env": { 86 | "browser": true 87 | }, 88 | "rules": { 89 | "semi": [2, "never"], 90 | "no-confusing-arrow": 0, 91 | "react/jsx-quotes": 0, 92 | "jsx-quotes": [2, "prefer-double"] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/components/Conversation/Conversation.jsx: -------------------------------------------------------------------------------- 1 | import $ from './Conversation.css' 2 | import classNames from 'classnames' 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import { Avatar, IconButton, TextField } from 'material-ui' 6 | 7 | class Messages extends React.Component { 8 | componentDidUpdate() { 9 | /* Scroll to bottom automatically */ 10 | const node = ReactDOM.findDOMNode(this) 11 | node.scrollTop = node.scrollHeight 12 | } 13 | render() { 14 | return ( 15 |
16 | {this.props.children} 17 |
18 | ) 19 | } 20 | } 21 | 22 | export default class Conversation extends React.Component { 23 | 24 | state = { 25 | chatContent: '', 26 | botMessages: {}, 27 | } 28 | 29 | botMessages = {} 30 | 31 | handleEnter = (e) => { 32 | if (e.keyCode === 13) { 33 | this.sendMessage() 34 | } 35 | } 36 | 37 | handleChange = e => this.setState({ chatContent: e.target.value }) 38 | 39 | sendMessage = () => { 40 | this.props.socket.emit('sendText', { 41 | bot: this.props.bot.id, 42 | chat: this.props.chat.id, 43 | text: this.state.chatContent, 44 | }) 45 | if (!this.botMessages[this.props.chat.id]) { 46 | this.botMessages[this.props.chat.id] = [] 47 | } 48 | this.botMessages[this.props.chat.id].push({ 49 | chat: { 50 | id: this.props.chat.id, 51 | title: this.props.chat.title, 52 | type: this.props.chat.type, 53 | }, 54 | date: +new Date, 55 | from: { 56 | first_name: this.props.bot.name, 57 | id: this.props.bot.id, 58 | username: this.props.bot.username, 59 | }, 60 | message_id: -1, 61 | text: this.state.chatContent, 62 | }) 63 | this.setState({ chatContent: '' }) 64 | } 65 | 66 | render() { 67 | return ( 68 |
69 |
70 |
71 | {this.props.chat.title} 72 |
73 | Bot: {this.props.bot.name} 74 |
75 |
76 | 77 | { 78 | this.props.messages.filter((msg) => { 79 | return String(msg.chat.id) === this.props.chat.id 80 | }).concat( 81 | this.botMessages[this.props.chat.id] ? this.botMessages[this.props.chat.id] : [] 82 | ).sort((a, b) => { 83 | if (a.date < b.date) 84 | return -1 85 | if (a.date > b.date) 86 | return 1 87 | return 0 88 | }).map((msg) => { 89 | let content = null 90 | if (msg.text) { 91 | content =
{msg.text}
92 | } 93 | return ( 94 |
95 |
96 | 97 |
98 |
99 |
{content}
100 |
101 |
102 | ) 103 | }) 104 | } 105 |
106 | 120 |
121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/components/Telegraph/Telegraph.jsx: -------------------------------------------------------------------------------- 1 | import $ from './Telegraph.css' 2 | import React from 'react' 3 | import { 4 | Avatar, 5 | FloatingActionButton, 6 | FontIcon, 7 | List, 8 | ListItem, 9 | Subheader, 10 | } from 'material-ui' 11 | import { AddBotDialog, Conversation } from '../../components' 12 | import io from 'socket.io-client' 13 | 14 | export default class Telegraph extends React.Component { 15 | state = { 16 | addBotFormOpen: false, 17 | newBotToken: '', 18 | activeChat: null, 19 | bots: {}, 20 | chats: {}, 21 | messages: {}, 22 | } 23 | 24 | socket = io(undefined, { path: '/_' }) 25 | 26 | async componentDidMount() { 27 | this.props.onLoad(true) 28 | this.socket.on('syncStore', (store) => this.setState(store)) 29 | this.socket.on('message', (data) => { 30 | /* Force use local time */ 31 | data.msg.date = +new Date 32 | let messages = this.state.messages[data.bot] 33 | if (!messages) { 34 | messages = [] 35 | } 36 | messages.push(data.msg) 37 | /* GC old messages > 1000 */ 38 | if (messages.length > 1) { 39 | messages = messages.slice(Math.max(messages.length - 1000, 0)) 40 | } 41 | this.setState({ 42 | messages: { 43 | ...this.state.messages, 44 | [data.bot]: messages 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | toggleAddBotDialog = () => { 51 | this.setState({ 52 | addBotFormOpen: !this.state.addBotFormOpen 53 | }) 54 | } 55 | 56 | openChat(botId, chatId) { 57 | let activeChat = [botId, chatId] 58 | if (this.state.activeChat !== null) { 59 | const [activeBotId, activeChatId] = this.state.activeChat 60 | /* close the conversation on toggle */ 61 | if (activeBotId === botId && activeChatId === chatId) { 62 | activeChat = null 63 | } 64 | } 65 | this.setState({ activeChat }) 66 | } 67 | 68 | render() { 69 | const listItems = [] 70 | for (let botId in this.state.bots) { 71 | listItems.push({this.state.bots[botId].name}) 72 | if (this.state.bots[botId].chats.length < 1) { 73 | listItems.push(No group was found for this bot.) 74 | } 75 | for (let chatId of this.state.bots[botId].chats) { 76 | listItems.push( 77 | } 79 | primaryText={this.state.chats[chatId].title} 80 | onTouchTap={this.openChat.bind(this, botId, chatId)} /> 81 | ) 82 | } 83 | } 84 | return ( 85 |
86 |
87 |
88 | { 89 | listItems.length < 1 ? ( 90 |
91 | No bot was found 92 |
93 | ) : ( 94 | {listItems} 95 | ) 96 | } 97 |
98 |
99 | 102 | 103 | 104 |
105 |
106 | { 107 | this.state.activeChat !== null ? ( 108 | 117 | ) : ( 118 |
119 | No group selected 120 |
121 | ) 122 | } 123 | 126 |
127 | ); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /api/server.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import express from 'express' 4 | import http from 'http' 5 | import bodyParser from 'body-parser' 6 | import proxy from 'proxy-middleware' 7 | import socket from 'socket.io' 8 | import { 9 | initStore, 10 | readStore, 11 | updateStore, 12 | craftBot 13 | } from './tools' 14 | import telegramBot from 'node-telegram-bot-api' 15 | 16 | require('monkey-patches-node-telegram-bot-api')(telegramBot, { 17 | stopPolling: true, 18 | sendVenue: true, 19 | answerCallbackQuery: true, 20 | editMessageText: true, 21 | }) 22 | 23 | const app = express() 24 | const server = http.createServer(app) 25 | const io = socket(server, { path: '/_' }) 26 | 27 | server.listen(8080, () => { 28 | console.log('api server running on 8080') 29 | }) 30 | 31 | /* Setup middlewares */ 32 | app.use(bodyParser.json()) 33 | 34 | /* Initialize application */ 35 | async function init() { 36 | 37 | const storeFile = path.resolve(__dirname, './store.json') 38 | /* Initialize the store if not exists */ 39 | await initStore(storeFile, JSON.stringify({ 40 | bots: {}, 41 | chats: {}, 42 | })) 43 | let Store = await readStore(storeFile) 44 | let Chain = Promise.resolve() 45 | let Bots = {} 46 | 47 | /* Routes */ 48 | 49 | io.on('connection', (socket) => { 50 | socket.emit('syncStore', Store) 51 | socket.on('sendText', (data) => { 52 | Bots[data.bot].sendMessage(data.chat, data.text) 53 | }) 54 | }) 55 | 56 | app.post('/parking', async (req, res) => { 57 | if (!req.body.token) { 58 | res.send(false) 59 | return 60 | } 61 | const id = req.body.token.split(':')[0] 62 | let chats = [] 63 | if (Store.bots[id]) { 64 | chats = Store.bots[id].chats 65 | } 66 | let bot = new telegramBot(req.body.token, { 67 | polling: { 68 | interval: 50, 69 | } 70 | }) 71 | /* Timeout the getMe() action after 2.5 seconds */ 72 | const timeout = setTimeout(() => { 73 | bot.stopPolling() 74 | res.send(false) 75 | }, 2500) 76 | const info = await bot.getMe() 77 | if (info) { 78 | /* Response received, unset the timeout */ 79 | clearTimeout(timeout) 80 | bot.stopPolling() 81 | bot = null 82 | Store.bots[id] = { 83 | id, 84 | name: info.first_name, 85 | username: info.username, 86 | token: req.body.token, 87 | chats, 88 | } 89 | Chain = Chain.then(() => updateStore(storeFile, Store)) 90 | await Chain 91 | io.emit('syncStore', Store) 92 | res.send(true) 93 | } 94 | }) 95 | 96 | /* Must be under routes */ 97 | if (process.env.NODE_ENV !== 'production') { 98 | app.use('/', proxy('http://127.0.0.1:8081/')) 99 | 100 | app.get('/', (req, res) => { 101 | res.sendFile(path.resolve(__dirname, '../app/index.html')) 102 | }) 103 | } else { 104 | app.use(express.static(path.resolve(__dirname, '../dist'))) 105 | } 106 | 107 | for (let botId in Store.bots) { 108 | const props = Store.bots[botId] 109 | Bots[botId] = new telegramBot(props.token, { 110 | polling: { 111 | interval: 99, 112 | timeout: 0, 113 | } 114 | }) 115 | Bots[botId].on('new_chat_participant', async (msg) => { 116 | const chatId = String(msg.chat.id) 117 | const memId = String(msg.new_chat_member.id) 118 | /* If Bot was added to group */ 119 | if (memId === botId) { 120 | /* Check if Bot already added to group */ 121 | if (Store.bots[botId].chats.indexOf(chatId) < 0) { 122 | Store.bots[botId].chats.push(chatId) 123 | } 124 | Store.chats[chatId] = { 125 | id: chatId, 126 | title: msg.chat.title, 127 | type: msg.chat.type, 128 | } 129 | /* Update the store */ 130 | Chain = Chain.then(() => updateStore(storeFile, Store)) 131 | await Chain 132 | io.emit('syncStore', Store) 133 | } 134 | }) 135 | Bots[botId].on('left_chat_participant', async (msg) => { 136 | const chatId = String(msg.chat.id) 137 | const memId = String(msg.left_chat_member.id) 138 | /* Save remove from store */ 139 | if (memId === botId) { 140 | Store.bots[botId].chats = Store.bots[botId].chats.filter((id) => id !== chatId) 141 | /* Check whatever if the group id is not belonging to any Bot */ 142 | let useless = true 143 | for (let _id in Store.bots) { 144 | if (useless) { 145 | useless = Store.bots[_id].chats.indexOf(chatId) < 0 146 | } 147 | } 148 | /* Perform Garbage Collection for the useless group */ 149 | if (useless) { 150 | Store.chats = Object.keys(Store.chats) 151 | .filter(id => id !== chatId) 152 | .reduce((result, current) => { 153 | result[current] = Store.chats[current] 154 | return result 155 | }, {}) 156 | } 157 | /* Update the store */ 158 | Chain = Chain.then(() => updateStore(storeFile, Store)) 159 | await Chain 160 | io.emit('syncStore', Store) 161 | } 162 | }) 163 | Bots[botId].on('message', async (msg) => { 164 | /* Ignore since catched above */ 165 | if (msg.new_chat_participant || msg.left_chat_participant) { 166 | return 167 | } 168 | /* Fill missing group */ 169 | const chatId = String(msg.chat.id) 170 | if (Store.bots[botId].chats.indexOf(chatId) < 0) { 171 | if (Store.bots[botId].chats.indexOf(chatId) < 0) { 172 | Store.bots[botId].chats.push(chatId) 173 | } 174 | Store.chats[chatId] = { 175 | id: chatId, 176 | title: msg.chat.title, 177 | type: msg.chat.type, 178 | } 179 | Chain = Chain.then(() => updateStore(storeFile, Store)) 180 | await Chain 181 | io.emit('syncStore', Store) 182 | } 183 | io.emit('message', { bot: botId, msg }) 184 | }) 185 | } 186 | } 187 | 188 | init() 189 | --------------------------------------------------------------------------------